@doist/doistbot-cli 1.0.1 → 1.0.3
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/dist/actions/review-usage.js +20 -0
- package/dist/actions/review.js +126 -10
- package/dist/actions/setup.js +3 -2
- package/dist/index.js +18 -8
- package/dist/runtime.js +3 -3
- package/dist/update-check.js +1 -1
- package/package.json +6 -5
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -0
- package/sandbox/dist/core/bot-logins.js +13 -0
- package/sandbox/dist/core/retry.js +29 -0
- package/sandbox/dist/core/shared.js +2 -2
- package/sandbox/dist/main.js +3 -0
- package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +1 -7
- package/sandbox/dist/tasks/issue-triage/fix-attempt.js +157 -28
- package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +190 -0
- package/sandbox/dist/tasks/issue-triage/fix-failure-reasons.js +2 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/effects.js +464 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/effects.types.js +1 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/fake-effects.js +65 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +110 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +227 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/state-machine.js +165 -0
- package/sandbox/dist/tasks/issue-triage/hero-group-map.js +32 -0
- package/sandbox/dist/tasks/issue-triage/markers.js +2 -0
- package/sandbox/dist/tasks/issue-triage/output.js +24 -3
- package/sandbox/dist/tasks/issue-triage/pr-creator.js +72 -30
- package/sandbox/dist/tasks/issue-triage/repo-config.js +13 -0
- package/sandbox/dist/tasks/issue-triage/triage.js +31 -10
- package/sandbox/dist/tasks/issue-triage/types.js +1 -0
- package/sandbox/dist/tasks/review/author-format.js +14 -0
- package/sandbox/dist/tasks/review/author-profile.js +67 -0
- package/sandbox/dist/tasks/review/check-run.js +8 -1
- package/sandbox/dist/tasks/review/comment-mapper.js +17 -12
- package/sandbox/dist/tasks/review/conversation-context.js +174 -20
- package/sandbox/dist/tasks/review/council/summary.js +141 -12
- package/sandbox/dist/tasks/review/engines/council.js +19 -3
- package/sandbox/dist/tasks/review/engines/dedupe.js +37 -28
- package/sandbox/dist/tasks/review/engines/multi-focus.js +123 -6
- package/sandbox/dist/tasks/review/engines/shared.js +28 -5
- package/sandbox/dist/tasks/review/finding-output.js +18 -0
- package/sandbox/dist/tasks/review/github-comment-format.js +32 -0
- package/sandbox/dist/tasks/review/local.js +1 -0
- package/sandbox/dist/tasks/review/multi-focus-prompt.js +0 -1
- package/sandbox/dist/tasks/review/review.js +12 -2
- package/sandbox/dist/tasks/review/runner.js +36 -2
- package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -0
- package/sandbox/review-prompt.md +18 -1
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/efficiency.md +10 -6
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/general.md +2 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/quality.md +15 -1
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/standards.md +2 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/tests.md +14 -1
- package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +31 -4
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/reuse.md +0 -13
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { logger } from '../../../core/logging.js';
|
|
2
|
+
import { withRetry } from '../../../core/retry.js';
|
|
3
|
+
import { renderPriorityBadge } from '../../review/github-comment-format.js';
|
|
4
|
+
import { runLocalReview as defaultRunLocalReview } from '../../review/local.js';
|
|
5
|
+
import { parseSeverity } from '../../review/severity.js';
|
|
6
|
+
import { runFixCore as defaultRunFixCore, stripPRTitleSection } from '../fix-attempt.js';
|
|
7
|
+
import { resolveHeroTeamSlug } from '../hero-group-map.js';
|
|
8
|
+
import { FIX_LOOP_START_MARKER } from '../markers.js';
|
|
9
|
+
import { commitAndPushFix as defaultCommitAndPushFix, commitAndPushIteration as defaultCommitAndPushIteration, openDraftFixPR as defaultOpenDraftFixPR, } from '../pr-creator.js';
|
|
10
|
+
export function buildFixLoopStartCommentBody(args) {
|
|
11
|
+
const p0 = renderPriorityBadge('P0') ?? 'P0';
|
|
12
|
+
const p1 = renderPriorityBadge('P1') ?? 'P1';
|
|
13
|
+
return [
|
|
14
|
+
FIX_LOOP_START_MARKER,
|
|
15
|
+
'',
|
|
16
|
+
'### Working on a fix',
|
|
17
|
+
'',
|
|
18
|
+
`I've opened a draft PR with an initial fix: ${args.prUrl}`,
|
|
19
|
+
'',
|
|
20
|
+
`The PR targets \`${args.targetRepoFullName}\`. I'll review it locally, iterate on ${p0} and ${p1} findings, and run CI. Once everything passes, I'll promote the PR out of draft and request a hero review on it.`,
|
|
21
|
+
].join('\n');
|
|
22
|
+
}
|
|
23
|
+
const DRY_RUN_HEAD_SHA = '0'.repeat(40);
|
|
24
|
+
const DRY_RUN_BRANCH = 'doistbot/dry-run';
|
|
25
|
+
const DRY_RUN_PR_NUMBER = 0;
|
|
26
|
+
const FAILURE_CONCLUSIONS = new Set([
|
|
27
|
+
'failure',
|
|
28
|
+
'cancelled',
|
|
29
|
+
'timed_out',
|
|
30
|
+
'action_required',
|
|
31
|
+
'startup_failure',
|
|
32
|
+
]);
|
|
33
|
+
const FAILURE_STATUS_STATES = new Set(['error', 'failure']);
|
|
34
|
+
function defaultDeps() {
|
|
35
|
+
return {
|
|
36
|
+
runFixCore: defaultRunFixCore,
|
|
37
|
+
commitAndPushFix: defaultCommitAndPushFix,
|
|
38
|
+
commitAndPushIteration: defaultCommitAndPushIteration,
|
|
39
|
+
openDraftFixPR: defaultOpenDraftFixPR,
|
|
40
|
+
runLocalReview: defaultRunLocalReview,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export class DefaultLoopEffects {
|
|
44
|
+
session;
|
|
45
|
+
opts;
|
|
46
|
+
deps;
|
|
47
|
+
constructor(session, opts = {}, deps = defaultDeps()) {
|
|
48
|
+
this.session = session;
|
|
49
|
+
this.opts = opts;
|
|
50
|
+
this.deps = deps;
|
|
51
|
+
}
|
|
52
|
+
logContext(extra = {}) {
|
|
53
|
+
const session = this.session;
|
|
54
|
+
return {
|
|
55
|
+
task: session.kind,
|
|
56
|
+
delivery_id: session.ctx.deliveryId,
|
|
57
|
+
source_repository: `${session.ctx.owner}/${session.ctx.repo}`,
|
|
58
|
+
target_repository: session.targetRepo.repository.fullName,
|
|
59
|
+
// issue_number identifies the source issue the bot is triaging in
|
|
60
|
+
// Run 1. Promotion (Run 2) has no source issue, so the field is
|
|
61
|
+
// emitted only when the session is fix-loop-shaped.
|
|
62
|
+
...(session.kind === 'fix-loop' ? { issue_number: session.ctx.issueNumber } : {}),
|
|
63
|
+
...extra,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async runFix(input) {
|
|
67
|
+
const session = this.session;
|
|
68
|
+
const result = await this.deps.runFixCore({
|
|
69
|
+
ctx: session.ctx,
|
|
70
|
+
triageOutput: session.triageOutput,
|
|
71
|
+
fixAssessment: session.fixAssessment,
|
|
72
|
+
targetRepo: session.targetRepo,
|
|
73
|
+
issueTitle: session.issueTitle,
|
|
74
|
+
issueUrl: session.issueUrl,
|
|
75
|
+
ghToken: session.ghToken,
|
|
76
|
+
...(input.prevHeadSha !== undefined && { resetTo: input.prevHeadSha }),
|
|
77
|
+
...(input.previousFindings !== undefined && {
|
|
78
|
+
previousFindings: input.previousFindings,
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
if (!result.ok) {
|
|
82
|
+
return { ok: false, reason: result.reason };
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
diff: result.diff,
|
|
87
|
+
codexOutput: result.codexOutput,
|
|
88
|
+
generatedPRTitle: result.generatedPRTitle,
|
|
89
|
+
generatedPRBody: result.generatedPRBody,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async runReview(input) {
|
|
93
|
+
const session = this.session;
|
|
94
|
+
const workspaceDir = this.opts.reviewWorkspaceDir ?? session.targetRepo.localPath;
|
|
95
|
+
const source = input.reviewBaseSha !== undefined
|
|
96
|
+
? { kind: 'sha-range', baseSha: input.reviewBaseSha }
|
|
97
|
+
: 'base';
|
|
98
|
+
const result = await this.deps.runLocalReview({
|
|
99
|
+
workspaceDir,
|
|
100
|
+
source,
|
|
101
|
+
apiKeys: session.apiKeys,
|
|
102
|
+
});
|
|
103
|
+
if (result.reviewResult.status !== 'reviewed') {
|
|
104
|
+
// Only `empty_review` means the review actually ran and the
|
|
105
|
+
// reviewer produced zero comments. The other skip reasons signal
|
|
106
|
+
// the review pipeline never reached a verdict (no models, no diff
|
|
107
|
+
// to review, model returned nothing), and an unreviewed fix must
|
|
108
|
+
// NOT advance toward CI/promotion.
|
|
109
|
+
if (result.reviewResult.reason === 'empty_review') {
|
|
110
|
+
logger.info('Review returned no comments; treating as clean', this.logContext({
|
|
111
|
+
iteration: input.iteration,
|
|
112
|
+
}));
|
|
113
|
+
return { kind: 'reviewed', findings: [], raw: { summary: '', comments: [] } };
|
|
114
|
+
}
|
|
115
|
+
logger.warn('Review unverified; escalating', this.logContext({
|
|
116
|
+
iteration: input.iteration,
|
|
117
|
+
reason: result.reviewResult.reason,
|
|
118
|
+
}));
|
|
119
|
+
return { kind: 'unverified', reason: result.reviewResult.reason };
|
|
120
|
+
}
|
|
121
|
+
const review = result.reviewResult.review;
|
|
122
|
+
const comments = review.comments;
|
|
123
|
+
const findings = comments.map((c) => ({
|
|
124
|
+
path: c.path,
|
|
125
|
+
line: c.line,
|
|
126
|
+
severity: parseSeverity(c.body),
|
|
127
|
+
body: c.body,
|
|
128
|
+
}));
|
|
129
|
+
return { kind: 'reviewed', findings, raw: review };
|
|
130
|
+
}
|
|
131
|
+
async pushCommit(input) {
|
|
132
|
+
if (input.iteration > 0 && input.branch === undefined) {
|
|
133
|
+
// Iteration > 0 always appends to an existing branch — opening a
|
|
134
|
+
// fresh draft PR would orphan the new commit from the original
|
|
135
|
+
// one. The runner should thread state.branch through; reaching
|
|
136
|
+
// here means a caller bug.
|
|
137
|
+
throw new Error(`DefaultLoopEffects.pushCommit: iteration ${input.iteration} requires a branch from the prior push`);
|
|
138
|
+
}
|
|
139
|
+
if (this.opts.dryRun) {
|
|
140
|
+
logger.info('DRY RUN: would commit and push fix', this.logContext({
|
|
141
|
+
iteration: input.iteration,
|
|
142
|
+
diff_lines: input.diff.split('\n').length,
|
|
143
|
+
}));
|
|
144
|
+
const isFirstPush = input.branch === undefined;
|
|
145
|
+
if (isFirstPush) {
|
|
146
|
+
logger.info('DRY RUN: would open draft fix PR', this.logContext());
|
|
147
|
+
logger.info('DRY RUN: would post fix-loop start comment on source issue', {
|
|
148
|
+
...this.logContext(),
|
|
149
|
+
source_issue_number: this.session.ctx.issueNumber,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
headSha: DRY_RUN_HEAD_SHA,
|
|
154
|
+
branch: input.branch ?? DRY_RUN_BRANCH,
|
|
155
|
+
prNumber: DRY_RUN_PR_NUMBER,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (input.branch === undefined) {
|
|
159
|
+
return this.pushFirstCommit(input);
|
|
160
|
+
}
|
|
161
|
+
return this.pushIteration(input, input.branch);
|
|
162
|
+
}
|
|
163
|
+
async pushFirstCommit(input) {
|
|
164
|
+
const session = this.session;
|
|
165
|
+
const { branch, headSha } = await this.deps.commitAndPushFix({
|
|
166
|
+
repoOwner: session.targetRepo.repository.owner,
|
|
167
|
+
repoName: session.targetRepo.repository.repo,
|
|
168
|
+
repoLocalPath: session.targetRepo.localPath,
|
|
169
|
+
githubToken: session.pushToken,
|
|
170
|
+
issueNumber: session.ctx.issueNumber,
|
|
171
|
+
issueTitle: session.issueTitle,
|
|
172
|
+
...(input.generatedPRTitle ? { prTitle: input.generatedPRTitle } : {}),
|
|
173
|
+
});
|
|
174
|
+
const prResult = await this.deps.openDraftFixPR({
|
|
175
|
+
repoOwner: session.targetRepo.repository.owner,
|
|
176
|
+
repoName: session.targetRepo.repository.repo,
|
|
177
|
+
octokit: session.octokit,
|
|
178
|
+
issueNumber: session.ctx.issueNumber,
|
|
179
|
+
issueTitle: session.issueTitle,
|
|
180
|
+
issueUrl: session.issueUrl,
|
|
181
|
+
triageAnalysis: session.triageOutput,
|
|
182
|
+
fixDescription: stripPRTitleSection(input.codexOutput),
|
|
183
|
+
branch,
|
|
184
|
+
...(input.generatedPRTitle ? { prTitle: input.generatedPRTitle } : {}),
|
|
185
|
+
...(input.generatedPRBody ? { prBody: input.generatedPRBody } : {}),
|
|
186
|
+
fixAssessment: session.fixAssessment,
|
|
187
|
+
});
|
|
188
|
+
await this.postFixStartedSourceIssueComment({
|
|
189
|
+
prUrl: prResult.prUrl,
|
|
190
|
+
prNumber: prResult.prNumber,
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
headSha,
|
|
194
|
+
branch,
|
|
195
|
+
prNumber: prResult.prNumber,
|
|
196
|
+
prUrl: prResult.prUrl,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
async postFixStartedSourceIssueComment(args) {
|
|
200
|
+
const session = this.session;
|
|
201
|
+
const body = buildFixLoopStartCommentBody({
|
|
202
|
+
prUrl: args.prUrl,
|
|
203
|
+
targetRepoFullName: session.targetRepo.repository.fullName,
|
|
204
|
+
});
|
|
205
|
+
try {
|
|
206
|
+
// Find-or-update by marker so a redelivery (after an opened PR but
|
|
207
|
+
// a failed response on this side) does not double-post.
|
|
208
|
+
const existing = await this.findSourceIssueCommentByMarker(FIX_LOOP_START_MARKER);
|
|
209
|
+
if (existing) {
|
|
210
|
+
await session.octokit.rest.issues.updateComment({
|
|
211
|
+
owner: session.ctx.owner,
|
|
212
|
+
repo: session.ctx.repo,
|
|
213
|
+
comment_id: existing.id,
|
|
214
|
+
body,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
await session.octokit.rest.issues.createComment({
|
|
219
|
+
owner: session.ctx.owner,
|
|
220
|
+
repo: session.ctx.repo,
|
|
221
|
+
issue_number: session.ctx.issueNumber,
|
|
222
|
+
body,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
logger.info('fix-loop: posted start comment on source issue', this.logContext({
|
|
226
|
+
pr_number: args.prNumber,
|
|
227
|
+
source_issue_number: session.ctx.issueNumber,
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
// Comment posting is best-effort — never fail the PR creation if
|
|
232
|
+
// it errors. The PR is already open and the loop must keep going.
|
|
233
|
+
logger.warn('fix-loop: failed to post start comment on source issue', this.logContext({
|
|
234
|
+
pr_number: args.prNumber,
|
|
235
|
+
source_issue_number: session.ctx.issueNumber,
|
|
236
|
+
error: error instanceof Error ? error.message : String(error),
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async findSourceIssueCommentByMarker(marker) {
|
|
241
|
+
const session = this.session;
|
|
242
|
+
const comments = await session.octokit.paginate(session.octokit.rest.issues.listComments, {
|
|
243
|
+
owner: session.ctx.owner,
|
|
244
|
+
repo: session.ctx.repo,
|
|
245
|
+
issue_number: session.ctx.issueNumber,
|
|
246
|
+
per_page: 100,
|
|
247
|
+
});
|
|
248
|
+
return comments.find((c) => typeof c.body === 'string' && c.body.includes(marker));
|
|
249
|
+
}
|
|
250
|
+
async pushIteration(input, branch) {
|
|
251
|
+
const session = this.session;
|
|
252
|
+
// Subsequent iterations must update the existing PR by pushing to the
|
|
253
|
+
// same branch. commitAndPushFix would generate a fresh branch and
|
|
254
|
+
// orphan the new commit from the open PR, so we use
|
|
255
|
+
// commitAndPushIteration.
|
|
256
|
+
const { headSha } = await this.deps.commitAndPushIteration({
|
|
257
|
+
repoOwner: session.targetRepo.repository.owner,
|
|
258
|
+
repoName: session.targetRepo.repository.repo,
|
|
259
|
+
repoLocalPath: session.targetRepo.localPath,
|
|
260
|
+
branch,
|
|
261
|
+
githubToken: session.pushToken,
|
|
262
|
+
issueNumber: session.ctx.issueNumber,
|
|
263
|
+
issueTitle: session.issueTitle,
|
|
264
|
+
...(input.generatedPRTitle ? { prTitle: input.generatedPRTitle } : {}),
|
|
265
|
+
});
|
|
266
|
+
return { headSha, branch };
|
|
267
|
+
}
|
|
268
|
+
async applyLabels(input) {
|
|
269
|
+
if (this.opts.dryRun) {
|
|
270
|
+
logger.info('DRY RUN: would apply labels', this.logContext({ pr_number: input.prNumber, labels: input.labels }));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
await withRetry(() => this.session.octokit.rest.issues.addLabels({
|
|
274
|
+
owner: this.session.targetRepo.repository.owner,
|
|
275
|
+
repo: this.session.targetRepo.repository.repo,
|
|
276
|
+
issue_number: input.prNumber,
|
|
277
|
+
labels: input.labels,
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
async postIssueComment(input) {
|
|
281
|
+
if (this.opts.dryRun) {
|
|
282
|
+
logger.info('DRY RUN: would post issue comment', this.logContext({
|
|
283
|
+
pr_number: input.prNumber,
|
|
284
|
+
body_chars: input.body.length,
|
|
285
|
+
marker: input.marker,
|
|
286
|
+
}));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const owner = this.session.targetRepo.repository.owner;
|
|
290
|
+
const repo = this.session.targetRepo.repository.repo;
|
|
291
|
+
const body = input.marker ? `${input.marker}\n\n${input.body}` : input.body;
|
|
292
|
+
if (input.marker !== undefined) {
|
|
293
|
+
const existing = await this.findCommentByMarker(input.prNumber, input.marker);
|
|
294
|
+
if (existing) {
|
|
295
|
+
await this.session.octokit.rest.issues.updateComment({
|
|
296
|
+
owner,
|
|
297
|
+
repo,
|
|
298
|
+
comment_id: existing.id,
|
|
299
|
+
body,
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
await this.session.octokit.rest.issues.createComment({
|
|
305
|
+
owner,
|
|
306
|
+
repo,
|
|
307
|
+
issue_number: input.prNumber,
|
|
308
|
+
body,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
async findCommentByMarker(prNumber, marker) {
|
|
312
|
+
const comments = await this.session.octokit.paginate(this.session.octokit.rest.issues.listComments, {
|
|
313
|
+
owner: this.session.targetRepo.repository.owner,
|
|
314
|
+
repo: this.session.targetRepo.repository.repo,
|
|
315
|
+
issue_number: prNumber,
|
|
316
|
+
per_page: 100,
|
|
317
|
+
});
|
|
318
|
+
return comments.find((c) => typeof c.body === 'string' && c.body.includes(marker));
|
|
319
|
+
}
|
|
320
|
+
async cloneRepo(_input) {
|
|
321
|
+
// Cloning is handled by the triage entrypoint before DefaultLoopEffects is
|
|
322
|
+
// constructed; the cloned repo is supplied via session.targetRepo.
|
|
323
|
+
return this.session.targetRepo;
|
|
324
|
+
}
|
|
325
|
+
async checkCIStatus(headSha) {
|
|
326
|
+
// Both endpoints collapse history to one entry per name/context. For
|
|
327
|
+
// checks: `filter=latest` keeps only the most recent run per check
|
|
328
|
+
// name (matters when a workflow has been re-run). For commit statuses:
|
|
329
|
+
// /commits/{ref}/status returns one combined object whose `statuses`
|
|
330
|
+
// array is already the latest entry per context — so we call it
|
|
331
|
+
// directly (not via paginate, which would yield combined-status
|
|
332
|
+
// objects, not the inner status entries).
|
|
333
|
+
const [checkRuns, combined] = await Promise.all([
|
|
334
|
+
this.session.octokit.paginate(this.session.octokit.rest.checks.listForRef, {
|
|
335
|
+
owner: this.session.targetRepo.repository.owner,
|
|
336
|
+
repo: this.session.targetRepo.repository.repo,
|
|
337
|
+
ref: headSha,
|
|
338
|
+
filter: 'latest',
|
|
339
|
+
per_page: 100,
|
|
340
|
+
}),
|
|
341
|
+
this.session.octokit.rest.repos.getCombinedStatusForRef({
|
|
342
|
+
owner: this.session.targetRepo.repository.owner,
|
|
343
|
+
repo: this.session.targetRepo.repository.repo,
|
|
344
|
+
ref: headSha,
|
|
345
|
+
}),
|
|
346
|
+
]);
|
|
347
|
+
const statuses = combined.data.statuses;
|
|
348
|
+
const failedChecks = [];
|
|
349
|
+
const pendingChecks = [];
|
|
350
|
+
let totalEntries = 0;
|
|
351
|
+
for (const cr of checkRuns) {
|
|
352
|
+
totalEntries++;
|
|
353
|
+
const info = {
|
|
354
|
+
name: cr.name,
|
|
355
|
+
conclusion: cr.conclusion ?? null,
|
|
356
|
+
url: cr.html_url ?? cr.details_url ?? '',
|
|
357
|
+
};
|
|
358
|
+
if (cr.status !== 'completed') {
|
|
359
|
+
pendingChecks.push(info);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (cr.conclusion && FAILURE_CONCLUSIONS.has(cr.conclusion)) {
|
|
363
|
+
failedChecks.push(info);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const status of statuses) {
|
|
367
|
+
totalEntries++;
|
|
368
|
+
const info = {
|
|
369
|
+
name: status.context,
|
|
370
|
+
conclusion: status.state ?? null,
|
|
371
|
+
url: status.target_url ?? '',
|
|
372
|
+
};
|
|
373
|
+
if (status.state === 'pending') {
|
|
374
|
+
pendingChecks.push(info);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (FAILURE_STATUS_STATES.has(status.state ?? '')) {
|
|
378
|
+
failedChecks.push(info);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (totalEntries === 0) {
|
|
382
|
+
return { kind: 'no_checks', headSha };
|
|
383
|
+
}
|
|
384
|
+
if (failedChecks.length > 0) {
|
|
385
|
+
return { kind: 'failed', headSha, failedChecks };
|
|
386
|
+
}
|
|
387
|
+
if (pendingChecks.length > 0) {
|
|
388
|
+
return { kind: 'pending', headSha, pendingChecks };
|
|
389
|
+
}
|
|
390
|
+
return { kind: 'passed', headSha };
|
|
391
|
+
}
|
|
392
|
+
async promoteToReady(prNumber) {
|
|
393
|
+
if (this.opts.dryRun) {
|
|
394
|
+
logger.info('DRY RUN: would promote PR to ready', this.logContext({
|
|
395
|
+
pr_number: prNumber,
|
|
396
|
+
}));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// TODO: thread node_id through from the webhook handler's
|
|
400
|
+
// fetchPRPromotionState so this second pulls.get goes away.
|
|
401
|
+
const pr = await withRetry(() => this.session.octokit.rest.pulls.get({
|
|
402
|
+
owner: this.session.targetRepo.repository.owner,
|
|
403
|
+
repo: this.session.targetRepo.repository.repo,
|
|
404
|
+
pull_number: prNumber,
|
|
405
|
+
}));
|
|
406
|
+
// Idempotency: markPullRequestReadyForReview throws
|
|
407
|
+
// `UNPROCESSABLE: Pull request is not a draft` if the PR is already
|
|
408
|
+
// ready. On webhook redelivery the catch block in runPromotionFlow
|
|
409
|
+
// would re-arm awaiting-ci and loop on every subsequent CI event.
|
|
410
|
+
if (!pr.data.draft) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
await withRetry(() => this.session.octokit.graphql(`mutation($pullRequestId: ID!) {
|
|
414
|
+
markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {
|
|
415
|
+
pullRequest { isDraft }
|
|
416
|
+
}
|
|
417
|
+
}`, { pullRequestId: pr.data.node_id }));
|
|
418
|
+
}
|
|
419
|
+
async applyAskLabel(prNumber) {
|
|
420
|
+
if (this.opts.dryRun) {
|
|
421
|
+
logger.info('DRY RUN: would apply ask label', this.logContext({ pr_number: prNumber }));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const labels = await this.session.octokit.paginate(this.session.octokit.rest.issues.listLabelsForRepo, {
|
|
425
|
+
owner: this.session.targetRepo.repository.owner,
|
|
426
|
+
repo: this.session.targetRepo.repository.repo,
|
|
427
|
+
per_page: 100,
|
|
428
|
+
});
|
|
429
|
+
// `\bask\b` matches `ask`, `Ask`, `ask: review`, `Review:Ask`, etc.
|
|
430
|
+
// without false-positives on labels that merely contain the substring
|
|
431
|
+
// (e.g. `Type:Task`). The repo-label search is the contract because
|
|
432
|
+
// the actual label name is repo-specific.
|
|
433
|
+
const candidates = labels.filter((label) => /\bask\b/i.test(label.name));
|
|
434
|
+
if (candidates.length === 0) {
|
|
435
|
+
logger.warn('No ask-style label found on target repo; promoted PR will not be marked for pre-merge review', this.logContext({ pr_number: prNumber }));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const chosen = candidates[0].name;
|
|
439
|
+
if (candidates.length > 1) {
|
|
440
|
+
logger.info('Multiple ask-style label candidates found; applying the first match', this.logContext({
|
|
441
|
+
pr_number: prNumber,
|
|
442
|
+
candidates: candidates.map((c) => c.name),
|
|
443
|
+
chosen,
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
await this.applyLabels({ prNumber, labels: [chosen] });
|
|
447
|
+
}
|
|
448
|
+
async requestHeroReview(prNumber) {
|
|
449
|
+
const team = resolveHeroTeamSlug(this.session.targetRepo.repository);
|
|
450
|
+
if (this.opts.dryRun) {
|
|
451
|
+
logger.info('DRY RUN: would request hero review', this.logContext({
|
|
452
|
+
pr_number: prNumber,
|
|
453
|
+
team,
|
|
454
|
+
}));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
await withRetry(() => this.session.octokit.rest.pulls.requestReviewers({
|
|
458
|
+
owner: this.session.targetRepo.repository.owner,
|
|
459
|
+
repo: this.session.targetRepo.repository.repo,
|
|
460
|
+
pull_number: prNumber,
|
|
461
|
+
team_reviewers: [team],
|
|
462
|
+
}));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fake for LoopEffects. Pre-program responses by passing arrays;
|
|
3
|
+
* each call shifts one response off the queue (throws if empty). Inspect
|
|
4
|
+
* `calls` to assert what the runner actually invoked and with what input.
|
|
5
|
+
*/
|
|
6
|
+
export class FakeLoopEffects {
|
|
7
|
+
calls = [];
|
|
8
|
+
fixOutputs;
|
|
9
|
+
reviewResults;
|
|
10
|
+
pushResults;
|
|
11
|
+
ciStatuses;
|
|
12
|
+
cloneOut;
|
|
13
|
+
constructor(config = {}) {
|
|
14
|
+
this.fixOutputs = [...(config.fixOutputs ?? [])];
|
|
15
|
+
this.reviewResults = [...(config.reviewResults ?? [])];
|
|
16
|
+
this.pushResults = [...(config.pushResults ?? [])];
|
|
17
|
+
this.ciStatuses = [...(config.ciStatuses ?? [])];
|
|
18
|
+
this.cloneOut = config.cloneRepo;
|
|
19
|
+
}
|
|
20
|
+
take(queue, method) {
|
|
21
|
+
const value = queue.shift();
|
|
22
|
+
if (value === undefined) {
|
|
23
|
+
throw new Error(`FakeLoopEffects: no programmed response for ${method}`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
async runFix(input) {
|
|
28
|
+
this.calls.push({ method: 'runFix', input });
|
|
29
|
+
return this.take(this.fixOutputs, 'runFix');
|
|
30
|
+
}
|
|
31
|
+
async runReview(input) {
|
|
32
|
+
this.calls.push({ method: 'runReview', input });
|
|
33
|
+
return this.take(this.reviewResults, 'runReview');
|
|
34
|
+
}
|
|
35
|
+
async pushCommit(input) {
|
|
36
|
+
this.calls.push({ method: 'pushCommit', input });
|
|
37
|
+
return this.take(this.pushResults, 'pushCommit');
|
|
38
|
+
}
|
|
39
|
+
async applyLabels(input) {
|
|
40
|
+
this.calls.push({ method: 'applyLabels', input });
|
|
41
|
+
}
|
|
42
|
+
async postIssueComment(input) {
|
|
43
|
+
this.calls.push({ method: 'postIssueComment', input });
|
|
44
|
+
}
|
|
45
|
+
async cloneRepo(input) {
|
|
46
|
+
this.calls.push({ method: 'cloneRepo', input });
|
|
47
|
+
if (!this.cloneOut) {
|
|
48
|
+
throw new Error('FakeLoopEffects: no programmed response for cloneRepo');
|
|
49
|
+
}
|
|
50
|
+
return this.cloneOut;
|
|
51
|
+
}
|
|
52
|
+
async checkCIStatus(headSha) {
|
|
53
|
+
this.calls.push({ method: 'checkCIStatus', headSha });
|
|
54
|
+
return this.take(this.ciStatuses, 'checkCIStatus');
|
|
55
|
+
}
|
|
56
|
+
async promoteToReady(prNumber) {
|
|
57
|
+
this.calls.push({ method: 'promoteToReady', prNumber });
|
|
58
|
+
}
|
|
59
|
+
async requestHeroReview(prNumber) {
|
|
60
|
+
this.calls.push({ method: 'requestHeroReview', prNumber });
|
|
61
|
+
}
|
|
62
|
+
async applyAskLabel(prNumber) {
|
|
63
|
+
this.calls.push({ method: 'applyAskLabel', prNumber });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
2
|
+
import { logger } from '../../../core/logging.js';
|
|
3
|
+
import { createGitHubClients, parseBaseContext, requiredEnv } from '../../../core/shared.js';
|
|
4
|
+
import { findRepositoryTarget } from '../hero-group-map.js';
|
|
5
|
+
import { DefaultLoopEffects } from './effects.js';
|
|
6
|
+
import { runPromotionFlow } from './runners.js';
|
|
7
|
+
/**
|
|
8
|
+
* Parses the env vars dispatched by the webhook handler's
|
|
9
|
+
* promotion-ci-gate route (apps/webhook-handler/src/tasks/promotion-ci-gate/fargate.ts).
|
|
10
|
+
* Throws a descriptive error for any missing or malformed variable so the
|
|
11
|
+
* sandbox task fails fast instead of degrading mid-run.
|
|
12
|
+
*/
|
|
13
|
+
export function parsePromotionTaskInput() {
|
|
14
|
+
return {
|
|
15
|
+
base: parseBaseContext(),
|
|
16
|
+
headSha: requiredEnv('HEAD_SHA'),
|
|
17
|
+
dryRun: process.env.DRY_RUN?.toLowerCase() === 'true',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Builds the PromotionSession. Promotion is API-only (no codex / push /
|
|
22
|
+
* review), so the session carries only the fields the promotion-flow
|
|
23
|
+
* effects actually read, and cloning the repo is skipped — none of the
|
|
24
|
+
* effects reads `localPath`.
|
|
25
|
+
*/
|
|
26
|
+
export function buildPromotionSession({ input, octokit, }) {
|
|
27
|
+
const { owner, repo } = input.base;
|
|
28
|
+
// Single source of truth: look up the canonical RepositoryTarget here
|
|
29
|
+
// (with real surfaces) so downstream effects like requestHeroReview can
|
|
30
|
+
// route by surface without re-scanning PRODUCT_REPOSITORIES.
|
|
31
|
+
const repository = findRepositoryTarget(owner, repo) ?? {
|
|
32
|
+
owner,
|
|
33
|
+
repo,
|
|
34
|
+
fullName: `${owner}/${repo}`,
|
|
35
|
+
// Fallback for repos not in the registry. surfaces=[] makes
|
|
36
|
+
// resolveHeroGroup fall through to cx-hero — the desired behavior
|
|
37
|
+
// for unknown targets.
|
|
38
|
+
product: 'Todoist',
|
|
39
|
+
surfaces: [],
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
kind: 'fix-loop-promotion',
|
|
43
|
+
ctx: {
|
|
44
|
+
owner,
|
|
45
|
+
repo,
|
|
46
|
+
installationId: input.base.installationId,
|
|
47
|
+
deliveryId: input.base.deliveryId ?? 'unknown',
|
|
48
|
+
dryRun: input.dryRun,
|
|
49
|
+
},
|
|
50
|
+
octokit,
|
|
51
|
+
targetRepo: {
|
|
52
|
+
repository,
|
|
53
|
+
localPath: '',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export async function runPromotionTask(input) {
|
|
58
|
+
const startedAt = performance.now();
|
|
59
|
+
const { base, headSha, dryRun } = input;
|
|
60
|
+
const repository = `${base.owner}/${base.repo}`;
|
|
61
|
+
logger.setBaseContext({
|
|
62
|
+
delivery_id: base.deliveryId ?? 'unknown',
|
|
63
|
+
repository,
|
|
64
|
+
});
|
|
65
|
+
logger.info('Fix-loop promotion task started', {
|
|
66
|
+
task: 'fix-loop-promotion',
|
|
67
|
+
repository,
|
|
68
|
+
pr_number: base.prNumber,
|
|
69
|
+
head_sha: headSha,
|
|
70
|
+
dry_run: dryRun,
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
const { writeOctokit } = await createGitHubClients(base);
|
|
74
|
+
const session = buildPromotionSession({ input, octokit: writeOctokit });
|
|
75
|
+
const effects = new DefaultLoopEffects(session, { dryRun });
|
|
76
|
+
const finalState = await runPromotionFlow(effects, {
|
|
77
|
+
prNumber: base.prNumber,
|
|
78
|
+
headSha,
|
|
79
|
+
});
|
|
80
|
+
logger.info('Fix-loop promotion task completed', {
|
|
81
|
+
task: 'fix-loop-promotion',
|
|
82
|
+
repository,
|
|
83
|
+
pr_number: base.prNumber,
|
|
84
|
+
terminal_state: finalState.kind,
|
|
85
|
+
duration_ms: performance.now() - startedAt,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
logger.error('Fix-loop promotion task failed', {
|
|
90
|
+
task: 'fix-loop-promotion',
|
|
91
|
+
repository,
|
|
92
|
+
pr_number: base.prNumber,
|
|
93
|
+
head_sha: headSha,
|
|
94
|
+
error: error instanceof Error ? error.message : String(error),
|
|
95
|
+
duration_ms: performance.now() - startedAt,
|
|
96
|
+
});
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export async function main() {
|
|
101
|
+
try {
|
|
102
|
+
await runPromotionTask(parsePromotionTaskInput());
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (process.env.VITEST !== 'true' && process.env.TASK_TYPE === 'fix-loop-promotion') {
|
|
109
|
+
main();
|
|
110
|
+
}
|