@doist/doistbot-cli 1.0.9 → 1.0.11

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 (35) hide show
  1. package/dist/actions/auth.js +1 -1
  2. package/dist/actions/review-usage.js +4 -4
  3. package/dist/actions/review.js +32 -21
  4. package/dist/config.js +59 -13
  5. package/dist/index.js +8 -2
  6. package/dist/terminal.js +5 -0
  7. package/package.json +4 -4
  8. package/sandbox/dist/core/datadog-metrics.js +9 -2
  9. package/sandbox/dist/core/label-parsing.js +1 -22
  10. package/sandbox/dist/core/pi-execution-limits-extension.js +79 -0
  11. package/sandbox/dist/core/pi-progress-logging.js +4 -0
  12. package/sandbox/dist/core/pi.js +290 -67
  13. package/sandbox/dist/core/repository-map.js +1 -220
  14. package/sandbox/dist/core/repository-resolver.js +1 -166
  15. package/sandbox/dist/core/tracing.js +1 -1
  16. package/sandbox/dist/providers/config.js +1 -1
  17. package/sandbox/dist/providers/index.js +1 -0
  18. package/sandbox/dist/providers/pi-review.js +53 -3
  19. package/sandbox/dist/tasks/chat/chat.js +95 -45
  20. package/sandbox/dist/tasks/chat/prompt.js +5 -0
  21. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +1 -0
  22. package/sandbox/dist/tasks/issue-summarize/model.js +1 -1
  23. package/sandbox/dist/tasks/issue-triage/context.js +2 -0
  24. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +2 -1
  25. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +4 -7
  26. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +65 -41
  27. package/sandbox/dist/tasks/issue-triage/output.js +1 -1
  28. package/sandbox/dist/tasks/issue-triage/resolution.js +1 -0
  29. package/sandbox/dist/tasks/issue-triage/shared.js +2 -12
  30. package/sandbox/dist/tasks/issue-triage/triage.js +2 -0
  31. package/sandbox/dist/tasks/review/engines/multi-focus.js +14 -0
  32. package/sandbox/dist/tasks/review/engines/shared.js +2 -0
  33. package/sandbox/dist/tasks/review/review.js +5 -3
  34. package/sandbox/dist/tasks/review/summary-model.js +4 -3
  35. package/sandbox/dist/tasks/review/thinking-level.js +2 -0
@@ -8,12 +8,15 @@ import { loadPromptTemplateFile, resolvePromptFile } from '../../core/prompt-tem
8
8
  import { removeIssueCommentReactionIfPresent } from '../../core/reactions.js';
9
9
  import { cloneRepository, createGitHubClients, parseBaseContext, requiredEnv, WORKSPACE_DIR, } from '../../core/shared.js';
10
10
  import { withPhase } from '../../core/tracing.js';
11
- import { buildPrompt } from './prompt.js';
11
+ import { buildBroPrompt, buildPrompt } from './prompt.js';
12
12
  const CHAT_PROMPT_FILE = 'chat-prompt.md';
13
+ const BRO_PROMPT_FILE = 'bro-prompt.md';
13
14
  const CHAT_TIMEOUT_MS = 5 * 60 * 1000;
14
15
  const CHAT_PROVIDER = 'openai';
15
16
  const CHAT_MODEL = 'gpt-5.6-luna';
16
17
  const CHAT_THINKING_LEVEL = 'high';
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
+ const EMPTY_BRO_RESPONSE = "There's no earlier Doistbot comment in this review thread for me to simplify, bro.";
17
20
  function parseContext() {
18
21
  const base = parseBaseContext();
19
22
  const reactionIdRaw = process.env.REACTION_ID;
@@ -24,23 +27,29 @@ function parseContext() {
24
27
  reactionId: Number.isFinite(reactionId) ? reactionId : undefined,
25
28
  commentType: requiredEnv('COMMENT_TYPE'),
26
29
  eventName: requiredEnv('EVENT_NAME'),
30
+ mode: process.env.CHAT_MODE === 'bro' ? 'bro' : 'chat',
27
31
  dryRun: process.env.DRY_RUN?.toLowerCase() === 'true',
28
32
  };
29
33
  }
34
+ function findLatestDoistbotComment(entries) {
35
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
36
+ const entry = entries[index];
37
+ if (entry && isKnownDoistbotLogin(entry.author)) {
38
+ return entry.body;
39
+ }
40
+ }
41
+ return undefined;
42
+ }
30
43
  /**
31
44
  * Gathers context for a review comment (inline code comment thread).
32
45
  */
