@doist/doistbot-cli 1.0.13 → 1.0.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/doistbot-cli",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "Local self-review CLI for Doistbot",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -40,8 +40,8 @@
40
40
  "test": "vitest run src scripts --config ../../config/vitest.base.ts"
41
41
  },
42
42
  "dependencies": {
43
- "@earendil-works/pi-ai": "0.85.1",
44
- "@earendil-works/pi-coding-agent": "0.85.1",
43
+ "@earendil-works/pi-ai": "0.87.1",
44
+ "@earendil-works/pi-coding-agent": "0.87.1",
45
45
  "@inquirer/checkbox": "5.2.5",
46
46
  "@inquirer/password": "5.2.2",
47
47
  "@napi-rs/keyring": "1.3.0",
@@ -54,7 +54,7 @@ export const DEFAULT_PI_MODELS = {
54
54
  gemini: 'gemini-3.1-pro-preview',
55
55
  grok: 'grok-4.6',
56
56
  zai: 'glm5.2',
57
- openai: 'gpt-5.6-terra',
57
+ openai: 'gpt-6-sol',
58
58
  };
59
59
  export const DEFAULT_OPENROUTER_MODELS = {
60
60
  deepseek: 'deepseek/deepseek-v4-pro-0813',
@@ -1 +1 @@
1
- export { APP_LABEL_ALIASES, APP_TO_ROUTE_SELECTORS, findIntegrationRoute, findIntegrationRoutes, findIssueRoute, findRepositoryRoutes, findRepositoryTarget, INTEGRATION_LABEL_ALIASES, INTEGRATION_ROUTES, PRODUCT_LABEL_ALIASES, PRODUCT_ROUTES, RESOLUTION_PRECEDENCE, TEAM_LABEL_ALIASES, TEAM_TO_ROUTE_SELECTORS, } from 'doistbot-issue-routing';
1
+ export { APP_LABEL_ALIASES, APP_TO_ROUTE_SELECTORS, findIntegrationRoute, findIntegrationRoutes, findIssueRoute, findOwningRoutes, findRepositoryRoutes, findRepositoryTarget, INTEGRATION_LABEL_ALIASES, INTEGRATION_ROUTES, PRODUCT_LABEL_ALIASES, PRODUCT_ROUTES, RESOLUTION_PRECEDENCE, TEAM_LABEL_ALIASES, TEAM_TO_ROUTE_SELECTORS, } from 'doistbot-issue-routing';
@@ -13,7 +13,7 @@ const CHAT_PROMPT_FILE = 'chat-prompt.md';
13
13
  const BRO_PROMPT_FILE = 'bro-prompt.md';
14
14
  const CHAT_TIMEOUT_MS = 5 * 60 * 1000;
15
15
  const CHAT_PROVIDER = 'openai';
16
- const CHAT_MODEL = 'gpt-5.6-luna';
16
+ const CHAT_MODEL = 'gpt-6-luna';
17
17
  const CHAT_THINKING_LEVEL = 'high';
18
18
  const BRO_SYSTEM_PROMPT = 'You are Doistbot re-explaining one of your own review comments. Use only the source comment in the task prompt. Do not inspect files, call tools, or add facts. Output only the rewritten explanation.';
19
19
  const EMPTY_BRO_RESPONSE = "There's no earlier Doistbot comment in this review thread for me to simplify, bro.";
@@ -10,7 +10,7 @@ import { parseIssueTriageContext } from '../issue-triage/context.js';
10
10
  import { attemptFix } from '../issue-triage/fix-attempt.js';
11
11
  import { parseFixAssessment } from '../issue-triage/output.js';
12
12
  import { cloneRepository, createGitHubClients, extractLabelNames, fetchIssue, fetchIssueComments, getIssueUrl, prepareWorkspaceReposDirectory, TRIAGE_MARKER, } from '../issue-triage/shared.js';
13
- async function main() {
13
+ export async function main() {
14
14
  const startedAt = performance.now();
15
15
  const ctx = parseIssueTriageContext();
16
16
  let exitCode = 0;
@@ -184,6 +184,10 @@ async function main() {
184
184
  issueUrl: issue.html_url ?? getIssueUrl(ctx.owner, ctx.repo, ctx.issueNumber),
185
185
  ghToken: issueInstallationAuth.token,
186
186
  force: true,
187
+ assigneeReview: {
188
+ assignees: (issue.assignees ?? []).map((assignee) => assignee.login),
189
+ membershipOctokit: readOctokit,
190
+ },
187
191
  modelPhaseName: 'issue-fix-retry.fix-attempt.model',
188
192
  }));
189
193
  metricRepository = fixResult.repository;
@@ -0,0 +1,114 @@
1
+ import { isKnownDoistbotLogin } from '../../core/bot-logins.js';
2
+ import { logger } from '../../core/logging.js';
3
+ import { findOwningRoutes, findRepositoryTarget } from '../../core/repository-map.js';
4
+ import { withRetry } from '../../core/retry.js';
5
+ import { GITHUB_REQUEST_TIMEOUT_MS } from './github-request.js';
6
+ import { ASSIGNEE_REVIEW_MARKER } from './markers.js';
7
+ export async function resolveAssigneeReviewers({ owner, repo, assignees, membershipOctokit, }) {
8
+ const repository = findRepositoryTarget(owner, repo);
9
+ if (!repository || assignees.length === 0)
10
+ return [];
11
+ const routes = findOwningRoutes(repository);
12
+ const teams = [...new Set(routes.map((route) => route.ownerTeam))];
13
+ const reviewers = [];
14
+ const logins = [...new Set(assignees.map((login) => login.toLowerCase()))];
15
+ for (const username of logins) {
16
+ if (isKnownDoistbotLogin(username))
17
+ continue;
18
+ for (const teamSlug of teams) {
19
+ try {
20
+ const { data } = await withRetry(() => membershipOctokit.rest.teams.getMembershipForUserInOrg({
21
+ org: owner,
22
+ team_slug: teamSlug,
23
+ username,
24
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
25
+ }));
26
+ if (data.state === 'active') {
27
+ reviewers.push(username);
28
+ break;
29
+ }
30
+ }
31
+ catch (error) {
32
+ // A missing/invisible membership never qualifies an assignee.
33
+ if (error?.status !== 404) {
34
+ logger.warn('Unable to verify autofix assignee team membership', {
35
+ repository: `${owner}/${repo}`,
36
+ username,
37
+ team: teamSlug,
38
+ error: error instanceof Error ? error.message : String(error),
39
+ });
40
+ }
41
+ }
42
+ }
43
+ }
44
+ return reviewers;
45
+ }
46
+ /** Best-effort early review request. Failure must not lose an already opened PR. */
47
+ export async function requestAssigneeReview({ assigneeReview, ...pr }) {
48
+ if (!assigneeReview)
49
+ return [];
50
+ const reviewers = await resolveAssigneeReviewers({ ...pr, ...assigneeReview });
51
+ if (reviewers.length === 0)
52
+ return [];
53
+ try {
54
+ await withRetry(() => pr.octokit.rest.pulls.requestReviewers({
55
+ owner: pr.owner,
56
+ repo: pr.repo,
57
+ pull_number: pr.prNumber,
58
+ reviewers,
59
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
60
+ }));
61
+ }
62
+ catch (error) {
63
+ logger.warn('Autofix assignee review request failed; retaining Hero fallback', {
64
+ repository: `${pr.owner}/${pr.repo}`,
65
+ pr_number: pr.prNumber,
66
+ reviewers,
67
+ error: error instanceof Error ? error.message : String(error),
68
+ });
69
+ return [];
70
+ }
71
+ try {
72
+ // A bot-authored receipt on the target PR survives the separate promotion
73
+ // process, completed/removed review requests, and edits to the PR body.
74
+ // Only write it AFTER GitHub accepts the request, never for candidates.
75
+ await withRetry(() => pr.octokit.rest.issues.createComment({
76
+ owner: pr.owner,
77
+ repo: pr.repo,
78
+ issue_number: pr.prNumber,
79
+ body: [
80
+ ASSIGNEE_REVIEW_MARKER,
81
+ '',
82
+ `Requested review from issue assignees: ${reviewers.map((login) => `@${login}`).join(', ')}.`,
83
+ ].join('\n'),
84
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
85
+ }));
86
+ }
87
+ catch (error) {
88
+ logger.warn('Failed to record autofix assignee review request; Hero fallback remains armed', {
89
+ repository: `${pr.owner}/${pr.repo}`,
90
+ pr_number: pr.prNumber,
91
+ reviewers,
92
+ error: error instanceof Error ? error.message : String(error),
93
+ });
94
+ }
95
+ return reviewers;
96
+ }
97
+ export async function hasAssigneeReviewRequest(pr) {
98
+ return withRetry(async () => {
99
+ const pages = pr.octokit.paginate.iterator(pr.octokit.rest.issues.listComments, {
100
+ owner: pr.owner,
101
+ repo: pr.repo,
102
+ issue_number: pr.prNumber,
103
+ per_page: 100,
104
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
105
+ });
106
+ for await (const { data: comments } of pages) {
107
+ if (comments.some((comment) => isKnownDoistbotLogin(comment.user?.login) &&
108
+ comment.body?.split('\n')[0]?.trim() === ASSIGNEE_REVIEW_MARKER)) {
109
+ return true;
110
+ }
111
+ }
112
+ return false;
113
+ });
114
+ }
@@ -147,13 +147,15 @@ function captureGitDiff(repoLocalPath) {
147
147
  }
148
148
  }
149
149
  const DOISTBOT_DEVS_MENTION = '@Doist/doistbot-devs';
150
- export function buildFollowUpCommentBody({ prUrl, targetRepoFullName, fixAssessment, heroMention, }) {
150
+ export function buildFollowUpCommentBody({ prUrl, targetRepoFullName, fixAssessment, heroMention, requestedAssignees = [], }) {
151
151
  const description = fixAssessment.classification === 'speculative'
152
- ? `I've created a draft PR with a speculative fix as a starting point: ${prUrl}. This likely needs refinement — see the PR description for confidence details.`
153
- : `I've created a draft PR with a proposed fix: ${prUrl}`;
154
- const introLine = heroMention
155
- ? `${heroMention} a doistbot autofix PR is ready for your review.`
156
- : `${CX_HERO_MANUAL_HANDLE} I couldn't resolve a Hero for \`${targetRepoFullName}\`. Could you please route this manually? cc ${DOISTBOT_DEVS_MENTION} to update the hero mapping so future autofix PRs can be assigned automatically.`;
152
+ ? `I've created a PR with a speculative fix as a starting point: ${prUrl}. This likely needs refinement — see the PR description for confidence details.`
153
+ : `I've created a PR with a proposed fix: ${prUrl}`;
154
+ const introLine = requestedAssignees.length > 0
155
+ ? `I've requested a review from ${requestedAssignees.map((login) => `@${login}`).join(', ')} on the autofix PR.`
156
+ : heroMention
157
+ ? `${heroMention} a doistbot autofix PR is ready for your review.`
158
+ : `${CX_HERO_MANUAL_HANDLE} I couldn't resolve a Hero for \`${targetRepoFullName}\`. Could you please route this manually? cc ${DOISTBOT_DEVS_MENTION} to update the hero mapping so future autofix PRs can be assigned automatically.`;
157
159
  return [
158
160
  HERO_AUTOFIX_MARKER,
159
161
  '',
@@ -282,9 +284,9 @@ export async function postAutoFixFailureComment({ octokit, ctx, reason, targetRe
282
284
  operation: existing ? 'update' : 'create',
283
285
  });
284
286
  }
285
- async function postFollowUpComment({ octokit, ctx, prUrl, prNumber, targetRepo, fixAssessment, }) {
286
- const heroGroup = resolveHeroGroup([], targetRepo);
287
- if (!heroGroup) {
287
+ async function postFollowUpComment({ octokit, ctx, prUrl, prNumber, targetRepo, fixAssessment, requestedAssignees = [], }) {
288
+ const heroGroup = requestedAssignees.length > 0 ? null : resolveHeroGroup([], targetRepo);
289
+ if (!heroGroup && requestedAssignees.length === 0) {
288
290
  logger.warn('No hero mapping found for autofix target repo', {
289
291
  task: 'issue-triage',
290
292
  issueNumber: ctx.issueNumber,
@@ -297,6 +299,7 @@ async function postFollowUpComment({ octokit, ctx, prUrl, prNumber, targetRepo,
297
299
  targetRepoFullName: targetRepo.fullName,
298
300
  fixAssessment,
299
301
  heroMention: heroGroup?.mention ?? null,
302
+ requestedAssignees,
300
303
  });
301
304
  await octokit.rest.issues.createComment({
302
305
  owner: ctx.owner,
@@ -389,7 +392,7 @@ export async function runFixCore(input) {
389
392
  prompt,
390
393
  apiKey: ctx.openaiApiKey,
391
394
  cwd: targetRepo.localPath,
392
- thinkingLevel: 'xhigh',
395
+ thinkingLevel: 'max',
393
396
  timeoutMs,
394
397
  extraEnv: {
395
398
  GH_TOKEN: input.ghToken,
@@ -438,7 +441,7 @@ export async function runFixCore(input) {
438
441
  return { ok: false, reason: 'Codex produced no file changes.' };
439
442
  }
440
443
  // Validate the diff — speculative fixes bypass the file-count limit since they
441
- // are draft PRs intended as starting points that may touch more files.
444
+ // are starting-point PRs that may touch more files.
442
445
  const diffLimits = fixAssessment.classification === 'speculative' ? { maxChangedFiles: Infinity } : undefined;
443
446
  const validation = validateDiff(diff, diffLimits);
444
447
  if (!validation.valid) {
@@ -577,6 +580,7 @@ export async function attemptFix(input) {
577
580
  prTitle: coreResult.generatedPRTitle ?? undefined,
578
581
  prBody: coreResult.generatedPRBody ?? undefined,
579
582
  fixAssessment,
583
+ assigneeReview: input.assigneeReview,
580
584
  });
581
585
  // Post follow-up comment on the issue
582
586
  await postFollowUpComment({
@@ -586,6 +590,7 @@ export async function attemptFix(input) {
586
590
  prNumber: openedPr.prNumber,
587
591
  targetRepo: targetRepo.repository,
588
592
  fixAssessment,
593
+ requestedAssignees: openedPr.requestedAssignees,
589
594
  });
590
595
  logger.info('Fix attempt completed successfully', {
591
596
  task: 'issue-triage',
@@ -52,6 +52,7 @@ function toAttemptFixInput(input) {
52
52
  issueTitle: input.issueTitle,
53
53
  issueUrl: input.issueUrl,
54
54
  ghToken: input.ghToken,
55
+ assigneeReview: input.assigneeReview,
55
56
  };
56
57
  }
57
58
  function stateLogMeta(state) {
@@ -184,6 +185,7 @@ export async function runConfiguredFix(input) {
184
185
  issueUrl: input.issueUrl,
185
186
  ghToken: input.ghToken,
186
187
  pushToken: input.ctx.githubToken,
188
+ assigneeReview: input.assigneeReview,
187
189
  apiKeys: {
188
190
  openai: input.ctx.openaiApiKey,
189
191
  ...(input.ctx.geminiApiKey !== undefined && { gemini: input.ctx.geminiApiKey }),
@@ -3,10 +3,12 @@ import { withRetry } from '../../../core/retry.js';
3
3
  import { renderPriorityBadge } from '../../review/github-comment-format.js';
4
4
  import { runLocalReview as defaultRunLocalReview } from '../../review/local.js';
5
5
  import { parseSeverity } from '../../review/severity.js';
6
+ import { hasAssigneeReviewRequest } from '../assignee-review.js';
6
7
  import { runFixCore as defaultRunFixCore, stripPRTitleSection } from '../fix-attempt.js';
7
8
  import { resolveHeroTeamSlug } from '../hero-group-map.js';
8
9
  import { FIX_LOOP_START_MARKER } from '../markers.js';
9
10
  import { commitAndPushFix as defaultCommitAndPushFix, commitAndPushIteration as defaultCommitAndPushIteration, openDraftFixPR as defaultOpenDraftFixPR, } from '../pr-creator.js';
11
+ import { markPullRequestReady } from '../pr-readiness.js';
10
12
  export function buildFixLoopStartCommentBody(args) {
11
13
  const p0 = renderPriorityBadge('P0') ?? 'P0';
12
14
  const p1 = renderPriorityBadge('P1') ?? 'P1';
@@ -15,9 +17,9 @@ export function buildFixLoopStartCommentBody(args) {
15
17
  '',
16
18
  '### Working on a fix',
17
19
  '',
18
- `I've opened a draft PR with an initial fix: ${args.prUrl}`,
20
+ `I've opened a PR with an initial fix: ${args.prUrl}`,
19
21
  '',
20
- `The PR targets \`${args.targetRepoFullName}\`. I'll review it locally, iterate on ${p0} and ${p1} findings, and run CI. Once everything passes, I'll promote the PR out of draft and request a hero review on it.`,
22
+ `The PR targets \`${args.targetRepoFullName}\`. I'll review it locally, iterate on ${p0} and ${p1} findings, and run CI. Once everything passes, I'll mark the PR ready if it is still a draft. If review hasn't already been requested from an issue assignee, I'll request a hero review then.`,
21
23
  ].join('\n');
22
24
  }
23
25
  const DRY_RUN_HEAD_SHA = '0'.repeat(40);
@@ -184,6 +186,7 @@ export class DefaultLoopEffects {
184
186
  ...(input.generatedPRTitle ? { prTitle: input.generatedPRTitle } : {}),
185
187
  ...(input.generatedPRBody ? { prBody: input.generatedPRBody } : {}),
186
188
  fixAssessment: session.fixAssessment,
189
+ assigneeReview: session.assigneeReview,
187
190
  });
188
191
  await this.postFixStartedSourceIssueComment({
189
192
  prUrl: prResult.prUrl,
@@ -396,25 +399,12 @@ export class DefaultLoopEffects {
396
399
  }));
397
400
  return;
398
401
  }
399
- // TODO: thread node_id through from the webhook handler's
400
- // fetchPRPromotionState so this second pulls.get goes away.
401
- const pr = await withRetry(() => this.session.octokit.rest.pulls.get({
402
+ await markPullRequestReady({
403
+ octokit: this.session.octokit,
402
404
  owner: this.session.targetRepo.repository.owner,
403
405
  repo: this.session.targetRepo.repository.repo,
404
- pull_number: prNumber,
405
- }));
406
- // Idempotency: markPullRequestReadyForReview throws
407
- // `UNPROCESSABLE: Pull request is not a draft` if the PR is already
408
- // ready. On webhook redelivery the catch block in runPromotionFlow
409
- // would re-arm awaiting-ci and loop on every subsequent CI event.
410
- if (!pr.data.draft) {
411
- return;
412
- }
413
- await withRetry(() => this.session.octokit.graphql(`mutation($pullRequestId: ID!) {
414
- markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {
415
- pullRequest { isDraft }
416
- }
417
- }`, { pullRequestId: pr.data.node_id }));
406
+ prNumber,
407
+ });
418
408
  }
419
409
  async applyAskLabel(prNumber) {
420
410
  if (this.opts.dryRun) {
@@ -454,6 +444,18 @@ export class DefaultLoopEffects {
454
444
  }));
455
445
  return;
456
446
  }
447
+ // Read the durable receipt on the target PR, not the source issue.
448
+ // Do not infer success from the current requested_reviewers array:
449
+ // completed reviews disappear from that array before CI may finish.
450
+ if (await hasAssigneeReviewRequest({
451
+ octokit: this.session.octokit,
452
+ owner: this.session.targetRepo.repository.owner,
453
+ repo: this.session.targetRepo.repository.repo,
454
+ prNumber,
455
+ })) {
456
+ logger.info('Skipping Hero review: assignee review was already requested', this.logContext({ pr_number: prNumber }));
457
+ return;
458
+ }
457
459
  await withRetry(() => this.session.octokit.rest.pulls.requestReviewers({
458
460
  owner: this.session.targetRepo.repository.owner,
459
461
  repo: this.session.targetRepo.repository.repo,
@@ -1,19 +1,22 @@
1
- import { iterationLabel, PROMOTION_COMMENT_MARKER } from 'doistbot-shared-contracts';
1
+ import { autofixFeedbackCommentMarker, iterationLabel } from 'doistbot-shared-contracts';
2
2
  import { logger } from '../../../core/logging.js';
3
3
  import { isFixLoopTerminal, isTerminal, next, } from './state-machine.js';
4
4
  const CI_FAILED_LABEL = 'doistbot:ci-failed';
5
5
  const NEEDS_HUMAN_LABEL = 'doistbot:needs-human';
6
6
  const AWAITING_CI_LABEL = 'doistbot:awaiting-ci';
7
- // Stamped on terminal comments so postIssueComment can find-or-update an
8
- // existing comment instead of double-posting when an earlier dispatch
9
- // succeeded on GitHub but the response timed out.
10
- //
11
- // The success and human-escalation paths use DISTINCT markers: merge tracking
12
- // reads 👍/👎 reactions on the promotion (success) comment to derive the thumbs
13
- // metric, so it must be uniquely identifiable. A shared marker would let a
14
- // CI-failed-then-human-merged bot PR expose its failure comment as the reaction
15
- // target instead.
16
- const PROMOTION_NEEDS_HUMAN_COMMENT_MARKER = '<!--doistbot-promotion-needs-human-comment-->';
7
+ // Each terminal comment carries its outcome in the marker so retries update
8
+ // the same reaction target and closure tracking can classify the result.
9
+ export const PR_FEEDBACK_FOOTER = '<sub>🤖🫵 Please, react 👍 or 👎 on this comment to rate this bot-authored PR.</sub>';
10
+ function withFeedbackFooter(body) {
11
+ return `${body}\n\n${PR_FEEDBACK_FOOTER}`;
12
+ }
13
+ async function postNeedsHumanFeedbackComment(effects, prNumber, body = 'This PR needs human review.') {
14
+ await effects.postIssueComment({
15
+ prNumber,
16
+ marker: autofixFeedbackCommentMarker('needs_human'),
17
+ body: withFeedbackFooter(body),
18
+ });
19
+ }
17
20
  function formatFailedChecks(checks) {
18
21
  return checks.map((c) => (c.url ? `- [${c.name}](${c.url})` : `- ${c.name}`)).join('\n');
19
22
  }
@@ -107,15 +110,20 @@ export async function runFixLoop(input) {
107
110
  if (state.kind === 'PUSHED_AWAITING_CI' &&
108
111
  state.iteration !== undefined &&
109
112
  state.promotionPolicy.kind === 'doistbot_fix_ci') {
110
- // Local review accepted the patch; hand off to CI. awaiting-ci is the
111
- // contract the webhook handler keys on, so it must NOT appear until
112
- // we reach this terminal — otherwise a fast CI suite can promote a
113
- // patch the bot is still reviewing (or about to iterate on).
113
+ // Local review accepted the patch; hand off to CI. awaiting-ci must
114
+ // NOT appear earlier: a fast CI suite could otherwise finalize checks
115
+ // for a patch the bot is still reviewing or about to iterate on.
116
+ // Assignee-reviewed PRs may already be ready, but still need this handoff.
117
+ // The iter-N label event also triggers reconciliation if CI already
118
+ // finished before awaiting-ci existed. Keep both labels in one call.
114
119
  await tryApplyLabels(effects, state.prNumber, [
115
120
  AWAITING_CI_LABEL,
116
121
  iterationLabel(state.iteration),
117
122
  ]);
118
123
  }
124
+ if (state.kind === 'DONE_NEEDS_HUMAN' && state.prNumber !== undefined) {
125
+ await postNeedsHumanFeedbackComment(effects, state.prNumber);
126
+ }
119
127
  return state;
120
128
  }
121
129
  async function tryApplyLabels(effects, prNumber, labels) {
@@ -123,7 +131,7 @@ async function tryApplyLabels(effects, prNumber, labels) {
123
131
  await effects.applyLabels({ prNumber, labels });
124
132
  }
125
133
  catch (error) {
126
- logger.error('fix-loop: failed to apply tracking labels to draft PR', {
134
+ logger.error('fix-loop: failed to apply tracking labels to PR', {
127
135
  task: 'fix-loop',
128
136
  pr_number: prNumber,
129
137
  labels,
@@ -170,22 +178,19 @@ export async function runPromotionFlow(effects, input) {
170
178
  return state;
171
179
  }
172
180
  if (state.kind === 'DONE_PROMOTED') {
173
- // Order matters: promoteToReady flips the PR out of draft and is
174
- // effectively irreversible. Run it LAST so any failure on the
175
- // reviewer/ask/comment steps re-arms awaiting-ci while the PR
176
- // is still a draft — the next delivery re-runs the full sequence
177
- // cleanly instead of seeing a half-promoted PR.
178
181
  await effects.requestHeroReview(input.prNumber);
179
182
  await effects.applyAskLabel(input.prNumber);
183
+ // Publish the successful validation outcome before making a
184
+ // remaining draft ready, so an immediate merge cannot beat the
185
+ // feedback comment and closure tracking.
180
186
  await effects.postIssueComment({
181
187
  prNumber: input.prNumber,
182
- marker: PROMOTION_COMMENT_MARKER,
183
- // This comment doubles as the thumbs-feedback prompt: merge
188
+ marker: autofixFeedbackCommentMarker('success'),
189
+ // This comment doubles as the thumbs-feedback prompt: closure
184
190
  // tracking reads 👍/👎 reactions on this exact comment (located
185
- // via the marker above) when the PR merges. Keep the reaction
191
+ // via the marker above) when the PR closes. Keep the reaction
186
192
  // ask on the comment people are invited to react to.
187
- body: 'CI passed — promoting this PR out of draft and requesting hero review.\n\n' +
188
- '<sub>🤖🫵 Please, react 👍 or 👎 on this comment to rate this bot-authored PR.</sub>',
193
+ body: withFeedbackFooter('CI passed. Review has been requested; any remaining draft status will be cleared.'),
189
194
  });
190
195
  await effects.promoteToReady(input.prNumber);
191
196
  return state;
@@ -200,11 +205,7 @@ export async function runPromotionFlow(effects, input) {
200
205
  : ciStatus.kind === 'no_checks'
201
206
  ? 'No CI checks ran on this PR — please verify manually before merging.'
202
207
  : 'This PR needs human review.';
203
- await effects.postIssueComment({
204
- prNumber: input.prNumber,
205
- marker: PROMOTION_NEEDS_HUMAN_COMMENT_MARKER,
206
- body,
207
- });
208
+ await postNeedsHumanFeedbackComment(effects, input.prNumber, body);
208
209
  return state;
209
210
  }
210
211
  if (!isTerminal(state)) {
@@ -0,0 +1,2 @@
1
+ /** Deadline for autofix reviewer routing and PR readiness requests. */
2
+ export const GITHUB_REQUEST_TIMEOUT_MS = 15_000;
@@ -1,5 +1,5 @@
1
1
  import { normalizeScopedLabelValue, scopedLabelValue } from '../../core/label-parsing.js';
2
- import { APP_LABEL_ALIASES, findIntegrationRoute, findIntegrationRoutes, findIssueRoute, findRepositoryRoutes, findRepositoryTarget as findRoutingRepositoryTarget, INTEGRATION_LABEL_ALIASES, PRODUCT_LABEL_ALIASES, PRODUCT_ROUTES, } from '../../core/repository-map.js';
2
+ import { APP_LABEL_ALIASES, findIntegrationRoute, findIssueRoute, findOwningRoutes, findRepositoryTarget as findRoutingRepositoryTarget, INTEGRATION_LABEL_ALIASES, PRODUCT_LABEL_ALIASES, PRODUCT_ROUTES, } from '../../core/repository-map.js';
3
3
  const HERO_GROUPS = {
4
4
  'android-hero': { key: 'android-hero', mention: '@Doist/android-hero' },
5
5
  'app-copy-hero': { key: 'app-copy-hero', mention: '@Doist/app-copy-hero' },
@@ -181,13 +181,7 @@ export function resolveHeroGroup(labels, primaryRepo) {
181
181
  return null;
182
182
  }
183
183
  if (primaryRepo) {
184
- // A repository carried by both a product route and an integration route —
185
- // todoist-macos owns the Safari extension, for example — keeps the product
186
- // route's hero, so integration routes only cover integration-only repos.
187
- const productRoutes = findRepositoryRoutes(primaryRepo);
188
- const repositoryHeroKeys = new Set(productRoutes.length > 0
189
- ? productRoutes.map((route) => route.heroTeam)
190
- : findIntegrationRoutes(primaryRepo).map((route) => route.heroTeam));
184
+ const repositoryHeroKeys = new Set(findOwningRoutes(primaryRepo).map((route) => route.heroTeam));
191
185
  if (repositoryHeroKeys.size === 1) {
192
186
  return HERO_GROUPS[repositoryHeroKeys.values().next().value];
193
187
  }
@@ -1,3 +1,4 @@
1
1
  export const HERO_AUTOFIX_MARKER = '<!--doistbot-autofix-followup-->';
2
+ export const ASSIGNEE_REVIEW_MARKER = '<!--doistbot-assignee-review-requested-->';
2
3
  export const FIX_LOOP_START_MARKER = '<!--doistbot-fixloop-start-->';
3
4
  export const AUTOFIX_FAILURE_MARKER = '<!--doistbot-autofix-failure-->';
@@ -4,6 +4,10 @@ import path from 'path';
4
4
  import { DOISTBOT_AUTO_FIX_LABEL, DOISTBOT_SPECULATIVE_FIX_LABEL } from 'doistbot-shared-contracts';
5
5
  import { logger } from '../../core/logging.js';
6
6
  import { fillPromptTemplate, loadPromptTemplateFile, resolvePromptFile, } from '../../core/prompt-template.js';
7
+ import { withRetry } from '../../core/retry.js';
8
+ import { requestAssigneeReview } from './assignee-review.js';
9
+ import { GITHUB_REQUEST_TIMEOUT_MS } from './github-request.js';
10
+ import { markPullRequestReady } from './pr-readiness.js';
7
11
  const PR_TEMPLATE_PATHS = [
8
12
  '.github/pull_request_template.md',
9
13
  '.github/PULL_REQUEST_TEMPLATE.md',
@@ -241,7 +245,7 @@ export async function commitAndPushFix(input) {
241
245
  /**
242
246
  * Commits + pushes a second fix attempt on the EXISTING iter-0 branch. Unlike
243
247
  * commitAndPushFix, this does not create a new branch — callers (the fix loop's
244
- * iter-1 path) need the existing draft PR to pick up the new commit, which only
248
+ * iter-1 path) need the existing PR to pick up the new commit, which only
245
249
  * works if we push to the same branch the PR was opened from.
246
250
  *
247
251
  * Assumes the working tree already has the iter-1 changes staged (typically by
@@ -272,6 +276,7 @@ export async function commitAndPushIteration(input) {
272
276
  });
273
277
  return { headSha };
274
278
  }
279
+ /** Opens a draft, then marks it ready if an eligible assignee review was requested. */
275
280
  export async function openDraftFixPR(input) {
276
281
  const prBody = input.prBody
277
282
  ? stripEmptySections(input.prBody)
@@ -298,6 +303,13 @@ export async function openDraftFixPR(input) {
298
303
  base: repo.default_branch,
299
304
  draft: true,
300
305
  });
306
+ const requestedAssignees = await requestAssigneeReview({
307
+ owner: input.repoOwner,
308
+ repo: input.repoName,
309
+ prNumber: pr.number,
310
+ octokit: input.octokit,
311
+ assigneeReview: input.assigneeReview,
312
+ });
301
313
  try {
302
314
  const prLabel = input.fixAssessment?.classification === 'speculative'
303
315
  ? DOISTBOT_SPECULATIVE_FIX_LABEL
@@ -307,6 +319,7 @@ export async function openDraftFixPR(input) {
307
319
  repo: input.repoName,
308
320
  issue_number: pr.number,
309
321
  labels: [prLabel],
322
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
310
323
  });
311
324
  }
312
325
  catch (error) {
@@ -317,6 +330,50 @@ export async function openDraftFixPR(input) {
317
330
  error: error instanceof Error ? error.message : String(error),
318
331
  });
319
332
  }
333
+ if (requestedAssignees.length > 0) {
334
+ // Apply the autofix label before emitting ready_for_review. An accepted
335
+ // assignee request is sufficient, even if writing its receipt failed.
336
+ try {
337
+ await markPullRequestReady({
338
+ octokit: input.octokit,
339
+ owner: input.repoOwner,
340
+ repo: input.repoName,
341
+ prNumber: pr.number,
342
+ });
343
+ logger.info('Assignee-reviewed fix PR marked ready', {
344
+ task: 'issue-triage',
345
+ repository: `${input.repoOwner}/${input.repoName}`,
346
+ prNumber: pr.number,
347
+ });
348
+ }
349
+ catch (error) {
350
+ // The PR and review request already exist. Preserve both and keep
351
+ // the fix loop running rather than reporting PR creation failure.
352
+ logger.warn('Failed to mark assignee-reviewed fix PR ready', {
353
+ task: 'issue-triage',
354
+ repository: `${input.repoOwner}/${input.repoName}`,
355
+ prNumber: pr.number,
356
+ error: error instanceof Error ? error.message : String(error),
357
+ });
358
+ try {
359
+ await withRetry(() => input.octokit.rest.issues.createComment({
360
+ owner: input.repoOwner,
361
+ repo: input.repoName,
362
+ issue_number: pr.number,
363
+ body: "Review was requested from the issue assignees, but I couldn't confirm that this PR was marked ready for review. Please check its status and mark it ready manually if needed.",
364
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
365
+ }));
366
+ }
367
+ catch (commentError) {
368
+ logger.warn('Failed to report fix PR readiness failure', {
369
+ task: 'issue-triage',
370
+ repository: `${input.repoOwner}/${input.repoName}`,
371
+ prNumber: pr.number,
372
+ error: commentError instanceof Error ? commentError.message : String(commentError),
373
+ });
374
+ }
375
+ }
376
+ }
320
377
  logger.info('Fix PR created', {
321
378
  task: 'issue-triage',
322
379
  repository: `${input.repoOwner}/${input.repoName}`,
@@ -327,5 +384,6 @@ export async function openDraftFixPR(input) {
327
384
  return {
328
385
  prUrl: pr.html_url,
329
386
  prNumber: pr.number,
387
+ ...(requestedAssignees.length > 0 && { requestedAssignees }),
330
388
  };
331
389
  }
@@ -0,0 +1,25 @@
1
+ import { withRetry } from '../../core/retry.js';
2
+ import { GITHUB_REQUEST_TIMEOUT_MS } from './github-request.js';
3
+ /** Safe both at creation and when the later CI-driven promotion runs. */
4
+ export async function markPullRequestReady({ octokit, owner, repo, prNumber, }) {
5
+ // Re-read on every attempt: a mutation may have succeeded even if its
6
+ // response was lost. Repeating that mutation on a ready PR would fail.
7
+ await withRetry(async () => {
8
+ const { data: pr } = await octokit.rest.pulls.get({
9
+ owner,
10
+ repo,
11
+ pull_number: prNumber,
12
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
13
+ });
14
+ if (!pr.draft)
15
+ return;
16
+ await octokit.graphql(`mutation($pullRequestId: ID!) {
17
+ markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {
18
+ pullRequest { isDraft }
19
+ }
20
+ }`, {
21
+ pullRequestId: pr.node_id,
22
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
23
+ });
24
+ });
25
+ }
@@ -254,7 +254,7 @@ async function runIssueTriagePipeline(ctx, mode) {
254
254
  extraEnv: {
255
255
  GH_TOKEN: issueInstallationAuth.token,
256
256
  },
257
- thinkingLevel: 'high',
257
+ thinkingLevel: 'xhigh',
258
258
  });
259
259
  if (!triageResult.ok) {
260
260
  await postFinalTriageComment(buildTriageFailureMessage(triageResult.error));
@@ -318,6 +318,10 @@ async function runIssueTriagePipeline(ctx, mode) {
318
318
  issueUrl: issue.html_url ?? getIssueUrl(ctx.owner, ctx.repo, ctx.issueNumber),
319
319
  ghToken: issueInstallationAuth.token,
320
320
  loadRepoConfig: getCachedRepoConfig,
321
+ assigneeReview: {
322
+ assignees: (issue.assignees ?? []).map((assignee) => assignee.login),
323
+ membershipOctokit: readOctokit,
324
+ },
321
325
  });
322
326
  logger.info('triage.fix_attempted', {
323
327
  task: 'issue-triage',
@@ -3,7 +3,7 @@ export function getReviewThinkingLevel(model) {
3
3
  case 'deepseek':
4
4
  return 'high';
5
5
  case 'openai':
6
- return 'high';
6
+ return 'xhigh';
7
7
  case 'grok':
8
8
  return 'high';
9
9
  case 'zai':