@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.
Files changed (54) hide show
  1. package/dist/actions/review-usage.js +20 -0
  2. package/dist/actions/review.js +126 -10
  3. package/dist/actions/setup.js +3 -2
  4. package/dist/index.js +18 -8
  5. package/dist/runtime.js +3 -3
  6. package/dist/update-check.js +1 -1
  7. package/package.json +6 -5
  8. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -0
  9. package/sandbox/dist/core/bot-logins.js +13 -0
  10. package/sandbox/dist/core/retry.js +29 -0
  11. package/sandbox/dist/core/shared.js +2 -2
  12. package/sandbox/dist/main.js +3 -0
  13. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +1 -7
  14. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +157 -28
  15. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +190 -0
  16. package/sandbox/dist/tasks/issue-triage/fix-failure-reasons.js +2 -0
  17. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.js +464 -0
  18. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.types.js +1 -0
  19. package/sandbox/dist/tasks/issue-triage/fix-loop/fake-effects.js +65 -0
  20. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +110 -0
  21. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +227 -0
  22. package/sandbox/dist/tasks/issue-triage/fix-loop/state-machine.js +165 -0
  23. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +32 -0
  24. package/sandbox/dist/tasks/issue-triage/markers.js +2 -0
  25. package/sandbox/dist/tasks/issue-triage/output.js +24 -3
  26. package/sandbox/dist/tasks/issue-triage/pr-creator.js +72 -30
  27. package/sandbox/dist/tasks/issue-triage/repo-config.js +13 -0
  28. package/sandbox/dist/tasks/issue-triage/triage.js +31 -10
  29. package/sandbox/dist/tasks/issue-triage/types.js +1 -0
  30. package/sandbox/dist/tasks/review/author-format.js +14 -0
  31. package/sandbox/dist/tasks/review/author-profile.js +67 -0
  32. package/sandbox/dist/tasks/review/check-run.js +8 -1
  33. package/sandbox/dist/tasks/review/comment-mapper.js +17 -12
  34. package/sandbox/dist/tasks/review/conversation-context.js +174 -20
  35. package/sandbox/dist/tasks/review/council/summary.js +141 -12
  36. package/sandbox/dist/tasks/review/engines/council.js +19 -3
  37. package/sandbox/dist/tasks/review/engines/dedupe.js +37 -28
  38. package/sandbox/dist/tasks/review/engines/multi-focus.js +123 -6
  39. package/sandbox/dist/tasks/review/engines/shared.js +28 -5
  40. package/sandbox/dist/tasks/review/finding-output.js +18 -0
  41. package/sandbox/dist/tasks/review/github-comment-format.js +32 -0
  42. package/sandbox/dist/tasks/review/local.js +1 -0
  43. package/sandbox/dist/tasks/review/multi-focus-prompt.js +0 -1
  44. package/sandbox/dist/tasks/review/review.js +12 -2
  45. package/sandbox/dist/tasks/review/runner.js +36 -2
  46. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -0
  47. package/sandbox/review-prompt.md +18 -1
  48. package/sandbox/src/tasks/review/prompts/review-focus-prompts/efficiency.md +10 -6
  49. package/sandbox/src/tasks/review/prompts/review-focus-prompts/general.md +2 -0
  50. package/sandbox/src/tasks/review/prompts/review-focus-prompts/quality.md +15 -1
  51. package/sandbox/src/tasks/review/prompts/review-focus-prompts/standards.md +2 -0
  52. package/sandbox/src/tasks/review/prompts/review-focus-prompts/tests.md +14 -1
  53. package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +31 -4
  54. package/sandbox/src/tasks/review/prompts/review-focus-prompts/reuse.md +0 -13
