phoebe-agent 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -8
- package/bootstrap/bin.mjs +42 -0
- package/bootstrap/boot.ts +383 -0
- package/bootstrap/cli.ts +29 -0
- package/bootstrap/crash-loop.ts +391 -0
- package/bootstrap/define-config.ts +20 -0
- package/bootstrap/engine-source.ts +83 -0
- package/bootstrap/github-engine.ts +169 -0
- package/bootstrap/index.mjs +9 -0
- package/bootstrap/index.ts +24 -0
- package/bootstrap/materialize.mjs +53 -0
- package/bootstrap/reconcile.ts +314 -0
- package/bootstrap/spawn-engine.mjs +78 -0
- package/package.json +26 -24
- package/prompts/checks-prompt.md +21 -6
- package/prompts/conflict-prompt.md +12 -1
- package/prompts/research-prompt.md +61 -0
- package/src/agent-env.ts +32 -0
- package/src/branded.ts +19 -0
- package/src/cli.ts +187 -0
- package/src/config-schema.ts +278 -0
- package/src/drain.ts +74 -0
- package/src/execution-gate.ts +27 -0
- package/src/git-model.ts +113 -0
- package/src/init.ts +277 -0
- package/src/load-config.ts +168 -0
- package/src/main.ts +1636 -0
- package/src/orchestrator.ts +953 -0
- package/src/prompt.ts +98 -0
- package/src/providers/providers.ts +222 -0
- package/src/providers/run-agent.ts +90 -0
- package/src/providers/types.ts +26 -0
- package/src/resolved-config.ts +55 -0
- package/templates/.env.example +11 -5
- package/templates/container/Dockerfile +41 -19
- package/templates/container/compose.local.yml +37 -0
- package/templates/container/compose.yml +34 -13
- package/templates/phoebe.config.ts +30 -6
- package/dist/phoebe.config.d.ts +0 -2
- package/dist/phoebe.config.js +0 -43
- package/dist/src/agent-env.d.ts +0 -6
- package/dist/src/agent-env.js +0 -24
- package/dist/src/cli.d.ts +0 -25
- package/dist/src/cli.js +0 -161
- package/dist/src/config-schema.d.ts +0 -163
- package/dist/src/config-schema.js +0 -140
- package/dist/src/execution-gate.d.ts +0 -9
- package/dist/src/execution-gate.js +0 -17
- package/dist/src/git-model.d.ts +0 -30
- package/dist/src/git-model.js +0 -71
- package/dist/src/index.d.ts +0 -2
- package/dist/src/index.js +0 -12
- package/dist/src/init.d.ts +0 -82
- package/dist/src/init.js +0 -207
- package/dist/src/load-config.d.ts +0 -88
- package/dist/src/load-config.js +0 -153
- package/dist/src/main.d.ts +0 -7
- package/dist/src/main.js +0 -1180
- package/dist/src/orchestrator.d.ts +0 -275
- package/dist/src/orchestrator.js +0 -579
- package/dist/src/prompt.d.ts +0 -13
- package/dist/src/prompt.js +0 -55
- package/dist/src/providers/providers.d.ts +0 -3
- package/dist/src/providers/providers.js +0 -210
- package/dist/src/providers/run-agent.d.ts +0 -34
- package/dist/src/providers/run-agent.js +0 -57
- package/dist/src/providers/types.d.ts +0 -27
- package/dist/src/providers/types.js +0 -6
- package/dist/src/resolved-config.d.ts +0 -8
- package/dist/src/resolved-config.js +0 -49
- package/dist/src/supervisor-decision.d.ts +0 -70
- package/dist/src/supervisor-decision.js +0 -94
- package/templates/container/compose.daemon.yml +0 -16
- package/templates/container/supervisor.sh +0 -50
- /package/prompts/{prompt.md → issues-prompt.md} +0 -0
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
// Pure selection + base-resolution logic for Phoebe's orchestrator.
|
|
2
|
+
// Kept separate from main.ts so it can be unit-tested without Docker/gh.
|
|
3
|
+
|
|
4
|
+
import { asBranchRef, asSha, type BranchRef, type PrNumber, type Sha } from "./branded.ts";
|
|
5
|
+
import { config } from "./resolved-config.ts";
|
|
6
|
+
|
|
7
|
+
export type Issue = {
|
|
8
|
+
number: number;
|
|
9
|
+
title: string;
|
|
10
|
+
body: string;
|
|
11
|
+
labels: string[];
|
|
12
|
+
createdAt: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type BlockerPrState = {
|
|
16
|
+
hasOpenPr: boolean;
|
|
17
|
+
openPrNumber?: PrNumber;
|
|
18
|
+
hasMergedPr: boolean;
|
|
19
|
+
mergedPrNumber?: PrNumber;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type BaseResolution = {
|
|
23
|
+
worktreeBase: string;
|
|
24
|
+
stacked: boolean;
|
|
25
|
+
blockerIssueNumber?: number;
|
|
26
|
+
blockerPrNumber?: PrNumber;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The stack-aware context every candidate selector needs: issue bodies (to read
|
|
31
|
+
* `blocked by` references) keyed by issue number, and the open/merged PR state of
|
|
32
|
+
* each referenced blocker. Bundled so the three work-kind flows thread one value
|
|
33
|
+
* instead of the same `(issueBodies, blockerStates)` pair.
|
|
34
|
+
*/
|
|
35
|
+
export type StackContext = {
|
|
36
|
+
issueBodies: ReadonlyMap<number, string>;
|
|
37
|
+
blockerStates: ReadonlyMap<number, BlockerPrState>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const PRIORITY_ORDER = ["bug", "tracer", "polish", "refactor"] as const;
|
|
41
|
+
export type Priority = (typeof PRIORITY_ORDER)[number];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse blocker references from issue body text (and optional comments).
|
|
45
|
+
* The pattern is configurable via `config.blockedByPattern`; capture group 1
|
|
46
|
+
* must yield the blocker issue number.
|
|
47
|
+
*/
|
|
48
|
+
export function parseBlockedBy(...texts: string[]): number[] {
|
|
49
|
+
const blockers: number[] = [];
|
|
50
|
+
const pattern = new RegExp(config.blockedByPattern, "gi");
|
|
51
|
+
for (const text of texts) {
|
|
52
|
+
for (const match of text.matchAll(pattern)) {
|
|
53
|
+
blockers.push(Number(match[1]));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...new Set(blockers)];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function classifyPriority(issue: Issue): Priority {
|
|
60
|
+
const text = `${issue.title} ${issue.body}`.toLowerCase();
|
|
61
|
+
if (/\b(bug|broken|crash|regression|fix)\b/.test(text)) return "bug";
|
|
62
|
+
if (/\b(tracer|wire|poc)\b/.test(text)) return "tracer";
|
|
63
|
+
if (/\brefactor\b/.test(text)) return "refactor";
|
|
64
|
+
return "polish";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function compareIssues(a: Issue, b: Issue): number {
|
|
68
|
+
const pa = PRIORITY_ORDER.indexOf(classifyPriority(a));
|
|
69
|
+
const pb = PRIORITY_ORDER.indexOf(classifyPriority(b));
|
|
70
|
+
if (pa !== pb) return pa - pb;
|
|
71
|
+
const ta = Date.parse(a.createdAt);
|
|
72
|
+
const tb = Date.parse(b.createdAt);
|
|
73
|
+
if (ta !== tb) return ta - tb;
|
|
74
|
+
return a.number - b.number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function issueBranch(issueNumber: number): BranchRef {
|
|
78
|
+
return asBranchRef(`${config.branchPrefix}issue-${issueNumber}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the worktree base for an issue.
|
|
83
|
+
* Returns `null` when the issue should be skipped this cycle (blocked with no
|
|
84
|
+
* open/merged blocker PR).
|
|
85
|
+
*/
|
|
86
|
+
export function resolveWorktreeBase(
|
|
87
|
+
issue: Issue,
|
|
88
|
+
blockerStates: ReadonlyMap<number, BlockerPrState>,
|
|
89
|
+
phoebeBase?: string,
|
|
90
|
+
): BaseResolution | null {
|
|
91
|
+
if (phoebeBase) {
|
|
92
|
+
return { worktreeBase: phoebeBase, stacked: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const blockers = parseBlockedBy(issue.body);
|
|
96
|
+
if (blockers.length === 0) {
|
|
97
|
+
return { worktreeBase: "origin/main", stacked: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const blockerIssueNumber = blockers[0]!;
|
|
101
|
+
const state = blockerStates.get(blockerIssueNumber);
|
|
102
|
+
if (!state) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (state.hasOpenPr) {
|
|
107
|
+
return {
|
|
108
|
+
worktreeBase: `origin/${issueBranch(blockerIssueNumber)}`,
|
|
109
|
+
stacked: true,
|
|
110
|
+
blockerIssueNumber,
|
|
111
|
+
blockerPrNumber: state.openPrNumber,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (state.hasMergedPr) {
|
|
116
|
+
return { worktreeBase: "origin/main", stacked: false };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Pick the highest-priority workable issue, or `null` when none qualify. */
|
|
123
|
+
export function selectIssue(
|
|
124
|
+
issues: readonly Issue[],
|
|
125
|
+
blockerStates: ReadonlyMap<number, BlockerPrState>,
|
|
126
|
+
phoebeBase?: string,
|
|
127
|
+
): { issue: Issue; resolution: BaseResolution } | null {
|
|
128
|
+
const sorted = [...issues].sort(compareIssues);
|
|
129
|
+
for (const issue of sorted) {
|
|
130
|
+
const resolution = resolveWorktreeBase(issue, blockerStates, phoebeBase);
|
|
131
|
+
if (resolution) {
|
|
132
|
+
return { issue, resolution };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function stackedPrComment(blockerIssueNumber: number, blockerPrNumber: PrNumber): string {
|
|
139
|
+
return (
|
|
140
|
+
`⛓️ Blocked by #${blockerIssueNumber} (PR #${blockerPrNumber}). ` +
|
|
141
|
+
`Its commits appear in this diff until #${blockerPrNumber} merges. ` +
|
|
142
|
+
`**Do not merge this PR before #${blockerPrNumber}** — doing so would pull ` +
|
|
143
|
+
`#${blockerIssueNumber}'s work into \`main\` ahead of its own review.`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type ConflictingPrCandidate = {
|
|
148
|
+
prNumber: PrNumber;
|
|
149
|
+
headRefName: BranchRef;
|
|
150
|
+
issueNumber?: number;
|
|
151
|
+
headSha?: Sha;
|
|
152
|
+
failureWatermark?: ConflictFailWatermark | null;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export type ConflictFailWatermark = {
|
|
156
|
+
prHead: Sha;
|
|
157
|
+
mainHead: Sha;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const CONFLICT_FAIL_WATERMARK_RE =
|
|
161
|
+
/<!--\s*phoebe-conflict-fail:\s*prHead=([0-9a-f]+)\s+mainHead=([0-9a-f]+)\s*-->/i;
|
|
162
|
+
|
|
163
|
+
export function buildConflictFailWatermarkMarker(watermark: ConflictFailWatermark): string {
|
|
164
|
+
return `<!-- phoebe-conflict-fail: prHead=${watermark.prHead} mainHead=${watermark.mainHead} -->`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function parseConflictFailWatermark(text: string): ConflictFailWatermark | null {
|
|
168
|
+
const match = CONFLICT_FAIL_WATERMARK_RE.exec(text);
|
|
169
|
+
if (!match) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return { prHead: asSha(match[1]!), mainHead: asSha(match[2]!) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Scan comment bodies newest-first and return the first marker `parse` extracts,
|
|
177
|
+
* or `null` when none match. Shared by every work kind's watermark lookup — the
|
|
178
|
+
* latest marker wins when several exist on one PR.
|
|
179
|
+
*/
|
|
180
|
+
export function parseLatestMarker<T>(
|
|
181
|
+
bodies: readonly string[],
|
|
182
|
+
parse: (text: string) => T | null,
|
|
183
|
+
): T | null {
|
|
184
|
+
for (let i = bodies.length - 1; i >= 0; i--) {
|
|
185
|
+
const parsed = parse(bodies[i]!);
|
|
186
|
+
if (parsed !== null) {
|
|
187
|
+
return parsed;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function shouldSkipWatermarkConflictFix(opts: {
|
|
194
|
+
watermark: ConflictFailWatermark | null;
|
|
195
|
+
currentPrHead: Sha;
|
|
196
|
+
currentMainHead: Sha;
|
|
197
|
+
}): boolean {
|
|
198
|
+
if (!opts.watermark) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return (
|
|
202
|
+
opts.watermark.prHead === opts.currentPrHead && opts.watermark.mainHead === opts.currentMainHead
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
207
|
+
|
|
208
|
+
const ISSUE_BRANCH_RE = new RegExp(`^${escapeRegExp(config.branchPrefix)}issue-(\\d+)$`);
|
|
209
|
+
|
|
210
|
+
export function isPhoebeHeadBranch(branch: BranchRef): boolean {
|
|
211
|
+
return branch.startsWith(config.branchPrefix);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export type PrScopeConfig = {
|
|
215
|
+
branchPrefix: string;
|
|
216
|
+
prScope: "phoebe" | "all";
|
|
217
|
+
draftPrs: "skip-non-phoebe" | "skip-all" | "include";
|
|
218
|
+
prOptOutLabel: string;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export type PrScanFields = {
|
|
222
|
+
headRefName: BranchRef;
|
|
223
|
+
isDraft: boolean;
|
|
224
|
+
isCrossRepository: boolean;
|
|
225
|
+
labels: readonly string[];
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const defaultPrScopeConfig = (): PrScopeConfig => ({
|
|
229
|
+
branchPrefix: config.branchPrefix,
|
|
230
|
+
prScope: config.prScope,
|
|
231
|
+
draftPrs: config.draftPrs,
|
|
232
|
+
prOptOutLabel: config.prOptOutLabel,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/** Whether an open PR is eligible for conflicts/checks/reviews scanning. */
|
|
236
|
+
export function isPrInScope(
|
|
237
|
+
pr: PrScanFields,
|
|
238
|
+
scopeConfig: PrScopeConfig = defaultPrScopeConfig(),
|
|
239
|
+
): boolean {
|
|
240
|
+
if (pr.isCrossRepository) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
if (pr.labels.includes(scopeConfig.prOptOutLabel)) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
const isPhoebe = pr.headRefName.startsWith(scopeConfig.branchPrefix);
|
|
247
|
+
if (scopeConfig.prScope === "phoebe" && !isPhoebe) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
if (pr.isDraft) {
|
|
251
|
+
if (scopeConfig.draftPrs === "skip-all") {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
if (scopeConfig.draftPrs === "skip-non-phoebe" && !isPhoebe) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function parseIssueNumberFromBranch(branch: BranchRef): number | null {
|
|
262
|
+
const match = ISSUE_BRANCH_RE.exec(branch);
|
|
263
|
+
return match ? Number(match[1]) : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** GitHub may return UNKNOWN while mergeability is still computing. */
|
|
267
|
+
export function isPrMergeConflicting(mergeable: string, mergeStateStatus?: string): boolean {
|
|
268
|
+
if (mergeable === "CONFLICTING") return true;
|
|
269
|
+
if (mergeable === "UNKNOWN" && mergeStateStatus === "DIRTY") return true;
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Skip idle conflict-fix when the PR's issue is still stacked on a blocker with
|
|
275
|
+
* an open PR — its divergence from `main` is expected, not a real conflict.
|
|
276
|
+
*/
|
|
277
|
+
export function shouldSkipStackedConflictFix(
|
|
278
|
+
issueBody: string,
|
|
279
|
+
blockerStates: ReadonlyMap<number, BlockerPrState>,
|
|
280
|
+
): boolean {
|
|
281
|
+
for (const blockerIssueNumber of parseBlockedBy(issueBody)) {
|
|
282
|
+
const state = blockerStates.get(blockerIssueNumber);
|
|
283
|
+
if (state?.hasOpenPr) {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Merged blocker PR numbers for lazy catch-up (bottom-up stack order). */
|
|
291
|
+
export function getMergedBlockerPrNumbers(
|
|
292
|
+
issueBody: string,
|
|
293
|
+
blockerStates: ReadonlyMap<number, BlockerPrState>,
|
|
294
|
+
): PrNumber[] {
|
|
295
|
+
const merged: PrNumber[] = [];
|
|
296
|
+
for (const blockerIssueNumber of parseBlockedBy(issueBody)) {
|
|
297
|
+
const state = blockerStates.get(blockerIssueNumber);
|
|
298
|
+
if (state?.hasMergedPr && state.mergedPrNumber !== undefined) {
|
|
299
|
+
merged.push(state.mergedPrNumber);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return merged;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function stackedCatchUpRetractionComment(blockerPrNumbers: readonly PrNumber[]): string {
|
|
306
|
+
if (blockerPrNumbers.length === 1) {
|
|
307
|
+
return (
|
|
308
|
+
`Blocker #${blockerPrNumbers[0]} merged; this branch has been caught up to \`main\` ` +
|
|
309
|
+
`and is now independently mergeable.`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
const list = blockerPrNumbers.map((n) => `#${n}`).join(", ");
|
|
313
|
+
return (
|
|
314
|
+
`Blockers ${list} merged; this branch has been caught up to \`main\` ` +
|
|
315
|
+
`and is now independently mergeable.`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Oldest PR (lowest number) among candidates, or `null` when the list is empty. */
|
|
320
|
+
function pickOldestPr<T extends { prNumber: number }>(candidates: readonly T[]): T | null {
|
|
321
|
+
if (candidates.length === 0) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
return candidates.reduce((oldest, pr) => (pr.prNumber < oldest.prNumber ? pr : oldest));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function selectConflictFixCandidates(
|
|
328
|
+
prs: readonly ConflictingPrCandidate[],
|
|
329
|
+
ctx: StackContext,
|
|
330
|
+
opts?: { currentMainHead: Sha },
|
|
331
|
+
): ConflictingPrCandidate[] {
|
|
332
|
+
return prs.filter((pr) => {
|
|
333
|
+
const issueNumber = pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName);
|
|
334
|
+
if (issueNumber !== null) {
|
|
335
|
+
const body = ctx.issueBodies.get(issueNumber) ?? "";
|
|
336
|
+
if (shouldSkipStackedConflictFix(body, ctx.blockerStates)) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (opts?.currentMainHead && pr.headSha) {
|
|
341
|
+
if (
|
|
342
|
+
shouldSkipWatermarkConflictFix({
|
|
343
|
+
watermark: pr.failureWatermark ?? null,
|
|
344
|
+
currentPrHead: pr.headSha,
|
|
345
|
+
currentMainHead: opts.currentMainHead,
|
|
346
|
+
})
|
|
347
|
+
) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return true;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export type StatusCheckItem = {
|
|
356
|
+
__typename?: string;
|
|
357
|
+
name?: string;
|
|
358
|
+
context?: string;
|
|
359
|
+
status?: string;
|
|
360
|
+
conclusion?: string | null;
|
|
361
|
+
state?: string;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
export type FailingCheck = {
|
|
365
|
+
name: string;
|
|
366
|
+
conclusion: string;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
export function checkItemName(item: StatusCheckItem): string {
|
|
370
|
+
return item.name ?? item.context ?? "unknown";
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function isCheckItemFailing(item: StatusCheckItem): boolean {
|
|
374
|
+
if (item.__typename === "CheckRun" || item.status !== undefined) {
|
|
375
|
+
const conclusion = item.conclusion ?? "";
|
|
376
|
+
return (
|
|
377
|
+
conclusion === "FAILURE" ||
|
|
378
|
+
conclusion === "CANCELLED" ||
|
|
379
|
+
conclusion === "TIMED_OUT" ||
|
|
380
|
+
conclusion === "ACTION_REQUIRED"
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const state = item.state ?? "";
|
|
384
|
+
return state === "FAILURE" || state === "ERROR";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function isCheckItemPending(item: StatusCheckItem): boolean {
|
|
388
|
+
if (item.__typename === "CheckRun" || item.status !== undefined) {
|
|
389
|
+
const status = item.status ?? "";
|
|
390
|
+
return (
|
|
391
|
+
status === "QUEUED" ||
|
|
392
|
+
status === "IN_PROGRESS" ||
|
|
393
|
+
status === "WAITING" ||
|
|
394
|
+
status === "PENDING" ||
|
|
395
|
+
status === "REQUESTED"
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
const state = item.state ?? "";
|
|
399
|
+
return state === "PENDING" || state === "EXPECTED";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Combined rollup: FAILURE when at least one check failed and none are pending. */
|
|
403
|
+
export function statusCheckRollupState(
|
|
404
|
+
checks: readonly StatusCheckItem[],
|
|
405
|
+
): "FAILURE" | "PENDING" | "SUCCESS" | "NONE" {
|
|
406
|
+
if (checks.length === 0) {
|
|
407
|
+
return "NONE";
|
|
408
|
+
}
|
|
409
|
+
if (checks.some(isCheckItemPending)) {
|
|
410
|
+
return "PENDING";
|
|
411
|
+
}
|
|
412
|
+
if (checks.some(isCheckItemFailing)) {
|
|
413
|
+
return "FAILURE";
|
|
414
|
+
}
|
|
415
|
+
return "SUCCESS";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export type WorkflowRunItem = {
|
|
419
|
+
workflowName?: string;
|
|
420
|
+
name?: string;
|
|
421
|
+
status?: string;
|
|
422
|
+
conclusion?: string | null;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Map `gh run list` rows onto StatusCheckItem. The REST Actions API is the
|
|
427
|
+
* check-state source usable by fine-grained PATs — GraphQL statusCheckRollup
|
|
428
|
+
* is GitHub-App/OAuth only. REST enums are lowercase; rows arrive newest
|
|
429
|
+
* first, and only the newest run per workflow counts.
|
|
430
|
+
*/
|
|
431
|
+
export function workflowRunsToCheckItems(runs: readonly WorkflowRunItem[]): StatusCheckItem[] {
|
|
432
|
+
const seen = new Set<string>();
|
|
433
|
+
const items: StatusCheckItem[] = [];
|
|
434
|
+
for (const run of runs) {
|
|
435
|
+
const name = run.workflowName ?? run.name ?? "unknown";
|
|
436
|
+
if (seen.has(name)) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
seen.add(name);
|
|
440
|
+
items.push({
|
|
441
|
+
name,
|
|
442
|
+
status: (run.status ?? "").toUpperCase(),
|
|
443
|
+
conclusion: run.conclusion ? run.conclusion.toUpperCase() : null,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return items;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function listFailingChecks(checks: readonly StatusCheckItem[]): FailingCheck[] {
|
|
450
|
+
return checks.filter(isCheckItemFailing).map((item) => ({
|
|
451
|
+
name: checkItemName(item),
|
|
452
|
+
conclusion: item.conclusion ?? item.state ?? "",
|
|
453
|
+
}));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export type ChecksCandidate = {
|
|
457
|
+
prNumber: PrNumber;
|
|
458
|
+
headRefName: BranchRef;
|
|
459
|
+
issueNumber?: number;
|
|
460
|
+
headSha?: Sha;
|
|
461
|
+
mergeable: string;
|
|
462
|
+
mergeStateStatus?: string;
|
|
463
|
+
failingChecks: FailingCheck[];
|
|
464
|
+
failureWatermark?: ChecksFailWatermark | null;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
export type ChecksFailWatermark = {
|
|
468
|
+
prHead: Sha;
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const CHECKS_FAIL_WATERMARK_RE = /<!--\s*phoebe-checks-fail:\s*prHead=([0-9a-f]+)\s*-->/i;
|
|
472
|
+
|
|
473
|
+
export function buildChecksFailWatermarkMarker(watermark: ChecksFailWatermark): string {
|
|
474
|
+
return `<!-- phoebe-checks-fail: prHead=${watermark.prHead} -->`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function parseChecksFailWatermark(text: string): ChecksFailWatermark | null {
|
|
478
|
+
const match = CHECKS_FAIL_WATERMARK_RE.exec(text);
|
|
479
|
+
if (!match) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
return { prHead: asSha(match[1]!) };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function shouldSkipWatermarkChecksFix(opts: {
|
|
486
|
+
watermark: ChecksFailWatermark | null;
|
|
487
|
+
currentPrHead: Sha;
|
|
488
|
+
}): boolean {
|
|
489
|
+
if (!opts.watermark) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
return opts.watermark.prHead === opts.currentPrHead;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Reuse stacked-blocker skip logic — stacked PR red CI is handled at blocker merge. */
|
|
496
|
+
export const shouldSkipStackedChecksFix = shouldSkipStackedConflictFix;
|
|
497
|
+
|
|
498
|
+
export function selectChecksCandidates(
|
|
499
|
+
prs: readonly ChecksCandidate[],
|
|
500
|
+
ctx: StackContext,
|
|
501
|
+
): ChecksCandidate[] {
|
|
502
|
+
return prs.filter((pr) => {
|
|
503
|
+
if (isPrMergeConflicting(pr.mergeable, pr.mergeStateStatus)) {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
const issueNumber = pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName);
|
|
507
|
+
if (issueNumber !== null) {
|
|
508
|
+
const body = ctx.issueBodies.get(issueNumber) ?? "";
|
|
509
|
+
if (shouldSkipStackedChecksFix(body, ctx.blockerStates)) {
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (pr.headSha) {
|
|
514
|
+
if (
|
|
515
|
+
shouldSkipWatermarkChecksFix({
|
|
516
|
+
watermark: pr.failureWatermark ?? null,
|
|
517
|
+
currentPrHead: pr.headSha,
|
|
518
|
+
})
|
|
519
|
+
) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return true;
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Pick the single checks unit — oldest PR number among eligible failing-CI candidates. */
|
|
528
|
+
export function selectChecksUnit(
|
|
529
|
+
prs: readonly ChecksCandidate[],
|
|
530
|
+
ctx: StackContext,
|
|
531
|
+
): ChecksCandidate | null {
|
|
532
|
+
return pickOldestPr(selectChecksCandidates(prs, ctx));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export type ReviewThreadComment = {
|
|
536
|
+
createdAt: string;
|
|
537
|
+
authorLogin: string;
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
export type ReviewThread = {
|
|
541
|
+
isResolved: boolean;
|
|
542
|
+
isOutdated: boolean;
|
|
543
|
+
comments: readonly ReviewThreadComment[];
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
export type ReviewsCandidate = {
|
|
547
|
+
prNumber: PrNumber;
|
|
548
|
+
headRefName: BranchRef;
|
|
549
|
+
issueNumber?: number;
|
|
550
|
+
authorLogin?: string;
|
|
551
|
+
mergeable: string;
|
|
552
|
+
mergeStateStatus?: string;
|
|
553
|
+
threads: readonly ReviewThread[];
|
|
554
|
+
handledWatermark?: ReviewsHandledWatermark | null;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
export type ReviewsHandledWatermark = {
|
|
558
|
+
latest: string;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const REVIEWS_HANDLED_WATERMARK_RE = /<!--\s*phoebe-reviews-handled:\s*latest=([^\s>]+)\s*-->/i;
|
|
562
|
+
|
|
563
|
+
export function buildReviewsHandledMarker(watermark: ReviewsHandledWatermark): string {
|
|
564
|
+
return `<!-- phoebe-reviews-handled: latest=${watermark.latest} -->`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function parseReviewsHandledWatermark(text: string): ReviewsHandledWatermark | null {
|
|
568
|
+
const match = REVIEWS_HANDLED_WATERMARK_RE.exec(text);
|
|
569
|
+
if (!match) {
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
return { latest: match[1]! };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function isReviewSummaryComment(body: string): boolean {
|
|
576
|
+
return body.includes(config.reviewsSuccessHeading);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function isActivityNewerThanWatermark(
|
|
580
|
+
createdAt: string,
|
|
581
|
+
watermark: ReviewsHandledWatermark | null,
|
|
582
|
+
): boolean {
|
|
583
|
+
if (!watermark) {
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
return createdAt > watermark.latest;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function newestReviewThreadCommentCreatedAt(
|
|
590
|
+
threads: readonly ReviewThread[],
|
|
591
|
+
): string | null {
|
|
592
|
+
let newest: string | null = null;
|
|
593
|
+
for (const thread of threads) {
|
|
594
|
+
for (const comment of thread.comments) {
|
|
595
|
+
if (newest === null || comment.createdAt > newest) {
|
|
596
|
+
newest = comment.createdAt;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return newest;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function hasNewNonPhoebeReviewActivity(opts: {
|
|
604
|
+
threads: readonly ReviewThread[];
|
|
605
|
+
phoebeLogin: string;
|
|
606
|
+
authorLogin?: string;
|
|
607
|
+
watermark: ReviewsHandledWatermark | null;
|
|
608
|
+
}): boolean {
|
|
609
|
+
for (const thread of opts.threads) {
|
|
610
|
+
if (thread.isResolved || thread.isOutdated) {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
for (const comment of thread.comments) {
|
|
614
|
+
if (comment.authorLogin === opts.phoebeLogin) {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (opts.authorLogin !== undefined && comment.authorLogin === opts.authorLogin) {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (isActivityNewerThanWatermark(comment.createdAt, opts.watermark)) {
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Reuse stacked-blocker skip logic — stacked PR review comments are often about blocker code. */
|
|
629
|
+
export const shouldSkipStackedReviewsFix = shouldSkipStackedConflictFix;
|
|
630
|
+
|
|
631
|
+
export function selectReviewsCandidates(
|
|
632
|
+
prs: readonly ReviewsCandidate[],
|
|
633
|
+
ctx: StackContext,
|
|
634
|
+
phoebeLogin: string,
|
|
635
|
+
): ReviewsCandidate[] {
|
|
636
|
+
return prs.filter((pr) => {
|
|
637
|
+
if (isPrMergeConflicting(pr.mergeable, pr.mergeStateStatus)) {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
const issueNumber = pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName);
|
|
641
|
+
if (issueNumber !== null) {
|
|
642
|
+
const body = ctx.issueBodies.get(issueNumber) ?? "";
|
|
643
|
+
if (shouldSkipStackedReviewsFix(body, ctx.blockerStates)) {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return hasNewNonPhoebeReviewActivity({
|
|
648
|
+
threads: pr.threads,
|
|
649
|
+
phoebeLogin,
|
|
650
|
+
authorLogin: pr.authorLogin,
|
|
651
|
+
watermark: pr.handledWatermark ?? null,
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Pick the single reviews unit — oldest PR number among eligible review-feedback candidates. */
|
|
657
|
+
export function selectReviewsUnit(
|
|
658
|
+
prs: readonly ReviewsCandidate[],
|
|
659
|
+
ctx: StackContext,
|
|
660
|
+
phoebeLogin: string,
|
|
661
|
+
): ReviewsCandidate | null {
|
|
662
|
+
return pickOldestPr(selectReviewsCandidates(prs, ctx, phoebeLogin));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export function buildReviewsHandledComment(opts: {
|
|
666
|
+
latestActivityAt: string | null;
|
|
667
|
+
failed: boolean;
|
|
668
|
+
}): string {
|
|
669
|
+
const latest = opts.latestActivityAt ?? "1970-01-01T00:00:00Z";
|
|
670
|
+
const marker = buildReviewsHandledMarker({ latest });
|
|
671
|
+
if (opts.failed) {
|
|
672
|
+
return (
|
|
673
|
+
"Phoebe attempted to handle review feedback and failed; will retry on new review activity.\n\n" +
|
|
674
|
+
marker
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
return marker;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export const WORK_KIND_NAMES = ["conflicts", "checks", "reviews", "issues", "research"] as const;
|
|
681
|
+
export type WorkKindName = (typeof WORK_KIND_NAMES)[number];
|
|
682
|
+
|
|
683
|
+
/** Whether a work-kind may run under `--run-once`. Janitor kinds are persistent-mode only. */
|
|
684
|
+
export const WORK_KIND_ONE_SHOT_ELIGIBLE: Record<WorkKindName, boolean> = {
|
|
685
|
+
conflicts: false,
|
|
686
|
+
checks: false,
|
|
687
|
+
reviews: false,
|
|
688
|
+
issues: true,
|
|
689
|
+
research: true,
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
export const RUN_ONCE_NOTHING_MESSAGE =
|
|
693
|
+
"[phoebe] Nothing to do under --run-once (janitor kinds are persistent-mode only).";
|
|
694
|
+
|
|
695
|
+
export function oneShotWorkKinds(workOrder: readonly WorkKindName[]): readonly WorkKindName[] {
|
|
696
|
+
return workOrder.filter((kind) => WORK_KIND_ONE_SHOT_ELIGIBLE[kind]);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Fail fast when `WORK_ORDER` is empty or names an unknown kind. */
|
|
700
|
+
export function validateWorkOrder(order: readonly string[]): readonly WorkKindName[] {
|
|
701
|
+
if (order.length === 0) {
|
|
702
|
+
throw new Error(
|
|
703
|
+
`WORK_ORDER must not be empty. Include at least one of: ${WORK_KIND_NAMES.join(", ")}.`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
const validated: WorkKindName[] = [];
|
|
707
|
+
for (const kind of order) {
|
|
708
|
+
if (!WORK_KIND_NAMES.includes(kind as WorkKindName)) {
|
|
709
|
+
throw new Error(
|
|
710
|
+
`Unknown work kind "${kind}" in WORK_ORDER. Use one of: ${WORK_KIND_NAMES.join(", ")}.`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
validated.push(kind as WorkKindName);
|
|
714
|
+
}
|
|
715
|
+
return validated;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Pick the single conflict unit — oldest PR number among unblocked candidates. */
|
|
719
|
+
export function selectConflictUnit(
|
|
720
|
+
prs: readonly ConflictingPrCandidate[],
|
|
721
|
+
ctx: StackContext,
|
|
722
|
+
opts?: { currentMainHead: Sha },
|
|
723
|
+
): ConflictingPrCandidate | null {
|
|
724
|
+
return pickOldestPr(selectConflictFixCandidates(prs, ctx, opts));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export type IssueWorkUnit = { issue: Issue; resolution: BaseResolution };
|
|
728
|
+
|
|
729
|
+
export type WorkUnit =
|
|
730
|
+
| { kind: "conflicts"; unit: ConflictingPrCandidate }
|
|
731
|
+
| { kind: "checks"; unit: ChecksCandidate }
|
|
732
|
+
| { kind: "reviews"; unit: ReviewsCandidate }
|
|
733
|
+
| { kind: "issues"; unit: IssueWorkUnit }
|
|
734
|
+
| { kind: "research"; unit: IssueWorkUnit };
|
|
735
|
+
|
|
736
|
+
export type WorkSelectionData = {
|
|
737
|
+
issues: readonly Issue[];
|
|
738
|
+
/** Wayfinder research tickets selected by the `research` kind (reuses the issues path). */
|
|
739
|
+
researchIssues?: readonly Issue[];
|
|
740
|
+
blockerStates: ReadonlyMap<number, BlockerPrState>;
|
|
741
|
+
conflictingPrs: readonly ConflictingPrCandidate[];
|
|
742
|
+
failingCheckPrs: readonly ChecksCandidate[];
|
|
743
|
+
reviewActivityPrs: readonly ReviewsCandidate[];
|
|
744
|
+
issueBodies: ReadonlyMap<number, string>;
|
|
745
|
+
phoebeBase?: string;
|
|
746
|
+
phoebeLogin?: string;
|
|
747
|
+
currentMainHead?: Sha;
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
export type WorkSelectionOptions = {
|
|
751
|
+
/** When true, skip kinds with `WORK_KIND_ONE_SHOT_ELIGIBLE[kind] === false`. */
|
|
752
|
+
oneShotOnly?: boolean;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
function conflictSelectionOpts(currentMainHead?: Sha): { currentMainHead: Sha } | undefined {
|
|
756
|
+
return currentMainHead ? { currentMainHead } : undefined;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Walk `workOrder` and return the first kind that has a unit of work. */
|
|
760
|
+
export function selectFirstWorkUnit(
|
|
761
|
+
workOrder: readonly WorkKindName[],
|
|
762
|
+
data: WorkSelectionData,
|
|
763
|
+
opts?: WorkSelectionOptions,
|
|
764
|
+
): WorkUnit | null {
|
|
765
|
+
const ctx: StackContext = {
|
|
766
|
+
issueBodies: data.issueBodies,
|
|
767
|
+
blockerStates: data.blockerStates,
|
|
768
|
+
};
|
|
769
|
+
for (const kind of workOrder) {
|
|
770
|
+
if (opts?.oneShotOnly && !WORK_KIND_ONE_SHOT_ELIGIBLE[kind]) {
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (kind === "conflicts") {
|
|
774
|
+
const unit = selectConflictUnit(
|
|
775
|
+
data.conflictingPrs,
|
|
776
|
+
ctx,
|
|
777
|
+
conflictSelectionOpts(data.currentMainHead),
|
|
778
|
+
);
|
|
779
|
+
if (unit) {
|
|
780
|
+
return { kind: "conflicts", unit };
|
|
781
|
+
}
|
|
782
|
+
} else if (kind === "checks") {
|
|
783
|
+
const unit = selectChecksUnit(data.failingCheckPrs, ctx);
|
|
784
|
+
if (unit) {
|
|
785
|
+
return { kind: "checks", unit };
|
|
786
|
+
}
|
|
787
|
+
} else if (kind === "reviews") {
|
|
788
|
+
if (!data.phoebeLogin) {
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
const unit = selectReviewsUnit(data.reviewActivityPrs, ctx, data.phoebeLogin);
|
|
792
|
+
if (unit) {
|
|
793
|
+
return { kind: "reviews", unit };
|
|
794
|
+
}
|
|
795
|
+
} else if (kind === "issues") {
|
|
796
|
+
const unit = selectIssue(data.issues, data.blockerStates, data.phoebeBase);
|
|
797
|
+
if (unit) {
|
|
798
|
+
return { kind: "issues", unit };
|
|
799
|
+
}
|
|
800
|
+
} else if (kind === "research") {
|
|
801
|
+
// Research reuses the issues selection path (blocker + priority ordering)
|
|
802
|
+
// against the researchLabel-tagged tickets gathered separately this cycle.
|
|
803
|
+
const unit = selectIssue(data.researchIssues ?? [], data.blockerStates, data.phoebeBase);
|
|
804
|
+
if (unit) {
|
|
805
|
+
return { kind: "research", unit };
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
// Idle-cycle selection summaries
|
|
814
|
+
//
|
|
815
|
+
// These sit beside the selectors so main's idle logging asks "what did you skip,
|
|
816
|
+
// and why?" instead of rebuilding blocker maps and re-running every selector. The
|
|
817
|
+
// `unit` field is the same pick the live loop would make; the skip counts explain
|
|
818
|
+
// an idle cycle to the operator.
|
|
819
|
+
// ---------------------------------------------------------------------------
|
|
820
|
+
|
|
821
|
+
export type ConflictSelectionSummary = {
|
|
822
|
+
unit: ConflictingPrCandidate | null;
|
|
823
|
+
skippedStacked: number;
|
|
824
|
+
skippedWatermark: number;
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
export function summarizeConflictSelection(
|
|
828
|
+
prs: readonly ConflictingPrCandidate[],
|
|
829
|
+
ctx: StackContext,
|
|
830
|
+
opts?: { currentMainHead: Sha },
|
|
831
|
+
): ConflictSelectionSummary {
|
|
832
|
+
const withoutWatermark = selectConflictFixCandidates(prs, ctx);
|
|
833
|
+
const candidates = selectConflictFixCandidates(prs, ctx, opts);
|
|
834
|
+
return {
|
|
835
|
+
unit: pickOldestPr(candidates),
|
|
836
|
+
skippedStacked: prs.length - withoutWatermark.length,
|
|
837
|
+
skippedWatermark: withoutWatermark.length - candidates.length,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export type ChecksSelectionSummary = {
|
|
842
|
+
unit: ChecksCandidate | null;
|
|
843
|
+
skipped: number;
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
export function summarizeChecksSelection(
|
|
847
|
+
prs: readonly ChecksCandidate[],
|
|
848
|
+
ctx: StackContext,
|
|
849
|
+
): ChecksSelectionSummary {
|
|
850
|
+
const candidates = selectChecksCandidates(prs, ctx);
|
|
851
|
+
return { unit: pickOldestPr(candidates), skipped: prs.length - candidates.length };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export type ReviewsSelectionSummary = {
|
|
855
|
+
unit: ReviewsCandidate | null;
|
|
856
|
+
skipped: number;
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
export function summarizeReviewsSelection(
|
|
860
|
+
prs: readonly ReviewsCandidate[],
|
|
861
|
+
ctx: StackContext,
|
|
862
|
+
phoebeLogin: string,
|
|
863
|
+
): ReviewsSelectionSummary {
|
|
864
|
+
const candidates = selectReviewsCandidates(prs, ctx, phoebeLogin);
|
|
865
|
+
return { unit: pickOldestPr(candidates), skipped: prs.length - candidates.length };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export function conflictFixFailureComment(
|
|
869
|
+
prNumber: PrNumber,
|
|
870
|
+
watermark?: ConflictFailWatermark,
|
|
871
|
+
): string {
|
|
872
|
+
const parts = [
|
|
873
|
+
`Phoebe attempted an idle merge-conflict fix (merge \`origin/main\` into this branch) ` +
|
|
874
|
+
`for PR #${prNumber} but could not resolve it cleanly. The branch was left unchanged ` +
|
|
875
|
+
`(\`git merge --abort\`). A human should resolve the conflicts manually.`,
|
|
876
|
+
];
|
|
877
|
+
if (watermark) {
|
|
878
|
+
parts.push("", buildConflictFailWatermarkMarker(watermark));
|
|
879
|
+
}
|
|
880
|
+
return parts.join("\n");
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* After a sandbox conflict fix, the host may see 0 unpushed commits even when the sandbox
|
|
885
|
+
* already pushed. Only post a failure comment when origin is unchanged and the PR still
|
|
886
|
+
* conflicts.
|
|
887
|
+
*/
|
|
888
|
+
export function shouldPostConflictFixFailure(opts: {
|
|
889
|
+
hostCommitCount: number;
|
|
890
|
+
originShaBefore: Sha;
|
|
891
|
+
originShaAfter: Sha;
|
|
892
|
+
mergeable: string;
|
|
893
|
+
mergeStateStatus?: string;
|
|
894
|
+
}): boolean {
|
|
895
|
+
if (opts.hostCommitCount > 0) {
|
|
896
|
+
return false;
|
|
897
|
+
}
|
|
898
|
+
if (opts.originShaAfter !== opts.originShaBefore) {
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
return isPrMergeConflicting(opts.mergeable, opts.mergeStateStatus);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export function checksFixFailureComment(
|
|
905
|
+
prNumber: PrNumber,
|
|
906
|
+
watermark?: ChecksFailWatermark,
|
|
907
|
+
): string {
|
|
908
|
+
const parts = [
|
|
909
|
+
`Phoebe attempted an idle CI fix for PR #${prNumber} but could not resolve the failing ` +
|
|
910
|
+
`checks. The branch was left unchanged. A human should investigate the CI failures.`,
|
|
911
|
+
];
|
|
912
|
+
if (watermark) {
|
|
913
|
+
parts.push("", buildChecksFailWatermarkMarker(watermark));
|
|
914
|
+
}
|
|
915
|
+
return parts.join("\n");
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* After a checks fix agent run, post a failure comment only when the agent made
|
|
920
|
+
* no commits and did not push.
|
|
921
|
+
*/
|
|
922
|
+
export function shouldPostChecksFixFailure(opts: {
|
|
923
|
+
hostCommitCount: number;
|
|
924
|
+
originShaBefore: Sha;
|
|
925
|
+
originShaAfter: Sha;
|
|
926
|
+
}): boolean {
|
|
927
|
+
if (opts.hostCommitCount > 0) {
|
|
928
|
+
return false;
|
|
929
|
+
}
|
|
930
|
+
return opts.originShaAfter === opts.originShaBefore;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
export function formatFailingChecksForPrompt(checks: readonly FailingCheck[]): string {
|
|
934
|
+
return checks.map((c) => `${c.name}: ${c.conclusion}`).join("\n");
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
export function buildInitialPrBody(opts: {
|
|
938
|
+
issueNumber: number;
|
|
939
|
+
commitCount: number;
|
|
940
|
+
stacked?: { blockerIssueNumber: number; blockerPrNumber: PrNumber };
|
|
941
|
+
}): string {
|
|
942
|
+
const parts = [`Closes #${opts.issueNumber}`, "", "Automated PR from Phoebe.", ""];
|
|
943
|
+
if (opts.stacked) {
|
|
944
|
+
parts.push(stackedPrComment(opts.stacked.blockerIssueNumber, opts.stacked.blockerPrNumber), "");
|
|
945
|
+
}
|
|
946
|
+
parts.push(`Commits: ${opts.commitCount}`);
|
|
947
|
+
return parts.join("\n");
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Incremental note for follow-up pushes — no stacked-PR banner. */
|
|
951
|
+
export function followUpPrComment(issueNumber: number, commitCount: number): string {
|
|
952
|
+
return `Phoebe update for #${issueNumber}: ${commitCount} new commit(s) pushed to this branch.`;
|
|
953
|
+
}
|