@umudik/task-bridge 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/apps/backend/dist/config.js +26 -0
  4. package/apps/backend/dist/db/epic-workflow-db.js +125 -0
  5. package/apps/backend/dist/db/library-db.js +123 -0
  6. package/apps/backend/dist/db/projects-db.js +110 -0
  7. package/apps/backend/dist/db/tasks-db.js +282 -0
  8. package/apps/backend/dist/db/users-db.js +117 -0
  9. package/apps/backend/dist/db/workflow-db.js +186 -0
  10. package/apps/backend/dist/db/workflow-template-db.js +715 -0
  11. package/apps/backend/dist/domain/project-member.js +15 -0
  12. package/apps/backend/dist/domain/task-template-graph.js +63 -0
  13. package/apps/backend/dist/domain/task.js +93 -0
  14. package/apps/backend/dist/domain/work-status.js +30 -0
  15. package/apps/backend/dist/domain/workflow-stage.js +186 -0
  16. package/apps/backend/dist/domain/workflow-state.js +73 -0
  17. package/apps/backend/dist/domain/workflow-template-id.js +6 -0
  18. package/apps/backend/dist/errors/app-error.js +24 -0
  19. package/apps/backend/dist/index.js +67 -0
  20. package/apps/backend/dist/lib/bridge-project.js +24 -0
  21. package/apps/backend/dist/lib/inbox-cursor.js +34 -0
  22. package/apps/backend/dist/lib/strings.js +15 -0
  23. package/apps/backend/dist/logger.js +29 -0
  24. package/apps/backend/dist/mappers/task-response.js +261 -0
  25. package/apps/backend/dist/middleware/auth.js +29 -0
  26. package/apps/backend/dist/openapi.js +716 -0
  27. package/apps/backend/dist/routes/admin-users.js +79 -0
  28. package/apps/backend/dist/routes/auth.js +81 -0
  29. package/apps/backend/dist/routes/connect.js +1 -0
  30. package/apps/backend/dist/routes/docs.js +13 -0
  31. package/apps/backend/dist/routes/health.js +6 -0
  32. package/apps/backend/dist/routes/library.js +139 -0
  33. package/apps/backend/dist/routes/projects.js +95 -0
  34. package/apps/backend/dist/routes/tasks.js +522 -0
  35. package/apps/backend/dist/routes/web.js +79 -0
  36. package/apps/backend/dist/routes/workflow-templates.js +152 -0
  37. package/apps/backend/dist/routes/workflow.js +165 -0
  38. package/apps/backend/dist/services/connect-target.js +4 -0
  39. package/apps/backend/dist/services/epic-service.js +269 -0
  40. package/apps/backend/dist/services/library-service.js +222 -0
  41. package/apps/backend/dist/services/project-registry.js +122 -0
  42. package/apps/backend/dist/services/task-assignee-service.js +42 -0
  43. package/apps/backend/dist/services/task-claim-policy.js +310 -0
  44. package/apps/backend/dist/services/task-queue.js +105 -0
  45. package/apps/backend/dist/services/task-service.js +198 -0
  46. package/apps/backend/dist/services/workflow-rules.js +18 -0
  47. package/apps/backend/dist/services/workflow-service.js +418 -0
  48. package/apps/backend/dist/services/workflow-spawn-service.js +179 -0
  49. package/apps/backend/dist/services/workflow-state-service.js +157 -0
  50. package/apps/backend/dist/services/workflow-template-service.js +204 -0
  51. package/apps/backend/public/assets/index-Bl1ciVpY.js +409 -0
  52. package/apps/backend/public/assets/index-ByKECv-I.css +1 -0
  53. package/apps/backend/public/index.html +13 -0
  54. package/bin/task-bridge.mjs +86 -0
  55. package/package.json +41 -0
