@co0ontty/wand 4.55.3 → 4.56.0

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/dist/build-info.json +3 -3
  2. package/dist/github-connector.d.ts +22 -0
  3. package/dist/github-connector.js +117 -0
  4. package/dist/missions.js +42 -9
  5. package/dist/server-github-routes.d.ts +10 -0
  6. package/dist/server-github-routes.js +239 -0
  7. package/dist/server-settings-routes.d.ts +2 -0
  8. package/dist/server-settings-routes.js +1 -0
  9. package/dist/server-task-routes.d.ts +4 -0
  10. package/dist/server-task-routes.js +99 -0
  11. package/dist/server-workspace-routes.js +70 -27
  12. package/dist/server.js +25 -0
  13. package/dist/storage.d.ts +42 -0
  14. package/dist/storage.js +223 -0
  15. package/dist/structured-provider-common.js +2 -1
  16. package/dist/task-types.d.ts +19 -0
  17. package/dist/task-types.js +1 -0
  18. package/dist/taskboard-bridge.d.ts +11 -0
  19. package/dist/taskboard-bridge.js +360 -0
  20. package/dist/web-ui/content/scripts.js +123 -127
  21. package/dist/web-ui/content/styles.css +1 -1
  22. package/dist/web-ui/embedded-assets.js +2 -2
  23. package/dist/web-ui/index.js +1 -0
  24. package/package.json +4 -2
  25. package/vendor/codex-taskboard/LICENSE +201 -0
  26. package/vendor/codex-taskboard/server/ai-chat-catalog.mjs +1031 -0
  27. package/vendor/codex-taskboard/server/ai-chat-process.mjs +462 -0
  28. package/vendor/codex-taskboard/server/ai-chat.mjs +1237 -0
  29. package/vendor/codex-taskboard/server/ai-turn-owner.mjs +39 -0
  30. package/vendor/codex-taskboard/server/app.mjs +3211 -0
  31. package/vendor/codex-taskboard/server/cloud-config.mjs +188 -0
  32. package/vendor/codex-taskboard/server/cloud-proxy.mjs +310 -0
  33. package/vendor/codex-taskboard/server/codex-app-server.mjs +376 -0
  34. package/vendor/codex-taskboard/server/codex-slash-commands-0.139.0.json +62 -0
  35. package/vendor/codex-taskboard/server/database.mjs +2845 -0
  36. package/vendor/codex-taskboard/server/index.mjs +41 -0
  37. package/vendor/codex-taskboard/server/jira-config.mjs +181 -0
  38. package/vendor/codex-taskboard/server/jira-integration.mjs +487 -0
  39. package/vendor/codex-taskboard/server/project-summary.mjs +159 -0
  40. package/vendor/codex-taskboard/shared/api-fields.mjs +163 -0
  41. package/vendor/codex-taskboard/shared/codex-environment.mjs +5 -0
  42. package/vendor/codex-taskboard/shared/codex-executable.mjs +77 -0
  43. package/vendor/codex-taskboard/shared/codex-permissions.mjs +9 -0
  44. package/vendor/codex-taskboard/shared/composer-reference.d.mts +19 -0
  45. package/vendor/codex-taskboard/shared/composer-reference.mjs +90 -0
  46. package/vendor/codex-taskboard/shared/domain.mjs +34 -0
  47. package/vendor/codex-taskboard/shared/executable-command.mjs +13 -0
  48. package/vendor/codex-taskboard/shared/process-tree.mjs +27 -0
  49. package/vendor/codex-taskboard/shared/task-input.mjs +308 -0
  50. package/vendor/codex-taskboard/shared/task-records.mjs +199 -0
  51. package/vendor/codex-taskboard/shared/task-relations.mjs +55 -0
  52. package/vendor/codex-taskboard/shared/taskboard-automation.mjs +277 -0
  53. package/vendor/codex-taskboard/skills/manage-taskboard/SKILL.md +45 -0
  54. package/vendor/codex-taskboard/skills/manage-taskboard/agents/openai.yaml +4 -0
  55. package/vendor/codex-taskboard/skills/manage-taskboard/references/cli.md +211 -0
