@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 { isReviewModePilotRepository, loadGitHubReviewModePolicy } from './review-mode-policy.js';
|
|
2
|
+
const MAX_REVIEW_MODE_COMMITS = 30;
|
|
3
|
+
const MAX_REVIEW_MODE_COMMIT_MESSAGE_CHARS = 2_000;
|
|
4
|
+
/** Keep advisory metadata separate from the normal review prompt and load it only if needed. */
|
|
5
|
+
export function createGitHubReviewModeContext({ octokit, owner, repo, pullNumber, baseSha, labels, }) {
|
|
6
|
+
if (!isReviewModePilotRepository(owner, repo)) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
const initialLabels = labels
|
|
10
|
+
.map((label) => (typeof label === 'string' ? label : label.name))
|
|
11
|
+
.filter((name) => Boolean(name));
|
|
12
|
+
return {
|
|
13
|
+
labels: initialLabels,
|
|
14
|
+
loadPolicy: (signal) => loadGitHubReviewModePolicy({ octokit, owner, repo, ref: baseSha, signal }),
|
|
15
|
+
loadLabels: async (signal) => {
|
|
16
|
+
try {
|
|
17
|
+
const response = await octokit.rest.pulls.get({
|
|
18
|
+
owner,
|
|
19
|
+
repo,
|
|
20
|
+
pull_number: pullNumber,
|
|
21
|
+
request: { signal },
|
|
22
|
+
});
|
|
23
|
+
return response.data.labels.map((label) => label.name);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return initialLabels;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
loadCommitContext: async (signal) => {
|
|
30
|
+
try {
|
|
31
|
+
// Fetch one extra commit to detect truncation without unbounded pagination.
|
|
32
|
+
const response = await octokit.rest.pulls.listCommits({
|
|
33
|
+
owner,
|
|
34
|
+
repo,
|
|
35
|
+
pull_number: pullNumber,
|
|
36
|
+
per_page: MAX_REVIEW_MODE_COMMITS + 1,
|
|
37
|
+
page: 1,
|
|
38
|
+
request: { signal },
|
|
39
|
+
});
|
|
40
|
+
const commits = response.data.slice(0, MAX_REVIEW_MODE_COMMITS).map((commit) => {
|
|
41
|
+
const message = commit.commit.message;
|
|
42
|
+
return `${commit.sha}\n${message.slice(0, MAX_REVIEW_MODE_COMMIT_MESSAGE_CHARS)}${message.length > MAX_REVIEW_MODE_COMMIT_MESSAGE_CHARS
|
|
43
|
+
? '\n[Commit message truncated.]'
|
|
44
|
+
: ''}`;
|
|
45
|
+
});
|
|
46
|
+
if (response.data.length > MAX_REVIEW_MODE_COMMITS) {
|
|
47
|
+
commits.push(`[Commit context truncated after ${MAX_REVIEW_MODE_COMMITS} PR commits.]`);
|
|
48
|
+
}
|
|
49
|
+
return commits.length > 0
|
|
50
|
+
? `PR commits (factual context, not policy):\n\n${commits.join('\n\n')}`
|
|
51
|
+
: 'PR commit context unavailable: GitHub returned no commits.';
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return 'PR commit context unavailable: unable to load PR commits from GitHub.';
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { CONFIG_FILE_PATH, fetchFileContent, parseRepoConfigContent, RepoFileContentError, } from 'doistbot-repo-config';
|
|
2
|
+
export const DEFAULT_REVIEW_MODE_GUIDANCE_PATH = '.github/review-mode.md';
|
|
3
|
+
export const DEFAULT_REVIEW_MODE_LABELS = {
|
|
4
|
+
ask: ['🙋 Ask'],
|
|
5
|
+
show: ['👀 Show'],
|
|
6
|
+
ship: ['🚢 Ship'],
|
|
7
|
+
};
|
|
8
|
+
const MAX_POLICY_FILE_BYTES = 32 * 1024;
|
|
9
|
+
const PILOT_REPOSITORIES = new Set([
|
|
10
|
+
'todoist/account-web',
|
|
11
|
+
'todoist/automations',
|
|
12
|
+
'todoist/comms-web',
|
|
13
|
+
'todoist/todoist-android',
|
|
14
|
+
'todoist/todoist-web',
|
|
15
|
+
'todoist/ui',
|
|
16
|
+
]);
|
|
17
|
+
export function isReviewModePilotRepository(owner, repo) {
|
|
18
|
+
return PILOT_REPOSITORIES.has(`${owner}/${repo}`.toLowerCase().replace(/^doist\//, 'todoist/'));
|
|
19
|
+
}
|
|
20
|
+
export function createGenericReviewModePolicy({ ref = null, warning, guidancePath = DEFAULT_REVIEW_MODE_GUIDANCE_PATH, } = {}) {
|
|
21
|
+
return {
|
|
22
|
+
source: 'generic',
|
|
23
|
+
guidance: null,
|
|
24
|
+
guidancePath,
|
|
25
|
+
ref,
|
|
26
|
+
labels: {
|
|
27
|
+
ask: [...DEFAULT_REVIEW_MODE_LABELS.ask],
|
|
28
|
+
show: [...DEFAULT_REVIEW_MODE_LABELS.show],
|
|
29
|
+
ship: [...DEFAULT_REVIEW_MODE_LABELS.ship],
|
|
30
|
+
},
|
|
31
|
+
...(warning ? { warning } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function isImmutableRef(ref) {
|
|
35
|
+
// SHA-1 on GitHub; local repositories can also use SHA-256.
|
|
36
|
+
return /^(?:[a-f\d]{40}|[a-f\d]{64})$/i.test(ref);
|
|
37
|
+
}
|
|
38
|
+
function fileFailureCause(error, file) {
|
|
39
|
+
if (error instanceof RepoFileContentError) {
|
|
40
|
+
switch (error.reason) {
|
|
41
|
+
case 'not_file':
|
|
42
|
+
return `the baseline ${file} path is not a regular file`;
|
|
43
|
+
case 'too_large':
|
|
44
|
+
return `the baseline ${file} file exceeds the 32 KB limit`;
|
|
45
|
+
case 'unavailable':
|
|
46
|
+
return `the baseline ${file} file content is unavailable`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return `the baseline ${file} file could not be read`;
|
|
50
|
+
}
|
|
51
|
+
async function loadReviewModePolicy({ ref, verifyBaseline, readFile, }) {
|
|
52
|
+
let fallback = createGenericReviewModePolicy({ ref });
|
|
53
|
+
function warn(cause) {
|
|
54
|
+
return {
|
|
55
|
+
...fallback,
|
|
56
|
+
warning: cause,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
if (!isImmutableRef(ref))
|
|
61
|
+
return warn('an immutable baseline commit is required');
|
|
62
|
+
await verifyBaseline();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return warn('the baseline commit could not be verified');
|
|
66
|
+
}
|
|
67
|
+
let configContent;
|
|
68
|
+
try {
|
|
69
|
+
configContent = await readFile(CONFIG_FILE_PATH);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return warn(fileFailureCause(error, 'configuration'));
|
|
73
|
+
}
|
|
74
|
+
let config = null;
|
|
75
|
+
try {
|
|
76
|
+
if (configContent !== null) {
|
|
77
|
+
config = parseRepoConfigContent(configContent, { validateReviewMode: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return warn('the baseline repository configuration is invalid');
|
|
82
|
+
}
|
|
83
|
+
const mode = config?.review?.mode;
|
|
84
|
+
const guidancePath = mode?.guidance_path ?? DEFAULT_REVIEW_MODE_GUIDANCE_PATH;
|
|
85
|
+
fallback = {
|
|
86
|
+
...fallback,
|
|
87
|
+
guidancePath,
|
|
88
|
+
labels: {
|
|
89
|
+
ask: [...(mode?.labels?.ask ?? fallback.labels.ask)],
|
|
90
|
+
show: [...(mode?.labels?.show ?? fallback.labels.show)],
|
|
91
|
+
ship: [...(mode?.labels?.ship ?? fallback.labels.ship)],
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
let guidance;
|
|
95
|
+
try {
|
|
96
|
+
guidance = await readFile(guidancePath);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
return warn(fileFailureCause(error, 'guidance'));
|
|
100
|
+
}
|
|
101
|
+
if (guidance === null) {
|
|
102
|
+
return mode?.guidance_path !== undefined
|
|
103
|
+
? warn('the configured guidance file is missing at the baseline')
|
|
104
|
+
: fallback;
|
|
105
|
+
}
|
|
106
|
+
if (!guidance.trim())
|
|
107
|
+
return warn('the baseline guidance file is empty');
|
|
108
|
+
// Readers also enforce this limit before fetching large file contents.
|
|
109
|
+
if (Buffer.byteLength(guidance, 'utf8') > MAX_POLICY_FILE_BYTES) {
|
|
110
|
+
return warn('the baseline guidance file exceeds the 32 KB limit');
|
|
111
|
+
}
|
|
112
|
+
return { ...fallback, source: 'repository', guidance };
|
|
113
|
+
}
|
|
114
|
+
export async function loadGitHubReviewModePolicy({ octokit, owner, repo, ref, signal, }) {
|
|
115
|
+
const request = signal ? { signal } : undefined;
|
|
116
|
+
let rootTreeSha;
|
|
117
|
+
const trees = new Map();
|
|
118
|
+
async function getTree(sha) {
|
|
119
|
+
const cached = trees.get(sha);
|
|
120
|
+
if (cached)
|
|
121
|
+
return cached;
|
|
122
|
+
const { data } = await octokit.rest.git.getTree({
|
|
123
|
+
owner,
|
|
124
|
+
repo,
|
|
125
|
+
tree_sha: sha,
|
|
126
|
+
...(request ? { request } : {}),
|
|
127
|
+
});
|
|
128
|
+
if (data.truncated)
|
|
129
|
+
throw new RepoFileContentError('unavailable');
|
|
130
|
+
trees.set(sha, data.tree);
|
|
131
|
+
return data.tree;
|
|
132
|
+
}
|
|
133
|
+
return loadReviewModePolicy({
|
|
134
|
+
ref,
|
|
135
|
+
verifyBaseline: async () => {
|
|
136
|
+
const { data } = await octokit.rest.git.getCommit({
|
|
137
|
+
owner,
|
|
138
|
+
repo,
|
|
139
|
+
commit_sha: ref,
|
|
140
|
+
...(request ? { request } : {}),
|
|
141
|
+
});
|
|
142
|
+
if (data.sha.toLowerCase() !== ref.toLowerCase() || !data.tree.sha) {
|
|
143
|
+
throw new Error('Invalid baseline commit');
|
|
144
|
+
}
|
|
145
|
+
rootTreeSha = data.tree.sha;
|
|
146
|
+
},
|
|
147
|
+
readFile: async (filePath) => {
|
|
148
|
+
let treeSha = rootTreeSha;
|
|
149
|
+
const parts = filePath.split('/');
|
|
150
|
+
for (const [index, part] of parts.entries()) {
|
|
151
|
+
const entry = (await getTree(treeSha)).find((entry) => entry.path === part);
|
|
152
|
+
if (!entry)
|
|
153
|
+
return null;
|
|
154
|
+
if (index < parts.length - 1) {
|
|
155
|
+
if (entry.type !== 'tree' || entry.mode !== '040000' || !entry.sha) {
|
|
156
|
+
throw new RepoFileContentError('not_file');
|
|
157
|
+
}
|
|
158
|
+
treeSha = entry.sha;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
// Contents API follows some symlinks and reports them as files.
|
|
162
|
+
// Verify the baseline tree mode before trusting getContent.
|
|
163
|
+
if (entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode ?? '')) {
|
|
164
|
+
throw new RepoFileContentError('not_file');
|
|
165
|
+
}
|
|
166
|
+
if (entry.size !== undefined && entry.size > MAX_POLICY_FILE_BYTES) {
|
|
167
|
+
throw new RepoFileContentError('too_large');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const content = await fetchFileContent({
|
|
172
|
+
octokit,
|
|
173
|
+
owner,
|
|
174
|
+
repo,
|
|
175
|
+
path: filePath,
|
|
176
|
+
ref,
|
|
177
|
+
signal,
|
|
178
|
+
strict: true,
|
|
179
|
+
maxBytes: MAX_POLICY_FILE_BYTES,
|
|
180
|
+
});
|
|
181
|
+
// The tree proves this path exists. A Contents 404 is a read failure,
|
|
182
|
+
// not conventional-policy absence (including auth disguised as 404).
|
|
183
|
+
if (content === null)
|
|
184
|
+
throw new RepoFileContentError('unavailable');
|
|
185
|
+
return content;
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
export async function loadLocalReviewModePolicy({ runner, ref, }) {
|
|
190
|
+
return loadReviewModePolicy({
|
|
191
|
+
ref,
|
|
192
|
+
verifyBaseline: () => {
|
|
193
|
+
if (runner(['cat-file', '-t', ref]).trim() !== 'commit') {
|
|
194
|
+
throw new Error('Invalid baseline commit');
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
readFile: (filePath) => {
|
|
198
|
+
const parts = filePath.split('/');
|
|
199
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
200
|
+
const prefix = parts.slice(0, index + 1).join('/');
|
|
201
|
+
const output = runner(['--literal-pathspecs', 'ls-tree', '-z', ref, '--', prefix]);
|
|
202
|
+
if (!output)
|
|
203
|
+
return null;
|
|
204
|
+
const entries = output.split('\0').filter(Boolean);
|
|
205
|
+
const entry = entries.find((entry) => entry.slice(entry.indexOf('\t') + 1) === prefix);
|
|
206
|
+
if (!entry)
|
|
207
|
+
throw new RepoFileContentError('unavailable');
|
|
208
|
+
const [mode, type] = entry.slice(0, entry.indexOf('\t')).split(' ');
|
|
209
|
+
if (index < parts.length - 1) {
|
|
210
|
+
if (mode !== '040000' || type !== 'tree') {
|
|
211
|
+
throw new RepoFileContentError('not_file');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else if (type !== 'blob' || !['100644', '100755'].includes(mode ?? '')) {
|
|
215
|
+
throw new RepoFileContentError('not_file');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const object = `${ref}:${filePath}`;
|
|
219
|
+
const size = Number(runner(['cat-file', '-s', object]).trim());
|
|
220
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
221
|
+
throw new RepoFileContentError('unavailable');
|
|
222
|
+
}
|
|
223
|
+
if (size > MAX_POLICY_FILE_BYTES)
|
|
224
|
+
throw new RepoFileContentError('too_large');
|
|
225
|
+
const content = runner(['show', object], { maxBufferBytes: MAX_POLICY_FILE_BYTES + 1 });
|
|
226
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_POLICY_FILE_BYTES) {
|
|
227
|
+
throw new RepoFileContentError('too_large');
|
|
228
|
+
}
|
|
229
|
+
return content;
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { SpanStatusCode } from '@opentelemetry/api';
|
|
2
|
+
import { logger } from '../../core/logging.js';
|
|
3
|
+
import { DEFAULT_OPENROUTER_MODELS, DEFAULT_PI_MODELS, invokePiPrompt, PI_PROVIDER_BY_DOISTBOT_PROVIDER, toPiInvocationTelemetryFields, } from '../../core/pi.js';
|
|
4
|
+
import { fillPromptTemplate, loadPromptTemplateFile, resolvePromptFile, } from '../../core/prompt-template.js';
|
|
5
|
+
import { genAiProviderName, recordDecision, withPhase } from '../../core/tracing.js';
|
|
6
|
+
import { resolveReviewProviderCredential } from '../../providers/credentials.js';
|
|
7
|
+
import { extractJsonCandidatesFromResponse } from '../../providers/helpers.js';
|
|
8
|
+
import { createGenericReviewModePolicy, isReviewModePilotRepository } from './review-mode-policy.js';
|
|
9
|
+
import { buildGeminiFlashReviewSummaryModel } from './summary-model.js';
|
|
10
|
+
export const REVIEW_MODE_TIMEOUT_MS = 90_000;
|
|
11
|
+
export const REVIEW_MODE_EXECUTION_LIMITS = {
|
|
12
|
+
softTimeMs: 45_000,
|
|
13
|
+
softToolCalls: 8,
|
|
14
|
+
hardTimeMs: 70_000,
|
|
15
|
+
hardToolCalls: 15,
|
|
16
|
+
};
|
|
17
|
+
const MODE_NAMES = { ask: 'Ask', show: 'Show', ship: 'Ship' };
|
|
18
|
+
const CONTEXT_LIMIT = 64_000;
|
|
19
|
+
const RATIONALE_LIMIT = 500;
|
|
20
|
+
const PUBLIC_FAILURE_REASONS = new Set([
|
|
21
|
+
'classification timed out',
|
|
22
|
+
'invalid classifier response',
|
|
23
|
+
'no classifier model available',
|
|
24
|
+
'model request failed',
|
|
25
|
+
]);
|
|
26
|
+
function boundedContext(text) {
|
|
27
|
+
return text.length <= CONTEXT_LIMIT
|
|
28
|
+
? text
|
|
29
|
+
: `${text.slice(0, CONTEXT_LIMIT)}\n[Context truncated; omitted information is unknown.]`;
|
|
30
|
+
}
|
|
31
|
+
export function buildReviewModePrompt(input, policy, commits) {
|
|
32
|
+
// JSON encodes task data so prose cannot close delimiters or introduce new policy blocks.
|
|
33
|
+
return JSON.stringify({
|
|
34
|
+
repository: `${input.owner}/${input.repo}`,
|
|
35
|
+
baselinePolicy: {
|
|
36
|
+
source: policy.source,
|
|
37
|
+
ref: policy.ref,
|
|
38
|
+
path: policy.guidancePath,
|
|
39
|
+
guidance: policy.guidance,
|
|
40
|
+
},
|
|
41
|
+
title: boundedContext(input.prTitle),
|
|
42
|
+
description: boundedContext(input.prBody),
|
|
43
|
+
conversation: boundedContext(input.conversation ?? ''),
|
|
44
|
+
commits: boundedContext(commits),
|
|
45
|
+
changedFiles: boundedContext((input.context.changeScope?.files ?? input.files)
|
|
46
|
+
.map((file) => `${file.filename}${file.patch ? '' : ' [patch unavailable]'}${file.changeDetails ? `\n${file.changeDetails}` : ''}`)
|
|
47
|
+
.join('\n')),
|
|
48
|
+
diff: input.diff,
|
|
49
|
+
diffTruncated: input.diffTruncated || input.context.changeScope?.truncated === true,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export function selectReviewModeModel({ apiKeys, reviewModelsEnabled, reviewSummaryModel, }) {
|
|
53
|
+
if (reviewSummaryModel && resolveReviewProviderCredential(reviewSummaryModel.credential)) {
|
|
54
|
+
return reviewSummaryModel;
|
|
55
|
+
}
|
|
56
|
+
const gemini = buildGeminiFlashReviewSummaryModel(apiKeys.gemini);
|
|
57
|
+
if (gemini)
|
|
58
|
+
return gemini;
|
|
59
|
+
for (const provider of ['zai', 'openai', 'deepseek', 'grok']) {
|
|
60
|
+
const credential = apiKeys[provider];
|
|
61
|
+
if (reviewModelsEnabled.includes(provider) && resolveReviewProviderCredential(credential)) {
|
|
62
|
+
return { provider, credential: credential };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
export function parseReviewModeResponse(output) {
|
|
68
|
+
for (const candidate of extractJsonCandidatesFromResponse(output)) {
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(candidate);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
77
|
+
continue;
|
|
78
|
+
const { mode, rationale } = parsed;
|
|
79
|
+
const normalizedRationale = typeof rationale === 'string' ? rationale.replace(/\s+/g, ' ').trim() : '';
|
|
80
|
+
if ((mode !== 'ask' && mode !== 'show' && mode !== 'ship') ||
|
|
81
|
+
typeof rationale !== 'string' ||
|
|
82
|
+
!normalizedRationale ||
|
|
83
|
+
rationale.length > RATIONALE_LIMIT ||
|
|
84
|
+
/\b(?:safe|ready) to merge\b|\b(?:bug[- ]free|no bugs)\b|\b(?:I|Doistbot) approve(?:s|d)?\b/i.test(normalizedRationale)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
return { mode, rationale: normalizedRationale };
|
|
88
|
+
}
|
|
89
|
+
throw new Error('invalid classifier response');
|
|
90
|
+
}
|
|
91
|
+
function plainText(text) {
|
|
92
|
+
return text
|
|
93
|
+
.replace(/\s+/g, ' ')
|
|
94
|
+
.replace(/[\\`*_[\]~]/g, '\\$&')
|
|
95
|
+
.replace(/</g, '<')
|
|
96
|
+
.replace(/>/g, '>');
|
|
97
|
+
}
|
|
98
|
+
export function renderReviewModeNote(result, labelNames, chosenLabels = []) {
|
|
99
|
+
const notes = [];
|
|
100
|
+
if (result.status === 'unavailable') {
|
|
101
|
+
notes.push(`**Review mode unavailable** — ${plainText(result.reason)}.`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const normalized = new Set(chosenLabels.map((label) => label.trim().toLowerCase()));
|
|
105
|
+
const chosenModes = ['ask', 'show', 'ship'].filter((mode) => labelNames[mode].some((name) => normalized.has(name.toLowerCase())));
|
|
106
|
+
const mismatch = chosenModes.some((mode) => mode !== result.mode);
|
|
107
|
+
if (result.mode !== 'ask' || mismatch) {
|
|
108
|
+
const comparison = chosenModes.length > 1
|
|
109
|
+
? ` (conflicting labels: ${chosenModes.map((mode) => MODE_NAMES[mode]).join(', ')})`
|
|
110
|
+
: mismatch
|
|
111
|
+
? ` (instead of ${MODE_NAMES[chosenModes[0]]})`
|
|
112
|
+
: '';
|
|
113
|
+
notes.push([
|
|
114
|
+
'<details>',
|
|
115
|
+
`<summary>Review mode: <strong>${MODE_NAMES[result.mode]}</strong>${comparison}</summary>`,
|
|
116
|
+
'',
|
|
117
|
+
plainText(result.rationale),
|
|
118
|
+
'',
|
|
119
|
+
`<!-- guidance: ${result.policy.source} -->`,
|
|
120
|
+
'',
|
|
121
|
+
'</details>',
|
|
122
|
+
].join('\n'));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (result.policy.warning) {
|
|
126
|
+
notes.push(`*Review mode uses generic guidance: ${plainText(result.policy.warning)}.*`);
|
|
127
|
+
}
|
|
128
|
+
return notes.join('\n\n');
|
|
129
|
+
}
|
|
130
|
+
async function requestReviewMode(input, policy, commits, timeoutMs) {
|
|
131
|
+
const model = selectReviewModeModel(input);
|
|
132
|
+
if (!model)
|
|
133
|
+
throw new Error('no classifier model available');
|
|
134
|
+
const systemPrompt = fillPromptTemplate(loadPromptTemplateFile({
|
|
135
|
+
fileName: resolvePromptFile(import.meta.url, 'review-mode.md'),
|
|
136
|
+
promptLabel: 'review mode',
|
|
137
|
+
}), { RATIONALE_LIMIT: String(RATIONALE_LIMIT) }).trimEnd();
|
|
138
|
+
const credential = resolveReviewProviderCredential(model.credential);
|
|
139
|
+
const piProvider = credential.piProviderId ?? PI_PROVIDER_BY_DOISTBOT_PROVIDER[model.provider];
|
|
140
|
+
const modelName = model.modelName ??
|
|
141
|
+
credential.modelName ??
|
|
142
|
+
(piProvider === 'openrouter' ? DEFAULT_OPENROUTER_MODELS[model.provider] : undefined) ??
|
|
143
|
+
DEFAULT_PI_MODELS[model.provider];
|
|
144
|
+
return withPhase('review.mode.model', {
|
|
145
|
+
'gen_ai.operation.name': 'chat',
|
|
146
|
+
'gen_ai.provider.name': genAiProviderName(model.provider),
|
|
147
|
+
'gen_ai.request.model': modelName,
|
|
148
|
+
}, async (span) => {
|
|
149
|
+
const result = await invokePiPrompt({
|
|
150
|
+
provider: model.provider,
|
|
151
|
+
...credential,
|
|
152
|
+
modelName,
|
|
153
|
+
prompt: buildReviewModePrompt(input, policy, commits),
|
|
154
|
+
systemPrompt,
|
|
155
|
+
noContextFiles: true,
|
|
156
|
+
capabilityPreset: 'read-only',
|
|
157
|
+
thinkingLevel: model.provider === 'zai' ? 'high' : 'medium',
|
|
158
|
+
cwd: input.workspaceDir,
|
|
159
|
+
timeoutMs,
|
|
160
|
+
executionLimits: {
|
|
161
|
+
...REVIEW_MODE_EXECUTION_LIMITS,
|
|
162
|
+
softTimeMs: Math.min(REVIEW_MODE_EXECUTION_LIMITS.softTimeMs, timeoutMs),
|
|
163
|
+
hardTimeMs: Math.min(REVIEW_MODE_EXECUTION_LIMITS.hardTimeMs, timeoutMs),
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
if (result.telemetry) {
|
|
167
|
+
span.setAttributes(Object.fromEntries(Object.entries(toPiInvocationTelemetryFields(result.telemetry)).map(([key, value]) => [`review.mode.${key}`, value])));
|
|
168
|
+
}
|
|
169
|
+
if (!result.ok) {
|
|
170
|
+
span.setStatus({
|
|
171
|
+
code: SpanStatusCode.ERROR,
|
|
172
|
+
message: result.timedOut ? 'timeout' : 'request_failed',
|
|
173
|
+
});
|
|
174
|
+
throw new Error(result.timedOut ? 'classification timed out' : 'model request failed');
|
|
175
|
+
}
|
|
176
|
+
const parsed = parseReviewModeResponse(result.output);
|
|
177
|
+
return parsed;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
export async function runReviewModePass(input) {
|
|
181
|
+
return withPhase('review.mode', { 'review.repository': `${input.owner}/${input.repo}` }, async (span) => {
|
|
182
|
+
const startedAt = Date.now();
|
|
183
|
+
const deadline = startedAt + REVIEW_MODE_TIMEOUT_MS;
|
|
184
|
+
let policy = createGenericReviewModePolicy();
|
|
185
|
+
let policyLoaded = false;
|
|
186
|
+
let labels = input.context.labels ?? [];
|
|
187
|
+
let completedClassification;
|
|
188
|
+
const controller = new AbortController();
|
|
189
|
+
let timer;
|
|
190
|
+
function policyInfo() {
|
|
191
|
+
return {
|
|
192
|
+
source: policy.source,
|
|
193
|
+
guidancePath: policy.guidancePath,
|
|
194
|
+
ref: policy.ref,
|
|
195
|
+
...(policy.warning ? { warning: policy.warning } : {}),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function unavailable(reason) {
|
|
199
|
+
return { status: 'unavailable', reason, policy: policyInfo() };
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
async function work() {
|
|
203
|
+
try {
|
|
204
|
+
policy = await input.context.loadPolicy(controller.signal, deadline);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
policy = createGenericReviewModePolicy({
|
|
208
|
+
warning: 'repository policy could not be loaded',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
policyLoaded = true;
|
|
212
|
+
if (controller.signal.aborted || Date.now() >= deadline)
|
|
213
|
+
throw new Error('classification timed out');
|
|
214
|
+
let commits = input.context.commits ?? '';
|
|
215
|
+
if (input.context.loadCommitContext) {
|
|
216
|
+
try {
|
|
217
|
+
commits = await input.context.loadCommitContext(controller.signal, deadline);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
commits = '[Commit context unavailable.]';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (controller.signal.aborted || Date.now() >= deadline)
|
|
224
|
+
throw new Error('classification timed out');
|
|
225
|
+
const parsed = await requestReviewMode(input, policy, commits, Math.max(1, deadline - Date.now()));
|
|
226
|
+
if (controller.signal.aborted || Date.now() >= deadline)
|
|
227
|
+
throw new Error('classification timed out');
|
|
228
|
+
const result = {
|
|
229
|
+
status: 'classified',
|
|
230
|
+
...parsed,
|
|
231
|
+
policy: policyInfo(),
|
|
232
|
+
};
|
|
233
|
+
completedClassification = result;
|
|
234
|
+
if (input.context.loadLabels) {
|
|
235
|
+
try {
|
|
236
|
+
labels = await input.context.loadLabels(controller.signal);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Preserve the known label snapshot; label IO never changes the classification.
|
|
240
|
+
logger.warn('Could not refresh review mode labels');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
const timeout = new Promise((_, reject) => {
|
|
246
|
+
timer = setTimeout(() => {
|
|
247
|
+
controller.abort();
|
|
248
|
+
reject(new Error('classification timed out'));
|
|
249
|
+
}, REVIEW_MODE_TIMEOUT_MS);
|
|
250
|
+
});
|
|
251
|
+
let classification;
|
|
252
|
+
try {
|
|
253
|
+
classification = await Promise.race([work(), timeout]);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
const reason = error instanceof Error && PUBLIC_FAILURE_REASONS.has(error.message)
|
|
257
|
+
? error.message
|
|
258
|
+
: 'classification failed';
|
|
259
|
+
if (!policyLoaded) {
|
|
260
|
+
policy = createGenericReviewModePolicy({
|
|
261
|
+
warning: 'repository policy loading timed out',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (completedClassification) {
|
|
265
|
+
// Presentation-only label IO cannot invalidate an already completed judgment.
|
|
266
|
+
classification = completedClassification;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
classification = unavailable(reason);
|
|
270
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: reason });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const attributes = {
|
|
274
|
+
'review.repository': `${input.owner}/${input.repo}`,
|
|
275
|
+
'review.mode.status': classification.status,
|
|
276
|
+
...(classification.status === 'classified'
|
|
277
|
+
? { 'review.mode.recommended': classification.mode }
|
|
278
|
+
: {}),
|
|
279
|
+
'review.mode.policy_source': classification.policy.source,
|
|
280
|
+
'review.mode.policy_fallback': classification.policy.warning != null,
|
|
281
|
+
'review.mode.duration_ms': Date.now() - startedAt,
|
|
282
|
+
};
|
|
283
|
+
span.setAttributes(attributes);
|
|
284
|
+
recordDecision('review.mode.result', attributes);
|
|
285
|
+
return {
|
|
286
|
+
classification,
|
|
287
|
+
note: renderReviewModeNote(classification, policy.labels, labels),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
if (timer)
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
export { isReviewModePilotRepository };
|