@akagilnc/pi-workflow-roles 0.1.1751
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/LICENSE +202 -0
- package/README.md +104 -0
- package/README.zh-CN.md +133 -0
- package/THIRD_PARTY_NOTICES.md +60 -0
- package/dist/activation-ledger-git.js +68 -0
- package/dist/activation-ledger-session.js +120 -0
- package/dist/activation-ledger-topology.js +239 -0
- package/dist/activation-reconciliation.js +61 -0
- package/dist/audit-escalation.js +108 -0
- package/dist/auditor-dossier-tool.js +35 -0
- package/dist/canonical-json.js +78 -0
- package/dist/compliance-transport.js +77 -0
- package/dist/doctor-contracts.js +172 -0
- package/dist/dossier-resolution.js +103 -0
- package/dist/evidence-child-executor.js +661 -0
- package/dist/exact-utf8.js +12 -0
- package/dist/git-object-id.js +7 -0
- package/dist/in-process-session.js +50 -0
- package/dist/merger-contracts.js +76 -0
- package/dist/navigator-attendance.js +995 -0
- package/dist/navigator-invocation-identity.js +220 -0
- package/dist/open-tool-schema.js +39 -0
- package/dist/package-contracts/collector-output.js +50 -0
- package/dist/package-contracts/fixer-output.js +72 -0
- package/dist/package-contracts/fixer-packet.js +77 -0
- package/dist/package-contracts/judge-output.js +17 -0
- package/dist/package-contracts/reviewer-output.js +82 -0
- package/dist/package-contracts/terminating-tools.js +173 -0
- package/dist/package-contracts/worker-output.js +13 -0
- package/dist/package-owned-tool-idle.js +104 -0
- package/dist/packaged-role-registry.js +34 -0
- package/dist/public-cli/main.js +23867 -0
- package/dist/public-command-renderer.js +20 -0
- package/dist/reviewer-agent.js +93 -0
- package/dist/reviewer-child-executor.js +23 -0
- package/dist/reviewer-construction.js +95 -0
- package/dist/reviewer-dispatch.js +77 -0
- package/dist/reviewer-execution-ledger.js +160 -0
- package/dist/reviewer-failure-diagnostic.js +17 -0
- package/dist/reviewer-git-snapshot.js +38 -0
- package/dist/reviewer-pinned-git.js +146 -0
- package/dist/reviewer-preflight-error.js +15 -0
- package/dist/reviewer-prompt-identity.js +10 -0
- package/dist/reviewer-scope-prompt.js +21 -0
- package/dist/reviewer-workspace.js +151 -0
- package/dist/sha256.js +5 -0
- package/dist/sitian-record-entry.js +33 -0
- package/dist/stderr-jsonl.js +26 -0
- package/dist/stream-idle-guard.js +75 -0
- package/dist/tool-execution-observation.js +141 -0
- package/dist/uuidv7.js +21 -0
- package/dist/work-subject-identity.js +53 -0
- package/extensions/role-runtime.ts +303 -0
- package/package.json +69 -0
- package/packets/fixer-prerequisites.json +6 -0
- package/packets/fixer-repair.md +5 -0
- package/packets/judge-apply.md +77 -0
- package/packets/judge-authority.md +64 -0
- package/packets/judge-plan.md +55 -0
- package/packets/judge-review.md +49 -0
- package/packets/judge-submission.md +34 -0
- package/resources/methods/code-review/SKILL.md +92 -0
- package/resources/methods/code-review/agents/openai.yaml +3 -0
- package/resources/methods/code-review/provenance.json +26 -0
- package/resources/methods/diagnosing-bugs/SKILL.md +134 -0
- package/resources/methods/diagnosing-bugs/agents/openai.yaml +3 -0
- package/resources/methods/diagnosing-bugs/provenance.json +31 -0
- package/resources/methods/diagnosing-bugs/scripts/hitl-loop.template.sh +41 -0
- package/resources/methods/resolving-merge-conflicts/SKILL.md +14 -0
- package/resources/methods/resolving-merge-conflicts/agents/openai.yaml +3 -0
- package/resources/methods/resolving-merge-conflicts/provenance.json +26 -0
- package/resources/methods/tdd/SKILL.md +38 -0
- package/resources/methods/tdd/agents/openai.yaml +3 -0
- package/resources/methods/tdd/mocking.md +59 -0
- package/resources/methods/tdd/provenance.json +36 -0
- package/resources/methods/tdd/tests.md +77 -0
- package/resources/navigator-route-playbook.md +32 -0
- package/schemas/tool-execution-observation.schema.json +107 -0
- package/scripts/build-package.mjs +65 -0
- package/scripts/generate-tool-execution-observation-schema.ts +7 -0
- package/souls/coder.md +10 -0
- package/souls/collector.md +11 -0
- package/souls/doctor-auditor.md +23 -0
- package/souls/doctor.md +8 -0
- package/souls/fixer-auditor.md +33 -0
- package/souls/fixer.md +13 -0
- package/souls/judge-auditor.md +33 -0
- package/souls/judge.md +74 -0
- package/souls/merger.md +5 -0
- package/souls/navigator.md +5 -0
- package/souls/reviewer-auditor.md +25 -0
- package/souls/reviewer.md +11 -0
- package/src/activation-ledger-git.ts +96 -0
- package/src/activation-ledger-session.ts +188 -0
- package/src/activation-ledger-topology.ts +301 -0
- package/src/activation-ledger.ts +240 -0
- package/src/activation-reconciliation.ts +163 -0
- package/src/activation-trace.ts +38 -0
- package/src/audit-escalation.ts +177 -0
- package/src/auditor-dossier-tool.ts +48 -0
- package/src/auditor-soul.ts +28 -0
- package/src/canonical-json.ts +74 -0
- package/src/canonical-skill-binding.ts +107 -0
- package/src/collector-config.ts +89 -0
- package/src/collector-evidence.ts +461 -0
- package/src/collector-github.ts +656 -0
- package/src/collector-identity.ts +161 -0
- package/src/collector-ledger.ts +827 -0
- package/src/collector-receipt.ts +87 -0
- package/src/collector-role.ts +592 -0
- package/src/collector-tool-schemas.ts +19 -0
- package/src/compliance-transport.ts +130 -0
- package/src/doctor-auditor.ts +53 -0
- package/src/doctor-contracts.ts +166 -0
- package/src/doctor-evidence.ts +47 -0
- package/src/doctor-role.ts +18 -0
- package/src/dossier-resolution.ts +137 -0
- package/src/evidence-child-executor.ts +775 -0
- package/src/exact-utf8.ts +9 -0
- package/src/factory-board.ts +1822 -0
- package/src/git-object-id.ts +11 -0
- package/src/human-format.ts +65 -0
- package/src/in-process-session.ts +78 -0
- package/src/judge-auditor.ts +55 -0
- package/src/judge-recording-anti-forge.ts +53 -0
- package/src/judge-role.ts +160 -0
- package/src/merger-contracts.ts +71 -0
- package/src/merger-git-state.ts +76 -0
- package/src/merger-role.ts +60 -0
- package/src/navigator-attendance.ts +1254 -0
- package/src/navigator-invocation-identity.ts +446 -0
- package/src/open-tool-schema.ts +46 -0
- package/src/package-contracts/collector-output.ts +109 -0
- package/src/package-contracts/fixer-output.ts +81 -0
- package/src/package-contracts/fixer-packet.ts +93 -0
- package/src/package-contracts/judge-output.ts +39 -0
- package/src/package-contracts/reviewer-output.ts +115 -0
- package/src/package-contracts/terminating-tools.ts +259 -0
- package/src/package-contracts/worker-output.ts +36 -0
- package/src/package-owned-tool-idle.ts +134 -0
- package/src/package-resources/method-skill-binding.ts +87 -0
- package/src/package-resources/method-skill.ts +358 -0
- package/src/packaged-role-registry.ts +36 -0
- package/src/public-cli/cli-errors.ts +11 -0
- package/src/public-cli/cli-io.ts +4 -0
- package/src/public-cli/cli.ts +912 -0
- package/src/public-cli/coder-run.ts +575 -0
- package/src/public-cli/collector-run.ts +375 -0
- package/src/public-cli/command-renderer.ts +8 -0
- package/src/public-cli/config.ts +346 -0
- package/src/public-cli/doctor-run.ts +355 -0
- package/src/public-cli/explicit-internal.ts +274 -0
- package/src/public-cli/fixer-run.ts +587 -0
- package/src/public-cli/host-pi-runtime.ts +112 -0
- package/src/public-cli/invocation.ts +1958 -0
- package/src/public-cli/judge-run.ts +507 -0
- package/src/public-cli/main.ts +15 -0
- package/src/public-cli/merger-run.ts +681 -0
- package/src/public-cli/public-run-credentials.ts +71 -0
- package/src/public-cli/registry.ts +153 -0
- package/src/public-cli/reviewer-run.ts +561 -0
- package/src/public-cli/run-lifecycle.ts +884 -0
- package/src/public-cli/settlement.ts +3765 -0
- package/src/public-cli/terminal.ts +325 -0
- package/src/public-command-renderer.ts +43 -0
- package/src/reviewer-agent.ts +94 -0
- package/src/reviewer-auditor.ts +53 -0
- package/src/reviewer-child-executor.ts +31 -0
- package/src/reviewer-construction.ts +137 -0
- package/src/reviewer-dispatch.ts +94 -0
- package/src/reviewer-execution-ledger.ts +206 -0
- package/src/reviewer-failure-diagnostic.ts +18 -0
- package/src/reviewer-git-snapshot.ts +53 -0
- package/src/reviewer-pinned-git.ts +144 -0
- package/src/reviewer-preflight-error.ts +14 -0
- package/src/reviewer-prompt-identity.ts +17 -0
- package/src/reviewer-role.ts +193 -0
- package/src/reviewer-scope-prompt.ts +24 -0
- package/src/reviewer-settlement.ts +63 -0
- package/src/reviewer-workspace.ts +111 -0
- package/src/role-runtime.ts +884 -0
- package/src/sha256.ts +6 -0
- package/src/sitian-record-entry.ts +57 -0
- package/src/stderr-jsonl.ts +28 -0
- package/src/stream-idle-guard.ts +98 -0
- package/src/ticket-snapshot.ts +662 -0
- package/src/ticket-trajectory.ts +1000 -0
- package/src/tool-execution-observation.ts +168 -0
- package/src/uuidv7.ts +1 -0
- package/src/work-subject-identity.ts +94 -0
- package/src/worker-role.ts +434 -0
- package/src/worker-submission-gates.ts +225 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub ticket snapshot adapter (factory board S2).
|
|
3
|
+
*
|
|
4
|
+
* Thin layer: explicit book→repo bindings + open set (+ named closed drills)
|
|
5
|
+
* → BoardSnapshot. Does not scan ledgers, does not guess git remote.
|
|
6
|
+
*
|
|
7
|
+
* Parent / blocked_by edges come from GitHub's native issue relationship API
|
|
8
|
+
* (GraphQL parent + blockedBy). Binding or API failure throws — callers render
|
|
9
|
+
* the error loudly; never invent an empty successful board.
|
|
10
|
+
*
|
|
11
|
+
* blockedBy is paginated to completion (or fails loudly). Never expose a
|
|
12
|
+
* truncated first page as the full blocker set.
|
|
13
|
+
*/
|
|
14
|
+
import { createGhApiRunner, type GhApiRunner } from "./collector-github.ts";
|
|
15
|
+
|
|
16
|
+
export type BookRepoBinding = {
|
|
17
|
+
bookKey: string;
|
|
18
|
+
owner: string;
|
|
19
|
+
repo: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type TicketIssueState = "open" | "closed";
|
|
23
|
+
|
|
24
|
+
export type BlockedByEdge = {
|
|
25
|
+
issueNumber: number;
|
|
26
|
+
state: TicketIssueState;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SnapshotTicket = {
|
|
30
|
+
issueNumber: number;
|
|
31
|
+
title: string;
|
|
32
|
+
state: TicketIssueState;
|
|
33
|
+
/** Milestone title, or null when unset. */
|
|
34
|
+
milestone: string | null;
|
|
35
|
+
parentIssueNumber: number | null;
|
|
36
|
+
/**
|
|
37
|
+
* Issue closure timestamp (GitHub `closedAt`).
|
|
38
|
+
* Required non-null when state is closed — landing cycle ends here, not at last ledger record.
|
|
39
|
+
* Always null when state is open.
|
|
40
|
+
*/
|
|
41
|
+
closedAt: string | null;
|
|
42
|
+
blockedBy: readonly BlockedByEdge[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type BookSnapshot = {
|
|
46
|
+
bookKey: string;
|
|
47
|
+
owner: string;
|
|
48
|
+
repo: string;
|
|
49
|
+
tickets: readonly SnapshotTicket[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type BoardSnapshot = {
|
|
53
|
+
books: readonly BookSnapshot[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type TicketSnapshotTransport = {
|
|
57
|
+
listBookTickets(input: {
|
|
58
|
+
owner: string;
|
|
59
|
+
repo: string;
|
|
60
|
+
closedIssueNumbers: readonly number[];
|
|
61
|
+
/**
|
|
62
|
+
* Retention clock (#162): when supplied, closed issues are drained completely
|
|
63
|
+
* and the merge+24h candidate set is computed mechanically — family-shared
|
|
64
|
+
* parent clock (root closedAt is the family's only exit clock), open-root
|
|
65
|
+
* children 陪跑, named drills always resident. When absent, no closed drain runs.
|
|
66
|
+
*/
|
|
67
|
+
retentionNow?: Date;
|
|
68
|
+
signal?: AbortSignal;
|
|
69
|
+
}): Promise<readonly SnapshotTicket[]>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Retention window: merged tickets leave the default board closedAt+24h later. */
|
|
73
|
+
export const RETENTION_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
74
|
+
|
|
75
|
+
export class TicketSnapshotBindingError extends Error {
|
|
76
|
+
readonly kind = "binding" as const;
|
|
77
|
+
readonly bookKey: string;
|
|
78
|
+
constructor(bookKey: string, message: string) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = "TicketSnapshotBindingError";
|
|
81
|
+
this.bookKey = bookKey;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class TicketSnapshotApiError extends Error {
|
|
86
|
+
readonly kind = "api" as const;
|
|
87
|
+
readonly bookKey: string;
|
|
88
|
+
constructor(bookKey: string, message: string, options?: { cause?: unknown }) {
|
|
89
|
+
super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
|
|
90
|
+
this.name = "TicketSnapshotApiError";
|
|
91
|
+
this.bookKey = bookKey;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
96
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requireNonEmpty(value: string, label: string): string {
|
|
100
|
+
const trimmed = value.trim();
|
|
101
|
+
if (!trimmed) throw new Error(`${label} must be a non-empty string`);
|
|
102
|
+
return trimmed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function mapIssueState(raw: unknown): TicketIssueState {
|
|
106
|
+
if (raw === "OPEN" || raw === "open") return "open";
|
|
107
|
+
if (raw === "CLOSED" || raw === "closed") return "closed";
|
|
108
|
+
throw new Error(`unexpected issue state: ${String(raw)}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type BlockedByPage = {
|
|
112
|
+
edges: BlockedByEdge[];
|
|
113
|
+
hasNextPage: boolean;
|
|
114
|
+
endCursor: string | null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse one blockedBy connection page. Completeness requires pageInfo;
|
|
119
|
+
* missing pageInfo is a loud failure (never treat a truncated first page as full).
|
|
120
|
+
*/
|
|
121
|
+
function parseBlockedByPage(raw: unknown, label: string): BlockedByPage {
|
|
122
|
+
if (raw === undefined || raw === null) {
|
|
123
|
+
return { edges: [], hasNextPage: false, endCursor: null };
|
|
124
|
+
}
|
|
125
|
+
if (!isRecord(raw) || !Array.isArray(raw.nodes)) {
|
|
126
|
+
throw new Error(`${label} invalid`);
|
|
127
|
+
}
|
|
128
|
+
const pageInfo = raw.pageInfo;
|
|
129
|
+
if (!isRecord(pageInfo) || typeof pageInfo.hasNextPage !== "boolean") {
|
|
130
|
+
throw new Error(`${label}.pageInfo missing — cannot establish blockedBy completeness`);
|
|
131
|
+
}
|
|
132
|
+
let endCursor: string | null = null;
|
|
133
|
+
if (pageInfo.endCursor === null || pageInfo.endCursor === undefined) {
|
|
134
|
+
endCursor = null;
|
|
135
|
+
} else if (typeof pageInfo.endCursor === "string") {
|
|
136
|
+
endCursor = pageInfo.endCursor;
|
|
137
|
+
} else {
|
|
138
|
+
throw new Error(`${label}.pageInfo.endCursor invalid`);
|
|
139
|
+
}
|
|
140
|
+
if (pageInfo.hasNextPage && !endCursor) {
|
|
141
|
+
throw new Error(`${label}.pageInfo endCursor missing while hasNextPage`);
|
|
142
|
+
}
|
|
143
|
+
const edges: BlockedByEdge[] = [];
|
|
144
|
+
for (const [index, node] of raw.nodes.entries()) {
|
|
145
|
+
if (!isRecord(node) || typeof node.number !== "number") {
|
|
146
|
+
throw new Error(`${label}.nodes[${index}] invalid`);
|
|
147
|
+
}
|
|
148
|
+
edges.push({
|
|
149
|
+
issueNumber: node.number,
|
|
150
|
+
state: mapIssueState(node.state),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
edges,
|
|
155
|
+
hasNextPage: pageInfo.hasNextPage,
|
|
156
|
+
endCursor,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type ParsedTicketNode = {
|
|
161
|
+
ticket: Omit<SnapshotTicket, "blockedBy">;
|
|
162
|
+
blockedByPage: BlockedByPage;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function parseClosedAt(raw: unknown, label: string): string | null {
|
|
166
|
+
if (raw === null || raw === undefined) return null;
|
|
167
|
+
if (typeof raw !== "string" || !Number.isFinite(Date.parse(raw))) {
|
|
168
|
+
throw new Error(`${label}.closedAt invalid`);
|
|
169
|
+
}
|
|
170
|
+
return raw;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseTicketNode(raw: unknown, label: string): ParsedTicketNode {
|
|
174
|
+
if (!isRecord(raw)) throw new Error(`${label} is not an object`);
|
|
175
|
+
const number = raw.number;
|
|
176
|
+
if (typeof number !== "number" || !Number.isInteger(number) || number < 1) {
|
|
177
|
+
throw new Error(`${label}.number invalid`);
|
|
178
|
+
}
|
|
179
|
+
if (typeof raw.title !== "string") throw new Error(`${label}.title missing`);
|
|
180
|
+
const milestoneRaw = raw.milestone;
|
|
181
|
+
let milestone: string | null = null;
|
|
182
|
+
if (milestoneRaw === null || milestoneRaw === undefined) {
|
|
183
|
+
milestone = null;
|
|
184
|
+
} else if (isRecord(milestoneRaw) && typeof milestoneRaw.title === "string") {
|
|
185
|
+
milestone = milestoneRaw.title;
|
|
186
|
+
} else {
|
|
187
|
+
throw new Error(`${label}.milestone invalid`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let parentIssueNumber: number | null = null;
|
|
191
|
+
if (raw.parent === null || raw.parent === undefined) {
|
|
192
|
+
parentIssueNumber = null;
|
|
193
|
+
} else if (isRecord(raw.parent) && typeof raw.parent.number === "number") {
|
|
194
|
+
parentIssueNumber = raw.parent.number;
|
|
195
|
+
} else {
|
|
196
|
+
throw new Error(`${label}.parent invalid`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const state = mapIssueState(raw.state);
|
|
200
|
+
const closedAtRaw = parseClosedAt(raw.closedAt, label);
|
|
201
|
+
if (state === "closed" && closedAtRaw === null) {
|
|
202
|
+
throw new Error(`${label}.closedAt missing for closed issue`);
|
|
203
|
+
}
|
|
204
|
+
// Open tickets never carry a closure endpoint into landing-cycle math.
|
|
205
|
+
const closedAt = state === "closed" ? closedAtRaw : null;
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
ticket: {
|
|
209
|
+
issueNumber: number,
|
|
210
|
+
title: raw.title,
|
|
211
|
+
state,
|
|
212
|
+
milestone,
|
|
213
|
+
parentIssueNumber,
|
|
214
|
+
closedAt,
|
|
215
|
+
},
|
|
216
|
+
blockedByPage: parseBlockedByPage(raw.blockedBy, `${label}.blockedBy`),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const BLOCKED_BY_PAGE_SIZE = 100;
|
|
221
|
+
|
|
222
|
+
function blockedBySelection(afterVar?: string): string {
|
|
223
|
+
const afterArg = afterVar ? `, after: ${afterVar}` : "";
|
|
224
|
+
return `blockedBy(first: ${BLOCKED_BY_PAGE_SIZE}${afterArg}) {
|
|
225
|
+
pageInfo { hasNextPage endCursor }
|
|
226
|
+
nodes { number state }
|
|
227
|
+
}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildOpenIssuesQuery(): string {
|
|
231
|
+
return `query($owner: String!, $repo: String!, $after: String) {
|
|
232
|
+
repository(owner: $owner, name: $repo) {
|
|
233
|
+
issues(states: OPEN, first: 100, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {
|
|
234
|
+
pageInfo { hasNextPage endCursor }
|
|
235
|
+
nodes {
|
|
236
|
+
number
|
|
237
|
+
title
|
|
238
|
+
state
|
|
239
|
+
closedAt
|
|
240
|
+
milestone { title }
|
|
241
|
+
parent { number }
|
|
242
|
+
${blockedBySelection()}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function buildClosedIssuesQuery(numbers: readonly number[]): string {
|
|
250
|
+
const aliases = numbers
|
|
251
|
+
.map(
|
|
252
|
+
(n, index) => `
|
|
253
|
+
c${index}: issue(number: ${n}) {
|
|
254
|
+
number
|
|
255
|
+
title
|
|
256
|
+
state
|
|
257
|
+
closedAt
|
|
258
|
+
milestone { title }
|
|
259
|
+
parent { number }
|
|
260
|
+
${blockedBySelection()}
|
|
261
|
+
}`,
|
|
262
|
+
)
|
|
263
|
+
.join("\n");
|
|
264
|
+
return `query($owner: String!, $repo: String!) {
|
|
265
|
+
repository(owner: $owner, name: $repo) {
|
|
266
|
+
${aliases}
|
|
267
|
+
}
|
|
268
|
+
}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildClosedIssuesDrainQuery(): string {
|
|
272
|
+
return `query($owner: String!, $repo: String!, $after: String) {
|
|
273
|
+
repository(owner: $owner, name: $repo) {
|
|
274
|
+
issues(states: CLOSED, first: 100, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
|
|
275
|
+
pageInfo { hasNextPage endCursor }
|
|
276
|
+
nodes {
|
|
277
|
+
number
|
|
278
|
+
title
|
|
279
|
+
state
|
|
280
|
+
closedAt
|
|
281
|
+
milestone { title }
|
|
282
|
+
parent { number }
|
|
283
|
+
${blockedBySelection()}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function buildBlockedByPageQuery(): string {
|
|
291
|
+
return `query($owner: String!, $repo: String!, $number: Int!, $after: String) {
|
|
292
|
+
repository(owner: $owner, name: $repo) {
|
|
293
|
+
issue(number: $number) {
|
|
294
|
+
${blockedBySelection("$after")}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function ghGraphql(
|
|
301
|
+
runner: GhApiRunner,
|
|
302
|
+
query: string,
|
|
303
|
+
variables: Record<string, string | number | null>,
|
|
304
|
+
signal?: AbortSignal,
|
|
305
|
+
): Promise<unknown> {
|
|
306
|
+
const args = [
|
|
307
|
+
"api",
|
|
308
|
+
"graphql",
|
|
309
|
+
"--hostname",
|
|
310
|
+
"github.com",
|
|
311
|
+
"--include",
|
|
312
|
+
"-f",
|
|
313
|
+
`query=${query}`,
|
|
314
|
+
];
|
|
315
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
316
|
+
if (value === null) {
|
|
317
|
+
args.push("-F", `${key}=null`);
|
|
318
|
+
} else if (typeof value === "number") {
|
|
319
|
+
// GraphQL Int variables must be typed, not string-forced.
|
|
320
|
+
args.push("-F", `${key}=${value}`);
|
|
321
|
+
} else {
|
|
322
|
+
args.push("-f", `${key}=${value}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const response = await runner(args, signal === undefined ? {} : { signal });
|
|
326
|
+
if (response.status < 200 || response.status >= 300) {
|
|
327
|
+
throw new Error(`GitHub GraphQL HTTP ${response.status}: ${response.bodyText.slice(0, 400)}`);
|
|
328
|
+
}
|
|
329
|
+
let payload: unknown;
|
|
330
|
+
try {
|
|
331
|
+
payload = JSON.parse(response.bodyText);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
throw new Error("GitHub GraphQL returned malformed JSON", { cause: error });
|
|
334
|
+
}
|
|
335
|
+
if (!isRecord(payload)) throw new Error("GitHub GraphQL payload is not an object");
|
|
336
|
+
if (payload.errors !== undefined) {
|
|
337
|
+
const msg = JSON.stringify(payload.errors).slice(0, 600);
|
|
338
|
+
throw new Error(`GitHub GraphQL errors: ${msg}`);
|
|
339
|
+
}
|
|
340
|
+
return payload.data;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Drain blockedBy pages until complete. Never return a partial first page as full.
|
|
345
|
+
*/
|
|
346
|
+
async function completeBlockedBy(
|
|
347
|
+
runner: GhApiRunner,
|
|
348
|
+
owner: string,
|
|
349
|
+
repo: string,
|
|
350
|
+
issueNumber: number,
|
|
351
|
+
firstPage: BlockedByPage,
|
|
352
|
+
signal?: AbortSignal,
|
|
353
|
+
): Promise<BlockedByEdge[]> {
|
|
354
|
+
const edges = [...firstPage.edges];
|
|
355
|
+
let hasNextPage = firstPage.hasNextPage;
|
|
356
|
+
let endCursor = firstPage.endCursor;
|
|
357
|
+
while (hasNextPage) {
|
|
358
|
+
if (!endCursor) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`blockedBy pagination incomplete for #${issueNumber}: missing endCursor`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
const data = await ghGraphql(
|
|
364
|
+
runner,
|
|
365
|
+
buildBlockedByPageQuery(),
|
|
366
|
+
{ owner, repo, number: issueNumber, after: endCursor },
|
|
367
|
+
signal,
|
|
368
|
+
);
|
|
369
|
+
if (!isRecord(data) || !isRecord(data.repository) || !isRecord(data.repository.issue)) {
|
|
370
|
+
throw new Error(`blockedBy page missing for #${issueNumber}`);
|
|
371
|
+
}
|
|
372
|
+
const page = parseBlockedByPage(
|
|
373
|
+
data.repository.issue.blockedBy,
|
|
374
|
+
`blockedBy[#${issueNumber}]`,
|
|
375
|
+
);
|
|
376
|
+
edges.push(...page.edges);
|
|
377
|
+
hasNextPage = page.hasNextPage;
|
|
378
|
+
endCursor = page.endCursor;
|
|
379
|
+
}
|
|
380
|
+
return edges;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function materializeTicket(
|
|
384
|
+
runner: GhApiRunner,
|
|
385
|
+
owner: string,
|
|
386
|
+
repo: string,
|
|
387
|
+
raw: unknown,
|
|
388
|
+
label: string,
|
|
389
|
+
signal?: AbortSignal,
|
|
390
|
+
): Promise<SnapshotTicket> {
|
|
391
|
+
const parsed = parseTicketNode(raw, label);
|
|
392
|
+
const blockedBy = await completeBlockedBy(
|
|
393
|
+
runner,
|
|
394
|
+
owner,
|
|
395
|
+
repo,
|
|
396
|
+
parsed.ticket.issueNumber,
|
|
397
|
+
parsed.blockedByPage,
|
|
398
|
+
signal,
|
|
399
|
+
);
|
|
400
|
+
return {
|
|
401
|
+
...parsed.ticket,
|
|
402
|
+
blockedBy,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function createGhTicketSnapshotTransport(
|
|
407
|
+
runner: GhApiRunner = createGhApiRunner(),
|
|
408
|
+
): TicketSnapshotTransport {
|
|
409
|
+
return {
|
|
410
|
+
async listBookTickets(input) {
|
|
411
|
+
const owner = requireNonEmpty(input.owner, "owner");
|
|
412
|
+
const repo = requireNonEmpty(input.repo, "repo");
|
|
413
|
+
const retentionNow = input.retentionNow;
|
|
414
|
+
if (retentionNow !== undefined && !Number.isFinite(retentionNow.getTime())) {
|
|
415
|
+
throw new Error("retentionNow must be a finite Date");
|
|
416
|
+
}
|
|
417
|
+
const tickets = new Map<number, SnapshotTicket>();
|
|
418
|
+
|
|
419
|
+
let after: string | null = null;
|
|
420
|
+
for (;;) {
|
|
421
|
+
const data = await ghGraphql(
|
|
422
|
+
runner,
|
|
423
|
+
buildOpenIssuesQuery(),
|
|
424
|
+
{ owner, repo, after },
|
|
425
|
+
input.signal,
|
|
426
|
+
);
|
|
427
|
+
if (!isRecord(data) || !isRecord(data.repository)) {
|
|
428
|
+
throw new Error("GitHub GraphQL repository missing");
|
|
429
|
+
}
|
|
430
|
+
const issues = data.repository.issues;
|
|
431
|
+
if (!isRecord(issues) || !Array.isArray(issues.nodes)) {
|
|
432
|
+
throw new Error("GitHub GraphQL issues connection missing");
|
|
433
|
+
}
|
|
434
|
+
for (const [index, node] of issues.nodes.entries()) {
|
|
435
|
+
if (node === null) continue;
|
|
436
|
+
const ticket = await materializeTicket(
|
|
437
|
+
runner,
|
|
438
|
+
owner,
|
|
439
|
+
repo,
|
|
440
|
+
node,
|
|
441
|
+
`open[${index}]`,
|
|
442
|
+
input.signal,
|
|
443
|
+
);
|
|
444
|
+
tickets.set(ticket.issueNumber, ticket);
|
|
445
|
+
}
|
|
446
|
+
const pageInfo = issues.pageInfo;
|
|
447
|
+
if (
|
|
448
|
+
!isRecord(pageInfo) ||
|
|
449
|
+
typeof pageInfo.hasNextPage !== "boolean"
|
|
450
|
+
) {
|
|
451
|
+
throw new Error("GitHub GraphQL pageInfo missing");
|
|
452
|
+
}
|
|
453
|
+
if (!pageInfo.hasNextPage) break;
|
|
454
|
+
if (typeof pageInfo.endCursor !== "string" || !pageInfo.endCursor) {
|
|
455
|
+
throw new Error("GitHub GraphQL endCursor missing");
|
|
456
|
+
}
|
|
457
|
+
after = pageInfo.endCursor;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (retentionNow !== undefined) {
|
|
461
|
+
// Closed drain: complete pagination only (never a truncated first page),
|
|
462
|
+
// then the mechanical merge+24h candidate set with the family shared clock.
|
|
463
|
+
const closedPool = new Map<number, SnapshotTicket>();
|
|
464
|
+
let closedAfter: string | null = null;
|
|
465
|
+
for (;;) {
|
|
466
|
+
const data = await ghGraphql(
|
|
467
|
+
runner,
|
|
468
|
+
buildClosedIssuesDrainQuery(),
|
|
469
|
+
{ owner, repo, after: closedAfter },
|
|
470
|
+
input.signal,
|
|
471
|
+
);
|
|
472
|
+
if (!isRecord(data) || !isRecord(data.repository)) {
|
|
473
|
+
throw new Error("GitHub GraphQL repository missing");
|
|
474
|
+
}
|
|
475
|
+
const issues = data.repository.issues;
|
|
476
|
+
if (!isRecord(issues) || !Array.isArray(issues.nodes)) {
|
|
477
|
+
throw new Error("GitHub GraphQL closed issues connection missing");
|
|
478
|
+
}
|
|
479
|
+
for (const [index, node] of issues.nodes.entries()) {
|
|
480
|
+
if (node === null) continue;
|
|
481
|
+
const ticket = await materializeTicket(
|
|
482
|
+
runner,
|
|
483
|
+
owner,
|
|
484
|
+
repo,
|
|
485
|
+
node,
|
|
486
|
+
`closedDrain[${index}]`,
|
|
487
|
+
input.signal,
|
|
488
|
+
);
|
|
489
|
+
closedPool.set(ticket.issueNumber, ticket);
|
|
490
|
+
}
|
|
491
|
+
const pageInfo = issues.pageInfo;
|
|
492
|
+
if (!isRecord(pageInfo) || typeof pageInfo.hasNextPage !== "boolean") {
|
|
493
|
+
throw new Error("GitHub GraphQL closed issues pageInfo missing — cannot establish completeness");
|
|
494
|
+
}
|
|
495
|
+
if (!pageInfo.hasNextPage) break;
|
|
496
|
+
if (typeof pageInfo.endCursor !== "string" || !pageInfo.endCursor) {
|
|
497
|
+
throw new Error("GitHub GraphQL closed issues endCursor missing");
|
|
498
|
+
}
|
|
499
|
+
closedAfter = pageInfo.endCursor;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Family roots from native parent edges across open ∪ closed (cycle-safe).
|
|
503
|
+
const allByNumber = new Map<number, SnapshotTicket>([...tickets, ...closedPool]);
|
|
504
|
+
const rootOf = (ticket: SnapshotTicket): SnapshotTicket => {
|
|
505
|
+
const seen = new Set<number>();
|
|
506
|
+
let current = ticket;
|
|
507
|
+
for (;;) {
|
|
508
|
+
if (seen.has(current.issueNumber)) return current;
|
|
509
|
+
seen.add(current.issueNumber);
|
|
510
|
+
const parentNumber = current.parentIssueNumber;
|
|
511
|
+
if (parentNumber === null) return current;
|
|
512
|
+
const parent = allByNumber.get(parentNumber);
|
|
513
|
+
if (!parent) return current; // edge outside the book — self is the best clock
|
|
514
|
+
current = parent;
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const nowMs = retentionNow.getTime();
|
|
519
|
+
for (const candidate of closedPool.values()) {
|
|
520
|
+
if (tickets.has(candidate.issueNumber)) continue;
|
|
521
|
+
const root = rootOf(candidate);
|
|
522
|
+
if (root.state === "open") {
|
|
523
|
+
// 陪跑: merged children stay while the family root is open.
|
|
524
|
+
tickets.set(candidate.issueNumber, candidate);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const rootClosedAt = root.closedAt;
|
|
528
|
+
if (rootClosedAt === null) {
|
|
529
|
+
throw new Error(`closed family root #${root.issueNumber} missing closedAt`);
|
|
530
|
+
}
|
|
531
|
+
if (Date.parse(rootClosedAt) + RETENTION_WINDOW_MS > nowMs) {
|
|
532
|
+
tickets.set(candidate.issueNumber, candidate);
|
|
533
|
+
}
|
|
534
|
+
// else: the family has exited (root closedAt+24h passed) — not supplied.
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const closedNumbers = [
|
|
539
|
+
...new Set(
|
|
540
|
+
input.closedIssueNumbers.filter(
|
|
541
|
+
(n) => Number.isInteger(n) && n > 0 && !tickets.has(n),
|
|
542
|
+
),
|
|
543
|
+
),
|
|
544
|
+
].sort((a, b) => a - b);
|
|
545
|
+
|
|
546
|
+
// Fetch named closed issues in modest batches (alias query size).
|
|
547
|
+
const batchSize = 20;
|
|
548
|
+
for (let offset = 0; offset < closedNumbers.length; offset += batchSize) {
|
|
549
|
+
const batch = closedNumbers.slice(offset, offset + batchSize);
|
|
550
|
+
if (batch.length === 0) continue;
|
|
551
|
+
const data = await ghGraphql(
|
|
552
|
+
runner,
|
|
553
|
+
buildClosedIssuesQuery(batch),
|
|
554
|
+
{ owner, repo },
|
|
555
|
+
input.signal,
|
|
556
|
+
);
|
|
557
|
+
if (!isRecord(data) || !isRecord(data.repository)) {
|
|
558
|
+
throw new Error("GitHub GraphQL repository missing for closed drills");
|
|
559
|
+
}
|
|
560
|
+
for (let index = 0; index < batch.length; index += 1) {
|
|
561
|
+
const node = data.repository[`c${index}`];
|
|
562
|
+
if (node === null || node === undefined) {
|
|
563
|
+
throw new Error(
|
|
564
|
+
`named closed issue #${batch[index]} not found in ${owner}/${repo}`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
const ticket = await materializeTicket(
|
|
568
|
+
runner,
|
|
569
|
+
owner,
|
|
570
|
+
repo,
|
|
571
|
+
node,
|
|
572
|
+
`closed[#${batch[index]}]`,
|
|
573
|
+
input.signal,
|
|
574
|
+
);
|
|
575
|
+
tickets.set(ticket.issueNumber, ticket);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return [...tickets.values()].sort((a, b) => a.issueNumber - b.issueNumber);
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function assertBinding(binding: BookRepoBinding): { owner: string; repo: string } {
|
|
585
|
+
if (!binding.bookKey.trim()) {
|
|
586
|
+
throw new TicketSnapshotBindingError(binding.bookKey, "bookKey must be a non-empty string");
|
|
587
|
+
}
|
|
588
|
+
const owner = binding.owner?.trim() ?? "";
|
|
589
|
+
const repo = binding.repo?.trim() ?? "";
|
|
590
|
+
if (!owner || !repo) {
|
|
591
|
+
throw new TicketSnapshotBindingError(
|
|
592
|
+
binding.bookKey,
|
|
593
|
+
`binding missing owner/repo for book ${binding.bookKey}`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
return { owner, repo };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Fetch a board snapshot from explicit book→repo bindings.
|
|
601
|
+
* Never inspects git remotes. Transport failures become TicketSnapshotApiError.
|
|
602
|
+
* Duplicate bookKey bindings fail closed before any transport work.
|
|
603
|
+
*/
|
|
604
|
+
export async function fetchBoardSnapshot(input: {
|
|
605
|
+
bindings: readonly BookRepoBinding[];
|
|
606
|
+
closedIssueNumbersByBook?: Readonly<Record<string, readonly number[]>>;
|
|
607
|
+
/** Retention clock for the merge+24h candidate supply (#162); injected per fetch. */
|
|
608
|
+
retentionNow?: Date;
|
|
609
|
+
transport: TicketSnapshotTransport;
|
|
610
|
+
signal?: AbortSignal;
|
|
611
|
+
}): Promise<BoardSnapshot> {
|
|
612
|
+
if (!Array.isArray(input.bindings) || input.bindings.length === 0) {
|
|
613
|
+
throw new TicketSnapshotBindingError("", "bindings must be a non-empty array");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Reject duplicate bookKeys before any API / ledger work — bookKey is the
|
|
617
|
+
// sole join identity across bindings, snapshot books, and swimlanes.
|
|
618
|
+
const seenBookKeys = new Set<string>();
|
|
619
|
+
for (const binding of input.bindings) {
|
|
620
|
+
const key = binding.bookKey;
|
|
621
|
+
if (seenBookKeys.has(key)) {
|
|
622
|
+
throw new TicketSnapshotBindingError(
|
|
623
|
+
key,
|
|
624
|
+
`duplicate bookKey binding: ${key}`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
seenBookKeys.add(key);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const books: BookSnapshot[] = [];
|
|
631
|
+
for (const binding of input.bindings) {
|
|
632
|
+
const { owner, repo } = assertBinding(binding);
|
|
633
|
+
const closedIssueNumbers = input.closedIssueNumbersByBook?.[binding.bookKey] ?? [];
|
|
634
|
+
try {
|
|
635
|
+
const tickets = await input.transport.listBookTickets({
|
|
636
|
+
owner,
|
|
637
|
+
repo,
|
|
638
|
+
closedIssueNumbers,
|
|
639
|
+
...(input.retentionNow !== undefined ? { retentionNow: input.retentionNow } : {}),
|
|
640
|
+
...(input.signal !== undefined ? { signal: input.signal } : {}),
|
|
641
|
+
});
|
|
642
|
+
books.push({
|
|
643
|
+
bookKey: binding.bookKey,
|
|
644
|
+
owner,
|
|
645
|
+
repo,
|
|
646
|
+
tickets,
|
|
647
|
+
});
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (error instanceof TicketSnapshotBindingError) throw error;
|
|
650
|
+
const message =
|
|
651
|
+
error instanceof Error
|
|
652
|
+
? error.message
|
|
653
|
+
: `ticket snapshot API failed for book ${binding.bookKey}`;
|
|
654
|
+
throw new TicketSnapshotApiError(
|
|
655
|
+
binding.bookKey,
|
|
656
|
+
`ticket snapshot API failed for book ${binding.bookKey}: ${message}`,
|
|
657
|
+
{ cause: error },
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return { books };
|
|
662
|
+
}
|