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