@doist/doistbot-cli 1.0.2 → 1.0.4

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 +6 -1
  2. package/dist/actions/review.js +121 -11
  3. package/dist/actions/setup.js +1 -1
  4. package/dist/index.js +36 -8
  5. package/dist/runtime.js +3 -3
  6. package/dist/update-check.js +1 -1
  7. package/package.json +2 -2
  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-triage/fix-attempt.js +140 -13
  14. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +190 -0
  15. package/sandbox/dist/tasks/issue-triage/fix-failure-reasons.js +2 -0
  16. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.js +464 -0
  17. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.types.js +1 -0
  18. package/sandbox/dist/tasks/issue-triage/fix-loop/fake-effects.js +65 -0
  19. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +110 -0
  20. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +227 -0
  21. package/sandbox/dist/tasks/issue-triage/fix-loop/state-machine.js +165 -0
  22. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +32 -0
  23. package/sandbox/dist/tasks/issue-triage/markers.js +2 -0
  24. package/sandbox/dist/tasks/issue-triage/output.js +24 -3
  25. package/sandbox/dist/tasks/issue-triage/pr-creator.js +72 -30
  26. package/sandbox/dist/tasks/issue-triage/repo-config.js +13 -0
  27. package/sandbox/dist/tasks/issue-triage/triage.js +30 -3
  28. package/sandbox/dist/tasks/issue-triage/types.js +1 -0
  29. package/sandbox/dist/tasks/review/author-format.js +14 -0
  30. package/sandbox/dist/tasks/review/author-profile.js +67 -0
  31. package/sandbox/dist/tasks/review/check-run.js +8 -1
  32. package/sandbox/dist/tasks/review/comment-mapper.js +17 -12
  33. package/sandbox/dist/tasks/review/conversation-context.js +174 -20
  34. package/sandbox/dist/tasks/review/council/summary.js +141 -12
  35. package/sandbox/dist/tasks/review/engines/council.js +19 -3
  36. package/sandbox/dist/tasks/review/engines/dedupe.js +37 -28
  37. package/sandbox/dist/tasks/review/engines/multi-focus.js +123 -6
  38. package/sandbox/dist/tasks/review/engines/shared.js +28 -5
  39. package/sandbox/dist/tasks/review/finding-output.js +18 -0
  40. package/sandbox/dist/tasks/review/github-comment-format.js +32 -0
  41. package/sandbox/dist/tasks/review/local-git.js +3 -1
  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,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
+ }
@@ -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
  }