@@ -0,0 +1,227 @@
1
+ import { logger } from '../../../core/logging.js';
2
+ import { isFixLoopTerminal, isTerminal, next, } from './state-machine.js';
3
+ const CI_FAILED_LABEL = 'doistbot:ci-failed';
4
+ const NEEDS_HUMAN_LABEL = 'doistbot:needs-human';
5
+ const AWAITING_CI_LABEL = 'doistbot:awaiting-ci';
6
+ // Stamped on terminal comments so postIssueComment can find-or-update an
7
+ // existing comment instead of double-posting when an earlier dispatch
8
+ // succeeded on GitHub but the response timed out.
9
+ const PROMOTION_COMMENT_MARKER = '<!--doistbot-promotion-comment-->';
10
+ function iterationLabel(iteration) {
11
+ return `doistbot:iter-${iteration}`;
12
+ }
13
+ function formatFailedChecks(checks) {
14
+ return checks.map((c) => (c.url ? `- [${c.name}](${c.url})` : `- ${c.name}`)).join('\n');
15
+ }
16
+ const DEFAULT_INITIAL_STATE = {
17
+ kind: 'AWAITING_FIRST_FIX',
18
+ intent: 'issue_fix',
19
+ promotionPolicy: { kind: 'doistbot_fix_ci' },
20
+ maxIterations: 2,
21
+ };
22
+ function createEventApplier(onTransition) {
23
+ return (state, event) => {
24
+ const nextState = next(state, event);
25
+ onTransition?.({ from: state, event, to: nextState });
26
+ return nextState;
27
+ };
28
+ }
29
+ /**
30
+ * Drives the fix loop state machine to a terminal-for-fix-loop state
31
+ * (PUSHED_AWAITING_CI, DONE_PR_UPDATED, or DONE_NEEDS_HUMAN). For the
32
+ * doistbot_fix_ci policy, the promotion flow takes over from
33
+ * PUSHED_AWAITING_CI on the check_suite webhook; for the `none` policy
34
+ * (address_review), the workflow terminates in-process at DONE_PR_UPDATED.
35
+ */
36
+ export async function runFixLoop(input) {
37
+ const { effects, initial, onTransition } = input;
38
+ const applyEvent = createEventApplier(onTransition);
39
+ let state = initial ?? DEFAULT_INITIAL_STATE;
40
+ while (!isFixLoopTerminal(state)) {
41
+ if (state.kind === 'AWAITING_FIRST_FIX' || state.kind === 'ITERATING') {
42
+ const fixInput = state.kind === 'ITERATING'
43
+ ? {
44
+ iteration: state.iteration,
45
+ prevHeadSha: state.prevHeadSha,
46
+ previousFindings: state.findings,
47
+ }
48
+ : { iteration: 0 };
49
+ const fix = await effects.runFix(fixInput);
50
+ // runFixCore validates the diff internally and returns ok:false
51
+ // (with a "Diff validation failed: ..." reason) when invalid; no
52
+ // need for the runner to re-validate.
53
+ if (!fix.ok) {
54
+ state = applyEvent(state, { kind: 'FIX_FAILED', reason: fix.reason });
55
+ continue;
56
+ }
57
+ const pushInput = {
58
+ iteration: fixInput.iteration,
59
+ diff: fix.diff,
60
+ codexOutput: fix.codexOutput,
61
+ generatedPRTitle: fix.generatedPRTitle,
62
+ generatedPRBody: fix.generatedPRBody,
63
+ };
64
+ if (state.kind === 'ITERATING') {
65
+ pushInput.branch = state.branch;
66
+ }
67
+ const push = await effects.pushCommit(pushInput);
68
+ const prNumber = state.kind === 'ITERATING' ? state.prNumber : push.prNumber;
69
+ if (prNumber === undefined) {
70
+ state = applyEvent(state, {
71
+ kind: 'FIX_FAILED',
72
+ reason: 'Fix push did not produce a PR number.',
73
+ });
74
+ continue;
75
+ }
76
+ state = applyEvent(state, {
77
+ kind: 'FIX_PUSHED',
78
+ headSha: push.headSha,
79
+ branch: push.branch,
80
+ prNumber,
81
+ });
82
+ continue;
83
+ }
84
+ if (state.kind === 'REVIEWING') {
85
+ const reviewInput = {
86
+ iteration: state.iteration,
87
+ };
88
+ if (state.reviewBaseSha !== undefined) {
89
+ reviewInput.reviewBaseSha = state.reviewBaseSha;
90
+ }
91
+ const result = await effects.runReview(reviewInput);
92
+ if (result.kind === 'unverified') {
93
+ state = applyEvent(state, { kind: 'REVIEW_UNVERIFIED', reason: result.reason });
94
+ }
95
+ else {
96
+ state = applyEvent(state, { kind: 'REVIEW_COMPLETE', findings: result.findings });
97
+ }
98
+ continue;
99
+ }
100
+ // Defensive: any other intermediate state is a bug.
101
+ throw new Error(`runFixLoop: unexpected state ${state.kind}`);
102
+ }
103
+ if (state.kind === 'PUSHED_AWAITING_CI' &&
104
+ state.iteration !== undefined &&
105
+ state.promotionPolicy.kind === 'doistbot_fix_ci') {
106
+ // Local review accepted the patch; hand off to CI. awaiting-ci is the
107
+ // contract the webhook handler keys on, so it must NOT appear until
108
+ // we reach this terminal — otherwise a fast CI suite can promote a
109
+ // patch the bot is still reviewing (or about to iterate on).
110
+ await tryApplyLabels(effects, state.prNumber, [
111
+ AWAITING_CI_LABEL,
112
+ iterationLabel(state.iteration),
113
+ ]);
114
+ }
115
+ return state;
116
+ }
117
+ async function tryApplyLabels(effects, prNumber, labels) {
118
+ try {
119
+ await effects.applyLabels({ prNumber, labels });
120
+ }
121
+ catch (error) {
122
+ logger.error('fix-loop: failed to apply tracking labels to draft PR', {
123
+ task: 'fix-loop',
124
+ pr_number: prNumber,
125
+ labels,
126
+ error: error instanceof Error ? error.message : String(error),
127
+ });
128
+ }
129
+ }
130
+ /**
131
+ * Drives the promotion flow from PUSHED_AWAITING_CI through CI verification.
132
+ * Terminates at DONE_PROMOTED or DONE_NEEDS_HUMAN, or returns the unchanged
133
+ * PUSHED_AWAITING_CI state when CI is still pending (the caller re-invokes
134
+ * on the next webhook delivery).
135
+ */
136
+ export async function runPromotionFlow(effects, input) {
137
+ // Only the doistbot auto-fix workflow ever reaches CI promotion; the
138
+ // address_review path terminates at DONE_PR_UPDATED in-process and never
139
+ // invokes this entry point. The upstream webhook handler is responsible
140
+ // for filtering (e.g. by PR label) so this function is only called for
141
+ // issue_fix PRs.
142
+ const initial = {
143
+ kind: 'PUSHED_AWAITING_CI',
144
+ headSha: input.headSha,
145
+ prNumber: input.prNumber,
146
+ intent: 'issue_fix',
147
+ promotionPolicy: { kind: 'doistbot_fix_ci' },
148
+ maxIterations: 2,
149
+ };
150
+ // The webhook handler's atomic claim removed AWAITING_CI_LABEL before
151
+ // dispatching this task. Every exit path from here must either re-arm
152
+ // the label (pending CI, side-effect failure) or leave it off
153
+ // intentionally (terminal success / human escalation). Each side effect
154
+ // below is idempotent on GitHub's side, so a future webhook redelivery
155
+ // can safely re-enter promotion.
156
+ try {
157
+ const ciStatus = await effects.checkCIStatus(input.headSha);
158
+ const state = next(initial, { kind: 'CI_RESULT', status: ciStatus });
159
+ if (state.kind === 'PUSHED_AWAITING_CI') {
160
+ // CI still in flight. Re-arm so the next check_suite event can
161
+ // re-claim and re-dispatch.
162
+ await effects.applyLabels({
163
+ prNumber: input.prNumber,
164
+ labels: [AWAITING_CI_LABEL],
165
+ });
166
+ return state;
167
+ }
168
+ if (state.kind === 'DONE_PROMOTED') {
169
+ // Order matters: promoteToReady flips the PR out of draft and is
170
+ // effectively irreversible. Run it LAST so any failure on the
171
+ // reviewer/ask/comment steps re-arms awaiting-ci while the PR
172
+ // is still a draft — the next delivery re-runs the full sequence
173
+ // cleanly instead of seeing a half-promoted PR.
174
+ await effects.requestHeroReview(input.prNumber);
175
+ await effects.applyAskLabel(input.prNumber);
176
+ await effects.postIssueComment({
177
+ prNumber: input.prNumber,
178
+ marker: PROMOTION_COMMENT_MARKER,
179
+ body: 'CI passed — promoting this PR out of draft and requesting hero review.',
180
+ });
181
+ await effects.promoteToReady(input.prNumber);
182
+ return state;
183
+ }
184
+ if (state.kind === 'DONE_NEEDS_HUMAN') {
185
+ const labels = ciStatus.kind === 'failed'
186
+ ? [CI_FAILED_LABEL, NEEDS_HUMAN_LABEL]
187
+ : [NEEDS_HUMAN_LABEL];
188
+ await effects.applyLabels({ prNumber: input.prNumber, labels });
189
+ const body = ciStatus.kind === 'failed'
190
+ ? `CI failed for this fix attempt. Failing checks:\n${formatFailedChecks(ciStatus.failedChecks)}\n\nMarking this for human review.`
191
+ : ciStatus.kind === 'no_checks'
192
+ ? 'No CI checks ran on this PR — please verify manually before merging.'
193
+ : 'This PR needs human review.';
194
+ await effects.postIssueComment({
195
+ prNumber: input.prNumber,
196
+ marker: PROMOTION_COMMENT_MARKER,
197
+ body,
198
+ });
199
+ return state;
200
+ }
201
+ if (!isTerminal(state)) {
202
+ throw new Error(`runPromotionFlow: unexpected non-terminal state ${state.kind}`);
203
+ }
204
+ return state;
205
+ }
206
+ catch (error) {
207
+ await reapplyAwaitingCi(effects, input.prNumber, error);
208
+ throw error;
209
+ }
210
+ }
211
+ async function reapplyAwaitingCi(effects, prNumber, originalError) {
212
+ try {
213
+ await effects.applyLabels({ prNumber, labels: [AWAITING_CI_LABEL] });
214
+ }
215
+ catch (rearmError) {
216
+ // The original error is the primary failure operators need to see;
217
+ // surface the re-arm failure alongside it so the recovery hint
218
+ // ("awaiting-ci is still missing, redelivery will not retry") is
219
+ // visible, but don't replace the cause.
220
+ logger.error('runPromotionFlow: failed to re-apply awaiting-ci after side-effect failure', {
221
+ task: 'fix-loop-promotion',
222
+ pr_number: prNumber,
223
+ original_error: originalError instanceof Error ? originalError.message : String(originalError),
224
+ rearm_error: rearmError instanceof Error ? rearmError.message : String(rearmError),
225
+ });
226
+ }
227
+ }
@@ -0,0 +1,165 @@
1
+ export function isTerminal(state) {
2
+ return (state.kind === 'DONE_PROMOTED' ||
3
+ state.kind === 'DONE_PR_UPDATED' ||
4
+ state.kind === 'DONE_NEEDS_HUMAN');
5
+ }
6
+ export function isFixLoopTerminal(state) {
7
+ return isTerminal(state) || state.kind === 'PUSHED_AWAITING_CI';
8
+ }
9
+ function blockers(findings) {
10
+ return findings.filter((f) => f.severity === 'P0' || f.severity === 'P1');
11
+ }
12
+ function invariant(state, event) {
13
+ throw new Error(`fix-loop: invalid event ${event.kind} for state ${state.kind}`);
14
+ }
15
+ function withMeta(base, meta) {
16
+ const out = { ...base };
17
+ if (meta.prNumber !== undefined)
18
+ out.prNumber = meta.prNumber;
19
+ if (meta.branch !== undefined)
20
+ out.branch = meta.branch;
21
+ return out;
22
+ }
23
+ function header(state) {
24
+ return {
25
+ intent: state.intent,
26
+ promotionPolicy: state.promotionPolicy,
27
+ maxIterations: state.maxIterations,
28
+ };
29
+ }
30
+ export function next(state, event) {
31
+ switch (state.kind) {
32
+ case 'AWAITING_FIRST_FIX': {
33
+ if (event.kind === 'FIX_PUSHED') {
34
+ return {
35
+ kind: 'REVIEWING',
36
+ iteration: 0,
37
+ headSha: event.headSha,
38
+ branch: event.branch,
39
+ prNumber: event.prNumber,
40
+ ...header(state),
41
+ };
42
+ }
43
+ if (event.kind === 'FIX_FAILED') {
44
+ return {
45
+ kind: 'DONE_NEEDS_HUMAN',
46
+ reason: event.reason,
47
+ findings: [],
48
+ ...header(state),
49
+ };
50
+ }
51
+ return invariant(state, event);
52
+ }
53
+ case 'REVIEWING': {
54
+ if (event.kind === 'REVIEW_UNVERIFIED') {
55
+ return withMeta({
56
+ kind: 'DONE_NEEDS_HUMAN',
57
+ reason: `Review unverified: ${event.reason}`,
58
+ findings: [],
59
+ ...header(state),
60
+ }, { prNumber: state.prNumber, branch: state.branch });
61
+ }
62
+ if (event.kind !== 'REVIEW_COMPLETE')
63
+ return invariant(state, event);
64
+ const blocking = blockers(event.findings);
65
+ if (blocking.length > 0) {
66
+ if (state.iteration + 1 < state.maxIterations) {
67
+ return {
68
+ kind: 'ITERATING',
69
+ iteration: state.iteration + 1,
70
+ findings: blocking,
71
+ prevHeadSha: state.headSha,
72
+ branch: state.branch,
73
+ prNumber: state.prNumber,
74
+ ...header(state),
75
+ };
76
+ }
77
+ return withMeta({
78
+ kind: 'DONE_NEEDS_HUMAN',
79
+ reason: 'P0/P1 findings persisting after retry — manual review required',
80
+ findings: event.findings,
81
+ ...header(state),
82
+ }, { prNumber: state.prNumber, branch: state.branch });
83
+ }
84
+ if (state.promotionPolicy.kind === 'doistbot_fix_ci') {
85
+ return {
86
+ kind: 'PUSHED_AWAITING_CI',
87
+ iteration: state.iteration,
88
+ headSha: state.headSha,
89
+ prNumber: state.prNumber,
90
+ branch: state.branch,
91
+ ...header(state),
92
+ };
93
+ }
94
+ return withMeta({
95
+ kind: 'DONE_PR_UPDATED',
96
+ headSha: state.headSha,
97
+ ...header(state),
98
+ }, { prNumber: state.prNumber, branch: state.branch });
99
+ }
100
+ case 'ITERATING': {
101
+ if (event.kind === 'FIX_PUSHED') {
102
+ return {
103
+ kind: 'REVIEWING',
104
+ iteration: state.iteration,
105
+ headSha: event.headSha,
106
+ reviewBaseSha: state.prevHeadSha,
107
+ branch: event.branch,
108
+ prNumber: event.prNumber,
109
+ ...header(state),
110
+ };
111
+ }
112
+ if (event.kind === 'FIX_FAILED') {
113
+ return withMeta({
114
+ kind: 'DONE_NEEDS_HUMAN',
115
+ reason: event.reason,
116
+ findings: state.findings,
117
+ ...header(state),
118
+ }, { prNumber: state.prNumber, branch: state.branch });
119
+ }
120
+ return invariant(state, event);
121
+ }
122
+ case 'PUSHED_AWAITING_CI': {
123
+ if (event.kind !== 'CI_RESULT')
124
+ return invariant(state, event);
125
+ const status = event.status;
126
+ // Ignore CI results carrying a SHA other than our current head.
127
+ // GitHub can deliver a check_suite for an earlier push after we
128
+ // have already pushed an iter-1 commit, in which case acting on
129
+ // the stale verdict would promote or escalate based on a SHA we
130
+ // have already superseded.
131
+ if (status.headSha !== state.headSha) {
132
+ return state;
133
+ }
134
+ if (status.kind === 'passed') {
135
+ return withMeta({
136
+ kind: 'DONE_PROMOTED',
137
+ headSha: status.headSha,
138
+ ...header(state),
139
+ }, { prNumber: state.prNumber, branch: state.branch });
140
+ }
141
+ if (status.kind === 'failed') {
142
+ const failed = status.failedChecks.map((c) => c.name).join(', ');
143
+ return withMeta({
144
+ kind: 'DONE_NEEDS_HUMAN',
145
+ reason: `CI failed: ${failed || 'unknown checks'}`,
146
+ findings: [],
147
+ ...header(state),
148
+ }, { prNumber: state.prNumber, branch: state.branch });
149
+ }
150
+ if (status.kind === 'pending') {
151
+ return state;
152
+ }
153
+ return withMeta({
154
+ kind: 'DONE_NEEDS_HUMAN',
155
+ reason: 'No CI checks configured — manual review required',
156
+ findings: [],
157
+ ...header(state),
158
+ }, { prNumber: state.prNumber, branch: state.branch });
159
+ }
160
+ case 'DONE_PROMOTED':
161
+ case 'DONE_PR_UPDATED':
162
+ case 'DONE_NEEDS_HUMAN':
163
+ return invariant(state, event);
164
+ }
165
+ }
@@ -1,4 +1,5 @@
1
1
  import { normalizeScopedLabelValue, scopedLabelValue } from '../../core/label-parsing.js';
