@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
@@ -1,4 +1,9 @@
1
- const REVIEW_USAGE_COMMANDS = ['doistbot review [staged]', 'db review [staged]'];
1
+ const REVIEW_USAGE_COMMANDS = [
2
+ 'doistbot review [--base <ref>]',
3
+ 'doistbot review staged',
4
+ 'db review [--base <ref>]',
5
+ 'db review staged',
6
+ ];
2
7
  const REVIEW_SETUP_COMMANDS = [
3
8
  'doistbot review',
4
9
  'doistbot review staged',
@@ -1,8 +1,8 @@
1
1
  import { getApiKeys } from '../config.js';
2
2
  import { getRepositoryRoot, loadSandboxFormatModule, loadSandboxReviewModule } from '../runtime.js';
3
3
  import { formatReviewProgress, renderAlert, renderMarkdown, withSpinner, writeLine, } from '../terminal.js';
4
- import { formatReviewUsage } from './review-usage.js';
5
4
  const LOCAL_REVIEW_SOURCE_STAGED = 'staged';
5
+ const JSON_SCHEMA_VERSION = 1;
6
6
  const PROVIDER_DISPLAY_NAMES = {
7
7
  gemini: 'Gemini',
8
8
  openai: 'OpenAI Codex',
@@ -42,14 +42,17 @@ function getReadableProviderFailure(failure) {
42
42
  ?.trim();
43
43
  return `${PROVIDER_DISPLAY_NAMES[failure.model]} failed: ${firstLine || 'unknown error'}`;
44
44
  }
45
- function getReadableProviderFailures(failures) {
45
+ function getReadableProviderFailureEntries(failures) {
46
46
  const messagesByModel = new Map();
47
47
  for (const failure of failures) {
48
48
  if (!messagesByModel.has(failure.model)) {
49
49
  messagesByModel.set(failure.model, getReadableProviderFailure(failure));
50
50
  }
51
51
  }
52
- return [...messagesByModel.values()];
52
+ return Array.from(messagesByModel, ([model, message]) => ({ model, message }));
53
+ }
54
+ function getReadableProviderFailures(failures) {
55
+ return getReadableProviderFailureEntries(failures).map(({ message }) => message);
53
56
  }
54
57
  function formatProviderFailureMessage(failures) {
55
58
  const messages = getReadableProviderFailures(failures);
@@ -87,19 +90,126 @@ function handleSkippedReview(result) {
87
90
  function formatReviewOutput({ formatReviewForTerminal, review, }) {
88
91
  return formatReviewForTerminal({ review });
89
92
  }
90
- export async function runReview(sourceArg) {
91
- if (sourceArg !== undefined && sourceArg !== LOCAL_REVIEW_SOURCE_STAGED) {
92
- throw new Error(formatReviewUsage());
93
+ function getJsonStatus(result) {
94
+ function mapping(statusMapping, canTrustCleanReview = statusMapping.status === 'reviewed') {
95
+ return { mapping: statusMapping, canTrustCleanReview };
96
+ }
97
+ if (result.reviewResult.status === 'skipped') {
98
+ switch (result.reviewResult.reason) {
99
+ case 'empty_review':
100
+ case 'no_models':
101
+ case 'no_review':
102
+ return mapping({
103
+ status: 'unverified',
104
+ verified: false,
105
+ unverifiedReason: result.reviewResult.reason,
106
+ }, false);
107
+ default:
108
+ return mapping({
109
+ status: 'skipped',
110
+ verified: false,
111
+ skipReason: result.reviewResult.reason,
112
+ }, false);
113
+ }
114
+ }
115
+ return mapping({ status: 'reviewed', verified: true });
116
+ }
117
+ function finalizeJsonStatus({ initialStatus, findingCount, providerFailureCount, }) {
118
+ if (initialStatus.canTrustCleanReview && providerFailureCount > 0 && findingCount === 0) {
119
+ return { status: 'unverified', verified: false, unverifiedReason: 'provider_failures' };
93
120
  }
94
- const source = sourceArg === LOCAL_REVIEW_SOURCE_STAGED ? LOCAL_REVIEW_SOURCE_STAGED : 'base';
121
+ return initialStatus.mapping;
122
+ }
123
+ function getReviewForJson(result) {
124
+ if (result.reviewResult.status === 'reviewed') {
125
+ return result.reviewResult.review;
126
+ }
127
+ return { summary: '', comments: [] };
128
+ }
129
+ function hasLocation(finding) {
130
+ return typeof finding.path === 'string' && typeof finding.line === 'number';
131
+ }
132
+ function formatReviewJsonFinding(finding) {
133
+ return {
134
+ body: finding.body,
135
+ severity: finding.severity,
136
+ ...(hasLocation(finding)
137
+ ? {
138
+ location: {
139
+ path: finding.path,
140
+ line: finding.line,
141
+ ...(finding.side ? { side: finding.side } : {}),
142
+ },
143
+ }
144
+ : {}),
145
+ };
146
+ }
147
+ function getReviewFindingsForJson(review) {
148
+ if (review.findings) {
149
+ return review.findings;
150
+ }
151
+ return review.comments.map((comment) => ({
152
+ body: comment.body,
153
+ severity: null,
154
+ placement: 'inline',
155
+ path: comment.path,
156
+ line: comment.line,
157
+ side: comment.side,
158
+ }));
159
+ }
160
+ function getReviewFindingCount(review) {
161
+ return getReviewFindingsForJson(review).length;
162
+ }
163
+ function formatReviewForJson(result) {
164
+ const review = getReviewForJson(result);
165
+ const reviewFindings = getReviewFindingsForJson(review);
166
+ const findings = reviewFindings.map(formatReviewJsonFinding);
167
+ const statusMapping = finalizeJsonStatus({
168
+ initialStatus: getJsonStatus(result),
169
+ findingCount: findings.length,
170
+ providerFailureCount: result.reviewResult.providerFailures.length,
171
+ });
172
+ return {
173
+ schemaVersion: JSON_SCHEMA_VERSION,
174
+ ...statusMapping,
175
+ reviewSource: result.source,
176
+ repository: {
177
+ owner: result.owner,
178
+ repo: result.repo,
179
+ branch: result.branch,
180
+ baseBranch: result.baseBranch,
181
+ },
182
+ review: {
183
+ count: findings.length,
184
+ findings,
185
+ },
186
+ providerFailures: getReadableProviderFailureEntries(result.reviewResult.providerFailures),
187
+ };
188
+ }
189
+ async function runLocalReviewForCli({ source, onProgress, }) {
95
190
  const reviewModule = await loadSandboxReviewModule();
96
- const result = await withSpinner(source === LOCAL_REVIEW_SOURCE_STAGED
97
- ? 'Reviewing staged changes...'
98
- : 'Reviewing local changes...', async (status) => await reviewModule.runLocalReview({
191
+ return await reviewModule.runLocalReview({
99
192
  workspaceDir: getRepositoryRoot(),
100
193
  source,
101
194
  apiKeys: await getApiKeys(),
102
195
  suppressLogs: true,
196
+ onProgress,
197
+ });
198
+ }
199
+ export async function runReview({ source, json = false } = { source: 'base' }) {
200
+ if (json) {
201
+ const result = await runLocalReviewForCli({ source });
202
+ const output = formatReviewForJson(result);
203
+ writeLine(JSON.stringify(output, null, 2));
204
+ if (output.status === 'unverified') {
205
+ process.exitCode = 1;
206
+ }
207
+ return;
208
+ }
209
+ const result = await withSpinner(source === LOCAL_REVIEW_SOURCE_STAGED
210
+ ? 'Reviewing staged changes...'
211
+ : 'Reviewing local changes...', async (status) => await runLocalReviewForCli({
212
+ source,
103
213
  onProgress: status.enabled
104
214
  ? (event) => status.update(formatReviewProgress(event))
105
215
  : undefined,
@@ -110,7 +220,7 @@ export async function runReview(sourceArg) {
110
220
  }
111
221
  const formatModule = await loadSandboxFormatModule();
112
222
  if (result.reviewResult.providerFailures.length > 0 &&
113
- result.reviewResult.review.comments.length === 0) {
223
+ getReviewFindingCount(result.reviewResult.review) === 0) {
114
224
  throw new Error(formatProviderFailureMessage(result.reviewResult.providerFailures));
115
225
  }
116
226
  const output = formatReviewOutput({
@@ -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 DoistBot CLI prerequisites...');
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,10 +2,11 @@
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';
9
+ import { formatReviewUsage } from './actions/review-usage.js';
9
10
  import { runSetup } from './actions/setup.js';
10
11
  import { writeErrorLine } from './terminal.js';
11
12
  import { notifyAndRefreshCliUpdateCache, notifyIfCliUpdateAvailable } from './update-check.js';
@@ -21,9 +22,25 @@ function getPackageVersion() {
21
22
  }
22
23
  }
23
24
  const PACKAGE_VERSION = getPackageVersion();
25
+ const LOCAL_REVIEW_SOURCE_STAGED = 'staged';
24
26
  function getUpdateCacheEntrypointPath() {
25
27
  return fileURLToPath(new URL('./update-cache-entrypoint.js', import.meta.url));
26
28
  }
29
+ function parseReviewInput(sourceArg, options = {}) {
30
+ if (sourceArg !== undefined && sourceArg !== LOCAL_REVIEW_SOURCE_STAGED) {
31
+ throw new Error(formatReviewUsage());
32
+ }
33
+ if (sourceArg === LOCAL_REVIEW_SOURCE_STAGED && options.base) {
34
+ throw new Error(formatReviewUsage());
35
+ }
36
+ if (options.base !== undefined) {
37
+ return { source: { kind: 'base-ref', ref: options.base }, json: options.json === true };
38
+ }
39
+ return {
40
+ source: sourceArg === LOCAL_REVIEW_SOURCE_STAGED ? LOCAL_REVIEW_SOURCE_STAGED : 'base',
41
+ json: options.json === true,
42
+ };
43
+ }
27
44
  export function createProgram({ runAuthLogout: authLogout = runAuthLogout, runAuthSetup: authSetup = runAuthSetup, runAuthStatus: authStatus = runAuthStatus, runDoctor: doctor = runDoctor, runSetup: setup = runSetup, runReview: review = runReview, runAutomaticUpdateCheck: automaticUpdateCheck = async () => await notifyAndRefreshCliUpdateCache({
28
45
  currentVersion: PACKAGE_VERSION,
29
46
  updateCacheEntrypointPath: getUpdateCacheEntrypointPath(),
@@ -34,18 +51,27 @@ export function createProgram({ runAuthLogout: authLogout = runAuthLogout, runAu
34
51
  automaticUpdateCommands.add(command);
35
52
  return command;
36
53
  }
54
+ function shouldRunAutomaticUpdateCheck(actionCommand) {
55
+ if (!automaticUpdateCommands.has(actionCommand)) {
56
+ return false;
57
+ }
58
+ if (actionCommand.name() === 'review' && actionCommand.opts().json) {
59
+ return false;
60
+ }
61
+ return true;
62
+ }
37
63
  program
38
64
  .name('doistbot')
39
- .description('DoistBot local self-review CLI')
65
+ .description('Doistbot local self-review CLI')
40
66
  .version(PACKAGE_VERSION)
41
67
  .hook('postAction', async (_thisCommand, actionCommand) => {
42
- if (automaticUpdateCommands.has(actionCommand)) {
68
+ if (shouldRunAutomaticUpdateCheck(actionCommand)) {
43
69
  await automaticUpdateCheck();
44
70
  }
45
71
  });
46
72
  withAutomaticUpdateCheck(program
47
73
  .command('doctor')
48
- .description('Diagnose local DoistBot CLI setup')
74
+ .description('Diagnose local Doistbot CLI setup')
49
75
  .action(async () => await doctor()));
50
76
  withAutomaticUpdateCheck(program
51
77
  .command('setup')
@@ -53,7 +79,7 @@ export function createProgram({ runAuthLogout: authLogout = runAuthLogout, runAu
53
79
  .action(async () => await setup()));
54
80
  const authCommand = withAutomaticUpdateCheck(program
55
81
  .command('auth')
56
- .description('Authenticate DoistBot CLI with local review providers')
82
+ .description('Authenticate Doistbot CLI with local review providers')
57
83
  .action(async () => await authSetup()));
58
84
  withAutomaticUpdateCheck(authCommand
59
85
  .command('status')
@@ -65,15 +91,17 @@ export function createProgram({ runAuthLogout: authLogout = runAuthLogout, runAu
65
91
  .action(async () => await authSetup()));
66
92
  withAutomaticUpdateCheck(authCommand
67
93
  .command('logout [provider]')
68
- .description(`Clear stored DoistBot CLI auth for ${AUTH_LOGOUT_PROVIDER_USAGE}`)
94
+ .description(`Clear stored Doistbot CLI auth for ${AUTH_LOGOUT_PROVIDER_USAGE}`)
69
95
  .action(async (provider) => await authLogout(provider)));
70
96
  withAutomaticUpdateCheck(program
71
97
  .command('review [source]')
72
98
  .description('Run a local self-review. Use `staged` to review only staged changes.')
73
- .action(async (source) => await review(source)));
99
+ .option('--base <ref>', 'Review local changes against a specific base ref')
100
+ .addOption(new Option('--json', 'Print machine-readable JSON instead of terminal-formatted markdown').hideHelp())
101
+ .action(async (source, options) => await review(parseReviewInput(source, options))));
74
102
  program
75
103
  .command('update')
76
- .description('Check whether a newer DoistBot CLI version is available')
104
+ .description('Check whether a newer Doistbot CLI version is available')
77
105
  .action(async () => await updateCheck());
78
106
  return program;
79
107
  }
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 DoistBot CLI package.');
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 DoistBot CLI package.');
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 DoistBot CLI package' };
92
+ : { ok: false, message: 'rebuild or reinstall the Doistbot CLI package' };
93
93
  }
94
94
  export function checkPiDependency() {
95
95
  try {
@@ -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 DoistBot CLI updates.');
41
+ write('Unable to check for Doistbot CLI updates.');
42
42
  return;
43
43
  }
44
44
  if (!isNewerVersion(latestVersion, currentVersion)) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@doist/doistbot-cli",
3
- "version": "1.0.2",
4
- "description": "Local self-review CLI for DoistBot",
3
+ "version": "1.0.4",
4
+ "description": "Local self-review CLI for Doistbot",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -28,6 +28,7 @@ export type RepoConfig = {
28
28
  issue_triage?: {
29
29
  enable_auto_triage?: boolean;
30
30
  enable_auto_fix?: boolean;
31
+ use_fix_loop?: boolean;
31
32
  max_changed_files?: number;
32
33
  max_changed_lines?: number;
33
34
  };
@@ -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: Number(requiredEnv('PR_NUMBER')),
48
- installationId: Number(requiredEnv('INSTALLATION_ID')),
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,
@@ -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: 'Codex determined fix cannot be applied automatically.' };
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: `Assessment: ${fixAssessment.classification} (confidence: ${fixAssessment.confidence})`,
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
- const prResult = await openDraftFixPR({
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: prResult.prUrl,
433
- prNumber: prResult.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: prResult.prUrl,
441
- prNumber: prResult.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: prResult.prUrl,
449
- prNumber: prResult.prNumber,
570
+ prUrl: openedPr.prUrl,
571
+ prNumber: openedPr.prNumber,
450
572
  };
451
573
  }
452
574
  catch (error) {
453
- logger.error('Fix PR creation failed', {
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: error instanceof Error ? error.message : String(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: `PR creation failed: ${error instanceof Error ? error.message : String(error)}`,
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
  }