flowcat 1.3.0 → 1.5.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.
- package/core/addTask.ts +121 -0
- package/core/aliasReconcile.ts +41 -0
- package/core/aliasRepo.ts +92 -0
- package/core/autoCommit.ts +84 -0
- package/core/commitMessage.ts +95 -0
- package/core/config.ts +110 -0
- package/core/constants.ts +8 -0
- package/core/dayAssignments.ts +198 -0
- package/core/edit.ts +59 -0
- package/core/format.ts +63 -0
- package/core/fsm.ts +28 -0
- package/core/git.ts +117 -0
- package/core/gitignore.ts +30 -0
- package/core/id.ts +41 -0
- package/core/initFlow.ts +122 -0
- package/core/json.ts +13 -0
- package/core/lock.ts +31 -0
- package/core/plugin.ts +99 -0
- package/core/pluginErrors.ts +26 -0
- package/core/pluginLoader.ts +222 -0
- package/core/pluginManager.ts +75 -0
- package/core/pluginRunner.ts +217 -0
- package/core/pr.ts +89 -0
- package/core/prWorkflow.ts +185 -0
- package/core/schemas/dayAssignment.ts +11 -0
- package/core/schemas/logEntry.ts +11 -0
- package/core/schemas/pr.ts +29 -0
- package/core/schemas/task.ts +41 -0
- package/core/search.ts +25 -0
- package/core/taskFactory.ts +29 -0
- package/core/taskOperations.ts +104 -0
- package/core/taskRepo.ts +109 -0
- package/core/taskResolver.ts +44 -0
- package/core/taskStore.ts +14 -0
- package/core/time.ts +63 -0
- package/core/types.ts +7 -0
- package/core/workspace.ts +133 -0
- package/dist/fc.mjs +87532 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/index.mjs +5221 -2724
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/package.json +25 -9
package/core/pr.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Octokit } from "octokit";
|
|
2
|
+
|
|
3
|
+
import type { PrAttachment, PrFetched } from "./schemas/pr";
|
|
4
|
+
import { nowIso } from "./time";
|
|
5
|
+
|
|
6
|
+
export type ParsedPr = {
|
|
7
|
+
url: string;
|
|
8
|
+
provider: "github";
|
|
9
|
+
repo: {
|
|
10
|
+
host: string;
|
|
11
|
+
owner: string;
|
|
12
|
+
name: string;
|
|
13
|
+
};
|
|
14
|
+
number: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const parseGitHubPrUrl = (value: string): ParsedPr | null => {
|
|
18
|
+
let url: URL;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(value);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
26
|
+
if (parts.length < 4 || parts[2] !== "pull") {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const number = Number(parts[3]);
|
|
31
|
+
if (!Number.isInteger(number)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
url: value,
|
|
37
|
+
provider: "github",
|
|
38
|
+
repo: {
|
|
39
|
+
host: url.host,
|
|
40
|
+
owner: parts[0],
|
|
41
|
+
name: parts[1],
|
|
42
|
+
},
|
|
43
|
+
number,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const buildPrAttachment = (parsed: ParsedPr, fetched?: PrFetched): PrAttachment => {
|
|
48
|
+
return {
|
|
49
|
+
url: parsed.url,
|
|
50
|
+
provider: "github",
|
|
51
|
+
repo: parsed.repo,
|
|
52
|
+
number: parsed.number,
|
|
53
|
+
fetched,
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const fetchGitHubPr = async (
|
|
58
|
+
parsed: ParsedPr,
|
|
59
|
+
token?: string | null,
|
|
60
|
+
): Promise<PrFetched | null> => {
|
|
61
|
+
if (!parsed.repo || !parsed.number) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!token) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const octokit = new Octokit({ auth: token });
|
|
70
|
+
const response = await octokit.rest.pulls.get({
|
|
71
|
+
owner: parsed.repo.owner,
|
|
72
|
+
repo: parsed.repo.name,
|
|
73
|
+
pull_number: parsed.number,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const data = response.data;
|
|
77
|
+
const state = data.merged ? "merged" : data.state === "open" ? "open" : "closed";
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
at: nowIso(),
|
|
81
|
+
title: data.title,
|
|
82
|
+
author: {
|
|
83
|
+
login: data.user?.login ?? "unknown",
|
|
84
|
+
},
|
|
85
|
+
state,
|
|
86
|
+
draft: data.draft ?? false,
|
|
87
|
+
updated_at: data.updated_at,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { resolveGithubToken } from "./config";
|
|
2
|
+
import { withWorkspaceLock } from "./lock";
|
|
3
|
+
import { emitHook } from "./pluginManager";
|
|
4
|
+
import { buildPrAttachment, fetchGitHubPr, parseGitHubPrUrl } from "./pr";
|
|
5
|
+
import type { PrFetched } from "./schemas/pr";
|
|
6
|
+
import type { Task } from "./schemas/task";
|
|
7
|
+
import { taskSchema } from "./schemas/task";
|
|
8
|
+
import { appendLog, applyTransition } from "./taskOperations";
|
|
9
|
+
import { moveTaskFile, saveTask } from "./taskRepo";
|
|
10
|
+
import { resolveTask } from "./taskResolver";
|
|
11
|
+
import { nowIso } from "./time";
|
|
12
|
+
import type { TaskAction, TaskStatus } from "./types";
|
|
13
|
+
|
|
14
|
+
export class PrWorkflowError extends Error {
|
|
15
|
+
code: "INVALID_PR_URL" | "MISSING_TOKEN";
|
|
16
|
+
|
|
17
|
+
constructor(code: "INVALID_PR_URL" | "MISSING_TOKEN", message: string) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.name = "PrWorkflowError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type AttachPrResult = {
|
|
25
|
+
status: "attached";
|
|
26
|
+
task: Task;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type RefreshPrResult =
|
|
30
|
+
| { status: "no_pr" }
|
|
31
|
+
| { status: "refreshed"; task: Task; nextStatus: TaskStatus; transitioned: boolean };
|
|
32
|
+
|
|
33
|
+
export type PrTransitionDecision = {
|
|
34
|
+
action?: TaskAction;
|
|
35
|
+
message?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const resolvePrTransition = (input: {
|
|
39
|
+
taskStatus: TaskStatus;
|
|
40
|
+
previousState?: PrFetched["state"];
|
|
41
|
+
nextState?: PrFetched["state"];
|
|
42
|
+
message?: string;
|
|
43
|
+
}): PrTransitionDecision => {
|
|
44
|
+
const isTerminal = input.taskStatus === "completed" || input.taskStatus === "cancelled";
|
|
45
|
+
if (isTerminal || input.previousState === input.nextState) {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (input.nextState === "merged") {
|
|
50
|
+
return { action: "complete", message: input.message ?? "PR merged" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (input.nextState === "closed") {
|
|
54
|
+
return { action: "cancel", message: input.message ?? "PR closed without merge" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const attachPrToTask = async (input: {
|
|
61
|
+
workspaceRoot: string;
|
|
62
|
+
identifier: string;
|
|
63
|
+
url: string;
|
|
64
|
+
message?: string;
|
|
65
|
+
}): Promise<AttachPrResult> => {
|
|
66
|
+
return withWorkspaceLock(input.workspaceRoot, async () => {
|
|
67
|
+
const parsed = parseGitHubPrUrl(input.url);
|
|
68
|
+
if (!parsed) {
|
|
69
|
+
throw new PrWorkflowError("INVALID_PR_URL", "Invalid PR URL");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const token = await resolveGithubToken(input.workspaceRoot);
|
|
73
|
+
let fetched: PrFetched | null = null;
|
|
74
|
+
if (token) {
|
|
75
|
+
try {
|
|
76
|
+
fetched = await fetchGitHubPr(parsed, token);
|
|
77
|
+
} catch {
|
|
78
|
+
fetched = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const stored = await resolveTask(input.workspaceRoot, input.identifier);
|
|
82
|
+
|
|
83
|
+
const updated: Task = {
|
|
84
|
+
...stored.task,
|
|
85
|
+
metadata: {
|
|
86
|
+
...stored.task.metadata,
|
|
87
|
+
pr: buildPrAttachment(parsed, fetched ?? stored.task.metadata.pr?.fetched),
|
|
88
|
+
},
|
|
89
|
+
updated_at: nowIso(),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const logMessage = input.message ?? `Attached PR ${input.url}`;
|
|
93
|
+
let next = appendLog(updated, logMessage);
|
|
94
|
+
await saveTask(input.workspaceRoot, stored.status, next);
|
|
95
|
+
|
|
96
|
+
// Emit hook and apply task modifications from plugins
|
|
97
|
+
const hookResult = await emitHook(input.workspaceRoot, "pr:attached", {
|
|
98
|
+
task: next,
|
|
99
|
+
prUrl: input.url,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (hookResult.task) {
|
|
103
|
+
taskSchema.parse(hookResult.task);
|
|
104
|
+
await saveTask(input.workspaceRoot, stored.status, hookResult.task);
|
|
105
|
+
next = hookResult.task;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { status: "attached", task: next };
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const refreshPrForTask = async (input: {
|
|
113
|
+
workspaceRoot: string;
|
|
114
|
+
identifier: string;
|
|
115
|
+
message?: string;
|
|
116
|
+
}): Promise<RefreshPrResult> => {
|
|
117
|
+
return withWorkspaceLock(input.workspaceRoot, async () => {
|
|
118
|
+
const stored = await resolveTask(input.workspaceRoot, input.identifier);
|
|
119
|
+
const prUrl = stored.task.metadata.pr?.url;
|
|
120
|
+
if (!prUrl) {
|
|
121
|
+
return { status: "no_pr" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const parsed = parseGitHubPrUrl(prUrl);
|
|
125
|
+
if (!parsed) {
|
|
126
|
+
throw new Error("Invalid PR URL");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const token = await resolveGithubToken(input.workspaceRoot);
|
|
130
|
+
const fetched = await fetchGitHubPr(parsed, token);
|
|
131
|
+
if (!fetched) {
|
|
132
|
+
throw new PrWorkflowError("MISSING_TOKEN", "No GitHub token configured");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const baseUpdated: Task = {
|
|
136
|
+
...stored.task,
|
|
137
|
+
metadata: {
|
|
138
|
+
...stored.task.metadata,
|
|
139
|
+
pr: buildPrAttachment(parsed, fetched),
|
|
140
|
+
},
|
|
141
|
+
updated_at: nowIso(),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const decision = resolvePrTransition({
|
|
145
|
+
taskStatus: stored.task.status,
|
|
146
|
+
previousState: stored.task.metadata.pr?.fetched?.state,
|
|
147
|
+
nextState: fetched.state,
|
|
148
|
+
message: input.message,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let finalTask = baseUpdated;
|
|
152
|
+
let nextStatus = stored.status;
|
|
153
|
+
|
|
154
|
+
if (decision.action) {
|
|
155
|
+
const result = applyTransition(baseUpdated, decision.action, decision.message);
|
|
156
|
+
finalTask = result.task;
|
|
157
|
+
nextStatus = result.nextStatus;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await saveTask(input.workspaceRoot, stored.status, finalTask);
|
|
161
|
+
if (nextStatus !== stored.status) {
|
|
162
|
+
await moveTaskFile(input.workspaceRoot, finalTask.id, stored.status, nextStatus);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Emit hook and apply task modifications from plugins
|
|
166
|
+
const hookResult = await emitHook(input.workspaceRoot, "pr:refreshed", {
|
|
167
|
+
task: finalTask,
|
|
168
|
+
previousState: stored.task.metadata.pr?.fetched?.state,
|
|
169
|
+
newState: fetched.state,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (hookResult.task) {
|
|
173
|
+
taskSchema.parse(hookResult.task);
|
|
174
|
+
await saveTask(input.workspaceRoot, nextStatus, hookResult.task);
|
|
175
|
+
finalTask = hookResult.task;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
status: "refreshed",
|
|
180
|
+
task: finalTask,
|
|
181
|
+
nextStatus,
|
|
182
|
+
transitioned: nextStatus !== stored.status,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const dayAssignmentSchema = z.object({
|
|
4
|
+
date: z.string(),
|
|
5
|
+
action: z.enum(["assign", "unassign", "reorder"]),
|
|
6
|
+
ts: z.string(),
|
|
7
|
+
order: z.number().int().positive().optional(),
|
|
8
|
+
msg: z.string().optional(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export type DayAssignment = z.infer<typeof dayAssignmentSchema>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { taskStatuses } from "../types";
|
|
4
|
+
|
|
5
|
+
export const logEntrySchema = z.object({
|
|
6
|
+
ts: z.string(),
|
|
7
|
+
msg: z.string(),
|
|
8
|
+
status: z.enum(taskStatuses).optional(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export type LogEntry = z.infer<typeof logEntrySchema>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const prRepoSchema = z.object({
|
|
4
|
+
host: z.string(),
|
|
5
|
+
owner: z.string(),
|
|
6
|
+
name: z.string(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const prFetchedSchema = z.object({
|
|
10
|
+
at: z.string(),
|
|
11
|
+
title: z.string(),
|
|
12
|
+
author: z.object({
|
|
13
|
+
login: z.string(),
|
|
14
|
+
}),
|
|
15
|
+
state: z.enum(["open", "closed", "merged"]),
|
|
16
|
+
draft: z.boolean(),
|
|
17
|
+
updated_at: z.string(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const prAttachmentSchema = z.object({
|
|
21
|
+
url: z.string(),
|
|
22
|
+
provider: z.literal("github"),
|
|
23
|
+
repo: prRepoSchema.optional(),
|
|
24
|
+
number: z.number().int().positive().optional(),
|
|
25
|
+
fetched: prFetchedSchema.optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export type PrFetched = z.infer<typeof prFetchedSchema>;
|
|
29
|
+
export type PrAttachment = z.infer<typeof prAttachmentSchema>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { taskStatuses, taskTypes } from "../types";
|
|
4
|
+
import { dayAssignmentSchema } from "./dayAssignment";
|
|
5
|
+
import { logEntrySchema } from "./logEntry";
|
|
6
|
+
import { prAttachmentSchema } from "./pr";
|
|
7
|
+
|
|
8
|
+
export const taskMetadataSchema = z.object({
|
|
9
|
+
url: z.string().optional(),
|
|
10
|
+
pr: prAttachmentSchema.optional(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const taskSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
ref: z.string(),
|
|
17
|
+
type: z.enum(taskTypes),
|
|
18
|
+
title: z.string(),
|
|
19
|
+
status: z.enum(taskStatuses),
|
|
20
|
+
created_at: z.string(),
|
|
21
|
+
updated_at: z.string(),
|
|
22
|
+
metadata: taskMetadataSchema,
|
|
23
|
+
logs: z.array(logEntrySchema),
|
|
24
|
+
day_assignments: z.array(dayAssignmentSchema),
|
|
25
|
+
})
|
|
26
|
+
.superRefine((task, ctx) => {
|
|
27
|
+
if (task.type === "review" && !task.metadata.pr?.url) {
|
|
28
|
+
ctx.addIssue({
|
|
29
|
+
code: z.ZodIssueCode.custom,
|
|
30
|
+
message: "Review tasks require metadata.pr.url",
|
|
31
|
+
path: ["metadata", "pr", "url"],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type TaskMetadata = z.infer<typeof taskMetadataSchema>;
|
|
37
|
+
export type Task = z.infer<typeof taskSchema>;
|
|
38
|
+
|
|
39
|
+
export const validateTaskOrThrow = (task: Task): Task => {
|
|
40
|
+
return taskSchema.parse(task);
|
|
41
|
+
};
|
package/core/search.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Task } from "./schemas/task";
|
|
2
|
+
|
|
3
|
+
export const taskMatchesQuery = (task: Task, query: string): boolean => {
|
|
4
|
+
const needle = query.toLowerCase();
|
|
5
|
+
const contains = (value?: string): boolean =>
|
|
6
|
+
value ? value.toLowerCase().includes(needle) : false;
|
|
7
|
+
|
|
8
|
+
if (contains(task.title)) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (task.logs.some((log) => contains(log.msg))) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (contains(task.metadata.pr?.url)) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (contains(task.metadata.pr?.fetched?.title)) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return false;
|
|
25
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { generateStableRef, generateTaskId } from "./id";
|
|
2
|
+
import type { Task, TaskMetadata } from "./schemas/task";
|
|
3
|
+
import { nowIso } from "./time";
|
|
4
|
+
import type { TaskType } from "./types";
|
|
5
|
+
|
|
6
|
+
export type NewTaskInput = {
|
|
7
|
+
type: TaskType;
|
|
8
|
+
title: string;
|
|
9
|
+
metadata?: TaskMetadata;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const buildTask = (input: NewTaskInput): Task => {
|
|
13
|
+
const id = generateTaskId(input.type);
|
|
14
|
+
const ref = generateStableRef(input.type, id);
|
|
15
|
+
const timestamp = nowIso();
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
id,
|
|
19
|
+
ref,
|
|
20
|
+
type: input.type,
|
|
21
|
+
title: input.title,
|
|
22
|
+
status: "backlog",
|
|
23
|
+
created_at: timestamp,
|
|
24
|
+
updated_at: timestamp,
|
|
25
|
+
metadata: input.metadata ?? {},
|
|
26
|
+
logs: [],
|
|
27
|
+
day_assignments: [],
|
|
28
|
+
};
|
|
29
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { getNextStatus } from "./fsm";
|
|
2
|
+
import type { DayAssignment } from "./schemas/dayAssignment";
|
|
3
|
+
import type { LogEntry } from "./schemas/logEntry";
|
|
4
|
+
import type { Task } from "./schemas/task";
|
|
5
|
+
import { nowIso } from "./time";
|
|
6
|
+
import type { TaskAction, TaskStatus } from "./types";
|
|
7
|
+
|
|
8
|
+
const defaultMessages: Record<TaskAction, string> = {
|
|
9
|
+
start: "Started task",
|
|
10
|
+
pause: "Paused task",
|
|
11
|
+
complete: "Completed task",
|
|
12
|
+
cancel: "Cancelled task",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const appendLog = (task: Task, message: string, status?: TaskStatus): Task => {
|
|
16
|
+
const log: LogEntry = {
|
|
17
|
+
ts: nowIso(),
|
|
18
|
+
msg: message,
|
|
19
|
+
status,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
...task,
|
|
24
|
+
logs: [...task.logs, log],
|
|
25
|
+
updated_at: nowIso(),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const applyTransition = (
|
|
30
|
+
task: Task,
|
|
31
|
+
action: TaskAction,
|
|
32
|
+
message?: string,
|
|
33
|
+
): { task: Task; nextStatus: TaskStatus } => {
|
|
34
|
+
const nextStatus = getNextStatus(task.status, action);
|
|
35
|
+
const logMessage = message ?? defaultMessages[action];
|
|
36
|
+
const log: LogEntry = {
|
|
37
|
+
ts: nowIso(),
|
|
38
|
+
msg: logMessage,
|
|
39
|
+
status: nextStatus,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const updated: Task = {
|
|
43
|
+
...task,
|
|
44
|
+
status: nextStatus,
|
|
45
|
+
updated_at: nowIso(),
|
|
46
|
+
logs: [...task.logs, log],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return { task: updated, nextStatus };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const isAssignedOnDate = (task: Task, date: string): boolean => {
|
|
53
|
+
const latest = getLatestDayAssignment(task, date);
|
|
54
|
+
if (!latest) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return latest.event.action === "assign" || latest.event.action === "reorder";
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const getLatestDayAssignment = (
|
|
62
|
+
task: Task,
|
|
63
|
+
date: string,
|
|
64
|
+
): { event: DayAssignment; index: number } | null => {
|
|
65
|
+
let latestIndex = -1;
|
|
66
|
+
for (let i = 0; i < task.day_assignments.length; i += 1) {
|
|
67
|
+
if (task.day_assignments[i]?.date === date) {
|
|
68
|
+
latestIndex = i;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (latestIndex === -1) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const event = task.day_assignments[latestIndex];
|
|
77
|
+
if (!event) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { event, index: latestIndex };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const appendDayAssignment = (
|
|
85
|
+
task: Task,
|
|
86
|
+
date: string,
|
|
87
|
+
action: DayAssignment["action"],
|
|
88
|
+
msg?: string,
|
|
89
|
+
order?: number,
|
|
90
|
+
): Task => {
|
|
91
|
+
const event: DayAssignment = {
|
|
92
|
+
date,
|
|
93
|
+
action,
|
|
94
|
+
ts: nowIso(),
|
|
95
|
+
order,
|
|
96
|
+
msg,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
...task,
|
|
101
|
+
day_assignments: [...task.day_assignments, event],
|
|
102
|
+
updated_at: nowIso(),
|
|
103
|
+
};
|
|
104
|
+
};
|
package/core/taskRepo.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { access, readdir, rename } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { STATUS_ORDER, TASKS_DIR } from "./constants";
|
|
5
|
+
import { readJsonFile, writeJsonAtomic } from "./json";
|
|
6
|
+
import type { Task } from "./schemas/task";
|
|
7
|
+
import { taskSchema } from "./schemas/task";
|
|
8
|
+
import type { TaskStatus } from "./types";
|
|
9
|
+
|
|
10
|
+
export type StoredTask = {
|
|
11
|
+
task: Task;
|
|
12
|
+
status: TaskStatus;
|
|
13
|
+
filePath: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const taskFileName = (taskId: string): string => `${taskId}.json`;
|
|
17
|
+
|
|
18
|
+
const getStatusDir = (workspaceRoot: string, status: TaskStatus): string =>
|
|
19
|
+
path.join(workspaceRoot, TASKS_DIR, status);
|
|
20
|
+
|
|
21
|
+
export const getTaskPath = (workspaceRoot: string, status: TaskStatus, taskId: string): string => {
|
|
22
|
+
return path.join(getStatusDir(workspaceRoot, status), taskFileName(taskId));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const fileExists = async (filePath: string): Promise<boolean> => {
|
|
26
|
+
try {
|
|
27
|
+
await access(filePath);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const findTaskPathById = async (
|
|
35
|
+
workspaceRoot: string,
|
|
36
|
+
taskId: string,
|
|
37
|
+
): Promise<{ filePath: string; status: TaskStatus } | null> => {
|
|
38
|
+
for (const status of STATUS_ORDER) {
|
|
39
|
+
const candidate = getTaskPath(workspaceRoot, status, taskId);
|
|
40
|
+
if (await fileExists(candidate)) {
|
|
41
|
+
return { filePath: candidate, status };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const loadTaskFromPath = async (filePath: string): Promise<Task> => {
|
|
49
|
+
const parsed = await readJsonFile<Task>(filePath);
|
|
50
|
+
return taskSchema.parse(parsed);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const loadTaskById = async (workspaceRoot: string, taskId: string): Promise<StoredTask> => {
|
|
54
|
+
const found = await findTaskPathById(workspaceRoot, taskId);
|
|
55
|
+
if (!found) {
|
|
56
|
+
throw new Error(`Task not found: ${taskId}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const task = await loadTaskFromPath(found.filePath);
|
|
60
|
+
return { task, status: found.status, filePath: found.filePath };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const listTasksByStatus = async (
|
|
64
|
+
workspaceRoot: string,
|
|
65
|
+
status: TaskStatus,
|
|
66
|
+
): Promise<StoredTask[]> => {
|
|
67
|
+
const statusDir = getStatusDir(workspaceRoot, status);
|
|
68
|
+
const entries = await readdir(statusDir, { withFileTypes: true });
|
|
69
|
+
const tasks = await Promise.all(
|
|
70
|
+
entries
|
|
71
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
72
|
+
.map(async (entry) => {
|
|
73
|
+
const filePath = path.join(statusDir, entry.name);
|
|
74
|
+
const task = await loadTaskFromPath(filePath);
|
|
75
|
+
return { task, status, filePath };
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return tasks;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const listAllTasks = async (workspaceRoot: string): Promise<StoredTask[]> => {
|
|
83
|
+
const all = await Promise.all(
|
|
84
|
+
STATUS_ORDER.map((status) => listTasksByStatus(workspaceRoot, status)),
|
|
85
|
+
);
|
|
86
|
+
return all.flat();
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const saveTask = async (
|
|
90
|
+
workspaceRoot: string,
|
|
91
|
+
status: TaskStatus,
|
|
92
|
+
task: Task,
|
|
93
|
+
): Promise<string> => {
|
|
94
|
+
const filePath = getTaskPath(workspaceRoot, status, task.id);
|
|
95
|
+
await writeJsonAtomic(filePath, task);
|
|
96
|
+
return filePath;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const moveTaskFile = async (
|
|
100
|
+
workspaceRoot: string,
|
|
101
|
+
taskId: string,
|
|
102
|
+
fromStatus: TaskStatus,
|
|
103
|
+
toStatus: TaskStatus,
|
|
104
|
+
): Promise<string> => {
|
|
105
|
+
const fromPath = getTaskPath(workspaceRoot, fromStatus, taskId);
|
|
106
|
+
const toPath = getTaskPath(workspaceRoot, toStatus, taskId);
|
|
107
|
+
await rename(fromPath, toPath);
|
|
108
|
+
return toPath;
|
|
109
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { resolveAlias } from "./aliasRepo";
|
|
2
|
+
import type { StoredTask } from "./taskRepo";
|
|
3
|
+
import { findTaskPathById, listAllTasks, loadTaskFromPath } from "./taskRepo";
|
|
4
|
+
|
|
5
|
+
const stableRefPattern = /^[TR]-[A-Z0-9]{6}$/;
|
|
6
|
+
|
|
7
|
+
export const resolveTaskId = async (workspaceRoot: string, identifier: string): Promise<string> => {
|
|
8
|
+
const aliasMatch = await resolveAlias(workspaceRoot, identifier);
|
|
9
|
+
if (aliasMatch) {
|
|
10
|
+
return aliasMatch;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (stableRefPattern.test(identifier)) {
|
|
14
|
+
const tasks = await listAllTasks(workspaceRoot);
|
|
15
|
+
const match = tasks.find((stored) => stored.task.ref === identifier);
|
|
16
|
+
if (!match) {
|
|
17
|
+
throw new Error(`Task not found for ref: ${identifier}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return match.task.id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const found = await findTaskPathById(workspaceRoot, identifier);
|
|
24
|
+
if (!found) {
|
|
25
|
+
throw new Error(`Task not found: ${identifier}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return identifier;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const resolveTask = async (
|
|
32
|
+
workspaceRoot: string,
|
|
33
|
+
identifier: string,
|
|
34
|
+
): Promise<StoredTask> => {
|
|
35
|
+
const taskId = await resolveTaskId(workspaceRoot, identifier);
|
|
36
|
+
const found = await findTaskPathById(workspaceRoot, taskId);
|
|
37
|
+
|
|
38
|
+
if (!found) {
|
|
39
|
+
throw new Error(`Task not found: ${identifier}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const task = await loadTaskFromPath(found.filePath);
|
|
43
|
+
return { task, status: found.status, filePath: found.filePath };
|
|
44
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { allocateAlias, writeAliases } from "./aliasRepo";
|
|
2
|
+
import type { Task } from "./schemas/task";
|
|
3
|
+
import { saveTask } from "./taskRepo";
|
|
4
|
+
|
|
5
|
+
export const createTaskInStore = async (
|
|
6
|
+
workspaceRoot: string,
|
|
7
|
+
task: Task,
|
|
8
|
+
): Promise<{ alias: string; task: Task }> => {
|
|
9
|
+
const prefix = task.type === "review" ? "R" : "T";
|
|
10
|
+
const { alias, aliases } = await allocateAlias(workspaceRoot, prefix, task.id);
|
|
11
|
+
await writeAliases(workspaceRoot, aliases);
|
|
12
|
+
await saveTask(workspaceRoot, task.status, task);
|
|
13
|
+
return { alias, task };
|
|
14
|
+
};
|