33
- async function gatherReviewCommentContext(octokit, owner, repo, commentId) {
46
+ export async function gatherReviewCommentContext(octokit, owner, repo, commentId, mode = 'chat') {
34
47
  const comment = await octokit.rest.pulls.getReviewComment({
35
48
  owner,
36
49
  repo,
37
50
  comment_id: commentId,
38
51
  });
39
- const codeSnippet = comment.data.diff_hunk ?? '';
40
- const commentBody = comment.data.body ?? '';
41
- // Build thread history by walking up the chain
42
- const threadHistory = [];
43
- const currentCommentAuthor = comment.data.user?.login ?? 'unknown';
52
+ const threadEntries = [];
44
53
  let inReplyToId = comment.data.in_reply_to_id;
45
54
  while (inReplyToId) {
46
55
  try {
@@ -51,7 +60,16 @@ async function gatherReviewCommentContext(octokit, owner, repo, commentId) {
51
60
  });
52
61
  const parentAuthor = parentComment.data.user?.login ?? 'unknown';
53
62
  const parentBody = parentComment.data.body ?? '';
54
- threadHistory.unshift(`**${parentAuthor}**: ${parentBody}`);
63
+ if (mode === 'bro' && isKnownDoistbotLogin(parentAuthor)) {
64
+ return {
65
+ previousDoistbotComment: parentBody,
66
+ };
67
+ }
68
+ if (mode === 'bro') {
69
+ inReplyToId = parentComment.data.in_reply_to_id;
70
+ continue;
71
+ }
72
+ threadEntries.unshift({ author: parentAuthor, body: parentBody });
55
73
  inReplyToId = parentComment.data.in_reply_to_id;
56
74
  }
57
75
  catch (error) {
@@ -59,16 +77,28 @@ async function gatherReviewCommentContext(octokit, owner, repo, commentId) {
59
77
  commentId: inReplyToId,
60
78
  error: error instanceof Error ? error.message : String(error),
61
79
  });
80
+ if (mode === 'bro') {
81
+ throw new Error('Failed to fetch the review thread for bro mode', { cause: error });
82
+ }
62
83
  break;
63
84
  }
64
85
  }
65
- // Add the current comment at the end
66
- threadHistory.push(`**${currentCommentAuthor}**: ${commentBody}`);
86
+ if (mode === 'bro') {
87
+ return {};
88
+ }
89
+ const codeSnippet = comment.data.diff_hunk ?? '';
90
+ const commentBody = comment.data.body ?? '';
91
+ const currentCommentAuthor = comment.data.user?.login ?? 'unknown';
92
+ const previousDoistbotComment = findLatestDoistbotComment(threadEntries);
93
+ threadEntries.push({ author: currentCommentAuthor, body: commentBody });
67
94
  const userQuestion = commentBody.replace(/@doistbot\s*/i, '').trim();
68
95
  return {
69
- threadHistory: threadHistory.join('\n\n'),
96
+ threadHistory: threadEntries
97
+ .map(({ author, body }) => `**${author}**: ${body}`)
98
+ .join('\n\n'),
70
99
  userQuestion,
71
100
  codeSnippet,
101
+ previousDoistbotComment,
72
102
  };
73
103
  }