2
+ import { PRODUCT_REPOSITORIES } from '../../core/repository-map.js';
2
3
  const HERO_GROUPS = {
3
4
  'android-hero': { key: 'android-hero', mention: '@Doist/android-hero' },
4
5
  'app-copy-hero': { key: 'app-copy-hero', mention: '@Doist/app-copy-hero' },
@@ -46,6 +47,37 @@ const SURFACE_TO_HERO = {
46
47
  web: 'frontend-hero',
47
48
  };
48
49
  export const CX_HERO_MANUAL_HANDLE = HERO_GROUPS['cx-hero'].mention;
50
+ /**
51
+ * Looks up a known Doist repository by owner/repo in PRODUCT_REPOSITORIES.
52
+ * Returns the canonical RepositoryTarget (with surfaces) or undefined when
53
+ * the repo isn't in the registry. Used at session construction to populate
54
+ * targetRepo.repository.surfaces accurately, so downstream effects can
55
+ * reuse the surface map without re-scanning the registry.
56
+ */
57
+ export function findRepositoryTarget(owner, repo) {
58
+ for (const list of Object.values(PRODUCT_REPOSITORIES)) {
59
+ const match = list.find((target) => target.owner === owner && target.repo === repo);
60
+ if (match) {
61
+ return match;
62
+ }
63
+ }
64
+ return undefined;
65
+ }
66
+ /**
67
+ * Derives the GitHub team slug (under the Doist org) for a PR's hero
68
+ * reviewer from a RepositoryTarget. The target's surfaces decide the team
69
+ * (web → frontend-hero, etc.). Falls back to cx-hero for unknown surfaces
70
+ * and for user-mention groups like dateist (`@amix`) that aren't real teams.
71
+ */
72
+ export function resolveHeroTeamSlug(target) {
73
+ const group = resolveHeroGroup([], target);
74
+ if (!group || group.key === 'dateist') {
75
+ return 'cx-hero';
76
+ }
77
+ // The check above excluded `dateist`, so TS narrows group.key from
78
+ // HeroGroupKey to HeroTeamSlug here.
79
+ return group.key;
80
+ }
49
81
  export function isKnownHeroRoutingLabel(label) {
50
82
  const teamValue = scopedLabelValue(label, 'Team');
51
83
  if (teamValue && TEAM_LABEL_TO_HERO[normalizeScopedLabelValue(teamValue)]) {
@@ -1 +1,3 @@
1
1
  export const HERO_AUTOFIX_MARKER = '<!--doistbot-autofix-followup-->';
2
+ export const FIX_LOOP_START_MARKER = '<!--doistbot-fixloop-start-->';
3
+ export const AUTOFIX_FAILURE_MARKER = '<!--doistbot-autofix-failure-->';
@@ -25,16 +25,37 @@ function hasHeading(body, heading) {
25
25
  const regex = new RegExp(`^${escapedHeading}\\s*$`, 'im');
26
26
  return regex.test(body);
27
27
  }
28
+ function buildTriageCommentOutput(output) {
29
+ const findingsMatch = output.match(/^## Findings\s*$/m);
30
+ if (findingsMatch?.index === undefined) {
31
+ return output;
32
+ }
33
+ const summary = output.slice(0, findingsMatch.index).trim();
34
+ const details = output.slice(findingsMatch.index).trim();
35
+ return [
36
+ summary,
37
+ '',
38
+ '<details>',
39
+ '<summary>Details</summary>',
40
+ '',
41
+ details,
42
+ '',
43
+ '</details>',
44
+ ].join('\n');
45
+ }
28
46
  export function ensureStructuredTriageOutput(rawOutput) {
29
- const missingSections = REQUIRED_HEADINGS.filter((heading) => !hasHeading(rawOutput, heading));
47
+ const output = rawOutput.trim();
48
+ const missingSections = REQUIRED_HEADINGS.filter((heading) => !hasHeading(output, heading));
30
49
  if (missingSections.length === 0) {
31
50
  return {
32
- output: rawOutput.trim(),
51
+ output,
52
+ commentOutput: buildTriageCommentOutput(output),
33
53
  missingSections: [],
34
54
  };
35
55
  }
36
56
  return {
37
- output: rawOutput.trim(),
57
+ output,
58
+ commentOutput: output,
38
59
  missingSections,
39
60
  };
40
61
  }
@@ -32,9 +32,15 @@ export function findPRTemplate(repoLocalPath) {
32
32
  }
33
33
  return null;
34
34
  }
35
+ // TODO: extract into a shared module consumed by both `apps/sandbox` and
36
+ // `apps/webhook-handler`. The webhook handler hardcodes the same prefix in
37
+ // `apps/webhook-handler/src/tasks/promotion-ci-gate/index.ts`
38
+ // (`DOISTBOT_BRANCH_PREFIX`) to fast-path filter `check_suite.completed`
39
+ // events before any API call. If this value changes, update both sites.
40
+ const DOISTBOT_BRANCH_PREFIX = 'doistbot/';
35
41
  function generateBranchName(issueNumber) {
36
42
  const timestamp = Math.floor(Date.now() / 1000);
37
- return `doistbot/auto-fix/issue-${issueNumber}-${timestamp}`;
43
+ return `${DOISTBOT_BRANCH_PREFIX}auto-fix/issue-${issueNumber}-${timestamp}`;
38
44
  }
39
45
  export async function resolveInstallationIdForRepo({ app, owner, repo, fallbackInstallationId, }) {
40
46
  try {
@@ -61,19 +67,6 @@ const GIT_IDENTITY_ENV = {
61
67
  GIT_COMMITTER_NAME: 'Doistbot',
62
68
  GIT_COMMITTER_EMAIL: 'doistbot@users.noreply.github.com',
63
69
  };
64
- function createBranchAndCommit({ repoLocalPath, branch, issueNumber, issueTitle, prTitle, }) {
65
- const titleLine = prTitle ?? `fix: ${issueTitle}`;
66
- const commitMessage = `${titleLine} (#${issueNumber})\n\nAutomated fix generated by Doistbot based on triage analysis.\nIssue: #${issueNumber}`;
67
- const execOptions = {
68
- cwd: repoLocalPath,
69
- encoding: 'utf8',
70
- stdio: 'pipe',
71
- env: { ...process.env, ...GIT_IDENTITY_ENV },
72
- };
73
- execFileSync('git', ['checkout', '-b', branch], execOptions);
74
- // Files are already staged by captureGitDiff in fix-attempt.ts
75
- execFileSync('git', ['commit', '-m', commitMessage], execOptions);
76
- }
77
70
  function pushBranch({ repoLocalPath, branch, token, owner, repo, }) {
78
71
  const credentials = Buffer.from(`x-access-token:${token}`).toString('base64');
79
72
  const remoteUrl = `https://github.com/${owner}/${repo}.git`;
@@ -90,6 +83,29 @@ function pushBranch({ repoLocalPath, branch, token, owner, repo, }) {
90
83
  },
91
84
  });
92
85
  }
86
+ function commitAndPushBranch({ repoLocalPath, checkoutArgs, commitMessage, branch, githubToken, repoOwner, repoName, }) {
87
+ const execOptions = {
88
+ cwd: repoLocalPath,
89
+ encoding: 'utf8',
90
+ stdio: 'pipe',
91
+ env: { ...process.env, ...GIT_IDENTITY_ENV },
92
+ };
93
+ execFileSync('git', checkoutArgs, execOptions);
94
+ // Files are already staged by captureGitDiff in fix-attempt.ts
95
+ execFileSync('git', ['commit', '-m', commitMessage], execOptions);
96
+ pushBranch({
97
+ repoLocalPath,
98
+ branch,
99
+ token: githubToken,
100
+ owner: repoOwner,
101
+ repo: repoName,
102
+ });
103
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
104
+ cwd: repoLocalPath,
105
+ encoding: 'utf8',
106
+ stdio: 'pipe',
107
+ }).trim();
108
+ }
93
109
  /**
94
110
  * Strips markdown sections whose body contains no meaningful content.
95
111
  * A section is considered empty when its body (between this heading and the
@@ -197,31 +213,23 @@ function buildPRBody({ issueNumber, issueUrl, triageAnalysis, fixDescription, fi
197
213
  }
198
214
  export async function commitAndPushFix(input) {
199
215
  const branch = generateBranchName(input.issueNumber);
216
+ const titleLine = input.prTitle ?? `fix: ${input.issueTitle}`;
217
+ const commitMessage = `${titleLine} (#${input.issueNumber})\n\nAutomated fix generated by Doistbot based on triage analysis.\nIssue: #${input.issueNumber}`;
200
218
  logger.info('Creating fix PR', {
201
219
  task: 'issue-triage',
202
220
  repository: `${input.repoOwner}/${input.repoName}`,
203
221
  issueNumber: input.issueNumber,
204
222
  branch,
205
223
  });
206
- createBranchAndCommit({
207
- repoLocalPath: input.repoLocalPath,
208
- branch,
209
- issueNumber: input.issueNumber,
210
- issueTitle: input.issueTitle,
211
- prTitle: input.prTitle,
212
- });
213
- pushBranch({
224
+ const headSha = commitAndPushBranch({
214
225
  repoLocalPath: input.repoLocalPath,
226
+ checkoutArgs: ['checkout', '-b', branch],
227
+ commitMessage,
215
228
  branch,
216
- token: input.githubToken,
217
- owner: input.repoOwner,
218
- repo: input.repoName,
229
+ githubToken: input.githubToken,
230
+ repoOwner: input.repoOwner,
231
+ repoName: input.repoName,
219
232
  });
220
- const headSha = execFileSync('git', ['rev-parse', 'HEAD'], {
221
- cwd: input.repoLocalPath,
222
- encoding: 'utf8',
223
- stdio: 'pipe',
224
- }).trim();
225
233
  logger.info('Fix branch pushed, creating draft PR', {
226
234
  task: 'issue-triage',
227
235
  repository: `${input.repoOwner}/${input.repoName}`,
@@ -229,6 +237,40 @@ export async function commitAndPushFix(input) {
229
237
  });
230
238
  return { branch, headSha };
231
239
  }
240
+ /**
241
+ * Commits + pushes a second fix attempt on the EXISTING iter-0 branch. Unlike
242
+ * commitAndPushFix, this does not create a new branch — callers (the fix loop's
243
+ * iter-1 path) need the existing draft PR to pick up the new commit, which only
244
+ * works if we push to the same branch the PR was opened from.
245
+ *
246
+ * Assumes the working tree already has the iter-1 changes staged (typically by
247
+ * runFixCore's captureGitDiff `git add -A`) and that the repo is currently on
248
+ * `branch` (the case after iter-0 push + iter-1 `git reset --hard <iter0Sha>`).
249
+ */
250
+ export async function commitAndPushIteration(input) {
251
+ const titleLine = input.prTitle ?? `fix: ${input.issueTitle}`;
252
+ const commitMessage = `${titleLine} (iteration) (#${input.issueNumber})\n\nFollow-up fix addressing prior review findings.\nIssue: #${input.issueNumber}`;
253
+ // Defensive: make sure we are on the iter-0 branch before committing. The
254
+ // iter-1 codex run does `git reset --hard <iter0Sha>` which preserves the
255
+ // current branch, so this should be a no-op, but a checkout makes the
256
+ // invariant explicit.
257
+ const headSha = commitAndPushBranch({
258
+ repoLocalPath: input.repoLocalPath,
259
+ checkoutArgs: ['checkout', input.branch],
260
+ commitMessage,
261
+ branch: input.branch,
262
+ githubToken: input.githubToken,
263
+ repoOwner: input.repoOwner,
264
+ repoName: input.repoName,
265
+ });
266
+ logger.info('Fix iteration pushed to existing branch', {
267
+ task: 'issue-triage',
268
+ repository: `${input.repoOwner}/${input.repoName}`,
269
+ branch: input.branch,
270
+ issueNumber: input.issueNumber,
271
+ });
272
+ return { headSha };
273
+ }
232
274
  export async function openDraftFixPR(input) {
233
275
  const prBody = input.prBody
234
276
  ? stripEmptySections(input.prBody)
@@ -5,6 +5,19 @@ export function isAutoFixEnabled(config) {
5
5
  export function isAutoTriageEnabled(config) {
6
6
  return config?.issue_triage?.enable_auto_triage !== false;
7
7
  }
8
+ export function getAutoFixMode(config, enableAutoFixOverride) {
9
+ if (enableAutoFixOverride === false) {
10
+ return 'disabled';
11
+ }
12
+ if (enableAutoFixOverride !== true && !isAutoFixEnabled(config)) {
13
+ return 'disabled';
14
+ }
15
+ const useFixLoop = config?.issue_triage?.use_fix_loop;
16
+ if (useFixLoop !== undefined && useFixLoop !== true) {
17
+ return 'one-pass';
18
+ }
19
+ return 'fix-loop';
20
+ }
8
21
  export function createRepoConfigLoader({ octokit, logger, }) {
9
22
  const cache = new Map();
10
23
  return async function loadRepoConfig(repository) {