@doist/doistbot-cli 1.0.12 → 1.0.14
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/config.js +1 -1
- package/package.json +5 -5
- package/sandbox/dist/core/pi.js +2 -2
- package/sandbox/dist/core/repository-map.js +1 -1
- 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-summarize/context.js +1 -0
- package/sandbox/dist/tasks/issue-summarize/model.js +24 -7
- package/sandbox/dist/tasks/issue-summarize/summarize.js +6 -4
- package/sandbox/dist/tasks/issue-triage/assignee-review.js +114 -0
- package/sandbox/dist/tasks/issue-triage/fix-attempt.js +16 -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 +41 -3
- 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/resolution.js +3 -0
- package/sandbox/dist/tasks/issue-triage/triage.js +5 -1
- package/sandbox/dist/tasks/review/thinking-level.js +1 -1
- package/sandbox/src/review/prompts/ai-internal-tools.md +3 -3
- package/sandbox/src/review/prompts/backend-repository-standards.md +102 -0
- package/sandbox/src/review/prompts/outline-map.json +14 -10
- package/sandbox/src/review/prompts/service-production-readiness.md +2 -2
- package/sandbox/src/review/prompts/which-cloud-and-where-to-deploy.md +0 -344
package/dist/config.js
CHANGED
|
@@ -29,7 +29,7 @@ const ALL_REVIEW_FOCUSES = [
|
|
|
29
29
|
'standards',
|
|
30
30
|
];
|
|
31
31
|
const OPENROUTER_REVIEW_MODELS = ['deepseek', 'zai'];
|
|
32
|
-
const CLI_GEMINI_REVIEW_MODEL = 'gemini-3.
|
|
32
|
+
const CLI_GEMINI_REVIEW_MODEL = 'gemini-3.8-flash';
|
|
33
33
|
function createCliGeminiCredential(apiKey) {
|
|
34
34
|
return {
|
|
35
35
|
apiKey,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doist/doistbot-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.14",
|
|
4
4
|
"description": "Local self-review CLI for Doistbot",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -40,10 +40,10 @@
|
|
|
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.
|
|
45
|
-
"@inquirer/checkbox": "5.2.
|
|
46
|
-
"@inquirer/password": "5.2.
|
|
43
|
+
"@earendil-works/pi-ai": "0.87.1",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "0.87.1",
|
|
45
|
+
"@inquirer/checkbox": "5.2.5",
|
|
46
|
+
"@inquirer/password": "5.2.2",
|
|
47
47
|
"@napi-rs/keyring": "1.3.0",
|
|
48
48
|
"@opentelemetry/api": "1.9.1",
|
|
49
49
|
"ajv": "8.20.0",
|
package/sandbox/dist/core/pi.js
CHANGED
|
@@ -54,7 +54,7 @@ export const DEFAULT_PI_MODELS = {
|
|
|
54
54
|
gemini: 'gemini-3.1-pro-preview',
|
|
55
55
|
grok: 'grok-4.6',
|
|
56
56
|
zai: 'glm5.2',
|
|
57
|
-
openai: 'gpt-
|
|
57
|
+
openai: 'gpt-6-sol',
|
|
58
58
|
};
|
|
59
59
|
export const DEFAULT_OPENROUTER_MODELS = {
|
|
60
60
|
deepseek: 'deepseek/deepseek-v4-pro-0813',
|
|
@@ -63,7 +63,7 @@ export const DEFAULT_OPENROUTER_MODELS = {
|
|
|
63
63
|
zai: 'z-ai/glm-5.3-flash',
|
|
64
64
|
};
|
|
65
65
|
// Gemini flash fallback model.
|
|
66
|
-
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-3.
|
|
66
|
+
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-3.8-flash';
|
|
67
67
|
export const MAX_PI_THINKING_BY_PROVIDER = {
|
|
68
68
|
deepseek: 'high',
|
|
69
69
|
gemini: 'high',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { APP_LABEL_ALIASES, APP_TO_ROUTE_SELECTORS, findIssueRoute, findRepositoryRoutes, findRepositoryTarget, 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';
|
|
@@ -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;
|
|
@@ -11,6 +11,7 @@ export function parseSummarizeContext(env = process.env) {
|
|
|
11
11
|
appId: requiredEnv('APP_ID', env),
|
|
12
12
|
privateKey: requiredEnv('PRIVATE_KEY', env),
|
|
13
13
|
githubToken: requiredEnv('GITHUB_TOKEN', env),
|
|
14
|
+
geminiApiKey: requiredEnv('GEMINI_API_KEY', env),
|
|
14
15
|
openRouterApiKey: requiredEnv('OPENROUTER_API_KEY', env),
|
|
15
16
|
commentId,
|
|
16
17
|
reactionId,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { SpanStatusCode } from '@opentelemetry/api';
|
|
2
2
|
import { logger } from '../../core/logging.js';
|
|
3
|
-
import { buildPiImageContent, createPiInvoker, DEFAULT_OPENROUTER_MODELS } from '../../core/pi.js';
|
|
3
|
+
import { buildPiImageContent, createPiInvoker, DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_OPENROUTER_MODELS, } from '../../core/pi.js';
|
|
4
4
|
import { withPhase } from '../../core/tracing.js';
|
|
5
5
|
const ISSUE_SUMMARIZE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
6
|
-
const ISSUE_SUMMARIZE_MULTIMODAL_MODEL =
|
|
6
|
+
const ISSUE_SUMMARIZE_MULTIMODAL_MODEL = DEFAULT_GEMINI_FLASH_MODEL;
|
|
7
7
|
const SUMMARIZE_TEXT_PROVIDER = 'zai';
|
|
8
8
|
// Single source of truth: inherit the repo-wide Z.ai default so a default-model
|
|
9
9
|
// bump in core/pi.ts moves summarize too, and the executed model matches the
|
|
@@ -14,7 +14,7 @@ const SUMMARIZE_MULTIMODAL_PROVIDER = 'gcp.gemini';
|
|
|
14
14
|
// `issue-summarize.model`; each chunk summary uses `issue-summarize.chunk`.
|
|
15
15
|
const SUMMARIZE_MODEL_PHASE = 'issue-summarize.model';
|
|
16
16
|
const SUMMARIZE_CHUNK_PHASE = 'issue-summarize.chunk';
|
|
17
|
-
export function
|
|
17
|
+
export function createIssueSummarizeTextModel(apiKey) {
|
|
18
18
|
return createPiInvoker({
|
|
19
19
|
provider: 'zai',
|
|
20
20
|
apiKey,
|
|
@@ -23,6 +23,15 @@ export function createIssueSummarizeModel(apiKey) {
|
|
|
23
23
|
capabilityPreset: 'read-only',
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
+
export function createIssueSummarizeMultimodalModel(apiKey) {
|
|
27
|
+
return createPiInvoker({
|
|
28
|
+
provider: 'gemini',
|
|
29
|
+
apiKey,
|
|
30
|
+
piProviderId: 'google',
|
|
31
|
+
cwd: process.cwd(),
|
|
32
|
+
capabilityPreset: 'read-only',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
26
35
|
async function invokeIssueSummarizeTextModel({ model, prompt, phaseName, }) {
|
|
27
36
|
return withPhase(phaseName, {
|
|
28
37
|
'gen_ai.operation.name': 'chat',
|
|
@@ -115,11 +124,11 @@ async function invokeMultimodalSummarizeModel({ model, prompt, mediaImages, }) {
|
|
|
115
124
|
}
|
|
116
125
|
});
|
|
117
126
|
}
|
|
118
|
-
export async function invokeIssueSummarizeModel({
|
|
127
|
+
export async function invokeIssueSummarizeModel({ textModel, multimodalModel, prompt, mediaImages, }) {
|
|
119
128
|
if (mediaImages.length === 0) {
|
|
120
129
|
logger.info('Using text-only summarize model request');
|
|
121
130
|
return await invokeIssueSummarizeTextModel({
|
|
122
|
-
model,
|
|
131
|
+
model: textModel,
|
|
123
132
|
prompt,
|
|
124
133
|
phaseName: SUMMARIZE_MODEL_PHASE,
|
|
125
134
|
});
|
|
@@ -127,12 +136,20 @@ export async function invokeIssueSummarizeModel({ model, prompt, mediaImages, })
|
|
|
127
136
|
logger.info('Using multimodal summarize model request', {
|
|
128
137
|
mediaImages: mediaImages.length,
|
|
129
138
|
});
|
|
130
|
-
const multimodal = await invokeMultimodalSummarizeModel({
|
|
139
|
+
const multimodal = await invokeMultimodalSummarizeModel({
|
|
140
|
+
model: multimodalModel,
|
|
141
|
+
prompt,
|
|
142
|
+
mediaImages,
|
|
143
|
+
});
|
|
131
144
|
if (multimodal) {
|
|
132
145
|
return multimodal;
|
|
133
146
|
}
|
|
134
147
|
// Multimodal failed — fall back to a separate text request (own model span).
|
|
135
|
-
return await invokeIssueSummarizeTextModel({
|
|
148
|
+
return await invokeIssueSummarizeTextModel({
|
|
149
|
+
model: textModel,
|
|
150
|
+
prompt,
|
|
151
|
+
phaseName: SUMMARIZE_MODEL_PHASE,
|
|
152
|
+
});
|
|
136
153
|
}
|
|
137
154
|
function buildChunkSummaryPrompt({ chunk, chunkIndex, selectedChunkCount, totalChunks, }) {
|
|
138
155
|
return `You are preparing an intermediate summary for a GitHub issue discussion.
|
|
@@ -7,7 +7,7 @@ import { resolveWriteOctokit } from '../../core/shared.js';
|
|
|
7
7
|
import { withPhase } from '../../core/tracing.js';
|
|
8
8
|
import { parseSummarizeContext } from './context.js';
|
|
9
9
|
import { buildMediaPromptImages, extractMediaReferences, formatMediaContext, inspectMediaReferences, } from './media.js';
|
|
10
|
-
import {
|
|
10
|
+
import { createIssueSummarizeMultimodalModel, createIssueSummarizeTextModel, invokeIssueSummarizeModel, summarizeChunks, } from './model.js';
|
|
11
11
|
import { buildFallbackSummary, ensureStructuredSummary, stripVisualContextSection, } from './output.js';
|
|
12
12
|
import { fillTemplate, loadPromptTemplate } from './prompt.js';
|
|
13
13
|
import { buildThreadEntries, prepareThreadPrompt } from './thread.js';
|
|
@@ -41,7 +41,8 @@ function summarizeMediaInspectionStats(mediaInspections) {
|
|
|
41
41
|
async function summarizeIssue({ ctx, issue, entries, mediaReferences, mediaInspections, }) {
|
|
42
42
|
const template = loadPromptTemplate();
|
|
43
43
|
const threadPrompt = prepareThreadPrompt(entries);
|
|
44
|
-
const
|
|
44
|
+
const textModel = createIssueSummarizeTextModel(ctx.openRouterApiKey);
|
|
45
|
+
const multimodalModel = createIssueSummarizeMultimodalModel(ctx.geminiApiKey);
|
|
45
46
|
logger.info('Prepared summarize thread prompt', {
|
|
46
47
|
entryCount: entries.length,
|
|
47
48
|
chunked: threadPrompt.chunked,
|
|
@@ -50,7 +51,7 @@ async function summarizeIssue({ ctx, issue, entries, mediaReferences, mediaInspe
|
|
|
50
51
|
});
|
|
51
52
|
const threadContent = threadPrompt.chunked
|
|
52
53
|
? await summarizeChunks({
|
|
53
|
-
model:
|
|
54
|
+
model: textModel,
|
|
54
55
|
chunks: threadPrompt.chunks,
|
|
55
56
|
totalChunks: threadPrompt.totalChunks,
|
|
56
57
|
})
|
|
@@ -75,7 +76,8 @@ async function summarizeIssue({ ctx, issue, entries, mediaReferences, mediaInspe
|
|
|
75
76
|
mediaImagesIncluded: mediaPromptImages.length,
|
|
76
77
|
});
|
|
77
78
|
const summary = await invokeIssueSummarizeModel({
|
|
78
|
-
|
|
79
|
+
textModel,
|
|
80
|
+
multimodalModel,
|
|
79
81
|
prompt,
|
|
80
82
|
mediaImages: mediaPromptImages,
|
|
81
83
|
});
|
|
@@ -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
|
+
}
|
|
@@ -147,13 +147,15 @@ function captureGitDiff(repoLocalPath) {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
const DOISTBOT_DEVS_MENTION = '@Doist/doistbot-devs';
|
|
150
|
-
export function buildFollowUpCommentBody({ prUrl, targetRepoFullName, fixAssessment, heroMention, }) {
|
|
150
|
+
export function buildFollowUpCommentBody({ prUrl, targetRepoFullName, fixAssessment, heroMention, requestedAssignees = [], }) {
|
|
151
151
|
const description = fixAssessment.classification === 'speculative'
|
|
152
|
-
? `I've created a
|
|
153
|
-
: `I've created a
|
|
154
|
-
const introLine =
|
|
155
|
-
?
|
|
156
|
-
:
|
|
152
|
+
? `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.`
|
|
153
|
+
: `I've created a PR with a proposed fix: ${prUrl}`;
|
|
154
|
+
const introLine = requestedAssignees.length > 0
|
|
155
|
+
? `I've requested a review from ${requestedAssignees.map((login) => `@${login}`).join(', ')} on the autofix PR.`
|
|
156
|
+
: heroMention
|
|
157
|
+
? `${heroMention} a doistbot autofix PR is ready for your review.`
|
|
158
|
+
: `${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
159
|
return [
|
|
158
160
|
HERO_AUTOFIX_MARKER,
|
|
159
161
|
'',
|
|
@@ -282,9 +284,9 @@ export async function postAutoFixFailureComment({ octokit, ctx, reason, targetRe
|
|
|
282
284
|
operation: existing ? 'update' : 'create',
|
|
283
285
|
});
|
|
284
286
|
}
|
|
285
|
-
async function postFollowUpComment({ octokit, ctx, prUrl, prNumber, targetRepo, fixAssessment, }) {
|
|
286
|
-
const heroGroup = resolveHeroGroup([], targetRepo);
|
|
287
|
-
if (!heroGroup) {
|
|
287
|
+
async function postFollowUpComment({ octokit, ctx, prUrl, prNumber, targetRepo, fixAssessment, requestedAssignees = [], }) {
|
|
288
|
+
const heroGroup = requestedAssignees.length > 0 ? null : resolveHeroGroup([], targetRepo);
|
|
289
|
+
if (!heroGroup && requestedAssignees.length === 0) {
|
|
288
290
|
logger.warn('No hero mapping found for autofix target repo', {
|
|
289
291
|
task: 'issue-triage',
|
|
290
292
|
issueNumber: ctx.issueNumber,
|
|
@@ -297,6 +299,7 @@ async function postFollowUpComment({ octokit, ctx, prUrl, prNumber, targetRepo,
|
|
|
297
299
|
targetRepoFullName: targetRepo.fullName,
|
|
298
300
|
fixAssessment,
|
|
299
301
|
heroMention: heroGroup?.mention ?? null,
|
|
302
|
+
requestedAssignees,
|
|
300
303
|
});
|
|
301
304
|
await octokit.rest.issues.createComment({
|
|
302
305
|
owner: ctx.owner,
|
|
@@ -389,7 +392,7 @@ export async function runFixCore(input) {
|
|
|
389
392
|
prompt,
|
|
390
393
|
apiKey: ctx.openaiApiKey,
|
|
391
394
|
cwd: targetRepo.localPath,
|
|
392
|
-
thinkingLevel: '
|
|
395
|
+
thinkingLevel: 'max',
|
|
393
396
|
timeoutMs,
|
|
394
397
|
extraEnv: {
|
|
395
398
|
GH_TOKEN: input.ghToken,
|
|
@@ -438,7 +441,7 @@ export async function runFixCore(input) {
|
|
|
438
441
|
return { ok: false, reason: 'Codex produced no file changes.' };
|
|
439
442
|
}
|
|
440
443
|
// Validate the diff — speculative fixes bypass the file-count limit since they
|
|
441
|
-
// are
|
|
444
|
+
// are starting-point PRs that may touch more files.
|
|
442
445
|
const diffLimits = fixAssessment.classification === 'speculative' ? { maxChangedFiles: Infinity } : undefined;
|
|
443
446
|
const validation = validateDiff(diff, diffLimits);
|
|
444
447
|
if (!validation.valid) {
|
|
@@ -577,6 +580,7 @@ export async function attemptFix(input) {
|
|
|
577
580
|
prTitle: coreResult.generatedPRTitle ?? undefined,
|
|
578
581
|
prBody: coreResult.generatedPRBody ?? undefined,
|
|
579
582
|
fixAssessment,
|
|
583
|
+
assigneeReview: input.assigneeReview,
|
|
580
584
|
});
|
|
581
585
|
// Post follow-up comment on the issue
|
|
582
586
|
await postFollowUpComment({
|
|
@@ -586,6 +590,7 @@ export async function attemptFix(input) {
|
|
|
586
590
|
prNumber: openedPr.prNumber,
|
|
587
591
|
targetRepo: targetRepo.repository,
|
|
588
592
|
fixAssessment,
|
|
593
|
+
requestedAssignees: openedPr.requestedAssignees,
|
|
589
594
|
});
|
|
590
595
|
logger.info('Fix attempt completed successfully', {
|
|
591
596
|
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)) {
|