74
104
  /**
@@ -125,7 +155,7 @@ async function gatherIssueCommentContext(octokit, owner, repo, prNumber, comment
125
155
  prAuthor,
126
156
  };
127
157
  }
128
- export async function invokeChatModel(prompt) {
158
+ export async function invokeChatModel(prompt, mode = 'chat') {
129
159
  const apiKey = process.env.OPENAI_KEY?.trim();
130
160
  if (!apiKey) {
131
161
  // Config error: no model call attempted, so no model span. The reason is
@@ -144,10 +174,16 @@ export async function invokeChatModel(prompt) {
144
174
  prompt,
145
175
  cwd: WORKSPACE_DIR,
146
176
  timeoutMs: CHAT_TIMEOUT_MS,
147
- capabilityPreset: 'workspace-inspection',
177
+ capabilityPreset: mode === 'bro' ? 'none' : 'workspace-inspection',
148
178
  thinkingLevel: CHAT_THINKING_LEVEL,
149
179
  // Keep the executed model in sync with the recorded span attr.
150
180
  modelName: CHAT_MODEL,
181
+ ...(mode === 'bro'
182
+ ? {
183
+ systemPrompt: BRO_SYSTEM_PROMPT,
184
+ noContextFiles: true,
185
+ }
186
+ : {}),
151
187
  });
152
188
  // Record why the model stopped, before the ok/failure branch so
153
189
  // terminal "error"/"aborted"/"length" endings are reported too.
@@ -215,6 +251,7 @@ function formatChatDryRunOutput(ctx, response) {
215
251
  commentId: ctx.commentId,
216
252
  commentType: ctx.commentType,
217
253
  eventName: ctx.eventName,
254
+ mode: ctx.mode,
218
255
  response,
219
256
  }, null, 2);
220
257
  }
@@ -265,6 +302,7 @@ async function main() {
265
302
  task: 'chat',
266
303
  'gen_ai.operation.name': 'invoke_agent',
267
304
  'gen_ai.agent.name': 'doistbot.chat',
305
+ 'chat.mode': ctx.mode,
268
306
  'pr.number': ctx.prNumber,
269
307
  'comment.id': ctx.commentId,
270
308
  'comment.type': ctx.commentType,
@@ -274,47 +312,59 @@ async function main() {
274
312
  // Inside the span so auth/setup failures are captured by the root span.
275
313
  const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
276
314
  try {
277
- // Clone repository
278
- await cloneRepository({
279
- app,
280
- owner: ctx.owner,
281
- repo: ctx.repo,
282
- prNumber: ctx.prNumber,
283
- installationId: ctx.installationId,
284
- });
315
+ // /bro only rewrites an existing comment, so it does not need repository contents.
316
+ if (ctx.mode !== 'bro') {
317
+ await cloneRepository({
318
+ app,
319
+ owner: ctx.owner,
320
+ repo: ctx.repo,
321
+ prNumber: ctx.prNumber,
322
+ installationId: ctx.installationId,
323
+ });
324
+ }
285
325
  // Gather thread context from GitHub API
286
326
  let threadContext;
287
327
  if (ctx.commentType === 'review') {
288
- threadContext = await gatherReviewCommentContext(readOctokit, ctx.owner, ctx.repo, ctx.commentId);
328
+ threadContext = await gatherReviewCommentContext(readOctokit, ctx.owner, ctx.repo, ctx.commentId, ctx.mode);
289
329
  }
290
330
  else {
291
331
  threadContext = await gatherIssueCommentContext(readOctokit, ctx.owner, ctx.repo, ctx.prNumber, ctx.commentId);
292
332
  }
293
- // Build prompt and invoke the chat model
294
- const promptTemplate = loadPromptTemplateFile({
295
- fileName: resolvePromptFile(import.meta.url, CHAT_PROMPT_FILE),
296
- promptLabel: 'chat',
297
- });
298
- const prompt = buildPrompt({
299
- promptTemplate,
300
- owner: ctx.owner,
301
- repo: ctx.repo,
302
- prNumber: ctx.prNumber,
303
- threadHistory: threadContext.threadHistory,
304
- userQuestion: threadContext.userQuestion,
305
- codeSnippet: threadContext.codeSnippet,
306
- prTitle: threadContext.prTitle,
307
- prAuthor: threadContext.prAuthor,
308
- });
309
- const modelResult = await invokeChatModel(prompt);
310
- if (!modelResult.ok) {
311
- span.setStatus({ code: SpanStatusCode.ERROR, message: modelResult.reason });
312
- logger.warn('Chat model produced no usable response', {
313
- reason: modelResult.reason,
333
+ let response;
334
+ if (ctx.mode === 'bro' && !threadContext.previousDoistbotComment) {
335
+ response = EMPTY_BRO_RESPONSE;
336
+ }
337
+ else {
338
+ const promptTemplate = loadPromptTemplateFile({
339
+ fileName: resolvePromptFile(import.meta.url, ctx.mode === 'bro' ? BRO_PROMPT_FILE : CHAT_PROMPT_FILE),
340
+ promptLabel: ctx.mode,
314
341
  });
315
- return;
342
+ const prompt = ctx.mode === 'bro'
343
+ ? buildBroPrompt({
344
+ promptTemplate,
345
+ previousDoistbotComment: threadContext.previousDoistbotComment ?? '',
346
+ })
347
+ : buildPrompt({
348
+ promptTemplate,
349
+ owner: ctx.owner,
350
+ repo: ctx.repo,
351
+ prNumber: ctx.prNumber,
352
+ threadHistory: threadContext.threadHistory ?? '',
353
+ userQuestion: threadContext.userQuestion ?? '',
354
+ codeSnippet: threadContext.codeSnippet,
355
+ prTitle: threadContext.prTitle,
356
+ prAuthor: threadContext.prAuthor,
357
+ });
358
+ const modelResult = await invokeChatModel(prompt, ctx.mode);
359
+ if (!modelResult.ok) {
360
+ span.setStatus({ code: SpanStatusCode.ERROR, message: modelResult.reason });
361
+ logger.warn('Chat model produced no usable response', {
362
+ reason: modelResult.reason,
363
+ });
364
+ return;
365
+ }
366
+ response = modelResult.output;
316
367
  }
317
- const response = modelResult.output;
318
368
  if (ctx.dryRun) {
319
369
  logger.info('Dry run enabled, printing chat response instead of posting to GitHub');
320
370
  process.stdout.write(`${formatChatDryRunOutput(ctx, response)}\n`);
@@ -13,3 +13,8 @@ export function buildPrompt({ promptTemplate, owner, repo, prNumber, threadHisto
13
13
  CODE_SNIPPET: codeSnippet ? `\`\`\`\n${codeSnippet}\n\`\`\`` : 'No code context available.',
14
14
  });
15
15
  }
16
+ export function buildBroPrompt({ promptTemplate, previousDoistbotComment, }) {
17
+ return fillPromptTemplate(promptTemplate, {
18
+ PREVIOUS_DOISTBOT_COMMENT: previousDoistbotComment,
19
+ });
20
+ }
@@ -252,6 +252,7 @@ async function main() {
252
252
  model: 'openai',
253
253
  outcome: metricOutcome,
254
254
  durationMs: performance.now() - startedAt,
255
+ sandboxVariant: ctx.sandboxVariant,
255
256
  });
256
257
  }
257
258
  });
@@ -5,7 +5,7 @@ import { withPhase } from '../../core/tracing.js';
5
5
  const ISSUE_SUMMARIZE_TIMEOUT_MS = 5 * 60 * 1000;
6
6
  const ISSUE_SUMMARIZE_MULTIMODAL_MODEL = 'google/gemini-2.5-pro';
7
7
  const SUMMARIZE_TEXT_PROVIDER = 'zai';
8
- // Single source of truth: inherit the repo-wide ZAI default so a default-model
8
+ // Single source of truth: inherit the repo-wide Z.ai default so a default-model
9
9
  // bump in core/pi.ts moves summarize too, and the executed model matches the
10
10
  // recorded gen_ai.request.model.
11
11
  const SUMMARIZE_TEXT_MODEL = DEFAULT_OPENROUTER_MODELS.zai;
@@ -1,3 +1,4 @@
1
+ import { parseSandboxVariant } from 'doistbot-issue-routing';
1
2
  import { optionalPositiveInt, requiredEnv, requiredPositiveInt } from '../../core/shared.js';
2
3
  import { withPhase } from '../../core/tracing.js';
3
4
  import { extractLabelNames, fetchIssue, fetchIssueComments, } from './shared.js';
@@ -37,6 +38,7 @@ export function parseIssueTriageContext(env = process.env) {
37
38
  deliveryId,
38
39
  dryRun: env.DRY_RUN?.toLowerCase() === 'true',
39
40
  triggerType,
41
+ sandboxVariant: parseSandboxVariant(env.SANDBOX_VARIANT),
40
42
  enableAutoFixOverride: parseOptionalBoolean(env.ENABLE_AUTO_FIX),
41
43
  };
42
44
  }
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'child_process';
2
2
  import { logger } from '../../core/logging.js';
3
3
  import { fillPromptTemplate, loadPromptTemplateFile, resolvePromptFile, } from '../../core/prompt-template.js';
4
+ import { findRepositoryRoutes } from '../../core/repository-map.js';
4
5
  import { resolveAutofixCapabilityEnv } from './autofix-capabilities.js';
5
6
  import { validateDiff } from './diff-validator.js';
6
7
  import { CODEX_CANNOT_FIX_REASON, FIX_LOOP_FAILED_REASON_PREFIX } from './fix-failure-reasons.js';
@@ -288,7 +289,7 @@ async function postFollowUpComment({ octokit, ctx, prUrl, prNumber, targetRepo,
288
289
  task: 'issue-triage',
289
290
  issueNumber: ctx.issueNumber,
290
291
  targetRepo: targetRepo.fullName,
291
- surfaces: targetRepo.surfaces,
292
+ routes: findRepositoryRoutes(targetRepo).map((route) => route.id),
292
293
  });
293
294
  }
294
295
  const body = buildFollowUpCommentBody({
@@ -25,18 +25,15 @@ export function parsePromotionTaskInput() {
25
25
  */