@@ -0,0 +1,222 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { deleteLibraryDocumentLink, deleteLibraryDocumentRow, deleteLibraryRow, insertLibraryDocumentLink, insertLibraryDocumentRow, insertLibraryRow, listLibraryDocumentLinkRowsForDocument, listLibraryDocumentLinkRowsForTask, listLibraryDocumentRows, listLibraryRows, updateLibraryDocumentRow, updateLibraryRow, } from "../db/library-db.js";
3
+ import { listTaskRows } from "../db/tasks-db.js";
4
+ import { AppError } from "../errors/app-error.js";
5
+ function slugify(value) {
6
+ return value
7
+ .replace(/[^a-z0-9]+/g, "-")
8
+ .replace(/^-+|-+$/g, "");
9
+ }
10
+ function resolveLibraryId(inputId, title) {
11
+ const custom = inputId;
12
+ if (custom !== "")
13
+ return custom;
14
+ const base = slugify(title) || `library-${randomUUID()}`;
15
+ let candidate = base;
16
+ let suffix = 1;
17
+ while (listLibraryRows({ id: candidate }).length > 0) {
18
+ candidate = `${base}-${suffix}`;
19
+ suffix += 1;
20
+ }
21
+ return candidate;
22
+ }
23
+ function resolveDocumentId(inputId, title, libraryId) {
24
+ const custom = inputId;
25
+ if (custom !== "")
26
+ return custom;
27
+ const base = slugify(title) || `doc-${randomUUID()}`;
28
+ let candidate = base;
29
+ let suffix = 1;
30
+ while (listLibraryDocumentRows({ libraryId: "", documentId: candidate }).length > 0) {
31
+ candidate = `${base}-${suffix}`;
32
+ suffix += 1;
33
+ }
34
+ if (listLibraryDocumentRows({ libraryId: "", documentId: candidate }).length > 0) {
35
+ return `${libraryId}-${candidate}`;
36
+ }
37
+ return candidate;
38
+ }
39
+ function mapLibraryDetail(row) {
40
+ return {
41
+ id: row.id,
42
+ title: row.title,
43
+ description: row.description,
44
+ documents: listLibraryDocumentRows({ libraryId: row.id, documentId: "" }).map((doc) => ({
45
+ id: doc.id,
46
+ libraryId: doc.library_id,
47
+ title: doc.title,
48
+ description: doc.description,
49
+ })),
50
+ };
51
+ }
52
+ function mapLibraryDocument(row) {
53
+ const libraries = listLibraryRows({ id: row.library_id });
54
+ let libraryTitle;
55
+ const libraryRow = libraries[0];
56
+ if (libraryRow) {
57
+ libraryTitle = libraryRow.title;
58
+ }
59
+ else {
60
+ libraryTitle = row.library_id;
61
+ }
62
+ return {
63
+ id: row.id,
64
+ libraryId: row.library_id,
65
+ libraryTitle,
66
+ title: row.title,
67
+ description: row.description,
68
+ linkCount: listLibraryDocumentLinkRowsForDocument(row.id).length,
69
+ };
70
+ }
71
+ export function listLibraries() {
72
+ return listLibraryRows({ id: "" }).map((row) => ({
73
+ id: row.id,
74
+ title: row.title,
75
+ description: row.description,
76
+ documentCount: listLibraryDocumentRows({ libraryId: row.id, documentId: "" }).length,
77
+ }));
78
+ }
79
+ export function getLibrary(libraryId) {
80
+ const rows = listLibraryRows({ id: libraryId });
81
+ if (rows.length === 0)
82
+ return null;
83
+ const row = rows[0];
84
+ if (!row)
85
+ return null;
86
+ return mapLibraryDetail(row);
87
+ }
88
+ export function createLibrary(input) {
89
+ const title = input.title;
90
+ if (title === "")
91
+ throw new AppError("Title is required", 400);
92
+ const id = resolveLibraryId(input.id, title);
93
+ if (listLibraryRows({ id }).length > 0)
94
+ throw new AppError("Library already exists", 409);
95
+ insertLibraryRow({ id, title, description: input.description });
96
+ const created = getLibrary(id);
97
+ if (created === null)
98
+ throw new AppError("Library creation failed", 500);
99
+ return created;
100
+ }
101
+ export function updateLibrary(libraryId, input) {
102
+ const rows = listLibraryRows({ id: libraryId });
103
+ if (rows.length === 0)
104
+ throw new AppError("Library not found", 404);
105
+ const title = input.title;
106
+ if (title === "")
107
+ throw new AppError("Title is required", 400);
108
+ updateLibraryRow(libraryId, { title, description: input.description });
109
+ const updated = getLibrary(libraryId);
110
+ if (updated === null)
111
+ throw new AppError("Library not found after update", 500);
112
+ return updated;
113
+ }
114
+ export function removeLibrary(libraryId) {
115
+ if (listLibraryRows({ id: libraryId }).length === 0) {
116
+ throw new AppError("Library not found", 404);
117
+ }
118
+ deleteLibraryRow(libraryId);
119
+ }
120
+ export function getLibraryDocument(documentId) {
121
+ const rows = listLibraryDocumentRows({ libraryId: "", documentId });
122
+ if (rows.length === 0)
123
+ return null;
124
+ const row = rows[0];
125
+ if (!row)
126
+ return null;
127
+ return mapLibraryDocument(row);
128
+ }
129
+ export function createLibraryDocument(libraryId, input) {
130
+ if (listLibraryRows({ id: libraryId }).length === 0) {
131
+ throw new AppError("Library not found", 404);
132
+ }
133
+ const title = input.title;
134
+ if (title === "")
135
+ throw new AppError("Title is required", 400);
136
+ const id = resolveDocumentId(input.id, title, libraryId);
137
+ if (listLibraryDocumentRows({ libraryId: "", documentId: id }).length > 0) {
138
+ throw new AppError("Document already exists", 409);
139
+ }
140
+ insertLibraryDocumentRow({
141
+ id,
142
+ libraryId,
143
+ title,
144
+ description: input.description,
145
+ });
146
+ const created = getLibraryDocument(id);
147
+ if (created === null)
148
+ throw new AppError("Document creation failed", 500);
149
+ return created;
150
+ }
151
+ export function updateLibraryDocument(documentId, input) {
152
+ if (listLibraryDocumentRows({ libraryId: "", documentId }).length === 0) {
153
+ throw new AppError("Document not found", 404);
154
+ }
155
+ const title = input.title;
156
+ if (title === "")
157
+ throw new AppError("Title is required", 400);
158
+ updateLibraryDocumentRow(documentId, { title, description: input.description });
159
+ const updated = getLibraryDocument(documentId);
160
+ if (updated === null)
161
+ throw new AppError("Document not found after update", 500);
162
+ return updated;
163
+ }
164
+ export function removeLibraryDocument(documentId) {
165
+ if (listLibraryDocumentRows({ libraryId: "", documentId }).length === 0) {
166
+ throw new AppError("Document not found", 404);
167
+ }
168
+ deleteLibraryDocumentRow(documentId);
169
+ }
170
+ export function linkDocumentToTask(documentId, taskId) {
171
+ if (listLibraryDocumentRows({ libraryId: "", documentId }).length === 0) {
172
+ throw new AppError("Document not found", 404);
173
+ }
174
+ const tasks = listTaskRows({ id: taskId });
175
+ if (tasks.length === 0)
176
+ throw new AppError("Task not found", 404);
177
+ const task = tasks[0];
178
+ if (!task)
179
+ throw new AppError("Task not found", 404);
180
+ if (task.parentId !== null) {
181
+ throw new AppError("Documents can only be linked to epics", 400);
182
+ }
183
+ insertLibraryDocumentLink(documentId, taskId);
184
+ return listTaskLibraryLinks(taskId);
185
+ }
186
+ export function unlinkDocumentFromTask(documentId, taskId) {
187
+ if (listLibraryDocumentRows({ libraryId: "", documentId }).length === 0) {
188
+ throw new AppError("Document not found", 404);
189
+ }
190
+ deleteLibraryDocumentLink(documentId, taskId);
191
+ }
192
+ export function listTaskLibraryLinks(taskId) {
193
+ if (listTaskRows({ id: taskId }).length === 0)
194
+ return [];
195
+ return listLibraryDocumentLinkRowsForTask(taskId).flatMap((link) => {
196
+ const docs = listLibraryDocumentRows({ libraryId: "", documentId: link.document_id });
197
+ if (docs.length === 0)
198
+ return [];
199
+ const document = docs[0];
200
+ if (!document)
201
+ return [];
202
+ const libraries = listLibraryRows({ id: document.library_id });
203
+ let libraryTitle;
204
+ const libraryRow = libraries[0];
205
+ if (libraryRow) {
206
+ libraryTitle = libraryRow.title;
207
+ }
208
+ else {
209
+ libraryTitle = document.library_id;
210
+ }
211
+ return [
212
+ {
213
+ documentId: document.id,
214
+ documentTitle: document.title,
215
+ libraryId: document.library_id,
216
+ libraryTitle,
217
+ taskId: link.task_id,
218
+ linkedAt: link.created_at,
219
+ },
220
+ ];
221
+ });
222
+ }
@@ -0,0 +1,122 @@
1
+ import { getProjectsDb, insertProjectRow, listProjectRows, listProjectRowsById, updateProjectRow, } from "../db/projects-db.js";
2
+ import { migrateEpicWorkflowTables } from "../db/epic-workflow-db.js";
3
+ import { copyTemplateStagesToProject, ensureDefaultWorkflowTemplates, } from "./workflow-template-service.js";
4
+ import { applyWorkflowTemplateToProject } from "./workflow-service.js";
5
+ import { normalizeWorkflowTemplateId, } from "../domain/workflow-template-id.js";
6
+ function normalizeRepoPath(value) {
7
+ if (String(value) !== value)
8
+ return null;
9
+ if (!value)
10
+ return null;
11
+ return value;
12
+ }
13
+ function rowToProject(row) {
14
+ return {
15
+ id: row.id,
16
+ name: row.name,
17
+ repoPath: normalizeRepoPath(row.repo_path),
18
+ description: row.description,
19
+ workflowTemplateId: normalizeWorkflowTemplateId(row.workflow_template_id),
20
+ };
21
+ }
22
+ function slugifyProjectId(name) {
23
+ const mapped = name
24
+ .replace(/ş/g, "s")
25
+ .replace(/ğ/g, "g")
26
+ .replace(/ü/g, "u")
27
+ .replace(/ö/g, "o")
28
+ .replace(/ç/g, "c")
29
+ .replace(/ı/g, "i")
30
+ .replace(/İ/g, "i");
31
+ return mapped
32
+ .replace(/[^a-z0-9]+/g, "-")
33
+ .replace(/^-+|-+$/g, "");
34
+ }
35
+ export function initProjectRegistry() {
36
+ getProjectsDb();
37
+ migrateEpicWorkflowTables();
38
+ ensureDefaultWorkflowTemplates();
39
+ }
40
+ export function refreshProjectRegistry() {
41
+ return listProjectRows().map(rowToProject);
42
+ }
43
+ export function listPublicProjects() {
44
+ return listProjectRows().map((row) => ({
45
+ id: row.id,
46
+ name: row.name,
47
+ repoPath: normalizeRepoPath(row.repo_path),
48
+ description: row.description,
49
+ workflowTemplateId: normalizeWorkflowTemplateId(row.workflow_template_id),
50
+ }));
51
+ }
52
+ export function createProject(input) {
53
+ const name = input.name;
54
+ const repoPath = input.repoPath;
55
+ if (!name || !repoPath)
56
+ return null;
57
+ const inputId = input.id;
58
+ const id = inputId || slugifyProjectId(name);
59
+ if (!id || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))
60
+ return null;
61
+ if (listProjectRowsById(id).length > 0)
62
+ return "duplicate";
63
+ const templateId = normalizeWorkflowTemplateId(input.workflowTemplateId);
64
+ const description = input.description;
65
+ if (!insertProjectRow(id, name, repoPath, description, templateId)) {
66
+ return "duplicate";
67
+ }
68
+ copyTemplateStagesToProject(id, templateId);
69
+ const rows = listProjectRowsById(id);
70
+ const row = rows[0];
71
+ if (row) {
72
+ return rowToProject(row);
73
+ }
74
+ return null;
75
+ }
76
+ export function updateProject(projectId, input) {
77
+ const existing = getProjectById(projectId);
78
+ if (!existing)
79
+ return null;
80
+ const name = input.name;
81
+ const repoPath = input.repoPath;
82
+ if (!name || !repoPath)
83
+ return null;
84
+ const description = input.description;
85
+ const workflowTemplateId = normalizeWorkflowTemplateId(input.workflowTemplateId);
86
+ const templateChanged = workflowTemplateId !== existing.workflowTemplateId;
87
+ if (templateChanged) {
88
+ applyWorkflowTemplateToProject(projectId, workflowTemplateId);
89
+ }
90
+ if (!updateProjectRow(projectId, {
91
+ name,
92
+ repoPath,
93
+ description,
94
+ workflowTemplateId,
95
+ })) {
96
+ return null;
97
+ }
98
+ return getProjectById(projectId);
99
+ }
100
+ export function updateProjectRepoPath(projectId, repoPath) {
101
+ const existing = getProjectById(projectId);
102
+ if (!existing)
103
+ return null;
104
+ return updateProject(projectId, {
105
+ name: existing.name,
106
+ repoPath: repoPath,
107
+ description: existing.description,
108
+ workflowTemplateId: existing.workflowTemplateId,
109
+ });
110
+ }
111
+ export function getProjectById(projectId) {
112
+ const id = projectId;
113
+ if (!id)
114
+ return null;
115
+ const rows = listProjectRowsById(id);
116
+ const row = rows[0];
117
+ if (row) {
118
+ return rowToProject(row);
119
+ }
120
+ return null;
121
+ }
122
+ export function resetProjectRegistryCache() { }
@@ -0,0 +1,42 @@
1
+ import { listProjectMemberRows, listWorkflowStageRows } from "../db/workflow-db.js";
2
+ import { AppError } from "../errors/app-error.js";
3
+ function pickRandomMember(members) {
4
+ const index = Math.floor(Math.random() * members.length);
5
+ const member = members[index];
6
+ if (!member) {
7
+ throw new AppError("No project members to assign task", 400);
8
+ }
9
+ return member.name;
10
+ }
11
+ export function pickMemberByProjectRole(projectId, _roleName) {
12
+ const members = listProjectMemberRows({ projectId, id: "" });
13
+ if (members.length === 0) {
14
+ throw new AppError("No project members to assign task", 400);
15
+ }
16
+ return pickRandomMember(members);
17
+ }
18
+ export function resolveTaskAssignee(input) {
19
+ let explicit = "";
20
+ if (input.assignee !== null) {
21
+ explicit = input.assignee;
22
+ }
23
+ if (explicit) {
24
+ return { assignee: explicit, assigneeRole: input.assigneeRole };
25
+ }
26
+ let role = "";
27
+ if (input.assigneeRole !== null) {
28
+ role = input.assigneeRole;
29
+ }
30
+ if (!role && input.stageId) {
31
+ const stageRows = listWorkflowStageRows({
32
+ projectId: input.projectId,
33
+ stageId: input.stageId,
34
+ });
35
+ const stage = stageRows[0];
36
+ if (stage) {
37
+ role = stage.auto_assign_role;
38
+ }
39
+ }
40
+ const assignee = pickMemberByProjectRole(input.projectId, role);
41
+ return { assignee, assigneeRole: role || input.assigneeRole };
42
+ }
@@ -0,0 +1,310 @@
1
+ import { listWorkflowStageRows, } from "../db/workflow-db.js";
2
+ import { flattenTemplateNodes } from "../domain/task-template-graph.js";
3
+ import { findProjectMember } from "../domain/project-member.js";
4
+ import { isWorkDone, resolveWorkStatus } from "../domain/work-status.js";
5
+ import { listEpicWorkflowTasks } from "../domain/task.js";
6
+ import { resolveStageTaskTemplates } from "../domain/workflow-stage.js";
7
+ import { emptyToNull } from "../lib/strings.js";
8
+ import { computeEpicStageId } from "./epic-service.js";
9
+ export function resolveClaimActor(projectId, memberName) {
10
+ const member = findProjectMember(projectId, memberName);
11
+ if (!member)
12
+ return null;
13
+ return {
14
+ claimedBy: member.name,
15
+ role: member.role,
16
+ };
17
+ }
18
+ export function normalizeClaimActor(actor) {
19
+ return {
20
+ claimedBy: actor.claimedBy,
21
+ role: actor.role,
22
+ };
23
+ }
24
+ function latestCommentByRole(comments, role) {
25
+ for (let index = comments.length - 1; index >= 0; index -= 1) {
26
+ const entry = comments[index];
27
+ if (!entry)
28
+ continue;
29
+ if (entry.role === role)
30
+ return entry;
31
+ }
32
+ return null;
33
+ }
34
+ export function userAwaitingReply(task) {
35
+ const lastUser = latestCommentByRole(task.comments, "user");
36
+ if (!lastUser)
37
+ return false;
38
+ const lastSystem = latestCommentByRole(task.comments, "system");
39
+ if (!lastSystem)
40
+ return true;
41
+ const humanAt = Date.parse(lastUser.at);
42
+ const systemAt = Date.parse(lastSystem.at);
43
+ if (Number.isNaN(humanAt) || Number.isNaN(systemAt))
44
+ return true;
45
+ return humanAt > systemAt;
46
+ }
47
+ export function rolesMatch(_actorRole, _requiredRole) {
48
+ return true;
49
+ }
50
+ export function resolveTaskClaimRole(task) {
51
+ if (task.parentId === null)
52
+ return null;
53
+ if (task.assigneeRole)
54
+ return task.assigneeRole;
55
+ if (task.templateId) {
56
+ for (const row of listWorkflowStageRows({ projectId: task.projectId, stageId: "" })) {
57
+ const roots = resolveStageTaskTemplates({
58
+ taskTemplatesJson: row.task_templates_json,
59
+ stageId: row.id,
60
+ stageTitle: row.title,
61
+ });
62
+ for (const node of flattenTemplateNodes(roots)) {
63
+ if (node.id === task.templateId && node.assigneeRole) {
64
+ return node.assigneeRole;
65
+ }
66
+ }
67
+ }
68
+ }
69
+ if (task.assignee !== null) {
70
+ const member = findProjectMember(task.projectId, task.assignee);
71
+ if (member !== null)
72
+ return member.role;
73
+ }
74
+ if (task.stageId !== null) {
75
+ const stageRows = listWorkflowStageRows({
76
+ projectId: task.projectId,
77
+ stageId: task.stageId,
78
+ });
79
+ if (stageRows.length > 0) {
80
+ const stageRow = stageRows[0];
81
+ if (!stageRow)
82
+ return null;
83
+ const autoRole = emptyToNull(stageRow.auto_assign_role);
84
+ if (autoRole)
85
+ return autoRole;
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+ export function buildEpicClaimIndex(tasks) {
91
+ const activeStageByEpic = new Map();
92
+ const stagePositionByProject = new Map();
93
+ const epics = tasks.filter((task) => task.parentId === null);
94
+ for (const epic of epics) {
95
+ if (!stagePositionByProject.has(epic.projectId)) {
96
+ const positions = new Map();
97
+ for (const row of listWorkflowStageRows({ projectId: epic.projectId, stageId: "" }).sort((a, b) => a.position - b.position)) {
98
+ positions.set(row.id, row.position);
99
+ }
100
+ stagePositionByProject.set(epic.projectId, positions);
101
+ }
102
+ const stageRows = listWorkflowStageRows({ projectId: epic.projectId, stageId: "" }).sort((a, b) => a.position - b.position);
103
+ const subtasks = listEpicWorkflowTasks(tasks, epic.id);
104
+ activeStageByEpic.set(epic.id, computeEpicStageId(stageRows, subtasks));
105
+ }
106
+ return { activeStageByEpic, stagePositionByProject };
107
+ }
108
+ export function isTaskOnEpicActiveStage(task, index) {
109
+ if (!task.stageId)
110
+ return false;
111
+ let epicId;
112
+ if (task.epicId !== null) {
113
+ epicId = task.epicId;
114
+ }
115
+ else {
116
+ epicId = task.parentId;
117
+ }
118
+ if (!epicId)
119
+ return false;
120
+ const activeStageId = index.activeStageByEpic.get(epicId);
121
+ return activeStageId === task.stageId;
122
+ }
123
+ export function passesWorkflowClaimGate(task, index) {
124
+ if (task.parentId === null)
125
+ return false;
126
+ if (isWorkDone(task))
127
+ return false;
128
+ return isTaskOnEpicActiveStage(task, index);
129
+ }
130
+ export function canActorClaimTask(task, index, _actor) {
131
+ if (task.parentId === null || isWorkDone(task))
132
+ return false;
133
+ if (userAwaitingReply(task)) {
134
+ return true;
135
+ }
136
+ if (task.claimedBy)
137
+ return false;
138
+ return passesWorkflowClaimGate(task, index);
139
+ }
140
+ export function canActorUpdateWorkStatus(task, index, actor) {
141
+ if (task.parentId === null || isWorkDone(task))
142
+ return false;
143
+ if (!isTaskOnEpicActiveStage(task, index))
144
+ return false;
145
+ if (task.claimedBy && task.claimedBy !== actor.claimedBy)
146
+ return false;
147
+ return true;
148
+ }
149
+ export function workflowUpdateBlockReason(task, index, actor, nextWorkStatus = null) {
150
+ if (task.parentId === null)
151
+ return "Epics cannot be updated";
152
+ const isReopen = isWorkDone(task) &&
153
+ nextWorkStatus !== null &&
154
+ (nextWorkStatus === "todo" || nextWorkStatus === "in_progress");
155
+ if (isWorkDone(task) && !isReopen) {
156
+ return "Task is already done";
157
+ }
158
+ if (!isReopen && !isTaskOnEpicActiveStage(task, index)) {
159
+ return "Task is not on the active pipeline step";
160
+ }
161
+ if (!isReopen && task.claimedBy && task.claimedBy !== actor.claimedBy) {
162
+ return "Task is claimed by another member";
163
+ }
164
+ return null;
165
+ }
166
+ export function isWorkflowClaimable(task, index, actor) {
167
+ if (actor === null) {
168
+ if (userAwaitingReply(task))
169
+ return task.parentId !== null && !isWorkDone(task);
170
+ if (task.claimedBy)
171
+ return false;
172
+ return passesWorkflowClaimGate(task, index);
173
+ }
174
+ return canActorClaimTask(task, index, actor);
175
+ }
176
+ function activeStageSortKey(task, index) {
177
+ if (!task.parentId)
178
+ return Number.MAX_SAFE_INTEGER;
179
+ const positions = index.stagePositionByProject.get(task.projectId);
180
+ const activeStageId = index.activeStageByEpic.get(task.parentId);
181
+ if (!positions || !activeStageId)
182
+ return Number.MAX_SAFE_INTEGER;
183
+ if (!positions.has(activeStageId)) {
184
+ return Number.MAX_SAFE_INTEGER;
185
+ }
186
+ return positions.get(activeStageId);
187
+ }
188
+ function workStatusSortKey(task) {
189
+ const status = resolveWorkStatus(task);
190
+ if (status === "in_progress")
191
+ return 0;
192
+ if (status === "todo")
193
+ return 1;
194
+ return 2;
195
+ }
196
+ export function compareWorkflowClaimPriority(a, b, index) {
197
+ let awaitingA;
198
+ if (userAwaitingReply(a)) {
199
+ awaitingA = 0;
200
+ }
201
+ else {
202
+ awaitingA = 1;
203
+ }
204
+ let awaitingB;
205
+ if (userAwaitingReply(b)) {
206
+ awaitingB = 0;
207
+ }
208
+ else {
209
+ awaitingB = 1;
210
+ }
211
+ if (awaitingA !== awaitingB)
212
+ return awaitingA - awaitingB;
213
+ const stageA = activeStageSortKey(a, index);
214
+ const stageB = activeStageSortKey(b, index);
215
+ if (stageA !== stageB)
216
+ return stageA - stageB;
217
+ let epicAId;
218
+ if (a.epicId !== null) {
219
+ epicAId = a.epicId;
220
+ }
221
+ else {
222
+ epicAId = a.parentId;
223
+ }
224
+ let epicBId;
225
+ if (b.epicId !== null) {
226
+ epicBId = b.epicId;
227
+ }
228
+ else {
229
+ epicBId = b.parentId;
230
+ }
231
+ let epicA;
232
+ if (epicAId !== null) {
233
+ epicA = epicAId;
234
+ }
235
+ else {
236
+ epicA = Number.MAX_SAFE_INTEGER;
237
+ }
238
+ let epicB;
239
+ if (epicBId !== null) {
240
+ epicB = epicBId;
241
+ }
242
+ else {
243
+ epicB = Number.MAX_SAFE_INTEGER;
244
+ }
245
+ if (epicA !== epicB)
246
+ return epicA - epicB;
247
+ const workA = workStatusSortKey(a);
248
+ const workB = workStatusSortKey(b);
249
+ if (workA !== workB)
250
+ return workA - workB;
251
+ const createdA = Date.parse(a.createdAt);
252
+ const createdB = Date.parse(b.createdAt);
253
+ if (!Number.isNaN(createdA) && !Number.isNaN(createdB) && createdA !== createdB) {
254
+ return createdA - createdB;
255
+ }
256
+ return a.id - b.id;
257
+ }
258
+ export function sortWorkflowClaimCandidates(tasks, index) {
259
+ return tasks.slice().sort((a, b) => compareWorkflowClaimPriority(a, b, index));
260
+ }
261
+ export function workflowClaimBlockReason(task, index, actor) {
262
+ if (task.parentId === null)
263
+ return "Epics cannot be claimed";
264
+ if (isWorkDone(task))
265
+ return "Task is already done";
266
+ if (actor !== null) {
267
+ if (!userAwaitingReply(task)) {
268
+ if (task.claimedBy)
269
+ return "Task is already claimed";
270
+ if (!isTaskOnEpicActiveStage(task, index)) {
271
+ let epicId;
272
+ if (task.epicId !== null) {
273
+ epicId = task.epicId;
274
+ }
275
+ else {
276
+ epicId = task.parentId;
277
+ }
278
+ let activeStageId = null;
279
+ if (epicId !== null) {
280
+ if (index.activeStageByEpic.has(epicId)) {
281
+ activeStageId = index.activeStageByEpic.get(epicId);
282
+ }
283
+ }
284
+ if (!activeStageId)
285
+ return "Epic has no active pipeline step";
286
+ return `Task is on a later pipeline step; epic is at "${activeStageId}"`;
287
+ }
288
+ }
289
+ return null;
290
+ }
291
+ if (!isTaskOnEpicActiveStage(task, index)) {
292
+ let epicId;
293
+ if (task.epicId !== null) {
294
+ epicId = task.epicId;
295
+ }
296
+ else {
297
+ epicId = task.parentId;
298
+ }
299
+ let activeStageId = null;
300
+ if (epicId !== null) {
301
+ if (index.activeStageByEpic.has(epicId)) {
302
+ activeStageId = index.activeStageByEpic.get(epicId);
303
+ }
304
+ }
305
+ if (!activeStageId)
306
+ return "Epic has no active pipeline step";
307
+ return `Task is on a later pipeline step; epic is at "${activeStageId}"`;
308
+ }
309
+ return null;
310
+ }