@doist/doistbot-cli 1.0.2 → 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.
Files changed (52) hide show
  1. package/dist/actions/review.js +124 -9
  2. package/dist/actions/setup.js +1 -1
  3. package/dist/index.js +18 -8
  4. package/dist/runtime.js +3 -3
  5. package/dist/update-check.js +1 -1
  6. package/package.json +2 -2
  7. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -0
  8. package/sandbox/dist/core/bot-logins.js +13 -0
  9. package/sandbox/dist/core/retry.js +29 -0
  10. package/sandbox/dist/core/shared.js +2 -2
  11. package/sandbox/dist/main.js +3 -0
  12. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +140 -13
  13. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +190 -0
  14. package/sandbox/dist/tasks/issue-triage/fix-failure-reasons.js +2 -0
  15. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.js +464 -0
  16. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.types.js +1 -0
  17. package/sandbox/dist/tasks/issue-triage/fix-loop/fake-effects.js +65 -0
  18. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +110 -0
  19. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +227 -0
  20. package/sandbox/dist/tasks/issue-triage/fix-loop/state-machine.js +165 -0
  21. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +32 -0
  22. package/sandbox/dist/tasks/issue-triage/markers.js +2 -0
  23. package/sandbox/dist/tasks/issue-triage/output.js +24 -3
  24. package/sandbox/dist/tasks/issue-triage/pr-creator.js +72 -30
  25. package/sandbox/dist/tasks/issue-triage/repo-config.js +13 -0
  26. package/sandbox/dist/tasks/issue-triage/triage.js +30 -3
  27. package/sandbox/dist/tasks/issue-triage/types.js +1 -0
  28. package/sandbox/dist/tasks/review/author-format.js +14 -0
  29. package/sandbox/dist/tasks/review/author-profile.js +67 -0
  30. package/sandbox/dist/tasks/review/check-run.js +8 -1
  31. package/sandbox/dist/tasks/review/comment-mapper.js +17 -12
  32. package/sandbox/dist/tasks/review/conversation-context.js +174 -20
  33. package/sandbox/dist/tasks/review/council/summary.js +141 -12
  34. package/sandbox/dist/tasks/review/engines/council.js +19 -3
  35. package/sandbox/dist/tasks/review/engines/dedupe.js +37 -28
  36. package/sandbox/dist/tasks/review/engines/multi-focus.js +123 -6
  37. package/sandbox/dist/tasks/review/engines/shared.js +28 -5
  38. package/sandbox/dist/tasks/review/finding-output.js +18 -0
  39. package/sandbox/dist/tasks/review/github-comment-format.js +32 -0
  40. package/sandbox/dist/tasks/review/local.js +1 -0
  41. package/sandbox/dist/tasks/review/multi-focus-prompt.js +0 -1
  42. package/sandbox/dist/tasks/review/review.js +12 -2
  43. package/sandbox/dist/tasks/review/runner.js +36 -2
  44. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -0
  45. package/sandbox/review-prompt.md +18 -1
  46. package/sandbox/src/tasks/review/prompts/review-focus-prompts/efficiency.md +10 -6
  47. package/sandbox/src/tasks/review/prompts/review-focus-prompts/general.md +2 -0
  48. package/sandbox/src/tasks/review/prompts/review-focus-prompts/quality.md +15 -1
  49. package/sandbox/src/tasks/review/prompts/review-focus-prompts/standards.md +2 -0
  50. package/sandbox/src/tasks/review/prompts/review-focus-prompts/tests.md +14 -1
  51. package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +31 -4
  52. package/sandbox/src/tasks/review/prompts/review-focus-prompts/reuse.md +0 -13
