@doist/doistbot-cli 1.0.13 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/sandbox/_node_modules/doistbot-logging/dist/index.d.ts +2 -0
- package/sandbox/_node_modules/doistbot-logging/dist/index.js +8 -2
- package/sandbox/dist/core/pi-tool-output-filter-extension.js +59 -0
- package/sandbox/dist/core/pi.js +17 -14
- package/sandbox/dist/core/repository-map.js +1 -1
- package/sandbox/dist/core/tool-environment.js +22 -0
- package/sandbox/dist/tasks/chat/chat.js +1 -1
- package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +5 -1
- package/sandbox/dist/tasks/issue-triage/assignee-review.js +114 -0
- package/sandbox/dist/tasks/issue-triage/fix-attempt.js +20 -11
- package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +2 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/effects.js +21 -19
- package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +32 -31
- package/sandbox/dist/tasks/issue-triage/github-request.js +2 -0
- package/sandbox/dist/tasks/issue-triage/hero-group-map.js +2 -8
- package/sandbox/dist/tasks/issue-triage/markers.js +1 -0
- package/sandbox/dist/tasks/issue-triage/pr-creator.js +59 -1
- package/sandbox/dist/tasks/issue-triage/pr-readiness.js +25 -0
- package/sandbox/dist/tasks/issue-triage/sandbox-environment.js +53 -0
- package/sandbox/dist/tasks/issue-triage/triage.js +8 -2
- package/sandbox/dist/tasks/review/thinking-level.js +1 -1
- package/sandbox/node_modules/doistbot-logging/dist/index.d.ts +2 -0
- package/sandbox/node_modules/doistbot-logging/dist/index.js +8 -2
- package/sandbox/src/review/prompts/service-production-readiness.md +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doist/doistbot-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.15",
|
|
4
4
|
"description": "Local self-review CLI for Doistbot",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"test": "vitest run src scripts --config ../../config/vitest.base.ts"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@earendil-works/pi-ai": "0.
|
|
44
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
43
|
+
"@earendil-works/pi-ai": "0.87.1",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "0.87.1",
|
|
45
45
|
"@inquirer/checkbox": "5.2.5",
|
|
46
46
|
"@inquirer/password": "5.2.2",
|
|
47
47
|
"@napi-rs/keyring": "1.3.0",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"commander": "14.0.3",
|
|
52
52
|
"js-yaml": "4.3.2",
|
|
53
53
|
"octokit": "5.0.5",
|
|
54
|
-
"undici": "8.
|
|
54
|
+
"undici": "8.11.2",
|
|
55
55
|
"yocto-spinner": "1.2.2"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
@@ -7,6 +7,8 @@ export type LoggerOptions = {
|
|
|
7
7
|
suppressLogs?: (level: LogLevel) => boolean;
|
|
8
8
|
suppressInfoLogs?: () => boolean;
|
|
9
9
|
includeLogField?: boolean;
|
|
10
|
+
/** Receives serialized JSON without a trailing newline. The writer must add one. */
|
|
11
|
+
write?: (line: string) => void;
|
|
10
12
|
buildInfo?: {
|
|
11
13
|
version: number;
|
|
12
14
|
commit: string;
|
|
@@ -33,8 +33,14 @@ export function createLogger(options = {}) {
|
|
|
33
33
|
}
|
|
34
34
|
Object.assign(payload, traceInfo);
|
|
35
35
|
payload.meta = metaInfo;
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const line = JSON.stringify(payload);
|
|
37
|
+
if (options.write) {
|
|
38
|
+
options.write(line);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// eslint-disable-next-line no-console
|
|
42
|
+
console.log(line);
|
|
43
|
+
}
|
|
38
44
|
}
|
|
39
45
|
return {
|
|
40
46
|
info: (message, meta) => log('info', message, meta),
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { writeSync } from 'node:fs';
|
|
2
|
+
import { scrubPii } from './pii-scrubber.js';
|
|
3
|
+
// Pi applies tool_result changes before sending the result to the model. Filter
|
|
4
|
+
// all text tools so redirecting CLI output to a file cannot bypass filtering.
|
|
5
|
+
// eslint-disable-next-line import/no-default-export
|
|
6
|
+
export default function toolOutputFilterExtension(pi) {
|
|
7
|
+
const secrets = Object.entries(process.env)
|
|
8
|
+
.filter(([key, value]) => /(?:KEY|TOKEN|PASSWORD|SECRET)$/.test(key) && value && value.length >= 8)
|
|
9
|
+
.map(([, value]) => value);
|
|
10
|
+
function filterText(value) {
|
|
11
|
+
for (const secret of secrets)
|
|
12
|
+
value = value.split(secret).join('[REDACTED_SECRET]');
|
|
13
|
+
return scrubPii(value);
|
|
14
|
+
}
|
|
15
|
+
function filterValue(value) {
|
|
16
|
+
if (typeof value === 'string')
|
|
17
|
+
return filterText(value);
|
|
18
|
+
if (Array.isArray(value))
|
|
19
|
+
return value.map(filterValue);
|
|
20
|
+
if (value && typeof value === 'object')
|
|
21
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [filterText(key), filterValue(entry)]));
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
pi.on('tool_call', (event) => {
|
|
25
|
+
try {
|
|
26
|
+
// Pi redirects process.stdout writes to stderr in JSON mode.
|
|
27
|
+
// Write this protocol record directly to its stdout pipe.
|
|
28
|
+
writeSync(1, `${JSON.stringify({
|
|
29
|
+
type: 'doistbot_tool_audit',
|
|
30
|
+
tool: event.toolName,
|
|
31
|
+
// Audit queries/commands, never service response bodies.
|
|
32
|
+
input: JSON.stringify(filterValue(event.input)).slice(0, 8_000),
|
|
33
|
+
})}\n`);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { block: true, reason: 'Tool query could not be safely recorded.' };
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
});
|
|
40
|
+
pi.on('tool_result', (event) => {
|
|
41
|
+
try {
|
|
42
|
+
return {
|
|
43
|
+
content: event.content.map((item) => item.type === 'text' ? { ...item, text: filterText(item.text) } : item),
|
|
44
|
+
details: filterValue(event.details ?? null),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Pi otherwise ignores extension errors and keeps the original
|
|
49
|
+
// result. Explicitly replace it so a filter failure cannot leak text.
|
|
50
|
+
return {
|
|
51
|
+
content: [
|
|
52
|
+
{ type: 'text', text: 'Tool output withheld: filtering failed.' },
|
|
53
|
+
],
|
|
54
|
+
details: null,
|
|
55
|
+
isError: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
package/sandbox/dist/core/pi.js
CHANGED
|
@@ -3,7 +3,9 @@ import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
|
3
3
|
import { tmpdir } from 'os';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
+
import { logger } from './logging.js';
|
|
6
7
|
import { PI_EXECUTION_LIMIT_ENV } from './pi-execution-limits-extension.js';
|
|
8
|
+
import { approvedSkillArgs, buildToolEnvironment } from './tool-environment.js';
|
|
7
9
|
export function toPiInvocationTelemetryFields(telemetry) {
|
|
8
10
|
return {
|
|
9
11
|
turn_count: telemetry.turnCount,
|
|
@@ -54,7 +56,7 @@ export const DEFAULT_PI_MODELS = {
|
|
|
54
56
|
gemini: 'gemini-3.1-pro-preview',
|
|
55
57
|
grok: 'grok-4.6',
|
|
56
58
|
zai: 'glm5.2',
|
|
57
|
-
openai: 'gpt-
|
|
59
|
+
openai: 'gpt-6.1-sol',
|
|
58
60
|
};
|
|
59
61
|
export const DEFAULT_OPENROUTER_MODELS = {
|
|
60
62
|
deepseek: 'deepseek/deepseek-v4-pro-0813',
|
|
@@ -95,6 +97,7 @@ function resolvePiCliPath() {
|
|
|
95
97
|
}
|
|
96
98
|
const PI_CLI_PATH = resolvePiCliPath();
|
|
97
99
|
const PI_EXECUTION_LIMIT_EXTENSION_PATH = fileURLToPath(new URL('./pi-execution-limits-extension.js', import.meta.url));
|
|
100
|
+
const PI_TOOL_OUTPUT_FILTER_EXTENSION_PATH = fileURLToPath(new URL('./pi-tool-output-filter-extension.js', import.meta.url));
|
|
98
101
|
let cachedNodeExecutable;
|
|
99
102
|
let cachedOpenRouterSessionExtensionPath;
|
|
100
103
|
export function resolveNodeExecutable({ execPath = process.execPath, argv0 = process.argv0, canExecute = canExecuteNode, } = {}) {
|
|
@@ -416,7 +419,11 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
416
419
|
'json',
|
|
417
420
|
'--no-session',
|
|
418
421
|
'--no-extensions',
|
|
422
|
+
...(process.env.SANDBOX_PROFILE
|
|
423
|
+
? ['--extension', PI_TOOL_OUTPUT_FILTER_EXTENSION_PATH]
|
|
424
|
+
: []),
|
|
419
425
|
'--no-skills',
|
|
426
|
+
...approvedSkillArgs(),
|
|
420
427
|
'--no-prompt-templates',
|
|
421
428
|
...(noContextFiles ? ['--no-context-files'] : []),
|
|
422
429
|
'--model',
|
|
@@ -428,21 +435,11 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
428
435
|
...executionLimitExtension.args,
|
|
429
436
|
...imageArgs,
|
|
430
437
|
];
|
|
431
|
-
const
|
|
432
|
-
...process.env,
|
|
433
|
-
};
|
|
434
|
-
// Repository-scoped credentials must be passed explicitly through
|
|
435
|
-
// extraEnv by an authorized task invocation.
|
|
436
|
-
delete inheritedEnv.GH_PACKAGES_TOKEN;
|
|
437
|
-
const childEnv = {
|
|
438
|
-
...inheritedEnv,
|
|
439
|
-
HOME: process.env.HOME,
|
|
440
|
-
PATH: process.env.PATH,
|
|
441
|
-
GIT_TERMINAL_PROMPT: '0',
|
|
438
|
+
const childEnv = buildToolEnvironment({
|
|
442
439
|
...extraEnv,
|
|
443
440
|
...openRouterSessionExtension.extraEnv,
|
|
444
441
|
...executionLimitExtension.extraEnv,
|
|
445
|
-
};
|
|
442
|
+
});
|
|
446
443
|
const apiKeyEnvVar = PI_API_KEY_ENV_VAR_BY_PI_PROVIDER[resolvedPiProviderId];
|
|
447
444
|
if (piAuthProviderId) {
|
|
448
445
|
delete childEnv[apiKeyEnvVar];
|
|
@@ -450,7 +447,6 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
450
447
|
else {
|
|
451
448
|
childEnv[apiKeyEnvVar] = apiKey;
|
|
452
449
|
}
|
|
453
|
-
delete childEnv.GITHUB_TOKEN;
|
|
454
450
|
try {
|
|
455
451
|
const child = spawn(resolveNodeExecutable(), args, {
|
|
456
452
|
cwd,
|
|
@@ -576,6 +572,13 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
576
572
|
}
|
|
577
573
|
try {
|
|
578
574
|
const parsed = JSON.parse(trimmed);
|
|
575
|
+
if (parsed.type === 'doistbot_tool_audit') {
|
|
576
|
+
logger.info('Sandbox tool query', {
|
|
577
|
+
tool: parsed.tool,
|
|
578
|
+
input: parsed.input,
|
|
579
|
+
});
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
579
582
|
if (parsed.type === 'session') {
|
|
580
583
|
sessionHeaderSeen = true;
|
|
581
584
|
continue;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { APP_LABEL_ALIASES, APP_TO_ROUTE_SELECTORS, findIntegrationRoute, findIntegrationRoutes, findIssueRoute, findRepositoryRoutes, findRepositoryTarget, INTEGRATION_LABEL_ALIASES, INTEGRATION_ROUTES, PRODUCT_LABEL_ALIASES, PRODUCT_ROUTES, RESOLUTION_PRECEDENCE, TEAM_LABEL_ALIASES, TEAM_TO_ROUTE_SELECTORS, } from 'doistbot-issue-routing';
|
|
1
|
+
export { APP_LABEL_ALIASES, APP_TO_ROUTE_SELECTORS, findIntegrationRoute, findIntegrationRoutes, findIssueRoute, findOwningRoutes, findRepositoryRoutes, findRepositoryTarget, INTEGRATION_LABEL_ALIASES, INTEGRATION_ROUTES, PRODUCT_LABEL_ALIASES, PRODUCT_ROUTES, RESOLUTION_PRECEDENCE, TEAM_LABEL_ALIASES, TEAM_TO_ROUTE_SELECTORS, } from 'doistbot-issue-routing';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Prepare credentials for Pi shell tools. */
|
|
2
|
+
export function buildToolEnvironment(extraEnv = {}, inherited = process.env) {
|
|
3
|
+
const env = { ...inherited };
|
|
4
|
+
delete env.GH_PACKAGES_TOKEN;
|
|
5
|
+
Object.assign(env, extraEnv);
|
|
6
|
+
delete env.GITHUB_TOKEN;
|
|
7
|
+
delete env.PRIVATE_KEY;
|
|
8
|
+
env.GIT_TERMINAL_PROMPT = '0';
|
|
9
|
+
env.DD_API_KEY = env.DD_API_KEY ?? env.DATADOG_API_KEY;
|
|
10
|
+
env.DD_APP_KEY = env.DD_APP_KEY ?? env.DATADOG_APP_KEY;
|
|
11
|
+
env.DD_SITE = env.DD_SITE?.trim() || env.DATADOG_SITE?.trim() || 'datadoghq.com';
|
|
12
|
+
return env;
|
|
13
|
+
}
|
|
14
|
+
export function approvedSkillArgs(env = process.env) {
|
|
15
|
+
if (!env.SANDBOX_PROFILE)
|
|
16
|
+
return [];
|
|
17
|
+
const skills = ['/app/apps/sandbox/skills/investigation/SKILL.md'];
|
|
18
|
+
if (env.SANDBOX_PROFILE === 'android') {
|
|
19
|
+
skills.push('/app/apps/sandbox/skills/agent-device/SKILL.md');
|
|
20
|
+
}
|
|
21
|
+
return skills.flatMap((skill) => ['--skill', skill]);
|
|
22
|
+
}
|
|
@@ -13,7 +13,7 @@ const CHAT_PROMPT_FILE = 'chat-prompt.md';
|
|
|
13
13
|
const BRO_PROMPT_FILE = 'bro-prompt.md';
|
|
14
14
|
const CHAT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
15
15
|
const CHAT_PROVIDER = 'openai';
|
|
16
|
-
const CHAT_MODEL = 'gpt-
|
|
16
|
+
const CHAT_MODEL = 'gpt-6-luna';
|
|
17
17
|
const CHAT_THINKING_LEVEL = 'high';
|
|
18
18
|
const BRO_SYSTEM_PROMPT = 'You are Doistbot re-explaining one of your own review comments. Use only the source comment in the task prompt. Do not inspect files, call tools, or add facts. Output only the rewritten explanation.';
|
|
19
19
|
const EMPTY_BRO_RESPONSE = "There's no earlier Doistbot comment in this review thread for me to simplify, bro.";
|
|
@@ -10,7 +10,7 @@ import { parseIssueTriageContext } from '../issue-triage/context.js';
|
|
|
10
10
|
import { attemptFix } from '../issue-triage/fix-attempt.js';
|
|
11
11
|
import { parseFixAssessment } from '../issue-triage/output.js';
|
|
12
12
|
import { cloneRepository, createGitHubClients, extractLabelNames, fetchIssue, fetchIssueComments, getIssueUrl, prepareWorkspaceReposDirectory, TRIAGE_MARKER, } from '../issue-triage/shared.js';
|
|
13
|
-
async function main() {
|
|
13
|
+
export async function main() {
|
|
14
14
|
const startedAt = performance.now();
|
|
15
15
|
const ctx = parseIssueTriageContext();
|
|
16
16
|
let exitCode = 0;
|
|
@@ -184,6 +184,10 @@ async function main() {
|
|
|
184
184
|
issueUrl: issue.html_url ?? getIssueUrl(ctx.owner, ctx.repo, ctx.issueNumber),
|
|
185
185
|
ghToken: issueInstallationAuth.token,
|
|
186
186
|
force: true,
|
|
187
|
+
assigneeReview: {
|
|
188
|
+
assignees: (issue.assignees ?? []).map((assignee) => assignee.login),
|
|
189
|
+
membershipOctokit: readOctokit,
|
|
190
|
+
},
|
|
187
191
|
modelPhaseName: 'issue-fix-retry.fix-attempt.model',
|
|
188
192
|
}));
|
|
189
193
|
metricRepository = fixResult.repository;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { isKnownDoistbotLogin } from '../../core/bot-logins.js';
|
|
2
|
+
import { logger } from '../../core/logging.js';
|
|
3
|
+
import { findOwningRoutes, findRepositoryTarget } from '../../core/repository-map.js';
|
|
4
|
+
import { withRetry } from '../../core/retry.js';
|
|
5
|
+
import { GITHUB_REQUEST_TIMEOUT_MS } from './github-request.js';
|
|
6
|
+
import { ASSIGNEE_REVIEW_MARKER } from './markers.js';
|
|
7
|
+
export async function resolveAssigneeReviewers({ owner, repo, assignees, membershipOctokit, }) {
|
|
8
|
+
const repository = findRepositoryTarget(owner, repo);
|
|
9
|
+
if (!repository || assignees.length === 0)
|
|
10
|
+
return [];
|
|
11
|
+
const routes = findOwningRoutes(repository);
|
|
12
|
+
const teams = [...new Set(routes.map((route) => route.ownerTeam))];
|
|
13
|
+
const reviewers = [];
|
|
14
|
+
const logins = [...new Set(assignees.map((login) => login.toLowerCase()))];
|
|
15
|
+
for (const username of logins) {
|
|
16
|
+
if (isKnownDoistbotLogin(username))
|
|
17
|
+
continue;
|
|
18
|
+
for (const teamSlug of teams) {
|
|
19
|
+
try {
|
|
20
|
+
const { data } = await withRetry(() => membershipOctokit.rest.teams.getMembershipForUserInOrg({
|
|
21
|
+
org: owner,
|
|
22
|
+
team_slug: teamSlug,
|
|
23
|
+
username,
|
|
24
|
+
request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
|
|
25
|
+
}));
|
|
26
|
+
if (data.state === 'active') {
|
|
27
|
+
reviewers.push(username);
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
// A missing/invisible membership never qualifies an assignee.
|
|
33
|
+
if (error?.status !== 404) {
|
|
34
|
+
logger.warn('Unable to verify autofix assignee team membership', {
|
|
35
|
+
repository: `${owner}/${repo}`,
|
|
36
|
+
username,
|
|
37
|
+
team: teamSlug,
|
|
38
|
+
error: error instanceof Error ? error.message : String(error),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return reviewers;
|
|
45
|
+
}
|
|
46
|
+
/** Best-effort early review request. Failure must not lose an already opened PR. */
|
|
47
|
+
export async function requestAssigneeReview({ assigneeReview, ...pr }) {
|
|
48
|
+
if (!assigneeReview)
|
|
49
|
+
return [];
|
|
50
|
+
const reviewers = await resolveAssigneeReviewers({ ...pr, ...assigneeReview });
|
|
51
|
+
if (reviewers.length === 0)
|
|
52
|
+
return [];
|
|
53
|
+
try {
|
|
54
|
+
await withRetry(() => pr.octokit.rest.pulls.requestReviewers({
|
|
55
|
+
owner: pr.owner,
|
|
56
|
+
repo: pr.repo,
|
|
57
|
+
pull_number: pr.prNumber,
|
|
58
|
+
reviewers,
|
|
59
|
+
request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
logger.warn('Autofix assignee review request failed; retaining Hero fallback', {
|
|
64
|
+
repository: `${pr.owner}/${pr.repo}`,
|
|
65
|
+
pr_number: pr.prNumber,
|
|
66
|
+
reviewers,
|
|
67
|
+
error: error instanceof Error ? error.message : String(error),
|
|
68
|
+
});
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
// A bot-authored receipt on the target PR survives the separate promotion
|
|
73
|
+
// process, completed/removed review requests, and edits to the PR body.
|
|
74
|
+
// Only write it AFTER GitHub accepts the request, never for candidates.
|
|
75
|
+
await withRetry(() => pr.octokit.rest.issues.createComment({
|
|
76
|
+
owner: pr.owner,
|
|
77
|
+
repo: pr.repo,
|
|
78
|
+
issue_number: pr.prNumber,
|
|
79
|
+
body: [
|
|
80
|
+
ASSIGNEE_REVIEW_MARKER,
|
|
81
|
+
'',
|
|
82
|
+
`Requested review from issue assignees: ${reviewers.map((login) => `@${login}`).join(', ')}.`,
|
|
83
|
+
].join('\n'),
|
|
84
|
+
request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
logger.warn('Failed to record autofix assignee review request; Hero fallback remains armed', {
|
|
89
|
+
repository: `${pr.owner}/${pr.repo}`,
|
|
90
|
+
pr_number: pr.prNumber,
|
|
91
|
+
reviewers,
|
|
92
|
+
error: error instanceof Error ? error.message : String(error),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return reviewers;
|
|
96
|
+
}
|
|
97
|
+
export async function hasAssigneeReviewRequest(pr) {
|
|
98
|
+
return withRetry(async () => {
|
|
99
|
+
const pages = pr.octokit.paginate.iterator(pr.octokit.rest.issues.listComments, {
|
|
100
|
+
owner: pr.owner,
|
|
101
|
+
repo: pr.repo,
|
|
102
|
+
issue_number: pr.prNumber,
|
|
103
|
+
per_page: 100,
|
|
104
|
+
request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
|
|
105
|
+
});
|
|
106
|
+
for await (const { data: comments } of pages) {
|
|
107
|
+
if (comments.some((comment) => isKnownDoistbotLogin(comment.user?.login) &&
|
|
108
|
+
comment.body?.split('\n')[0]?.trim() === ASSIGNEE_REVIEW_MARKER)) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -9,6 +9,7 @@ import { CX_HERO_MANUAL_HANDLE, resolveHeroGroup } from './hero-group-map.js';
|
|
|
9
9
|
import { AUTOFIX_FAILURE_MARKER, FIX_LOOP_START_MARKER, HERO_AUTOFIX_MARKER } from './markers.js';
|
|
10
10
|
import { invokeTriageModel } from './model.js';
|
|
11
11
|
import { commitAndPushFix, findPRTemplate, openDraftFixPR } from './pr-creator.js';
|
|
12
|
+
import { sandboxEnvironmentError } from './sandbox-environment.js';
|
|
12
13
|
const FIX_PROMPT_FILE = 'issue-triage-fix-prompt.md';
|
|
13
14
|
// Maximum time allowed for the Codex fix-generation invocation before we abort
|
|
14
15
|
const AUTO_FIX_CODEX_TIMEOUT_MS = 45 * 60 * 1000;
|
|
@@ -147,13 +148,15 @@ function captureGitDiff(repoLocalPath) {
|
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
150
|
const DOISTBOT_DEVS_MENTION = '@Doist/doistbot-devs';
|
|
150
|
-
export function buildFollowUpCommentBody({ prUrl, targetRepoFullName, fixAssessment, heroMention, }) {
|
|
151
|
+
export function buildFollowUpCommentBody({ prUrl, targetRepoFullName, fixAssessment, heroMention, requestedAssignees = [], }) {
|
|
151
152
|
const description = fixAssessment.classification === 'speculative'
|
|
152
|
-
? `I've created a
|
|
153
|
-
: `I've created a
|
|
154
|
-
const introLine =
|
|
155
|
-
?
|
|
156
|
-
:
|
|
153
|
+
? `I've created a PR with a speculative fix as a starting point: ${prUrl}. This likely needs refinement — see the PR description for confidence details.`
|
|
154
|
+
: `I've created a PR with a proposed fix: ${prUrl}`;
|
|
155
|
+
const introLine = requestedAssignees.length > 0
|
|
156
|
+
? `I've requested a review from ${requestedAssignees.map((login) => `@${login}`).join(', ')} on the autofix PR.`
|
|
157
|
+
: heroMention
|
|
158
|
+
? `${heroMention} a doistbot autofix PR is ready for your review.`
|
|
159
|
+
: `${CX_HERO_MANUAL_HANDLE} I couldn't resolve a Hero for \`${targetRepoFullName}\`. Could you please route this manually? cc ${DOISTBOT_DEVS_MENTION} to update the hero mapping so future autofix PRs can be assigned automatically.`;
|
|
157
160
|
return [
|
|
158
161
|
HERO_AUTOFIX_MARKER,
|
|
159
162
|
'',
|
|
@@ -282,9 +285,9 @@ export async function postAutoFixFailureComment({ octokit, ctx, reason, targetRe
|
|
|
282
285
|
operation: existing ? 'update' : 'create',
|
|
283
286
|
});
|
|
284
287
|
}
|
|
285
|
-
async function postFollowUpComment({ octokit, ctx, prUrl, prNumber, targetRepo, fixAssessment, }) {
|
|
286
|
-
const heroGroup = resolveHeroGroup([], targetRepo);
|
|
287
|
-
if (!heroGroup) {
|
|
288
|
+
async function postFollowUpComment({ octokit, ctx, prUrl, prNumber, targetRepo, fixAssessment, requestedAssignees = [], }) {
|
|
289
|
+
const heroGroup = requestedAssignees.length > 0 ? null : resolveHeroGroup([], targetRepo);
|
|
290
|
+
if (!heroGroup && requestedAssignees.length === 0) {
|
|
288
291
|
logger.warn('No hero mapping found for autofix target repo', {
|
|
289
292
|
task: 'issue-triage',
|
|
290
293
|
issueNumber: ctx.issueNumber,
|
|
@@ -297,6 +300,7 @@ async function postFollowUpComment({ octokit, ctx, prUrl, prNumber, targetRepo,
|
|
|
297
300
|
targetRepoFullName: targetRepo.fullName,
|
|
298
301
|
fixAssessment,
|
|
299
302
|
heroMention: heroGroup?.mention ?? null,
|
|
303
|
+
requestedAssignees,
|
|
300
304
|
});
|
|
301
305
|
await octokit.rest.issues.createComment({
|
|
302
306
|
owner: ctx.owner,
|
|
@@ -327,6 +331,9 @@ export async function runFixCore(input) {
|
|
|
327
331
|
reason: buildFixAssessmentSkipReason(fixAssessment),
|
|
328
332
|
};
|
|
329
333
|
}
|
|
334
|
+
const environmentError = sandboxEnvironmentError(targetRepo.repository.fullName);
|
|
335
|
+
if (environmentError)
|
|
336
|
+
return { ok: false, reason: environmentError };
|
|
330
337
|
const capabilityEnvResult = resolveAutofixCapabilityEnv(targetRepo.repository.fullName);
|
|
331
338
|
if (!capabilityEnvResult.ok) {
|
|
332
339
|
return capabilityEnvResult;
|
|
@@ -389,7 +396,7 @@ export async function runFixCore(input) {
|
|
|
389
396
|
prompt,
|
|
390
397
|
apiKey: ctx.openaiApiKey,
|
|
391
398
|
cwd: targetRepo.localPath,
|
|
392
|
-
thinkingLevel: '
|
|
399
|
+
thinkingLevel: 'max',
|
|
393
400
|
timeoutMs,
|
|
394
401
|
extraEnv: {
|
|
395
402
|
GH_TOKEN: input.ghToken,
|
|
@@ -438,7 +445,7 @@ export async function runFixCore(input) {
|
|
|
438
445
|
return { ok: false, reason: 'Codex produced no file changes.' };
|
|
439
446
|
}
|
|
440
447
|
// Validate the diff — speculative fixes bypass the file-count limit since they
|
|
441
|
-
// are
|
|
448
|
+
// are starting-point PRs that may touch more files.
|
|
442
449
|
const diffLimits = fixAssessment.classification === 'speculative' ? { maxChangedFiles: Infinity } : undefined;
|
|
443
450
|
const validation = validateDiff(diff, diffLimits);
|
|
444
451
|
if (!validation.valid) {
|
|
@@ -577,6 +584,7 @@ export async function attemptFix(input) {
|
|
|
577
584
|
prTitle: coreResult.generatedPRTitle ?? undefined,
|
|
578
585
|
prBody: coreResult.generatedPRBody ?? undefined,
|
|
579
586
|
fixAssessment,
|
|
587
|
+
assigneeReview: input.assigneeReview,
|
|
580
588
|
});
|
|
581
589
|
// Post follow-up comment on the issue
|
|
582
590
|
await postFollowUpComment({
|
|
@@ -586,6 +594,7 @@ export async function attemptFix(input) {
|
|
|
586
594
|
prNumber: openedPr.prNumber,
|
|
587
595
|
targetRepo: targetRepo.repository,
|
|
588
596
|
fixAssessment,
|
|
597
|
+
requestedAssignees: openedPr.requestedAssignees,
|
|
589
598
|
});
|
|
590
599
|
logger.info('Fix attempt completed successfully', {
|
|
591
600
|
task: 'issue-triage',
|
|
@@ -52,6 +52,7 @@ function toAttemptFixInput(input) {
|
|
|
52
52
|
issueTitle: input.issueTitle,
|
|
53
53
|
issueUrl: input.issueUrl,
|
|
54
54
|
ghToken: input.ghToken,
|
|
55
|
+
assigneeReview: input.assigneeReview,
|
|
55
56
|
};
|
|
56
57
|
}
|
|
57
58
|
function stateLogMeta(state) {
|
|
@@ -184,6 +185,7 @@ export async function runConfiguredFix(input) {
|
|
|
184
185
|
issueUrl: input.issueUrl,
|
|
185
186
|
ghToken: input.ghToken,
|
|
186
187
|
pushToken: input.ctx.githubToken,
|
|
188
|
+
assigneeReview: input.assigneeReview,
|
|
187
189
|
apiKeys: {
|
|
188
190
|
openai: input.ctx.openaiApiKey,
|
|
189
191
|
...(input.ctx.geminiApiKey !== undefined && { gemini: input.ctx.geminiApiKey }),
|
|
@@ -3,10 +3,12 @@ import { withRetry } from '../../../core/retry.js';
|
|
|
3
3
|
import { renderPriorityBadge } from '../../review/github-comment-format.js';
|
|
4
4
|
import { runLocalReview as defaultRunLocalReview } from '../../review/local.js';
|
|
5
5
|
import { parseSeverity } from '../../review/severity.js';
|
|
6
|
+
import { hasAssigneeReviewRequest } from '../assignee-review.js';
|
|
6
7
|
import { runFixCore as defaultRunFixCore, stripPRTitleSection } from '../fix-attempt.js';
|
|
7
8
|
import { resolveHeroTeamSlug } from '../hero-group-map.js';
|
|
8
9
|
import { FIX_LOOP_START_MARKER } from '../markers.js';
|
|
9
10
|
import { commitAndPushFix as defaultCommitAndPushFix, commitAndPushIteration as defaultCommitAndPushIteration, openDraftFixPR as defaultOpenDraftFixPR, } from '../pr-creator.js';
|
|
11
|
+
import { markPullRequestReady } from '../pr-readiness.js';
|
|
10
12
|
export function buildFixLoopStartCommentBody(args) {
|
|
11
13
|
const p0 = renderPriorityBadge('P0') ?? 'P0';
|
|
12
14
|
const p1 = renderPriorityBadge('P1') ?? 'P1';
|
|
@@ -15,9 +17,9 @@ export function buildFixLoopStartCommentBody(args) {
|
|
|
15
17
|
'',
|
|
16
18
|
'### Working on a fix',
|
|
17
19
|
'',
|
|
18
|
-
`I've opened a
|
|
20
|
+
`I've opened a PR with an initial fix: ${args.prUrl}`,
|
|
19
21
|
'',
|
|
20
|
-
`The PR targets \`${args.targetRepoFullName}\`. I'll review it locally, iterate on ${p0} and ${p1} findings, and run CI. Once everything passes, I'll
|
|
22
|
+
`The PR targets \`${args.targetRepoFullName}\`. I'll review it locally, iterate on ${p0} and ${p1} findings, and run CI. Once everything passes, I'll mark the PR ready if it is still a draft. If review hasn't already been requested from an issue assignee, I'll request a hero review then.`,
|
|
21
23
|
].join('\n');
|
|
22
24
|
}
|
|
23
25
|
const DRY_RUN_HEAD_SHA = '0'.repeat(40);
|
|
@@ -184,6 +186,7 @@ export class DefaultLoopEffects {
|
|
|
184
186
|
...(input.generatedPRTitle ? { prTitle: input.generatedPRTitle } : {}),
|
|
185
187
|
...(input.generatedPRBody ? { prBody: input.generatedPRBody } : {}),
|
|
186
188
|
fixAssessment: session.fixAssessment,
|
|
189
|
+
assigneeReview: session.assigneeReview,
|
|
187
190
|
});
|
|
188
191
|
await this.postFixStartedSourceIssueComment({
|
|
189
192
|
prUrl: prResult.prUrl,
|
|
@@ -396,25 +399,12 @@ export class DefaultLoopEffects {
|
|
|
396
399
|
}));
|
|
397
400
|
return;
|
|
398
401
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const pr = await withRetry(() => this.session.octokit.rest.pulls.get({
|
|
402
|
+
await markPullRequestReady({
|
|
403
|
+
octokit: this.session.octokit,
|
|
402
404
|
owner: this.session.targetRepo.repository.owner,
|
|
403
405
|
repo: this.session.targetRepo.repository.repo,
|
|
404
|
-
|
|
405
|
-
})
|
|
406
|
-
// Idempotency: markPullRequestReadyForReview throws
|
|
407
|
-
// `UNPROCESSABLE: Pull request is not a draft` if the PR is already
|
|
408
|
-
// ready. On webhook redelivery the catch block in runPromotionFlow
|
|
409
|
-
// would re-arm awaiting-ci and loop on every subsequent CI event.
|
|
410
|
-
if (!pr.data.draft) {
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
await withRetry(() => this.session.octokit.graphql(`mutation($pullRequestId: ID!) {
|
|
414
|
-
markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {
|
|
415
|
-
pullRequest { isDraft }
|
|
416
|
-
}
|
|
417
|
-
}`, { pullRequestId: pr.data.node_id }));
|
|
406
|
+
prNumber,
|
|
407
|
+
});
|
|
418
408
|
}
|
|
419
409
|
async applyAskLabel(prNumber) {
|
|
420
410
|
if (this.opts.dryRun) {
|
|
@@ -454,6 +444,18 @@ export class DefaultLoopEffects {
|
|
|
454
444
|
}));
|
|
455
445
|
return;
|
|
456
446
|
}
|
|
447
|
+
// Read the durable receipt on the target PR, not the source issue.
|
|
448
|
+
// Do not infer success from the current requested_reviewers array:
|
|
449
|
+
// completed reviews disappear from that array before CI may finish.
|
|
450
|
+
if (await hasAssigneeReviewRequest({
|
|
451
|
+
octokit: this.session.octokit,
|
|
452
|
+
owner: this.session.targetRepo.repository.owner,
|
|
453
|
+
repo: this.session.targetRepo.repository.repo,
|
|
454
|
+
prNumber,
|
|
455
|
+
})) {
|
|
456
|
+
logger.info('Skipping Hero review: assignee review was already requested', this.logContext({ pr_number: prNumber }));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
457
459
|
await withRetry(() => this.session.octokit.rest.pulls.requestReviewers({
|
|
458
460
|
owner: this.session.targetRepo.repository.owner,
|
|
459
461
|
repo: this.session.targetRepo.repository.repo,
|
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { autofixFeedbackCommentMarker, iterationLabel } from 'doistbot-shared-contracts';
|
|
2
2
|
import { logger } from '../../../core/logging.js';
|
|
3
3
|
import { isFixLoopTerminal, isTerminal, next, } from './state-machine.js';
|
|
4
4
|
const CI_FAILED_LABEL = 'doistbot:ci-failed';
|
|
5
5
|
const NEEDS_HUMAN_LABEL = 'doistbot:needs-human';
|
|
6
6
|
const AWAITING_CI_LABEL = 'doistbot:awaiting-ci';
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
// Each terminal comment carries its outcome in the marker so retries update
|
|
8
|
+
// the same reaction target and closure tracking can classify the result.
|
|
9
|
+
export const PR_FEEDBACK_FOOTER = '<sub>🤖🫵 Please, react 👍 or 👎 on this comment to rate this bot-authored PR.</sub>';
|
|
10
|
+
function withFeedbackFooter(body) {
|
|
11
|
+
return `${body}\n\n${PR_FEEDBACK_FOOTER}`;
|
|
12
|
+
}
|
|
13
|
+
async function postNeedsHumanFeedbackComment(effects, prNumber, body = 'This PR needs human review.') {
|
|
14
|
+
await effects.postIssueComment({
|
|
15
|
+
prNumber,
|
|
16
|
+
marker: autofixFeedbackCommentMarker('needs_human'),
|
|
17
|
+
body: withFeedbackFooter(body),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
17
20
|
function formatFailedChecks(checks) {
|
|
18
21
|
return checks.map((c) => (c.url ? `- [${c.name}](${c.url})` : `- ${c.name}`)).join('\n');
|
|
19
22
|
}
|
|
@@ -107,15 +110,20 @@ export async function runFixLoop(input) {
|
|
|
107
110
|
if (state.kind === 'PUSHED_AWAITING_CI' &&
|
|
108
111
|
state.iteration !== undefined &&
|
|
109
112
|
state.promotionPolicy.kind === 'doistbot_fix_ci') {
|
|
110
|
-
// Local review accepted the patch; hand off to CI. awaiting-ci
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
113
|
+
// Local review accepted the patch; hand off to CI. awaiting-ci must
|
|
114
|
+
// NOT appear earlier: a fast CI suite could otherwise finalize checks
|
|
115
|
+
// for a patch the bot is still reviewing or about to iterate on.
|
|
116
|
+
// Assignee-reviewed PRs may already be ready, but still need this handoff.
|
|
117
|
+
// The iter-N label event also triggers reconciliation if CI already
|
|
118
|
+
// finished before awaiting-ci existed. Keep both labels in one call.
|
|
114
119
|
await tryApplyLabels(effects, state.prNumber, [
|
|
115
120
|
AWAITING_CI_LABEL,
|
|
116
121
|
iterationLabel(state.iteration),
|
|
117
122
|
]);
|
|
118
123
|
}
|
|
124
|
+
if (state.kind === 'DONE_NEEDS_HUMAN' && state.prNumber !== undefined) {
|
|
125
|
+
await postNeedsHumanFeedbackComment(effects, state.prNumber);
|
|
126
|
+
}
|
|
119
127
|
return state;
|
|
120
128
|
}
|
|
121
129
|
async function tryApplyLabels(effects, prNumber, labels) {
|
|
@@ -123,7 +131,7 @@ async function tryApplyLabels(effects, prNumber, labels) {
|
|
|
123
131
|
await effects.applyLabels({ prNumber, labels });
|
|
124
132
|
}
|
|
125
133
|
catch (error) {
|
|
126
|
-
logger.error('fix-loop: failed to apply tracking labels to
|
|
134
|
+
logger.error('fix-loop: failed to apply tracking labels to PR', {
|
|
127
135
|
task: 'fix-loop',
|
|
128
136
|
pr_number: prNumber,
|
|
129
137
|
labels,
|
|
@@ -170,22 +178,19 @@ export async function runPromotionFlow(effects, input) {
|
|
|
170
178
|
return state;
|
|
171
179
|
}
|
|
172
180
|
if (state.kind === 'DONE_PROMOTED') {
|
|
173
|
-
// Order matters: promoteToReady flips the PR out of draft and is
|
|
174
|
-
// effectively irreversible. Run it LAST so any failure on the
|
|
175
|
-
// reviewer/ask/comment steps re-arms awaiting-ci while the PR
|
|
176
|
-
// is still a draft — the next delivery re-runs the full sequence
|
|
177
|
-
// cleanly instead of seeing a half-promoted PR.
|
|
178
181
|
await effects.requestHeroReview(input.prNumber);
|
|
179
182
|
await effects.applyAskLabel(input.prNumber);
|
|
183
|
+
// Publish the successful validation outcome before making a
|
|
184
|
+
// remaining draft ready, so an immediate merge cannot beat the
|
|
185
|
+
// feedback comment and closure tracking.
|
|
180
186
|
await effects.postIssueComment({
|
|
181
187
|
prNumber: input.prNumber,
|
|
182
|
-
marker:
|
|
183
|
-
// This comment doubles as the thumbs-feedback prompt:
|
|
188
|
+
marker: autofixFeedbackCommentMarker('success'),
|
|
189
|
+
// This comment doubles as the thumbs-feedback prompt: closure
|
|
184
190
|
// tracking reads 👍/👎 reactions on this exact comment (located
|
|
185
|
-
// via the marker above) when the PR
|
|
191
|
+
// via the marker above) when the PR closes. Keep the reaction
|
|
186
192
|
// ask on the comment people are invited to react to.
|
|
187
|
-
body: 'CI passed
|
|
188
|
-
'<sub>🤖🫵 Please, react 👍 or 👎 on this comment to rate this bot-authored PR.</sub>',
|
|
193
|
+
body: withFeedbackFooter('CI passed. Review has been requested; any remaining draft status will be cleared.'),
|
|
189
194
|
});
|
|
190
195
|
await effects.promoteToReady(input.prNumber);
|
|
191
196
|
return state;
|
|
@@ -200,11 +205,7 @@ export async function runPromotionFlow(effects, input) {
|
|
|
200
205
|
: ciStatus.kind === 'no_checks'
|
|
201
206
|
? 'No CI checks ran on this PR — please verify manually before merging.'
|
|
202
207
|
: 'This PR needs human review.';
|
|
203
|
-
await effects.
|
|
204
|
-
prNumber: input.prNumber,
|
|
205
|
-
marker: PROMOTION_NEEDS_HUMAN_COMMENT_MARKER,
|
|
206
|
-
body,
|
|
207
|
-
});
|
|
208
|
+
await postNeedsHumanFeedbackComment(effects, input.prNumber, body);
|
|
208
209
|
return state;
|
|
209
210
|
}
|
|
210
211
|
if (!isTerminal(state)) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { normalizeScopedLabelValue, scopedLabelValue } from '../../core/label-parsing.js';
|
|
2
|
-
import { APP_LABEL_ALIASES, findIntegrationRoute,
|
|
2
|
+
import { APP_LABEL_ALIASES, findIntegrationRoute, findIssueRoute, findOwningRoutes, findRepositoryTarget as findRoutingRepositoryTarget, INTEGRATION_LABEL_ALIASES, PRODUCT_LABEL_ALIASES, PRODUCT_ROUTES, } from '../../core/repository-map.js';
|
|
3
3
|
const HERO_GROUPS = {
|
|
4
4
|
'android-hero': { key: 'android-hero', mention: '@Doist/android-hero' },
|
|
5
5
|
'app-copy-hero': { key: 'app-copy-hero', mention: '@Doist/app-copy-hero' },
|
|
@@ -181,13 +181,7 @@ export function resolveHeroGroup(labels, primaryRepo) {
|
|
|
181
181
|
return null;
|
|
182
182
|
}
|
|
183
183
|
if (primaryRepo) {
|
|
184
|
-
|
|
185
|
-
// todoist-macos owns the Safari extension, for example — keeps the product
|
|
186
|
-
// route's hero, so integration routes only cover integration-only repos.
|
|
187
|
-
const productRoutes = findRepositoryRoutes(primaryRepo);
|
|
188
|
-
const repositoryHeroKeys = new Set(productRoutes.length > 0
|
|
189
|
-
? productRoutes.map((route) => route.heroTeam)
|
|
190
|
-
: findIntegrationRoutes(primaryRepo).map((route) => route.heroTeam));
|
|
184
|
+
const repositoryHeroKeys = new Set(findOwningRoutes(primaryRepo).map((route) => route.heroTeam));
|
|
191
185
|
if (repositoryHeroKeys.size === 1) {
|
|
192
186
|
return HERO_GROUPS[repositoryHeroKeys.values().next().value];
|
|
193
187
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export const HERO_AUTOFIX_MARKER = '<!--doistbot-autofix-followup-->';
|
|
2
|
+
export const ASSIGNEE_REVIEW_MARKER = '<!--doistbot-assignee-review-requested-->';
|
|
2
3
|
export const FIX_LOOP_START_MARKER = '<!--doistbot-fixloop-start-->';
|
|
3
4
|
export const AUTOFIX_FAILURE_MARKER = '<!--doistbot-autofix-failure-->';
|
|
@@ -4,6 +4,10 @@ import path from 'path';
|
|
|
4
4
|
import { DOISTBOT_AUTO_FIX_LABEL, DOISTBOT_SPECULATIVE_FIX_LABEL } from 'doistbot-shared-contracts';
|
|
5
5
|
import { logger } from '../../core/logging.js';
|
|
6
6
|
import { fillPromptTemplate, loadPromptTemplateFile, resolvePromptFile, } from '../../core/prompt-template.js';
|
|
7
|
+
import { withRetry } from '../../core/retry.js';
|
|
8
|
+
import { requestAssigneeReview } from './assignee-review.js';
|
|
9
|
+
import { GITHUB_REQUEST_TIMEOUT_MS } from './github-request.js';
|
|
10
|
+
import { markPullRequestReady } from './pr-readiness.js';
|
|
7
11
|
const PR_TEMPLATE_PATHS = [
|
|
8
12
|
'.github/pull_request_template.md',
|
|
9
13
|
'.github/PULL_REQUEST_TEMPLATE.md',
|
|
@@ -241,7 +245,7 @@ export async function commitAndPushFix(input) {
|
|
|
241
245
|
/**
|
|
242
246
|
* Commits + pushes a second fix attempt on the EXISTING iter-0 branch. Unlike
|
|
243
247
|
* commitAndPushFix, this does not create a new branch — callers (the fix loop's
|
|
244
|
-
* iter-1 path) need the existing
|
|
248
|
+
* iter-1 path) need the existing PR to pick up the new commit, which only
|
|
245
249
|
* works if we push to the same branch the PR was opened from.
|
|
246
250
|
*
|
|
247
251
|
* Assumes the working tree already has the iter-1 changes staged (typically by
|
|
@@ -272,6 +276,7 @@ export async function commitAndPushIteration(input) {
|
|
|
272
276
|
});
|
|
273
277
|
return { headSha };
|
|
274
278
|
}
|
|
279
|
+
/** Opens a draft, then marks it ready if an eligible assignee review was requested. */
|
|
275
280
|
export async function openDraftFixPR(input) {
|
|
276
281
|
const prBody = input.prBody
|
|
277
282
|
? stripEmptySections(input.prBody)
|
|
@@ -298,6 +303,13 @@ export async function openDraftFixPR(input) {
|
|
|
298
303
|
base: repo.default_branch,
|
|
299
304
|
draft: true,
|
|
300
305
|
});
|
|
306
|
+
const requestedAssignees = await requestAssigneeReview({
|
|
307
|
+
owner: input.repoOwner,
|
|
308
|
+
repo: input.repoName,
|
|
309
|
+
prNumber: pr.number,
|
|
310
|
+
octokit: input.octokit,
|
|
311
|
+
assigneeReview: input.assigneeReview,
|
|
312
|
+
});
|
|
301
313
|
try {
|
|
302
314
|
const prLabel = input.fixAssessment?.classification === 'speculative'
|
|
303
315
|
? DOISTBOT_SPECULATIVE_FIX_LABEL
|
|
@@ -307,6 +319,7 @@ export async function openDraftFixPR(input) {
|
|
|
307
319
|
repo: input.repoName,
|
|
308
320
|
issue_number: pr.number,
|
|
309
321
|
labels: [prLabel],
|
|
322
|
+
request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
|
|
310
323
|
});
|
|
311
324
|
}
|
|
312
325
|
catch (error) {
|
|
@@ -317,6 +330,50 @@ export async function openDraftFixPR(input) {
|
|
|
317
330
|
error: error instanceof Error ? error.message : String(error),
|
|
318
331
|
});
|
|
319
332
|
}
|
|
333
|
+
if (requestedAssignees.length > 0) {
|
|
334
|
+
// Apply the autofix label before emitting ready_for_review. An accepted
|
|
335
|
+
// assignee request is sufficient, even if writing its receipt failed.
|
|
336
|
+
try {
|
|
337
|
+
await markPullRequestReady({
|
|
338
|
+
octokit: input.octokit,
|
|
339
|
+
owner: input.repoOwner,
|
|
340
|
+
repo: input.repoName,
|
|
341
|
+
prNumber: pr.number,
|
|
342
|
+
});
|
|
343
|
+
logger.info('Assignee-reviewed fix PR marked ready', {
|
|
344
|
+
task: 'issue-triage',
|
|
345
|
+
repository: `${input.repoOwner}/${input.repoName}`,
|
|
346
|
+
prNumber: pr.number,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
// The PR and review request already exist. Preserve both and keep
|
|
351
|
+
// the fix loop running rather than reporting PR creation failure.
|
|
352
|
+
logger.warn('Failed to mark assignee-reviewed fix PR ready', {
|
|
353
|
+
task: 'issue-triage',
|
|
354
|
+
repository: `${input.repoOwner}/${input.repoName}`,
|
|
355
|
+
prNumber: pr.number,
|
|
356
|
+
error: error instanceof Error ? error.message : String(error),
|
|
357
|
+
});
|
|
358
|
+
try {
|
|
359
|
+
await withRetry(() => input.octokit.rest.issues.createComment({
|
|
360
|
+
owner: input.repoOwner,
|
|
361
|
+
repo: input.repoName,
|
|
362
|
+
issue_number: pr.number,
|
|
363
|
+
body: "Review was requested from the issue assignees, but I couldn't confirm that this PR was marked ready for review. Please check its status and mark it ready manually if needed.",
|
|
364
|
+
request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
catch (commentError) {
|
|
368
|
+
logger.warn('Failed to report fix PR readiness failure', {
|
|
369
|
+
task: 'issue-triage',
|
|
370
|
+
repository: `${input.repoOwner}/${input.repoName}`,
|
|
371
|
+
prNumber: pr.number,
|
|
372
|
+
error: commentError instanceof Error ? commentError.message : String(commentError),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
320
377
|
logger.info('Fix PR created', {
|
|
321
378
|
task: 'issue-triage',
|
|
322
379
|
repository: `${input.repoOwner}/${input.repoName}`,
|
|
@@ -327,5 +384,6 @@ export async function openDraftFixPR(input) {
|
|
|
327
384
|
return {
|
|
328
385
|
prUrl: pr.html_url,
|
|
329
386
|
prNumber: pr.number,
|
|
387
|
+
...(requestedAssignees.length > 0 && { requestedAssignees }),
|
|
330
388
|
};
|
|
331
389
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { withRetry } from '../../core/retry.js';
|
|
2
|
+
import { GITHUB_REQUEST_TIMEOUT_MS } from './github-request.js';
|
|
3
|
+
/** Safe both at creation and when the later CI-driven promotion runs. */
|
|
4
|
+
export async function markPullRequestReady({ octokit, owner, repo, prNumber, }) {
|
|
5
|
+
// Re-read on every attempt: a mutation may have succeeded even if its
|
|
6
|
+
// response was lost. Repeating that mutation on a ready PR would fail.
|
|
7
|
+
await withRetry(async () => {
|
|
8
|
+
const { data: pr } = await octokit.rest.pulls.get({
|
|
9
|
+
owner,
|
|
10
|
+
repo,
|
|
11
|
+
pull_number: prNumber,
|
|
12
|
+
request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
|
|
13
|
+
});
|
|
14
|
+
if (!pr.draft)
|
|
15
|
+
return;
|
|
16
|
+
await octokit.graphql(`mutation($pullRequestId: ID!) {
|
|
17
|
+
markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {
|
|
18
|
+
pullRequest { isDraft }
|
|
19
|
+
}
|
|
20
|
+
}`, {
|
|
21
|
+
pullRequestId: pr.node_id,
|
|
22
|
+
request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { repositorySandboxProfile, SANDBOX_PROFILES } from 'doistbot-issue-routing';
|
|
2
|
+
function readSelection() {
|
|
3
|
+
if (!process.env.SANDBOX_SELECTION)
|
|
4
|
+
return undefined;
|
|
5
|
+
const selection = JSON.parse(process.env.SANDBOX_SELECTION);
|
|
6
|
+
function strings(value) {
|
|
7
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
8
|
+
}
|
|
9
|
+
if (!selection ||
|
|
10
|
+
!strings(selection.repositories) ||
|
|
11
|
+
!strings(selection.labels) ||
|
|
12
|
+
!strings(selection.verificationLimits) ||
|
|
13
|
+
typeof selection.reason !== 'string' ||
|
|
14
|
+
(selection.profile !== 'legacy' &&
|
|
15
|
+
(!SANDBOX_PROFILES.includes(selection.profile) || typeof selection.image !== 'string')))
|
|
16
|
+
throw new Error('Invalid sandbox selection.');
|
|
17
|
+
return selection;
|
|
18
|
+
}
|
|
19
|
+
/** Check image identity only. Repo setup and required checks belong to issue #524. */
|
|
20
|
+
export function sandboxEnvironmentError(repository) {
|
|
21
|
+
let selection;
|
|
22
|
+
try {
|
|
23
|
+
selection = readSelection();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return 'Invalid sandbox selection.';
|
|
27
|
+
}
|
|
28
|
+
if (!selection)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (!selection.repositories.some((candidate) => candidate.toLowerCase() === repository.toLowerCase()))
|
|
31
|
+
return 'Fix target is outside the recorded sandbox scope.';
|
|
32
|
+
// Mixed scopes deliberately retain the existing image and autofix behavior.
|
|
33
|
+
if (selection.profile === 'legacy')
|
|
34
|
+
return undefined;
|
|
35
|
+
const profile = repositorySandboxProfile(repository, selection.labels);
|
|
36
|
+
if (selection.profile !== profile ||
|
|
37
|
+
process.env.SANDBOX_PROFILE !== profile ||
|
|
38
|
+
process.env.SANDBOX_IMAGE_ID !== selection.image)
|
|
39
|
+
return 'Unsupported fix environment: target needs a different image. Cross-image fix dispatch is not enabled.';
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
export function describeSandboxEnvironment() {
|
|
43
|
+
let selection;
|
|
44
|
+
try {
|
|
45
|
+
selection = readSelection();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return '\n\nSandbox selection is invalid; automatic fixes are unavailable.';
|
|
49
|
+
}
|
|
50
|
+
if (!selection)
|
|
51
|
+
return '';
|
|
52
|
+
return `\n\n## Sandbox environment\nProfile: ${selection.profile}. ${selection.reason}\n${selection.verificationLimits.join('\n')}\nInstalled tools do not prove that repository builds or tests passed. Report only checks actually performed.`;
|
|
53
|
+
}
|
|
@@ -17,6 +17,7 @@ import { fillTemplate, loadPromptTemplate } from './prompt.js';
|
|
|
17
17
|
import { allRepositoriesAllow, createRepoConfigLoader, isAutoFixEnabled, isAutoTriageEnabled, } from './repo-config.js';
|
|
18
18
|
import { resolveTriageScope } from './resolution.js';
|
|
19
19
|
import { buildHeroRoutingComment, postTriageRouting, shouldPostHeroRoutingComment, } from './routing.js';
|
|
20
|
+
import { describeSandboxEnvironment } from './sandbox-environment.js';
|
|
20
21
|
import { createGitHubClients, getIssueUrl, prepareWorkspaceReposDirectory } from './shared.js';
|
|
21
22
|
import { deleteAutoTriagePlaceholder, postTriageOutput } from './side-effects.js';
|
|
22
23
|
const MAX_ISSUE_BODY_CHARS = 8_000;
|
|
@@ -238,7 +239,7 @@ async function runIssueTriagePipeline(ctx, mode) {
|
|
|
238
239
|
type: 'installation',
|
|
239
240
|
installationId: ctx.installationId,
|
|
240
241
|
}));
|
|
241
|
-
|
|
242
|
+
let prompt = buildPrompt({
|
|
242
243
|
ctx,
|
|
243
244
|
issue,
|
|
244
245
|
comments,
|
|
@@ -247,6 +248,7 @@ async function runIssueTriagePipeline(ctx, mode) {
|
|
|
247
248
|
decisionPath: resolution.decisionPath,
|
|
248
249
|
enableAutoFix,
|
|
249
250
|
});
|
|
251
|
+
prompt += describeSandboxEnvironment();
|
|
250
252
|
const triageResult = await invokeTriageModel({
|
|
251
253
|
prompt,
|
|
252
254
|
apiKey: ctx.openaiApiKey,
|
|
@@ -254,7 +256,7 @@ async function runIssueTriagePipeline(ctx, mode) {
|
|
|
254
256
|
extraEnv: {
|
|
255
257
|
GH_TOKEN: issueInstallationAuth.token,
|
|
256
258
|
},
|
|
257
|
-
thinkingLevel: '
|
|
259
|
+
thinkingLevel: 'xhigh',
|
|
258
260
|
});
|
|
259
261
|
if (!triageResult.ok) {
|
|
260
262
|
await postFinalTriageComment(buildTriageFailureMessage(triageResult.error));
|
|
@@ -318,6 +320,10 @@ async function runIssueTriagePipeline(ctx, mode) {
|
|
|
318
320
|
issueUrl: issue.html_url ?? getIssueUrl(ctx.owner, ctx.repo, ctx.issueNumber),
|
|
319
321
|
ghToken: issueInstallationAuth.token,
|
|
320
322
|
loadRepoConfig: getCachedRepoConfig,
|
|
323
|
+
assigneeReview: {
|
|
324
|
+
assignees: (issue.assignees ?? []).map((assignee) => assignee.login),
|
|
325
|
+
membershipOctokit: readOctokit,
|
|
326
|
+
},
|
|
321
327
|
});
|
|
322
328
|
logger.info('triage.fix_attempted', {
|
|
323
329
|
task: 'issue-triage',
|
|
@@ -7,6 +7,8 @@ export type LoggerOptions = {
|
|
|
7
7
|
suppressLogs?: (level: LogLevel) => boolean;
|
|
8
8
|
suppressInfoLogs?: () => boolean;
|
|
9
9
|
includeLogField?: boolean;
|
|
10
|
+
/** Receives serialized JSON without a trailing newline. The writer must add one. */
|
|
11
|
+
write?: (line: string) => void;
|
|
10
12
|
buildInfo?: {
|
|
11
13
|
version: number;
|
|
12
14
|
commit: string;
|
|
@@ -33,8 +33,14 @@ export function createLogger(options = {}) {
|
|
|
33
33
|
}
|
|
34
34
|
Object.assign(payload, traceInfo);
|
|
35
35
|
payload.meta = metaInfo;
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const line = JSON.stringify(payload);
|
|
37
|
+
if (options.write) {
|
|
38
|
+
options.write(line);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// eslint-disable-next-line no-console
|
|
42
|
+
console.log(line);
|
|
43
|
+
}
|
|
38
44
|
}
|
|
39
45
|
return {
|
|
40
46
|
info: (message, meta) => log('info', message, meta),
|
|
@@ -73,6 +73,8 @@ Any state that must survive restarts must be stored externally, e.g.:
|
|
|
73
73
|
|
|
74
74
|
Local disk may be used only for temporary data.
|
|
75
75
|
|
|
76
|
+
Durable state must be backed up. DynamoDB tables must have [point-in-time recovery](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Point-in-time-recovery.html) enabled and carry the account's `Backup` tag so the AWS Backup plan selects them. Caches, lock tables and short-lived session tables with a TTL are exempt.
|
|
77
|
+
|
|
76
78
|
## Resource Limits
|
|
77
79
|
|
|
78
80
|
Define expected CPU, memory, network, and local disk usage.
|