@doist/doistbot-cli 1.0.10 → 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 (34) hide show
  1. package/dist/actions/review-usage.js +4 -4
  2. package/dist/actions/review.js +31 -21
  3. package/dist/config.js +61 -16
  4. package/dist/index.js +8 -2
  5. package/dist/terminal.js +4 -0
  6. package/package.json +3 -3
  7. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
  8. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +1 -1
  9. package/sandbox/dist/core/datadog-metrics.js +7 -2
  10. package/sandbox/dist/core/label-parsing.js +1 -22
  11. package/sandbox/dist/core/pi-execution-limits-extension.js +79 -0
  12. package/sandbox/dist/core/pi-progress-logging.js +4 -0
  13. package/sandbox/dist/core/pi.js +286 -67
  14. package/sandbox/dist/core/repository-map.js +1 -220
  15. package/sandbox/dist/core/repository-resolver.js +1 -166
  16. package/sandbox/dist/providers/config.js +1 -1
  17. package/sandbox/dist/providers/pi-review.js +53 -3
  18. package/sandbox/dist/tasks/chat/chat.js +95 -45
  19. package/sandbox/dist/tasks/chat/prompt.js +5 -0
  20. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +1 -0
  21. package/sandbox/dist/tasks/issue-summarize/model.js +4 -4
  22. package/sandbox/dist/tasks/issue-triage/context.js +2 -0
  23. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +2 -1
  24. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +4 -7
  25. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +65 -41
  26. package/sandbox/dist/tasks/issue-triage/output.js +1 -1
  27. package/sandbox/dist/tasks/issue-triage/resolution.js +1 -0
  28. package/sandbox/dist/tasks/issue-triage/shared.js +2 -12
  29. package/sandbox/dist/tasks/issue-triage/triage.js +2 -0
  30. package/sandbox/dist/tasks/review/engines/multi-focus.js +23 -9
  31. package/sandbox/dist/tasks/review/review-summary.js +1 -1
  32. package/sandbox/dist/tasks/review/summary-model.js +2 -2
  33. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
  34. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +1 -1
@@ -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
  });
@@ -4,11 +4,11 @@ import { buildPiImageContent, createPiInvoker, DEFAULT_OPENROUTER_MODELS } from
4
4
  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
- const SUMMARIZE_TEXT_PROVIDER = 'grok';
8
- // Single source of truth: inherit the repo-wide Grok default so a default-model
7
+ const SUMMARIZE_TEXT_PROVIDER = 'zai';
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
- const SUMMARIZE_TEXT_MODEL = DEFAULT_OPENROUTER_MODELS.grok;
11
+ const SUMMARIZE_TEXT_MODEL = DEFAULT_OPENROUTER_MODELS.zai;
12
12
  const SUMMARIZE_MULTIMODAL_PROVIDER = 'gcp.gemini';
13
13
  // One model span per actual model.invoke. The final summary uses
14
14
  // `issue-summarize.model`; each chunk summary uses `issue-summarize.chunk`.
@@ -16,7 +16,7 @@ const SUMMARIZE_MODEL_PHASE = 'issue-summarize.model';
16
16
  const SUMMARIZE_CHUNK_PHASE = 'issue-summarize.chunk';
