toolcraft 0.0.16 → 0.0.17
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/node_modules/@poe-code/task-list/README.md +98 -0
- package/node_modules/@poe-code/task-list/dist/backends/gh-issues-sync.d.ts +46 -0
- package/node_modules/@poe-code/task-list/dist/backends/gh-issues-sync.js +309 -0
- package/node_modules/@poe-code/task-list/dist/backends/gh-issues.d.ts +2 -0
- package/node_modules/@poe-code/task-list/dist/backends/gh-issues.js +22 -2
- package/node_modules/@poe-code/task-list/dist/backends/markdown-dir.js +266 -99
- package/node_modules/@poe-code/task-list/dist/index.d.ts +1 -0
- package/node_modules/@poe-code/task-list/dist/index.js +1 -0
- package/node_modules/@poe-code/task-list/dist/open.js +3 -0
- package/node_modules/@poe-code/task-list/dist/types.d.ts +4 -0
- package/package.json +2 -2
|
@@ -140,6 +140,104 @@ await project.move(created.id, { after: "42" });
|
|
|
140
140
|
|
|
141
141
|
`gh-issues` exposes one list named `${project.owner}/${project.number}`. `create()` ignores `TaskCreate.id` because GitHub assigns issue numbers. `fire(state)` writes the Project v2 `Status` field to the matching single-select option. `move()` reorders project items.
|
|
142
142
|
|
|
143
|
+
If the GitHub Project v2 board has not been set up manually, run `poe-code tasks sync <list>` before opening the backend. `openTaskList({ type: "gh-issues" })` no longer requires manual board setup when the board was provisioned with `tasks sync` first.
|
|
144
|
+
|
|
145
|
+
## Verifying and provisioning the GitHub Project v2 board
|
|
146
|
+
|
|
147
|
+
Use `verifyGhProject` to check whether a GitHub Project v2 board has the required Project and `Status` single-select options:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { verifyGhProject } from "@poe-code/task-list";
|
|
151
|
+
|
|
152
|
+
const report = await verifyGhProject({
|
|
153
|
+
owner: "octo-org",
|
|
154
|
+
number: 7,
|
|
155
|
+
requiredStates: ["queued", "agent-running", "human-review", "done", "failed", "archived"],
|
|
156
|
+
auth: { token: "github-token" }
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`VerifyGhProjectOptions` has this shape:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
interface VerifyGhProjectOptions {
|
|
164
|
+
owner: string;
|
|
165
|
+
number: number;
|
|
166
|
+
requiredStates: readonly string[];
|
|
167
|
+
client?: GhClient;
|
|
168
|
+
fetch?: typeof fetch;
|
|
169
|
+
auth?: { token: string };
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`verifyGhProject` returns `Promise<VerifyGhProjectReport>`:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
interface VerifyGhProjectReport {
|
|
177
|
+
ok: boolean;
|
|
178
|
+
project: { id: string; number: number; owner: string } | null;
|
|
179
|
+
statusField: { id: string; options: readonly string[] } | null;
|
|
180
|
+
missingProject: boolean;
|
|
181
|
+
missingStatusField: boolean;
|
|
182
|
+
missingOptions: readonly string[];
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Use `syncGhProject` to provision anything missing:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { syncGhProject } from "@poe-code/task-list";
|
|
190
|
+
|
|
191
|
+
const report = await syncGhProject({
|
|
192
|
+
owner: "octo-org",
|
|
193
|
+
number: 7,
|
|
194
|
+
requiredStates: ["queued", "agent-running", "human-review", "done", "failed", "archived"],
|
|
195
|
+
title: "Delivery Board",
|
|
196
|
+
yes: true,
|
|
197
|
+
auth: { token: "github-token" }
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`SyncGhProjectOptions` extends `VerifyGhProjectOptions`:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
interface SyncGhProjectOptions extends VerifyGhProjectOptions {
|
|
205
|
+
title?: string;
|
|
206
|
+
yes?: boolean;
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`syncGhProject` returns `Promise<SyncGhProjectReport>`:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
interface SyncGhProjectReport extends VerifyGhProjectReport {
|
|
214
|
+
created: readonly string[];
|
|
215
|
+
updated: readonly string[];
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The CLI exposes the same verification and provisioning flow:
|
|
220
|
+
|
|
221
|
+
```sh
|
|
222
|
+
poe-code tasks verify <list> --workflow ./WORKFLOW.md --repo octo-org/octo-repo --project octo-org/7 --states queued,agent-running,human-review,done,failed,archived --json
|
|
223
|
+
poe-code tasks sync <list> --workflow ./WORKFLOW.md --repo octo-org/octo-repo --project octo-org/7 --states queued,agent-running,human-review,done,failed,archived --json --yes
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`<list>` and `--project` both use `<owner>/<number>` project syntax. `--workflow` defaults to `./WORKFLOW.md`. `--repo` overrides the task repository from workflow frontmatter. `--states` overrides the required state list from workflow frontmatter. `--json` prints the report object as JSON. `--yes` confirms non-interactive sync; it is only used by `poe-code tasks sync`.
|
|
227
|
+
|
|
228
|
+
| Option | Commands | Behavior |
|
|
229
|
+
| --- | --- | --- |
|
|
230
|
+
| `--workflow <path>` | `verify`, `sync` | Workflow file path. Defaults to `./WORKFLOW.md`. |
|
|
231
|
+
| `--repo <owner/name>` | `verify`, `sync` | GitHub repository owner/name. |
|
|
232
|
+
| `--project <owner/number>` | `verify`, `sync` | GitHub Project v2 owner/number. Overrides `<list>`. |
|
|
233
|
+
| `--states <csv>` | `verify`, `sync` | Required task state names. |
|
|
234
|
+
| `--json` | `verify`, `sync` | Prints the report as JSON. |
|
|
235
|
+
| `--yes` | `sync` | Confirms non-interactive provisioning. |
|
|
236
|
+
|
|
237
|
+
The `Status` field name and option names are matched case-sensitively. The field must be named `Status`, and required option names must match exactly. For example, `status` is treated as a missing field, and `Done` is treated as missing when the required state is `done`.
|
|
238
|
+
|
|
239
|
+
In v1, if sync creates a new GitHub Project v2 board, the CLI prints the new project number and does not rewrite `WORKFLOW.md`. The operator must update `WORKFLOW.md` by hand with the printed `<owner>/<number>`.
|
|
240
|
+
|
|
143
241
|
## Notes
|
|
144
242
|
|
|
145
243
|
The package never overwrites existing task files or store files. `defaults.metadata` is applied only when creating new tasks and does not retroactively update existing tasks.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type GhClient } from "./gh-issues-client.js";
|
|
2
|
+
export interface VerifyGhProjectOptions {
|
|
3
|
+
owner: string;
|
|
4
|
+
number: number;
|
|
5
|
+
requiredStates: readonly string[];
|
|
6
|
+
client?: GhClient;
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
auth?: {
|
|
9
|
+
token: string;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export interface VerifyGhProjectReport {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
project: {
|
|
15
|
+
id: string;
|
|
16
|
+
number: number;
|
|
17
|
+
owner: string;
|
|
18
|
+
} | null;
|
|
19
|
+
statusField: {
|
|
20
|
+
id: string;
|
|
21
|
+
options: readonly string[];
|
|
22
|
+
} | null;
|
|
23
|
+
missingProject: boolean;
|
|
24
|
+
missingStatusField: boolean;
|
|
25
|
+
missingOptions: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
export interface SyncGhProjectOptions extends VerifyGhProjectOptions {
|
|
28
|
+
title?: string;
|
|
29
|
+
yes?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface SyncGhProjectReport extends VerifyGhProjectReport {
|
|
32
|
+
created: readonly string[];
|
|
33
|
+
updated: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
export declare class GhProjectSyncError extends Error {
|
|
36
|
+
readonly op: "lookup" | "createProject" | "createField" | "createOption";
|
|
37
|
+
readonly target: string;
|
|
38
|
+
constructor(options: {
|
|
39
|
+
op: "lookup" | "createProject" | "createField" | "createOption";
|
|
40
|
+
target: string;
|
|
41
|
+
cause?: unknown;
|
|
42
|
+
message: string;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export declare function verifyGhProject(opts: VerifyGhProjectOptions): Promise<VerifyGhProjectReport>;
|
|
46
|
+
export declare function syncGhProject(opts: SyncGhProjectOptions): Promise<SyncGhProjectReport>;
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { PROJECT_ORGANIZATION_QUERY, PROJECT_USER_QUERY } from "./gh-issues.js";
|
|
2
|
+
import { createGhClient } from "./gh-issues-client.js";
|
|
3
|
+
const OWNER_ORGANIZATION_QUERY = `query ProjectOwner($owner: String!) {
|
|
4
|
+
organization(login: $owner) {
|
|
5
|
+
id
|
|
6
|
+
}
|
|
7
|
+
}`;
|
|
8
|
+
const OWNER_USER_QUERY = `query ProjectOwner($owner: String!) {
|
|
9
|
+
user(login: $owner) {
|
|
10
|
+
id
|
|
11
|
+
}
|
|
12
|
+
}`;
|
|
13
|
+
const CREATE_PROJECT_MUTATION = `mutation CreateProject($input: CreateProjectV2Input!) {
|
|
14
|
+
createProjectV2(input: $input) {
|
|
15
|
+
projectV2 {
|
|
16
|
+
id
|
|
17
|
+
number
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}`;
|
|
21
|
+
const CREATE_STATUS_FIELD_MUTATION = `mutation CreateStatusField($input: CreateProjectV2FieldInput!) {
|
|
22
|
+
createProjectV2Field(input: $input) {
|
|
23
|
+
projectV2Field {
|
|
24
|
+
... on ProjectV2SingleSelectField {
|
|
25
|
+
id
|
|
26
|
+
name
|
|
27
|
+
options { id name }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}`;
|
|
32
|
+
const CREATE_STATUS_OPTION_MUTATION = `mutation CreateStatusOption($input: CreateProjectV2SingleSelectFieldOptionInput!) {
|
|
33
|
+
createProjectV2SingleSelectFieldOption(input: $input) {
|
|
34
|
+
singleSelectFieldOption {
|
|
35
|
+
id
|
|
36
|
+
name
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}`;
|
|
40
|
+
export class GhProjectSyncError extends Error {
|
|
41
|
+
op;
|
|
42
|
+
target;
|
|
43
|
+
constructor(options) {
|
|
44
|
+
super(options.message, { cause: options.cause });
|
|
45
|
+
this.name = "GhProjectSyncError";
|
|
46
|
+
this.op = options.op;
|
|
47
|
+
this.target = options.target;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function verifyGhProject(opts) {
|
|
51
|
+
const client = resolveGhClient(opts);
|
|
52
|
+
const target = `project:${opts.owner}/${opts.number}`;
|
|
53
|
+
const variables = {
|
|
54
|
+
owner: opts.owner,
|
|
55
|
+
number: opts.number
|
|
56
|
+
};
|
|
57
|
+
let project;
|
|
58
|
+
try {
|
|
59
|
+
const organizationResult = await client.graphql(PROJECT_ORGANIZATION_QUERY, variables);
|
|
60
|
+
project = organizationResult.organization?.projectV2 ?? null;
|
|
61
|
+
if (project === null) {
|
|
62
|
+
const userResult = await client.graphql(PROJECT_USER_QUERY, variables);
|
|
63
|
+
project = userResult.user?.projectV2 ?? null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
throw new GhProjectSyncError({
|
|
68
|
+
op: "lookup",
|
|
69
|
+
target,
|
|
70
|
+
cause: error,
|
|
71
|
+
message: "lookup_failed"
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (project === null) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
project: null,
|
|
78
|
+
statusField: null,
|
|
79
|
+
missingProject: true,
|
|
80
|
+
missingStatusField: true,
|
|
81
|
+
missingOptions: opts.requiredStates
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const statusField = selectStatusField(project);
|
|
85
|
+
if (statusField === null) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
project: {
|
|
89
|
+
id: project.id,
|
|
90
|
+
number: opts.number,
|
|
91
|
+
owner: opts.owner
|
|
92
|
+
},
|
|
93
|
+
statusField: null,
|
|
94
|
+
missingProject: false,
|
|
95
|
+
missingStatusField: true,
|
|
96
|
+
missingOptions: opts.requiredStates
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const options = statusField.options.map((option) => option.name);
|
|
100
|
+
const missingOptions = opts.requiredStates.filter((state) => !options.includes(state));
|
|
101
|
+
return {
|
|
102
|
+
ok: missingOptions.length === 0,
|
|
103
|
+
project: {
|
|
104
|
+
id: project.id,
|
|
105
|
+
number: opts.number,
|
|
106
|
+
owner: opts.owner
|
|
107
|
+
},
|
|
108
|
+
statusField: {
|
|
109
|
+
id: statusField.id,
|
|
110
|
+
options
|
|
111
|
+
},
|
|
112
|
+
missingProject: false,
|
|
113
|
+
missingStatusField: false,
|
|
114
|
+
missingOptions
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export async function syncGhProject(opts) {
|
|
118
|
+
const client = resolveGhClient(opts);
|
|
119
|
+
const verified = await verifyGhProject({ ...opts, client });
|
|
120
|
+
const created = [];
|
|
121
|
+
if (verified.ok) {
|
|
122
|
+
return {
|
|
123
|
+
...verified,
|
|
124
|
+
created,
|
|
125
|
+
updated: []
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
let project = verified.project;
|
|
129
|
+
let statusField = verified.statusField;
|
|
130
|
+
let missingOptions = [...verified.missingOptions];
|
|
131
|
+
if (project === null) {
|
|
132
|
+
project = await createProject(client, opts);
|
|
133
|
+
created.push("project");
|
|
134
|
+
statusField = null;
|
|
135
|
+
missingOptions = [...opts.requiredStates];
|
|
136
|
+
}
|
|
137
|
+
if (statusField === null) {
|
|
138
|
+
const createdStatusField = await createStatusField(client, project.id);
|
|
139
|
+
statusField = createdStatusField;
|
|
140
|
+
created.push("field");
|
|
141
|
+
missingOptions = opts.requiredStates.filter((state) => !createdStatusField.options.includes(state));
|
|
142
|
+
}
|
|
143
|
+
if (missingOptions.length > 0) {
|
|
144
|
+
for (const optionName of missingOptions) {
|
|
145
|
+
await createStatusOption(client, statusField.id, optionName);
|
|
146
|
+
created.push(`option:${optionName}`);
|
|
147
|
+
}
|
|
148
|
+
statusField = {
|
|
149
|
+
id: statusField.id,
|
|
150
|
+
options: [...statusField.options, ...missingOptions]
|
|
151
|
+
};
|
|
152
|
+
missingOptions = [];
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
ok: statusField !== null && missingOptions.length === 0,
|
|
156
|
+
project,
|
|
157
|
+
statusField,
|
|
158
|
+
missingProject: false,
|
|
159
|
+
missingStatusField: statusField === null,
|
|
160
|
+
missingOptions,
|
|
161
|
+
created,
|
|
162
|
+
updated: []
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
async function createProject(client, opts) {
|
|
166
|
+
const target = `${opts.owner}/${opts.number}`;
|
|
167
|
+
try {
|
|
168
|
+
const ownerId = await lookupOwnerId(client, opts.owner);
|
|
169
|
+
const result = await client.graphql(CREATE_PROJECT_MUTATION, {
|
|
170
|
+
input: {
|
|
171
|
+
ownerId,
|
|
172
|
+
title: opts.title ?? `${opts.owner}/${opts.number}`
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
const project = result.createProjectV2?.projectV2;
|
|
176
|
+
if (project === undefined || project === null) {
|
|
177
|
+
throw new Error("createProjectV2 returned no project");
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
id: project.id,
|
|
181
|
+
number: project.number,
|
|
182
|
+
owner: opts.owner
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
throw new GhProjectSyncError({
|
|
187
|
+
op: "createProject",
|
|
188
|
+
target,
|
|
189
|
+
cause: error,
|
|
190
|
+
message: errorMessage(error)
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function lookupOwnerId(client, owner) {
|
|
195
|
+
const organizationResult = await client.graphql(OWNER_ORGANIZATION_QUERY, {
|
|
196
|
+
owner
|
|
197
|
+
});
|
|
198
|
+
const organizationId = organizationResult.organization?.id;
|
|
199
|
+
if (organizationId !== undefined) {
|
|
200
|
+
return organizationId;
|
|
201
|
+
}
|
|
202
|
+
const userResult = await client.graphql(OWNER_USER_QUERY, {
|
|
203
|
+
owner
|
|
204
|
+
});
|
|
205
|
+
const userId = userResult.user?.id;
|
|
206
|
+
if (userId !== undefined) {
|
|
207
|
+
return userId;
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`GitHub owner not found: ${owner}`);
|
|
210
|
+
}
|
|
211
|
+
async function createStatusField(client, projectId) {
|
|
212
|
+
try {
|
|
213
|
+
const result = await client.graphql(CREATE_STATUS_FIELD_MUTATION, {
|
|
214
|
+
input: {
|
|
215
|
+
projectId,
|
|
216
|
+
dataType: "SINGLE_SELECT",
|
|
217
|
+
name: "Status",
|
|
218
|
+
singleSelectOptions: []
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
const field = result.createProjectV2Field?.projectV2Field;
|
|
222
|
+
if (!isStatusField(field)) {
|
|
223
|
+
throw new Error("createProjectV2Field returned no Status field");
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
id: field.id,
|
|
227
|
+
options: field.options.map((option) => option.name)
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
throw new GhProjectSyncError({
|
|
232
|
+
op: "createField",
|
|
233
|
+
target: "Status",
|
|
234
|
+
cause: error,
|
|
235
|
+
message: errorMessage(error)
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function createStatusOption(client, fieldId, name) {
|
|
240
|
+
try {
|
|
241
|
+
await client.graphql(CREATE_STATUS_OPTION_MUTATION, {
|
|
242
|
+
input: {
|
|
243
|
+
fieldId,
|
|
244
|
+
name,
|
|
245
|
+
color: "GRAY"
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
throw new GhProjectSyncError({
|
|
251
|
+
op: "createOption",
|
|
252
|
+
target: name,
|
|
253
|
+
cause: error,
|
|
254
|
+
message: errorMessage(error)
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function errorMessage(error) {
|
|
259
|
+
return error instanceof Error ? error.message : String(error);
|
|
260
|
+
}
|
|
261
|
+
function resolveGhClient(opts) {
|
|
262
|
+
if (opts.client !== undefined) {
|
|
263
|
+
return opts.client;
|
|
264
|
+
}
|
|
265
|
+
const token = opts.auth?.token;
|
|
266
|
+
if (token === undefined || token.length === 0) {
|
|
267
|
+
throw new GhProjectSyncError({
|
|
268
|
+
op: "lookup",
|
|
269
|
+
target: "auth",
|
|
270
|
+
message: "missing_auth"
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return createGhClient({
|
|
274
|
+
token,
|
|
275
|
+
fetch: opts.fetch
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function selectStatusField(project) {
|
|
279
|
+
const fields = project.fields?.nodes?.filter(isStatusField) ?? [];
|
|
280
|
+
const exactStatusField = fields.find((field) => field.name === "Status");
|
|
281
|
+
if (exactStatusField !== undefined) {
|
|
282
|
+
return exactStatusField;
|
|
283
|
+
}
|
|
284
|
+
if (isStatusField(project.field) && isExactStatusField(project.field)) {
|
|
285
|
+
return project.field;
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
function isExactStatusField(field) {
|
|
290
|
+
return field.name === undefined || field.name === "Status";
|
|
291
|
+
}
|
|
292
|
+
function isStatusOption(value) {
|
|
293
|
+
return (typeof value === "object" &&
|
|
294
|
+
value !== null &&
|
|
295
|
+
"id" in value &&
|
|
296
|
+
typeof value.id === "string" &&
|
|
297
|
+
"name" in value &&
|
|
298
|
+
typeof value.name === "string");
|
|
299
|
+
}
|
|
300
|
+
function isStatusField(value) {
|
|
301
|
+
return (typeof value === "object" &&
|
|
302
|
+
value !== null &&
|
|
303
|
+
"id" in value &&
|
|
304
|
+
typeof value.id === "string" &&
|
|
305
|
+
(!("name" in value) || typeof value.name === "string") &&
|
|
306
|
+
"options" in value &&
|
|
307
|
+
Array.isArray(value.options) &&
|
|
308
|
+
value.options.every(isStatusOption));
|
|
309
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { TaskDefaults, TaskList } from "../types.js";
|
|
2
|
+
export declare const PROJECT_ORGANIZATION_QUERY = "query Project($owner: String!, $number: Int!) {\n organization(login: $owner) {\n projectV2(number: $number) {\n id\n title\n field(name: \"Status\") {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n fields(first: 100) {\n nodes {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }\n }\n}";
|
|
3
|
+
export declare const PROJECT_USER_QUERY = "query Project($owner: String!, $number: Int!) {\n user(login: $owner) {\n projectV2(number: $number) {\n id\n title\n field(name: \"Status\") {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n fields(first: 100) {\n nodes {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }\n }\n}";
|
|
2
4
|
export interface GhIssuesBackendDeps {
|
|
3
5
|
repo: string;
|
|
4
6
|
project: {
|
|
@@ -2,7 +2,7 @@ import { eventsFromState, findEvent } from "../state-machine.js";
|
|
|
2
2
|
import { AnchorNotFoundError, InvalidTransitionError, OrderMismatchError, TaskNotFoundError } from "../types.js";
|
|
3
3
|
import { createGhClient } from "./gh-issues-client.js";
|
|
4
4
|
import { applyOrder, sortTasks } from "./utils.js";
|
|
5
|
-
const PROJECT_ORGANIZATION_QUERY = `query Project($owner: String!, $number: Int!) {
|
|
5
|
+
export const PROJECT_ORGANIZATION_QUERY = `query Project($owner: String!, $number: Int!) {
|
|
6
6
|
organization(login: $owner) {
|
|
7
7
|
projectV2(number: $number) {
|
|
8
8
|
id
|
|
@@ -10,13 +10,23 @@ const PROJECT_ORGANIZATION_QUERY = `query Project($owner: String!, $number: Int!
|
|
|
10
10
|
field(name: "Status") {
|
|
11
11
|
... on ProjectV2SingleSelectField {
|
|
12
12
|
id
|
|
13
|
+
name
|
|
13
14
|
options { id name }
|
|
14
15
|
}
|
|
15
16
|
}
|
|
17
|
+
fields(first: 100) {
|
|
18
|
+
nodes {
|
|
19
|
+
... on ProjectV2SingleSelectField {
|
|
20
|
+
id
|
|
21
|
+
name
|
|
22
|
+
options { id name }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
16
26
|
}
|
|
17
27
|
}
|
|
18
28
|
}`;
|
|
19
|
-
const PROJECT_USER_QUERY = `query Project($owner: String!, $number: Int!) {
|
|
29
|
+
export const PROJECT_USER_QUERY = `query Project($owner: String!, $number: Int!) {
|
|
20
30
|
user(login: $owner) {
|
|
21
31
|
projectV2(number: $number) {
|
|
22
32
|
id
|
|
@@ -24,9 +34,19 @@ const PROJECT_USER_QUERY = `query Project($owner: String!, $number: Int!) {
|
|
|
24
34
|
field(name: "Status") {
|
|
25
35
|
... on ProjectV2SingleSelectField {
|
|
26
36
|
id
|
|
37
|
+
name
|
|
27
38
|
options { id name }
|
|
28
39
|
}
|
|
29
40
|
}
|
|
41
|
+
fields(first: 100) {
|
|
42
|
+
nodes {
|
|
43
|
+
... on ProjectV2SingleSelectField {
|
|
44
|
+
id
|
|
45
|
+
name
|
|
46
|
+
options { id name }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
30
50
|
}
|
|
31
51
|
}
|
|
32
52
|
}`;
|
|
@@ -22,6 +22,10 @@ const RESERVED_FRONTMATTER_KEYS = new Set([
|
|
|
22
22
|
"state",
|
|
23
23
|
"version"
|
|
24
24
|
]);
|
|
25
|
+
const PASSTHROUGH_RESERVED_FRONTMATTER_KEYS = new Set(["description", "name", "state"]);
|
|
26
|
+
function resolveListLayout(deps) {
|
|
27
|
+
return deps.singleList ? { kind: "single", name: deps.singleList } : { kind: "multi" };
|
|
28
|
+
}
|
|
25
29
|
function validateListName(name) {
|
|
26
30
|
if (name.length === 0 ||
|
|
27
31
|
name === ARCHIVE_DIRECTORY_NAME ||
|
|
@@ -45,17 +49,19 @@ function parseQualifiedId(qualifiedId) {
|
|
|
45
49
|
id: validateTaskId(qualifiedId.slice(separatorIndex + 1))
|
|
46
50
|
};
|
|
47
51
|
}
|
|
48
|
-
function listPath(rootPath, list) {
|
|
49
|
-
return path.join(rootPath, list);
|
|
52
|
+
function listPath(rootPath, layout, list) {
|
|
53
|
+
return layout.kind === "single" ? rootPath : path.join(rootPath, list);
|
|
50
54
|
}
|
|
51
|
-
function archiveDirectoryPath(rootPath, list) {
|
|
52
|
-
return
|
|
55
|
+
function archiveDirectoryPath(rootPath, layout, list) {
|
|
56
|
+
return layout.kind === "single"
|
|
57
|
+
? path.join(rootPath, ARCHIVE_DIRECTORY_NAME)
|
|
58
|
+
: path.join(rootPath, list, ARCHIVE_DIRECTORY_NAME);
|
|
53
59
|
}
|
|
54
60
|
function activeTaskFilename(id, order, width) {
|
|
55
61
|
return `${String(order).padStart(width, "0")}-${id}${MARKDOWN_EXTENSION}`;
|
|
56
62
|
}
|
|
57
|
-
function archivedTaskPath(rootPath, list, id) {
|
|
58
|
-
return path.join(archiveDirectoryPath(rootPath, list), `${id}${MARKDOWN_EXTENSION}`);
|
|
63
|
+
function archivedTaskPath(rootPath, layout, list, id) {
|
|
64
|
+
return path.join(archiveDirectoryPath(rootPath, layout, list), `${id}${MARKDOWN_EXTENSION}`);
|
|
59
65
|
}
|
|
60
66
|
function isMarkdownFile(entryName) {
|
|
61
67
|
return entryName.endsWith(MARKDOWN_EXTENSION);
|
|
@@ -67,7 +73,11 @@ function isLockFile(entryName) {
|
|
|
67
73
|
return entryName.endsWith(".lock");
|
|
68
74
|
}
|
|
69
75
|
function isValidTaskIdShape(id) {
|
|
70
|
-
return id.length > 0 &&
|
|
76
|
+
return (id.length > 0 &&
|
|
77
|
+
!id.startsWith(".") &&
|
|
78
|
+
!id.includes("/") &&
|
|
79
|
+
!id.includes("\\") &&
|
|
80
|
+
!id.includes(".."));
|
|
71
81
|
}
|
|
72
82
|
function parseActiveFilename(entryName) {
|
|
73
83
|
if (!isMarkdownFile(entryName))
|
|
@@ -94,9 +104,13 @@ function malformedTask(filePath, field) {
|
|
|
94
104
|
function stripTrailingCarriageReturn(line) {
|
|
95
105
|
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
96
106
|
}
|
|
97
|
-
function splitTaskDocument(content, filePath) {
|
|
107
|
+
function splitTaskDocument(content, filePath, mode) {
|
|
98
108
|
const lines = content.split("\n");
|
|
99
|
-
|
|
109
|
+
const hasFrontmatterBlock = lines.length > 0 && stripTrailingCarriageReturn(lines[0]) === "---";
|
|
110
|
+
if (!hasFrontmatterBlock) {
|
|
111
|
+
if (mode === "passthrough") {
|
|
112
|
+
return { frontmatter: "", body: content };
|
|
113
|
+
}
|
|
100
114
|
throw malformedTask(filePath, "frontmatter");
|
|
101
115
|
}
|
|
102
116
|
let closingIndex = -1;
|
|
@@ -153,16 +167,20 @@ function assertValidTaskRecord(frontmatter, filePath, validStates) {
|
|
|
153
167
|
throw malformedTask(filePath, "description");
|
|
154
168
|
}
|
|
155
169
|
}
|
|
156
|
-
function
|
|
170
|
+
function reservedFrontmatterKeys(mode) {
|
|
171
|
+
return mode === "passthrough" ? PASSTHROUGH_RESERVED_FRONTMATTER_KEYS : RESERVED_FRONTMATTER_KEYS;
|
|
172
|
+
}
|
|
173
|
+
function metadataFromFrontmatter(frontmatter, mode) {
|
|
157
174
|
const metadata = {};
|
|
175
|
+
const reservedKeys = reservedFrontmatterKeys(mode);
|
|
158
176
|
for (const [key, value] of Object.entries(frontmatter)) {
|
|
159
|
-
if (!
|
|
177
|
+
if (!reservedKeys.has(key)) {
|
|
160
178
|
metadata[key] = value;
|
|
161
179
|
}
|
|
162
180
|
}
|
|
163
181
|
return metadata;
|
|
164
182
|
}
|
|
165
|
-
function createTask(list, id, frontmatter, body) {
|
|
183
|
+
function createTask(list, id, frontmatter, body, mode) {
|
|
166
184
|
return {
|
|
167
185
|
list,
|
|
168
186
|
id,
|
|
@@ -170,7 +188,7 @@ function createTask(list, id, frontmatter, body) {
|
|
|
170
188
|
name: frontmatter.name,
|
|
171
189
|
state: frontmatter.state,
|
|
172
190
|
description: body,
|
|
173
|
-
metadata: metadataFromFrontmatter(frontmatter)
|
|
191
|
+
metadata: metadataFromFrontmatter(frontmatter, mode)
|
|
174
192
|
};
|
|
175
193
|
}
|
|
176
194
|
function serializeTaskDocument(frontmatter, description) {
|
|
@@ -194,15 +212,33 @@ async function ensureRootPath(deps) {
|
|
|
194
212
|
}
|
|
195
213
|
await deps.fs.stat(deps.path);
|
|
196
214
|
}
|
|
197
|
-
async function readTaskFile(fs, list, id, filePath, validStates) {
|
|
215
|
+
async function readTaskFile(fs, list, id, filePath, validStates, initialState, mode) {
|
|
198
216
|
const content = await fs.readFile(filePath, "utf8");
|
|
199
|
-
const document = splitTaskDocument(content, filePath);
|
|
200
|
-
const frontmatter =
|
|
201
|
-
|
|
217
|
+
const document = splitTaskDocument(content, filePath, mode);
|
|
218
|
+
const frontmatter = mode === "passthrough" && document.frontmatter.trim().length === 0
|
|
219
|
+
? {}
|
|
220
|
+
: readFrontmatter(document.frontmatter, filePath);
|
|
221
|
+
if (mode !== "passthrough") {
|
|
222
|
+
assertValidTaskRecord(frontmatter, filePath, validStates);
|
|
223
|
+
return {
|
|
224
|
+
path: filePath,
|
|
225
|
+
frontmatter,
|
|
226
|
+
task: createTask(list, id, frontmatter, document.body, mode)
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const parsedFilename = parseActiveFilename(path.basename(filePath));
|
|
230
|
+
const defaultName = parsedFilename?.id ?? id;
|
|
231
|
+
const effectiveFrontmatter = {
|
|
232
|
+
...frontmatter,
|
|
233
|
+
name: typeof frontmatter.name === "string" ? frontmatter.name : defaultName,
|
|
234
|
+
state: typeof frontmatter.state === "string" && validStates.has(frontmatter.state)
|
|
235
|
+
? frontmatter.state
|
|
236
|
+
: initialState
|
|
237
|
+
};
|
|
202
238
|
return {
|
|
203
239
|
path: filePath,
|
|
204
240
|
frontmatter,
|
|
205
|
-
task: createTask(list, id,
|
|
241
|
+
task: createTask(list, id, effectiveFrontmatter, document.body, mode)
|
|
206
242
|
};
|
|
207
243
|
}
|
|
208
244
|
async function findActiveTaskFilename(fs, listDirectoryPath, id) {
|
|
@@ -217,8 +253,8 @@ async function findActiveTaskFilename(fs, listDirectoryPath, id) {
|
|
|
217
253
|
}
|
|
218
254
|
return undefined;
|
|
219
255
|
}
|
|
220
|
-
async function findTaskLocation(fs, rootPath, list, id) {
|
|
221
|
-
const listDirectoryPath = listPath(rootPath, list);
|
|
256
|
+
async function findTaskLocation(fs, rootPath, layout, list, id) {
|
|
257
|
+
const listDirectoryPath = listPath(rootPath, layout, list);
|
|
222
258
|
const activeName = await findActiveTaskFilename(fs, listDirectoryPath, id);
|
|
223
259
|
if (activeName) {
|
|
224
260
|
const activePath = path.join(listDirectoryPath, activeName);
|
|
@@ -227,71 +263,91 @@ async function findTaskLocation(fs, rootPath, list, id) {
|
|
|
227
263
|
return { archived: false, path: activePath };
|
|
228
264
|
}
|
|
229
265
|
}
|
|
230
|
-
const archivedPath = archivedTaskPath(rootPath, list, id);
|
|
266
|
+
const archivedPath = archivedTaskPath(rootPath, layout, list, id);
|
|
231
267
|
const archivedStat = await statIfExists(fs, archivedPath);
|
|
232
268
|
if (archivedStat?.isFile()) {
|
|
233
269
|
return { archived: true, path: archivedPath };
|
|
234
270
|
}
|
|
235
271
|
return undefined;
|
|
236
272
|
}
|
|
237
|
-
async function readTaskAtLocation(fs, rootPath, list, id, validStates) {
|
|
238
|
-
const location = await findTaskLocation(fs, rootPath, list, id);
|
|
273
|
+
async function readTaskAtLocation(fs, rootPath, layout, list, id, validStates, initialState, mode) {
|
|
274
|
+
const location = await findTaskLocation(fs, rootPath, layout, list, id);
|
|
239
275
|
if (!location) {
|
|
240
276
|
throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
|
|
241
277
|
}
|
|
242
|
-
return readTaskFile(fs, list, id, location.path, validStates);
|
|
278
|
+
return readTaskFile(fs, list, id, location.path, validStates, initialState, mode);
|
|
243
279
|
}
|
|
244
|
-
function createdFrontmatter(defaults, input, initialState) {
|
|
245
|
-
const frontmatter =
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
280
|
+
function createdFrontmatter(defaults, input, initialState, mode) {
|
|
281
|
+
const frontmatter = mode !== "passthrough"
|
|
282
|
+
? {
|
|
283
|
+
$schema: TASK_SCHEMA_ID,
|
|
284
|
+
kind: TASK_KIND,
|
|
285
|
+
version: TASK_VERSION,
|
|
286
|
+
name: input.name,
|
|
287
|
+
state: initialState
|
|
288
|
+
}
|
|
289
|
+
: {
|
|
290
|
+
name: input.name,
|
|
291
|
+
state: initialState
|
|
292
|
+
};
|
|
293
|
+
const reservedKeys = reservedFrontmatterKeys(mode);
|
|
252
294
|
for (const [key, value] of Object.entries(defaults.metadata)) {
|
|
253
|
-
if (!
|
|
295
|
+
if (!reservedKeys.has(key)) {
|
|
254
296
|
frontmatter[key] = value;
|
|
255
297
|
}
|
|
256
298
|
}
|
|
257
299
|
for (const [key, value] of Object.entries(input.metadata ?? {})) {
|
|
258
|
-
if (!
|
|
300
|
+
if (!reservedKeys.has(key)) {
|
|
259
301
|
frontmatter[key] = value;
|
|
260
302
|
}
|
|
261
303
|
}
|
|
262
304
|
frontmatter.created = new Date().toISOString();
|
|
263
305
|
return frontmatter;
|
|
264
306
|
}
|
|
265
|
-
function updatedFrontmatter(existingFrontmatter, task, patch) {
|
|
266
|
-
const nextFrontmatter =
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
307
|
+
function updatedFrontmatter(existingFrontmatter, task, patch, mode) {
|
|
308
|
+
const nextFrontmatter = mode !== "passthrough"
|
|
309
|
+
? {
|
|
310
|
+
...existingFrontmatter,
|
|
311
|
+
$schema: existingFrontmatter.$schema ?? TASK_SCHEMA_ID,
|
|
312
|
+
kind: existingFrontmatter.kind ?? TASK_KIND,
|
|
313
|
+
version: existingFrontmatter.version ?? TASK_VERSION,
|
|
314
|
+
name: patch.name ?? task.name,
|
|
315
|
+
state: task.state
|
|
316
|
+
}
|
|
317
|
+
: {
|
|
318
|
+
...existingFrontmatter,
|
|
319
|
+
name: patch.name ?? task.name,
|
|
320
|
+
state: task.state
|
|
321
|
+
};
|
|
322
|
+
const reservedKeys = reservedFrontmatterKeys(mode);
|
|
274
323
|
for (const [key, value] of Object.entries(patch.metadata ?? {})) {
|
|
275
|
-
if (!
|
|
324
|
+
if (!reservedKeys.has(key)) {
|
|
276
325
|
nextFrontmatter[key] = value;
|
|
277
326
|
}
|
|
278
327
|
}
|
|
279
328
|
return nextFrontmatter;
|
|
280
329
|
}
|
|
281
|
-
function transitionedFrontmatter(existingFrontmatter, task, to) {
|
|
282
|
-
return
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
330
|
+
function transitionedFrontmatter(existingFrontmatter, task, to, mode) {
|
|
331
|
+
return mode !== "passthrough"
|
|
332
|
+
? {
|
|
333
|
+
...existingFrontmatter,
|
|
334
|
+
$schema: existingFrontmatter.$schema ?? TASK_SCHEMA_ID,
|
|
335
|
+
kind: existingFrontmatter.kind ?? TASK_KIND,
|
|
336
|
+
version: existingFrontmatter.version ?? TASK_VERSION,
|
|
337
|
+
name: task.name,
|
|
338
|
+
state: to
|
|
339
|
+
}
|
|
340
|
+
: {
|
|
341
|
+
...existingFrontmatter,
|
|
342
|
+
name: task.name,
|
|
343
|
+
state: to
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function firedFrontmatter(existingFrontmatter, task, to, mode, metadataPatch) {
|
|
347
|
+
const nextFrontmatter = transitionedFrontmatter(existingFrontmatter, task, to, mode);
|
|
348
|
+
const reservedKeys = reservedFrontmatterKeys(mode);
|
|
293
349
|
for (const [key, value] of Object.entries(metadataPatch ?? {})) {
|
|
294
|
-
if (!
|
|
350
|
+
if (!reservedKeys.has(key)) {
|
|
295
351
|
nextFrontmatter[key] = value;
|
|
296
352
|
}
|
|
297
353
|
}
|
|
@@ -312,8 +368,8 @@ function assertUpdateDoesNotSetState(patch) {
|
|
|
312
368
|
throw new Error('Tasks.update() does not accept "state"; use fire() to change task state.');
|
|
313
369
|
}
|
|
314
370
|
}
|
|
315
|
-
function createTasksView(deps, list) {
|
|
316
|
-
const listDirectoryPath = listPath(deps.path, list);
|
|
371
|
+
function createTasksView(deps, layout, list) {
|
|
372
|
+
const listDirectoryPath = listPath(deps.path, layout, list);
|
|
317
373
|
const stateMachine = resolveStateMachine(deps.stateMachine);
|
|
318
374
|
const validStates = new Set(stateMachine.states);
|
|
319
375
|
async function readActiveEntries() {
|
|
@@ -345,13 +401,13 @@ function createTasksView(deps, list) {
|
|
|
345
401
|
const tasks = new Map();
|
|
346
402
|
for (const entry of entries) {
|
|
347
403
|
const filePath = path.join(listDirectoryPath, entry.filename);
|
|
348
|
-
const file = await readTaskFile(deps.fs, list, entry.id, filePath, validStates);
|
|
404
|
+
const file = await readTaskFile(deps.fs, list, entry.id, filePath, validStates, stateMachine.initial, deps.frontmatterMode);
|
|
349
405
|
tasks.set(entry.id, { task: file.task, raw: file.frontmatter });
|
|
350
406
|
}
|
|
351
407
|
return { entries, tasks };
|
|
352
408
|
}
|
|
353
409
|
async function readArchivedTasks() {
|
|
354
|
-
const archivePath = archiveDirectoryPath(deps.path, list);
|
|
410
|
+
const archivePath = archiveDirectoryPath(deps.path, layout, list);
|
|
355
411
|
const entries = await readDirectoryNames(deps.fs, archivePath);
|
|
356
412
|
const result = [];
|
|
357
413
|
for (const entryName of entries) {
|
|
@@ -362,35 +418,109 @@ function createTasksView(deps, list) {
|
|
|
362
418
|
if (!entryStat?.isFile())
|
|
363
419
|
continue;
|
|
364
420
|
const id = entryName.slice(0, -MARKDOWN_EXTENSION.length);
|
|
365
|
-
const file = await readTaskFile(deps.fs, list, id, entryPath, validStates);
|
|
421
|
+
const file = await readTaskFile(deps.fs, list, id, entryPath, validStates, stateMachine.initial, deps.frontmatterMode);
|
|
366
422
|
result.push({ task: file.task, raw: file.frontmatter });
|
|
367
423
|
}
|
|
368
424
|
return result.sort((left, right) => left.task.qualifiedId.localeCompare(right.task.qualifiedId));
|
|
369
425
|
}
|
|
426
|
+
async function renameActiveEntries(entries, desiredOrdersById) {
|
|
427
|
+
const staged = [];
|
|
428
|
+
const maxOrder = Math.max(...desiredOrdersById.values(), entries.length);
|
|
429
|
+
const width = padWidthForCount(maxOrder);
|
|
430
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
431
|
+
const entry = entries[index];
|
|
432
|
+
const desiredOrder = desiredOrdersById.get(entry.id);
|
|
433
|
+
if (desiredOrder === undefined)
|
|
434
|
+
continue;
|
|
435
|
+
const desiredFilename = activeTaskFilename(entry.id, desiredOrder, width);
|
|
436
|
+
if (entry.filename !== desiredFilename) {
|
|
437
|
+
const fromPath = path.join(listDirectoryPath, entry.filename);
|
|
438
|
+
const stagingPath = path.join(listDirectoryPath, `${desiredFilename}.staging-${process.pid}-${index}`);
|
|
439
|
+
const targetPath = path.join(listDirectoryPath, desiredFilename);
|
|
440
|
+
await deps.fs.rename(fromPath, stagingPath);
|
|
441
|
+
staged.push({ from: stagingPath, to: targetPath });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
for (const entry of staged) {
|
|
445
|
+
await deps.fs.rename(entry.from, entry.to);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
370
448
|
async function rewriteListPrefixes(orderedIds) {
|
|
371
449
|
const entries = await readActiveEntries();
|
|
372
450
|
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
373
|
-
const
|
|
451
|
+
const desiredOrdersById = new Map();
|
|
374
452
|
for (let index = 0; index < orderedIds.length; index += 1) {
|
|
375
453
|
const id = orderedIds[index];
|
|
376
454
|
const entry = byId.get(id);
|
|
377
455
|
if (!entry)
|
|
378
456
|
continue;
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
457
|
+
desiredOrdersById.set(id, index + 1);
|
|
458
|
+
}
|
|
459
|
+
await renameActiveEntries(entries, desiredOrdersById);
|
|
460
|
+
}
|
|
461
|
+
function entryOrder(entry, index) {
|
|
462
|
+
return entry.order ?? index + 1;
|
|
463
|
+
}
|
|
464
|
+
async function rewriteMovedPrefix(movedId, orderedIds) {
|
|
465
|
+
const entries = await readActiveEntries();
|
|
466
|
+
const byId = new Map(entries.map((entry, index) => [entry.id, { entry, index }]));
|
|
467
|
+
const movedIndex = orderedIds.indexOf(movedId);
|
|
468
|
+
const moved = byId.get(movedId);
|
|
469
|
+
if (movedIndex < 0 || moved === undefined)
|
|
470
|
+
return;
|
|
471
|
+
const desiredOrdersById = new Map();
|
|
472
|
+
const previousId = movedIndex > 0 ? orderedIds[movedIndex - 1] : undefined;
|
|
473
|
+
const nextId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : undefined;
|
|
474
|
+
const previous = previousId === undefined ? undefined : byId.get(previousId);
|
|
475
|
+
const next = nextId === undefined ? undefined : byId.get(nextId);
|
|
476
|
+
if (previous !== undefined && next === undefined) {
|
|
477
|
+
desiredOrdersById.set(movedId, entryOrder(previous.entry, previous.index) + 1);
|
|
478
|
+
await renameActiveEntries(entries, desiredOrdersById);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (previous === undefined && next !== undefined) {
|
|
482
|
+
const nextOrder = entryOrder(next.entry, next.index);
|
|
483
|
+
if (nextOrder > 1) {
|
|
484
|
+
desiredOrdersById.set(movedId, nextOrder - 1);
|
|
485
|
+
await renameActiveEntries(entries, desiredOrdersById);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
desiredOrdersById.set(movedId, 1);
|
|
489
|
+
let lastOrder = 1;
|
|
490
|
+
for (let index = movedIndex + 1; index < orderedIds.length; index += 1) {
|
|
491
|
+
const candidate = byId.get(orderedIds[index]);
|
|
492
|
+
if (candidate === undefined)
|
|
493
|
+
continue;
|
|
494
|
+
const currentOrder = entryOrder(candidate.entry, candidate.index);
|
|
495
|
+
if (currentOrder > lastOrder)
|
|
496
|
+
break;
|
|
497
|
+
lastOrder += 1;
|
|
498
|
+
desiredOrdersById.set(candidate.entry.id, lastOrder);
|
|
385
499
|
}
|
|
500
|
+
await renameActiveEntries(entries, desiredOrdersById);
|
|
501
|
+
return;
|
|
386
502
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
if (
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
503
|
+
if (previous !== undefined && next !== undefined) {
|
|
504
|
+
const previousOrder = entryOrder(previous.entry, previous.index);
|
|
505
|
+
const nextOrder = entryOrder(next.entry, next.index);
|
|
506
|
+
if (nextOrder - previousOrder > 1) {
|
|
507
|
+
desiredOrdersById.set(movedId, previousOrder + 1);
|
|
508
|
+
await renameActiveEntries(entries, desiredOrdersById);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
let lastOrder = previousOrder + 1;
|
|
512
|
+
desiredOrdersById.set(movedId, lastOrder);
|
|
513
|
+
for (let index = movedIndex + 1; index < orderedIds.length; index += 1) {
|
|
514
|
+
const candidate = byId.get(orderedIds[index]);
|
|
515
|
+
if (candidate === undefined)
|
|
516
|
+
continue;
|
|
517
|
+
const currentOrder = entryOrder(candidate.entry, candidate.index);
|
|
518
|
+
if (currentOrder > lastOrder)
|
|
519
|
+
break;
|
|
520
|
+
lastOrder += 1;
|
|
521
|
+
desiredOrdersById.set(candidate.entry.id, lastOrder);
|
|
522
|
+
}
|
|
523
|
+
await renameActiveEntries(entries, desiredOrdersById);
|
|
394
524
|
}
|
|
395
525
|
}
|
|
396
526
|
async function withListLock(action) {
|
|
@@ -411,9 +541,22 @@ function createTasksView(deps, list) {
|
|
|
411
541
|
validateTaskId(id);
|
|
412
542
|
return withListLock(action);
|
|
413
543
|
}
|
|
544
|
+
async function withLocatedTaskLock(location, action) {
|
|
545
|
+
const release = await acquireFileLock(location.path, {
|
|
546
|
+
fs: deps.fs,
|
|
547
|
+
staleMs: deps.lockStaleMs,
|
|
548
|
+
retries: deps.lockRetries
|
|
549
|
+
});
|
|
550
|
+
try {
|
|
551
|
+
return await action();
|
|
552
|
+
}
|
|
553
|
+
finally {
|
|
554
|
+
await release();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
414
557
|
async function getTaskFile(id) {
|
|
415
558
|
validateTaskId(id);
|
|
416
|
-
return readTaskAtLocation(deps.fs, deps.path, list, id, validStates);
|
|
559
|
+
return readTaskAtLocation(deps.fs, deps.path, layout, list, id, validStates, stateMachine.initial, deps.frontmatterMode);
|
|
417
560
|
}
|
|
418
561
|
function assertFireableTaskEvent(task, eventName) {
|
|
419
562
|
const event = findEvent(stateMachine, task.state, eventName);
|
|
@@ -457,7 +600,7 @@ function createTasksView(deps, list) {
|
|
|
457
600
|
validateTaskId(input.id);
|
|
458
601
|
await deps.fs.mkdir(listDirectoryPath, { recursive: true });
|
|
459
602
|
return withListLock(async () => {
|
|
460
|
-
const existing = await findTaskLocation(deps.fs, deps.path, list, input.id);
|
|
603
|
+
const existing = await findTaskLocation(deps.fs, deps.path, layout, list, input.id);
|
|
461
604
|
if (existing) {
|
|
462
605
|
throw new TaskAlreadyExistsError(`Task "${list}/${input.id}" already exists.`);
|
|
463
606
|
}
|
|
@@ -467,24 +610,24 @@ function createTasksView(deps, list) {
|
|
|
467
610
|
const width = padWidthForCount(activeEntries.length + 1);
|
|
468
611
|
const filename = activeTaskFilename(input.id, nextOrder, width);
|
|
469
612
|
const targetPath = path.join(listDirectoryPath, filename);
|
|
470
|
-
const frontmatter = createdFrontmatter(deps.defaults, input, stateMachine.initial);
|
|
613
|
+
const frontmatter = createdFrontmatter(deps.defaults, input, stateMachine.initial, deps.frontmatterMode);
|
|
471
614
|
const description = input.description ?? "";
|
|
472
615
|
await writeAtomically(deps.fs, targetPath, serializeTaskDocument(frontmatter, description));
|
|
473
|
-
return createTask(list, input.id, frontmatter, description);
|
|
616
|
+
return createTask(list, input.id, frontmatter, description, deps.frontmatterMode);
|
|
474
617
|
});
|
|
475
618
|
},
|
|
476
619
|
async update(id, patch) {
|
|
477
620
|
assertUpdateDoesNotSetState(patch);
|
|
478
621
|
return withTaskLock(id, async () => {
|
|
479
622
|
const existing = await getTaskFile(id);
|
|
480
|
-
const nextFrontmatter = updatedFrontmatter(existing.frontmatter, existing.task, patch);
|
|
623
|
+
const nextFrontmatter = updatedFrontmatter(existing.frontmatter, existing.task, patch, deps.frontmatterMode);
|
|
481
624
|
const description = patch.description ?? existing.task.description;
|
|
482
625
|
await writeAtomically(deps.fs, existing.path, serializeTaskDocument(nextFrontmatter, description));
|
|
483
|
-
return createTask(list, id, nextFrontmatter, description);
|
|
626
|
+
return createTask(list, id, nextFrontmatter, description, deps.frontmatterMode);
|
|
484
627
|
});
|
|
485
628
|
},
|
|
486
629
|
async fire(id, eventName, opts) {
|
|
487
|
-
|
|
630
|
+
const fireTask = async () => {
|
|
488
631
|
const existing = await getTaskFile(id);
|
|
489
632
|
const event = assertFireableTaskEvent(existing.task, eventName);
|
|
490
633
|
const guardResult = event.guard?.(existing.task) ?? true;
|
|
@@ -497,17 +640,17 @@ function createTasksView(deps, list) {
|
|
|
497
640
|
});
|
|
498
641
|
}
|
|
499
642
|
await event.onExit?.(existing.task);
|
|
500
|
-
const nextFrontmatter = firedFrontmatter(existing.frontmatter, existing.task, event.to, opts?.metadataPatch);
|
|
501
|
-
const nextTask = createTask(list, id, nextFrontmatter, existing.task.description);
|
|
643
|
+
const nextFrontmatter = firedFrontmatter(existing.frontmatter, existing.task, event.to, deps.frontmatterMode, opts?.metadataPatch);
|
|
644
|
+
const nextTask = createTask(list, id, nextFrontmatter, existing.task.description, deps.frontmatterMode);
|
|
502
645
|
const serializedTask = serializeTaskDocument(nextFrontmatter, existing.task.description);
|
|
503
646
|
if (event.to === "archived") {
|
|
504
|
-
const targetPath = archivedTaskPath(deps.path, list, id);
|
|
647
|
+
const targetPath = archivedTaskPath(deps.path, layout, list, id);
|
|
505
648
|
const archivedTargetExists = await statIfExists(deps.fs, targetPath);
|
|
506
649
|
if (archivedTargetExists?.isFile()) {
|
|
507
650
|
throw new TaskAlreadyExistsError(`Task "${list}/${id}" already exists in archive.`);
|
|
508
651
|
}
|
|
509
652
|
await writeAtomically(deps.fs, existing.path, serializedTask);
|
|
510
|
-
await deps.fs.mkdir(archiveDirectoryPath(deps.path, list), { recursive: true });
|
|
653
|
+
await deps.fs.mkdir(archiveDirectoryPath(deps.path, layout, list), { recursive: true });
|
|
511
654
|
await deps.fs.rename(existing.path, targetPath);
|
|
512
655
|
await event.onEnter?.(nextTask);
|
|
513
656
|
return nextTask;
|
|
@@ -515,7 +658,18 @@ function createTasksView(deps, list) {
|
|
|
515
658
|
await writeAtomically(deps.fs, existing.path, serializedTask);
|
|
516
659
|
await event.onEnter?.(nextTask);
|
|
517
660
|
return nextTask;
|
|
518
|
-
}
|
|
661
|
+
};
|
|
662
|
+
if (stateMachine.events[eventName]?.to === "archived") {
|
|
663
|
+
validateTaskId(id);
|
|
664
|
+
return withListLock(async () => {
|
|
665
|
+
const location = await findTaskLocation(deps.fs, deps.path, layout, list, id);
|
|
666
|
+
if (!location) {
|
|
667
|
+
throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
|
|
668
|
+
}
|
|
669
|
+
return withLocatedTaskLock(location, fireTask);
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
return withTaskLock(id, fireTask);
|
|
519
673
|
},
|
|
520
674
|
async canFire(id, eventName) {
|
|
521
675
|
const task = (await getTaskFile(id)).task;
|
|
@@ -531,7 +685,7 @@ function createTasksView(deps, list) {
|
|
|
531
685
|
},
|
|
532
686
|
async delete(id) {
|
|
533
687
|
await withTaskLock(id, async () => {
|
|
534
|
-
const location = await findTaskLocation(deps.fs, deps.path, list, id);
|
|
688
|
+
const location = await findTaskLocation(deps.fs, deps.path, layout, list, id);
|
|
535
689
|
if (!location) {
|
|
536
690
|
throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
|
|
537
691
|
}
|
|
@@ -561,7 +715,7 @@ function createTasksView(deps, list) {
|
|
|
561
715
|
insertIndex = "before" in anchor ? anchorIndex : anchorIndex + 1;
|
|
562
716
|
}
|
|
563
717
|
ordered.splice(insertIndex, 0, id);
|
|
564
|
-
await
|
|
718
|
+
await rewriteMovedPrefix(id, ordered);
|
|
565
719
|
return tasks.get(id).task;
|
|
566
720
|
});
|
|
567
721
|
},
|
|
@@ -587,13 +741,23 @@ function createTasksView(deps, list) {
|
|
|
587
741
|
}
|
|
588
742
|
export async function markdownDirBackend(deps) {
|
|
589
743
|
await ensureRootPath(deps);
|
|
744
|
+
const layout = resolveListLayout(deps);
|
|
590
745
|
const stateMachine = resolveStateMachine(deps.stateMachine);
|
|
591
746
|
const validStates = new Set(stateMachine.states);
|
|
592
747
|
const list = (name) => {
|
|
748
|
+
if (layout.kind === "single") {
|
|
749
|
+
if (name !== layout.name) {
|
|
750
|
+
throw new Error(`Task list "${name}" not found.`);
|
|
751
|
+
}
|
|
752
|
+
return createTasksView(deps, layout, name);
|
|
753
|
+
}
|
|
593
754
|
const listName = validateListName(name);
|
|
594
|
-
return createTasksView(deps, listName);
|
|
755
|
+
return createTasksView(deps, layout, listName);
|
|
595
756
|
};
|
|
596
757
|
const lists = async () => {
|
|
758
|
+
if (layout.kind === "single") {
|
|
759
|
+
return [layout.name];
|
|
760
|
+
}
|
|
597
761
|
const entries = await readDirectoryNames(deps.fs, deps.path);
|
|
598
762
|
const result = [];
|
|
599
763
|
for (const entryName of entries) {
|
|
@@ -623,21 +787,24 @@ export async function markdownDirBackend(deps) {
|
|
|
623
787
|
return list(listName).get(id);
|
|
624
788
|
};
|
|
625
789
|
const moveBetweenLists = async (qualifiedId, targetList) => {
|
|
790
|
+
if (layout.kind === "single") {
|
|
791
|
+
throw new Error("moveBetweenLists is unsupported in single-list mode.");
|
|
792
|
+
}
|
|
626
793
|
const { list: sourceListName, id } = parseQualifiedId(qualifiedId);
|
|
627
794
|
const targetListName = validateListName(targetList);
|
|
628
795
|
if (sourceListName === targetListName) {
|
|
629
|
-
const file = await readTaskAtLocation(deps.fs, deps.path, sourceListName, id, validStates);
|
|
796
|
+
const file = await readTaskAtLocation(deps.fs, deps.path, layout, sourceListName, id, validStates, stateMachine.initial, deps.frontmatterMode);
|
|
630
797
|
return file.task;
|
|
631
798
|
}
|
|
632
|
-
const targetExisting = await findTaskLocation(deps.fs, deps.path, targetListName, id);
|
|
799
|
+
const targetExisting = await findTaskLocation(deps.fs, deps.path, layout, targetListName, id);
|
|
633
800
|
if (targetExisting) {
|
|
634
801
|
throw new TaskAlreadyExistsError(`Task "${targetListName}/${id}" already exists.`);
|
|
635
802
|
}
|
|
636
|
-
const sourceLocation = await findTaskLocation(deps.fs, deps.path, sourceListName, id);
|
|
803
|
+
const sourceLocation = await findTaskLocation(deps.fs, deps.path, layout, sourceListName, id);
|
|
637
804
|
if (!sourceLocation) {
|
|
638
805
|
throw new TaskNotFoundError(`Task "${sourceListName}/${id}" not found.`);
|
|
639
806
|
}
|
|
640
|
-
const targetListDir = listPath(deps.path, targetListName);
|
|
807
|
+
const targetListDir = listPath(deps.path, layout, targetListName);
|
|
641
808
|
await deps.fs.mkdir(targetListDir, { recursive: true });
|
|
642
809
|
const targetEntries = await (async () => {
|
|
643
810
|
const out = [];
|
|
@@ -653,11 +820,11 @@ export async function markdownDirBackend(deps) {
|
|
|
653
820
|
return out;
|
|
654
821
|
})();
|
|
655
822
|
if (sourceLocation.archived) {
|
|
656
|
-
const archivedTargetDir = archiveDirectoryPath(deps.path, targetListName);
|
|
823
|
+
const archivedTargetDir = archiveDirectoryPath(deps.path, layout, targetListName);
|
|
657
824
|
await deps.fs.mkdir(archivedTargetDir, { recursive: true });
|
|
658
|
-
const archivedTargetPath = archivedTaskPath(deps.path, targetListName, id);
|
|
825
|
+
const archivedTargetPath = archivedTaskPath(deps.path, layout, targetListName, id);
|
|
659
826
|
await deps.fs.rename(sourceLocation.path, archivedTargetPath);
|
|
660
|
-
const file = await readTaskFile(deps.fs, targetListName, id, archivedTargetPath, validStates);
|
|
827
|
+
const file = await readTaskFile(deps.fs, targetListName, id, archivedTargetPath, validStates, stateMachine.initial, deps.frontmatterMode);
|
|
661
828
|
return file.task;
|
|
662
829
|
}
|
|
663
830
|
const maxOrder = targetEntries.reduce((max, entry) => (entry.order !== null && entry.order > max ? entry.order : max), 0);
|
|
@@ -665,7 +832,7 @@ export async function markdownDirBackend(deps) {
|
|
|
665
832
|
const targetFilename = activeTaskFilename(id, maxOrder + 1, width);
|
|
666
833
|
const targetPath = path.join(targetListDir, targetFilename);
|
|
667
834
|
await deps.fs.rename(sourceLocation.path, targetPath);
|
|
668
|
-
const file = await readTaskFile(deps.fs, targetListName, id, targetPath, validStates);
|
|
835
|
+
const file = await readTaskFile(deps.fs, targetListName, id, targetPath, validStates, stateMachine.initial, deps.frontmatterMode);
|
|
669
836
|
return file.task;
|
|
670
837
|
};
|
|
671
838
|
return {
|
|
@@ -4,3 +4,4 @@ export { eventsFromState, findEvent, validateMachine, type EventDef, type StateM
|
|
|
4
4
|
export { AnchorNotFoundError, InvalidTransitionError, MalformedTaskError, OrderMismatchError, TaskAlreadyExistsError, TaskNotFoundError, type ListFilter, type MoveAnchor, type OpenGhIssuesOptions, type OpenMarkdownDirOptions, type OpenTaskListOptions, type OpenYamlFileOptions, type Task, type TaskCreate, type TaskDefaults, type TaskFireOptions, type TaskList, type TaskListFs, type TaskOrder, type TaskState, type Tasks, type TaskUpdate } from "./types.js";
|
|
5
5
|
export type { GhClient, GhClientOptions, ResolveAuthOptions, ResolveEndpointOptions } from "./backends/gh-issues-client.js";
|
|
6
6
|
export type { GhIssuesBackendDeps } from "./backends/gh-issues.js";
|
|
7
|
+
export { GhProjectSyncError, syncGhProject, verifyGhProject, type SyncGhProjectOptions, type SyncGhProjectReport, type VerifyGhProjectOptions, type VerifyGhProjectReport } from "./backends/gh-issues-sync.js";
|
|
@@ -2,3 +2,4 @@ export { openTaskList } from "./open.js";
|
|
|
2
2
|
export { assertEvent, assertTransition, defaultStateMachine } from "./state.js";
|
|
3
3
|
export { eventsFromState, findEvent, validateMachine } from "./state-machine.js";
|
|
4
4
|
export { AnchorNotFoundError, InvalidTransitionError, MalformedTaskError, OrderMismatchError, TaskAlreadyExistsError, TaskNotFoundError } from "./types.js";
|
|
5
|
+
export { GhProjectSyncError, syncGhProject, verifyGhProject } from "./backends/gh-issues-sync.js";
|
|
@@ -29,11 +29,14 @@ async function openFileBackend(options) {
|
|
|
29
29
|
const factory = backendFactories[options.type];
|
|
30
30
|
const stateMachine = resolveStateMachine(options.stateMachine);
|
|
31
31
|
validateMachine(stateMachine);
|
|
32
|
+
const markdownOptions = options.type === "markdown-dir" ? options : undefined;
|
|
32
33
|
const deps = {
|
|
33
34
|
path: options.path,
|
|
34
35
|
defaults: {
|
|
35
36
|
metadata: { ...(options.defaults?.metadata ?? {}) }
|
|
36
37
|
},
|
|
38
|
+
singleList: markdownOptions?.singleList,
|
|
39
|
+
frontmatterMode: markdownOptions?.frontmatterMode ?? "strict",
|
|
37
40
|
lockStaleMs: options.lockStaleMs ?? DEFAULT_LOCK_STALE_MS,
|
|
38
41
|
lockRetries: options.lockRetries ?? DEFAULT_LOCK_RETRIES,
|
|
39
42
|
create: options.create ?? false,
|
|
@@ -91,6 +91,8 @@ export interface OpenMarkdownDirOptions {
|
|
|
91
91
|
path: string;
|
|
92
92
|
defaults?: TaskDefaults;
|
|
93
93
|
create?: boolean;
|
|
94
|
+
singleList?: string;
|
|
95
|
+
frontmatterMode?: "strict" | "passthrough";
|
|
94
96
|
lockStaleMs?: number;
|
|
95
97
|
lockRetries?: number;
|
|
96
98
|
fs?: TaskListFs;
|
|
@@ -122,6 +124,8 @@ export interface OpenGhIssuesOptions {
|
|
|
122
124
|
export interface BackendDeps {
|
|
123
125
|
path: string;
|
|
124
126
|
defaults: Required<TaskDefaults>;
|
|
127
|
+
singleList?: string;
|
|
128
|
+
frontmatterMode: "strict" | "passthrough";
|
|
125
129
|
lockStaleMs: number;
|
|
126
130
|
lockRetries: number;
|
|
127
131
|
create: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolcraft",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"mustache": "^4.2.0",
|
|
50
50
|
"smol-toml": "^1.3.0",
|
|
51
51
|
"tiny-stdio-mcp-server": "^0.1.0",
|
|
52
|
-
"toolcraft-schema": "^0.0.
|
|
52
|
+
"toolcraft-schema": "^0.0.17",
|
|
53
53
|
"yaml": "^2.8.2"
|
|
54
54
|
},
|
|
55
55
|
"files": [
|