@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,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)]) {
|
|
@@ -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
|
|
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
|
|
51
|
+
output,
|
|
52
|
+
commentOutput: buildTriageCommentOutput(output),
|
|
33
53
|
missingSections: [],
|
|
34
54
|
};
|
|
35
55
|
}
|
|
36
56
|
return {
|
|
37
|
-
output
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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) {
|