flowcat 1.3.0 → 1.4.4
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/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/index.mjs +5219 -2722
- package/package.json +23 -7
|
@@ -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
|
+
};
|
package/core/time.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { DateTime } from "luxon";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_ZONE = "Europe/Paris";
|
|
4
|
+
|
|
5
|
+
type ListDateScope = "today" | "yesterday" | "this_week" | "last_week";
|
|
6
|
+
|
|
7
|
+
const toIsoDate = (value: DateTime, label: string): string => {
|
|
8
|
+
const date = value.toISODate();
|
|
9
|
+
if (!date) {
|
|
10
|
+
throw new Error(`Failed to generate ${label} date`);
|
|
11
|
+
}
|
|
12
|
+
return date;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const buildDateRange = (start: DateTime, end: DateTime): string[] => {
|
|
16
|
+
const dates: string[] = [];
|
|
17
|
+
let cursor = start.startOf("day");
|
|
18
|
+
const final = end.startOf("day");
|
|
19
|
+
|
|
20
|
+
while (cursor <= final) {
|
|
21
|
+
dates.push(toIsoDate(cursor, "range"));
|
|
22
|
+
cursor = cursor.plus({ days: 1 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return dates;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const nowIso = (): string => {
|
|
29
|
+
const value = DateTime.now().setZone(DEFAULT_ZONE).toISO();
|
|
30
|
+
if (!value) {
|
|
31
|
+
throw new Error("Failed to generate timestamp");
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const todayDate = (date?: string): string => {
|
|
37
|
+
const value = date
|
|
38
|
+
? DateTime.fromISO(date, { zone: DEFAULT_ZONE }).toISODate()
|
|
39
|
+
: DateTime.now().setZone(DEFAULT_ZONE).toISODate();
|
|
40
|
+
|
|
41
|
+
if (!value) {
|
|
42
|
+
throw new Error("Failed to generate date");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return value;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const listDatesForScope = (scope: ListDateScope): string[] => {
|
|
49
|
+
const now = DateTime.now().setZone(DEFAULT_ZONE);
|
|
50
|
+
|
|
51
|
+
switch (scope) {
|
|
52
|
+
case "today":
|
|
53
|
+
return [toIsoDate(now, "today")];
|
|
54
|
+
case "yesterday":
|
|
55
|
+
return [toIsoDate(now.minus({ days: 1 }), "yesterday")];
|
|
56
|
+
case "this_week":
|
|
57
|
+
return buildDateRange(now.startOf("week"), now.endOf("week"));
|
|
58
|
+
case "last_week": {
|
|
59
|
+
const lastWeek = now.minus({ weeks: 1 });
|
|
60
|
+
return buildDateRange(lastWeek.startOf("week"), lastWeek.endOf("week"));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
package/core/types.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const taskStatuses = ["backlog", "active", "paused", "completed", "cancelled"] as const;
|
|
2
|
+
export const taskTypes = ["regular", "review"] as const;
|
|
3
|
+
export const taskActions = ["start", "pause", "complete", "cancel"] as const;
|
|
4
|
+
|
|
5
|
+
export type TaskStatus = (typeof taskStatuses)[number];
|
|
6
|
+
export type TaskType = (typeof taskTypes)[number];
|
|
7
|
+
export type TaskAction = (typeof taskActions)[number];
|