@todoist/doistbot-cli 0.0.0-stage → 1.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/auth.js +225 -0
- package/dist/actions/doctor.js +39 -0
- package/dist/actions/review-usage.js +25 -0
- package/dist/actions/review.js +311 -0
- package/dist/actions/setup.js +14 -0
- package/dist/auth.js +202 -0
- package/dist/config.js +145 -0
- package/dist/env.js +10 -0
- package/dist/file-store.js +112 -0
- package/dist/index.js +164 -0
- package/dist/input.js +40 -0
- package/dist/runtime.js +105 -0
- package/dist/secure-store.js +148 -0
- package/dist/terminal.js +185 -0
- package/dist/test-utils/secure-store-mocks.js +29 -0
- package/dist/update-cache-entrypoint.js +16 -0
- package/dist/update-check.js +235 -0
- package/package.json +65 -5
- package/sandbox/_node_modules/doistbot-logging/dist/index.d.ts +23 -0
- package/sandbox/_node_modules/doistbot-logging/dist/index.js +51 -0
- package/sandbox/_node_modules/doistbot-logging/package.json +12 -0
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +93 -0
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +197 -0
- package/sandbox/_node_modules/doistbot-repo-config/package.json +12 -0
- package/sandbox/dist/core/autofix-resources.js +58 -0
- package/sandbox/dist/core/autofix-service-query.js +69 -0
- package/sandbox/dist/core/autofix-test-policy.js +11 -0
- package/sandbox/dist/core/autofix-verification.js +355 -0
- package/sandbox/dist/core/bot-logins.js +14 -0
- package/sandbox/dist/core/check-run.js +158 -0
- package/sandbox/dist/core/datadog-metrics.js +113 -0
- package/sandbox/dist/core/environment.js +49 -0
- package/sandbox/dist/core/git-auth.js +17 -0
- package/sandbox/dist/core/github-history.js +4 -0
- package/sandbox/dist/core/github-issues.js +18 -0
- package/sandbox/dist/core/label-parsing.js +1 -0
- package/sandbox/dist/core/langfuse-observation.js +33 -0
- package/sandbox/dist/core/langfuse-session.js +39 -0
- package/sandbox/dist/core/logger-span-processor.js +90 -0
- package/sandbox/dist/core/logging.js +18 -0
- package/sandbox/dist/core/pi-autofix-extension.js +188 -0
- package/sandbox/dist/core/pi-execution-limits-extension.js +79 -0
- package/sandbox/dist/core/pi-progress-logging.js +51 -0
- package/sandbox/dist/core/pi-tool-output-filter-extension.js +65 -0
- package/sandbox/dist/core/pi.js +870 -0
- package/sandbox/dist/core/pii-scrubber.js +31 -0
- package/sandbox/dist/core/prompt-template.js +63 -0
- package/sandbox/dist/core/reactions.js +36 -0
- package/sandbox/dist/core/regexp.js +3 -0
- package/sandbox/dist/core/repository-map.js +1 -0
- package/sandbox/dist/core/repository-resolver.js +1 -0
- package/sandbox/dist/core/retry.js +29 -0
- package/sandbox/dist/core/s3-utils.js +42 -0
- package/sandbox/dist/core/shared.js +124 -0
- package/sandbox/dist/core/snapshot-helpers.js +17 -0
- package/sandbox/dist/core/span-test-helpers.js +13 -0
- package/sandbox/dist/core/tool-environment.js +83 -0
- package/sandbox/dist/core/tool-output.js +18 -0
- package/sandbox/dist/core/tracing-setup.js +92 -0
- package/sandbox/dist/core/tracing.js +81 -0
- package/sandbox/dist/main.js +51 -0
- package/sandbox/dist/providers/config.js +23 -0
- package/sandbox/dist/providers/credentials.js +40 -0
- package/sandbox/dist/providers/gemini.js +28 -0
- package/sandbox/dist/providers/helpers.js +253 -0
- package/sandbox/dist/providers/index.js +27 -0
- package/sandbox/dist/providers/openai.js +23 -0
- package/sandbox/dist/providers/openrouter-pi.js +25 -0
- package/sandbox/dist/providers/pi-review.js +301 -0
- package/sandbox/dist/providers/process.js +34 -0
- package/sandbox/dist/providers/schemas.js +61 -0
- package/sandbox/dist/providers/types.js +4 -0
- package/sandbox/dist/tasks/chat/chat.js +440 -0
- package/sandbox/dist/tasks/chat/prompt.js +20 -0
- package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +304 -0
- package/sandbox/dist/tasks/issue-summarize/context.js +21 -0
- package/sandbox/dist/tasks/issue-summarize/media.js +335 -0
- package/sandbox/dist/tasks/issue-summarize/model.js +230 -0
- package/sandbox/dist/tasks/issue-summarize/output.js +45 -0
- package/sandbox/dist/tasks/issue-summarize/prompt.js +11 -0
- package/sandbox/dist/tasks/issue-summarize/summarize.js +238 -0
- package/sandbox/dist/tasks/issue-summarize/thread.js +123 -0
- package/sandbox/dist/tasks/issue-summarize/types.js +1 -0
- package/sandbox/dist/tasks/issue-triage/assignee-review.js +114 -0
- package/sandbox/dist/tasks/issue-triage/autofix-capabilities.js +22 -0
- package/sandbox/dist/tasks/issue-triage/autofix-checkpoint.js +122 -0
- package/sandbox/dist/tasks/issue-triage/autofix-policy.js +36 -0
- package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
- package/sandbox/dist/tasks/issue-triage/context.js +57 -0
- package/sandbox/dist/tasks/issue-triage/diff-validator.js +102 -0
- package/sandbox/dist/tasks/issue-triage/fix-attempt.js +617 -0
- package/sandbox/dist/tasks/issue-triage/fix-context.js +5 -0
- package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +263 -0
- package/sandbox/dist/tasks/issue-triage/fix-failure-reasons.js +2 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/effects.js +526 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/effects.types.js +1 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/fake-effects.js +65 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +108 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/recovery.js +512 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +233 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/state-machine.js +171 -0
- package/sandbox/dist/tasks/issue-triage/github-request.js +2 -0
- package/sandbox/dist/tasks/issue-triage/hero-group-map.js +206 -0
- package/sandbox/dist/tasks/issue-triage/markers.js +6 -0
- package/sandbox/dist/tasks/issue-triage/model.js +124 -0
- package/sandbox/dist/tasks/issue-triage/output.js +164 -0
- package/sandbox/dist/tasks/issue-triage/pr-creator.js +585 -0
- package/sandbox/dist/tasks/issue-triage/pr-readiness.js +25 -0
- package/sandbox/dist/tasks/issue-triage/prompt.js +63 -0
- package/sandbox/dist/tasks/issue-triage/repo-config.js +47 -0
- package/sandbox/dist/tasks/issue-triage/repo-prompts.js +77 -0
- package/sandbox/dist/tasks/issue-triage/report-fixture.js +53 -0
- package/sandbox/dist/tasks/issue-triage/report-history.js +113 -0
- package/sandbox/dist/tasks/issue-triage/report-schema.js +63 -0
- package/sandbox/dist/tasks/issue-triage/report.js +111 -0
- package/sandbox/dist/tasks/issue-triage/resolution.js +30 -0
- package/sandbox/dist/tasks/issue-triage/routing.js +169 -0
- package/sandbox/dist/tasks/issue-triage/sandbox-environment.js +59 -0
- package/sandbox/dist/tasks/issue-triage/shared.js +86 -0
- package/sandbox/dist/tasks/issue-triage/side-effects.js +102 -0
- package/sandbox/dist/tasks/issue-triage/triage.js +442 -0
- package/sandbox/dist/tasks/issue-triage/types.js +1 -0
- package/sandbox/dist/tasks/persist-logs/context.js +19 -0
- package/sandbox/dist/tasks/persist-logs/dd-proxy.js +116 -0
- package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +156 -0
- package/sandbox/dist/tasks/persist-logs/persist-logs.js +491 -0
- package/sandbox/dist/tasks/refresh-s3-urls/refresh-s3-urls.js +214 -0
- package/sandbox/dist/tasks/review/check-run.js +326 -0
- package/sandbox/dist/tasks/review/comment-mapper.js +46 -0
- package/sandbox/dist/tasks/review/conversation-context.js +477 -0
- package/sandbox/dist/tasks/review/diff.js +95 -0
- package/sandbox/dist/tasks/review/engines/dedupe.js +291 -0
- package/sandbox/dist/tasks/review/engines/multi-focus.js +419 -0
- package/sandbox/dist/tasks/review/engines/shared.js +87 -0
- package/sandbox/dist/tasks/review/engines/types.js +1 -0
- package/sandbox/dist/tasks/review/finding-output.js +18 -0
- package/sandbox/dist/tasks/review/format.js +17 -0
- package/sandbox/dist/tasks/review/github-comment-format.js +94 -0
- package/sandbox/dist/tasks/review/local-git.js +374 -0
- package/sandbox/dist/tasks/review/local.js +118 -0
- package/sandbox/dist/tasks/review/multi-focus-prompt.js +59 -0
- package/sandbox/dist/tasks/review/prompt.js +81 -0
- package/sandbox/dist/tasks/review/review-feedback.js +48 -0
- package/sandbox/dist/tasks/review/review-mode-github.js +58 -0
- package/sandbox/dist/tasks/review/review-mode-policy.js +232 -0
- package/sandbox/dist/tasks/review/review-mode-types.js +1 -0
- package/sandbox/dist/tasks/review/review-mode.js +296 -0
- package/sandbox/dist/tasks/review/review-summary.js +123 -0
- package/sandbox/dist/tasks/review/review.js +391 -0
- package/sandbox/dist/tasks/review/runner.js +255 -0
- package/sandbox/dist/tasks/review/severity.js +5 -0
- package/sandbox/dist/tasks/review/summary-model.js +271 -0
- package/sandbox/dist/tasks/review/thinking-level.js +14 -0
- package/sandbox/dist/tasks/review/translation-review-policy.js +37 -0
- package/sandbox/dist/tasks/review/types.js +1 -0
- package/sandbox/dist/tasks/review-slice-plan/context.js +66 -0
- package/sandbox/dist/tasks/review-slice-plan/plan.js +210 -0
- package/sandbox/dist/tasks/review-slice-plan/prompt.js +59 -0
- package/sandbox/dist/tasks/review-slice-plan/review-slice-plan.js +367 -0
- package/sandbox/dist/tasks/sentry-triage/store.js +133 -0
- package/sandbox/dist/tasks/sentry-triage/triage.js +217 -0
- package/sandbox/node_modules/doistbot-logging/dist/index.d.ts +23 -0
- package/sandbox/node_modules/doistbot-logging/dist/index.js +51 -0
- package/sandbox/node_modules/doistbot-logging/package.json +12 -0
- package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +93 -0
- package/sandbox/node_modules/doistbot-repo-config/dist/index.js +197 -0
- package/sandbox/node_modules/doistbot-repo-config/package.json +12 -0
- package/sandbox/src/review/prompts/ai-internal-tools.md +254 -0
- package/sandbox/src/review/prompts/backend-repository-standards.md +102 -0
- package/sandbox/src/review/prompts/observability-standard.md +169 -0
- package/sandbox/src/review/prompts/outline-map.json +26 -0
- package/sandbox/src/review/prompts/secrets-management.md +286 -0
- package/sandbox/src/review/prompts/service-production-readiness.md +122 -0
- package/sandbox/src/review/prompts/workload-placement.md +345 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/efficiency.md +16 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/general.md +32 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/quality.md +20 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/reuse.md +13 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/security.md +18 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/standards.md +21 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/tests.md +19 -0
- package/sandbox/src/tasks/review/prompts/review-mode.md +40 -0
- package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +142 -0
- package/scripts/restore-runtime-assets.mjs +21 -0
- package/README.md +0 -3
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getRepoConfig } from 'doistbot-repo-config';
|
|
2
|
+
export function isAutoTriageEnabled(config) {
|
|
3
|
+
return config?.issue_triage?.enable_auto_triage !== false;
|
|
4
|
+
}
|
|
5
|
+
export function createRepoConfigLoader({ octokit, logger, }) {
|
|
6
|
+
const cache = new Map();
|
|
7
|
+
return async function loadRepoConfig(repository) {
|
|
8
|
+
const cached = cache.get(repository.fullName);
|
|
9
|
+
if (cached !== undefined) {
|
|
10
|
+
return cached;
|
|
11
|
+
}
|
|
12
|
+
const config = (await getRepoConfig({
|
|
13
|
+
octokit,
|
|
14
|
+
owner: repository.owner,
|
|
15
|
+
repo: repository.repo,
|
|
16
|
+
logger,
|
|
17
|
+
})) ?? null;
|
|
18
|
+
cache.set(repository.fullName, config);
|
|
19
|
+
return config;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Returns true only when every repository allows the feature. Treats a config
|
|
24
|
+
* load error as an opt-out (fail closed) so transient GitHub API failures
|
|
25
|
+
* cannot silently override an explicit `enable_*: false`.
|
|
26
|
+
*/
|
|
27
|
+
export async function allRepositoriesAllow({ repositories, loadRepoConfig, isEnabled, feature, logger, }) {
|
|
28
|
+
for (const repository of repositories) {
|
|
29
|
+
let config;
|
|
30
|
+
try {
|
|
31
|
+
config = await loadRepoConfig(repository);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
logger.warn('Failed to load repo config; failing closed', {
|
|
35
|
+
component: 'issue-triage',
|
|
36
|
+
feature,
|
|
37
|
+
repository: repository.fullName,
|
|
38
|
+
error: error instanceof Error ? error.message : String(error),
|
|
39
|
+
});
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (!isEnabled(config)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CONFIG_FILE_PATH, getLocalRepoConfig } from 'doistbot-repo-config';
|
|
4
|
+
import { hashAutofixInputs } from '../../core/autofix-verification.js';
|
|
5
|
+
import { logger } from '../../core/logging.js';
|
|
6
|
+
import { loadPromptTemplateFile, resolvePromptFile } from '../../core/prompt-template.js';
|
|
7
|
+
const MAX_PROMPT_BYTES = 64 * 1024;
|
|
8
|
+
function isWithin(root, file) {
|
|
9
|
+
const relative = path.relative(root, file);
|
|
10
|
+
return (relative !== '' &&
|
|
11
|
+
relative !== '..' &&
|
|
12
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
13
|
+
!path.isAbsolute(relative));
|
|
14
|
+
}
|
|
15
|
+
/** Read once before model execution; never cache repository files across runs. */
|
|
16
|
+
export function loadRepositoryWorkflow(repoPath, phase) {
|
|
17
|
+
const config = getLocalRepoConfig({ workspaceDir: repoPath, logger, strict: true });
|
|
18
|
+
const section = config?.issue_triage;
|
|
19
|
+
if (section === undefined)
|
|
20
|
+
return null;
|
|
21
|
+
if (!section || typeof section !== 'object' || Array.isArray(section))
|
|
22
|
+
throw new Error(`Invalid issue_triage in ${CONFIG_FILE_PATH}: expected an object`);
|
|
23
|
+
const prompts = section.prompts;
|
|
24
|
+
if (prompts === undefined)
|
|
25
|
+
return null;
|
|
26
|
+
if (!prompts || typeof prompts !== 'object' || Array.isArray(prompts))
|
|
27
|
+
throw new Error(`Invalid issue_triage.prompts in ${CONFIG_FILE_PATH}: expected an object`);
|
|
28
|
+
const source = prompts[phase];
|
|
29
|
+
if (source === undefined)
|
|
30
|
+
return null;
|
|
31
|
+
if (typeof source !== 'string' || !source.trim() || path.isAbsolute(source))
|
|
32
|
+
throw new Error(`issue_triage.prompts.${phase} must be a non-empty repository-relative path`);
|
|
33
|
+
try {
|
|
34
|
+
const root = realpathSync(repoPath);
|
|
35
|
+
const candidate = path.resolve(root, source);
|
|
36
|
+
if (!isWithin(root, candidate))
|
|
37
|
+
throw new Error('path must stay inside the repository');
|
|
38
|
+
const file = realpathSync(candidate);
|
|
39
|
+
if (!isWithin(root, file))
|
|
40
|
+
throw new Error('symlink must stay inside the repository');
|
|
41
|
+
const stat = statSync(file);
|
|
42
|
+
if (!stat.isFile() || stat.size > MAX_PROMPT_BYTES)
|
|
43
|
+
throw new Error('expected a file of at most 64 KiB');
|
|
44
|
+
const content = readFileSync(file, 'utf8');
|
|
45
|
+
if (!content.trim() || content.includes('\0'))
|
|
46
|
+
throw new Error('expected non-empty Markdown text');
|
|
47
|
+
const hash = hashAutofixInputs(content);
|
|
48
|
+
logger.info('Loaded repository workflow prompt', {
|
|
49
|
+
repositoryPath: repoPath,
|
|
50
|
+
phase,
|
|
51
|
+
source,
|
|
52
|
+
hash,
|
|
53
|
+
});
|
|
54
|
+
return { source, content, hash };
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new Error(`Cannot load issue_triage.prompts.${phase} (${source}): ${error instanceof Error ? error.message : String(error)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function describeWorkflow(workflow, defaultFileName) {
|
|
61
|
+
return [
|
|
62
|
+
`Workflow source: ${workflow ? `${workflow.source} (sha256: ${workflow.hash})` : 'Doistbot default'}`,
|
|
63
|
+
'Follow these workflow instructions for this repository. The shared task rules, output format, tool limits, and verification rules still apply and take precedence.',
|
|
64
|
+
'',
|
|
65
|
+
workflow?.content ??
|
|
66
|
+
loadPromptTemplateFile({
|
|
67
|
+
fileName: resolvePromptFile(import.meta.url, defaultFileName),
|
|
68
|
+
promptLabel: defaultFileName,
|
|
69
|
+
}),
|
|
70
|
+
].join('\n');
|
|
71
|
+
}
|
|
72
|
+
/** Preserve existing checkpoint identities for repositories using the default workflow. */
|
|
73
|
+
export function serializeAutofixPolicy(policy, workflow) {
|
|
74
|
+
return workflow
|
|
75
|
+
? JSON.stringify({ policy, workflow: { source: workflow.source, hash: workflow.hash } })
|
|
76
|
+
: JSON.stringify(policy);
|
|
77
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { escapeReportText } from './report.js';
|
|
2
|
+
import { createTriageReport } from './report-history.js';
|
|
3
|
+
export const TRIAGE_ISSUE_URL = 'https://github.com/Doist/Issues/issues/42';
|
|
4
|
+
export const TRIAGE_TARGET_REPO = 'Doist/todoist-web';
|
|
5
|
+
export function triageReport(overrides = {}) {
|
|
6
|
+
return createTriageReport({
|
|
7
|
+
analysis: triageAnalysis(),
|
|
8
|
+
issueUrl: TRIAGE_ISSUE_URL,
|
|
9
|
+
repositories: [TRIAGE_TARGET_REPO],
|
|
10
|
+
history: { latest: null, pending: null },
|
|
11
|
+
now: new Date('2026-10-01T00:00:00Z'),
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function triageAnalysis() {
|
|
16
|
+
return {
|
|
17
|
+
summary: 'Missing creator data leaves an email placeholder visible. A fallback can preserve the expected banner text.',
|
|
18
|
+
investigation: `## Findings
|
|
19
|
+
|
|
20
|
+
The banner should show admin guidance. At src/banner.tsx:42 in ${TRIAGE_TARGET_REPO}, the template accepts an undefined email. The focused component test reproduces the visible placeholder when creator data is missing.
|
|
21
|
+
|
|
22
|
+
## Proposed fix
|
|
23
|
+
|
|
24
|
+
Use existing admin guidance when the email is missing. Run banner tests with valid and missing creator data.`,
|
|
25
|
+
decision: {
|
|
26
|
+
classification: 'auto-fixable',
|
|
27
|
+
confidence: 'high',
|
|
28
|
+
scope: 'Add fallback admin guidance.',
|
|
29
|
+
targetRepo: TRIAGE_TARGET_REPO,
|
|
30
|
+
},
|
|
31
|
+
reinvestigation: { kind: 'none' },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function loggingAnalysis() {
|
|
35
|
+
return {
|
|
36
|
+
...triageAnalysis(),
|
|
37
|
+
summary: 'The cause is not established. Log creator-data presence to test the hypothesis.',
|
|
38
|
+
investigation: `The creator data may be absent when Banner renders in src/banner.tsx.
|
|
39
|
+
|
|
40
|
+
Add banner.creator_state with hasCreator and hasEmail presence flags. Do not record email addresses. Query with pup: service:todoist-web @event:banner.creator_state. A false hasEmail distinguishes missing data from a rendering failure. Check that events contain only presence flags. Observe for 7 days after release.`,
|
|
41
|
+
decision: {
|
|
42
|
+
classification: 'logs-needed',
|
|
43
|
+
confidence: 'medium',
|
|
44
|
+
scope: 'Record whether creator data reaches the banner.',
|
|
45
|
+
targetRepo: TRIAGE_TARGET_REPO,
|
|
46
|
+
observationDaysAfterRelease: 7,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function triageModelOutput(analysis = triageAnalysis()) {
|
|
51
|
+
const { summary, investigation, ...metadata } = analysis;
|
|
52
|
+
return `${summary}\n\n<!--doistbot-triage-metadata\n${JSON.stringify(metadata)}\n-->\n\n<!--doistbot-triage-report\n${escapeReportText(investigation)}\n-->`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { repositoryKey } from 'doistbot-issue-routing';
|
|
3
|
+
import { isKnownDoistbotLogin } from '../../core/bot-logins.js';
|
|
4
|
+
import { githubHistoryIdentity } from '../../core/github-history.js';
|
|
5
|
+
import { MAX_LOGGING_ROUNDS, readTriageReport, validateTriageDecision } from './report.js';
|
|
6
|
+
export function readTriageHistory(comments, issueUrl) {
|
|
7
|
+
const reports = new Map();
|
|
8
|
+
let latest = null;
|
|
9
|
+
for (const comment of [...comments].sort((a, b) => a.id - b.id)) {
|
|
10
|
+
if (!isKnownDoistbotLogin(comment.user?.login))
|
|
11
|
+
continue;
|
|
12
|
+
const report = readTriageReport(comment.body ?? '');
|
|
13
|
+
if (!report || githubHistoryIdentity(report.issueUrl) !== githubHistoryIdentity(issueUrl))
|
|
14
|
+
continue;
|
|
15
|
+
latest = { report, commentUrl: comment.html_url };
|
|
16
|
+
reports.set(report.id, latest);
|
|
17
|
+
}
|
|
18
|
+
const pendingId = latest?.report.pendingLoggingReportId;
|
|
19
|
+
const pending = pendingId ? reports.get(pendingId) : null;
|
|
20
|
+
if (pendingId && pending?.report.analysis.decision.classification !== 'logs-needed') {
|
|
21
|
+
throw new Error('The previous logging report is missing. Restore it before another triage.');
|
|
22
|
+
}
|
|
23
|
+
return { latest, pending: pending ?? null };
|
|
24
|
+
}
|
|
25
|
+
function completedLoggingRound(analysis, history, now) {
|
|
26
|
+
const { reinvestigation } = analysis;
|
|
27
|
+
const pending = history.pending?.report;
|
|
28
|
+
if (!pending) {
|
|
29
|
+
if (reinvestigation.kind !== 'none')
|
|
30
|
+
throw new Error('No previous logging plan is awaiting investigation.');
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (reinvestigation.kind === 'none') {
|
|
34
|
+
throw new Error('Triage must account for the pending logging plan.');
|
|
35
|
+
}
|
|
36
|
+
if (reinvestigation.kind === 'pending')
|
|
37
|
+
return false;
|
|
38
|
+
const decision = pending.analysis.decision;
|
|
39
|
+
if (decision.classification !== 'logs-needed' ||
|
|
40
|
+
reinvestigation.loggingReportId !== pending.id) {
|
|
41
|
+
throw new Error('Reinvestigation does not reference the pending logging plan.');
|
|
42
|
+
}
|
|
43
|
+
const prPrefix = `https://github.com/${githubHistoryIdentity(decision.targetRepo)}/pull/`;
|
|
44
|
+
const loggingPrUrl = githubHistoryIdentity(reinvestigation.loggingPrUrl);
|
|
45
|
+
if (!loggingPrUrl.startsWith(prPrefix) ||
|
|
46
|
+
!/^\d+$/.test(loggingPrUrl.slice(prPrefix.length)) ||
|
|
47
|
+
!URL.canParse(reinvestigation.releaseUrl) ||
|
|
48
|
+
new URL(reinvestigation.releaseUrl).protocol !== 'https:') {
|
|
49
|
+
throw new Error('Reinvestigation requires a logging PR in the target repository and a release evidence URL.');
|
|
50
|
+
}
|
|
51
|
+
const releasedAt = Date.parse(reinvestigation.releasedAt);
|
|
52
|
+
const observationEndsAt = releasedAt + decision.observationDaysAfterRelease * 86_400_000;
|
|
53
|
+
if (!Number.isFinite(releasedAt) ||
|
|
54
|
+
releasedAt < Date.parse(pending.createdAt) ||
|
|
55
|
+
observationEndsAt > now.getTime()) {
|
|
56
|
+
throw new Error('The logging release and its observation period must precede reinvestigation.');
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
export function createTriageReport({ analysis, issueUrl, repositories, history, now = new Date(), }) {
|
|
61
|
+
const targetRepo = 'targetRepo' in analysis.decision ? analysis.decision.targetRepo : undefined;
|
|
62
|
+
if (targetRepo &&
|
|
63
|
+
!repositories.some((repo) => repositoryKey(repo) === repositoryKey(targetRepo))) {
|
|
64
|
+
throw new Error('The proposed target is outside the cloned repository scope.');
|
|
65
|
+
}
|
|
66
|
+
const completed = completedLoggingRound(analysis, history, now);
|
|
67
|
+
const completedLoggingRounds = (history.latest?.report.completedLoggingRounds ?? 0) + (completed ? 1 : 0);
|
|
68
|
+
if (completedLoggingRounds > MAX_LOGGING_ROUNDS)
|
|
69
|
+
throw new Error('Logging round limit exceeded.');
|
|
70
|
+
const id = randomUUID();
|
|
71
|
+
let pendingLoggingReportId = completed ? null : (history.pending?.report.id ?? null);
|
|
72
|
+
let finalAnalysis = analysis;
|
|
73
|
+
if (analysis.decision.classification === 'logs-needed') {
|
|
74
|
+
if (completedLoggingRounds === MAX_LOGGING_ROUNDS) {
|
|
75
|
+
const reason = 'Two logging rounds are complete. Further investigation needs an engineer.';
|
|
76
|
+
finalAnalysis = {
|
|
77
|
+
...analysis,
|
|
78
|
+
summary: reason,
|
|
79
|
+
decision: {
|
|
80
|
+
classification: 'needs-human',
|
|
81
|
+
confidence: analysis.decision.confidence,
|
|
82
|
+
scope: reason,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
pendingLoggingReportId = null;
|
|
86
|
+
}
|
|
87
|
+
else if (pendingLoggingReportId && history.pending) {
|
|
88
|
+
finalAnalysis = {
|
|
89
|
+
...analysis,
|
|
90
|
+
summary: 'The previous logging plan is still pending. Complete its release and observation checks before another logging round.',
|
|
91
|
+
decision: history.pending.report.analysis.decision,
|
|
92
|
+
investigation: `${analysis.investigation}\n\n## Pending logging plan\n\n${history.pending.report.analysis.investigation}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
pendingLoggingReportId = id;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (analysis.decision.classification === 'auto-fixable') {
|
|
100
|
+
pendingLoggingReportId = null;
|
|
101
|
+
}
|
|
102
|
+
validateTriageDecision(finalAnalysis.decision);
|
|
103
|
+
return {
|
|
104
|
+
schemaVersion: '1',
|
|
105
|
+
id,
|
|
106
|
+
issueUrl,
|
|
107
|
+
createdAt: now.toISOString(),
|
|
108
|
+
previousReportId: history.latest?.report.id ?? null,
|
|
109
|
+
completedLoggingRounds,
|
|
110
|
+
pendingLoggingReportId,
|
|
111
|
+
analysis: finalAnalysis,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Ajv } from 'ajv/dist/jtd.js';
|
|
2
|
+
const text = { type: 'string' };
|
|
3
|
+
const confidence = { enum: ['high', 'medium', 'low'] };
|
|
4
|
+
const handover = { properties: { confidence, scope: text } };
|
|
5
|
+
export const TRIAGE_METADATA_SCHEMA = {
|
|
6
|
+
properties: {
|
|
7
|
+
decision: {
|
|
8
|
+
discriminator: 'classification',
|
|
9
|
+
mapping: {
|
|
10
|
+
'auto-fixable': {
|
|
11
|
+
properties: {
|
|
12
|
+
confidence: { enum: ['high'] },
|
|
13
|
+
scope: text,
|
|
14
|
+
targetRepo: text,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
'logs-needed': {
|
|
18
|
+
properties: {
|
|
19
|
+
confidence,
|
|
20
|
+
scope: text,
|
|
21
|
+
targetRepo: text,
|
|
22
|
+
observationDaysAfterRelease: { type: 'uint16' },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
'needs-human': handover,
|
|
26
|
+
'insufficient-context': handover,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
reinvestigation: {
|
|
30
|
+
discriminator: 'kind',
|
|
31
|
+
mapping: {
|
|
32
|
+
none: { properties: {} },
|
|
33
|
+
pending: { properties: {} },
|
|
34
|
+
completed: {
|
|
35
|
+
properties: {
|
|
36
|
+
loggingReportId: text,
|
|
37
|
+
loggingPrUrl: text,
|
|
38
|
+
releaseUrl: text,
|
|
39
|
+
releasedAt: text,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const TRIAGE_STORED_METADATA_SCHEMA = {
|
|
47
|
+
properties: {
|
|
48
|
+
schemaVersion: { enum: ['1'] },
|
|
49
|
+
id: text,
|
|
50
|
+
issueUrl: text,
|
|
51
|
+
createdAt: text,
|
|
52
|
+
previousReportId: { ...text, nullable: true },
|
|
53
|
+
completedLoggingRounds: { type: 'uint8' },
|
|
54
|
+
pendingLoggingReportId: { ...text, nullable: true },
|
|
55
|
+
...TRIAGE_METADATA_SCHEMA.properties,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const ajv = new Ajv();
|
|
59
|
+
export const isTriageMetadata = ajv.compile(TRIAGE_METADATA_SCHEMA);
|
|
60
|
+
export const isStoredTriageMetadata = ajv.compile(TRIAGE_STORED_METADATA_SCHEMA);
|
|
61
|
+
export function triageSchemaError() {
|
|
62
|
+
return ajv.errorsText(isTriageMetadata.errors);
|
|
63
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { isStoredTriageMetadata, isTriageMetadata, triageSchemaError } from './report-schema.js';
|
|
2
|
+
export const TRIAGE_METADATA_MARKER = '<!--doistbot-triage-metadata';
|
|
3
|
+
export const TRIAGE_REPORT_MARKER = '<!--doistbot-triage-report';
|
|
4
|
+
export const TRIAGE_REJECTED_MARKER = '<!--doistbot-triage-rejected';
|
|
5
|
+
export const MAX_LOGGING_ROUNDS = 2;
|
|
6
|
+
export const AUTO_FIX_START_NOTICE = "**I'm starting an AutoFix attempt for this report**";
|
|
7
|
+
export function escapeReportText(text) {
|
|
8
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
9
|
+
}
|
|
10
|
+
function readBlock(body, marker) {
|
|
11
|
+
const start = body.indexOf(marker);
|
|
12
|
+
const end = body.indexOf('-->', start + marker.length);
|
|
13
|
+
if (start < 0 || end < 0 || body.indexOf(marker, start + 1) >= 0) {
|
|
14
|
+
throw new Error(`Missing, incomplete, or duplicate triage block: ${marker.slice(4)}`);
|
|
15
|
+
}
|
|
16
|
+
return { start, end: end + 3, text: body.slice(start + marker.length, end).trim() };
|
|
17
|
+
}
|
|
18
|
+
function readReportParts(body) {
|
|
19
|
+
const metadata = readBlock(body, TRIAGE_METADATA_MARKER);
|
|
20
|
+
const report = readBlock(body, TRIAGE_REPORT_MARKER);
|
|
21
|
+
if (metadata.end > report.start ||
|
|
22
|
+
body.slice(metadata.end, report.start).trim() ||
|
|
23
|
+
body.slice(report.end).trim()) {
|
|
24
|
+
throw new Error('Place the summary first, then metadata, then the hidden Markdown report.');
|
|
25
|
+
}
|
|
26
|
+
const visibleSummary = body
|
|
27
|
+
.slice(0, metadata.start)
|
|
28
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
29
|
+
.trim();
|
|
30
|
+
const summary = visibleSummary.endsWith(AUTO_FIX_START_NOTICE)
|
|
31
|
+
? visibleSummary.slice(0, -AUTO_FIX_START_NOTICE.length).trim()
|
|
32
|
+
: visibleSummary;
|
|
33
|
+
const investigation = report.text.replace(/&(amp|lt|gt);/g, (entity) => {
|
|
34
|
+
if (entity === '<')
|
|
35
|
+
return '<';
|
|
36
|
+
if (entity === '>')
|
|
37
|
+
return '>';
|
|
38
|
+
return '&';
|
|
39
|
+
});
|
|
40
|
+
if (!summary || !investigation) {
|
|
41
|
+
throw new Error('Triage requires a summary and a Markdown investigation report.');
|
|
42
|
+
}
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(metadata.text);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new Error('Triage workflow metadata is not valid JSON.');
|
|
49
|
+
}
|
|
50
|
+
return { summary, investigation, metadata: parsed };
|
|
51
|
+
}
|
|
52
|
+
export function validateTriageDecision(decision) {
|
|
53
|
+
if (!decision.scope.trim() || ('targetRepo' in decision && !decision.targetRepo.trim())) {
|
|
54
|
+
throw new Error('Triage workflow metadata requires a nonempty scope and target repository.');
|
|
55
|
+
}
|
|
56
|
+
if (decision.classification === 'logs-needed' &&
|
|
57
|
+
(decision.observationDaysAfterRelease < 1 || decision.observationDaysAfterRelease > 7)) {
|
|
58
|
+
throw new Error('decision.observationDaysAfterRelease must be between 1 and 7 days.');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function parseTriageAnalysis(rawOutput) {
|
|
62
|
+
const { metadata, ...prose } = readReportParts(rawOutput);
|
|
63
|
+
if (!isTriageMetadata(metadata)) {
|
|
64
|
+
throw new Error(`Invalid triage workflow metadata: ${triageSchemaError()}`);
|
|
65
|
+
}
|
|
66
|
+
const analysis = { ...prose, ...metadata };
|
|
67
|
+
validateTriageDecision(analysis.decision);
|
|
68
|
+
return analysis;
|
|
69
|
+
}
|
|
70
|
+
export function readTriageReport(body) {
|
|
71
|
+
if (body.includes(TRIAGE_REJECTED_MARKER))
|
|
72
|
+
return null;
|
|
73
|
+
if (!body.includes(TRIAGE_REPORT_MARKER) && !body.includes(TRIAGE_METADATA_MARKER))
|
|
74
|
+
return null;
|
|
75
|
+
const { metadata, ...prose } = readReportParts(body);
|
|
76
|
+
if (!isStoredTriageMetadata(metadata) || metadata.completedLoggingRounds > MAX_LOGGING_ROUNDS) {
|
|
77
|
+
throw new Error('Triage comment contains invalid or unsupported workflow metadata.');
|
|
78
|
+
}
|
|
79
|
+
if (!Number.isFinite(Date.parse(metadata.createdAt))) {
|
|
80
|
+
throw new Error('Triage comment contains an invalid creation time.');
|
|
81
|
+
}
|
|
82
|
+
const { decision, reinvestigation, ...stored } = metadata;
|
|
83
|
+
const analysis = { ...prose, decision, reinvestigation };
|
|
84
|
+
validateTriageDecision(analysis.decision);
|
|
85
|
+
return { ...stored, analysis };
|
|
86
|
+
}
|
|
87
|
+
export function renderTriageReport(report) {
|
|
88
|
+
const { analysis, ...stored } = report;
|
|
89
|
+
const { summary, investigation, ...workflow } = analysis;
|
|
90
|
+
const json = JSON.stringify({ ...stored, ...workflow })
|
|
91
|
+
.replace(/</g, '\\u003c')
|
|
92
|
+
.replace(/>/g, '\\u003e');
|
|
93
|
+
const visibleSummary = workflow.decision.classification === 'auto-fixable'
|
|
94
|
+
? `${summary.trim()}\n\n${AUTO_FIX_START_NOTICE}`
|
|
95
|
+
: summary.trim();
|
|
96
|
+
return `${visibleSummary}\n\n${TRIAGE_METADATA_MARKER}\n${json}\n-->\n\n${TRIAGE_REPORT_MARKER}\n${escapeReportText(investigation)}\n-->`;
|
|
97
|
+
}
|
|
98
|
+
export function reportAssessment(report) {
|
|
99
|
+
const { decision } = report.analysis;
|
|
100
|
+
return {
|
|
101
|
+
classification: decision.classification,
|
|
102
|
+
confidence: decision.confidence,
|
|
103
|
+
scope: decision.scope,
|
|
104
|
+
...('targetRepo' in decision ? { targetRepo: decision.targetRepo } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export function reportForFix(report) {
|
|
108
|
+
const { analysis, ...stored } = report;
|
|
109
|
+
const { summary, investigation, ...workflow } = analysis;
|
|
110
|
+
return `${summary}\n\n${investigation}\n\n## Triage workflow\n\n\`\`\`json\n${JSON.stringify({ ...stored, ...workflow }, null, 2)}\n\`\`\``;
|
|
111
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { resolveIssueRepositories, } from '../../core/repository-resolver.js';
|
|
2
|
+
import { recordDecision, withPhase } from '../../core/tracing.js';
|
|
3
|
+
export async function resolveTriageScope(params) {
|
|
4
|
+
return withPhase('triage.resolution', { 'labels.count': params.labelNames.length }, async (span) => {
|
|
5
|
+
const result = resolveIssueRepositories(params.labelNames);
|
|
6
|
+
recordDecision('triage.resolution.outcome', {
|
|
7
|
+
trigger: params.triggerType,
|
|
8
|
+
status: result.status,
|
|
9
|
+
confidence: result.confidence,
|
|
10
|
+
reason: result.reason,
|
|
11
|
+
decision_path: result.decisionPath,
|
|
12
|
+
parsed_products: result.parsedLabels.productLabels,
|
|
13
|
+
parsed_apps: result.parsedLabels.appLabels,
|
|
14
|
+
parsed_teams: result.parsedLabels.teamLabels,
|
|
15
|
+
parsed_integrations: result.parsedLabels.integrationLabels,
|
|
16
|
+
matched_routes: result.matchedRoutes,
|
|
17
|
+
matched_integrations: result.matchedIntegrations,
|
|
18
|
+
unknown_product_labels: result.parsedLabels.unknownProductLabels,
|
|
19
|
+
unknown_app_labels: result.parsedLabels.unknownAppLabels,
|
|
20
|
+
unknown_team_labels: result.parsedLabels.unknownTeamLabels,
|
|
21
|
+
unknown_integration_labels: result.parsedLabels.unknownIntegrationLabels,
|
|
22
|
+
});
|
|
23
|
+
span.setAttributes({
|
|
24
|
+
'resolution.status': result.status,
|
|
25
|
+
'resolution.confidence': result.confidence,
|
|
26
|
+
'resolution.repos.count': result.status === 'resolved' ? result.repositories.length : 0,
|
|
27
|
+
});
|
|
28
|
+
return result;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { repositoryKey } from 'doistbot-issue-routing';
|
|
2
|
+
import { isKnownDoistbotLogin } from '../../core/bot-logins.js';
|
|
3
|
+
import { withRetry } from '../../core/retry.js';
|
|
4
|
+
import { withPhase } from '../../core/tracing.js';
|
|
5
|
+
import { shouldAttemptFix } from './fix-attempt.js';
|
|
6
|
+
import { GITHUB_REQUEST_TIMEOUT_MS } from './github-request.js';
|
|
7
|
+
import { CX_HERO_MANUAL_HANDLE, resolveHeroGroup } from './hero-group-map.js';
|
|
8
|
+
import { postIssueComment } from './side-effects.js';
|
|
9
|
+
export const HERO_ROUTING_MARKER = '<!--doistbot-hero-routing-->';
|
|
10
|
+
const CX_NO_PRODUCT_LABEL_COMMENT = `${HERO_ROUTING_MARKER}
|
|
11
|
+
|
|
12
|
+
${CX_HERO_MANUAL_HANDLE} This issue doesn't have a product label, so I can't route it. Could you please add the right label? I'll pick it up automatically once it's labeled.`;
|
|
13
|
+
const CX_UNRESOLVABLE_COMMENT = `${HERO_ROUTING_MARKER}
|
|
14
|
+
|
|
15
|
+
${CX_HERO_MANUAL_HANDLE} I couldn't determine which team to route this to based on its labels. Could you please take a look?`;
|
|
16
|
+
function hasProductLabel(labels) {
|
|
17
|
+
return labels.some((label) => label.trim().toLowerCase().startsWith('product:'));
|
|
18
|
+
}
|
|
19
|
+
// The single sentence-boundary heuristic. Returns whole sentences or nothing —
|
|
20
|
+
// a half-sentence of context is what made the old routing comment worthless, so
|
|
21
|
+
// detail that doesn't fit is dropped, not clipped. Any complete sentence is worth
|
|
22
|
+
// keeping, however short: the boundary check already rules out fragments.
|
|
23
|
+
function sentencesWithin(text, maxChars) {
|
|
24
|
+
if (text.length <= maxChars) {
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
const clipped = text.slice(0, maxChars);
|
|
28
|
+
const sentenceEnd = Math.max(clipped.lastIndexOf('. '), clipped.lastIndexOf('! '), clipped.lastIndexOf('? '));
|
|
29
|
+
return sentenceEnd > 0 ? clipped.slice(0, sentenceEnd + 1) : undefined;
|
|
30
|
+
}
|
|
31
|
+
// As above, but for text we would rather shorten than lose. Falls back to a word
|
|
32
|
+
// boundary + ellipsis when the budget holds no complete sentence at all.
|
|
33
|
+
function trimToSentence(text, maxChars) {
|
|
34
|
+
const whole = sentencesWithin(text, maxChars);
|
|
35
|
+
if (whole !== undefined) {
|
|
36
|
+
return whole;
|
|
37
|
+
}
|
|
38
|
+
const capped = text.slice(0, maxChars - 3);
|
|
39
|
+
const wordEnd = capped.lastIndexOf(' ');
|
|
40
|
+
return `${(wordEnd > 0 ? capped.slice(0, wordEnd) : capped).trimEnd()}...`;
|
|
41
|
+
}
|
|
42
|
+
export function summarizeTriageReasoning(summary, maxChars = 320) {
|
|
43
|
+
const normalized = summary.replace(/\s+/g, ' ').trim();
|
|
44
|
+
return normalized
|
|
45
|
+
? trimToSentence(normalized, maxChars)
|
|
46
|
+
: 'See the triage summary above for details.';
|
|
47
|
+
}
|
|
48
|
+
function buildStatusLine(assessment, maxChars) {
|
|
49
|
+
if (!assessment) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const states = {
|
|
53
|
+
'insufficient-context': 'Blocked, no PR opened',
|
|
54
|
+
'needs-human': 'Needs a human, no PR opened',
|
|
55
|
+
'logs-needed': 'Logging plan ready',
|
|
56
|
+
};
|
|
57
|
+
const state = states[assessment.classification] ?? `No PR opened (${assessment.confidence} confidence)`;
|
|
58
|
+
const scope = sentencesWithin(assessment.scope.replace(/\s+/g, ' ').trim(), maxChars);
|
|
59
|
+
return scope ? `- **${state}** — ${scope}` : `- **${state}.**`;
|
|
60
|
+
}
|
|
61
|
+
// ~1 in 6 triages concludes the code lives outside the repos the labels resolved to.
|
|
62
|
+
// Without this the pinged team opens the issue, decides it isn't theirs, and bounces it.
|
|
63
|
+
function buildRepoMismatchLine(assessment, resolvedRepos) {
|
|
64
|
+
const targetRepo = assessment?.targetRepo?.trim();
|
|
65
|
+
if (!targetRepo || resolvedRepos.length === 0) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const matches = resolvedRepos.some((repo) => repositoryKey(repo) === repositoryKey(targetRepo));
|
|
69
|
+
if (matches) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return `- ⚠️ **Likely in \`${targetRepo}\`**, not ${resolvedRepos.map((repo) => `\`${repo}\``).join(' / ')} — may belong to another team.`;
|
|
73
|
+
}
|
|
74
|
+
export function buildTriageRecap({ summary, assessment, resolvedRepos = [], maxChars = 220, }) {
|
|
75
|
+
return [
|
|
76
|
+
buildRepoMismatchLine(assessment, resolvedRepos),
|
|
77
|
+
buildStatusLine(assessment, maxChars),
|
|
78
|
+
summarizeTriageReasoning(summary),
|
|
79
|
+
]
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join('\n\n');
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Returns the current issue assignees that the `todoist/Issues` auto-routing workflows assigned.
|
|
85
|
+
*
|
|
86
|
+
* Auto-routing is the only automation that assigns issues as Doistbot, so an assignee whose
|
|
87
|
+
* latest assignment event was made by Doistbot is treated as auto-routed.
|
|
88
|
+
*/
|
|
89
|
+
export async function findAutoRoutedAssignees({ octokit, ctx, }) {
|
|
90
|
+
const request = {
|
|
91
|
+
owner: ctx.owner,
|
|
92
|
+
repo: ctx.repo,
|
|
93
|
+
issue_number: ctx.issueNumber,
|
|
94
|
+
};
|
|
95
|
+
const { data: issue } = await withRetry(() => octokit.rest.issues.get({
|
|
96
|
+
...request,
|
|
97
|
+
request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
|
|
98
|
+
}));
|
|
99
|
+
const assignees = (issue.assignees ?? []).map((assignee) => assignee.login);
|
|
100
|
+
if (assignees.length === 0) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
const events = await withRetry(() => octokit.paginate(octokit.rest.issues.listEvents, {
|
|
104
|
+
...request,
|
|
105
|
+
per_page: 100,
|
|
106
|
+
request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
|
|
107
|
+
}));
|
|
108
|
+
const latestAssignmentByDoistbot = new Map();
|
|
109
|
+
for (const event of events) {
|
|
110
|
+
if (event.event !== 'assigned' && event.event !== 'unassigned') {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const login = 'assignee' in event ? event.assignee?.login?.toLowerCase() : undefined;
|
|
114
|
+
if (!login) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
latestAssignmentByDoistbot.set(login, event.event === 'assigned' &&
|
|
118
|
+
'assigner' in event &&
|
|
119
|
+
isKnownDoistbotLogin(event.assigner?.login));
|
|
120
|
+
}
|
|
121
|
+
return assignees.filter((login) => !isKnownDoistbotLogin(login) &&
|
|
122
|
+
latestAssignmentByDoistbot.get(login.toLowerCase()) === true);
|
|
123
|
+
}
|
|
124
|
+
export function canRouteToHeroGroup(labels, primaryRepo) {
|
|
125
|
+
return hasProductLabel(labels) && resolveHeroGroup(labels, primaryRepo) !== null;
|
|
126
|
+
}
|
|
127
|
+
function formatRoutingTarget(autoRoutedAssignees, heroMention) {
|
|
128
|
+
if (autoRoutedAssignees.length === 0) {
|
|
129
|
+
return heroMention;
|
|
130
|
+
}
|
|
131
|
+
const mentions = autoRoutedAssignees.map((login) => `@${login}`).join(', ');
|
|
132
|
+
return autoRoutedAssignees.length === 1
|
|
133
|
+
? `${mentions}, the auto-routed assignee,`
|
|
134
|
+
: `${mentions}, the auto-routed assignees,`;
|
|
135
|
+
}
|
|
136
|
+
export function buildHeroRoutingComment({ labels, primaryRepo, summary, assessment, resolvedRepos, autoRoutedAssignees = [], }) {
|
|
137
|
+
if (!hasProductLabel(labels)) {
|
|
138
|
+
return CX_NO_PRODUCT_LABEL_COMMENT;
|
|
139
|
+
}
|
|
140
|
+
const heroGroup = resolveHeroGroup(labels, primaryRepo);
|
|
141
|
+
if (!heroGroup) {
|
|
142
|
+
return CX_UNRESOLVABLE_COMMENT;
|
|
143
|
+
}
|
|
144
|
+
return `${HERO_ROUTING_MARKER}
|
|
145
|
+
|
|
146
|
+
Routing to ${formatRoutingTarget(autoRoutedAssignees, heroGroup.mention)} for triage.
|
|
147
|
+
|
|
148
|
+
${buildTriageRecap({ summary, assessment, resolvedRepos })}`;
|
|
149
|
+
}
|
|
150
|
+
export function shouldPostHeroRoutingComment(fixAssessment) {
|
|
151
|
+
if (!fixAssessment) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
return !shouldAttemptFix(fixAssessment);
|
|
155
|
+
}
|
|
156
|
+
export async function postHeroRoutingComment({ octokit, ctx, body, }) {
|
|
157
|
+
await postIssueComment(ctx, octokit, body, {
|
|
158
|
+
marker: HERO_ROUTING_MARKER,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
export async function postTriageRouting(params) {
|
|
162
|
+
await withPhase('triage.routing', { 'comment.body.length': params.body.length }, async () => {
|
|
163
|
+
await postHeroRoutingComment({
|
|
164
|
+
octokit: params.octokit,
|
|
165
|
+
ctx: params.ctx,
|
|
166
|
+
body: params.body,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|