@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,58 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
function readNumber(file) {
|
|
3
|
+
try {
|
|
4
|
+
const value = Number(readFileSync(file, 'utf8').trim());
|
|
5
|
+
return Number.isFinite(value) ? value : null;
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function readCounter(file, key) {
|
|
12
|
+
try {
|
|
13
|
+
const match = readFileSync(file, 'utf8').match(new RegExp(`^${key}\\s+(\\d+)$`, 'm'));
|
|
14
|
+
return match?.[1] ? Number(match[1]) : null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function sampleAutofixResources(elapsedMs) {
|
|
21
|
+
const cpuMicros = readCounter('/sys/fs/cgroup/cpu.stat', 'usage_usec');
|
|
22
|
+
const cpuNanos = cpuMicros === null ? readNumber('/sys/fs/cgroup/cpuacct/cpuacct.usage') : null;
|
|
23
|
+
return {
|
|
24
|
+
elapsedMs,
|
|
25
|
+
cpuSeconds: cpuMicros === null ? (cpuNanos === null ? null : cpuNanos / 1e9) : cpuMicros / 1e6,
|
|
26
|
+
memoryBytes: readNumber('/sys/fs/cgroup/memory.current') ??
|
|
27
|
+
readNumber('/sys/fs/cgroup/memory/memory.usage_in_bytes'),
|
|
28
|
+
memoryLimitBytes: readNumber('/sys/fs/cgroup/memory.max') ??
|
|
29
|
+
readNumber('/sys/fs/cgroup/memory/memory.limit_in_bytes'),
|
|
30
|
+
oomKills: readCounter('/sys/fs/cgroup/memory.events', 'oom_kill') ??
|
|
31
|
+
readCounter('/sys/fs/cgroup/memory/memory.oom_control', 'oom_kill'),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function summarizeAutofixResources(samples) {
|
|
35
|
+
const first = samples[0];
|
|
36
|
+
const last = samples.at(-1);
|
|
37
|
+
const memory = samples.flatMap((sample) => sample.memoryBytes === null ? [] : [sample.memoryBytes]);
|
|
38
|
+
let peakCpuCores = null;
|
|
39
|
+
for (let index = 1; index < samples.length; index++) {
|
|
40
|
+
const previous = samples[index - 1];
|
|
41
|
+
const current = samples[index];
|
|
42
|
+
const seconds = (current.elapsedMs - previous.elapsedMs) / 1000;
|
|
43
|
+
if (seconds > 0 && current.cpuSeconds !== null && previous.cpuSeconds !== null) {
|
|
44
|
+
peakCpuCores = Math.max(peakCpuCores ?? 0, (current.cpuSeconds - previous.cpuSeconds) / seconds);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
sampledPeakMemoryBytes: memory.length ? Math.max(...memory) : null,
|
|
49
|
+
memoryLimitBytes: last?.memoryLimitBytes ?? null,
|
|
50
|
+
cpuSeconds: first?.cpuSeconds != null && last?.cpuSeconds != null
|
|
51
|
+
? Math.max(0, last.cpuSeconds - first.cpuSeconds)
|
|
52
|
+
: null,
|
|
53
|
+
peakCpuCores,
|
|
54
|
+
oomKills: first?.oomKills != null && last?.oomKills != null
|
|
55
|
+
? Math.max(0, last.oomKills - first.oomKills)
|
|
56
|
+
: null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export function serviceQueryArgs(cli, args) {
|
|
2
|
+
if (!args.length || args.length > 100 || args.some((arg) => arg.includes('\0'))) {
|
|
3
|
+
throw new Error('Supply CLI arguments for a read-only evidence query');
|
|
4
|
+
}
|
|
5
|
+
if (!args.includes('--') &&
|
|
6
|
+
(args.at(-1) === '--help' || args.at(-1) === '-h' || args[0] === '--version'))
|
|
7
|
+
return args;
|
|
8
|
+
const [group, action] = args;
|
|
9
|
+
const route = `${group} ${action}`;
|
|
10
|
+
if (cli === 'gh') {
|
|
11
|
+
for (let index = 0; index < args.length; index++) {
|
|
12
|
+
const arg = args[index];
|
|
13
|
+
if (/^--hostname(?:=|$)/.test(arg))
|
|
14
|
+
throw new Error('GitHub evidence queries must use github.com');
|
|
15
|
+
if (/^https?:\/\//i.test(arg)) {
|
|
16
|
+
const url = new URL(arg);
|
|
17
|
+
if (url.protocol !== 'https:' ||
|
|
18
|
+
url.host !== 'github.com' ||
|
|
19
|
+
url.username ||
|
|
20
|
+
url.password)
|
|
21
|
+
throw new Error('GitHub evidence queries must use HTTPS on github.com');
|
|
22
|
+
}
|
|
23
|
+
const repo = arg === '-R' || arg === '--repo'
|
|
24
|
+
? args[index + 1]
|
|
25
|
+
: arg.startsWith('--repo=')
|
|
26
|
+
? arg.slice(7)
|
|
27
|
+
: arg.startsWith('-R')
|
|
28
|
+
? arg.slice(2)
|
|
29
|
+
: route === 'repo view' && index === 2 && !arg.startsWith('-')
|
|
30
|
+
? arg
|
|
31
|
+
: undefined;
|
|
32
|
+
if (repo &&
|
|
33
|
+
!repo.startsWith('https://github.com/') &&
|
|
34
|
+
repo.split('/').length > 2 &&
|
|
35
|
+
!repo.startsWith('github.com/'))
|
|
36
|
+
throw new Error('GitHub repository queries must use github.com');
|
|
37
|
+
}
|
|
38
|
+
if (group === 'api') {
|
|
39
|
+
if (args.some((arg) => /^-(?:X|f|F)|^--(?:method|field|raw-field|input)(?:=|$)/.test(arg))) {
|
|
40
|
+
throw new Error('GitHub API queries use GET and cannot supply a request body');
|
|
41
|
+
}
|
|
42
|
+
return [...args, '--method', 'GET', '--hostname', 'github.com'];
|
|
43
|
+
}
|
|
44
|
+
if ([
|
|
45
|
+
'issue view',
|
|
46
|
+
'issue list',
|
|
47
|
+
'pr view',
|
|
48
|
+
'pr diff',
|
|
49
|
+
'pr list',
|
|
50
|
+
'pr checks',
|
|
51
|
+
'repo view',
|
|
52
|
+
'run view',
|
|
53
|
+
'run list',
|
|
54
|
+
'search issues',
|
|
55
|
+
'search prs',
|
|
56
|
+
'search code',
|
|
57
|
+
'search commits',
|
|
58
|
+
].includes(route))
|
|
59
|
+
return args;
|
|
60
|
+
}
|
|
61
|
+
if (cli === 'pup' &&
|
|
62
|
+
['logs search', 'logs query', 'logs list', 'logs aggregate'].includes(route))
|
|
63
|
+
return args;
|
|
64
|
+
if (cli === 'sentry' &&
|
|
65
|
+
['issue', 'event', 'trace', 'span', 'project', 'org'].includes(group ?? '') &&
|
|
66
|
+
['view', 'get', 'list', 'search'].includes(action ?? ''))
|
|
67
|
+
return args;
|
|
68
|
+
throw new Error('Only read-only GitHub, Datadog log, and Sentry evidence queries are supported');
|
|
69
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
const UI_TEST_PATH = /(?:^|\/)(?:androidTest|uiTests?|uitests?|e2e|cypress|\.maestro)(?:\/|$)|(?:^|\/)[^/]*(?:UITests|UiTest|\.ui\.test\.|\.e2e\.)/i;
|
|
3
|
+
const UI_TEST_FRAMEWORK = /^\s*(?:import\b.*|from\s+|}\s*from\s+).*(?:androidx\.(?:test\.(?:espresso|uiautomator)|compose\.ui\.test)|app\.cash\.paparazzi|AndroidJUnit4|@playwright\/test)|^\s*@RunWith\(AndroidJUnit4|^\s*(?:(?:let|var|val|const)\s+\w+\s*=\s*)?(?:XCUIApplication|Espresso|UiDevice|composeTestRule|browser\.url|cy\.(?:visit|get))\b/m;
|
|
4
|
+
export function isForbiddenAutofixTest(file, extraPaths = [], content = '') {
|
|
5
|
+
const normalized = file.replaceAll('\\', '/');
|
|
6
|
+
return (UI_TEST_PATH.test(normalized) ||
|
|
7
|
+
UI_TEST_FRAMEWORK.test(content) ||
|
|
8
|
+
extraPaths.some((pattern) => normalized === pattern ||
|
|
9
|
+
normalized.startsWith(`${pattern.replace(/\/$/, '')}/`) ||
|
|
10
|
+
path.matchesGlob(normalized, pattern)));
|
|
11
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { Ajv } from 'ajv';
|
|
5
|
+
import { buildVerificationEnvironment } from './tool-environment.js';
|
|
6
|
+
import { safeToolOutput } from './tool-output.js';
|
|
7
|
+
export const AUTOFIX_RUNTIME_ENV = 'DOISTBOT_AUTOFIX_RUNTIME';
|
|
8
|
+
const OUTPUT_LIMIT = 64_000;
|
|
9
|
+
export const autofixPolicySchema = {
|
|
10
|
+
type: 'object',
|
|
11
|
+
additionalProperties: false,
|
|
12
|
+
required: ['verification'],
|
|
13
|
+
properties: {
|
|
14
|
+
guidance: { type: 'string', maxLength: 8000 },
|
|
15
|
+
forbidden_test_paths: { type: 'array', items: { type: 'string', minLength: 1 } },
|
|
16
|
+
verification: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
required: ['checks'],
|
|
20
|
+
properties: {
|
|
21
|
+
checks: {
|
|
22
|
+
type: 'array',
|
|
23
|
+
maxItems: 20,
|
|
24
|
+
items: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
required: ['name', 'command'],
|
|
28
|
+
properties: {
|
|
29
|
+
name: { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,64}$' },
|
|
30
|
+
command: { type: 'string', minLength: 1, maxLength: 4000 },
|
|
31
|
+
use_test_selectors: { type: 'boolean' },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
export const validateAutofixPolicy = new Ajv().compile(autofixPolicySchema);
|
|
40
|
+
const checkResultProperties = {
|
|
41
|
+
name: { type: 'string' },
|
|
42
|
+
command: { type: 'string' },
|
|
43
|
+
round: { type: 'integer', minimum: 1 },
|
|
44
|
+
fingerprint: { type: 'string' },
|
|
45
|
+
testSelectors: {
|
|
46
|
+
type: 'array',
|
|
47
|
+
maxItems: 30,
|
|
48
|
+
items: { type: 'string', maxLength: 200 },
|
|
49
|
+
},
|
|
50
|
+
startedAtMs: { type: 'integer' },
|
|
51
|
+
buildStartedAtMs: { type: ['integer', 'null'] },
|
|
52
|
+
buildDurationMs: { type: ['number', 'null'] },
|
|
53
|
+
setupDurationMs: { type: ['number', 'null'] },
|
|
54
|
+
durationMs: { type: 'number', minimum: 0 },
|
|
55
|
+
exitCode: { type: ['integer', 'null'] },
|
|
56
|
+
signal: { type: ['string', 'null'] },
|
|
57
|
+
timedOut: { type: 'boolean' },
|
|
58
|
+
output: { type: 'string', maxLength: OUTPUT_LIMIT },
|
|
59
|
+
outputTruncated: { type: 'boolean' },
|
|
60
|
+
status: { enum: ['running', 'finished'] },
|
|
61
|
+
};
|
|
62
|
+
export const verificationStateSchema = {
|
|
63
|
+
type: 'object',
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
required: ['roundsUsed', 'results'],
|
|
66
|
+
properties: {
|
|
67
|
+
roundsUsed: { type: 'integer', minimum: 0 },
|
|
68
|
+
testSelectors: checkResultProperties.testSelectors,
|
|
69
|
+
results: {
|
|
70
|
+
type: 'array',
|
|
71
|
+
items: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
required: Object.keys(checkResultProperties),
|
|
75
|
+
properties: checkResultProperties,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const validateState = new Ajv().compile(verificationStateSchema);
|
|
81
|
+
const validateResult = new Ajv().compile({
|
|
82
|
+
type: 'object',
|
|
83
|
+
additionalProperties: false,
|
|
84
|
+
required: Object.keys(checkResultProperties),
|
|
85
|
+
properties: checkResultProperties,
|
|
86
|
+
});
|
|
87
|
+
const validateRuntime = new Ajv().compile({
|
|
88
|
+
type: 'object',
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
required: ['repoPath', 'statePath', 'deadlineMs', 'policy'],
|
|
91
|
+
properties: {
|
|
92
|
+
repoPath: { type: 'string', minLength: 1 },
|
|
93
|
+
statePath: { type: 'string', minLength: 1 },
|
|
94
|
+
deadlineMs: { type: 'integer', minimum: 1 },
|
|
95
|
+
policy: autofixPolicySchema,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
export function readAutofixRuntime(file) {
|
|
99
|
+
const data = JSON.parse(readFileSync(file, 'utf8'));
|
|
100
|
+
if (!validateRuntime(data))
|
|
101
|
+
throw new Error('Invalid autofix runtime configuration');
|
|
102
|
+
return data;
|
|
103
|
+
}
|
|
104
|
+
export function readVerificationState(file) {
|
|
105
|
+
const data = JSON.parse(readFileSync(file, 'utf8'));
|
|
106
|
+
if (!validateState(data))
|
|
107
|
+
throw new Error('Invalid autofix verification state');
|
|
108
|
+
let partial;
|
|
109
|
+
try {
|
|
110
|
+
partial = JSON.parse(readFileSync(`${file}.running`, 'utf8'));
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
if (partial !== undefined) {
|
|
117
|
+
if (!validateResult(partial))
|
|
118
|
+
throw new Error('Invalid running verification result');
|
|
119
|
+
const index = data.results.findIndex((result) => result.status === 'running' &&
|
|
120
|
+
result.name === partial.name &&
|
|
121
|
+
result.round === partial.round &&
|
|
122
|
+
result.startedAtMs === partial.startedAtMs &&
|
|
123
|
+
result.command === partial.command &&
|
|
124
|
+
result.fingerprint === partial.fingerprint);
|
|
125
|
+
if (index >= 0)
|
|
126
|
+
data.results[index] = partial;
|
|
127
|
+
}
|
|
128
|
+
return data;
|
|
129
|
+
}
|
|
130
|
+
export function saveVerificationState(file, state) {
|
|
131
|
+
writeFileSync(`${file}.tmp`, JSON.stringify(state), { mode: 0o600 });
|
|
132
|
+
renameSync(`${file}.tmp`, file);
|
|
133
|
+
}
|
|
134
|
+
export function selectCheckResult(state, check, fingerprint) {
|
|
135
|
+
const results = [...state.results]
|
|
136
|
+
.reverse()
|
|
137
|
+
.filter((result) => result.name === check.name && result.command === check.command);
|
|
138
|
+
const current = results.filter((result) => isCurrentCheck(result, check, fingerprint, state));
|
|
139
|
+
return (current.find((result) => result.status === 'finished' && result.exitCode === 0 && !result.timedOut) ??
|
|
140
|
+
current[0] ??
|
|
141
|
+
results[0]);
|
|
142
|
+
}
|
|
143
|
+
export function isCurrentCheck(result, check, fingerprint, state) {
|
|
144
|
+
return (result.fingerprint === fingerprint &&
|
|
145
|
+
JSON.stringify(result.testSelectors) ===
|
|
146
|
+
JSON.stringify(check.use_test_selectors ? (state.testSelectors ?? []) : []));
|
|
147
|
+
}
|
|
148
|
+
export function summarizeVerification(state, policy, fingerprint) {
|
|
149
|
+
const summary = {
|
|
150
|
+
roundsUsed: state.roundsUsed,
|
|
151
|
+
results: policy.verification.checks.map((check) => {
|
|
152
|
+
const result = selectCheckResult(state, check, fingerprint);
|
|
153
|
+
return {
|
|
154
|
+
name: check.name,
|
|
155
|
+
status: result
|
|
156
|
+
? isCurrentCheck(result, check, fingerprint, state)
|
|
157
|
+
? result.status
|
|
158
|
+
: 'stale'
|
|
159
|
+
: 'not-run',
|
|
160
|
+
exitCode: result?.exitCode ?? null,
|
|
161
|
+
signal: result?.signal ?? null,
|
|
162
|
+
timedOut: result?.timedOut ?? false,
|
|
163
|
+
round: result?.round ?? null,
|
|
164
|
+
durationMs: result?.durationMs ?? null,
|
|
165
|
+
buildDurationMs: result?.buildDurationMs ?? null,
|
|
166
|
+
setupDurationMs: result?.setupDurationMs ?? null,
|
|
167
|
+
output: result?.output.slice(-6000) ?? '',
|
|
168
|
+
outputTruncated: result
|
|
169
|
+
? result.outputTruncated || result.output.length > 6000
|
|
170
|
+
: false,
|
|
171
|
+
};
|
|
172
|
+
}),
|
|
173
|
+
};
|
|
174
|
+
let text = JSON.stringify(summary);
|
|
175
|
+
while (text.length > 24_000) {
|
|
176
|
+
for (const result of summary.results) {
|
|
177
|
+
if (result.output) {
|
|
178
|
+
result.output = result.output.slice(-Math.floor(result.output.length / 2));
|
|
179
|
+
if (result.output.length === 1)
|
|
180
|
+
result.output = '';
|
|
181
|
+
result.outputTruncated = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
text = JSON.stringify(summary);
|
|
185
|
+
}
|
|
186
|
+
return text;
|
|
187
|
+
}
|
|
188
|
+
export class AutofixVerifier {
|
|
189
|
+
runtime;
|
|
190
|
+
fingerprint;
|
|
191
|
+
record;
|
|
192
|
+
busy = false;
|
|
193
|
+
constructor(runtime, fingerprint, record) {
|
|
194
|
+
this.runtime = runtime;
|
|
195
|
+
this.fingerprint = fingerprint;
|
|
196
|
+
this.record = record;
|
|
197
|
+
}
|
|
198
|
+
async verify(names, signal, testSelectors) {
|
|
199
|
+
if (this.busy)
|
|
200
|
+
throw new Error('A verification round is already running');
|
|
201
|
+
this.busy = true;
|
|
202
|
+
try {
|
|
203
|
+
const { policy, statePath, deadlineMs } = this.runtime;
|
|
204
|
+
const requested = new Set(names);
|
|
205
|
+
const selected = policy.verification.checks.filter((check) => requested.has(check.name));
|
|
206
|
+
if (requested.size === 0 || selected.length !== requested.size) {
|
|
207
|
+
throw new Error('Select one or more check names from the repository policy');
|
|
208
|
+
}
|
|
209
|
+
const state = readVerificationState(statePath);
|
|
210
|
+
const checks = state.roundsUsed === 0 ? policy.verification.checks : selected;
|
|
211
|
+
const fingerprint = this.fingerprint();
|
|
212
|
+
state.testSelectors = testSelectors ?? state.testSelectors ?? [];
|
|
213
|
+
const pending = checks.filter((check) => {
|
|
214
|
+
const result = selectCheckResult(state, check, fingerprint);
|
|
215
|
+
return (!result ||
|
|
216
|
+
!isCurrentCheck(result, check, fingerprint, state) ||
|
|
217
|
+
result.status !== 'finished' ||
|
|
218
|
+
result.exitCode !== 0 ||
|
|
219
|
+
result.timedOut);
|
|
220
|
+
});
|
|
221
|
+
saveVerificationState(statePath, state);
|
|
222
|
+
if (pending.length === 0)
|
|
223
|
+
return state;
|
|
224
|
+
if (signal?.aborted || Date.now() >= deadlineMs)
|
|
225
|
+
throw new Error('Autofix time limit reached');
|
|
226
|
+
state.roundsUsed++;
|
|
227
|
+
saveVerificationState(statePath, state);
|
|
228
|
+
for (const check of pending) {
|
|
229
|
+
if (signal?.aborted || Date.now() >= deadlineMs)
|
|
230
|
+
break;
|
|
231
|
+
const result = {
|
|
232
|
+
name: check.name,
|
|
233
|
+
command: check.command,
|
|
234
|
+
round: state.roundsUsed,
|
|
235
|
+
fingerprint,
|
|
236
|
+
testSelectors: check.use_test_selectors ? state.testSelectors : [],
|
|
237
|
+
startedAtMs: Date.now(),
|
|
238
|
+
buildStartedAtMs: null,
|
|
239
|
+
buildDurationMs: null,
|
|
240
|
+
setupDurationMs: null,
|
|
241
|
+
durationMs: 0,
|
|
242
|
+
exitCode: null,
|
|
243
|
+
signal: null,
|
|
244
|
+
timedOut: false,
|
|
245
|
+
output: '',
|
|
246
|
+
outputTruncated: false,
|
|
247
|
+
status: 'running',
|
|
248
|
+
};
|
|
249
|
+
state.results.push(result);
|
|
250
|
+
saveVerificationState(statePath, state);
|
|
251
|
+
await this.runCheck(result, state, signal);
|
|
252
|
+
}
|
|
253
|
+
return state;
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
this.busy = false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async runCheck(result, state, signal) {
|
|
260
|
+
const startedAt = Date.now();
|
|
261
|
+
const child = spawn('/bin/sh', ['-c', result.command], {
|
|
262
|
+
cwd: this.runtime.repoPath,
|
|
263
|
+
detached: process.platform !== 'win32',
|
|
264
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
265
|
+
env: {
|
|
266
|
+
...buildVerificationEnvironment(),
|
|
267
|
+
DOISTBOT_TEST_SELECTORS: result.testSelectors.join('\n'),
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
let rawTail = '';
|
|
271
|
+
const { deadlineMs, statePath } = this.runtime;
|
|
272
|
+
let stopping;
|
|
273
|
+
function stop() {
|
|
274
|
+
if (stopping)
|
|
275
|
+
return;
|
|
276
|
+
result.timedOut = Date.now() >= deadlineMs;
|
|
277
|
+
function kill(signalName) {
|
|
278
|
+
try {
|
|
279
|
+
if (child.pid && process.platform !== 'win32')
|
|
280
|
+
process.kill(-child.pid, signalName);
|
|
281
|
+
else
|
|
282
|
+
child.kill(signalName);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
/* The process can finish before cancellation. */
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
kill('SIGTERM');
|
|
289
|
+
stopping = new Promise((resolve) => {
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
kill('SIGKILL');
|
|
292
|
+
resolve();
|
|
293
|
+
}, 1000);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const timer = setTimeout(stop, Math.max(1, this.runtime.deadlineMs - Date.now()));
|
|
297
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
298
|
+
if (signal?.aborted)
|
|
299
|
+
stop();
|
|
300
|
+
function saveOutput() {
|
|
301
|
+
try {
|
|
302
|
+
result.output = safeToolOutput(rawTail).slice(-OUTPUT_LIMIT);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
result.output = 'Tool output withheld: filtering failed.';
|
|
306
|
+
}
|
|
307
|
+
result.durationMs = Date.now() - startedAt;
|
|
308
|
+
if (result.buildStartedAtMs !== null) {
|
|
309
|
+
result.buildDurationMs = Date.now() - result.buildStartedAtMs;
|
|
310
|
+
result.setupDurationMs = result.buildStartedAtMs - startedAt;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function saveRunning() {
|
|
314
|
+
saveOutput();
|
|
315
|
+
writeFileSync(`${statePath}.running.tmp`, JSON.stringify(result), { mode: 0o600 });
|
|
316
|
+
renameSync(`${statePath}.running.tmp`, `${statePath}.running`);
|
|
317
|
+
}
|
|
318
|
+
function onData(chunk) {
|
|
319
|
+
const next = rawTail + chunk;
|
|
320
|
+
if (result.buildStartedAtMs === null && /(?:^|\n)DOISTBOT_BUILD_START\r?\n/.test(next))
|
|
321
|
+
result.buildStartedAtMs = Date.now();
|
|
322
|
+
result.outputTruncated ||= next.length > OUTPUT_LIMIT;
|
|
323
|
+
rawTail = next.slice(-OUTPUT_LIMIT);
|
|
324
|
+
}
|
|
325
|
+
child.stdout.setEncoding('utf8');
|
|
326
|
+
child.stderr.setEncoding('utf8');
|
|
327
|
+
child.stdout.on('data', onData);
|
|
328
|
+
child.stderr.on('data', onData);
|
|
329
|
+
const outputTimer = setInterval(saveRunning, 1000);
|
|
330
|
+
await new Promise((resolve) => {
|
|
331
|
+
child.once('error', (error) => {
|
|
332
|
+
onData(error.message);
|
|
333
|
+
result.exitCode = -1;
|
|
334
|
+
resolve();
|
|
335
|
+
});
|
|
336
|
+
child.once('close', (code, childSignal) => {
|
|
337
|
+
result.exitCode = code;
|
|
338
|
+
result.signal = childSignal;
|
|
339
|
+
resolve();
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
clearInterval(outputTimer);
|
|
344
|
+
await stopping;
|
|
345
|
+
signal?.removeEventListener('abort', stop);
|
|
346
|
+
result.status = 'finished';
|
|
347
|
+
saveOutput();
|
|
348
|
+
saveVerificationState(statePath, state);
|
|
349
|
+
rmSync(`${statePath}.running`, { force: true });
|
|
350
|
+
this.record(result);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
export function hashAutofixInputs(value) {
|
|
354
|
+
return createHash('sha256').update(value).digest('hex');
|
|
355
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const KNOWN_DOISTBOT_LOGINS = new Set([
|
|
2
|
+
'doistbot',
|
|
3
|
+
'doistbot[bot]',
|
|
4
|
+
'doist-bot',
|
|
5
|
+
'doist-bot[bot]',
|
|
6
|
+
'doistbot-app',
|
|
7
|
+
'doistbot-app[bot]',
|
|
8
|
+
]);
|
|
9
|
+
export function isKnownDoistbotLogin(login) {
|
|
10
|
+
if (!login) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
return KNOWN_DOISTBOT_LOGINS.has(login.toLowerCase());
|
|
14
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { logger } from './logging.js';
|
|
2
|
+
/**
|
|
3
|
+
* GitHub Check Run output titles render in the Checks tab list and in the PR
|
|
4
|
+
* conversation; we keep them short for at-a-glance scannability.
|
|
5
|
+
*/
|
|
6
|
+
const MAX_TITLE_LENGTH = 100;
|
|
7
|
+
function truncateTitle(value) {
|
|
8
|
+
if (value.length <= MAX_TITLE_LENGTH)
|
|
9
|
+
return value;
|
|
10
|
+
return `${value.slice(0, MAX_TITLE_LENGTH - 1)}…`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Generic GitHub Check Run poster. Failures (e.g., missing `checks:write`
|
|
14
|
+
* permission) are logged at `warn` and swallowed — callers have typically
|
|
15
|
+
* already completed their main work and we don't want a check-posting failure
|
|
16
|
+
* to mask the primary outcome (a review, a fix-retry, etc.).
|
|
17
|
+
*
|
|
18
|
+
* Upserts on (sha, name): if a check run with the same name already exists for
|
|
19
|
+
* the SHA (e.g. an earlier in-progress run from this delivery, or a previous
|
|
20
|
+
* `/review` invocation), updates that run in place rather than appending a
|
|
21
|
+
* fresh one. Matches the prior commit-status behavior of "single current
|
|
22
|
+
* result" per (SHA, context), and lets the in_progress→completed transition
|
|
23
|
+
* happen naturally on the second call.
|
|
24
|
+
*/
|
|
25
|
+
export async function postCheckRun(params) {
|
|
26
|
+
const { octokit, owner, repo, sha, title, summary, detailsUrl, name, externalId } = params;
|
|
27
|
+
const checkRunId = params.checkRunId;
|
|
28
|
+
const status = params.status ?? 'completed';
|
|
29
|
+
const conclusion = status === 'completed' ? params.conclusion : undefined;
|
|
30
|
+
const truncatedTitle = truncateTitle(title);
|
|
31
|
+
// Fields shared between checks.create and checks.update — extracted so the
|
|
32
|
+
// two call sites stay in sync as fields are added.
|
|
33
|
+
const payload = {
|
|
34
|
+
owner,
|
|
35
|
+
repo,
|
|
36
|
+
status,
|
|
37
|
+
conclusion,
|
|
38
|
+
details_url: detailsUrl,
|
|
39
|
+
external_id: externalId,
|
|
40
|
+
output: { title: truncatedTitle, summary },
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
if (checkRunId !== undefined) {
|
|
44
|
+
await octokit.rest.checks.update({
|
|
45
|
+
...payload,
|
|
46
|
+
check_run_id: checkRunId,
|
|
47
|
+
});
|
|
48
|
+
logger.info('Updated known check run', {
|
|
49
|
+
component: 'check-run',
|
|
50
|
+
name,
|
|
51
|
+
sha,
|
|
52
|
+
status,
|
|
53
|
+
conclusion,
|
|
54
|
+
checkRunId,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const existing = await octokit.rest.checks.listForRef({
|
|
59
|
+
owner,
|
|
60
|
+
repo,
|
|
61
|
+
ref: sha,
|
|
62
|
+
check_name: name,
|
|
63
|
+
per_page: 5,
|
|
64
|
+
});
|
|
65
|
+
// Prefer matching by external_id when we have one; otherwise fall back
|
|
66
|
+
// to the first run for the (sha, name) tuple. This keeps a delivery
|
|
67
|
+
// from accidentally clobbering another delivery's run if multiple
|
|
68
|
+
// exist (e.g. after a transient lock-lookup failure).
|
|
69
|
+
const existingRun = externalId
|
|
70
|
+
? (existing.data.check_runs.find((run) => (run.external_id ?? undefined) === externalId) ?? existing.data.check_runs[0])
|
|
71
|
+
: existing.data.check_runs[0];
|
|
72
|
+
if (existingRun) {
|
|
73
|
+
await octokit.rest.checks.update({
|
|
74
|
+
...payload,
|
|
75
|
+
check_run_id: existingRun.id,
|
|
76
|
+
});
|
|
77
|
+
logger.info('Updated existing check run', {
|
|
78
|
+
component: 'check-run',
|
|
79
|
+
name,
|
|
80
|
+
sha,
|
|
81
|
+
status,
|
|
82
|
+
conclusion,
|
|
83
|
+
checkRunId: existingRun.id,
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
await octokit.rest.checks.create({
|
|
88
|
+
...payload,
|
|
89
|
+
name,
|
|
90
|
+
head_sha: sha,
|
|
91
|
+
});
|
|
92
|
+
logger.info('Posted check run', {
|
|
93
|
+
component: 'check-run',
|
|
94
|
+
name,
|
|
95
|
+
sha,
|
|
96
|
+
status,
|
|
97
|
+
conclusion,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
logger.warn('Failed to post check run', {
|
|
102
|
+
component: 'check-run',
|
|
103
|
+
name,
|
|
104
|
+
sha,
|
|
105
|
+
status,
|
|
106
|
+
conclusion,
|
|
107
|
+
error: error instanceof Error ? error.message : String(error),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Concurrency lock: returns `true` if there's an `in_progress` check run for
|
|
113
|
+
* the given (sha, name) whose `external_id` is from a different delivery.
|
|
114
|
+
*
|
|
115
|
+
* Replaces the pre-Check-Runs PR-comment-based lock. Same semantics: if a
|
|
116
|
+
* different delivery is already mid-review on the same SHA, skip rather
|
|
117
|
+
* than double-bill the LLM. Same-delivery in-progress runs (e.g. a retry
|
|
118
|
+
* after a webhook redelivery) are not treated as locks.
|
|
119
|
+
*
|
|
120
|
+
* `per_page: 5` defends against the rare case where multiple in-progress
|
|
121
|
+
* runs slipped through earlier (e.g. transient lock-lookup failure); we'd
|
|
122
|
+
* still see them all and lock if any belongs to another delivery. The
|
|
123
|
+
* normal steady state is one run per (sha, name).
|
|
124
|
+
*
|
|
125
|
+
* Caveat — TOCTOU: there's a window between this check and the caller's
|
|
126
|
+
* subsequent in_progress post where a concurrent delivery can pass its
|
|
127
|
+
* own check too, leading both to proceed. The pre-Check-Runs comment lock
|
|
128
|
+
* had the same race; bound is the same (≤2× LLM per concurrent fire).
|
|
129
|
+
*
|
|
130
|
+
* Failures are logged at `warn` and treated as "no lock found" so a
|
|
131
|
+
* read-side outage can't permanently wedge the bot. The cost of a false
|
|
132
|
+
* negative is one duplicate review.
|
|
133
|
+
*/
|
|
134
|
+
export async function isAnotherDeliveryInProgress({ octokit, owner, repo, sha, name, deliveryId, }) {
|
|
135
|
+
try {
|
|
136
|
+
const response = await octokit.rest.checks.listForRef({
|
|
137
|
+
owner,
|
|
138
|
+
repo,
|
|
139
|
+
ref: sha,
|
|
140
|
+
check_name: name,
|
|
141
|
+
per_page: 5,
|
|
142
|
+
});
|
|
143
|
+
return response.data.check_runs.some(
|
|
144
|
+
// GitHub returns `null` for `external_id` when it was unset on
|
|
145
|
+
// creation; normalise to `undefined` so it compares equal to a
|
|
146
|
+
// missing `deliveryId` (e.g. local CLI retries).
|
|
147
|
+
(run) => run.status === 'in_progress' && (run.external_id ?? undefined) !== deliveryId);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
logger.warn('Failed to look up in-progress check runs (lock check)', {
|
|
151
|
+
component: 'check-run',
|
|
152
|
+
name,
|
|
153
|
+
sha,
|
|
154
|
+
error: error instanceof Error ? error.message : String(error),
|
|
155
|
+
});
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|