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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { withWorkspaceLock } from "./lock";
|
|
2
|
+
import type { DayAssignment } from "./schemas/dayAssignment";
|
|
3
|
+
import type { Task } from "./schemas/task";
|
|
4
|
+
import { appendDayAssignment, getLatestDayAssignment, isAssignedOnDate } from "./taskOperations";
|
|
5
|
+
import type { StoredTask } from "./taskRepo";
|
|
6
|
+
import { listAllTasks, saveTask } from "./taskRepo";
|
|
7
|
+
import { resolveTask } from "./taskResolver";
|
|
8
|
+
|
|
9
|
+
export type OrderedAssignment = {
|
|
10
|
+
stored: StoredTask;
|
|
11
|
+
lastEvent: DayAssignment;
|
|
12
|
+
lastIndex: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type AssignToDateResult =
|
|
16
|
+
| { status: "assigned"; task: Task; order: number }
|
|
17
|
+
| { status: "already_assigned"; task: Task };
|
|
18
|
+
|
|
19
|
+
export type MoveAssignmentResult =
|
|
20
|
+
| { status: "moved"; task: Task; position: number }
|
|
21
|
+
| { status: "no_change"; task: Task; position: number }
|
|
22
|
+
| { status: "not_assigned"; task: Task };
|
|
23
|
+
|
|
24
|
+
export type UnassignResult =
|
|
25
|
+
| { status: "unassigned"; task: Task }
|
|
26
|
+
| { status: "already_unassigned"; task: Task };
|
|
27
|
+
|
|
28
|
+
type MovePosition = "up" | "down" | "top" | "bottom";
|
|
29
|
+
type MovePositionInput = MovePosition | number | string;
|
|
30
|
+
|
|
31
|
+
export class DayAssignmentError extends Error {
|
|
32
|
+
code: "INVALID_POSITION" | "POSITION_OUT_OF_RANGE";
|
|
33
|
+
|
|
34
|
+
constructor(code: "INVALID_POSITION" | "POSITION_OUT_OF_RANGE", message: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.name = "DayAssignmentError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const collectAssignments = (tasks: StoredTask[], date: string): OrderedAssignment[] => {
|
|
42
|
+
const entries: OrderedAssignment[] = [];
|
|
43
|
+
for (const stored of tasks) {
|
|
44
|
+
const latest = getLatestDayAssignment(stored.task, date);
|
|
45
|
+
if (!latest) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (latest.event.action === "assign" || latest.event.action === "reorder") {
|
|
50
|
+
entries.push({ stored, lastEvent: latest.event, lastIndex: latest.index });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return entries;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const sortAssignments = (entries: OrderedAssignment[]): OrderedAssignment[] => {
|
|
58
|
+
return [...entries].sort((a, b) => {
|
|
59
|
+
const orderA = a.lastEvent.order;
|
|
60
|
+
const orderB = b.lastEvent.order;
|
|
61
|
+
if (orderA != null && orderB != null) {
|
|
62
|
+
return orderA - orderB;
|
|
63
|
+
}
|
|
64
|
+
if (orderA != null) {
|
|
65
|
+
return -1;
|
|
66
|
+
}
|
|
67
|
+
if (orderB != null) {
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
return a.lastIndex - b.lastIndex;
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const buildOrderedAssignments = (tasks: StoredTask[], date: string): OrderedAssignment[] => {
|
|
75
|
+
const entries = collectAssignments(tasks, date);
|
|
76
|
+
return sortAssignments(entries);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const resolveMoveTarget = (
|
|
80
|
+
position: MovePositionInput,
|
|
81
|
+
currentIndex: number,
|
|
82
|
+
total: number,
|
|
83
|
+
): number => {
|
|
84
|
+
if (position === "up") {
|
|
85
|
+
return Math.max(0, currentIndex - 1);
|
|
86
|
+
}
|
|
87
|
+
if (position === "down") {
|
|
88
|
+
return Math.min(total - 1, currentIndex + 1);
|
|
89
|
+
}
|
|
90
|
+
if (position === "top") {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
if (position === "bottom") {
|
|
94
|
+
return total - 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const parsed = typeof position === "number" ? position : Number(position);
|
|
98
|
+
if (!Number.isInteger(parsed)) {
|
|
99
|
+
throw new DayAssignmentError("INVALID_POSITION", `Invalid position: ${position}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (parsed < 1 || parsed > total) {
|
|
103
|
+
throw new DayAssignmentError("POSITION_OUT_OF_RANGE", `Position out of range: ${position}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return parsed - 1;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const assignTaskToDate = async (input: {
|
|
110
|
+
workspaceRoot: string;
|
|
111
|
+
identifier: string;
|
|
112
|
+
date: string;
|
|
113
|
+
message?: string;
|
|
114
|
+
}): Promise<AssignToDateResult> => {
|
|
115
|
+
return withWorkspaceLock(input.workspaceRoot, async () => {
|
|
116
|
+
const tasks = await listAllTasks(input.workspaceRoot);
|
|
117
|
+
const stored = await resolveTask(input.workspaceRoot, input.identifier);
|
|
118
|
+
if (isAssignedOnDate(stored.task, input.date)) {
|
|
119
|
+
return { status: "already_assigned", task: stored.task };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ordered = buildOrderedAssignments(tasks, input.date);
|
|
123
|
+
const order = ordered.length + 1;
|
|
124
|
+
const updated = appendDayAssignment(stored.task, input.date, "assign", input.message, order);
|
|
125
|
+
await saveTask(input.workspaceRoot, stored.status, updated);
|
|
126
|
+
return { status: "assigned", task: updated, order };
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const moveAssignmentForDate = async (input: {
|
|
131
|
+
workspaceRoot: string;
|
|
132
|
+
identifier: string;
|
|
133
|
+
date: string;
|
|
134
|
+
position: MovePositionInput;
|
|
135
|
+
}): Promise<MoveAssignmentResult> => {
|
|
136
|
+
return withWorkspaceLock(input.workspaceRoot, async () => {
|
|
137
|
+
const stored = await resolveTask(input.workspaceRoot, input.identifier);
|
|
138
|
+
const tasks = await listAllTasks(input.workspaceRoot);
|
|
139
|
+
const ordered = buildOrderedAssignments(tasks, input.date);
|
|
140
|
+
const currentIndex = ordered.findIndex((entry) => entry.stored.task.id === stored.task.id);
|
|
141
|
+
|
|
142
|
+
if (currentIndex === -1) {
|
|
143
|
+
return { status: "not_assigned", task: stored.task };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const targetIndex = resolveMoveTarget(input.position, currentIndex, ordered.length);
|
|
147
|
+
if (targetIndex === currentIndex) {
|
|
148
|
+
return { status: "no_change", task: stored.task, position: currentIndex + 1 };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const reordered = [...ordered];
|
|
152
|
+
const [moved] = reordered.splice(currentIndex, 1);
|
|
153
|
+
if (!moved) {
|
|
154
|
+
return { status: "not_assigned", task: stored.task };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
reordered.splice(targetIndex, 0, moved);
|
|
158
|
+
|
|
159
|
+
let updatedTask = stored.task;
|
|
160
|
+
for (const [index, entry] of reordered.entries()) {
|
|
161
|
+
const nextOrder = index + 1;
|
|
162
|
+
if (entry.lastEvent.order === nextOrder) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const updated = appendDayAssignment(
|
|
167
|
+
entry.stored.task,
|
|
168
|
+
input.date,
|
|
169
|
+
"reorder",
|
|
170
|
+
undefined,
|
|
171
|
+
nextOrder,
|
|
172
|
+
);
|
|
173
|
+
await saveTask(input.workspaceRoot, entry.stored.status, updated);
|
|
174
|
+
if (entry.stored.task.id === stored.task.id) {
|
|
175
|
+
updatedTask = updated;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { status: "moved", task: updatedTask, position: targetIndex + 1 };
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const unassignTaskFromDate = async (input: {
|
|
184
|
+
workspaceRoot: string;
|
|
185
|
+
identifier: string;
|
|
186
|
+
date: string;
|
|
187
|
+
}): Promise<UnassignResult> => {
|
|
188
|
+
return withWorkspaceLock(input.workspaceRoot, async () => {
|
|
189
|
+
const stored = await resolveTask(input.workspaceRoot, input.identifier);
|
|
190
|
+
if (!isAssignedOnDate(stored.task, input.date)) {
|
|
191
|
+
return { status: "already_unassigned", task: stored.task };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const updated = appendDayAssignment(stored.task, input.date, "unassign");
|
|
195
|
+
await saveTask(input.workspaceRoot, stored.status, updated);
|
|
196
|
+
return { status: "unassigned", task: updated };
|
|
197
|
+
});
|
|
198
|
+
};
|
package/core/edit.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { buildPrAttachment, parseGitHubPrUrl } from "./pr";
|
|
2
|
+
import type { Task } from "./schemas/task";
|
|
3
|
+
import { validateTaskOrThrow } from "./schemas/task";
|
|
4
|
+
import { nowIso } from "./time";
|
|
5
|
+
|
|
6
|
+
const parseValue = (raw: string): unknown => {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(raw);
|
|
9
|
+
} catch {
|
|
10
|
+
return raw;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const applyTaskEdit = (task: Task, dottedPath: string, rawValue: string): Task => {
|
|
15
|
+
if (dottedPath === "status" || dottedPath.startsWith("status.")) {
|
|
16
|
+
throw new Error("Use status commands to change status");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const value = parseValue(rawValue);
|
|
20
|
+
const updated = structuredClone(task);
|
|
21
|
+
const segments = dottedPath.split(".");
|
|
22
|
+
|
|
23
|
+
let current: Record<string, unknown> = updated as unknown as Record<string, unknown>;
|
|
24
|
+
for (const segment of segments.slice(0, -1)) {
|
|
25
|
+
if (!(segment in current)) {
|
|
26
|
+
current[segment] = {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const next = current[segment];
|
|
30
|
+
if (typeof next !== "object" || next === null || Array.isArray(next)) {
|
|
31
|
+
throw new Error(`Cannot set ${dottedPath} on non-object path`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
current = next as Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const last = segments[segments.length - 1];
|
|
38
|
+
current[last] = value;
|
|
39
|
+
|
|
40
|
+
if (dottedPath === "metadata.pr.url") {
|
|
41
|
+
if (typeof value !== "string") {
|
|
42
|
+
throw new Error("metadata.pr.url must be a string");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parsed = parseGitHubPrUrl(value);
|
|
46
|
+
if (!parsed) {
|
|
47
|
+
throw new Error("Invalid PR URL");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
updated.metadata.pr = buildPrAttachment(parsed, updated.metadata.pr?.fetched);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const nextTask: Task = {
|
|
54
|
+
...updated,
|
|
55
|
+
updated_at: nowIso(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return validateTaskOrThrow(nextTask);
|
|
59
|
+
};
|
package/core/format.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AliasMap } from "./aliasRepo";
|
|
2
|
+
import type { Task } from "./schemas/task";
|
|
3
|
+
|
|
4
|
+
export const buildAliasLookup = (aliases: AliasMap): Map<string, string> => {
|
|
5
|
+
const map = new Map<string, string>();
|
|
6
|
+
for (const [key, value] of Object.entries(aliases.T)) {
|
|
7
|
+
map.set(value, `T${key}`);
|
|
8
|
+
}
|
|
9
|
+
for (const [key, value] of Object.entries(aliases.R)) {
|
|
10
|
+
map.set(value, `R${key}`);
|
|
11
|
+
}
|
|
12
|
+
return map;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const formatTaskListLine = (task: Task, alias?: string): string => {
|
|
16
|
+
const aliasText = alias ?? "--";
|
|
17
|
+
return `${aliasText.padEnd(4)} ${task.ref.padEnd(9)} ${task.status.padEnd(9)} ${task.title}`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const formatTaskDetails = (task: Task, alias?: string): string => {
|
|
21
|
+
const lines: string[] = [];
|
|
22
|
+
lines.push(`${alias ?? "--"} ${task.ref} ${task.title}`);
|
|
23
|
+
lines.push(`id: ${task.id}`);
|
|
24
|
+
lines.push(`type: ${task.type}`);
|
|
25
|
+
lines.push(`status: ${task.status}`);
|
|
26
|
+
lines.push(`created: ${task.created_at}`);
|
|
27
|
+
lines.push(`updated: ${task.updated_at}`);
|
|
28
|
+
|
|
29
|
+
if (task.metadata.url) {
|
|
30
|
+
lines.push(`url: ${task.metadata.url}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (task.metadata.pr?.url) {
|
|
34
|
+
lines.push(`pr: ${task.metadata.pr.url}`);
|
|
35
|
+
if (task.metadata.pr.fetched) {
|
|
36
|
+
const fetched = task.metadata.pr.fetched;
|
|
37
|
+
lines.push(`pr_state: ${fetched.state}`);
|
|
38
|
+
lines.push(`pr_title: ${fetched.title}`);
|
|
39
|
+
lines.push(`pr_author: ${fetched.author.login}`);
|
|
40
|
+
lines.push(`pr_updated_at: ${fetched.updated_at}`);
|
|
41
|
+
lines.push(`pr_refreshed_at: ${fetched.at}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (task.logs.length > 0) {
|
|
46
|
+
lines.push("logs:");
|
|
47
|
+
for (const log of task.logs) {
|
|
48
|
+
const status = log.status ? ` [${log.status}]` : "";
|
|
49
|
+
lines.push(`- ${log.ts}${status} ${log.msg}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (task.day_assignments.length > 0) {
|
|
54
|
+
lines.push("day_assignments:");
|
|
55
|
+
for (const entry of task.day_assignments) {
|
|
56
|
+
const order = entry.order ? ` order:${entry.order}` : "";
|
|
57
|
+
const msg = entry.msg ? ` (${entry.msg})` : "";
|
|
58
|
+
lines.push(`- ${entry.date} ${entry.action}${order} ${entry.ts}${msg}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
};
|
package/core/fsm.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TaskAction, TaskStatus } from "./types";
|
|
2
|
+
|
|
3
|
+
export const transitionTable: Record<TaskStatus, Partial<Record<TaskAction, TaskStatus>>> = {
|
|
4
|
+
backlog: {
|
|
5
|
+
start: "active",
|
|
6
|
+
cancel: "cancelled",
|
|
7
|
+
},
|
|
8
|
+
active: {
|
|
9
|
+
pause: "paused",
|
|
10
|
+
complete: "completed",
|
|
11
|
+
cancel: "cancelled",
|
|
12
|
+
},
|
|
13
|
+
paused: {
|
|
14
|
+
start: "active",
|
|
15
|
+
complete: "completed",
|
|
16
|
+
cancel: "cancelled",
|
|
17
|
+
},
|
|
18
|
+
completed: {},
|
|
19
|
+
cancelled: {},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const getNextStatus = (current: TaskStatus, action: TaskAction): TaskStatus => {
|
|
23
|
+
const next = transitionTable[current][action];
|
|
24
|
+
if (!next) {
|
|
25
|
+
throw new Error(`Invalid transition: ${current} -> ${action}`);
|
|
26
|
+
}
|
|
27
|
+
return next;
|
|
28
|
+
};
|
package/core/git.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export type GitCommitOptions = {
|
|
8
|
+
message: string;
|
|
9
|
+
files?: string[];
|
|
10
|
+
author?: {
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const isGitRepository = async (dir: string): Promise<boolean> => {
|
|
17
|
+
try {
|
|
18
|
+
await execFileAsync("git", ["-C", dir, "rev-parse", "--git-dir"], {
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
});
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const getGitStatus = async (
|
|
28
|
+
dir: string,
|
|
29
|
+
): Promise<{
|
|
30
|
+
modified: string[];
|
|
31
|
+
added: string[];
|
|
32
|
+
deleted: string[];
|
|
33
|
+
untracked: string[];
|
|
34
|
+
}> => {
|
|
35
|
+
try {
|
|
36
|
+
const { stdout } = await execFileAsync("git", ["-C", dir, "status", "--porcelain"], {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const modified: string[] = [];
|
|
41
|
+
const added: string[] = [];
|
|
42
|
+
const deleted: string[] = [];
|
|
43
|
+
const untracked: string[] = [];
|
|
44
|
+
|
|
45
|
+
for (const line of stdout.trim().split("\n")) {
|
|
46
|
+
if (!line) continue;
|
|
47
|
+
|
|
48
|
+
const status = line.substring(0, 2);
|
|
49
|
+
const filePath = line.substring(3);
|
|
50
|
+
|
|
51
|
+
if (status === "??") {
|
|
52
|
+
untracked.push(filePath);
|
|
53
|
+
} else if (status[0] === "A" || status[1] === "A") {
|
|
54
|
+
added.push(filePath);
|
|
55
|
+
} else if (status[0] === "D" || status[1] === "D") {
|
|
56
|
+
deleted.push(filePath);
|
|
57
|
+
} else if (status[0] === "M" || status[1] === "M") {
|
|
58
|
+
modified.push(filePath);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { modified, added, deleted, untracked };
|
|
63
|
+
} catch {
|
|
64
|
+
return { modified: [], added: [], deleted: [], untracked: [] };
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const stageFiles = async (dir: string, files: string[]): Promise<void> => {
|
|
69
|
+
if (files.length === 0) return;
|
|
70
|
+
|
|
71
|
+
await execFileAsync("git", ["-C", dir, "add", ...files]);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const commitChanges = async (dir: string, options: GitCommitOptions): Promise<void> => {
|
|
75
|
+
const args = ["-C", dir, "commit"];
|
|
76
|
+
|
|
77
|
+
if (options.author) {
|
|
78
|
+
args.push("--author", `${options.author.name} <${options.author.email}>`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
args.push("--message", options.message);
|
|
82
|
+
|
|
83
|
+
await execFileAsync("git", args);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const hasChangesToCommit = async (dir: string): Promise<boolean> => {
|
|
87
|
+
const status = await getGitStatus(dir);
|
|
88
|
+
return (
|
|
89
|
+
status.modified.length > 0 ||
|
|
90
|
+
status.added.length > 0 ||
|
|
91
|
+
status.deleted.length > 0 ||
|
|
92
|
+
status.untracked.length > 0
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const autoCommit = async (dir: string, options: GitCommitOptions): Promise<boolean> => {
|
|
97
|
+
if (!(await isGitRepository(dir))) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!(await hasChangesToCommit(dir))) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
if (options.files) {
|
|
107
|
+
await stageFiles(dir, options.files);
|
|
108
|
+
} else {
|
|
109
|
+
await stageFiles(dir, ["."]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await commitChanges(dir, options);
|
|
113
|
+
return true;
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { APP_DIR } from "./constants";
|
|
5
|
+
|
|
6
|
+
const ignoredEntries = [`${APP_DIR}/.lock/`, `${APP_DIR}/config.json`];
|
|
7
|
+
|
|
8
|
+
export const ensureWorkspaceIgnored = async (repoRoot: string): Promise<boolean> => {
|
|
9
|
+
const gitignorePath = path.join(repoRoot, ".gitignore");
|
|
10
|
+
let current = "";
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
current = await readFile(gitignorePath, "utf8");
|
|
14
|
+
} catch {
|
|
15
|
+
current = "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const lines = current.split("\n");
|
|
19
|
+
const missingEntries = ignoredEntries.filter(
|
|
20
|
+
(entry) => !lines.some((line) => line.trim() === entry),
|
|
21
|
+
);
|
|
22
|
+
if (missingEntries.length === 0) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const separator = current.endsWith("\n") || current.length === 0 ? "" : "\n";
|
|
27
|
+
const next = `${current}${separator}${missingEntries.join("\n")}\n`;
|
|
28
|
+
await writeFile(gitignorePath, next, "utf8");
|
|
29
|
+
return true;
|
|
30
|
+
};
|
package/core/id.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { ulid } from "ulid";
|
|
4
|
+
|
|
5
|
+
import type { TaskType } from "./types";
|
|
6
|
+
|
|
7
|
+
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
8
|
+
|
|
9
|
+
const base32Encode = (input: Uint8Array): string => {
|
|
10
|
+
let bits = 0;
|
|
11
|
+
let value = 0;
|
|
12
|
+
let output = "";
|
|
13
|
+
|
|
14
|
+
for (const byte of input) {
|
|
15
|
+
value = (value << 8) | byte;
|
|
16
|
+
bits += 8;
|
|
17
|
+
|
|
18
|
+
while (bits >= 5) {
|
|
19
|
+
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
|
20
|
+
bits -= 5;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (bits > 0) {
|
|
25
|
+
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return output;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const generateTaskId = (taskType: TaskType): string => {
|
|
32
|
+
const prefix = taskType === "review" ? "R" : "T";
|
|
33
|
+
return `${prefix}${ulid()}`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const generateStableRef = (taskType: TaskType, taskId: string): string => {
|
|
37
|
+
const prefix = taskType === "review" ? "R" : "T";
|
|
38
|
+
const hash = createHash("sha256").update(taskId).digest();
|
|
39
|
+
const encoded = base32Encode(hash).slice(0, 6);
|
|
40
|
+
return `${prefix}-${encoded}`;
|
|
41
|
+
};
|
package/core/initFlow.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
readConfigFile,
|
|
5
|
+
resolveGlobalConfigPath,
|
|
6
|
+
resolveWorkspaceConfigPath,
|
|
7
|
+
updateConfigFile,
|
|
8
|
+
} from "./config";
|
|
9
|
+
import { APP_DIR } from "./constants";
|
|
10
|
+
import { ensureWorkspaceIgnored } from "./gitignore";
|
|
11
|
+
import type { WorkspaceKind } from "./workspace";
|
|
12
|
+
import {
|
|
13
|
+
ensureWorkspaceLayout,
|
|
14
|
+
findGitRoot,
|
|
15
|
+
resolveGlobalWorkspaceRoot,
|
|
16
|
+
} from "./workspace";
|
|
17
|
+
|
|
18
|
+
type InitChoice = {
|
|
19
|
+
workspaceRoot: string;
|
|
20
|
+
kind: WorkspaceKind;
|
|
21
|
+
repoRoot?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type InitFlowOptions = {
|
|
25
|
+
cwd: string;
|
|
26
|
+
store?: string;
|
|
27
|
+
repo?: boolean;
|
|
28
|
+
global?: boolean;
|
|
29
|
+
allowNonInteractiveDefault?: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const resolveOptionalText = (value: unknown): string | null => {
|
|
33
|
+
if (value === null || typeof value === "undefined") {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const trimmed = String(value).trim();
|
|
37
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const selectWorkspace = async (
|
|
41
|
+
cwd: string,
|
|
42
|
+
force?: WorkspaceKind,
|
|
43
|
+
store?: string,
|
|
44
|
+
): Promise<InitChoice> => {
|
|
45
|
+
if (store) {
|
|
46
|
+
return { workspaceRoot: path.resolve(store), kind: "explicit" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const repoRoot = await findGitRoot(cwd);
|
|
50
|
+
if (force === "repo") {
|
|
51
|
+
if (!repoRoot) {
|
|
52
|
+
throw new Error("Not inside a git repository");
|
|
53
|
+
}
|
|
54
|
+
return { workspaceRoot: path.join(repoRoot, APP_DIR), kind: "repo", repoRoot };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (force === "global") {
|
|
58
|
+
return { workspaceRoot: resolveGlobalWorkspaceRoot(), kind: "global" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (repoRoot) {
|
|
62
|
+
return {
|
|
63
|
+
workspaceRoot: path.join(repoRoot, APP_DIR),
|
|
64
|
+
kind: "repo",
|
|
65
|
+
repoRoot: repoRoot ?? undefined,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { workspaceRoot: resolveGlobalWorkspaceRoot(), kind: "global" };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const resolveConfigPath = (choice: InitChoice): string => {
|
|
73
|
+
if (choice.kind === "global") {
|
|
74
|
+
return resolveGlobalConfigPath();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return resolveWorkspaceConfigPath(choice.workspaceRoot);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const runInitFlow = async (options: InitFlowOptions): Promise<InitChoice> => {
|
|
81
|
+
const hasExplicitTarget = Boolean(options.store || options.repo || options.global);
|
|
82
|
+
const allowDefault = options.allowNonInteractiveDefault ?? false;
|
|
83
|
+
|
|
84
|
+
const forceKind = options.repo ? "repo" : options.global ? "global" : undefined;
|
|
85
|
+
let choice: InitChoice;
|
|
86
|
+
|
|
87
|
+
if (!hasExplicitTarget && allowDefault) {
|
|
88
|
+
const repoRoot = await findGitRoot(options.cwd);
|
|
89
|
+
if (repoRoot) {
|
|
90
|
+
choice = { workspaceRoot: path.join(repoRoot, APP_DIR), kind: "repo", repoRoot };
|
|
91
|
+
} else {
|
|
92
|
+
choice = { workspaceRoot: resolveGlobalWorkspaceRoot(), kind: "global" };
|
|
93
|
+
}
|
|
94
|
+
} else if (!hasExplicitTarget) {
|
|
95
|
+
throw new Error("Workspace not initialized. Run `flowcat init` or pass --repo/--global.");
|
|
96
|
+
} else {
|
|
97
|
+
choice = await selectWorkspace(options.cwd, forceKind, options.store);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const configPath = resolveConfigPath(choice);
|
|
101
|
+
const currentConfig = await readConfigFile(configPath);
|
|
102
|
+
|
|
103
|
+
const autoCommit = currentConfig.autoCommit ?? false;
|
|
104
|
+
const githubToken = currentConfig.github?.token ?? null;
|
|
105
|
+
|
|
106
|
+
await ensureWorkspaceLayout(choice.workspaceRoot);
|
|
107
|
+
|
|
108
|
+
await updateConfigFile(configPath, (current) => ({
|
|
109
|
+
...current,
|
|
110
|
+
autoCommit,
|
|
111
|
+
github: {
|
|
112
|
+
...current.github,
|
|
113
|
+
...(githubToken ? { token: githubToken } : {}),
|
|
114
|
+
},
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
if (choice.kind === "repo" && choice.repoRoot) {
|
|
118
|
+
await ensureWorkspaceIgnored(choice.repoRoot);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return choice;
|
|
122
|
+
};
|
package/core/json.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import writeFileAtomic from "write-file-atomic";
|
|
4
|
+
|
|
5
|
+
export const readJsonFile = async <T>(filePath: string): Promise<T> => {
|
|
6
|
+
const raw = await readFile(filePath, "utf8");
|
|
7
|
+
return JSON.parse(raw) as T;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const writeJsonAtomic = async (filePath: string, data: unknown): Promise<void> => {
|
|
11
|
+
const payload = `${JSON.stringify(data, null, 2)}\n`;
|
|
12
|
+
await writeFileAtomic(filePath, payload, { encoding: "utf8", fsync: true });
|
|
13
|
+
};
|