@@ -0,0 +1,190 @@
1
+ import { logger } from '../../core/logging.js';
2
+ import { DefaultLoopEffects } from './fix-loop/effects.js';
3
+ import { runFixLoop } from './fix-loop/runners.js';
4
+ import { attemptFix, buildFixAssessmentSkipReason, shouldAttemptFix } from './fix-attempt.js';
5
+ import { FIX_LOOP_FAILED_REASON_PREFIX } from './fix-failure-reasons.js';
6
+ import { getAutoFixMode } from './repo-config.js';
7
+ function fallbackRepository(input) {
8
+ return (input.fixAssessment.targetRepo ??
9
+ (input.clonedRepos.length === 1
10
+ ? input.clonedRepos[0].repository.fullName
11
+ : `${input.ctx.owner}/${input.ctx.repo}`));
12
+ }
13
+ function resolveFixTarget(input) {
14
+ // TODO: Refactor attemptFix to accept a resolved target repo so dispatch and
15
+ // the legacy engine do not duplicate target-resolution logic.
16
+ if (input.clonedRepos.length === 0) {
17
+ return {
18
+ ok: false,
19
+ repository: fallbackRepository(input),
20
+ reason: 'No cloned repositories available for fix attempt.',
21
+ };
22
+ }
23
+ if (input.fixAssessment.targetRepo) {
24
+ const targetRepo = input.clonedRepos.find((repo) => repo.repository.fullName === input.fixAssessment.targetRepo);
25
+ if (targetRepo) {
26
+ return { ok: true, targetRepo };
27
+ }
28
+ return {
29
+ ok: false,
30
+ repository: fallbackRepository(input),
31
+ reason: `Unable to resolve a unique target repository from assessment: ${input.fixAssessment.targetRepo}`,
32
+ };
33
+ }
34
+ if (input.clonedRepos.length === 1) {
35
+ return { ok: true, targetRepo: input.clonedRepos[0] };
36
+ }
37
+ return {
38
+ ok: false,
39
+ repository: fallbackRepository(input),
40
+ reason: 'Unable to resolve a unique target repository from assessment: missing targetRepo',
41
+ };
42
+ }
43
+ function toAttemptFixInput(input) {
44
+ return {
45
+ ctx: input.ctx,
46
+ octokit: input.octokit,
47
+ triageOutput: input.triageOutput,
48
+ fixAssessment: input.fixAssessment,
49
+ clonedRepos: input.clonedRepos,
50
+ issueTitle: input.issueTitle,
51
+ issueUrl: input.issueUrl,
52
+ ghToken: input.ghToken,
53
+ };
54
+ }
55
+ function stateLogMeta(state) {
56
+ return {
57
+ kind: state.kind,
58
+ ...(state.kind !== 'AWAITING_FIRST_FIX' && 'iteration' in state
59
+ ? { iteration: state.iteration }
60
+ : {}),
61
+ ...('branch' in state && state.branch !== undefined ? { branch: state.branch } : {}),
62
+ ...('prNumber' in state && state.prNumber !== undefined
63
+ ? { pr_number: state.prNumber }
64
+ : {}),
65
+ };
66
+ }
67
+ function logDryRunTransition({ from, event, to }) {
68
+ logger.info('DRY RUN: fix loop state transition', {
69
+ task: 'fix-loop',
70
+ from: stateLogMeta(from),
71
+ event: event.kind,
72
+ to: stateLogMeta(to),
73
+ });
74
+ }
75
+ function errorReason(error) {
76
+ return error instanceof Error ? error.message : String(error);
77
+ }
78
+ function prUrlFor(targetRepo, prNumber) {
79
+ return `https://github.com/${targetRepo.repository.fullName}/pull/${prNumber}`;
80
+ }
81
+ export async function runConfiguredFix(input) {
82
+ const target = resolveFixTarget(input);
83
+ if (!target.ok) {
84
+ return { attempted: false, repository: target.repository, reason: target.reason };
85
+ }
86
+ const targetRepo = target.targetRepo;
87
+ let repoConfig;
88
+ try {
89
+ repoConfig = await input.loadRepoConfig(targetRepo.repository);
90
+ }
91
+ catch (error) {
92
+ logger.warn('Fix loop config load failed', {
93
+ task: 'fix-loop',
94
+ repository: targetRepo.repository.fullName,
95
+ error: errorReason(error),
96
+ });
97
+ return {
98
+ attempted: true,
99
+ repository: targetRepo.repository.fullName,
100
+ prCreated: false,
101
+ reason: `Fix loop config load failed: ${errorReason(error)}`,
102
+ };
103
+ }
104
+ const autoFixMode = getAutoFixMode(repoConfig, input.ctx.enableAutoFixOverride);
105
+ if (autoFixMode === 'disabled') {
106
+ return {
107
+ attempted: false,
108
+ repository: targetRepo.repository.fullName,
109
+ reason: 'Auto fix disabled by repository config.',
110
+ };
111
+ }
112
+ if (autoFixMode === 'one-pass') {
113
+ return attemptFix(toAttemptFixInput(input));
114
+ }
115
+ if (!shouldAttemptFix(input.fixAssessment)) {
116
+ return {
117
+ attempted: false,
118
+ repository: targetRepo.repository.fullName,
119
+ reason: buildFixAssessmentSkipReason(input.fixAssessment),
120
+ };
121
+ }
122
+ const session = {
123
+ kind: 'fix-loop',
124
+ ctx: input.ctx,
125
+ octokit: input.octokit,
126
+ triageOutput: input.triageOutput,
127
+ fixAssessment: input.fixAssessment,
128
+ targetRepo,
129
+ issueTitle: input.issueTitle,
130
+ issueUrl: input.issueUrl,
131
+ ghToken: input.ghToken,
132
+ pushToken: input.ctx.githubToken,
133
+ apiKeys: {
134
+ openai: input.ctx.openaiApiKey,
135
+ ...(input.ctx.geminiApiKey !== undefined && { gemini: input.ctx.geminiApiKey }),
136
+ ...(input.ctx.anthropicApiKey !== undefined && {
137
+ anthropic: input.ctx.anthropicApiKey,
138
+ }),
139
+ },
140
+ };
141
+ let finalState;
142
+ try {
143
+ finalState = await runFixLoop({
144
+ effects: new DefaultLoopEffects(session, { dryRun: input.ctx.dryRun }),
145
+ ...(input.ctx.dryRun ? { onTransition: logDryRunTransition } : {}),
146
+ });
147
+ }
148
+ catch (error) {
149
+ return {
150
+ attempted: true,
151
+ repository: targetRepo.repository.fullName,
152
+ prCreated: false,
153
+ reason: `${FIX_LOOP_FAILED_REASON_PREFIX} ${errorReason(error)}`,
154
+ };
155
+ }
156
+ if (finalState.kind === 'PUSHED_AWAITING_CI') {
157
+ if (input.ctx.dryRun) {
158
+ return {
159
+ attempted: true,
160
+ repository: targetRepo.repository.fullName,
161
+ prCreated: false,
162
+ reason: 'Dry run: PR not created.',
163
+ };
164
+ }
165
+ const prNumber = finalState.prNumber;
166
+ const prUrl = prUrlFor(targetRepo, prNumber);
167
+ return {
168
+ attempted: true,
169
+ repository: targetRepo.repository.fullName,
170
+ prCreated: true,
171
+ prUrl,
172
+ prNumber,
173
+ };
174
+ }
175
+ const reason = finalState.kind === 'DONE_NEEDS_HUMAN'
176
+ ? finalState.reason
177
+ : `Fix loop ended in ${finalState.kind}`;
178
+ return {
179
+ attempted: true,
180
+ repository: targetRepo.repository.fullName,
181
+ prCreated: false,
182
+ reason,
183
+ // If a PR was opened earlier in the loop (REVIEWING/ITERATING reached
184
+ // before DONE_NEEDS_HUMAN), surface it so callers can distinguish a
185
+ // silent failure from an in-progress PR.
186
+ ...('prNumber' in finalState && finalState.prNumber !== undefined
187
+ ? { prNumber: finalState.prNumber }
188
+ : {}),
189
+ };
190
+ }
@@ -0,0 +1,2 @@
1
+ export const CODEX_CANNOT_FIX_REASON = 'Codex determined fix cannot be applied automatically.';
2
+ export const FIX_LOOP_FAILED_REASON_PREFIX = 'Fix loop failed:';
@@ -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
+ }