@cliangdev/flux-plugin 0.3.1 → 0.4.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/commands/dashboard.md +1 -1
- package/package.json +3 -1
- package/src/server/adapters/factory.ts +6 -28
- package/src/server/adapters/github/__tests__/criteria-deps.test.ts +579 -0
- package/src/server/adapters/github/__tests__/documents-stats.test.ts +789 -0
- package/src/server/adapters/github/__tests__/epic-task-crud.test.ts +1072 -0
- package/src/server/adapters/github/__tests__/foundation.test.ts +537 -0
- package/src/server/adapters/github/__tests__/index-store.test.ts +319 -0
- package/src/server/adapters/github/__tests__/prd-crud.test.ts +836 -0
- package/src/server/adapters/github/adapter.ts +1574 -0
- package/src/server/adapters/github/client.ts +34 -0
- package/src/server/adapters/github/config.ts +59 -0
- package/src/server/adapters/github/helpers/criteria.ts +157 -0
- package/src/server/adapters/github/helpers/index-store.ts +79 -0
- package/src/server/adapters/github/helpers/meta.ts +26 -0
- package/src/server/adapters/github/index.ts +5 -0
- package/src/server/adapters/github/mappers/epic.ts +21 -0
- package/src/server/adapters/github/mappers/index.ts +15 -0
- package/src/server/adapters/github/mappers/prd.ts +50 -0
- package/src/server/adapters/github/mappers/task.ts +37 -0
- package/src/server/adapters/github/types.ts +27 -0
- package/src/server/adapters/linear/adapter.ts +121 -105
- package/src/server/adapters/linear/client.ts +21 -14
- package/src/server/adapters/types.ts +1 -1
- package/src/server/index.ts +2 -0
- package/src/server/tools/__tests__/mcp-interface.test.ts +6 -0
- package/src/server/tools/__tests__/z-configure-github.test.ts +513 -0
- package/src/server/tools/__tests__/z-get-linear-url.test.ts +2 -2
- package/src/server/tools/__tests__/z-init-project.test.ts +168 -0
- package/src/server/tools/configure-github.ts +422 -0
- package/src/server/tools/index.ts +2 -1
- package/src/server/tools/init-project.ts +26 -12
|
@@ -0,0 +1,1574 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AcceptanceCriterion,
|
|
3
|
+
AddCriterionInput,
|
|
4
|
+
BackendAdapter,
|
|
5
|
+
CascadeResult,
|
|
6
|
+
CreateEpicInput,
|
|
7
|
+
CreatePrdInput,
|
|
8
|
+
CreateTaskInput,
|
|
9
|
+
Document,
|
|
10
|
+
Epic,
|
|
11
|
+
EpicFilters,
|
|
12
|
+
PaginatedResult,
|
|
13
|
+
PaginationOptions,
|
|
14
|
+
Prd,
|
|
15
|
+
PrdFilters,
|
|
16
|
+
Priority,
|
|
17
|
+
Stats,
|
|
18
|
+
Task,
|
|
19
|
+
TaskFilters,
|
|
20
|
+
UpdateEpicInput,
|
|
21
|
+
UpdatePrdInput,
|
|
22
|
+
UpdateTaskInput,
|
|
23
|
+
} from "../types.js";
|
|
24
|
+
import { GitHubClient } from "./client.js";
|
|
25
|
+
import {
|
|
26
|
+
addCriterionToDescription,
|
|
27
|
+
generateCriteriaId,
|
|
28
|
+
parseCriteriaFromDescription,
|
|
29
|
+
updateCriterionInDescription,
|
|
30
|
+
} from "./helpers/criteria.js";
|
|
31
|
+
import { readIndex, writeIndex } from "./helpers/index-store.js";
|
|
32
|
+
import {
|
|
33
|
+
decodeMeta,
|
|
34
|
+
encodeMeta,
|
|
35
|
+
extractDescription,
|
|
36
|
+
type FluxMeta,
|
|
37
|
+
} from "./helpers/meta.js";
|
|
38
|
+
import {
|
|
39
|
+
epicStatusToLabel,
|
|
40
|
+
getAllStatusLabels,
|
|
41
|
+
labelToEpicStatus,
|
|
42
|
+
labelToPrdStatus,
|
|
43
|
+
labelToPriority,
|
|
44
|
+
labelToTag,
|
|
45
|
+
labelToTaskStatus,
|
|
46
|
+
prdStatusToLabel,
|
|
47
|
+
priorityToLabel,
|
|
48
|
+
tagToLabel,
|
|
49
|
+
taskStatusToLabel,
|
|
50
|
+
} from "./mappers/index.js";
|
|
51
|
+
import { GITHUB_LABELS, type GitHubConfig } from "./types.js";
|
|
52
|
+
|
|
53
|
+
interface GitHubIssue {
|
|
54
|
+
number: number;
|
|
55
|
+
id: number;
|
|
56
|
+
node_id: string;
|
|
57
|
+
title: string;
|
|
58
|
+
body: string | null;
|
|
59
|
+
state: string;
|
|
60
|
+
labels: Array<{ name: string }>;
|
|
61
|
+
created_at: string;
|
|
62
|
+
updated_at: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface GitHubDirectoryEntry {
|
|
66
|
+
type: string;
|
|
67
|
+
name: string;
|
|
68
|
+
path: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function issueToPrd(issue: GitHubIssue, config: GitHubConfig): Prd {
|
|
72
|
+
const meta = decodeMeta(issue.body || "");
|
|
73
|
+
const labels = issue.labels.map((l) => l.name);
|
|
74
|
+
const ref = meta?.ref ?? `${config.refPrefix}-P${issue.number}`;
|
|
75
|
+
const refSlug = ref.toLowerCase().replace(":", "-");
|
|
76
|
+
const tag = labelToTag(labels) ?? meta?.tag;
|
|
77
|
+
return {
|
|
78
|
+
id: String(issue.number),
|
|
79
|
+
projectId: config.projectId,
|
|
80
|
+
ref,
|
|
81
|
+
title: issue.title,
|
|
82
|
+
description: extractDescription(issue.body || ""),
|
|
83
|
+
status: labelToPrdStatus(labels, issue.state === "closed"),
|
|
84
|
+
tag,
|
|
85
|
+
folderPath: `prds/${refSlug}/`,
|
|
86
|
+
createdAt: issue.created_at,
|
|
87
|
+
updatedAt: issue.updated_at,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class GitHubAdapter implements BackendAdapter {
|
|
92
|
+
private config: GitHubConfig;
|
|
93
|
+
private client: GitHubClient;
|
|
94
|
+
|
|
95
|
+
constructor(config: GitHubConfig) {
|
|
96
|
+
this.config = config;
|
|
97
|
+
this.client = new GitHubClient(config);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async addToIndex(ref: string, issueNumber: number): Promise<void> {
|
|
101
|
+
const index = await readIndex(
|
|
102
|
+
this.client,
|
|
103
|
+
this.config.owner,
|
|
104
|
+
this.config.repo,
|
|
105
|
+
);
|
|
106
|
+
index[ref] = issueNumber;
|
|
107
|
+
await writeIndex(this.client, this.config.owner, this.config.repo, index);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async removeFromIndex(ref: string): Promise<void> {
|
|
111
|
+
const index = await readIndex(
|
|
112
|
+
this.client,
|
|
113
|
+
this.config.owner,
|
|
114
|
+
this.config.repo,
|
|
115
|
+
);
|
|
116
|
+
delete index[ref];
|
|
117
|
+
await writeIndex(this.client, this.config.owner, this.config.repo, index);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async resolveRef(ref: string, entityLabel: string): Promise<number | null> {
|
|
121
|
+
const index = await readIndex(
|
|
122
|
+
this.client,
|
|
123
|
+
this.config.owner,
|
|
124
|
+
this.config.repo,
|
|
125
|
+
);
|
|
126
|
+
if (index[ref] !== undefined) {
|
|
127
|
+
return index[ref];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const response = await this.client.rest.issues.listForRepo({
|
|
131
|
+
owner: this.config.owner,
|
|
132
|
+
repo: this.config.repo,
|
|
133
|
+
labels: entityLabel,
|
|
134
|
+
state: "all",
|
|
135
|
+
per_page: 100,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const issues = response.data as Array<{
|
|
139
|
+
number: number;
|
|
140
|
+
body: string | null;
|
|
141
|
+
}>;
|
|
142
|
+
for (const issue of issues) {
|
|
143
|
+
if (issue.body?.includes(`"ref":"${ref}"`)) {
|
|
144
|
+
await this.addToIndex(ref, issue.number);
|
|
145
|
+
return issue.number;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async addToProjectsBoard(issueNodeId: string): Promise<void> {
|
|
153
|
+
await this.client.graphql(
|
|
154
|
+
`
|
|
155
|
+
mutation AddToProject($projectId: ID!, $contentId: ID!) {
|
|
156
|
+
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
|
|
157
|
+
item { id }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
`,
|
|
161
|
+
{ projectId: this.config.projectId, contentId: issueNodeId },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async createPrd(input: CreatePrdInput): Promise<Prd> {
|
|
166
|
+
const labels: string[] = [
|
|
167
|
+
GITHUB_LABELS.ENTITY_PRD,
|
|
168
|
+
GITHUB_LABELS.STATUS_DRAFT,
|
|
169
|
+
];
|
|
170
|
+
if (input.tag) {
|
|
171
|
+
labels.push(tagToLabel(input.tag));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const tempMeta = encodeMeta({ ref: "TEMP", tag: input.tag });
|
|
175
|
+
const body = input.description
|
|
176
|
+
? `${input.description}\n\n${tempMeta}`
|
|
177
|
+
: tempMeta;
|
|
178
|
+
|
|
179
|
+
const createResponse = await this.client.rest.issues.create({
|
|
180
|
+
owner: this.config.owner,
|
|
181
|
+
repo: this.config.repo,
|
|
182
|
+
title: input.title,
|
|
183
|
+
body,
|
|
184
|
+
labels,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const createdIssue = createResponse.data as GitHubIssue;
|
|
188
|
+
const issueNumber = createdIssue.number;
|
|
189
|
+
const ref = `${this.config.refPrefix}-P${issueNumber}`;
|
|
190
|
+
|
|
191
|
+
const finalMeta = encodeMeta({
|
|
192
|
+
ref,
|
|
193
|
+
...(input.tag ? { tag: input.tag } : {}),
|
|
194
|
+
dependencies: [],
|
|
195
|
+
});
|
|
196
|
+
const finalBody = input.description
|
|
197
|
+
? `${input.description}\n\n${finalMeta}`
|
|
198
|
+
: finalMeta;
|
|
199
|
+
|
|
200
|
+
await this.client.rest.issues.update({
|
|
201
|
+
owner: this.config.owner,
|
|
202
|
+
repo: this.config.repo,
|
|
203
|
+
issue_number: issueNumber,
|
|
204
|
+
body: finalBody,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await this.addToProjectsBoard(createdIssue.node_id);
|
|
208
|
+
await this.addToIndex(ref, issueNumber);
|
|
209
|
+
|
|
210
|
+
const getResponse = await this.client.rest.issues.get({
|
|
211
|
+
owner: this.config.owner,
|
|
212
|
+
repo: this.config.repo,
|
|
213
|
+
issue_number: issueNumber,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return issueToPrd(getResponse.data as GitHubIssue, this.config);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async updatePrd(ref: string, input: UpdatePrdInput): Promise<Prd> {
|
|
220
|
+
const issueNumber = await this.resolveRef(ref, GITHUB_LABELS.ENTITY_PRD);
|
|
221
|
+
if (issueNumber === null) {
|
|
222
|
+
throw new Error(`PRD not found: ${ref}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const getResponse = await this.client.rest.issues.get({
|
|
226
|
+
owner: this.config.owner,
|
|
227
|
+
repo: this.config.repo,
|
|
228
|
+
issue_number: issueNumber,
|
|
229
|
+
});
|
|
230
|
+
const existingIssue = getResponse.data as GitHubIssue;
|
|
231
|
+
const existingLabels: string[] = existingIssue.labels.map((l) => l.name);
|
|
232
|
+
|
|
233
|
+
const statusLabels = new Set(getAllStatusLabels());
|
|
234
|
+
const baseLabels = existingLabels.filter(
|
|
235
|
+
(l) => !statusLabels.has(l) && !l.startsWith(GITHUB_LABELS.TAG_PREFIX),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const newStatus =
|
|
239
|
+
input.status ??
|
|
240
|
+
labelToPrdStatus(existingLabels, existingIssue.state === "closed");
|
|
241
|
+
const statusLabel = prdStatusToLabel(newStatus);
|
|
242
|
+
if (statusLabel) {
|
|
243
|
+
baseLabels.push(statusLabel);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const newTag =
|
|
247
|
+
input.tag !== undefined ? input.tag : labelToTag(existingLabels);
|
|
248
|
+
if (newTag) {
|
|
249
|
+
baseLabels.push(tagToLabel(newTag));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const existingMeta = decodeMeta(existingIssue.body || "");
|
|
253
|
+
const updatedMeta = encodeMeta({
|
|
254
|
+
ref,
|
|
255
|
+
...(existingMeta?.prd_ref ? { prd_ref: existingMeta.prd_ref } : {}),
|
|
256
|
+
...(newTag ? { tag: newTag } : {}),
|
|
257
|
+
...(existingMeta?.dependencies
|
|
258
|
+
? { dependencies: existingMeta.dependencies }
|
|
259
|
+
: {}),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const cleanDescription =
|
|
263
|
+
input.description !== undefined
|
|
264
|
+
? input.description
|
|
265
|
+
: extractDescription(existingIssue.body || "");
|
|
266
|
+
const newBody = cleanDescription
|
|
267
|
+
? `${cleanDescription}\n\n${updatedMeta}`
|
|
268
|
+
: updatedMeta;
|
|
269
|
+
|
|
270
|
+
const updateParams: Record<string, unknown> = {
|
|
271
|
+
owner: this.config.owner,
|
|
272
|
+
repo: this.config.repo,
|
|
273
|
+
issue_number: issueNumber,
|
|
274
|
+
labels: baseLabels,
|
|
275
|
+
body: newBody,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
if (input.title !== undefined) {
|
|
279
|
+
updateParams.title = input.title;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (newStatus === "ARCHIVED") {
|
|
283
|
+
updateParams.state = "closed";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const updateResponse = await this.client.rest.issues.update(
|
|
287
|
+
updateParams as Parameters<typeof this.client.rest.issues.update>[0],
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
return issueToPrd(updateResponse.data as GitHubIssue, this.config);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async getPrd(ref: string): Promise<Prd | null> {
|
|
294
|
+
const issueNumber = await this.resolveRef(ref, GITHUB_LABELS.ENTITY_PRD);
|
|
295
|
+
if (issueNumber === null) return null;
|
|
296
|
+
|
|
297
|
+
const response = await this.client.rest.issues.get({
|
|
298
|
+
owner: this.config.owner,
|
|
299
|
+
repo: this.config.repo,
|
|
300
|
+
issue_number: issueNumber,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
return issueToPrd(response.data as GitHubIssue, this.config);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async listPrds(
|
|
307
|
+
filters?: PrdFilters,
|
|
308
|
+
pagination?: PaginationOptions,
|
|
309
|
+
): Promise<PaginatedResult<Prd>> {
|
|
310
|
+
const limit = pagination?.limit ?? 50;
|
|
311
|
+
const offset = pagination?.offset ?? 0;
|
|
312
|
+
|
|
313
|
+
const labelFilters: string[] = [GITHUB_LABELS.ENTITY_PRD];
|
|
314
|
+
if (filters?.status) {
|
|
315
|
+
const statusLabel = prdStatusToLabel(filters.status);
|
|
316
|
+
if (statusLabel) {
|
|
317
|
+
labelFilters.push(statusLabel);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (filters?.tag) {
|
|
321
|
+
labelFilters.push(tagToLabel(filters.tag));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const response = await this.client.rest.issues.listForRepo({
|
|
325
|
+
owner: this.config.owner,
|
|
326
|
+
repo: this.config.repo,
|
|
327
|
+
labels: labelFilters.join(","),
|
|
328
|
+
state: "all",
|
|
329
|
+
per_page: 100,
|
|
330
|
+
page: 1,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const allIssues = response.data as GitHubIssue[];
|
|
334
|
+
const total = allIssues.length;
|
|
335
|
+
const paginated = allIssues.slice(offset, offset + limit);
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
items: paginated.map((issue) => issueToPrd(issue, this.config)),
|
|
339
|
+
total,
|
|
340
|
+
limit,
|
|
341
|
+
offset,
|
|
342
|
+
hasMore: offset + limit < total,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async deletePrd(
|
|
347
|
+
ref: string,
|
|
348
|
+
): Promise<{ deleted: string; cascade: CascadeResult }> {
|
|
349
|
+
const issueNumber = await this.resolveRef(ref, GITHUB_LABELS.ENTITY_PRD);
|
|
350
|
+
if (issueNumber === null) {
|
|
351
|
+
throw new Error(`PRD not found: ${ref}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let epicCount = 0;
|
|
355
|
+
let taskCount = 0;
|
|
356
|
+
|
|
357
|
+
const epicsResponse = await this.client.rest.issues.listForRepo({
|
|
358
|
+
owner: this.config.owner,
|
|
359
|
+
repo: this.config.repo,
|
|
360
|
+
labels: GITHUB_LABELS.ENTITY_EPIC,
|
|
361
|
+
state: "all",
|
|
362
|
+
per_page: 100,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const epics = (epicsResponse.data as GitHubIssue[]).filter((issue) => {
|
|
366
|
+
const meta = decodeMeta(issue.body || "");
|
|
367
|
+
return meta?.prd_ref === ref;
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
for (const epicIssue of epics) {
|
|
371
|
+
const epicMeta = decodeMeta(epicIssue.body || "");
|
|
372
|
+
const epicRef =
|
|
373
|
+
epicMeta?.ref ?? `${this.config.refPrefix}-E${epicIssue.number}`;
|
|
374
|
+
|
|
375
|
+
const tasksResponse = await this.client.rest.issues.listForRepo({
|
|
376
|
+
owner: this.config.owner,
|
|
377
|
+
repo: this.config.repo,
|
|
378
|
+
labels: GITHUB_LABELS.ENTITY_TASK,
|
|
379
|
+
state: "all",
|
|
380
|
+
per_page: 100,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const tasks = (tasksResponse.data as GitHubIssue[]).filter((issue) => {
|
|
384
|
+
const meta = decodeMeta(issue.body || "");
|
|
385
|
+
return meta?.epic_ref === epicRef;
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
for (const taskIssue of tasks) {
|
|
389
|
+
const taskMeta = decodeMeta(taskIssue.body || "");
|
|
390
|
+
const taskRef =
|
|
391
|
+
taskMeta?.ref ?? `${this.config.refPrefix}-T${taskIssue.number}`;
|
|
392
|
+
|
|
393
|
+
await this.client.rest.issues.update({
|
|
394
|
+
owner: this.config.owner,
|
|
395
|
+
repo: this.config.repo,
|
|
396
|
+
issue_number: taskIssue.number,
|
|
397
|
+
state: "closed",
|
|
398
|
+
});
|
|
399
|
+
await this.removeFromIndex(taskRef);
|
|
400
|
+
taskCount++;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await this.client.rest.issues.update({
|
|
404
|
+
owner: this.config.owner,
|
|
405
|
+
repo: this.config.repo,
|
|
406
|
+
issue_number: epicIssue.number,
|
|
407
|
+
state: "closed",
|
|
408
|
+
});
|
|
409
|
+
await this.removeFromIndex(epicRef);
|
|
410
|
+
epicCount++;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
await this.client.rest.issues.update({
|
|
414
|
+
owner: this.config.owner,
|
|
415
|
+
repo: this.config.repo,
|
|
416
|
+
issue_number: issueNumber,
|
|
417
|
+
state: "closed",
|
|
418
|
+
});
|
|
419
|
+
await this.removeFromIndex(ref);
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
deleted: ref,
|
|
423
|
+
cascade: {
|
|
424
|
+
epics: epicCount,
|
|
425
|
+
tasks: taskCount,
|
|
426
|
+
criteria: 0,
|
|
427
|
+
dependencies: 0,
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private issueToEpic(issue: GitHubIssue): Epic {
|
|
433
|
+
const meta = decodeMeta(issue.body || "");
|
|
434
|
+
const labels = issue.labels.map((l) => l.name);
|
|
435
|
+
return {
|
|
436
|
+
id: String(issue.number),
|
|
437
|
+
prdId: meta?.prd_ref || "",
|
|
438
|
+
ref: meta?.ref || `${this.config.refPrefix}-E${issue.number}`,
|
|
439
|
+
title: issue.title,
|
|
440
|
+
description: extractDescription(issue.body || ""),
|
|
441
|
+
status: labelToEpicStatus(labels, issue.state === "closed"),
|
|
442
|
+
createdAt: issue.created_at,
|
|
443
|
+
updatedAt: issue.updated_at,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private issueToTask(issue: GitHubIssue): Task {
|
|
448
|
+
const meta = decodeMeta(issue.body || "");
|
|
449
|
+
const labels = issue.labels.map((l) => l.name);
|
|
450
|
+
return {
|
|
451
|
+
id: String(issue.number),
|
|
452
|
+
epicId: meta?.epic_ref || "",
|
|
453
|
+
ref: meta?.ref || `${this.config.refPrefix}-T${issue.number}`,
|
|
454
|
+
title: issue.title,
|
|
455
|
+
description: extractDescription(issue.body || ""),
|
|
456
|
+
status: labelToTaskStatus(labels, issue.state === "closed"),
|
|
457
|
+
priority: labelToPriority(labels),
|
|
458
|
+
createdAt: issue.created_at,
|
|
459
|
+
updatedAt: issue.updated_at,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async createEpic(input: CreateEpicInput): Promise<Epic> {
|
|
464
|
+
const prdIssueNum = await this.resolveRef(
|
|
465
|
+
input.prdRef,
|
|
466
|
+
GITHUB_LABELS.ENTITY_PRD,
|
|
467
|
+
);
|
|
468
|
+
if (prdIssueNum === null) {
|
|
469
|
+
throw new Error(`PRD not found: ${input.prdRef}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const labels: string[] = [GITHUB_LABELS.ENTITY_EPIC];
|
|
473
|
+
|
|
474
|
+
const tempMeta = encodeMeta({ ref: "TEMP", prd_ref: input.prdRef });
|
|
475
|
+
const body = input.description
|
|
476
|
+
? `${input.description}\n\n${tempMeta}`
|
|
477
|
+
: tempMeta;
|
|
478
|
+
|
|
479
|
+
const createResponse = await this.client.rest.issues.create({
|
|
480
|
+
owner: this.config.owner,
|
|
481
|
+
repo: this.config.repo,
|
|
482
|
+
title: input.title,
|
|
483
|
+
body,
|
|
484
|
+
labels,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const createdIssue = createResponse.data as GitHubIssue;
|
|
488
|
+
const issueNumber = createdIssue.number;
|
|
489
|
+
const ref = `${this.config.refPrefix}-E${issueNumber}`;
|
|
490
|
+
|
|
491
|
+
const acSection =
|
|
492
|
+
input.acceptanceCriteria && input.acceptanceCriteria.length > 0
|
|
493
|
+
? `\n\n## Acceptance Criteria\n${input.acceptanceCriteria.map((c) => `- [ ] ${c}`).join("\n")}`
|
|
494
|
+
: "";
|
|
495
|
+
|
|
496
|
+
const finalMeta = encodeMeta({
|
|
497
|
+
ref,
|
|
498
|
+
prd_ref: input.prdRef,
|
|
499
|
+
dependencies: [],
|
|
500
|
+
});
|
|
501
|
+
const descriptionPart = input.description ? `${input.description}` : "";
|
|
502
|
+
const finalBody = descriptionPart
|
|
503
|
+
? `${descriptionPart}${acSection}\n\n${finalMeta}`
|
|
504
|
+
: `${acSection ? `${acSection.trim()}\n\n` : ""}${finalMeta}`;
|
|
505
|
+
|
|
506
|
+
await this.client.rest.issues.update({
|
|
507
|
+
owner: this.config.owner,
|
|
508
|
+
repo: this.config.repo,
|
|
509
|
+
issue_number: issueNumber,
|
|
510
|
+
body: finalBody,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
await this.client.rest.request(
|
|
515
|
+
"POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues",
|
|
516
|
+
{
|
|
517
|
+
owner: this.config.owner,
|
|
518
|
+
repo: this.config.repo,
|
|
519
|
+
issue_number: prdIssueNum,
|
|
520
|
+
sub_issue_id: createdIssue.id,
|
|
521
|
+
},
|
|
522
|
+
);
|
|
523
|
+
} catch {
|
|
524
|
+
// sub-issue linking is cosmetic for GitHub UI; hierarchy is stored in flux-meta
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
await this.addToProjectsBoard(createdIssue.node_id);
|
|
528
|
+
await this.addToIndex(ref, issueNumber);
|
|
529
|
+
|
|
530
|
+
const getResponse = await this.client.rest.issues.get({
|
|
531
|
+
owner: this.config.owner,
|
|
532
|
+
repo: this.config.repo,
|
|
533
|
+
issue_number: issueNumber,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
return this.issueToEpic(getResponse.data as GitHubIssue);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async updateEpic(ref: string, input: UpdateEpicInput): Promise<Epic> {
|
|
540
|
+
const issueNumber = await this.resolveRef(ref, GITHUB_LABELS.ENTITY_EPIC);
|
|
541
|
+
if (issueNumber === null) {
|
|
542
|
+
throw new Error(`Epic not found: ${ref}`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const getResponse = await this.client.rest.issues.get({
|
|
546
|
+
owner: this.config.owner,
|
|
547
|
+
repo: this.config.repo,
|
|
548
|
+
issue_number: issueNumber,
|
|
549
|
+
});
|
|
550
|
+
const existingIssue = getResponse.data as GitHubIssue;
|
|
551
|
+
const existingLabels: string[] = existingIssue.labels.map((l) => l.name);
|
|
552
|
+
|
|
553
|
+
const statusLabelToRemove = GITHUB_LABELS.STATUS_IN_PROGRESS;
|
|
554
|
+
const baseLabels = existingLabels.filter((l) => l !== statusLabelToRemove);
|
|
555
|
+
|
|
556
|
+
const newStatus =
|
|
557
|
+
input.status ??
|
|
558
|
+
labelToEpicStatus(existingLabels, existingIssue.state === "closed");
|
|
559
|
+
const statusLabel = epicStatusToLabel(newStatus);
|
|
560
|
+
if (statusLabel) {
|
|
561
|
+
baseLabels.push(statusLabel);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const existingMeta = decodeMeta(existingIssue.body || "");
|
|
565
|
+
const updatedMeta = encodeMeta({
|
|
566
|
+
ref,
|
|
567
|
+
...(existingMeta?.prd_ref ? { prd_ref: existingMeta.prd_ref } : {}),
|
|
568
|
+
...(existingMeta?.dependencies
|
|
569
|
+
? { dependencies: existingMeta.dependencies }
|
|
570
|
+
: {}),
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const cleanDescription =
|
|
574
|
+
input.description !== undefined
|
|
575
|
+
? input.description
|
|
576
|
+
: extractDescription(existingIssue.body || "");
|
|
577
|
+
const newBody = cleanDescription
|
|
578
|
+
? `${cleanDescription}\n\n${updatedMeta}`
|
|
579
|
+
: updatedMeta;
|
|
580
|
+
|
|
581
|
+
const updateParams: Record<string, unknown> = {
|
|
582
|
+
owner: this.config.owner,
|
|
583
|
+
repo: this.config.repo,
|
|
584
|
+
issue_number: issueNumber,
|
|
585
|
+
labels: baseLabels,
|
|
586
|
+
body: newBody,
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
if (input.title !== undefined) {
|
|
590
|
+
updateParams.title = input.title;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (newStatus === "COMPLETED") {
|
|
594
|
+
updateParams.state = "closed";
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const updateResponse = await this.client.rest.issues.update(
|
|
598
|
+
updateParams as Parameters<typeof this.client.rest.issues.update>[0],
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
return this.issueToEpic(updateResponse.data as GitHubIssue);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async getEpic(ref: string): Promise<Epic | null> {
|
|
605
|
+
const issueNumber = await this.resolveRef(ref, GITHUB_LABELS.ENTITY_EPIC);
|
|
606
|
+
if (issueNumber === null) return null;
|
|
607
|
+
|
|
608
|
+
const response = await this.client.rest.issues.get({
|
|
609
|
+
owner: this.config.owner,
|
|
610
|
+
repo: this.config.repo,
|
|
611
|
+
issue_number: issueNumber,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
return this.issueToEpic(response.data as GitHubIssue);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async listEpics(
|
|
618
|
+
filters?: EpicFilters,
|
|
619
|
+
pagination?: PaginationOptions,
|
|
620
|
+
): Promise<PaginatedResult<Epic>> {
|
|
621
|
+
const limit = pagination?.limit ?? 50;
|
|
622
|
+
const offset = pagination?.offset ?? 0;
|
|
623
|
+
|
|
624
|
+
let allIssues: GitHubIssue[];
|
|
625
|
+
|
|
626
|
+
if (filters?.prdRef) {
|
|
627
|
+
const response = await this.client.rest.issues.listForRepo({
|
|
628
|
+
owner: this.config.owner,
|
|
629
|
+
repo: this.config.repo,
|
|
630
|
+
labels: GITHUB_LABELS.ENTITY_EPIC,
|
|
631
|
+
state: "all",
|
|
632
|
+
per_page: 100,
|
|
633
|
+
});
|
|
634
|
+
allIssues = (response.data as GitHubIssue[]).filter((issue) => {
|
|
635
|
+
const meta = decodeMeta(issue.body || "");
|
|
636
|
+
return meta?.prd_ref === filters.prdRef;
|
|
637
|
+
});
|
|
638
|
+
} else {
|
|
639
|
+
const labelFilters: string[] = [GITHUB_LABELS.ENTITY_EPIC];
|
|
640
|
+
if (filters?.status) {
|
|
641
|
+
const statusLabel = epicStatusToLabel(filters.status);
|
|
642
|
+
if (statusLabel) {
|
|
643
|
+
labelFilters.push(statusLabel);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const response = await this.client.rest.issues.listForRepo({
|
|
648
|
+
owner: this.config.owner,
|
|
649
|
+
repo: this.config.repo,
|
|
650
|
+
labels: labelFilters.join(","),
|
|
651
|
+
state: filters?.status === "COMPLETED" ? "closed" : "all",
|
|
652
|
+
per_page: 100,
|
|
653
|
+
page: 1,
|
|
654
|
+
});
|
|
655
|
+
allIssues = response.data as GitHubIssue[];
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (filters?.status) {
|
|
659
|
+
allIssues = allIssues.filter((issue) => {
|
|
660
|
+
const labels = issue.labels.map((l) => l.name);
|
|
661
|
+
return (
|
|
662
|
+
labelToEpicStatus(labels, issue.state === "closed") === filters.status
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const total = allIssues.length;
|
|
668
|
+
const paginated = allIssues.slice(offset, offset + limit);
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
items: paginated.map((issue) => this.issueToEpic(issue)),
|
|
672
|
+
total,
|
|
673
|
+
limit,
|
|
674
|
+
offset,
|
|
675
|
+
hasMore: offset + limit < total,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async deleteEpic(
|
|
680
|
+
ref: string,
|
|
681
|
+
): Promise<{ deleted: string; cascade: CascadeResult }> {
|
|
682
|
+
const issueNumber = await this.resolveRef(ref, GITHUB_LABELS.ENTITY_EPIC);
|
|
683
|
+
if (issueNumber === null) {
|
|
684
|
+
throw new Error(`Epic not found: ${ref}`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
let taskCount = 0;
|
|
688
|
+
|
|
689
|
+
const tasksResponse = await this.client.rest.issues.listForRepo({
|
|
690
|
+
owner: this.config.owner,
|
|
691
|
+
repo: this.config.repo,
|
|
692
|
+
labels: GITHUB_LABELS.ENTITY_TASK,
|
|
693
|
+
state: "all",
|
|
694
|
+
per_page: 100,
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const tasks = (tasksResponse.data as GitHubIssue[]).filter((issue) => {
|
|
698
|
+
const meta = decodeMeta(issue.body || "");
|
|
699
|
+
return meta?.epic_ref === ref;
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
for (const taskIssue of tasks) {
|
|
703
|
+
const taskMeta = decodeMeta(taskIssue.body || "");
|
|
704
|
+
const taskRef =
|
|
705
|
+
taskMeta?.ref ?? `${this.config.refPrefix}-T${taskIssue.number}`;
|
|
706
|
+
|
|
707
|
+
await this.client.rest.issues.update({
|
|
708
|
+
owner: this.config.owner,
|
|
709
|
+
repo: this.config.repo,
|
|
710
|
+
issue_number: taskIssue.number,
|
|
711
|
+
state: "closed",
|
|
712
|
+
});
|
|
713
|
+
await this.removeFromIndex(taskRef);
|
|
714
|
+
taskCount++;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
await this.client.rest.issues.update({
|
|
718
|
+
owner: this.config.owner,
|
|
719
|
+
repo: this.config.repo,
|
|
720
|
+
issue_number: issueNumber,
|
|
721
|
+
state: "closed",
|
|
722
|
+
});
|
|
723
|
+
await this.removeFromIndex(ref);
|
|
724
|
+
|
|
725
|
+
return {
|
|
726
|
+
deleted: ref,
|
|
727
|
+
cascade: {
|
|
728
|
+
tasks: taskCount,
|
|
729
|
+
epics: 0,
|
|
730
|
+
criteria: 0,
|
|
731
|
+
dependencies: 0,
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async createTask(input: CreateTaskInput): Promise<Task> {
|
|
737
|
+
const epicIssueNum = await this.resolveRef(
|
|
738
|
+
input.epicRef,
|
|
739
|
+
GITHUB_LABELS.ENTITY_EPIC,
|
|
740
|
+
);
|
|
741
|
+
if (epicIssueNum === null) {
|
|
742
|
+
throw new Error(`Epic not found: ${input.epicRef}`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const epicResponse = await this.client.rest.issues.get({
|
|
746
|
+
owner: this.config.owner,
|
|
747
|
+
repo: this.config.repo,
|
|
748
|
+
issue_number: epicIssueNum,
|
|
749
|
+
});
|
|
750
|
+
const epicData = epicResponse.data as GitHubIssue;
|
|
751
|
+
const epicMeta = decodeMeta(epicData.body || "");
|
|
752
|
+
const prdRef = epicMeta?.prd_ref || "";
|
|
753
|
+
|
|
754
|
+
const priority: Priority = input.priority ?? "MEDIUM";
|
|
755
|
+
const labels: string[] = [
|
|
756
|
+
GITHUB_LABELS.ENTITY_TASK,
|
|
757
|
+
priorityToLabel(priority),
|
|
758
|
+
];
|
|
759
|
+
|
|
760
|
+
const tempMeta = encodeMeta({
|
|
761
|
+
ref: "TEMP",
|
|
762
|
+
epic_ref: input.epicRef,
|
|
763
|
+
prd_ref: prdRef,
|
|
764
|
+
priority,
|
|
765
|
+
});
|
|
766
|
+
const body = input.description
|
|
767
|
+
? `${input.description}\n\n${tempMeta}`
|
|
768
|
+
: tempMeta;
|
|
769
|
+
|
|
770
|
+
const createResponse = await this.client.rest.issues.create({
|
|
771
|
+
owner: this.config.owner,
|
|
772
|
+
repo: this.config.repo,
|
|
773
|
+
title: input.title,
|
|
774
|
+
body,
|
|
775
|
+
labels,
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const createdIssue = createResponse.data as GitHubIssue;
|
|
779
|
+
const issueNumber = createdIssue.number;
|
|
780
|
+
const ref = `${this.config.refPrefix}-T${issueNumber}`;
|
|
781
|
+
|
|
782
|
+
const acSection =
|
|
783
|
+
input.acceptanceCriteria && input.acceptanceCriteria.length > 0
|
|
784
|
+
? `\n\n## Acceptance Criteria\n${input.acceptanceCriteria.map((c) => `- [ ] ${c}`).join("\n")}`
|
|
785
|
+
: "";
|
|
786
|
+
|
|
787
|
+
const finalMeta = encodeMeta({
|
|
788
|
+
ref,
|
|
789
|
+
epic_ref: input.epicRef,
|
|
790
|
+
prd_ref: prdRef,
|
|
791
|
+
priority,
|
|
792
|
+
dependencies: [],
|
|
793
|
+
});
|
|
794
|
+
const descriptionPart = input.description ? `${input.description}` : "";
|
|
795
|
+
const finalBody = descriptionPart
|
|
796
|
+
? `${descriptionPart}${acSection}\n\n${finalMeta}`
|
|
797
|
+
: `${acSection ? `${acSection.trim()}\n\n` : ""}${finalMeta}`;
|
|
798
|
+
|
|
799
|
+
await this.client.rest.issues.update({
|
|
800
|
+
owner: this.config.owner,
|
|
801
|
+
repo: this.config.repo,
|
|
802
|
+
issue_number: issueNumber,
|
|
803
|
+
body: finalBody,
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
try {
|
|
807
|
+
await this.client.rest.request(
|
|
808
|
+
"POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues",
|
|
809
|
+
{
|
|
810
|
+
owner: this.config.owner,
|
|
811
|
+
repo: this.config.repo,
|
|
812
|
+
issue_number: epicIssueNum,
|
|
813
|
+
sub_issue_id: createdIssue.id,
|
|
814
|
+
},
|
|
815
|
+
);
|
|
816
|
+
} catch {
|
|
817
|
+
// sub-issue linking is cosmetic for GitHub UI; hierarchy is in flux-meta
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
await this.addToProjectsBoard(createdIssue.node_id);
|
|
821
|
+
await this.addToIndex(ref, issueNumber);
|
|
822
|
+
|
|
823
|
+
const getResponse = await this.client.rest.issues.get({
|
|
824
|
+
owner: this.config.owner,
|
|
825
|
+
repo: this.config.repo,
|
|
826
|
+
issue_number: issueNumber,
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
return this.issueToTask(getResponse.data as GitHubIssue);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async updateTask(ref: string, input: UpdateTaskInput): Promise<Task> {
|
|
833
|
+
const issueNumber = await this.resolveRef(ref, GITHUB_LABELS.ENTITY_TASK);
|
|
834
|
+
if (issueNumber === null) {
|
|
835
|
+
throw new Error(`Task not found: ${ref}`);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const getResponse = await this.client.rest.issues.get({
|
|
839
|
+
owner: this.config.owner,
|
|
840
|
+
repo: this.config.repo,
|
|
841
|
+
issue_number: issueNumber,
|
|
842
|
+
});
|
|
843
|
+
const existingIssue = getResponse.data as GitHubIssue;
|
|
844
|
+
const existingLabels: string[] = existingIssue.labels.map((l) => l.name);
|
|
845
|
+
|
|
846
|
+
const priorityLabels = new Set<string>([
|
|
847
|
+
GITHUB_LABELS.PRIORITY_LOW,
|
|
848
|
+
GITHUB_LABELS.PRIORITY_MEDIUM,
|
|
849
|
+
GITHUB_LABELS.PRIORITY_HIGH,
|
|
850
|
+
]);
|
|
851
|
+
const baseLabels = existingLabels.filter(
|
|
852
|
+
(l) => l !== GITHUB_LABELS.STATUS_IN_PROGRESS && !priorityLabels.has(l),
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
const newStatus =
|
|
856
|
+
input.status ??
|
|
857
|
+
labelToTaskStatus(existingLabels, existingIssue.state === "closed");
|
|
858
|
+
const statusLabel = taskStatusToLabel(newStatus);
|
|
859
|
+
if (statusLabel) {
|
|
860
|
+
baseLabels.push(statusLabel);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const newPriority = input.priority ?? labelToPriority(existingLabels);
|
|
864
|
+
baseLabels.push(priorityToLabel(newPriority));
|
|
865
|
+
|
|
866
|
+
const existingMeta = decodeMeta(existingIssue.body || "");
|
|
867
|
+
const updatedMeta = encodeMeta({
|
|
868
|
+
ref,
|
|
869
|
+
...(existingMeta?.epic_ref ? { epic_ref: existingMeta.epic_ref } : {}),
|
|
870
|
+
...(existingMeta?.prd_ref ? { prd_ref: existingMeta.prd_ref } : {}),
|
|
871
|
+
priority: newPriority,
|
|
872
|
+
...(existingMeta?.dependencies
|
|
873
|
+
? { dependencies: existingMeta.dependencies }
|
|
874
|
+
: {}),
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
const cleanDescription =
|
|
878
|
+
input.description !== undefined
|
|
879
|
+
? input.description
|
|
880
|
+
: extractDescription(existingIssue.body || "");
|
|
881
|
+
const newBody = cleanDescription
|
|
882
|
+
? `${cleanDescription}\n\n${updatedMeta}`
|
|
883
|
+
: updatedMeta;
|
|
884
|
+
|
|
885
|
+
const updateParams: Record<string, unknown> = {
|
|
886
|
+
owner: this.config.owner,
|
|
887
|
+
repo: this.config.repo,
|
|
888
|
+
issue_number: issueNumber,
|
|
889
|
+
labels: baseLabels,
|
|
890
|
+
body: newBody,
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
if (input.title !== undefined) {
|
|
894
|
+
updateParams.title = input.title;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (newStatus === "COMPLETED") {
|
|
898
|
+
updateParams.state = "closed";
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const updateResponse = await this.client.rest.issues.update(
|
|
902
|
+
updateParams as Parameters<typeof this.client.rest.issues.update>[0],
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
return this.issueToTask(updateResponse.data as GitHubIssue);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async getTask(ref: string): Promise<Task | null> {
|
|
909
|
+
const issueNumber = await this.resolveRef(ref, GITHUB_LABELS.ENTITY_TASK);
|
|
910
|
+
if (issueNumber === null) return null;
|
|
911
|
+
|
|
912
|
+
const response = await this.client.rest.issues.get({
|
|
913
|
+
owner: this.config.owner,
|
|
914
|
+
repo: this.config.repo,
|
|
915
|
+
issue_number: issueNumber,
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
return this.issueToTask(response.data as GitHubIssue);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async listTasks(
|
|
922
|
+
filters?: TaskFilters,
|
|
923
|
+
pagination?: PaginationOptions,
|
|
924
|
+
): Promise<PaginatedResult<Task>> {
|
|
925
|
+
const limit = pagination?.limit ?? 50;
|
|
926
|
+
const offset = pagination?.offset ?? 0;
|
|
927
|
+
|
|
928
|
+
let allIssues: GitHubIssue[];
|
|
929
|
+
|
|
930
|
+
if (filters?.epicRef) {
|
|
931
|
+
const response = await this.client.rest.issues.listForRepo({
|
|
932
|
+
owner: this.config.owner,
|
|
933
|
+
repo: this.config.repo,
|
|
934
|
+
labels: GITHUB_LABELS.ENTITY_TASK,
|
|
935
|
+
state: "all",
|
|
936
|
+
per_page: 100,
|
|
937
|
+
});
|
|
938
|
+
allIssues = (response.data as GitHubIssue[]).filter((issue) => {
|
|
939
|
+
const meta = decodeMeta(issue.body || "");
|
|
940
|
+
return meta?.epic_ref === filters.epicRef;
|
|
941
|
+
});
|
|
942
|
+
} else {
|
|
943
|
+
const labelFilters: string[] = [GITHUB_LABELS.ENTITY_TASK];
|
|
944
|
+
if (filters?.status) {
|
|
945
|
+
const statusLabel = taskStatusToLabel(filters.status);
|
|
946
|
+
if (statusLabel) {
|
|
947
|
+
labelFilters.push(statusLabel);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (filters?.priority) {
|
|
951
|
+
labelFilters.push(priorityToLabel(filters.priority));
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const response = await this.client.rest.issues.listForRepo({
|
|
955
|
+
owner: this.config.owner,
|
|
956
|
+
repo: this.config.repo,
|
|
957
|
+
labels: labelFilters.join(","),
|
|
958
|
+
state: filters?.status === "COMPLETED" ? "closed" : "all",
|
|
959
|
+
per_page: 100,
|
|
960
|
+
page: 1,
|
|
961
|
+
});
|
|
962
|
+
allIssues = response.data as GitHubIssue[];
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (filters?.status) {
|
|
966
|
+
allIssues = allIssues.filter((issue) => {
|
|
967
|
+
const labels = issue.labels.map((l) => l.name);
|
|
968
|
+
return (
|
|
969
|
+
labelToTaskStatus(labels, issue.state === "closed") === filters.status
|
|
970
|
+
);
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const total = allIssues.length;
|
|
975
|
+
const paginated = allIssues.slice(offset, offset + limit);
|
|
976
|
+
|
|
977
|
+
return {
|
|
978
|
+
items: paginated.map((issue) => this.issueToTask(issue)),
|
|
979
|
+
total,
|
|
980
|
+
limit,
|
|
981
|
+
offset,
|
|
982
|
+
hasMore: offset + limit < total,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async deleteTask(
|
|
987
|
+
ref: string,
|
|
988
|
+
): Promise<{ deleted: string; cascade: CascadeResult }> {
|
|
989
|
+
const issueNumber = await this.resolveRef(ref, GITHUB_LABELS.ENTITY_TASK);
|
|
990
|
+
if (issueNumber === null) {
|
|
991
|
+
throw new Error(`Task not found: ${ref}`);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
await this.client.rest.issues.update({
|
|
995
|
+
owner: this.config.owner,
|
|
996
|
+
repo: this.config.repo,
|
|
997
|
+
issue_number: issueNumber,
|
|
998
|
+
state: "closed",
|
|
999
|
+
});
|
|
1000
|
+
await this.removeFromIndex(ref);
|
|
1001
|
+
|
|
1002
|
+
return {
|
|
1003
|
+
deleted: ref,
|
|
1004
|
+
cascade: {
|
|
1005
|
+
tasks: 0,
|
|
1006
|
+
epics: 0,
|
|
1007
|
+
criteria: 0,
|
|
1008
|
+
dependencies: 0,
|
|
1009
|
+
},
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
private getEntityLabel(ref: string): string {
|
|
1014
|
+
if (ref.includes("-P")) return GITHUB_LABELS.ENTITY_PRD;
|
|
1015
|
+
if (ref.includes("-E")) return GITHUB_LABELS.ENTITY_EPIC;
|
|
1016
|
+
if (ref.includes("-T")) return GITHUB_LABELS.ENTITY_TASK;
|
|
1017
|
+
throw new Error(`Cannot determine entity type from ref: ${ref}`);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private getParentType(ref: string): "epic" | "task" {
|
|
1021
|
+
if (ref.includes("-E")) return "epic";
|
|
1022
|
+
return "task";
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
private updateMetaInBody(body: string, newMeta: FluxMeta): string {
|
|
1026
|
+
const newMetaBlock = encodeMeta(newMeta);
|
|
1027
|
+
if (body.includes("<!-- flux-meta")) {
|
|
1028
|
+
return body.replace(/<!--\s*flux-meta[\s\S]*?-->/, newMetaBlock);
|
|
1029
|
+
}
|
|
1030
|
+
return `${body}\n\n${newMetaBlock}`;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async addCriterion(input: AddCriterionInput): Promise<AcceptanceCriterion> {
|
|
1034
|
+
const entityLabel = this.getEntityLabel(input.parentRef);
|
|
1035
|
+
const issueNumber = await this.resolveRef(input.parentRef, entityLabel);
|
|
1036
|
+
if (issueNumber === null) {
|
|
1037
|
+
throw new Error(`Entity not found: ${input.parentRef}`);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const getResponse = await this.client.rest.issues.get({
|
|
1041
|
+
owner: this.config.owner,
|
|
1042
|
+
repo: this.config.repo,
|
|
1043
|
+
issue_number: issueNumber,
|
|
1044
|
+
});
|
|
1045
|
+
const currentBody = (getResponse.data as GitHubIssue).body ?? "";
|
|
1046
|
+
|
|
1047
|
+
const newBody = addCriterionToDescription(currentBody, input.criteria);
|
|
1048
|
+
|
|
1049
|
+
await this.client.rest.issues.update({
|
|
1050
|
+
owner: this.config.owner,
|
|
1051
|
+
repo: this.config.repo,
|
|
1052
|
+
issue_number: issueNumber,
|
|
1053
|
+
body: newBody,
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
return {
|
|
1057
|
+
id: generateCriteriaId(input.criteria),
|
|
1058
|
+
parentType: this.getParentType(input.parentRef),
|
|
1059
|
+
parentId: issueNumber.toString(),
|
|
1060
|
+
criteria: input.criteria,
|
|
1061
|
+
isMet: false,
|
|
1062
|
+
createdAt: new Date().toISOString(),
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async markCriterionMet(criterionId: string): Promise<AcceptanceCriterion> {
|
|
1067
|
+
const allIssues: GitHubIssue[] = [];
|
|
1068
|
+
|
|
1069
|
+
const epicResponse = await this.client.rest.issues.listForRepo({
|
|
1070
|
+
owner: this.config.owner,
|
|
1071
|
+
repo: this.config.repo,
|
|
1072
|
+
labels: GITHUB_LABELS.ENTITY_EPIC,
|
|
1073
|
+
state: "open",
|
|
1074
|
+
per_page: 100,
|
|
1075
|
+
});
|
|
1076
|
+
allIssues.push(...(epicResponse.data as GitHubIssue[]));
|
|
1077
|
+
|
|
1078
|
+
const taskResponse = await this.client.rest.issues.listForRepo({
|
|
1079
|
+
owner: this.config.owner,
|
|
1080
|
+
repo: this.config.repo,
|
|
1081
|
+
labels: GITHUB_LABELS.ENTITY_TASK,
|
|
1082
|
+
state: "open",
|
|
1083
|
+
per_page: 100,
|
|
1084
|
+
});
|
|
1085
|
+
allIssues.push(...(taskResponse.data as GitHubIssue[]));
|
|
1086
|
+
|
|
1087
|
+
for (const issue of allIssues) {
|
|
1088
|
+
const body = issue.body ?? "";
|
|
1089
|
+
const criteria = parseCriteriaFromDescription(body);
|
|
1090
|
+
const found = criteria.find((c) => c.id === criterionId);
|
|
1091
|
+
if (found) {
|
|
1092
|
+
const newBody = updateCriterionInDescription(body, criterionId, true);
|
|
1093
|
+
await this.client.rest.issues.update({
|
|
1094
|
+
owner: this.config.owner,
|
|
1095
|
+
repo: this.config.repo,
|
|
1096
|
+
issue_number: issue.number,
|
|
1097
|
+
body: newBody,
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
const labels: string[] = issue.labels.map((l) => l.name);
|
|
1101
|
+
const parentType = labels.includes(GITHUB_LABELS.ENTITY_EPIC)
|
|
1102
|
+
? "epic"
|
|
1103
|
+
: "task";
|
|
1104
|
+
|
|
1105
|
+
return {
|
|
1106
|
+
id: criterionId,
|
|
1107
|
+
parentType,
|
|
1108
|
+
parentId: String(issue.number),
|
|
1109
|
+
criteria: found.text,
|
|
1110
|
+
isMet: true,
|
|
1111
|
+
createdAt: new Date().toISOString(),
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
throw new Error(`Criterion not found: ${criterionId}`);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
async getCriteria(parentRef: string): Promise<AcceptanceCriterion[]> {
|
|
1120
|
+
const entityLabel = this.getEntityLabel(parentRef);
|
|
1121
|
+
const issueNumber = await this.resolveRef(parentRef, entityLabel);
|
|
1122
|
+
if (issueNumber === null) {
|
|
1123
|
+
throw new Error(`Entity not found: ${parentRef}`);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const response = await this.client.rest.issues.get({
|
|
1127
|
+
owner: this.config.owner,
|
|
1128
|
+
repo: this.config.repo,
|
|
1129
|
+
issue_number: issueNumber,
|
|
1130
|
+
});
|
|
1131
|
+
const body = (response.data as GitHubIssue).body ?? "";
|
|
1132
|
+
const parsed = parseCriteriaFromDescription(body);
|
|
1133
|
+
const parentType = this.getParentType(parentRef);
|
|
1134
|
+
|
|
1135
|
+
return parsed.map((c) => ({
|
|
1136
|
+
id: c.id,
|
|
1137
|
+
parentType,
|
|
1138
|
+
parentId: issueNumber.toString(),
|
|
1139
|
+
criteria: c.text,
|
|
1140
|
+
isMet: c.isMet,
|
|
1141
|
+
createdAt: new Date().toISOString(),
|
|
1142
|
+
}));
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
async addDependency(ref: string, dependsOnRef: string): Promise<void> {
|
|
1146
|
+
if (ref === dependsOnRef) {
|
|
1147
|
+
throw new Error("Cannot add self-dependency");
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const refType = ref.includes("-P")
|
|
1151
|
+
? "prd"
|
|
1152
|
+
: ref.includes("-E")
|
|
1153
|
+
? "epic"
|
|
1154
|
+
: "task";
|
|
1155
|
+
const depType = dependsOnRef.includes("-P")
|
|
1156
|
+
? "prd"
|
|
1157
|
+
: dependsOnRef.includes("-E")
|
|
1158
|
+
? "epic"
|
|
1159
|
+
: "task";
|
|
1160
|
+
|
|
1161
|
+
if (refType !== depType) {
|
|
1162
|
+
throw new Error("Dependencies must be between entities of the same type");
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const entityLabel = this.getEntityLabel(ref);
|
|
1166
|
+
const issueNumber = await this.resolveRef(ref, entityLabel);
|
|
1167
|
+
if (issueNumber === null) {
|
|
1168
|
+
throw new Error(`Entity not found: ${ref}`);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const getResponse = await this.client.rest.issues.get({
|
|
1172
|
+
owner: this.config.owner,
|
|
1173
|
+
repo: this.config.repo,
|
|
1174
|
+
issue_number: issueNumber,
|
|
1175
|
+
});
|
|
1176
|
+
const currentBody = (getResponse.data as GitHubIssue).body ?? "";
|
|
1177
|
+
const meta = decodeMeta(currentBody);
|
|
1178
|
+
|
|
1179
|
+
if (!meta) {
|
|
1180
|
+
throw new Error(`No flux-meta found in issue for ref: ${ref}`);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const existing = meta.dependencies ?? [];
|
|
1184
|
+
if (!existing.includes(dependsOnRef)) {
|
|
1185
|
+
meta.dependencies = [...existing, dependsOnRef];
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const newBody = this.updateMetaInBody(currentBody, meta);
|
|
1189
|
+
await this.client.rest.issues.update({
|
|
1190
|
+
owner: this.config.owner,
|
|
1191
|
+
repo: this.config.repo,
|
|
1192
|
+
issue_number: issueNumber,
|
|
1193
|
+
body: newBody,
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
async removeDependency(ref: string, dependsOnRef: string): Promise<void> {
|
|
1198
|
+
const entityLabel = this.getEntityLabel(ref);
|
|
1199
|
+
const issueNumber = await this.resolveRef(ref, entityLabel);
|
|
1200
|
+
if (issueNumber === null) {
|
|
1201
|
+
throw new Error(`Entity not found: ${ref}`);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const getResponse = await this.client.rest.issues.get({
|
|
1205
|
+
owner: this.config.owner,
|
|
1206
|
+
repo: this.config.repo,
|
|
1207
|
+
issue_number: issueNumber,
|
|
1208
|
+
});
|
|
1209
|
+
const currentBody = (getResponse.data as GitHubIssue).body ?? "";
|
|
1210
|
+
const meta = decodeMeta(currentBody);
|
|
1211
|
+
|
|
1212
|
+
if (!meta) {
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
meta.dependencies = (meta.dependencies ?? []).filter(
|
|
1217
|
+
(d) => d !== dependsOnRef,
|
|
1218
|
+
);
|
|
1219
|
+
|
|
1220
|
+
const newBody = this.updateMetaInBody(currentBody, meta);
|
|
1221
|
+
await this.client.rest.issues.update({
|
|
1222
|
+
owner: this.config.owner,
|
|
1223
|
+
repo: this.config.repo,
|
|
1224
|
+
issue_number: issueNumber,
|
|
1225
|
+
body: newBody,
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async getDependencies(ref: string): Promise<string[]> {
|
|
1230
|
+
const entityLabel = this.getEntityLabel(ref);
|
|
1231
|
+
const issueNumber = await this.resolveRef(ref, entityLabel);
|
|
1232
|
+
if (issueNumber === null) {
|
|
1233
|
+
throw new Error(`Entity not found: ${ref}`);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const response = await this.client.rest.issues.get({
|
|
1237
|
+
owner: this.config.owner,
|
|
1238
|
+
repo: this.config.repo,
|
|
1239
|
+
issue_number: issueNumber,
|
|
1240
|
+
});
|
|
1241
|
+
const body = (response.data as GitHubIssue).body ?? "";
|
|
1242
|
+
const meta = decodeMeta(body);
|
|
1243
|
+
|
|
1244
|
+
return meta?.dependencies ?? [];
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
private refSlug(ref: string): string {
|
|
1248
|
+
return ref.toLowerCase().replace(":", "-");
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
private blobUrl(path: string): string {
|
|
1252
|
+
return `https://github.com/${this.config.owner}/${this.config.repo}/blob/main/${path}`;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
private async getFileSha(path: string): Promise<string | null> {
|
|
1256
|
+
try {
|
|
1257
|
+
const response = await this.client.rest.repos.getContent({
|
|
1258
|
+
owner: this.config.owner,
|
|
1259
|
+
repo: this.config.repo,
|
|
1260
|
+
path,
|
|
1261
|
+
});
|
|
1262
|
+
const data = response.data as { sha: string };
|
|
1263
|
+
return data.sha;
|
|
1264
|
+
} catch (err: unknown) {
|
|
1265
|
+
if ((err as { status?: number }).status === 404) return null;
|
|
1266
|
+
throw err;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
private addDocumentLink(body: string, filename: string, url: string): string {
|
|
1271
|
+
const link = `- [${filename}](${url})`;
|
|
1272
|
+
const sectionHeader = "## Supporting Documents";
|
|
1273
|
+
const sectionIndex = body.indexOf(sectionHeader);
|
|
1274
|
+
|
|
1275
|
+
if (sectionIndex === -1) {
|
|
1276
|
+
return `${body}\n\n${sectionHeader}\n\n${link}`;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const before = body.slice(0, sectionIndex + sectionHeader.length);
|
|
1280
|
+
const after = body.slice(sectionIndex + sectionHeader.length);
|
|
1281
|
+
|
|
1282
|
+
const escapedFilename = filename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1283
|
+
const existingLinkPattern = new RegExp(
|
|
1284
|
+
`- \\[${escapedFilename}\\]\\([^)]+\\)`,
|
|
1285
|
+
);
|
|
1286
|
+
|
|
1287
|
+
if (existingLinkPattern.test(after)) {
|
|
1288
|
+
return before + after.replace(existingLinkPattern, link);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const doubleNewlineIdx = after.search(/\n\n/);
|
|
1292
|
+
if (doubleNewlineIdx === -1) {
|
|
1293
|
+
return `${before}${after}\n${link}`;
|
|
1294
|
+
}
|
|
1295
|
+
return (
|
|
1296
|
+
before +
|
|
1297
|
+
after.slice(0, doubleNewlineIdx) +
|
|
1298
|
+
"\n" +
|
|
1299
|
+
link +
|
|
1300
|
+
after.slice(doubleNewlineIdx)
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
private removeDocumentLink(body: string, filename: string): string {
|
|
1305
|
+
const escapedFilename = filename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1306
|
+
const linkPattern = new RegExp(`\n- \\[${escapedFilename}\\]\\([^)]+\\)`);
|
|
1307
|
+
return body.replace(linkPattern, "");
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
async saveDocument(doc: Document): Promise<Document> {
|
|
1311
|
+
const issueNumber = await this.resolveRef(
|
|
1312
|
+
doc.prdRef,
|
|
1313
|
+
GITHUB_LABELS.ENTITY_PRD,
|
|
1314
|
+
);
|
|
1315
|
+
if (issueNumber === null) {
|
|
1316
|
+
throw new Error(`PRD not found: ${doc.prdRef}`);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const slug = this.refSlug(doc.prdRef);
|
|
1320
|
+
const filePath = `prds/${slug}/${doc.filename}`;
|
|
1321
|
+
const existingSha = await this.getFileSha(filePath);
|
|
1322
|
+
|
|
1323
|
+
const putParams: Record<string, unknown> = {
|
|
1324
|
+
owner: this.config.owner,
|
|
1325
|
+
repo: this.config.repo,
|
|
1326
|
+
path: filePath,
|
|
1327
|
+
message: `docs: save ${doc.filename} for ${doc.prdRef}`,
|
|
1328
|
+
content: Buffer.from(doc.content).toString("base64"),
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1331
|
+
if (existingSha !== null) {
|
|
1332
|
+
putParams.sha = existingSha;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
await this.client.rest.repos.createOrUpdateFileContents(
|
|
1336
|
+
putParams as Parameters<
|
|
1337
|
+
typeof this.client.rest.repos.createOrUpdateFileContents
|
|
1338
|
+
>[0],
|
|
1339
|
+
);
|
|
1340
|
+
|
|
1341
|
+
const blobUrl = this.blobUrl(filePath);
|
|
1342
|
+
|
|
1343
|
+
const issueResponse = await this.client.rest.issues.get({
|
|
1344
|
+
owner: this.config.owner,
|
|
1345
|
+
repo: this.config.repo,
|
|
1346
|
+
issue_number: issueNumber,
|
|
1347
|
+
});
|
|
1348
|
+
const currentBody = (issueResponse.data as GitHubIssue).body ?? "";
|
|
1349
|
+
const updatedBody = this.addDocumentLink(
|
|
1350
|
+
currentBody,
|
|
1351
|
+
doc.filename,
|
|
1352
|
+
blobUrl,
|
|
1353
|
+
);
|
|
1354
|
+
|
|
1355
|
+
await this.client.rest.issues.update({
|
|
1356
|
+
owner: this.config.owner,
|
|
1357
|
+
repo: this.config.repo,
|
|
1358
|
+
issue_number: issueNumber,
|
|
1359
|
+
body: updatedBody,
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
return { ...doc, url: blobUrl };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
async getDocuments(prdRef: string): Promise<Document[]> {
|
|
1366
|
+
const slug = this.refSlug(prdRef);
|
|
1367
|
+
const dirPath = `prds/${slug}`;
|
|
1368
|
+
|
|
1369
|
+
let entries: GitHubDirectoryEntry[];
|
|
1370
|
+
try {
|
|
1371
|
+
const response = await this.client.rest.repos.getContent({
|
|
1372
|
+
owner: this.config.owner,
|
|
1373
|
+
repo: this.config.repo,
|
|
1374
|
+
path: dirPath,
|
|
1375
|
+
});
|
|
1376
|
+
entries = response.data as GitHubDirectoryEntry[];
|
|
1377
|
+
} catch (err: unknown) {
|
|
1378
|
+
if ((err as { status?: number }).status === 404) return [];
|
|
1379
|
+
throw err;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const mdFiles = entries.filter(
|
|
1383
|
+
(entry) => entry.type === "file" && entry.name.endsWith(".md"),
|
|
1384
|
+
);
|
|
1385
|
+
|
|
1386
|
+
const documents: Document[] = [];
|
|
1387
|
+
for (const file of mdFiles) {
|
|
1388
|
+
const fileResponse = await this.client.rest.repos.getContent({
|
|
1389
|
+
owner: this.config.owner,
|
|
1390
|
+
repo: this.config.repo,
|
|
1391
|
+
path: file.path,
|
|
1392
|
+
});
|
|
1393
|
+
const fileData = fileResponse.data as {
|
|
1394
|
+
content: string;
|
|
1395
|
+
encoding: string;
|
|
1396
|
+
};
|
|
1397
|
+
const content = Buffer.from(fileData.content, "base64").toString("utf-8");
|
|
1398
|
+
|
|
1399
|
+
documents.push({
|
|
1400
|
+
prdRef,
|
|
1401
|
+
filename: file.name,
|
|
1402
|
+
content,
|
|
1403
|
+
url: this.blobUrl(file.path),
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
return documents;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
async deleteDocument(prdRef: string, filename: string): Promise<void> {
|
|
1411
|
+
const issueNumber = await this.resolveRef(prdRef, GITHUB_LABELS.ENTITY_PRD);
|
|
1412
|
+
if (issueNumber === null) {
|
|
1413
|
+
throw new Error(`PRD not found: ${prdRef}`);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const slug = this.refSlug(prdRef);
|
|
1417
|
+
const filePath = `prds/${slug}/${filename}`;
|
|
1418
|
+
const sha = await this.getFileSha(filePath);
|
|
1419
|
+
|
|
1420
|
+
if (sha !== null) {
|
|
1421
|
+
await this.client.rest.repos.deleteFile({
|
|
1422
|
+
owner: this.config.owner,
|
|
1423
|
+
repo: this.config.repo,
|
|
1424
|
+
path: filePath,
|
|
1425
|
+
message: `docs: delete ${filename}`,
|
|
1426
|
+
sha,
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const issueResponse = await this.client.rest.issues.get({
|
|
1431
|
+
owner: this.config.owner,
|
|
1432
|
+
repo: this.config.repo,
|
|
1433
|
+
issue_number: issueNumber,
|
|
1434
|
+
});
|
|
1435
|
+
const currentBody = (issueResponse.data as GitHubIssue).body ?? "";
|
|
1436
|
+
const updatedBody = this.removeDocumentLink(currentBody, filename);
|
|
1437
|
+
|
|
1438
|
+
await this.client.rest.issues.update({
|
|
1439
|
+
owner: this.config.owner,
|
|
1440
|
+
repo: this.config.repo,
|
|
1441
|
+
issue_number: issueNumber,
|
|
1442
|
+
body: updatedBody,
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
async getStats(): Promise<Stats> {
|
|
1447
|
+
const [
|
|
1448
|
+
openPrds,
|
|
1449
|
+
closedPrds,
|
|
1450
|
+
openEpics,
|
|
1451
|
+
closedEpics,
|
|
1452
|
+
openTasks,
|
|
1453
|
+
closedTasks,
|
|
1454
|
+
] = await Promise.all([
|
|
1455
|
+
this.client.rest.issues.listForRepo({
|
|
1456
|
+
owner: this.config.owner,
|
|
1457
|
+
repo: this.config.repo,
|
|
1458
|
+
labels: GITHUB_LABELS.ENTITY_PRD,
|
|
1459
|
+
state: "open",
|
|
1460
|
+
per_page: 100,
|
|
1461
|
+
}),
|
|
1462
|
+
this.client.rest.issues.listForRepo({
|
|
1463
|
+
owner: this.config.owner,
|
|
1464
|
+
repo: this.config.repo,
|
|
1465
|
+
labels: GITHUB_LABELS.ENTITY_PRD,
|
|
1466
|
+
state: "closed",
|
|
1467
|
+
per_page: 100,
|
|
1468
|
+
}),
|
|
1469
|
+
this.client.rest.issues.listForRepo({
|
|
1470
|
+
owner: this.config.owner,
|
|
1471
|
+
repo: this.config.repo,
|
|
1472
|
+
labels: GITHUB_LABELS.ENTITY_EPIC,
|
|
1473
|
+
state: "open",
|
|
1474
|
+
per_page: 100,
|
|
1475
|
+
}),
|
|
1476
|
+
this.client.rest.issues.listForRepo({
|
|
1477
|
+
owner: this.config.owner,
|
|
1478
|
+
repo: this.config.repo,
|
|
1479
|
+
labels: GITHUB_LABELS.ENTITY_EPIC,
|
|
1480
|
+
state: "closed",
|
|
1481
|
+
per_page: 100,
|
|
1482
|
+
}),
|
|
1483
|
+
this.client.rest.issues.listForRepo({
|
|
1484
|
+
owner: this.config.owner,
|
|
1485
|
+
repo: this.config.repo,
|
|
1486
|
+
labels: GITHUB_LABELS.ENTITY_TASK,
|
|
1487
|
+
state: "open",
|
|
1488
|
+
per_page: 100,
|
|
1489
|
+
}),
|
|
1490
|
+
this.client.rest.issues.listForRepo({
|
|
1491
|
+
owner: this.config.owner,
|
|
1492
|
+
repo: this.config.repo,
|
|
1493
|
+
labels: GITHUB_LABELS.ENTITY_TASK,
|
|
1494
|
+
state: "closed",
|
|
1495
|
+
per_page: 100,
|
|
1496
|
+
}),
|
|
1497
|
+
]);
|
|
1498
|
+
|
|
1499
|
+
const openPrdIssues = openPrds.data as GitHubIssue[];
|
|
1500
|
+
const closedPrdIssues = closedPrds.data as GitHubIssue[];
|
|
1501
|
+
const openEpicIssues = openEpics.data as GitHubIssue[];
|
|
1502
|
+
const closedEpicIssues = closedEpics.data as GitHubIssue[];
|
|
1503
|
+
const openTaskIssues = openTasks.data as GitHubIssue[];
|
|
1504
|
+
const closedTaskIssues = closedTasks.data as GitHubIssue[];
|
|
1505
|
+
|
|
1506
|
+
const prdStats = {
|
|
1507
|
+
total: openPrdIssues.length,
|
|
1508
|
+
draft: 0,
|
|
1509
|
+
pendingReview: 0,
|
|
1510
|
+
reviewed: 0,
|
|
1511
|
+
approved: 0,
|
|
1512
|
+
breakdownReady: 0,
|
|
1513
|
+
completed: 0,
|
|
1514
|
+
archived: closedPrdIssues.length,
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
for (const issue of openPrdIssues) {
|
|
1518
|
+
const labels = issue.labels.map((l) => l.name);
|
|
1519
|
+
if (labels.includes(GITHUB_LABELS.STATUS_COMPLETED)) {
|
|
1520
|
+
prdStats.completed++;
|
|
1521
|
+
} else if (labels.includes(GITHUB_LABELS.STATUS_BREAKDOWN_READY)) {
|
|
1522
|
+
prdStats.breakdownReady++;
|
|
1523
|
+
} else if (labels.includes(GITHUB_LABELS.STATUS_APPROVED)) {
|
|
1524
|
+
prdStats.approved++;
|
|
1525
|
+
} else if (labels.includes(GITHUB_LABELS.STATUS_REVIEWED)) {
|
|
1526
|
+
prdStats.reviewed++;
|
|
1527
|
+
} else if (labels.includes(GITHUB_LABELS.STATUS_PENDING_REVIEW)) {
|
|
1528
|
+
prdStats.pendingReview++;
|
|
1529
|
+
} else {
|
|
1530
|
+
prdStats.draft++;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const epicPending = openEpicIssues.filter((issue) => {
|
|
1535
|
+
const labels = issue.labels.map((l) => l.name);
|
|
1536
|
+
return !labels.includes(GITHUB_LABELS.STATUS_IN_PROGRESS);
|
|
1537
|
+
}).length;
|
|
1538
|
+
|
|
1539
|
+
const epicInProgress = openEpicIssues.filter((issue) => {
|
|
1540
|
+
const labels = issue.labels.map((l) => l.name);
|
|
1541
|
+
return labels.includes(GITHUB_LABELS.STATUS_IN_PROGRESS);
|
|
1542
|
+
}).length;
|
|
1543
|
+
|
|
1544
|
+
const epicCompleted = closedEpicIssues.length;
|
|
1545
|
+
|
|
1546
|
+
const taskPending = openTaskIssues.filter((issue) => {
|
|
1547
|
+
const labels = issue.labels.map((l) => l.name);
|
|
1548
|
+
return !labels.includes(GITHUB_LABELS.STATUS_IN_PROGRESS);
|
|
1549
|
+
}).length;
|
|
1550
|
+
|
|
1551
|
+
const taskInProgress = openTaskIssues.filter((issue) => {
|
|
1552
|
+
const labels = issue.labels.map((l) => l.name);
|
|
1553
|
+
return labels.includes(GITHUB_LABELS.STATUS_IN_PROGRESS);
|
|
1554
|
+
}).length;
|
|
1555
|
+
|
|
1556
|
+
const taskCompleted = closedTaskIssues.length;
|
|
1557
|
+
|
|
1558
|
+
return {
|
|
1559
|
+
prds: prdStats,
|
|
1560
|
+
epics: {
|
|
1561
|
+
total: openEpicIssues.length + epicCompleted,
|
|
1562
|
+
pending: epicPending,
|
|
1563
|
+
inProgress: epicInProgress,
|
|
1564
|
+
completed: epicCompleted,
|
|
1565
|
+
},
|
|
1566
|
+
tasks: {
|
|
1567
|
+
total: openTaskIssues.length + taskCompleted,
|
|
1568
|
+
pending: taskPending,
|
|
1569
|
+
inProgress: taskInProgress,
|
|
1570
|
+
completed: taskCompleted,
|
|
1571
|
+
},
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
}
|