@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.
- package/dist/actions/review.js +124 -9
- package/dist/actions/setup.js +1 -1
- package/dist/index.js +18 -8
- package/dist/runtime.js +3 -3
- package/dist/update-check.js +1 -1
- package/package.json +2 -2
- 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-triage/fix-attempt.js +140 -13
- 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 +30 -3
- 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
package/dist/actions/review.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getRepositoryRoot, loadSandboxFormatModule, loadSandboxReviewModule } f
|
|
|
3
3
|
import { formatReviewProgress, renderAlert, renderMarkdown, withSpinner, writeLine, } from '../terminal.js';
|
|
4
4
|
import { formatReviewUsage } from './review-usage.js';
|
|
5
5
|
const LOCAL_REVIEW_SOURCE_STAGED = 'staged';
|
|
6
|
+
const JSON_SCHEMA_VERSION = 1;
|
|
6
7
|
const PROVIDER_DISPLAY_NAMES = {
|
|
7
8
|
gemini: 'Gemini',
|
|
8
9
|
openai: 'OpenAI Codex',
|
|
@@ -42,14 +43,17 @@ function getReadableProviderFailure(failure) {
|
|
|
42
43
|
?.trim();
|
|
43
44
|
return `${PROVIDER_DISPLAY_NAMES[failure.model]} failed: ${firstLine || 'unknown error'}`;
|
|
44
45
|
}
|
|
45
|
-
function
|
|
46
|
+
function getReadableProviderFailureEntries(failures) {
|
|
46
47
|
const messagesByModel = new Map();
|
|
47
48
|
for (const failure of failures) {
|
|
48
49
|
if (!messagesByModel.has(failure.model)) {
|
|
49
50
|
messagesByModel.set(failure.model, getReadableProviderFailure(failure));
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
|
-
return
|
|
53
|
+
return Array.from(messagesByModel, ([model, message]) => ({ model, message }));
|
|
54
|
+
}
|
|
55
|
+
function getReadableProviderFailures(failures) {
|
|
56
|
+
return getReadableProviderFailureEntries(failures).map(({ message }) => message);
|
|
53
57
|
}
|
|
54
58
|
function formatProviderFailureMessage(failures) {
|
|
55
59
|
const messages = getReadableProviderFailures(failures);
|
|
@@ -87,19 +91,130 @@ function handleSkippedReview(result) {
|
|
|
87
91
|
function formatReviewOutput({ formatReviewForTerminal, review, }) {
|
|
88
92
|
return formatReviewForTerminal({ review });
|
|
89
93
|
}
|
|
90
|
-
|
|
94
|
+
function getJsonStatus(result) {
|
|
95
|
+
function mapping(statusMapping, canTrustCleanReview = statusMapping.status === 'reviewed') {
|
|
96
|
+
return { mapping: statusMapping, canTrustCleanReview };
|
|
97
|
+
}
|
|
98
|
+
if (result.reviewResult.status === 'skipped') {
|
|
99
|
+
switch (result.reviewResult.reason) {
|
|
100
|
+
case 'empty_review':
|
|
101
|
+
case 'no_models':
|
|
102
|
+
case 'no_review':
|
|
103
|
+
return mapping({
|
|
104
|
+
status: 'unverified',
|
|
105
|
+
verified: false,
|
|
106
|
+
unverifiedReason: result.reviewResult.reason,
|
|
107
|
+
}, false);
|
|
108
|
+
default:
|
|
109
|
+
return mapping({
|
|
110
|
+
status: 'skipped',
|
|
111
|
+
verified: false,
|
|
112
|
+
skipReason: result.reviewResult.reason,
|
|
113
|
+
}, false);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return mapping({ status: 'reviewed', verified: true });
|
|
117
|
+
}
|
|
118
|
+
function finalizeJsonStatus({ initialStatus, findingCount, providerFailureCount, }) {
|
|
119
|
+
if (initialStatus.canTrustCleanReview && providerFailureCount > 0 && findingCount === 0) {
|
|
120
|
+
return { status: 'unverified', verified: false, unverifiedReason: 'provider_failures' };
|
|
121
|
+
}
|
|
122
|
+
return initialStatus.mapping;
|
|
123
|
+
}
|
|
124
|
+
function getReviewForJson(result) {
|
|
125
|
+
if (result.reviewResult.status === 'reviewed') {
|
|
126
|
+
return result.reviewResult.review;
|
|
127
|
+
}
|
|
128
|
+
return { summary: '', comments: [] };
|
|
129
|
+
}
|
|
130
|
+
function hasLocation(finding) {
|
|
131
|
+
return typeof finding.path === 'string' && typeof finding.line === 'number';
|
|
132
|
+
}
|
|
133
|
+
function formatReviewJsonFinding(finding) {
|
|
134
|
+
return {
|
|
135
|
+
body: finding.body,
|
|
136
|
+
severity: finding.severity,
|
|
137
|
+
...(hasLocation(finding)
|
|
138
|
+
? {
|
|
139
|
+
location: {
|
|
140
|
+
path: finding.path,
|
|
141
|
+
line: finding.line,
|
|
142
|
+
...(finding.side ? { side: finding.side } : {}),
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
: {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function getReviewFindingsForJson(review) {
|
|
149
|
+
if (review.findings) {
|
|
150
|
+
return review.findings;
|
|
151
|
+
}
|
|
152
|
+
return review.comments.map((comment) => ({
|
|
153
|
+
body: comment.body,
|
|
154
|
+
severity: null,
|
|
155
|
+
placement: 'inline',
|
|
156
|
+
path: comment.path,
|
|
157
|
+
line: comment.line,
|
|
158
|
+
side: comment.side,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
function getReviewFindingCount(review) {
|
|
162
|
+
return getReviewFindingsForJson(review).length;
|
|
163
|
+
}
|
|
164
|
+
function formatReviewForJson(result) {
|
|
165
|
+
const review = getReviewForJson(result);
|
|
166
|
+
const reviewFindings = getReviewFindingsForJson(review);
|
|
167
|
+
const findings = reviewFindings.map(formatReviewJsonFinding);
|
|
168
|
+
const statusMapping = finalizeJsonStatus({
|
|
169
|
+
initialStatus: getJsonStatus(result),
|
|
170
|
+
findingCount: findings.length,
|
|
171
|
+
providerFailureCount: result.reviewResult.providerFailures.length,
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
schemaVersion: JSON_SCHEMA_VERSION,
|
|
175
|
+
...statusMapping,
|
|
176
|
+
reviewSource: result.source,
|
|
177
|
+
repository: {
|
|
178
|
+
owner: result.owner,
|
|
179
|
+
repo: result.repo,
|
|
180
|
+
branch: result.branch,
|
|
181
|
+
baseBranch: result.baseBranch,
|
|
182
|
+
},
|
|
183
|
+
review: {
|
|
184
|
+
count: findings.length,
|
|
185
|
+
findings,
|
|
186
|
+
},
|
|
187
|
+
providerFailures: getReadableProviderFailureEntries(result.reviewResult.providerFailures),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
async function runLocalReviewForCli({ source, onProgress, }) {
|
|
191
|
+
const reviewModule = await loadSandboxReviewModule();
|
|
192
|
+
return await reviewModule.runLocalReview({
|
|
193
|
+
workspaceDir: getRepositoryRoot(),
|
|
194
|
+
source,
|
|
195
|
+
apiKeys: await getApiKeys(),
|
|
196
|
+
suppressLogs: true,
|
|
197
|
+
onProgress,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
export async function runReview(sourceArg, options = {}) {
|
|
91
201
|
if (sourceArg !== undefined && sourceArg !== LOCAL_REVIEW_SOURCE_STAGED) {
|
|
92
202
|
throw new Error(formatReviewUsage());
|
|
93
203
|
}
|
|
94
204
|
const source = sourceArg === LOCAL_REVIEW_SOURCE_STAGED ? LOCAL_REVIEW_SOURCE_STAGED : 'base';
|
|
95
|
-
|
|
205
|
+
if (options.json === true) {
|
|
206
|
+
const result = await runLocalReviewForCli({ source });
|
|
207
|
+
const output = formatReviewForJson(result);
|
|
208
|
+
writeLine(JSON.stringify(output, null, 2));
|
|
209
|
+
if (output.status === 'unverified') {
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
96
214
|
const result = await withSpinner(source === LOCAL_REVIEW_SOURCE_STAGED
|
|
97
215
|
? 'Reviewing staged changes...'
|
|
98
|
-
: 'Reviewing local changes...', async (status) => await
|
|
99
|
-
workspaceDir: getRepositoryRoot(),
|
|
216
|
+
: 'Reviewing local changes...', async (status) => await runLocalReviewForCli({
|
|
100
217
|
source,
|
|
101
|
-
apiKeys: await getApiKeys(),
|
|
102
|
-
suppressLogs: true,
|
|
103
218
|
onProgress: status.enabled
|
|
104
219
|
? (event) => status.update(formatReviewProgress(event))
|
|
105
220
|
: undefined,
|
|
@@ -110,7 +225,7 @@ export async function runReview(sourceArg) {
|
|
|
110
225
|
}
|
|
111
226
|
const formatModule = await loadSandboxFormatModule();
|
|
112
227
|
if (result.reviewResult.providerFailures.length > 0 &&
|
|
113
|
-
result.reviewResult.review
|
|
228
|
+
getReviewFindingCount(result.reviewResult.review) === 0) {
|
|
114
229
|
throw new Error(formatProviderFailureMessage(result.reviewResult.providerFailures));
|
|
115
230
|
}
|
|
116
231
|
const output = formatReviewOutput({
|
package/dist/actions/setup.js
CHANGED
|
@@ -3,7 +3,7 @@ import { renderDiagnosticCheck, writeLine } from '../terminal.js';
|
|
|
3
3
|
import { runAuthSetup } from './auth.js';
|
|
4
4
|
import { formatSetupCompleteMessage } from './review-usage.js';
|
|
5
5
|
export async function runSetup() {
|
|
6
|
-
writeLine('Checking
|
|
6
|
+
writeLine('Checking Doistbot CLI prerequisites...');
|
|
7
7
|
await renderDiagnosticCheck('git', checkGit);
|
|
8
8
|
await renderDiagnosticCheck('pi', checkPiDependency);
|
|
9
9
|
await renderDiagnosticCheck('sandbox build', checkSandboxBuild);
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
-
import { Command } from 'commander';
|
|
5
|
+
import { Command, Option } from 'commander';
|
|
6
6
|
import { AUTH_LOGOUT_PROVIDER_USAGE, runAuthLogout, runAuthSetup, runAuthStatus, } from './actions/auth.js';
|
|
7
7
|
import { runDoctor } from './actions/doctor.js';
|
|
8
8
|
import { runReview } from './actions/review.js';
|
|
@@ -34,18 +34,27 @@ export function createProgram({ runAuthLogout: authLogout = runAuthLogout, runAu
|
|
|
34
34
|
automaticUpdateCommands.add(command);
|
|
35
35
|
return command;
|
|
36
36
|
}
|
|
37
|
+
function shouldRunAutomaticUpdateCheck(actionCommand) {
|
|
38
|
+
if (!automaticUpdateCommands.has(actionCommand)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (actionCommand.name() === 'review' && actionCommand.opts().json) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
37
46
|
program
|
|
38
47
|
.name('doistbot')
|
|
39
|
-
.description('
|
|
48
|
+
.description('Doistbot local self-review CLI')
|
|
40
49
|
.version(PACKAGE_VERSION)
|
|
41
50
|
.hook('postAction', async (_thisCommand, actionCommand) => {
|
|
42
|
-
if (
|
|
51
|
+
if (shouldRunAutomaticUpdateCheck(actionCommand)) {
|
|
43
52
|
await automaticUpdateCheck();
|
|
44
53
|
}
|
|
45
54
|
});
|
|
46
55
|
withAutomaticUpdateCheck(program
|
|
47
56
|
.command('doctor')
|
|
48
|
-
.description('Diagnose local
|
|
57
|
+
.description('Diagnose local Doistbot CLI setup')
|
|
49
58
|
.action(async () => await doctor()));
|
|
50
59
|
withAutomaticUpdateCheck(program
|
|
51
60
|
.command('setup')
|
|
@@ -53,7 +62,7 @@ export function createProgram({ runAuthLogout: authLogout = runAuthLogout, runAu
|
|
|
53
62
|
.action(async () => await setup()));
|
|
54
63
|
const authCommand = withAutomaticUpdateCheck(program
|
|
55
64
|
.command('auth')
|
|
56
|
-
.description('Authenticate
|
|
65
|
+
.description('Authenticate Doistbot CLI with local review providers')
|
|
57
66
|
.action(async () => await authSetup()));
|
|
58
67
|
withAutomaticUpdateCheck(authCommand
|
|
59
68
|
.command('status')
|
|
@@ -65,15 +74,16 @@ export function createProgram({ runAuthLogout: authLogout = runAuthLogout, runAu
|
|
|
65
74
|
.action(async () => await authSetup()));
|
|
66
75
|
withAutomaticUpdateCheck(authCommand
|
|
67
76
|
.command('logout [provider]')
|
|
68
|
-
.description(`Clear stored
|
|
77
|
+
.description(`Clear stored Doistbot CLI auth for ${AUTH_LOGOUT_PROVIDER_USAGE}`)
|
|
69
78
|
.action(async (provider) => await authLogout(provider)));
|
|
70
79
|
withAutomaticUpdateCheck(program
|
|
71
80
|
.command('review [source]')
|
|
72
81
|
.description('Run a local self-review. Use `staged` to review only staged changes.')
|
|
73
|
-
.
|
|
82
|
+
.addOption(new Option('--json', 'Print machine-readable JSON instead of terminal-formatted markdown').hideHelp())
|
|
83
|
+
.action(async (source, options) => await review(source, { json: options?.json === true })));
|
|
74
84
|
program
|
|
75
85
|
.command('update')
|
|
76
|
-
.description('Check whether a newer
|
|
86
|
+
.description('Check whether a newer Doistbot CLI version is available')
|
|
77
87
|
.action(async () => await updateCheck());
|
|
78
88
|
return program;
|
|
79
89
|
}
|
package/dist/runtime.js
CHANGED
|
@@ -53,10 +53,10 @@ async function loadBundledModule(modulePath, missingMessage) {
|
|
|
53
53
|
return (await import(pathToFileURL(modulePath).href));
|
|
54
54
|
}
|
|
55
55
|
export async function loadSandboxReviewModule() {
|
|
56
|
-
return loadBundledModule(getSandboxReviewModulePath(), 'Sandbox review runner is missing. Rebuild or reinstall the
|
|
56
|
+
return loadBundledModule(getSandboxReviewModulePath(), 'Sandbox review runner is missing. Rebuild or reinstall the Doistbot CLI package.');
|
|
57
57
|
}
|
|
58
58
|
export async function loadSandboxFormatModule() {
|
|
59
|
-
return loadBundledModule(getSandboxFormatModulePath(), 'Sandbox review formatter is missing. Rebuild or reinstall the
|
|
59
|
+
return loadBundledModule(getSandboxFormatModulePath(), 'Sandbox review formatter is missing. Rebuild or reinstall the Doistbot CLI package.');
|
|
60
60
|
}
|
|
61
61
|
export function runGit(args, cwd = process.cwd()) {
|
|
62
62
|
return execFileSync('git', args, {
|
|
@@ -89,7 +89,7 @@ export function checkSandboxBuild() {
|
|
|
89
89
|
const modulePath = getSandboxReviewModulePath();
|
|
90
90
|
return fs.existsSync(modulePath)
|
|
91
91
|
? { ok: true, message: 'sandbox review runner is built' }
|
|
92
|
-
: { ok: false, message: 'rebuild or reinstall the
|
|
92
|
+
: { ok: false, message: 'rebuild or reinstall the Doistbot CLI package' };
|
|
93
93
|
}
|
|
94
94
|
export function checkPiDependency() {
|
|
95
95
|
try {
|
package/dist/update-check.js
CHANGED
|
@@ -38,7 +38,7 @@ export async function notifyIfCliUpdateAvailable({ cachePath = getDefaultUpdateC
|
|
|
38
38
|
timeoutMs,
|
|
39
39
|
});
|
|
40
40
|
if (!latestVersion) {
|
|
41
|
-
write('Unable to check for
|
|
41
|
+
write('Unable to check for Doistbot CLI updates.');
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
44
44
|
if (!isNewerVersion(latestVersion, currentVersion)) {
|
package/package.json
CHANGED
|
@@ -6,9 +6,22 @@ export const KNOWN_DOISTBOT_LOGINS = new Set([
|
|
|
6
6
|
'doistbot-app',
|
|
7
7
|
'doistbot-app[bot]',
|
|
8
8
|
]);
|
|
9
|
+
export const KNOWN_AUTOMATION_LOGINS = new Set([
|
|
10
|
+
...KNOWN_DOISTBOT_LOGINS,
|
|
11
|
+
'dependabot',
|
|
12
|
+
'dependabot[bot]',
|
|
13
|
+
'renovate',
|
|
14
|
+
'renovate[bot]',
|
|
15
|
+
]);
|
|
9
16
|
export function isKnownDoistbotLogin(login) {
|
|
10
17
|
if (!login) {
|
|
11
18
|
return false;
|
|
12
19
|
}
|
|
13
20
|
return KNOWN_DOISTBOT_LOGINS.has(login.toLowerCase());
|
|
14
21
|
}
|
|
22
|
+
export function isKnownAutomationLogin(login) {
|
|
23
|
+
if (!login) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return KNOWN_AUTOMATION_LOGINS.has(login.toLowerCase());
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Default per-attempt delays for retrying transient HTTP failures. */
|
|
2
|
+
export const HTTP_RETRY_DELAYS_MS = [250, 500, 1000];
|
|
3
|
+
/** True for HTTP errors that are typically worth retrying: 429 and any 5xx. */
|
|
4
|
+
function isTransientHttpError(error) {
|
|
5
|
+
const status = error?.status;
|
|
6
|
+
return typeof status === 'number' && (status === 429 || (status >= 500 && status < 600));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Retries `fn` on errors classified as retryable, sleeping between attempts.
|
|
10
|
+
* Total attempts = 1 + delaysMs.length. Throws immediately on a non-retryable
|
|
11
|
+
* error, or after the budget is exhausted (re-throwing the last error).
|
|
12
|
+
*
|
|
13
|
+
* Defaults match HTTP usage (429 / 5xx with exponential backoff). Pass
|
|
14
|
+
* `delaysMs` / `isRetryable` to retry against a different signal.
|
|
15
|
+
*/
|
|
16
|
+
export async function withRetry(fn, delaysMs = HTTP_RETRY_DELAYS_MS, isRetryable = isTransientHttpError) {
|
|
17
|
+
let attempt = 0;
|
|
18
|
+
while (true) {
|
|
19
|
+
try {
|
|
20
|
+
return await fn();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (!isRetryable(error) || attempt >= delaysMs.length)
|
|
24
|
+
throw error;
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, delaysMs[attempt]));
|
|
26
|
+
attempt++;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -44,8 +44,8 @@ export function parseBaseContext() {
|
|
|
44
44
|
return {
|
|
45
45
|
owner: requiredEnv('REPO_OWNER'),
|
|
46
46
|
repo: requiredEnv('REPO_NAME'),
|
|
47
|
-
prNumber:
|
|
48
|
-
installationId:
|
|
47
|
+
prNumber: requiredPositiveInt('PR_NUMBER'),
|
|
48
|
+
installationId: requiredPositiveInt('INSTALLATION_ID'),
|
|
49
49
|
appId: requiredEnv('APP_ID'),
|
|
50
50
|
privateKey: requiredEnv('PRIVATE_KEY'),
|
|
51
51
|
githubToken: process.env.GITHUB_TOKEN?.trim() || undefined,
|
package/sandbox/dist/main.js
CHANGED
|
@@ -12,6 +12,9 @@ switch (taskType) {
|
|
|
12
12
|
case 'issue-fix-retry':
|
|
13
13
|
await import('./tasks/issue-fix-retry/fix-retry.js');
|
|
14
14
|
break;
|
|
15
|
+
case 'fix-loop-promotion':
|
|
16
|
+
await import('./tasks/issue-triage/fix-loop/promotion-task.js');
|
|
17
|
+
break;
|
|
15
18
|
case 'persist-logs': {
|
|
16
19
|
const { main } = await import('./tasks/persist-logs/persist-logs.js');
|
|
17
20
|
await main();
|
|
@@ -2,8 +2,9 @@ import { execFileSync } from 'child_process';
|
|
|
2
2
|
import { logger } from '../../core/logging.js';
|
|
3
3
|
import { fillPromptTemplate, loadPromptTemplateFile } from '../../core/prompt-template.js';
|
|
4
4
|
import { validateDiff } from './diff-validator.js';
|
|
5
|
+
import { CODEX_CANNOT_FIX_REASON, FIX_LOOP_FAILED_REASON_PREFIX } from './fix-failure-reasons.js';
|
|
5
6
|
import { CX_HERO_MANUAL_HANDLE, resolveHeroGroup } from './hero-group-map.js';
|
|
6
|
-
import { HERO_AUTOFIX_MARKER } from './markers.js';
|
|
7
|
+
import { AUTOFIX_FAILURE_MARKER, FIX_LOOP_START_MARKER, HERO_AUTOFIX_MARKER } from './markers.js';
|
|
7
8
|
import { invokeTriageModel } from './model.js';
|
|
8
9
|
import { commitAndPushFix, findPRTemplate, openDraftFixPR } from './pr-creator.js';
|
|
9
10
|
const FIX_PROMPT_FILE = 'issue-triage-fix-prompt.md';
|
|
@@ -18,6 +19,9 @@ export function shouldAttemptFix(assessment) {
|
|
|
18
19
|
}
|
|
19
20
|
return assessment.confidence === 'high' || assessment.confidence === 'medium';
|
|
20
21
|
}
|
|
22
|
+
export function buildFixAssessmentSkipReason(assessment) {
|
|
23
|
+
return `Assessment: ${assessment.classification} (confidence: ${assessment.confidence})`;
|
|
24
|
+
}
|
|
21
25
|
function loadFixPromptTemplate() {
|
|
22
26
|
return loadPromptTemplateFile({
|
|
23
27
|
fileName: FIX_PROMPT_FILE,
|
|
@@ -160,6 +164,110 @@ export function buildFollowUpCommentBody({ prUrl, targetRepoFullName, fixAssessm
|
|
|
160
164
|
`The PR targets \`${targetRepoFullName}\`. Please review it carefully before merging.`,
|
|
161
165
|
].join('\n');
|
|
162
166
|
}
|
|
167
|
+
function isNonRetryableFailure(reason) {
|
|
168
|
+
return reason.includes(CODEX_CANNOT_FIX_REASON);
|
|
169
|
+
}
|
|
170
|
+
async function findSourceIssueCommentByMarker({ octokit, ctx, marker, }) {
|
|
171
|
+
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
|
|
172
|
+
owner: ctx.owner,
|
|
173
|
+
repo: ctx.repo,
|
|
174
|
+
issue_number: ctx.issueNumber,
|
|
175
|
+
per_page: 100,
|
|
176
|
+
});
|
|
177
|
+
return comments.find((c) => typeof c.body === 'string' && c.body.includes(marker));
|
|
178
|
+
}
|
|
179
|
+
export async function shouldPostAutoFixFailureComment(fixResult, { dryRun, octokit, ctx }) {
|
|
180
|
+
if (dryRun)
|
|
181
|
+
return false;
|
|
182
|
+
if (!fixResult.attempted)
|
|
183
|
+
return false;
|
|
184
|
+
if (fixResult.prCreated)
|
|
185
|
+
return false;
|
|
186
|
+
// A PR opened during the loop is already announced via FIX_LOOP_START_MARKER;
|
|
187
|
+
// a "didn't produce a fix PR" comment would be misleading.
|
|
188
|
+
if (fixResult.prNumber !== undefined)
|
|
189
|
+
return false;
|
|
190
|
+
if (isNonRetryableFailure(fixResult.reason))
|
|
191
|
+
return false;
|
|
192
|
+
if (fixResult.reason.startsWith(FIX_LOOP_FAILED_REASON_PREFIX)) {
|
|
193
|
+
try {
|
|
194
|
+
const hasFixLoopStartComment = await sourceIssueHasFixLoopStartComment({ octokit, ctx });
|
|
195
|
+
if (hasFixLoopStartComment) {
|
|
196
|
+
logger.info('Auto-fix failure comment skipped', {
|
|
197
|
+
task: 'issue-triage',
|
|
198
|
+
issue_number: ctx.issueNumber,
|
|
199
|
+
reason: 'fix-loop start comment exists',
|
|
200
|
+
});
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
logger.warn('Failed to check fix-loop start comment', {
|
|
206
|
+
task: 'issue-triage',
|
|
207
|
+
issue_number: ctx.issueNumber,
|
|
208
|
+
error: error instanceof Error ? error.message : String(error),
|
|
209
|
+
});
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
export async function sourceIssueHasFixLoopStartComment({ octokit, ctx, }) {
|
|
216
|
+
const comment = await findSourceIssueCommentByMarker({
|
|
217
|
+
octokit,
|
|
218
|
+
ctx,
|
|
219
|
+
marker: FIX_LOOP_START_MARKER,
|
|
220
|
+
});
|
|
221
|
+
return comment !== undefined;
|
|
222
|
+
}
|
|
223
|
+
export function buildAutoFixFailureCommentBody({ reason, targetRepoFullName, }) {
|
|
224
|
+
const repoNote = targetRepoFullName ? ` in \`${targetRepoFullName}\`` : '';
|
|
225
|
+
return [
|
|
226
|
+
AUTOFIX_FAILURE_MARKER,
|
|
227
|
+
'',
|
|
228
|
+
'### Automated fix attempt failed',
|
|
229
|
+
'',
|
|
230
|
+
`I attempted an automated fix but couldn't open a fix PR${repoNote}.`,
|
|
231
|
+
'',
|
|
232
|
+
'**Reason:**',
|
|
233
|
+
'',
|
|
234
|
+
'```',
|
|
235
|
+
reason,
|
|
236
|
+
'```',
|
|
237
|
+
'',
|
|
238
|
+
'You can retry by commenting `@doistbot /fix`.',
|
|
239
|
+
].join('\n');
|
|
240
|
+
}
|
|
241
|
+
export async function postAutoFixFailureComment({ octokit, ctx, reason, targetRepoFullName, }) {
|
|
242
|
+
const body = buildAutoFixFailureCommentBody({ reason, targetRepoFullName });
|
|
243
|
+
const existing = await findSourceIssueCommentByMarker({
|
|
244
|
+
octokit,
|
|
245
|
+
ctx,
|
|
246
|
+
marker: AUTOFIX_FAILURE_MARKER,
|
|
247
|
+
});
|
|
248
|
+
if (existing) {
|
|
249
|
+
await octokit.rest.issues.updateComment({
|
|
250
|
+
owner: ctx.owner,
|
|
251
|
+
repo: ctx.repo,
|
|
252
|
+
comment_id: existing.id,
|
|
253
|
+
body,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
await octokit.rest.issues.createComment({
|
|
258
|
+
owner: ctx.owner,
|
|
259
|
+
repo: ctx.repo,
|
|
260
|
+
issue_number: ctx.issueNumber,
|
|
261
|
+
body,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
logger.info('Auto-fix failure comment posted', {
|
|
265
|
+
task: 'issue-triage',
|
|
266
|
+
issue_number: ctx.issueNumber,
|
|
267
|
+
target_repo: targetRepoFullName,
|
|
268
|
+
operation: existing ? 'update' : 'create',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
163
271
|
async function postFollowUpComment({ octokit, ctx, prUrl, prNumber, targetRepo, fixAssessment, }) {
|
|
164
272
|
const heroGroup = resolveHeroGroup([], targetRepo);
|
|
165
273
|
if (!heroGroup) {
|
|
@@ -193,6 +301,18 @@ async function postFollowUpComment({ octokit, ctx, prUrl, prNumber, targetRepo,
|
|
|
193
301
|
}
|
|
194
302
|
export async function runFixCore(input) {
|
|
195
303
|
const { ctx, triageOutput, fixAssessment, targetRepo, resetTo = 'HEAD' } = input;
|
|
304
|
+
if (!input.force && !shouldAttemptFix(fixAssessment)) {
|
|
305
|
+
logger.info('Fix core skipped: assessment does not qualify', {
|
|
306
|
+
task: 'issue-triage',
|
|
307
|
+
issue_number: ctx.issueNumber,
|
|
308
|
+
classification: fixAssessment.classification,
|
|
309
|
+
confidence: fixAssessment.confidence,
|
|
310
|
+
});
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
reason: buildFixAssessmentSkipReason(fixAssessment),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
196
316
|
// Reset repo to clean state before fix attempt (triage Codex may have modified files).
|
|
197
317
|
// Use `git reset --hard` to clear both the working tree and the index, since
|
|
198
318
|
// captureGitDiff stages files with `git add -A`.
|
|
@@ -277,7 +397,7 @@ export async function runFixCore(input) {
|
|
|
277
397
|
issueNumber: ctx.issueNumber,
|
|
278
398
|
output: fixDescription.slice(0, 500),
|
|
279
399
|
});
|
|
280
|
-
return { ok: false, reason:
|
|
400
|
+
return { ok: false, reason: CODEX_CANNOT_FIX_REASON };
|
|
281
401
|
}
|
|
282
402
|
// Capture the diff
|
|
283
403
|
const diff = captureGitDiff(targetRepo.localPath);
|
|
@@ -341,7 +461,7 @@ export async function attemptFix(input) {
|
|
|
341
461
|
return {
|
|
342
462
|
attempted: false,
|
|
343
463
|
repository: fallbackRepository,
|
|
344
|
-
reason:
|
|
464
|
+
reason: buildFixAssessmentSkipReason(fixAssessment),
|
|
345
465
|
};
|
|
346
466
|
}
|
|
347
467
|
if (clonedRepos.length === 0) {
|
|
@@ -378,6 +498,7 @@ export async function attemptFix(input) {
|
|
|
378
498
|
issueTitle: input.issueTitle,
|
|
379
499
|
issueUrl: input.issueUrl,
|
|
380
500
|
ghToken: input.ghToken,
|
|
501
|
+
force,
|
|
381
502
|
});
|
|
382
503
|
if (!coreResult.ok) {
|
|
383
504
|
return {
|
|
@@ -401,6 +522,7 @@ export async function attemptFix(input) {
|
|
|
401
522
|
reason: 'Dry run — PR not created.',
|
|
402
523
|
};
|
|
403
524
|
}
|
|
525
|
+
let openedPr;
|
|
404
526
|
try {
|
|
405
527
|
const { branch } = await commitAndPushFix({
|
|
406
528
|
repoOwner: targetRepo.repository.owner,
|
|
@@ -411,7 +533,7 @@ export async function attemptFix(input) {
|
|
|
411
533
|
issueTitle: input.issueTitle,
|
|
412
534
|
prTitle: coreResult.generatedPRTitle ?? undefined,
|
|
413
535
|
});
|
|
414
|
-
|
|
536
|
+
openedPr = await openDraftFixPR({
|
|
415
537
|
repoOwner: targetRepo.repository.owner,
|
|
416
538
|
repoName: targetRepo.repository.repo,
|
|
417
539
|
octokit,
|
|
@@ -429,38 +551,43 @@ export async function attemptFix(input) {
|
|
|
429
551
|
await postFollowUpComment({
|
|
430
552
|
octokit,
|
|
431
553
|
ctx,
|
|
432
|
-
prUrl:
|
|
433
|
-
prNumber:
|
|
554
|
+
prUrl: openedPr.prUrl,
|
|
555
|
+
prNumber: openedPr.prNumber,
|
|
434
556
|
targetRepo: targetRepo.repository,
|
|
435
557
|
fixAssessment,
|
|
436
558
|
});
|
|
437
559
|
logger.info('Fix attempt completed successfully', {
|
|
438
560
|
task: 'issue-triage',
|
|
439
561
|
issueNumber: ctx.issueNumber,
|
|
440
|
-
prUrl:
|
|
441
|
-
prNumber:
|
|
562
|
+
prUrl: openedPr.prUrl,
|
|
563
|
+
prNumber: openedPr.prNumber,
|
|
442
564
|
durationMs: Date.now() - startedAt,
|
|
443
565
|
});
|
|
444
566
|
return {
|
|
445
567
|
attempted: true,
|
|
446
568
|
repository: targetRepo.repository.fullName,
|
|
447
569
|
prCreated: true,
|
|
448
|
-
prUrl:
|
|
449
|
-
prNumber:
|
|
570
|
+
prUrl: openedPr.prUrl,
|
|
571
|
+
prNumber: openedPr.prNumber,
|
|
450
572
|
};
|
|
451
573
|
}
|
|
452
574
|
catch (error) {
|
|
453
|
-
|
|
575
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
576
|
+
logger.error(openedPr ? 'Fix PR follow-up failed' : 'Fix PR creation failed', {
|
|
454
577
|
task: 'issue-triage',
|
|
455
578
|
issueNumber: ctx.issueNumber,
|
|
456
|
-
error:
|
|
579
|
+
error: reason,
|
|
580
|
+
...(openedPr ? { pr_number: openedPr.prNumber, pr_url: openedPr.prUrl } : {}),
|
|
457
581
|
durationMs: Date.now() - startedAt,
|
|
458
582
|
});
|
|
459
583
|
return {
|
|
460
584
|
attempted: true,
|
|
461
585
|
repository: targetRepo.repository.fullName,
|
|
462
586
|
prCreated: false,
|
|
463
|
-
reason:
|
|
587
|
+
reason: openedPr
|
|
588
|
+
? `PR follow-up failed after opening PR: ${reason}`
|
|
589
|
+
: `PR creation failed: ${reason}`,
|
|
590
|
+
...(openedPr ? { prNumber: openedPr.prNumber } : {}),
|
|
464
591
|
};
|
|
465
592
|
}
|
|
466
593
|
}
|