26
26
  export function buildPromotionSession({ input, octokit, }) {
27
27
  const { owner, repo } = input.base;
28
- // Single source of truth: look up the canonical RepositoryTarget here
29
- // (with real surfaces) so downstream effects like requestHeroReview can
30
- // route by surface without re-scanning PRODUCT_REPOSITORIES.
28
+ // Use the issue-routing catalog when the target repository is supported.
29
+ // The review effect can then derive its configured route and hero team.
31
30
  const repository = findRepositoryTarget(owner, repo) ?? {
32
31
  owner,
33
32
  repo,
34
33
  fullName: `${owner}/${repo}`,
35
- // Fallback for repos not in the registry. surfaces=[] makes
36
- // resolveHeroGroup fall through to cx-hero — the desired behavior
37
- // for unknown targets.
34
+ // Keep the existing Product type for an unknown target. The repository
35
+ // does not occur in that product's routes, so hero routing uses cx-hero.
38
36
  product: 'Todoist',
39
- surfaces: [],
40
37
  };
41
38
  return {
42
39
  kind: 'fix-loop-promotion',
@@ -1,5 +1,5 @@
1
1
  import { normalizeScopedLabelValue, scopedLabelValue } from '../../core/label-parsing.js';
2
- import { PRODUCT_REPOSITORIES } from '../../core/repository-map.js';
2
+ import { APP_LABEL_ALIASES, findIssueRoute, findRepositoryRoutes, findRepositoryTarget as findRoutingRepositoryTarget, 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' },
@@ -39,37 +39,17 @@ const TEAM_LABEL_TO_HERO = {
39
39
  product: 'product-hero',
40
40
  support: 'cx-hero',
41
41
  };
42
- const SURFACE_TO_HERO = {
43
- android: 'android-hero',
44
- backend: 'backend-hero',
45
- cli: 'frontend-hero',
46
- desktop: 'desktop-hero',
47
- ios: 'apple-hero',
48
- mcp: 'frontend-hero',
49
- web: 'frontend-hero',
50
- };
51
42
  export const CX_HERO_MANUAL_HANDLE = HERO_GROUPS['cx-hero'].mention;
52
43
  /**
53
- * Looks up a known Doist repository by owner/repo in PRODUCT_REPOSITORIES.
54
- * Returns the canonical RepositoryTarget (with surfaces) or undefined when
55
- * the repo isn't in the registry. Used at session construction to populate
56
- * targetRepo.repository.surfaces accurately, so downstream effects can
57
- * reuse the surface map without re-scanning the registry.
44
+ * Looks up a repository supported by issue routing.
58
45
  */
59
46
  export function findRepositoryTarget(owner, repo) {
60
- for (const list of Object.values(PRODUCT_REPOSITORIES)) {
61
- const match = list.find((target) => target.owner === owner && target.repo === repo);
62
- if (match) {
63
- return match;
64
- }
65
- }
66
- return undefined;
47
+ return findRoutingRepositoryTarget(owner, repo);
67
48
  }
68
49
  /**
69
- * Derives the GitHub team slug (under the Doist org) for a PR's hero
70
- * reviewer from a RepositoryTarget. The target's surfaces decide the team
71
- * (web → frontend-hero, etc.). Falls back to cx-hero for unknown surfaces
72
- * and for user-mention groups like dateist (`@amix`) that aren't real teams.
50
+ * Derives the GitHub team slug for a PR from its configured issue route.
51
+ * Repositories that are unknown or belong to routes with different hero teams
52
+ * use cx-hero because the repository alone does not give enough context.
73
53
  */
74
54
  export function resolveHeroTeamSlug(target) {
75
55
  const group = resolveHeroGroup([], target);
@@ -80,6 +60,9 @@ export function resolveHeroTeamSlug(target) {
80
60
  // HeroGroupKey to HeroTeamSlug here.
81
61
  return group.key;
82
62
  }
63
+ function resolveAppLabelAlias(value) {
64
+ return APP_LABEL_ALIASES[normalizeScopedLabelValue(value)];
65
+ }
83
66
  export function isKnownHeroRoutingLabel(label) {
84
67
  const teamValue = scopedLabelValue(label, 'Team');
85
68
  if (teamValue && TEAM_LABEL_TO_HERO[normalizeScopedLabelValue(teamValue)]) {
@@ -89,7 +72,50 @@ export function isKnownHeroRoutingLabel(label) {
89
72
  if (!appValue) {
90
73
  return false;
91
74
  }
92
- return normalizeScopedLabelValue(appValue) in SURFACE_TO_HERO;
75
+ const appLabel = resolveAppLabelAlias(appValue);
76
+ return (appLabel !== undefined &&
77
+ appLabel !== 'all' &&
78
+ Object.values(PRODUCT_ROUTES).some((routes) => routes.some((route) => route.id === appLabel)));
79
+ }
80
+ function resolveProduct(labels) {
81
+ const products = new Set();
82
+ for (const label of labels) {
83
+ const productValue = scopedLabelValue(label, 'Product');
84
+ if (!productValue) {
85
+ continue;
86
+ }
87
+ const product = PRODUCT_LABEL_ALIASES[normalizeScopedLabelValue(productValue)];
88
+ if (product) {
89
+ products.add(product);
90
+ }
91
+ }
92
+ return products.size === 1 ? products.values().next().value : undefined;
93
+ }
94
+ function resolveAppRouteHeroKeys(labels) {
95
+ const routeIds = new Set();
96
+ for (const label of labels) {
97
+ const appValue = scopedLabelValue(label, 'App');
98
+ if (!appValue) {
99
+ continue;
100
+ }
101
+ const appLabel = resolveAppLabelAlias(appValue);
102
+ if (appLabel && appLabel !== 'all') {
103
+ routeIds.add(appLabel);
104
+ }
105
+ }
106
+ const product = resolveProduct(labels);
107
+ const heroKeys = new Set();
108
+ for (const routeId of routeIds) {
109
+ const routes = product
110
+ ? [findIssueRoute(product, routeId)].filter((route) => route !== undefined)
111
+ : Object.values(PRODUCT_ROUTES)
112
+ .flat()
113
+ .filter((route) => route.id === routeId);
114
+ for (const route of routes) {
115
+ heroKeys.add(route.heroTeam);
116
+ }
117
+ }
118
+ return heroKeys;
93
119
  }
94
120
  export function resolveHeroGroup(labels, primaryRepo) {
95
121
  const teamHeroKeys = new Set();
@@ -109,22 +135,20 @@ export function resolveHeroGroup(labels, primaryRepo) {
109
135
  if (teamHeroKeys.size > 1) {
110
136
  return null;
111
137
  }
112
- if (primaryRepo) {
113
- for (const surface of primaryRepo.surfaces) {
114
- const heroKey = SURFACE_TO_HERO[surface];
115
- if (heroKey) {
116
- return HERO_GROUPS[heroKey];
117
- }
118
- }
138
+ const appHeroKeys = resolveAppRouteHeroKeys(labels);
139
+ if (appHeroKeys.size === 1) {
140
+ return HERO_GROUPS[appHeroKeys.values().next().value];
119
141
  }
120
- for (const label of labels) {
121
- const appValue = scopedLabelValue(label, 'App');
122
- if (!appValue) {
123
- continue;
142
+ if (appHeroKeys.size > 1) {
143
+ return null;
144
+ }
145
+ if (primaryRepo) {
146
+ const repositoryHeroKeys = new Set(findRepositoryRoutes(primaryRepo).map((route) => route.heroTeam));
147
+ if (repositoryHeroKeys.size === 1) {
148
+ return HERO_GROUPS[repositoryHeroKeys.values().next().value];
124
149
  }
125
- const heroKey = SURFACE_TO_HERO[normalizeScopedLabelValue(appValue)];
126
- if (heroKey) {
127
- return HERO_GROUPS[heroKey];
150
+ if (repositoryHeroKeys.size > 1) {
151
+ return null;
128
152
  }
129
153
  }
130
154
  return null;
@@ -20,7 +20,7 @@ Detected labels: Product=[$PRODUCTS] App=[$APPS] Team=[$TEAMS]
20
20
  Please update labels and run \`@doistbot /triage\` again.
21
21
  Expected examples:
22
22
  - \`Product:Todoist\` + \`App:Android\`
23
- - \`Product:Twist\` + \`App:Web\`
23
+ - \`Product:Comms\` + \`App:Android\`
24
24
  - \`Product:Todoist\` + \`App:All\``;
25
25
  function hasHeading(body, heading) {
26
26
  const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -12,6 +12,7 @@ export async function resolveTriageScope(params) {
12
12
  parsed_products: result.parsedLabels.productLabels,
13
13
  parsed_apps: result.parsedLabels.appLabels,
14
14
  parsed_teams: result.parsedLabels.teamLabels,
15
+ matched_routes: result.matchedRoutes,
15
16
  unknown_product_labels: result.parsedLabels.unknownProductLabels,
16
17
  unknown_app_labels: result.parsedLabels.unknownAppLabels,
17
18
  unknown_team_labels: result.parsedLabels.unknownTeamLabels,
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from 'child_process';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
+ import { extractIssueLabelNames } from 'doistbot-issue-routing';
4
5
  import { App } from 'octokit';
5
6
  import { logger } from '../../core/logging.js';
6
7
  import { REPOS_SUBDIR, resolveWriteOctokit, WORKSPACE_DIR } from '../../core/shared.js';
@@ -10,18 +11,7 @@ export function getIssueUrl(owner, repo, issueNumber) {
10
11
  return `https://github.com/${owner}/${repo}/issues/${issueNumber}`;
11
12
  }
12
13
  export function extractLabelNames(labels) {
13
- if (!labels?.length) {
14
- return [];
15
- }
16
- const names = labels
17
- .map((label) => {
18
- if (typeof label === 'string') {
19
- return label.trim();
20
- }
21
- return label.name?.trim() ?? '';
22
- })
23
- .filter((label) => label.length > 0);
24
- return [...new Set(names)];
14
+ return extractIssueLabelNames(labels);
25
15
  }
26
16
  export function prepareWorkspaceReposDirectory() {
27
17
  const reposDir = path.join(WORKSPACE_DIR, REPOS_SUBDIR);
@@ -430,6 +430,7 @@ async function runIssueTriagePipeline(ctx, mode) {
430
430
  model: TRIAGE_PROVIDER,
431
431
  outcome: pendingFixMetric.outcome,
432
432
  durationMs: performance.now() - pendingFixMetric.startedAt,
433
+ sandboxVariant: ctx.sandboxVariant,
433
434
  });
434
435
  }
435
436
  if (shouldRecordTriageMetric) {
@@ -439,6 +440,7 @@ async function runIssueTriagePipeline(ctx, mode) {
439
440
  model: TRIAGE_PROVIDER,
440
441
  outcome: triageMetricOutcome,
441
442
  durationMs: triageDurationMs ?? performance.now() - startedAt,
443
+ sandboxVariant: ctx.sandboxVariant,
442
444
  });
443
445
  }
444
446
  }
@@ -1,5 +1,6 @@
1
1
  import { SpanStatusCode } from '@opentelemetry/api';
2
2
  import { logger } from '../../../core/logging.js';
3
+ import { toPiInvocationTelemetryFields } from '../../../core/pi.js';
3
4
  import { genAiProviderName, withPhase } from '../../../core/tracing.js';
4
5
  import { convertFindingsToComments } from '../comment-mapper.js';
5
6
  import { buildReviewOutputFindings } from '../finding-output.js';
@@ -29,6 +30,16 @@ const DEFAULT_GEMINI_FLASH_FALLBACK_FOCUSES = [
29
30
  ];
30
31
  // Open models whose failed pass triggers that fallback.
31
32
  const GEMINI_FLASH_FALLBACK_MODELS = ['deepseek', 'zai'];
33
+ function setReviewLatencyAttributes(span, result) {
34
+ const telemetry = result.telemetry;
35
+ if (!telemetry)
36
+ return;
37
+ const attributes = Object.fromEntries(Object.entries(toPiInvocationTelemetryFields(telemetry)).map(([name, value]) => [
38
+ `review.${name}`,
39
+ value,
40
+ ]));
41
+ span.setAttributes(attributes);
42
+ }
32
43
  function lowPrioritySummaryNotice(count) {
33
44
  return count === 1
34
45
  ? 'I also left one optional follow-up note in the details below.'
@@ -60,6 +71,9 @@ async function runFocusPass({ focus, model, basePrompt, standardsSection, apiKey
60
71
  if (result?.requestModel) {
61
72
  span.setAttributes({ 'gen_ai.request.model': result.requestModel });
62
73
  }
74
+ if (result) {
75
+ setReviewLatencyAttributes(span, result);
76
+ }
63
77
  if (!result) {
64
78
  const error = `No review result returned for ${model}`;
65
79
  span.setStatus({ code: SpanStatusCode.ERROR, message: error });
@@ -22,6 +22,8 @@ export function getAvailableModels(apiKeys) {
22
22
  models.push('openai');
23
23
  if (hasReviewProviderCredential(apiKeys.deepseek))
24
24
  models.push('deepseek');
25
+ if (hasReviewProviderCredential(apiKeys.grok))
26
+ models.push('grok');
25
27
  if (hasReviewProviderCredential(apiKeys.zai))
26
28
  models.push('zai');
27
29
  return models;
@@ -15,7 +15,8 @@ import { REVIEW_FOCUS_DEFINITIONS } from './multi-focus-prompt.js';
15
15
  import { appendReviewFeedbackLink } from './review-feedback.js';
16
16
  import { runSharedReview } from './runner.js';
17
17
  const REVIEW_EVENT = 'COMMENT';
18
- const REQUIRED_PRODUCTION_REVIEW_MODELS = getDefaultReviewModels();
18
+ const DEFAULT_PRODUCTION_REVIEW_MODELS = getDefaultReviewModels();
19
+ const SUPPORTED_PRODUCTION_REVIEW_MODELS = ['openai', 'deepseek', 'grok', 'zai'];
19
20
  const REVIEW_FOCUSES = REVIEW_FOCUS_DEFINITIONS.map((focus) => focus.name);
20
21
  function optionalEnv(name) {
21
22
  const value = process.env[name]?.trim();
@@ -43,8 +44,8 @@ function parseContext() {
43
44
  export function parseReviewModelsEnv(value) {
44
45
  return parseCsvAllowList({
45
46
  value,
46
- allowedValues: REQUIRED_PRODUCTION_REVIEW_MODELS,
47
- fallback: REQUIRED_PRODUCTION_REVIEW_MODELS,
47
+ allowedValues: SUPPORTED_PRODUCTION_REVIEW_MODELS,
48
+ fallback: DEFAULT_PRODUCTION_REVIEW_MODELS,
48
49
  envName: 'REVIEW_MODELS_ENABLED',
49
50
  });
50
51
  }
@@ -253,6 +254,7 @@ async function main() {
253
254
  }
254
255
  if (ctx.openRouterApiKey) {
255
256
  reviewApiKeys.deepseek = createOpenRouterCredential(ctx.openRouterApiKey);
257
+ reviewApiKeys.grok = createOpenRouterCredential(ctx.openRouterApiKey);
256
258
  reviewApiKeys.zai = createOpenRouterCredential(ctx.openRouterApiKey);
257
259
  }
258
260
  if (ctx.openaiApiKey) {
@@ -6,7 +6,7 @@ import { genAiProviderName, withPhase } from '../../core/tracing.js';
6
6
  import { resolveReviewProviderCredential } from '../../providers/credentials.js';
7
7
  import { extractJsonCandidatesFromResponse } from '../../providers/helpers.js';
8
8
  const REVIEW_SUMMARY_TIMEOUT_MS = 5 * 60 * 1000;
9
- const REVIEW_SUMMARY_THINKING_LEVEL = 'medium';
9
+ const DEFAULT_REVIEW_SUMMARY_THINKING_LEVEL = 'medium';
10
10
  // One model span per summary attempt; a fallback chain emits one span per model.
11
11
  const SUMMARY_PHASE = 'review.summary';
12
12
  const DIFF_SIDE = {
@@ -93,12 +93,13 @@ export async function requestReviewSummary({ credential, provider = 'zai', model
93
93
  }
94
94
  async function runReviewSummaryRequest({ provider, resolvedCredential, resolvedModelName, logModelName, prompt, workspaceDir, }) {
95
95
  try {
96
+ const thinkingLevel = provider === 'zai' ? 'high' : DEFAULT_REVIEW_SUMMARY_THINKING_LEVEL;
96
97
  logger.info('Review summary model request', {
97
98
  provider,
98
99
  modelName: logModelName,
99
100
  promptLength: prompt.length,
100
101
  timeoutMs: REVIEW_SUMMARY_TIMEOUT_MS,
101
- thinkingLevel: REVIEW_SUMMARY_THINKING_LEVEL,
102
+ thinkingLevel,
102
103
  });
103
104
  const result = await invokePiPrompt({
104
105
  provider,
@@ -111,7 +112,7 @@ async function runReviewSummaryRequest({ provider, resolvedCredential, resolvedM
111
112
  cwd: workspaceDir ?? getWorkspaceDir(),
112
113
  timeoutMs: REVIEW_SUMMARY_TIMEOUT_MS,
113
114
  capabilityPreset: 'read-only',
114
- thinkingLevel: REVIEW_SUMMARY_THINKING_LEVEL,
115
+ thinkingLevel,
115
116
  });
116
117
  if (!result.ok) {
117
118
  const reason = getRequestFailureReason(result);
@@ -4,6 +4,8 @@ export function getReviewThinkingLevel(model) {
4
4
  return 'high';
5
5
  case 'openai':
6
6
  return 'high';
7
+ case 'grok':
8
+ return 'high';
7
9
  case 'zai':
8
10
  return 'high';
9
11
  case 'gemini':