17
17
  export function createIssueSummarizeModel(apiKey) {
18
18
  return createPiInvoker({
19
- provider: 'grok',
19
+ provider: 'zai',
20
20
  apiKey,
21
21
  piProviderId: 'openrouter',
22
22
  cwd: process.cwd(),
@@ -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';
@@ -10,13 +11,13 @@ import { buildGeminiFlashReviewSummaryModel } from '../summary-model.js';
10
11
  import { dedupeMultiFocusFindings } from './dedupe.js';
11
12
  import { buildGeminiFlashCredential, getApiKeys, getAvailableModels, getEnabledReviewModels, getOpenRouterReviewCredential, getProviderFailures, requestModelReview, } from './shared.js';
12
13
  const DEFAULT_REVIEW_MODELS_BY_FOCUS = {
13
- general: ['deepseek', 'grok', 'openai'],
14
- security: ['deepseek', 'grok', 'openai'],
15
- reuse: ['deepseek', 'grok', 'openai'],
16
- quality: ['deepseek', 'grok', 'openai'],
17
- efficiency: ['deepseek', 'grok', 'openai'],
18
- tests: ['deepseek', 'grok'],
19
- standards: ['deepseek', 'grok'],
14
+ general: ['deepseek', 'zai', 'openai'],
15
+ security: ['deepseek', 'zai', 'openai'],
16
+ reuse: ['deepseek', 'zai', 'openai'],
17
+ quality: ['deepseek', 'zai', 'openai'],
18
+ efficiency: ['deepseek', 'zai', 'openai'],
19
+ tests: ['deepseek', 'zai'],
20
+ standards: ['deepseek', 'zai'],
20
21
  };
21
22
  // Default Gemini-flash pass on failure. tests/standards keep no fallback unless
22
23
  // a caller opts into a wider fallback set.
@@ -28,7 +29,17 @@ const DEFAULT_GEMINI_FLASH_FALLBACK_FOCUSES = [
28
29
  'efficiency',
29
30
  ];
30
31
  // Open models whose failed pass triggers that fallback.
31
- const GEMINI_FLASH_FALLBACK_MODELS = ['deepseek', 'grok'];
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 });
@@ -347,7 +361,7 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
347
361
  const routedFindings = routeLowPriorityFindings(dedupedFindings, lowPriorityFindingPlacement);
348
362
  const geminiSummaryFallback = buildGeminiFlashReviewSummaryModel(apiKeys.gemini);
349
363
  const summaryModel = ctx.reviewSummaryModel ??
350
- (apiKeys.grok ? { provider: 'grok', credential: apiKeys.grok } : undefined);
364
+ (apiKeys.zai ? { provider: 'zai', credential: apiKeys.zai } : undefined);
351
365
  const review = await buildMultiFocusReviewOutput({
352
366
  inlineFindings: routedFindings.inlineFindings,
353
367
  summaryFindings: routedFindings.summaryFindings,
@@ -51,7 +51,7 @@ Keep it human and compact: the opener should be 1 sentence, or 2 only if the PR
51
51
  Respond with only valid JSON in this exact shape:
52
52
  {"summary":"<your summary>","comments":[]}`;
53
53
  }
54
- export async function summarizeReviewFindings({ credential, provider = 'grok', modelName, fallbackModels = [], prTitle, prBody, findings, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
54
+ export async function summarizeReviewFindings({ credential, provider = 'zai', modelName, fallbackModels = [], prTitle, prBody, findings, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
55
55
  const prompt = buildReviewSummaryPrompt({
56
56
  prTitle,
57
57
  prBody,
@@ -56,7 +56,7 @@ function getRequestFailureReason(result) {
56
56
  ? 'no_assistant_response'
57
57
  : 'request_failed';
58
58
  }
59
- export async function requestReviewSummary({ credential, provider = 'grok', modelName, prompt, workspaceDir, }) {
59
+ export async function requestReviewSummary({ credential, provider = 'zai', modelName, prompt, workspaceDir, }) {
60
60
  const resolvedCredential = resolveReviewProviderCredential(credential);
61
61
  if (!resolvedCredential) {
62
62
  return failedReviewSummaryRequest({ reason: 'missing_credential' });
@@ -93,7 +93,7 @@ export async function requestReviewSummary({ credential, provider = 'grok', mode
93
93
  }
94
94
  async function runReviewSummaryRequest({ provider, resolvedCredential, resolvedModelName, logModelName, prompt, workspaceDir, }) {
95
95
  try {
96
- const thinkingLevel = provider === 'grok' ? 'high' : DEFAULT_REVIEW_SUMMARY_THINKING_LEVEL;
96
+ const thinkingLevel = provider === 'zai' ? 'high' : DEFAULT_REVIEW_SUMMARY_THINKING_LEVEL;
97
97
  logger.info('Review summary model request', {
98
98
  provider,
99
99
  modelName: logModelName,
@@ -1,7 +1,7 @@
1
1
  import type { Logger } from 'doistbot-logging';
2
2
  import type { Octokit } from 'octokit';
3
3
  export declare const CONFIG_FILE_PATH = ".github/doistbot.yml";
4
- export declare const DEFAULT_REVIEW_MODELS: readonly ["openai", "deepseek", "grok"];
4
+ export declare const DEFAULT_REVIEW_MODELS: readonly ["openai", "deepseek", "zai"];
5
5
  export type DefaultReviewModel = (typeof DEFAULT_REVIEW_MODELS)[number];
6
6
  export type RepoConfig = {
7
7
  review?: {