[Add] Base TaskBoard & TaskManager
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
!/Assets/
|
!/Assets/
|
||||||
!/ProjectSettings/
|
!/ProjectSettings/
|
||||||
!/Packages/
|
!/Packages/
|
||||||
|
!/docs/
|
||||||
!.gitignore
|
!.gitignore
|
||||||
!.gitattributes
|
!.gitattributes
|
||||||
!LICENSE
|
!LICENSE
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 85e740c52ab3e0a488b0109bd39c9e86
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Project.Tasks.Editor
|
||||||
|
{
|
||||||
|
internal static class TaskBoardConstants
|
||||||
|
{
|
||||||
|
public static readonly string[] Statuses = { "proposal", "ready", "in_progress", "blocked", "done" };
|
||||||
|
public static readonly string[] Priorities = { "Lowest", "Low", "Medium", "High", "Highest" };
|
||||||
|
}
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
internal sealed class TaskRecord
|
||||||
|
{
|
||||||
|
public string Id;
|
||||||
|
public string Title;
|
||||||
|
public string Status;
|
||||||
|
public string Priority;
|
||||||
|
public string Area;
|
||||||
|
public string Owner;
|
||||||
|
public string Created;
|
||||||
|
public string Updated;
|
||||||
|
public string ExecutionTime;
|
||||||
|
public string RelativeFilePath;
|
||||||
|
public string AbsoluteFilePath;
|
||||||
|
public string Summary;
|
||||||
|
public string Header;
|
||||||
|
public string Why;
|
||||||
|
public string ExpectedOutcome;
|
||||||
|
public string CurrentContext;
|
||||||
|
public string AcceptanceCriteria;
|
||||||
|
public string Verification;
|
||||||
|
public string Risks;
|
||||||
|
public string HumanDecisions;
|
||||||
|
public string DecisionLog;
|
||||||
|
public string HandoffNotes;
|
||||||
|
public bool FileExists;
|
||||||
|
public int IndexLineNumber = -1;
|
||||||
|
public readonly List<string> ValidationMessages = new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
internal sealed class TaskBoardData
|
||||||
|
{
|
||||||
|
public string ProjectRoot;
|
||||||
|
public string TasksDirectory;
|
||||||
|
public string IndexPath;
|
||||||
|
public readonly List<TaskRecord> Tasks = new List<TaskRecord>();
|
||||||
|
public readonly List<string> Warnings = new List<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4716897eb71185742ad590f2a59df8e3
|
||||||
@@ -0,0 +1,722 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Project.Tasks.Editor
|
||||||
|
{
|
||||||
|
internal static class TaskBoardService
|
||||||
|
{
|
||||||
|
private static readonly Regex HeadingRegex = new Regex(@"^##\s+(.+)$", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex H1Regex = new Regex(@"^#\s+(.+)$", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex JiraTimeRegex = new Regex(@"^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
public static string NormalizePriority(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (value.Trim())
|
||||||
|
{
|
||||||
|
case "lowest":
|
||||||
|
case "Lowest":
|
||||||
|
return "Lowest";
|
||||||
|
case "low":
|
||||||
|
case "Low":
|
||||||
|
return "Low";
|
||||||
|
case "medium":
|
||||||
|
case "Medium":
|
||||||
|
return "Medium";
|
||||||
|
case "high":
|
||||||
|
case "High":
|
||||||
|
return "High";
|
||||||
|
case "highest":
|
||||||
|
case "Highest":
|
||||||
|
return "Highest";
|
||||||
|
default:
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int GetPrioritySortOrder(string value)
|
||||||
|
{
|
||||||
|
switch (NormalizePriority(value))
|
||||||
|
{
|
||||||
|
case "Highest":
|
||||||
|
return 0;
|
||||||
|
case "High":
|
||||||
|
return 1;
|
||||||
|
case "Medium":
|
||||||
|
return 2;
|
||||||
|
case "Low":
|
||||||
|
return 3;
|
||||||
|
case "Lowest":
|
||||||
|
return 4;
|
||||||
|
default:
|
||||||
|
return int.MaxValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TaskBoardData Load()
|
||||||
|
{
|
||||||
|
var data = new TaskBoardData();
|
||||||
|
data.ProjectRoot = GetProjectRoot();
|
||||||
|
data.TasksDirectory = NormalizePath(Path.Combine(data.ProjectRoot, "docs", "tasks"));
|
||||||
|
data.IndexPath = NormalizePath(Path.Combine(data.TasksDirectory, "Index.md"));
|
||||||
|
|
||||||
|
if (!Directory.Exists(data.TasksDirectory))
|
||||||
|
{
|
||||||
|
data.Warnings.Add("Каталог docs/tasks не найден.");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(data.IndexPath))
|
||||||
|
{
|
||||||
|
data.Warnings.Add("Файл docs/tasks/Index.md не найден.");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] lines = File.ReadAllLines(data.IndexPath, Encoding.UTF8);
|
||||||
|
ParseIndex(data, lines);
|
||||||
|
|
||||||
|
string[] taskFiles = Directory.GetFiles(data.TasksDirectory, "TASK-*.md", SearchOption.TopDirectoryOnly);
|
||||||
|
var indexedIds = new HashSet<string>(data.Tasks.Select(task => task.Id), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (string taskPath in taskFiles)
|
||||||
|
{
|
||||||
|
string taskId = Path.GetFileNameWithoutExtension(taskPath);
|
||||||
|
int dashIndex = taskId.IndexOf('-');
|
||||||
|
if (dashIndex >= 0)
|
||||||
|
{
|
||||||
|
int secondDash = taskId.IndexOf('-', dashIndex + 1);
|
||||||
|
if (secondDash > 0)
|
||||||
|
{
|
||||||
|
taskId = taskId.Substring(0, secondDash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!indexedIds.Contains(taskId))
|
||||||
|
{
|
||||||
|
data.Warnings.Add("Task-файл без записи в реестре: " + NormalizePath(GetRelativePath(data.ProjectRoot, taskPath)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Tasks.Sort(CompareTaskIds);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Save(TaskBoardData data, TaskRecord updatedTask, out string error)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SaveIndexRow(data.IndexPath, updatedTask);
|
||||||
|
|
||||||
|
if (updatedTask.FileExists && File.Exists(updatedTask.AbsoluteFilePath))
|
||||||
|
{
|
||||||
|
SaveTaskFrontMatter(updatedTask.AbsoluteFilePath, updatedTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
error = exception.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CreateTaskFileFromTemplate(TaskBoardData data, TaskRecord task, out string error)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Данные task board не загружены.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Задача не выбрана.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(task.RelativeFilePath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("В Index.md не указан путь к task-файлу.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string relativePath = NormalizePath(task.RelativeFilePath);
|
||||||
|
if (!relativePath.StartsWith("docs/tasks/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Task-файл можно создавать только внутри docs/tasks.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string absolutePath = NormalizePath(Path.Combine(data.ProjectRoot, relativePath));
|
||||||
|
if (File.Exists(absolutePath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Task-файл уже существует.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string templatePath = NormalizePath(Path.Combine(data.TasksDirectory, "_template.md"));
|
||||||
|
if (!File.Exists(templatePath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Шаблон docs/tasks/_template.md не найден.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string template = File.ReadAllText(templatePath, Encoding.UTF8);
|
||||||
|
string today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
string title = string.IsNullOrWhiteSpace(task.Title) ? task.Id : task.Title.Trim();
|
||||||
|
string owner = string.IsNullOrWhiteSpace(task.Owner) ? "unassigned" : task.Owner.Trim();
|
||||||
|
string created = string.IsNullOrWhiteSpace(task.Created) ? today : task.Created.Trim();
|
||||||
|
string area = string.IsNullOrWhiteSpace(task.Area) ? "docs" : task.Area.Trim();
|
||||||
|
string priority = NormalizePriority(task.Priority);
|
||||||
|
if (string.IsNullOrWhiteSpace(priority))
|
||||||
|
{
|
||||||
|
priority = "Medium";
|
||||||
|
}
|
||||||
|
|
||||||
|
string executionTime = string.IsNullOrWhiteSpace(task.ExecutionTime) ? "1d" : task.ExecutionTime.Trim();
|
||||||
|
|
||||||
|
string content = template
|
||||||
|
.Replace("id: TASK-XXXX", "id: " + task.Id)
|
||||||
|
.Replace("title: Короткий заголовок", "title: " + title)
|
||||||
|
.Replace("priority: Medium", "priority: " + priority)
|
||||||
|
.Replace("area: docs", "area: " + area)
|
||||||
|
.Replace("owner: unassigned", "owner: " + owner)
|
||||||
|
.Replace("created: YYYY-MM-DD", "created: " + created)
|
||||||
|
.Replace("updated: YYYY-MM-DD", "updated: " + today)
|
||||||
|
.Replace("execution_time: 1d6h30m", "execution_time: " + executionTime)
|
||||||
|
.Replace("# TASK-XXXX - Короткий заголовок", "# " + task.Id + " - " + title);
|
||||||
|
|
||||||
|
string directory = Path.GetDirectoryName(absolutePath);
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(absolutePath, content, new UTF8Encoding(false));
|
||||||
|
task.AbsoluteFilePath = absolutePath;
|
||||||
|
task.FileExists = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
error = exception.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OpenInDefaultApp(string absolutePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(absolutePath) || !File.Exists(absolutePath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = absolutePath,
|
||||||
|
UseShellExecute = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
Process.Start(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsValidExecutionTime(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Match match = JiraTimeRegex.Match(value.Trim());
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasAnyGroup = false;
|
||||||
|
for (int i = 1; i < match.Groups.Count; i++)
|
||||||
|
{
|
||||||
|
if (match.Groups[i].Success)
|
||||||
|
{
|
||||||
|
hasAnyGroup = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAnyGroup)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Groups[4].Success)
|
||||||
|
{
|
||||||
|
int minutes;
|
||||||
|
if (!int.TryParse(match.Groups[4].Value, NumberStyles.None, CultureInfo.InvariantCulture, out minutes))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minutes % 30 != 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetProjectRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo parent = Directory.GetParent(Application.dataPath);
|
||||||
|
return parent != null ? NormalizePath(parent.FullName) : NormalizePath(Application.dataPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizePath(string path)
|
||||||
|
{
|
||||||
|
return string.IsNullOrEmpty(path) ? string.Empty : path.Replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetRelativePath(string rootPath, string fullPath)
|
||||||
|
{
|
||||||
|
Uri rootUri = new Uri(AppendDirectorySeparator(rootPath));
|
||||||
|
Uri fullUri = new Uri(fullPath);
|
||||||
|
return Uri.UnescapeDataString(rootUri.MakeRelativeUri(fullUri).ToString()).Replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string AppendDirectorySeparator(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path[path.Length - 1] == Path.DirectorySeparatorChar || path[path.Length - 1] == Path.AltDirectorySeparatorChar)
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path + Path.DirectorySeparatorChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParseIndex(TaskBoardData data, string[] lines)
|
||||||
|
{
|
||||||
|
int registryIndex = Array.FindIndex(lines, line => string.Equals(line.Trim(), "## Task Registry", StringComparison.Ordinal));
|
||||||
|
if (registryIndex < 0)
|
||||||
|
{
|
||||||
|
data.Warnings.Add("В Index.md не найдена секция '## Task Registry'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int headerIndex = -1;
|
||||||
|
int separatorIndex = -1;
|
||||||
|
for (int i = registryIndex + 1; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
string trimmed = lines[i].Trim();
|
||||||
|
if (trimmed.StartsWith("|", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (headerIndex < 0)
|
||||||
|
{
|
||||||
|
headerIndex = i;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
separatorIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headerIndex < 0 || separatorIndex < 0)
|
||||||
|
{
|
||||||
|
data.Warnings.Add("В Index.md не удалось прочитать таблицу реестра задач.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = separatorIndex + 1; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
string line = lines[i];
|
||||||
|
string trimmed = line.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed.StartsWith("|", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] columns = SplitMarkdownRow(trimmed);
|
||||||
|
if (columns.Length < 7)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var task = new TaskRecord
|
||||||
|
{
|
||||||
|
Id = columns[0],
|
||||||
|
Status = columns[1],
|
||||||
|
Priority = NormalizePriority(columns[2]),
|
||||||
|
Area = columns[3],
|
||||||
|
ExecutionTime = columns[4],
|
||||||
|
RelativeFilePath = StripTicks(columns[5]),
|
||||||
|
Summary = columns[6],
|
||||||
|
IndexLineNumber = i,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(task.RelativeFilePath))
|
||||||
|
{
|
||||||
|
task.AbsoluteFilePath = NormalizePath(Path.Combine(data.ProjectRoot, task.RelativeFilePath));
|
||||||
|
task.FileExists = File.Exists(task.AbsoluteFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.FileExists)
|
||||||
|
{
|
||||||
|
task.ValidationMessages.Add("Task-файл не найден по пути из реестра.");
|
||||||
|
}
|
||||||
|
|
||||||
|
PopulateTaskFromFile(task);
|
||||||
|
ValidateTask(task);
|
||||||
|
data.Tasks.Add(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PopulateTaskFromFile(TaskRecord task)
|
||||||
|
{
|
||||||
|
if (!task.FileExists || string.IsNullOrEmpty(task.AbsoluteFilePath))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(task.Title))
|
||||||
|
{
|
||||||
|
task.Title = task.Id;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] lines = File.ReadAllLines(task.AbsoluteFilePath, Encoding.UTF8);
|
||||||
|
int bodyStart = 0;
|
||||||
|
|
||||||
|
if (lines.Length > 0 && string.Equals(lines[0].Trim(), "---", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
for (int i = 1; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
if (string.Equals(lines[i].Trim(), "---", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
bodyStart = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
string line = lines[i];
|
||||||
|
int separator = line.IndexOf(':');
|
||||||
|
if (separator <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string key = line.Substring(0, separator).Trim();
|
||||||
|
string value = line.Substring(separator + 1).Trim();
|
||||||
|
ApplyFrontMatterField(task, key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sections = new Dictionary<string, StringBuilder>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
string currentSection = null;
|
||||||
|
|
||||||
|
for (int i = bodyStart; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
string line = lines[i];
|
||||||
|
Match h1Match = H1Regex.Match(line);
|
||||||
|
if (h1Match.Success && string.IsNullOrEmpty(task.Header))
|
||||||
|
{
|
||||||
|
task.Header = h1Match.Groups[1].Value.Trim();
|
||||||
|
if (string.IsNullOrEmpty(task.Title))
|
||||||
|
{
|
||||||
|
task.Title = ExtractTitleFromHeader(task.Header, task.Id);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Match headingMatch = HeadingRegex.Match(line);
|
||||||
|
if (headingMatch.Success)
|
||||||
|
{
|
||||||
|
currentSection = headingMatch.Groups[1].Value.Trim();
|
||||||
|
if (!sections.ContainsKey(currentSection))
|
||||||
|
{
|
||||||
|
sections[currentSection] = new StringBuilder();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(currentSection))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections[currentSection].Length > 0)
|
||||||
|
{
|
||||||
|
sections[currentSection].AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
sections[currentSection].Append(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.Why = GetSection(sections, "Why");
|
||||||
|
task.ExpectedOutcome = GetSection(sections, "Expected Outcome");
|
||||||
|
task.CurrentContext = GetSection(sections, "Current Context");
|
||||||
|
task.AcceptanceCriteria = GetSection(sections, "Acceptance Criteria");
|
||||||
|
task.Verification = GetSection(sections, "Verification");
|
||||||
|
task.Risks = GetSection(sections, "Risks / Open Questions");
|
||||||
|
task.HumanDecisions = GetSection(sections, "Human Decisions Needed");
|
||||||
|
task.DecisionLog = GetSection(sections, "Decision Log");
|
||||||
|
task.HandoffNotes = GetSection(sections, "Handoff Notes");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(task.Title))
|
||||||
|
{
|
||||||
|
task.Title = task.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyFrontMatterField(TaskRecord task, string key, string value)
|
||||||
|
{
|
||||||
|
switch (key)
|
||||||
|
{
|
||||||
|
case "id":
|
||||||
|
if (!string.Equals(task.Id, value, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
task.ValidationMessages.Add("ID в task-файле не совпадает с записью в Index.md.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "title":
|
||||||
|
task.Title = value;
|
||||||
|
break;
|
||||||
|
case "priority":
|
||||||
|
string normalizedPriority = NormalizePriority(value);
|
||||||
|
if (!string.IsNullOrEmpty(normalizedPriority) && !string.Equals(task.Priority, normalizedPriority, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
task.ValidationMessages.Add("Priority в task-файле не совпадает с реестром.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "area":
|
||||||
|
if (!string.IsNullOrEmpty(value) && !string.Equals(task.Area, value, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
task.ValidationMessages.Add("Area в task-файле не совпадает с реестром.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "owner":
|
||||||
|
task.Owner = value;
|
||||||
|
break;
|
||||||
|
case "created":
|
||||||
|
task.Created = value;
|
||||||
|
break;
|
||||||
|
case "updated":
|
||||||
|
task.Updated = value;
|
||||||
|
break;
|
||||||
|
case "execution_time":
|
||||||
|
if (!string.IsNullOrEmpty(value) && !string.Equals(task.ExecutionTime, value, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
task.ValidationMessages.Add("execution_time в task-файле не совпадает с реестром.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateTask(TaskRecord task)
|
||||||
|
{
|
||||||
|
if (Array.IndexOf(TaskBoardConstants.Statuses, task.Status) < 0)
|
||||||
|
{
|
||||||
|
task.ValidationMessages.Add("Неизвестный статус в Index.md: " + task.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.Priority = NormalizePriority(task.Priority);
|
||||||
|
if (Array.IndexOf(TaskBoardConstants.Priorities, task.Priority) < 0)
|
||||||
|
{
|
||||||
|
task.ValidationMessages.Add("Неизвестный приоритет в Index.md: " + task.Priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsValidExecutionTime(task.ExecutionTime))
|
||||||
|
{
|
||||||
|
task.ValidationMessages.Add("execution_time не соответствует Jira-формату или не кратен 30 минутам.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(task.RelativeFilePath))
|
||||||
|
{
|
||||||
|
task.ValidationMessages.Add("В реестре не указан путь к task-файлу.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SaveIndexRow(string indexPath, TaskRecord task)
|
||||||
|
{
|
||||||
|
string[] lines = File.ReadAllLines(indexPath, Encoding.UTF8);
|
||||||
|
if (task.IndexLineNumber < 0 || task.IndexLineNumber >= lines.Length)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Не удалось найти строку задачи в Index.md.");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines[task.IndexLineNumber] = FormatIndexRow(task);
|
||||||
|
File.WriteAllText(indexPath, string.Join("\n", lines), new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SaveTaskFrontMatter(string taskPath, TaskRecord task)
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(taskPath, Encoding.UTF8).ToList();
|
||||||
|
string today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
if (lines.Count == 0 || !string.Equals(lines[0].Trim(), "---", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var newLines = new List<string>
|
||||||
|
{
|
||||||
|
"---",
|
||||||
|
"id: " + task.Id,
|
||||||
|
"title: " + (string.IsNullOrEmpty(task.Title) ? task.Id : task.Title),
|
||||||
|
"priority: " + task.Priority,
|
||||||
|
"area: " + task.Area,
|
||||||
|
"owner: " + (string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner),
|
||||||
|
"created: " + (string.IsNullOrEmpty(task.Created) ? today : task.Created),
|
||||||
|
"updated: " + today,
|
||||||
|
"execution_time: " + task.ExecutionTime,
|
||||||
|
"depends_on: []",
|
||||||
|
"canonical_docs: []",
|
||||||
|
"related_files: []",
|
||||||
|
"---",
|
||||||
|
string.Empty,
|
||||||
|
};
|
||||||
|
newLines.AddRange(lines);
|
||||||
|
lines = newLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
int frontMatterEnd = -1;
|
||||||
|
for (int i = 1; i < lines.Count; i++)
|
||||||
|
{
|
||||||
|
if (string.Equals(lines[i].Trim(), "---", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
frontMatterEnd = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frontMatterEnd < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Не удалось прочитать front matter task-файла.");
|
||||||
|
}
|
||||||
|
|
||||||
|
UpsertFrontMatterField(lines, ref frontMatterEnd, "priority", task.Priority);
|
||||||
|
UpsertFrontMatterField(lines, ref frontMatterEnd, "area", task.Area);
|
||||||
|
UpsertFrontMatterField(lines, ref frontMatterEnd, "owner", string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner);
|
||||||
|
UpsertFrontMatterField(lines, ref frontMatterEnd, "created", string.IsNullOrEmpty(task.Created) ? today : task.Created);
|
||||||
|
UpsertFrontMatterField(lines, ref frontMatterEnd, "updated", today);
|
||||||
|
UpsertFrontMatterField(lines, ref frontMatterEnd, "execution_time", task.ExecutionTime);
|
||||||
|
|
||||||
|
File.WriteAllText(taskPath, string.Join("\n", lines), new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpsertFrontMatterField(List<string> lines, ref int frontMatterEnd, string key, string value)
|
||||||
|
{
|
||||||
|
string prefix = key + ":";
|
||||||
|
for (int i = 1; i < frontMatterEnd; i++)
|
||||||
|
{
|
||||||
|
if (lines[i].StartsWith(prefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
lines[i] = key + ": " + value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.Insert(frontMatterEnd, key + ": " + value);
|
||||||
|
frontMatterEnd++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatIndexRow(TaskRecord task)
|
||||||
|
{
|
||||||
|
return string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"| {0} | {1} | {2} | {3} | {4} | `{5}` | {6} |",
|
||||||
|
task.Id,
|
||||||
|
task.Status,
|
||||||
|
task.Priority,
|
||||||
|
task.Area,
|
||||||
|
task.ExecutionTime,
|
||||||
|
NormalizePath(task.RelativeFilePath),
|
||||||
|
(task.Summary ?? string.Empty).Replace("|", "/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] SplitMarkdownRow(string line)
|
||||||
|
{
|
||||||
|
string trimmed = line.Trim().Trim('|');
|
||||||
|
return trimmed.Split('|').Select(part => part.Trim()).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripTicks(string value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrEmpty(value) ? string.Empty : value.Trim().Trim('`');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractTitleFromHeader(string header, string id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(header))
|
||||||
|
{
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
string prefix = id + " - ";
|
||||||
|
if (header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return header.Substring(prefix.Length).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return header.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSection(Dictionary<string, StringBuilder> sections, string key)
|
||||||
|
{
|
||||||
|
StringBuilder value;
|
||||||
|
if (!sections.TryGetValue(key, out value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.ToString().Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CompareTaskIds(TaskRecord left, TaskRecord right)
|
||||||
|
{
|
||||||
|
return CompareTaskIds(left != null ? left.Id : null, right != null ? right.Id : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CompareTaskIds(string left, string right)
|
||||||
|
{
|
||||||
|
return ExtractTaskNumber(left).CompareTo(ExtractTaskNumber(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ExtractTaskNumber(string id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(id))
|
||||||
|
{
|
||||||
|
return int.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] parts = id.Split('-');
|
||||||
|
if (parts.Length < 2)
|
||||||
|
{
|
||||||
|
return int.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int number;
|
||||||
|
return int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out number) ? number : int.MaxValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: da90adbe6fa0acb429453d3550e18961
|
||||||
@@ -0,0 +1,677 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Project.Tasks.Editor
|
||||||
|
{
|
||||||
|
internal sealed class TaskBoardWindow : EditorWindow
|
||||||
|
{
|
||||||
|
private const float ColumnWidth = 290f;
|
||||||
|
private const float DetailsWidth = 420f;
|
||||||
|
|
||||||
|
private TaskBoardData data;
|
||||||
|
private Vector2 boardScroll;
|
||||||
|
private Vector2 detailsScroll;
|
||||||
|
private string searchText = string.Empty;
|
||||||
|
private string areaFilter = "All";
|
||||||
|
private string priorityFilter = "All";
|
||||||
|
private bool showDone = true;
|
||||||
|
private TaskRecord selectedTask;
|
||||||
|
private string saveMessage = string.Empty;
|
||||||
|
private MessageType saveMessageType = MessageType.Info;
|
||||||
|
|
||||||
|
private string editStatus;
|
||||||
|
private string editPriority;
|
||||||
|
private string editOwner;
|
||||||
|
private string editExecutionTime;
|
||||||
|
private string editSummary;
|
||||||
|
private GUIStyle readOnlyWrappedTextArea;
|
||||||
|
private GUIStyle priorityBadgeStyle;
|
||||||
|
private TaskRecord pressedTask;
|
||||||
|
private Vector2 pressedMousePosition;
|
||||||
|
|
||||||
|
[MenuItem("Tools/Tasks/Kanban Board")]
|
||||||
|
public static void Open()
|
||||||
|
{
|
||||||
|
GetWindow<TaskBoardWindow>("Task Board");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGUI()
|
||||||
|
{
|
||||||
|
DrawToolbar();
|
||||||
|
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox("Данные задач еще не загружены.", MessageType.Info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.Warnings.Count > 0)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox(string.Join("\n", data.Warnings), MessageType.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(saveMessage))
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox(saveMessage, saveMessageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
DrawBoard();
|
||||||
|
DrawDetails();
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawToolbar()
|
||||||
|
{
|
||||||
|
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
|
||||||
|
|
||||||
|
if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(60f)))
|
||||||
|
{
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GUILayout.Button("Open Index", EditorStyles.toolbarButton, GUILayout.Width(80f)))
|
||||||
|
{
|
||||||
|
string indexPath = data != null ? data.IndexPath : TaskBoardService.NormalizePath(System.IO.Path.Combine(TaskBoardService.GetProjectRoot(), "docs", "tasks", "Index.md"));
|
||||||
|
TaskBoardService.OpenInDefaultApp(indexPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
GUILayout.Space(8f);
|
||||||
|
GUILayout.Label("Search", GUILayout.Width(45f));
|
||||||
|
searchText = GUILayout.TextField(searchText, GUI.skin.FindStyle("ToolbarSeachTextField") ?? EditorStyles.toolbarTextField, GUILayout.MinWidth(150f));
|
||||||
|
|
||||||
|
GUILayout.Space(8f);
|
||||||
|
areaFilter = DrawFilterPopup("Area", areaFilter, BuildAreaOptions(), 120f);
|
||||||
|
priorityFilter = DrawFilterPopup("Priority", priorityFilter, BuildPriorityOptions(), 110f);
|
||||||
|
showDone = GUILayout.Toggle(showDone, "Show Done", EditorStyles.toolbarButton, GUILayout.Width(85f));
|
||||||
|
|
||||||
|
GUILayout.FlexibleSpace();
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string DrawFilterPopup(string label, string currentValue, string[] options, float width)
|
||||||
|
{
|
||||||
|
GUILayout.Label(label, GUILayout.Width(36f));
|
||||||
|
int currentIndex = Array.IndexOf(options, currentValue);
|
||||||
|
if (currentIndex < 0)
|
||||||
|
{
|
||||||
|
currentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int nextIndex = EditorGUILayout.Popup(currentIndex, options, EditorStyles.toolbarPopup, GUILayout.Width(width));
|
||||||
|
return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawBoard()
|
||||||
|
{
|
||||||
|
List<TaskRecord> filteredTasks = GetFilteredTasks();
|
||||||
|
|
||||||
|
EditorGUILayout.BeginVertical(GUILayout.ExpandWidth(true));
|
||||||
|
boardScroll = EditorGUILayout.BeginScrollView(boardScroll, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
|
||||||
|
foreach (string status in TaskBoardConstants.Statuses)
|
||||||
|
{
|
||||||
|
if (!showDone && string.Equals(status, "done", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawColumn(status, filteredTasks
|
||||||
|
.Where(task => string.Equals(task.Status, status, StringComparison.Ordinal))
|
||||||
|
.OrderBy(task => TaskBoardService.GetPrioritySortOrder(task.Priority))
|
||||||
|
.ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
EditorGUILayout.EndScrollView();
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawColumn(string status, List<TaskRecord> tasks)
|
||||||
|
{
|
||||||
|
EditorGUILayout.BeginVertical("box", GUILayout.Width(ColumnWidth), GUILayout.ExpandHeight(true));
|
||||||
|
EditorGUILayout.LabelField(ObjectNames.NicifyVariableName(status), EditorStyles.boldLabel);
|
||||||
|
EditorGUILayout.LabelField(tasks.Count + " task(s)", EditorStyles.miniLabel);
|
||||||
|
EditorGUILayout.Space(6f);
|
||||||
|
|
||||||
|
Rect headerRect = GUILayoutUtility.GetLastRect();
|
||||||
|
Rect lastRect = headerRect;
|
||||||
|
|
||||||
|
if (tasks.Count == 0)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox("Нет задач по текущему фильтру.", MessageType.None);
|
||||||
|
lastRect = GUILayoutUtility.GetLastRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (TaskRecord task in tasks)
|
||||||
|
{
|
||||||
|
DrawTaskCard(task);
|
||||||
|
lastRect = GUILayoutUtility.GetLastRect();
|
||||||
|
EditorGUILayout.Space(6f);
|
||||||
|
}
|
||||||
|
|
||||||
|
GUILayout.FlexibleSpace();
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
|
|
||||||
|
Rect columnRect = new Rect(headerRect.xMin - 6f, headerRect.yMin - 28f, ColumnWidth, Mathf.Max(110f, lastRect.yMax - headerRect.yMin + 36f));
|
||||||
|
HandleColumnDrop(columnRect, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawTaskCard(TaskRecord task)
|
||||||
|
{
|
||||||
|
Color previousBackground = GUI.backgroundColor;
|
||||||
|
GUI.backgroundColor = selectedTask == task ? new Color(0.72f, 0.82f, 1f) : GetStatusColor(task.Status);
|
||||||
|
|
||||||
|
EditorGUILayout.BeginVertical("box");
|
||||||
|
GUI.backgroundColor = previousBackground;
|
||||||
|
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
EditorGUILayout.LabelField(task.Id, EditorStyles.boldLabel);
|
||||||
|
GUILayout.FlexibleSpace();
|
||||||
|
DrawPriorityBadge(task.Priority);
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
Rect firstRect = GUILayoutUtility.GetLastRect();
|
||||||
|
|
||||||
|
EditorGUILayout.LabelField(string.IsNullOrEmpty(task.Title) ? task.Id : task.Title, EditorStyles.wordWrappedLabel);
|
||||||
|
if (!string.IsNullOrEmpty(task.Summary))
|
||||||
|
{
|
||||||
|
EditorGUILayout.LabelField(task.Summary, EditorStyles.wordWrappedMiniLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.Space(4f);
|
||||||
|
EditorGUILayout.LabelField("Area: " + SafeValue(task.Area) + " Owner: " + SafeValue(task.Owner), EditorStyles.miniLabel);
|
||||||
|
EditorGUILayout.LabelField("Time: " + SafeValue(task.ExecutionTime), EditorStyles.miniLabel);
|
||||||
|
|
||||||
|
if (task.ValidationMessages.Count > 0)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox(task.ValidationMessages[0], MessageType.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GUILayout.Button("Open Details"))
|
||||||
|
{
|
||||||
|
SelectTask(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
|
|
||||||
|
Rect lastRect = GUILayoutUtility.GetLastRect();
|
||||||
|
Rect cardRect = new Rect(firstRect.xMin - 6f, firstRect.yMin - 6f, firstRect.width + 12f, Mathf.Max(44f, lastRect.yMax - firstRect.yMin + 12f));
|
||||||
|
HandleCardDrag(task, cardRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawDetails()
|
||||||
|
{
|
||||||
|
EditorGUILayout.BeginVertical("box", GUILayout.Width(DetailsWidth), GUILayout.ExpandHeight(true));
|
||||||
|
EditorGUILayout.LabelField("Task Details", EditorStyles.boldLabel);
|
||||||
|
|
||||||
|
if (selectedTask == null)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox("Выберите карточку, чтобы посмотреть детали задачи и изменить статус.", MessageType.Info);
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailsScroll = EditorGUILayout.BeginScrollView(detailsScroll, GUILayout.ExpandHeight(true));
|
||||||
|
EditorGUILayout.LabelField(selectedTask.Id, EditorStyles.boldLabel);
|
||||||
|
EditorGUILayout.LabelField(string.IsNullOrEmpty(selectedTask.Title) ? selectedTask.Id : selectedTask.Title, EditorStyles.wordWrappedLabel);
|
||||||
|
EditorGUILayout.Space(8f);
|
||||||
|
|
||||||
|
if (selectedTask.ValidationMessages.Count > 0)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox(string.Join("\n", selectedTask.ValidationMessages), MessageType.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
using (new EditorGUI.DisabledScope(!selectedTask.FileExists))
|
||||||
|
{
|
||||||
|
if (GUILayout.Button("Open Task File"))
|
||||||
|
{
|
||||||
|
TaskBoardService.OpenInDefaultApp(selectedTask.AbsoluteFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using (new EditorGUI.DisabledScope(selectedTask.FileExists))
|
||||||
|
{
|
||||||
|
if (GUILayout.Button("Create From Template"))
|
||||||
|
{
|
||||||
|
CreateSelectedTaskFromTemplate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GUILayout.Button("Open Index"))
|
||||||
|
{
|
||||||
|
TaskBoardService.OpenInDefaultApp(data.IndexPath);
|
||||||
|
}
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
|
||||||
|
EditorGUILayout.Space(8f);
|
||||||
|
DrawEditableFields();
|
||||||
|
EditorGUILayout.Space(12f);
|
||||||
|
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
EditorGUILayout.LabelField("Priority", GUILayout.Width(80f));
|
||||||
|
DrawPriorityBadge(selectedTask.Priority);
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
DrawReadOnlyField("File", selectedTask.RelativeFilePath);
|
||||||
|
DrawReadOnlyField("Created", selectedTask.Created);
|
||||||
|
DrawReadOnlyField("Updated", selectedTask.Updated);
|
||||||
|
DrawReadOnlyField("Area", selectedTask.Area);
|
||||||
|
|
||||||
|
EditorGUILayout.Space(10f);
|
||||||
|
DrawSection("Why", selectedTask.Why);
|
||||||
|
DrawSection("Expected Outcome", selectedTask.ExpectedOutcome);
|
||||||
|
DrawSection("Current Context", selectedTask.CurrentContext);
|
||||||
|
DrawSection("Acceptance Criteria", selectedTask.AcceptanceCriteria);
|
||||||
|
DrawSection("Verification", selectedTask.Verification);
|
||||||
|
DrawSection("Risks / Open Questions", selectedTask.Risks);
|
||||||
|
DrawSection("Human Decisions Needed", selectedTask.HumanDecisions);
|
||||||
|
DrawSection("Decision Log", selectedTask.DecisionLog);
|
||||||
|
DrawSection("Handoff Notes", selectedTask.HandoffNotes);
|
||||||
|
|
||||||
|
EditorGUILayout.EndScrollView();
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawEditableFields()
|
||||||
|
{
|
||||||
|
EditorGUILayout.LabelField("Quick Edit", EditorStyles.boldLabel);
|
||||||
|
|
||||||
|
editStatus = DrawStringPopup("Status", editStatus, TaskBoardConstants.Statuses);
|
||||||
|
editPriority = DrawStringPopup("Priority", editPriority, TaskBoardConstants.Priorities);
|
||||||
|
editOwner = EditorGUILayout.TextField("Owner", editOwner ?? string.Empty);
|
||||||
|
editExecutionTime = EditorGUILayout.TextField("Execution Time", editExecutionTime ?? string.Empty);
|
||||||
|
|
||||||
|
EditorGUILayout.LabelField("Summary");
|
||||||
|
editSummary = EditorGUILayout.TextArea(editSummary ?? string.Empty, GUILayout.MinHeight(60f));
|
||||||
|
|
||||||
|
bool hasChanges = HasChanges();
|
||||||
|
bool executionTimeValid = TaskBoardService.IsValidExecutionTime(editExecutionTime);
|
||||||
|
|
||||||
|
if (!executionTimeValid)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox("execution_time должен быть в формате Jira, например 1d6h30m, и быть кратным 30 минутам.", MessageType.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (new EditorGUI.DisabledScope(!hasChanges || !executionTimeValid))
|
||||||
|
{
|
||||||
|
if (GUILayout.Button("Save Changes"))
|
||||||
|
{
|
||||||
|
SaveSelectedTask();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string DrawStringPopup(string label, string currentValue, string[] options)
|
||||||
|
{
|
||||||
|
int index = Array.IndexOf(options, currentValue);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int nextIndex = EditorGUILayout.Popup(label, index, options);
|
||||||
|
return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawReadOnlyField(string label, string value)
|
||||||
|
{
|
||||||
|
EditorGUILayout.LabelField(label, string.IsNullOrEmpty(value) ? "-" : value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawSection(string title, string content)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.LabelField(title, EditorStyles.boldLabel);
|
||||||
|
using (new EditorGUI.DisabledScope(true))
|
||||||
|
{
|
||||||
|
EditorGUILayout.TextArea(content, GetReadOnlyWrappedTextArea(), GUILayout.MinHeight(52f));
|
||||||
|
}
|
||||||
|
EditorGUILayout.Space(6f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private GUIStyle GetReadOnlyWrappedTextArea()
|
||||||
|
{
|
||||||
|
if (readOnlyWrappedTextArea == null)
|
||||||
|
{
|
||||||
|
readOnlyWrappedTextArea = new GUIStyle(EditorStyles.textArea)
|
||||||
|
{
|
||||||
|
wordWrap = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return readOnlyWrappedTextArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GUIStyle GetPriorityBadgeStyle()
|
||||||
|
{
|
||||||
|
if (priorityBadgeStyle == null)
|
||||||
|
{
|
||||||
|
priorityBadgeStyle = new GUIStyle(EditorStyles.miniBoldLabel)
|
||||||
|
{
|
||||||
|
alignment = TextAnchor.MiddleCenter,
|
||||||
|
padding = new RectOffset(8, 8, 3, 3),
|
||||||
|
normal = { textColor = Color.white },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return priorityBadgeStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawPriorityBadge(string priority)
|
||||||
|
{
|
||||||
|
GUIStyle style = GetPriorityBadgeStyle();
|
||||||
|
Vector2 contentSize = style.CalcSize(new GUIContent(priority));
|
||||||
|
Rect rect = GUILayoutUtility.GetRect(contentSize.x + 18f, 22f, GUILayout.Width(contentSize.x + 18f), GUILayout.Height(22f));
|
||||||
|
EditorGUI.DrawRect(rect, GetPriorityColor(priority));
|
||||||
|
GUI.Label(rect, priority, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleCardDrag(TaskRecord task, Rect cardRect)
|
||||||
|
{
|
||||||
|
Event current = Event.current;
|
||||||
|
if (current.type == EventType.MouseDown && current.button == 0 && cardRect.Contains(current.mousePosition))
|
||||||
|
{
|
||||||
|
pressedTask = task;
|
||||||
|
pressedMousePosition = current.mousePosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pressedTask != task)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.type == EventType.MouseDrag && (current.mousePosition - pressedMousePosition).sqrMagnitude > 16f)
|
||||||
|
{
|
||||||
|
DragAndDrop.PrepareStartDrag();
|
||||||
|
DragAndDrop.objectReferences = Array.Empty<UnityEngine.Object>();
|
||||||
|
DragAndDrop.SetGenericData("TaskBoard.TaskId", task.Id);
|
||||||
|
DragAndDrop.StartDrag(task.Id);
|
||||||
|
current.Use();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.type == EventType.MouseUp)
|
||||||
|
{
|
||||||
|
pressedTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleColumnDrop(Rect columnRect, string status)
|
||||||
|
{
|
||||||
|
Event current = Event.current;
|
||||||
|
if (!columnRect.Contains(current.mousePosition))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.type == EventType.DragUpdated)
|
||||||
|
{
|
||||||
|
if (GetDraggedTask() != null)
|
||||||
|
{
|
||||||
|
DragAndDrop.visualMode = DragAndDropVisualMode.Move;
|
||||||
|
current.Use();
|
||||||
|
Repaint();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.type == EventType.DragPerform)
|
||||||
|
{
|
||||||
|
TaskRecord task = GetDraggedTask();
|
||||||
|
if (task == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DragAndDrop.AcceptDrag();
|
||||||
|
current.Use();
|
||||||
|
pressedTask = null;
|
||||||
|
|
||||||
|
if (!string.Equals(task.Status, status, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
MoveTaskToStatus(task, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskRecord GetDraggedTask()
|
||||||
|
{
|
||||||
|
string draggedTaskId = DragAndDrop.GetGenericData("TaskBoard.TaskId") as string;
|
||||||
|
if (string.IsNullOrEmpty(draggedTaskId) || data == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.Tasks.FirstOrDefault(task => string.Equals(task.Id, draggedTaskId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MoveTaskToStatus(TaskRecord task, string status)
|
||||||
|
{
|
||||||
|
string selectedId = task.Id;
|
||||||
|
task.Status = status;
|
||||||
|
|
||||||
|
string error;
|
||||||
|
if (!TaskBoardService.Save(data, task, out error))
|
||||||
|
{
|
||||||
|
saveMessage = "Не удалось изменить статус перетаскиванием: " + error;
|
||||||
|
saveMessageType = MessageType.Error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMessage = task.FileExists
|
||||||
|
? "Статус задачи обновлен перетаскиванием."
|
||||||
|
: "Статус задачи обновлен в Index.md. Task-файл не найден, поэтому его метаданные не обновлялись.";
|
||||||
|
saveMessageType = MessageType.Info;
|
||||||
|
Reload(selectedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectTask(TaskRecord task)
|
||||||
|
{
|
||||||
|
selectedTask = task;
|
||||||
|
editStatus = task.Status;
|
||||||
|
editPriority = task.Priority;
|
||||||
|
editOwner = task.Owner;
|
||||||
|
editExecutionTime = task.ExecutionTime;
|
||||||
|
editSummary = task.Summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveSelectedTask()
|
||||||
|
{
|
||||||
|
if (selectedTask == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string selectedId = selectedTask.Id;
|
||||||
|
selectedTask.Status = editStatus;
|
||||||
|
selectedTask.Priority = editPriority;
|
||||||
|
selectedTask.Owner = string.IsNullOrWhiteSpace(editOwner) ? "unassigned" : editOwner.Trim();
|
||||||
|
selectedTask.ExecutionTime = editExecutionTime.Trim();
|
||||||
|
selectedTask.Summary = (editSummary ?? string.Empty).Trim();
|
||||||
|
|
||||||
|
string error;
|
||||||
|
if (!TaskBoardService.Save(data, selectedTask, out error))
|
||||||
|
{
|
||||||
|
saveMessage = "Не удалось сохранить задачу: " + error;
|
||||||
|
saveMessageType = MessageType.Error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMessage = selectedTask.FileExists
|
||||||
|
? "Изменения сохранены в Index.md и task-файл."
|
||||||
|
: "Изменения сохранены в Index.md. Task-файл не найден, поэтому его метаданные не обновлялись.";
|
||||||
|
saveMessageType = MessageType.Info;
|
||||||
|
Reload(selectedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateSelectedTaskFromTemplate()
|
||||||
|
{
|
||||||
|
if (selectedTask == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string selectedId = selectedTask.Id;
|
||||||
|
string error;
|
||||||
|
if (!TaskBoardService.CreateTaskFileFromTemplate(data, selectedTask, out error))
|
||||||
|
{
|
||||||
|
saveMessage = "Не удалось создать task-файл из шаблона: " + error;
|
||||||
|
saveMessageType = MessageType.Error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMessage = "Task-файл создан по шаблону.";
|
||||||
|
saveMessageType = MessageType.Info;
|
||||||
|
Reload(selectedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasChanges()
|
||||||
|
{
|
||||||
|
if (selectedTask == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !string.Equals(selectedTask.Status, editStatus, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(selectedTask.Priority, editPriority, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(selectedTask.Owner ?? string.Empty, editOwner ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(selectedTask.ExecutionTime ?? string.Empty, editExecutionTime ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(selectedTask.Summary ?? string.Empty, editSummary ?? string.Empty, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reload(string selectedId = null)
|
||||||
|
{
|
||||||
|
data = TaskBoardService.Load();
|
||||||
|
|
||||||
|
if (data == null || data.Tasks.Count == 0)
|
||||||
|
{
|
||||||
|
selectedTask = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskRecord nextSelection = null;
|
||||||
|
if (!string.IsNullOrEmpty(selectedId))
|
||||||
|
{
|
||||||
|
nextSelection = data.Tasks.FirstOrDefault(task => string.Equals(task.Id, selectedId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextSelection == null && selectedTask != null)
|
||||||
|
{
|
||||||
|
nextSelection = data.Tasks.FirstOrDefault(task => string.Equals(task.Id, selectedTask.Id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextSelection == null)
|
||||||
|
{
|
||||||
|
nextSelection = data.Tasks[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectTask(nextSelection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TaskRecord> GetFilteredTasks()
|
||||||
|
{
|
||||||
|
IEnumerable<TaskRecord> query = data.Tasks;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(searchText))
|
||||||
|
{
|
||||||
|
string search = searchText.Trim();
|
||||||
|
query = query.Where(task =>
|
||||||
|
ContainsIgnoreCase(task.Id, search)
|
||||||
|
|| ContainsIgnoreCase(task.Title, search)
|
||||||
|
|| ContainsIgnoreCase(task.Summary, search)
|
||||||
|
|| ContainsIgnoreCase(task.Area, search)
|
||||||
|
|| ContainsIgnoreCase(task.Owner, search));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(areaFilter, "All", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
query = query.Where(task => string.Equals(task.Area, areaFilter, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(priorityFilter, "All", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
query = query.Where(task => string.Equals(task.Priority, priorityFilter, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] BuildAreaOptions()
|
||||||
|
{
|
||||||
|
if (data == null || data.Tasks.Count == 0)
|
||||||
|
{
|
||||||
|
return new[] { "All" };
|
||||||
|
}
|
||||||
|
|
||||||
|
List<string> options = new List<string> { "All" };
|
||||||
|
options.AddRange(data.Tasks.Select(task => task.Area).Where(area => !string.IsNullOrWhiteSpace(area)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(area => area, StringComparer.OrdinalIgnoreCase));
|
||||||
|
return options.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] BuildPriorityOptions()
|
||||||
|
{
|
||||||
|
return new[] { "All", "Lowest", "Low", "Medium", "High", "Highest" };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsIgnoreCase(string source, string value)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(source) && source.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeValue(string value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrEmpty(value) ? "-" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color GetStatusColor(string status)
|
||||||
|
{
|
||||||
|
switch (status)
|
||||||
|
{
|
||||||
|
case "proposal":
|
||||||
|
return new Color(0.94f, 0.94f, 0.94f);
|
||||||
|
case "ready":
|
||||||
|
return new Color(0.85f, 0.96f, 0.85f);
|
||||||
|
case "in_progress":
|
||||||
|
return new Color(0.84f, 0.91f, 1f);
|
||||||
|
case "blocked":
|
||||||
|
return new Color(1f, 0.9f, 0.82f);
|
||||||
|
case "done":
|
||||||
|
return new Color(0.88f, 0.88f, 0.88f);
|
||||||
|
default:
|
||||||
|
return Color.white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color GetPriorityColor(string priority)
|
||||||
|
{
|
||||||
|
switch (TaskBoardService.NormalizePriority(priority))
|
||||||
|
{
|
||||||
|
case "Lowest":
|
||||||
|
return new Color(0.42f, 0.52f, 0.61f);
|
||||||
|
case "Low":
|
||||||
|
return new Color(0.45f, 0.45f, 0.45f);
|
||||||
|
case "Medium":
|
||||||
|
return new Color(0.19f, 0.48f, 0.84f);
|
||||||
|
case "High":
|
||||||
|
return new Color(0.88f, 0.54f, 0.16f);
|
||||||
|
case "Highest":
|
||||||
|
return new Color(0.78f, 0.2f, 0.2f);
|
||||||
|
default:
|
||||||
|
return new Color(0.35f, 0.35f, 0.35f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5203f967846df5f43baaa0a3ba2e2d5b
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Task Index
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Эта папка хранит один файл на каждую отложенную или асинхронную единицу работы и единый реестр статусов, чтобы контекст реализации не терялся между чатами.
|
||||||
|
|
||||||
|
Файлы задач должны описывать работу достаточно ясно, чтобы будущий человек или AI-агент мог продолжить ее без восстановления исходного замысла по истории переписки.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- используйте `docs/tasks/_template.md` для каждой новой задачи
|
||||||
|
- храните одну задачу в одном файле
|
||||||
|
- храните все task-файлы плоско в `docs/tasks`, без подпапок по статусам
|
||||||
|
- не переименовывайте и не перемещайте файл задачи при смене статуса
|
||||||
|
- статус задачи считается каноническим по записи в этом индексе
|
||||||
|
- предпочитайте ссылки на канонические документы вместо копирования больших фоновых разделов
|
||||||
|
- обновляйте статус задачи в этом индексе по мере продвижения работы
|
||||||
|
- если завершенная задача меняет поведение системы или операционные процессы, отдельно обновляйте каноническую документацию
|
||||||
|
- указывайте `execution_time` в формате Jira, например `1d6h30m`
|
||||||
|
- значение `execution_time` должно быть кратно 30 минутам
|
||||||
|
- используйте приоритеты `Lowest`, `Low`, `Medium`, `High`, `Highest`
|
||||||
|
- держите реестр отсортированным по `ID`, а не группируйте задачи по статусным разделам
|
||||||
|
|
||||||
|
## Supporting Docs
|
||||||
|
|
||||||
|
- шаблон задачи: `docs/tasks/_template.md`
|
||||||
|
|
||||||
|
## Statuses
|
||||||
|
|
||||||
|
- `proposal` - идея существует, но объем или подход еще не готовы к исполнению
|
||||||
|
- `ready` - задачу можно брать в работу сейчас
|
||||||
|
- `in_progress` - по задаче сейчас идет активная работа
|
||||||
|
- `blocked` - задача ждет решения, зависимости или внешней предпосылки
|
||||||
|
- `done` - работа завершена; оставьте короткую заметку по итогу и позже при необходимости переместите или переименуйте файл
|
||||||
|
|
||||||
|
## Task Registry
|
||||||
|
|
||||||
|
| ID | Status | Priority | Area | Execution Time | File | Summary |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| TASK-0001 | done | Medium | docs | 1d | `docs/tasks/TASK-0001-define-docs-structure-and-migration-plan.md` | Определена целевая структура документации, карта миграции и последовательность работ для переноса docs. |
|
||||||
|
| TASK-0002 | done | Medium | docs | 1d6h | `docs/tasks/TASK-0002-execute-docs-structure-migration.md` | Выполнен перенос дерева docs в разделы current, runbooks, history, process и tasks, а также обновлены пути входа в документацию. |
|
||||||
|
| TASK-0003 | in_progress | High | ci_cd | 2d | `docs/tasks/TASK-0003-stabilize-ci-cd-and-validate-pipeline.md` | Требуется итеративно стабилизировать текущий CI/CD путь на GitHub Actions и довести его до подтвержденно рабочего состояния. |
|
||||||
|
| TASK-0004 | ready | Medium | product | 1d | `docs/tasks/TASK-0004-define-directories-feature-and-implementation-decision.md` | Нужно согласовать и зафиксировать модель фичи directories, чтобы реализация не пошла в неверном направлении. |
|
||||||
|
| TASK-0005 | blocked | Medium | product | 2d | `docs/tasks/TASK-0005-implement-directories-and-folder-navigation.md` | Реализацию directories нельзя начинать, пока `TASK-0004` не зафиксирует согласованную модель папок и границы выполнения. |
|
||||||
|
| TASK-0006 | ready | Low | docs | 1d | `docs/tasks/TASK-0006-reposition-readme-as-project-brief.md` | Нужно переписать `README`, чтобы он начинался с идентичности проекта, стека и верхнеуровневого онбординга. |
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
id: TASK-XXXX
|
||||||
|
title: Короткий заголовок
|
||||||
|
priority: Medium
|
||||||
|
area: docs
|
||||||
|
owner: unassigned
|
||||||
|
created: YYYY-MM-DD
|
||||||
|
updated: YYYY-MM-DD
|
||||||
|
execution_time: 1d6h30m
|
||||||
|
depends_on: []
|
||||||
|
canonical_docs: []
|
||||||
|
related_files: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK-XXXX - Короткий заголовок
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Статус задачи ведется в `docs/tasks/Index.md` и является каноническим там.
|
||||||
|
|
||||||
|
Допустимые значения статуса:
|
||||||
|
|
||||||
|
- `proposal`
|
||||||
|
- `ready`
|
||||||
|
- `in_progress`
|
||||||
|
- `blocked`
|
||||||
|
- `done`
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Объясните, почему эта задача важна и какую проблему она решает.
|
||||||
|
|
||||||
|
## Expected Outcome
|
||||||
|
|
||||||
|
Опишите, какое новое состояние должно существовать после завершения задачи.
|
||||||
|
|
||||||
|
## Current Context
|
||||||
|
|
||||||
|
Держите этот раздел коротким. Ссылайтесь на канонические документы вместо копирования больших фоновых блоков.
|
||||||
|
|
||||||
|
## Source Of Truth
|
||||||
|
|
||||||
|
Перечислите документы или артефакты, которые имеют приоритет, если файл задачи неполон или устарел.
|
||||||
|
|
||||||
|
- канонические документы текущего состояния в `docs/current/...`
|
||||||
|
- операционные runbook-документы в `docs/runbooks/...`
|
||||||
|
- проверенный код, тесты и закоммиченные артефакты деплоя
|
||||||
|
- явные решения человека, принятые после создания этой задачи
|
||||||
|
|
||||||
|
## Read First
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- `docs/...`
|
||||||
|
- `src/...`
|
||||||
|
- `tests/...`
|
||||||
|
|
||||||
|
## Scope In
|
||||||
|
|
||||||
|
- пункт
|
||||||
|
- пункт
|
||||||
|
|
||||||
|
## Scope Out
|
||||||
|
|
||||||
|
- пункт
|
||||||
|
- пункт
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- сохраняйте контракты, уровень безопасности и задокументированную архитектуру, если только человек явно не изменил их
|
||||||
|
- предпочитайте наименьшее безопасное изменение, которое оставляет после себя более понятную документацию и подтверждение проверки
|
||||||
|
- указывайте `execution_time` в формате Jira, например `1d6h30m`, и только с шагом в 30 минут
|
||||||
|
- используйте приоритеты `Lowest`, `Low`, `Medium`, `High`, `Highest`
|
||||||
|
- не переименовывайте и не перемещайте task-файл при смене статуса; обновляйте запись в `docs/tasks/Index.md`
|
||||||
|
|
||||||
|
## If You Find Drift
|
||||||
|
|
||||||
|
- не считайте этот файл задачи молча источником высшего приоритета
|
||||||
|
- если текущие канонические документы и исторические документы расходятся, предпочитайте текущие канонические документы
|
||||||
|
- если код и документация расходятся, определите, является ли код намеренным текущим поведением или это дрейф документации, затем обновите ближайший канонический документ
|
||||||
|
- если конфликт затрагивает архитектуру, контракты, уровень безопасности, форму деплоя или поведение данных и миграций, остановитесь и спросите человека, если только более новое явное решение уже не сняло вопрос
|
||||||
|
- фиксируйте важный дрейф или последующие пробелы в файле задачи перед передачей дальше
|
||||||
|
|
||||||
|
## Suggested Approach
|
||||||
|
|
||||||
|
1. Шаг первый.
|
||||||
|
2. Шаг второй.
|
||||||
|
3. Шаг третий.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- измеримый результат
|
||||||
|
- измеримый результат
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- проверка согласованности документации при вычитке
|
||||||
|
- точечные шаги сборки, тестирования или ручной проверки, если ожидаются изменения в коде
|
||||||
|
|
||||||
|
## Risks / Open Questions
|
||||||
|
|
||||||
|
- вопрос или риск
|
||||||
|
|
||||||
|
## Human Decisions Needed
|
||||||
|
|
||||||
|
- перечисляйте только решения, которые действительно требуют человека
|
||||||
|
- пишите `none currently`, когда задачу можно выполнять без дополнительных уточнений
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
- `YYYY-MM-DD` - фиксируйте значимые решения, принятые при уточнении или выполнении задачи
|
||||||
|
|
||||||
|
## Handoff Notes
|
||||||
|
|
||||||
|
Добавляйте короткие заметки, которые помогут следующему человеку или AI-агенту безопасно продолжить работу.
|
||||||
Reference in New Issue
Block a user