@@ -0,0 +1,308 @@
1
+ import { DEFAULT_PROJECT_ID, isTaskStatus } from "./domain.mjs";
2
+ import {
3
+ ApiError, assertPlainObject, assertAllowedKeys, stringField,
4
+ parseVersion, validateProjectId, parseThreadId, parseAssigneeTarget,
5
+ parseRelationOrigin, parseStatus, parsePriority, parseLabels,
6
+ parseSortOrder, parseDueDate, parseRecurrence,
7
+ } from "./api-fields.mjs";
8
+
9
+ export function parseThreadBinding(value) {
10
+ if (value === undefined || value === null) return value;
11
+ assertPlainObject(value);
12
+ assertAllowedKeys(value, new Set([
13
+ "threadId",
14
+ "codexProjectId",
15
+ "codexProjectKind",
16
+ "codexHostId",
17
+ "workspacePath",
18
+ ]));
19
+ const threadId = stringField(value.threadId, "threadBinding.threadId", {
20
+ required: true,
21
+ maxLength: 256,
22
+ });
23
+ const identityFields = [
24
+ value.codexProjectId,
25
+ value.codexProjectKind,
26
+ value.codexHostId,
27
+ value.workspacePath,
28
+ ];
29
+ if (identityFields.every((field) => field === undefined)) return { threadId };
30
+ if (identityFields.some((field) => field === undefined)) {
31
+ throw new ApiError(400, "INVALID_FIELD", "Thread identity must include project, kind, host, and workspace");
32
+ }
33
+ const codexProjectId = stringField(value.codexProjectId, "threadBinding.codexProjectId", {
34
+ required: true,
35
+ maxLength: 256,
36
+ });
37
+ const codexProjectKind = value.codexProjectKind;
38
+ const codexHostId = stringField(value.codexHostId, "threadBinding.codexHostId", {
39
+ required: true,
40
+ maxLength: 256,
41
+ });
42
+ const workspacePath = stringField(value.workspacePath, "threadBinding.workspacePath", {
43
+ required: true,
44
+ maxLength: 4096,
45
+ });
46
+ if (codexProjectKind !== "local" && codexProjectKind !== "remote") {
47
+ throw new ApiError(400, "INVALID_FIELD", "threadBinding.codexProjectKind must be local or remote");
48
+ }
49
+ if (
50
+ (codexProjectKind === "local" && codexHostId !== "local")
51
+ || (codexProjectKind === "remote" && codexHostId === "local")
52
+ || workspacePath.includes("\0")
53
+ ) {
54
+ throw new ApiError(400, "INVALID_FIELD", "Thread project identity is invalid");
55
+ }
56
+ return { threadId, codexProjectId, codexProjectKind, codexHostId, workspacePath };
57
+ }
58
+
59
+ export function parseMove(body) {
60
+ assertPlainObject(body);
61
+ assertAllowedKeys(body, new Set(["version", "status", "sortOrder", "threadId", "threadBinding"]));
62
+ return {
63
+ version: parseVersion(body.version),
64
+ status: parseStatus(body.status),
65
+ sortOrder: body.sortOrder === undefined ? undefined : parseSortOrder(body.sortOrder),
66
+ threadId: parseThreadId(body.threadId),
67
+ threadBinding: parseThreadBinding(body.threadBinding),
68
+ };
69
+ }
70
+
71
+ export function parseVersionMutation(body) {
72
+ assertPlainObject(body);
73
+ assertAllowedKeys(body, new Set(["version", "threadId", "threadBinding"]));
74
+ return {
75
+ version: parseVersion(body.version),
76
+ threadId: parseThreadId(body.threadId),
77
+ threadBinding: parseThreadBinding(body.threadBinding),
78
+ };
79
+ }
80
+
81
+ export function parseRelationMutation(body) {
82
+ assertPlainObject(body);
83
+ assertAllowedKeys(body, new Set(["version", "threadId", "threadBinding", "origin"]));
84
+ return {
85
+ version: parseVersion(body.version),
86
+ threadId: parseThreadId(body.threadId),
87
+ threadBinding: parseThreadBinding(body.threadBinding),
88
+ origin: parseRelationOrigin(body.origin),
89
+ };
90
+ }
91
+
92
+ export function parseCommentCreate(body) {
93
+ assertPlainObject(body);
94
+ assertAllowedKeys(body, new Set(["body", "threadId", "threadBinding"]));
95
+ return {
96
+ body: stringField(body.body ?? "", "body", { maxLength: 100_000 }),
97
+ threadId: parseThreadId(body.threadId),
98
+ threadBinding: parseThreadBinding(body.threadBinding),
99
+ };
100
+ }
101
+
102
+ export function parseCommentPatch(body) {
103
+ assertPlainObject(body);
104
+ assertAllowedKeys(body, new Set(["version", "body", "threadId", "threadBinding"]));
105
+ if (body.body === undefined) {
106
+ throw new ApiError(400, "INVALID_FIELD", "'body' is required");
107
+ }
108
+ return {
109
+ version: parseVersion(body.version),
110
+ body: stringField(body.body, "body", { maxLength: 100_000 }),
111
+ threadId: parseThreadId(body.threadId),
112
+ threadBinding: parseThreadBinding(body.threadBinding),
113
+ };
114
+ }
115
+
116
+ export function parseTaskCreate(body, parseDevelopmentContext) {
117
+ assertPlainObject(body);
118
+ assertAllowedKeys(body, new Set([
119
+ "projectId", "title", "description", "status", "priority", "labels", "sortOrder", "threadId", "threadBinding",
120
+ "assigneeTarget", "developmentContext", "startDate", "dueDate", "recurrence",
121
+ ]));
122
+ const projectId = validateProjectId(body.projectId ?? DEFAULT_PROJECT_ID);
123
+ const task = {
124
+ projectId,
125
+ title: stringField(body.title, "title", { required: true, maxLength: 240 }),
126
+ description: stringField(body.description ?? "", "description", { maxLength: 100_000 }),
127
+ status: parseStatus(body.status, "backlog"),
128
+ priority: parsePriority(body.priority, "none"),
129
+ labels: body.labels === undefined ? [] : parseLabels(body.labels),
130
+ sortOrder: body.sortOrder === undefined ? undefined : parseSortOrder(body.sortOrder),
131
+ threadId: parseThreadId(body.threadId),
132
+ threadBinding: parseThreadBinding(body.threadBinding),
133
+ assigneeTarget: parseAssigneeTarget(body.assigneeTarget),
134
+ developmentContext: parseDevelopmentContext(body.developmentContext ?? null),
135
+ startDate: parseDueDate(body.startDate ?? null, "startDate"),
136
+ dueDate: parseDueDate(body.dueDate ?? null),
137
+ recurrence: parseRecurrence(body.recurrence ?? null),
138
+ };
139
+ if (task.recurrence && !task.dueDate) {
140
+ throw new ApiError(400, "INVALID_FIELD", "A recurring issue requires 'dueDate'");
141
+ }
142
+ return task;
143
+ }
144
+
145
+ // Local and cloud entry points retain their existing validation order and errors.
146
+ function assertTaskPatchBody(body) {
147
+ assertPlainObject(body);
148
+ assertAllowedKeys(body, new Set([
149
+ "version", "projectId", "title", "description", "status", "priority", "labels", "threadId", "threadBinding",
150
+ "assigneeTarget", "developmentContext", "startDate", "dueDate", "recurrence",
151
+ ]));
152
+ }
153
+
154
+ function parseTaskPatchChanges(body, parseDevelopmentContext) {
155
+ const changes = {};
156
+ if (body.projectId !== undefined) changes.projectId = validateProjectId(body.projectId);
157
+ if (body.title !== undefined) changes.title = stringField(body.title, "title", { required: true, maxLength: 240 });
158
+ if (body.description !== undefined) changes.description = stringField(body.description, "description", { maxLength: 100_000 });
159
+ if (body.status !== undefined) changes.status = parseStatus(body.status);
160
+ if (body.priority !== undefined) changes.priority = parsePriority(body.priority);
161
+ if (body.labels !== undefined) changes.labels = parseLabels(body.labels);
162
+ if (body.developmentContext !== undefined) changes.developmentContext = parseDevelopmentContext(body.developmentContext);
163
+ if (body.startDate !== undefined) changes.startDate = parseDueDate(body.startDate, "startDate");
164
+ if (body.dueDate !== undefined) changes.dueDate = parseDueDate(body.dueDate);
165
+ if (body.recurrence !== undefined) changes.recurrence = parseRecurrence(body.recurrence);
166
+ return changes;
167
+ }
168
+
169
+ function assertTaskPatchFields(changes, assigneeTarget) {
170
+ if (Object.keys(changes).length === 0 && assigneeTarget === undefined) {
171
+ throw new ApiError(400, "INVALID_BODY", "PATCH requires at least one task field");
172
+ }
173
+ }
174
+
175
+ export function parseTaskPatch(body, parseDevelopmentContext) {
176
+ assertTaskPatchBody(body);
177
+ const version = parseVersion(body.version);
178
+ const threadId = parseThreadId(body.threadId);
179
+ const threadBinding = parseThreadBinding(body.threadBinding);
180
+ const assigneeTarget = parseAssigneeTarget(body.assigneeTarget);
181
+ const changes = parseTaskPatchChanges(body, parseDevelopmentContext);
182
+ if (changes.recurrence && body.dueDate === null) {
183
+ throw new ApiError(400, "INVALID_FIELD", "A recurring issue requires 'dueDate'");
184
+ }
185
+ assertTaskPatchFields(changes, assigneeTarget);
186
+ return { version, changes, threadId, threadBinding, assigneeTarget };
187
+ }
188
+
189
+ export function parseCloudTaskPatch(body, parseDevelopmentContext) {
190
+ assertTaskPatchBody(body);
191
+ const changes = parseTaskPatchChanges(body, parseDevelopmentContext);
192
+ const assigneeTarget = parseAssigneeTarget(body.assigneeTarget);
193
+ assertTaskPatchFields(changes, assigneeTarget);
194
+ return {
195
+ version: parseVersion(body.version),
196
+ changes,
197
+ threadId: parseThreadId(body.threadId),
198
+ threadBinding: parseThreadBinding(body.threadBinding),
199
+ assigneeTarget,
200
+ };
201
+ }
202
+
203
+ function assertTaskQueryParameters(searchParams, allowed, repeatedCode, cloud = false) {
204
+ for (const key of searchParams.keys()) {
205
+ if (!allowed.has(key)) {
206
+ throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", cloud
207
+ ? `Unknown query parameter: ${key}`
208
+ : `Unknown query parameter '${key}'`);
209
+ }
210
+ const count = searchParams.getAll(key).length;
211
+ if (cloud ? count > 1 : count !== 1) {
212
+ throw new ApiError(400, repeatedCode, cloud
213
+ ? `'${key}' cannot be repeated`
214
+ : `Query parameter '${key}' cannot be repeated`);
215
+ }
216
+ }
217
+ }
218
+
219
+ function taskFilterValues(searchParams) {
220
+ return {
221
+ projectId: searchParams.get("projectId"),
222
+ status: searchParams.get("status"),
223
+ archived: searchParams.get("archived") ?? "false",
224
+ };
225
+ }
226
+
227
+ export function parseTaskFilters(searchParams) {
228
+ assertTaskQueryParameters(
229
+ searchParams, new Set(["projectId", "status", "archived"]), "INVALID_QUERY_PARAMETER",
230
+ );
231
+
232
+ const { projectId: projectIdValue, status: statusValue, archived } = taskFilterValues(searchParams);
233
+ if (statusValue !== null && !isTaskStatus(statusValue)) {
234
+ throw new ApiError(400, "INVALID_QUERY_PARAMETER", "Invalid task status");
235
+ }
236
+ if (!new Set(["true", "false", "all"]).has(archived)) {
237
+ throw new ApiError(400, "INVALID_QUERY_PARAMETER", "'archived' must be true, false, or all");
238
+ }
239
+ const projectId = projectIdValue === null ? undefined : validateProjectId(projectIdValue);
240
+ return { projectId, status: statusValue ?? undefined, archived };
241
+ }
242
+
243
+ export function parseCloudTaskFilters(searchParams) {
244
+ assertTaskQueryParameters(
245
+ searchParams, new Set(["projectId", "status", "archived"]), "INVALID_QUERY_PARAMETER", true,
246
+ );
247
+ const { projectId, status, archived } = taskFilterValues(searchParams);
248
+ if (projectId !== null) validateProjectId(projectId);
249
+ if (status !== null) parseStatus(status);
250
+ if (!["false", "true", "all"].includes(archived)) {
251
+ throw new ApiError(
252
+ 400,
253
+ "INVALID_QUERY_PARAMETER",
254
+ "'archived' must be false, true, or all",
255
+ );
256
+ }
257
+ return { projectId, status, archived };
258
+ }
259
+
260
+ function taskTreeQueryValues(searchParams) {
261
+ const direction = searchParams.get("direction");
262
+ if (direction !== "descendants" && direction !== "ancestors") {
263
+ throw new ApiError(400, "INVALID_TREE_QUERY", "'direction' must be descendants or ancestors");
264
+ }
265
+ const rawDepth = searchParams.get("depth");
266
+ const depth = Number(rawDepth);
267
+ if (!/^\d+$/.test(rawDepth ?? "") || !Number.isSafeInteger(depth) || depth < 1 || depth > 25) {
268
+ throw new ApiError(400, "INVALID_TREE_QUERY", "'depth' must be an integer from 1 to 25");
269
+ }
270
+ return { direction, depth };
271
+ }
272
+
273
+ export function parseTaskTreeQuery(searchParams) {
274
+ assertTaskQueryParameters(searchParams, new Set(["direction", "depth"]), "INVALID_TREE_QUERY");
275
+ return taskTreeQueryValues(searchParams);
276
+ }
277
+
278
+ export function parseCloudTaskTreeQuery(searchParams) {
279
+ assertTaskQueryParameters(searchParams, new Set(["direction", "depth"]), "INVALID_TREE_QUERY", true);
280
+ return taskTreeQueryValues(searchParams);
281
+ }
282
+
283
+ function projectReadmeContent(value) {
284
+ const content = value ?? "";
285
+ if (typeof content !== "string") {
286
+ throw new ApiError(400, "INVALID_FIELD", "'content' must be a string");
287
+ }
288
+ if (content.length > 500_000) {
289
+ throw new ApiError(400, "INVALID_FIELD", "'content' cannot exceed 500000 characters");
290
+ }
291
+ return content;
292
+ }
293
+
294
+ export function parseProjectReadmeSave(body) {
295
+ assertPlainObject(body);
296
+ assertAllowedKeys(body, new Set(["content", "version"]));
297
+ const content = projectReadmeContent(body.content);
298
+ const version = body.version === undefined ? undefined : parseVersion(body.version, { allowZero: true });
299
+ return { content, version };
300
+ }
301
+
302
+ export function parseCloudProjectReadmeSave(body) {
303
+ assertPlainObject(body);
304
+ assertAllowedKeys(body, new Set(["version", "content"]));
305
+ const version = body.version === undefined ? undefined : parseVersion(body.version, { allowZero: true });
306
+ const content = projectReadmeContent(body.content);
307
+ return { content, version };
308
+ }
@@ -0,0 +1,199 @@
1
+ export function commentConversationTitle(body) {
2
+ const firstLine = String(body ?? "")
3
+ .split(/\r?\n/)
4
+ .map((line) => line.trim())
5
+ .find(Boolean);
6
+ if (!firstLine) return "评论";
7
+ const compact = firstLine.replace(/\s+/g, " ");
8
+ return compact.length > 80 ? `${compact.slice(0, 77)}…` : compact;
9
+ }
10
+
11
+ export function threadBindingFromRow(row) {
12
+ if (
13
+ !row.thread_id
14
+ || !row.thread_codex_project_id
15
+ || !row.thread_codex_project_kind
16
+ || !row.thread_codex_host_id
17
+ || !row.thread_workspace_path
18
+ ) return null;
19
+ return {
20
+ threadId: row.thread_id,
21
+ codexProjectId: row.thread_codex_project_id,
22
+ codexProjectKind: row.thread_codex_project_kind,
23
+ codexHostId: row.thread_codex_host_id,
24
+ workspacePath: row.thread_workspace_path,
25
+ };
26
+ }
27
+
28
+ export function legacyLocalThreadIdFromRow(row) {
29
+ if (!row.thread_id) return null;
30
+ return [
31
+ row.thread_codex_project_id,
32
+ row.thread_codex_project_kind,
33
+ row.thread_codex_host_id,
34
+ row.thread_workspace_path,
35
+ ].every((value) => value == null)
36
+ ? row.thread_id
37
+ : null;
38
+ }
39
+
40
+ export function storedThreadBinding(threadBinding, threadId) {
41
+ if (threadBinding === undefined && (threadId === undefined || threadId === null)) return undefined;
42
+ const binding = threadBinding === undefined ? { threadId } : threadBinding;
43
+ return [
44
+ binding?.threadId ?? null,
45
+ binding?.codexProjectId ?? null,
46
+ binding?.codexProjectKind ?? null,
47
+ binding?.codexHostId ?? null,
48
+ binding?.workspacePath ?? null,
49
+ ];
50
+ }
51
+
52
+ export function attachTaskActivity(task, comments, activities, previewImage = null) {
53
+ const orderedComments = [...comments].sort((left, right) => (
54
+ left.id.localeCompare(right.id)
55
+ ));
56
+ const orderedActivities = [...activities].sort((left, right) => (
57
+ left.id.localeCompare(right.id)
58
+ ));
59
+ const participants = [];
60
+ const participantIds = new Set();
61
+ const addParticipant = (actor) => {
62
+ const key = `${actor.type}:${actor.id}`;
63
+ if (participantIds.has(key)) return;
64
+ participantIds.add(key);
65
+ participants.push(actor);
66
+ };
67
+ addParticipant({
68
+ type: task.creatorType,
69
+ id: task.creatorId,
70
+ name: task.creatorName,
71
+ avatarUrl: task.creatorAvatarUrl,
72
+ });
73
+ addParticipant(task.assignee);
74
+ for (const comment of orderedComments) {
75
+ addParticipant({
76
+ type: comment.author_type,
77
+ id: comment.author_id,
78
+ name: comment.author_name,
79
+ avatarUrl: comment.author_avatar_url,
80
+ });
81
+ }
82
+ for (const activity of orderedActivities) {
83
+ addParticipant({
84
+ type: activity.actor_type,
85
+ id: activity.actor_id,
86
+ name: activity.actor_name,
87
+ avatarUrl: activity.actor_avatar_url,
88
+ });
89
+ }
90
+ const conversationRefs = [];
91
+ if (task.threadBinding) {
92
+ conversationRefs.push({
93
+ ...task.threadBinding,
94
+ source: "task",
95
+ sourceId: task.id,
96
+ title: task.title,
97
+ updatedAt: task.updatedAt,
98
+ });
99
+ } else if (task.legacyLocalThreadId) {
100
+ conversationRefs.push({
101
+ threadId: task.legacyLocalThreadId,
102
+ legacyLocal: true,
103
+ source: "task",
104
+ sourceId: task.id,
105
+ title: task.title,
106
+ updatedAt: task.updatedAt,
107
+ });
108
+ }
109
+ for (const comment of orderedComments) {
110
+ const threadBinding = threadBindingFromRow(comment);
111
+ const legacyLocalThreadId = legacyLocalThreadIdFromRow(comment);
112
+ if (threadBinding || legacyLocalThreadId) {
113
+ conversationRefs.push({
114
+ ...(threadBinding ?? { threadId: legacyLocalThreadId, legacyLocal: true }),
115
+ source: "comment",
116
+ sourceId: comment.id,
117
+ title: commentConversationTitle(comment.body),
118
+ updatedAt: comment.updated_at,
119
+ });
120
+ }
121
+ }
122
+
123
+ task.conversationRefs = conversationRefs;
124
+ task.participants = participants;
125
+ task.previewImage = previewImage;
126
+ task.activityKey = JSON.stringify({
127
+ version: 1,
128
+ task: [task.id, task.version, task.updatedAt],
129
+ comments: orderedComments.map((comment) => [comment.id, comment.version, comment.updated_at]),
130
+ changes: orderedActivities.map((activity) => [activity.id, activity.created_at]),
131
+ });
132
+ task.activityUpdatedAt = [...orderedComments, ...orderedActivities].reduce(
133
+ (latest, activity) => {
134
+ const updatedAt = activity.updated_at ?? activity.created_at;
135
+ return updatedAt > latest ? updatedAt : latest;
136
+ },
137
+ task.updatedAt,
138
+ );
139
+ return task;
140
+ }
141
+
142
+ export function taskActivityFromRow(row) {
143
+ return {
144
+ id: row.id,
145
+ taskId: row.task_id,
146
+ actorType: row.actor_type,
147
+ actorId: row.actor_id,
148
+ actorName: row.actor_name,
149
+ actorAvatarUrl: row.actor_avatar_url,
150
+ changes: JSON.parse(row.changes),
151
+ createdAt: row.created_at,
152
+ };
153
+ }
154
+
155
+ export function taskFieldChanges(task, changes) {
156
+ return Object.entries(changes).flatMap(([field, after]) => {
157
+ const before = task[field];
158
+ return JSON.stringify(before) === JSON.stringify(after)
159
+ ? []
160
+ : [{ field, before, after }];
161
+ });
162
+ }
163
+
164
+ export function taskTreeNode(row, parentId, depth, path) {
165
+ return {
166
+ id: row.id,
167
+ parentId,
168
+ depth,
169
+ path,
170
+ summary: {
171
+ identifier: row.identifier,
172
+ title: row.title,
173
+ status: row.status,
174
+ priority: row.priority,
175
+ archivedAt: row.archived_at,
176
+ },
177
+ };
178
+ }
179
+
180
+ export function projectReadmeAttachmentFromRow(row) {
181
+ return {
182
+ id: row.id,
183
+ projectId: row.project_id,
184
+ kind: "inline",
185
+ filename: row.filename,
186
+ contentType: row.content_type,
187
+ size: row.size,
188
+ createdAt: row.created_at,
189
+ };
190
+ }
191
+
192
+ export function projectPrefix(project) {
193
+ const idPrefix = project.id.toUpperCase().replace(/[^A-Z0-9]+/g, "").slice(0, 12) || "TASK";
194
+ const existingPrefix = project.first_identifier?.replace(/-\d+$/, "");
195
+ if (existingPrefix && /^[A-Z0-9]+$/i.test(existingPrefix) && existingPrefix !== idPrefix) return existingPrefix;
196
+ if (idPrefix.length <= 5) return idPrefix;
197
+ const namePrefix = project.name.toUpperCase().replace(/[^A-Z0-9]+/g, "").slice(0, 3);
198
+ return namePrefix || idPrefix.slice(0, 3);
199
+ }
@@ -0,0 +1,55 @@
1
+ // Select both endpoints once per ID batch; the outer order also interleaves
2
+ // incoming and outgoing "related" edges in the original task order.
3
+ export function taskRelationsQuery(placeholders) {
4
+ return `
5
+ WITH selected_tasks AS (
6
+ SELECT id FROM tasks WHERE id IN (${placeholders})
7
+ ), relation_targets AS (
8
+ SELECT
9
+ selected_tasks.id AS task_id,
10
+ task_relations.target_task_id AS related_task_id,
11
+ CASE task_relations.relation_type
12
+ WHEN 'parent' THEN 'subIssues'
13
+ WHEN 'blocks' THEN 'blocks'
14
+ WHEN 'related' THEN 'related'
15
+ END AS relation_field
16
+ FROM selected_tasks
17
+ JOIN task_relations ON task_relations.source_task_id = selected_tasks.id
18
+ UNION ALL
19
+ SELECT
20
+ selected_tasks.id AS task_id,
21
+ task_relations.source_task_id AS related_task_id,
22
+ CASE task_relations.relation_type
23
+ WHEN 'parent' THEN 'parent'
24
+ WHEN 'blocks' THEN 'blockedBy'
25
+ WHEN 'related' THEN 'related'
26
+ END AS relation_field
27
+ FROM selected_tasks
28
+ JOIN task_relations ON task_relations.target_task_id = selected_tasks.id
29
+ )
30
+ SELECT tasks.*, relation_targets.task_id AS relation_task_id, relation_targets.relation_field
31
+ FROM relation_targets
32
+ JOIN tasks ON tasks.id = relation_targets.related_task_id
33
+ ORDER BY tasks.sort_order, tasks.created_at, tasks.id
34
+ `;
35
+ }
36
+
37
+ export function taskRelationsFromRows(taskIds, rows, summarizeTask) {
38
+ const relationsByTask = new Map(taskIds.map((taskId) => [taskId, {
39
+ parent: null,
40
+ subIssues: [],
41
+ blockedBy: [],
42
+ blocks: [],
43
+ related: [],
44
+ }]));
45
+ for (const row of rows) {
46
+ const relations = relationsByTask.get(row.relation_task_id);
47
+ const summary = summarizeTask(row);
48
+ if (row.relation_field === "parent") {
49
+ relations.parent = summary;
50
+ } else {
51
+ relations[row.relation_field].push(summary);
52
+ }
53
+ }
54
+ return relationsByTask;
55
+ }