@doist/doistbot-cli 1.0.11 → 1.0.12
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 +10 -9
- package/sandbox/dist/providers/helpers.js +3 -0
- package/sandbox/dist/providers/schemas.js +7 -0
- package/sandbox/dist/providers/types.js +4 -1
- package/sandbox/dist/tasks/review/diff.js +1 -1
- package/sandbox/dist/tasks/review/engines/multi-focus.js +24 -3
- package/sandbox/dist/tasks/review/local-git.js +62 -8
- package/sandbox/dist/tasks/review/prompt.js +2 -0
- package/sandbox/dist/tasks/review/translation-review-policy.js +37 -0
- package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +18 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doist/doistbot-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"description": "Local self-review CLI for Doistbot",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -40,20 +40,21 @@
|
|
|
40
40
|
"test": "vitest run src scripts --config ../../config/vitest.base.ts"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@
|
|
44
|
-
"@
|
|
45
|
-
"@
|
|
46
|
-
"@
|
|
43
|
+
"@earendil-works/pi-ai": "0.85.1",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
45
|
+
"@inquirer/checkbox": "5.2.4",
|
|
46
|
+
"@inquirer/password": "5.2.1",
|
|
47
47
|
"@napi-rs/keyring": "1.3.0",
|
|
48
|
+
"@opentelemetry/api": "1.9.1",
|
|
48
49
|
"ajv": "8.20.0",
|
|
49
50
|
"chalk": "4.1.2",
|
|
50
51
|
"commander": "14.0.3",
|
|
51
|
-
"js-yaml": "4.3.
|
|
52
|
+
"js-yaml": "4.3.2",
|
|
52
53
|
"octokit": "5.0.5",
|
|
53
|
-
"undici": "8.10.
|
|
54
|
-
"yocto-spinner": "1.2.
|
|
54
|
+
"undici": "8.10.2",
|
|
55
|
+
"yocto-spinner": "1.2.2"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
57
|
-
"vitest": "4.1.
|
|
58
|
+
"vitest": "4.1.11"
|
|
58
59
|
}
|
|
59
60
|
}
|
|
@@ -141,6 +141,9 @@ export function parseRawFindings(comments, modelName) {
|
|
|
141
141
|
side,
|
|
142
142
|
body: comment.body.trim(),
|
|
143
143
|
sourceModel: modelName,
|
|
144
|
+
...(comment.translationResolution
|
|
145
|
+
? { translationResolution: comment.translationResolution }
|
|
146
|
+
: {}),
|
|
144
147
|
};
|
|
145
148
|
});
|
|
146
149
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Ajv } from 'ajv';
|
|
2
|
+
import { TRANSLATION_RESOLUTIONS } from './types.js';
|
|
2
3
|
export const REVIEW_SCHEMA = {
|
|
3
4
|
type: 'object',
|
|
4
5
|
additionalProperties: false,
|
|
@@ -14,7 +15,13 @@ export const REVIEW_SCHEMA = {
|
|
|
14
15
|
line: { type: 'integer', minimum: 1 },
|
|
15
16
|
side: { type: 'string', enum: ['LEFT', 'RIGHT'] },
|
|
16
17
|
body: { type: 'string', minLength: 1 },
|
|
18
|
+
translationResolution: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
enum: TRANSLATION_RESOLUTIONS,
|
|
21
|
+
},
|
|
17
22
|
},
|
|
23
|
+
// Classification stays optional so omission keeps the finding instead of failing
|
|
24
|
+
// the entire review pass.
|
|
18
25
|
required: ['path', 'line', 'side', 'body'],
|
|
19
26
|
},
|
|
20
27
|
},
|
|
@@ -8,6 +8,7 @@ import { buildMultiFocusPrompt, REVIEW_FOCUS_DEFINITIONS, } from '../multi-focus
|
|
|
8
8
|
import { buildFallbackSummary, summarizeReviewFindings } from '../review-summary.js';
|
|
9
9
|
import { parseSeverity } from '../severity.js';
|
|
10
10
|
import { buildGeminiFlashReviewSummaryModel } from '../summary-model.js';
|
|
11
|
+
import { filterTranslationSyncOnlyFindings } from '../translation-review-policy.js';
|
|
11
12
|
import { dedupeMultiFocusFindings } from './dedupe.js';
|
|
12
13
|
import { buildGeminiFlashCredential, getApiKeys, getAvailableModels, getEnabledReviewModels, getOpenRouterReviewCredential, getProviderFailures, requestModelReview, } from './shared.js';
|
|
13
14
|
const DEFAULT_REVIEW_MODELS_BY_FOCUS = {
|
|
@@ -340,8 +341,26 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
|
|
|
340
341
|
providerFailures,
|
|
341
342
|
};
|
|
342
343
|
}
|
|
343
|
-
const
|
|
344
|
-
const
|
|
344
|
+
const modelFindings = successfulRuns.flatMap((run) => run.findings);
|
|
345
|
+
const { findings: allFindings, droppedFindings: translationSyncFindings } = filterTranslationSyncOnlyFindings({
|
|
346
|
+
repoOwner: ctx.owner,
|
|
347
|
+
repoName: ctx.repo,
|
|
348
|
+
findings: modelFindings,
|
|
349
|
+
});
|
|
350
|
+
if (translationSyncFindings.length > 0) {
|
|
351
|
+
logger.info('Filtered translation sync-only findings', {
|
|
352
|
+
repository: `${ctx.owner}/${ctx.repo}`,
|
|
353
|
+
droppedCount: translationSyncFindings.length,
|
|
354
|
+
droppedFindingIds: translationSyncFindings.map((finding) => finding.id),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const suppressedFindings = new Set(translationSyncFindings);
|
|
358
|
+
const reviewObservationRuns = successfulRuns.map((run) => {
|
|
359
|
+
const hasSuppressedFinding = run.findings.some((finding) => suppressedFindings.has(finding));
|
|
360
|
+
const hasKeptFinding = run.findings.some((finding) => !suppressedFindings.has(finding));
|
|
361
|
+
return hasSuppressedFinding && !hasKeptFinding ? { ...run, summary: '' } : run;
|
|
362
|
+
});
|
|
363
|
+
const reviewObservations = collectReviewObservations(reviewObservationRuns);
|
|
345
364
|
let dedupedFindings = allFindings;
|
|
346
365
|
try {
|
|
347
366
|
dedupedFindings = await dedupeMultiFocusFindings({
|
|
@@ -380,7 +399,9 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
|
|
|
380
399
|
supportModels: allAvailableModels,
|
|
381
400
|
focuses: focusDefinitions.map((focus) => focus.name),
|
|
382
401
|
successfulRuns: successfulRuns.length,
|
|
383
|
-
findingsCount:
|
|
402
|
+
findingsCount: modelFindings.length,
|
|
403
|
+
findingsAfterPolicyCount: allFindings.length,
|
|
404
|
+
translationSyncFindingsCount: translationSyncFindings.length,
|
|
384
405
|
dedupedFindingsCount: dedupedFindings.length,
|
|
385
406
|
inlineFindingsCount: routedFindings.inlineFindings.length,
|
|
386
407
|
summaryOnlyFindingsCount: routedFindings.summaryFindings.length,
|
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
import { execFileSync } from 'child_process';
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import {} from './diff.js';
|
|
4
|
+
import { MAX_DIFF_CHARS } from './diff.js';
|
|
5
5
|
const GIT_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
6
6
|
export function createGitRunner(workspaceDir) {
|
|
7
|
-
return (args) =>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
return (args, options) => {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync('git', ['-C', workspaceDir, '-c', 'core.quotePath=false', ...args], {
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
maxBuffer: options?.maxBufferBytes ?? GIT_MAX_BUFFER_BYTES,
|
|
12
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
const failure = error;
|
|
17
|
+
const stdout = typeof failure.stdout === 'string'
|
|
18
|
+
? failure.stdout
|
|
19
|
+
: Buffer.isBuffer(failure.stdout)
|
|
20
|
+
? failure.stdout.toString('utf8')
|
|
21
|
+
: '';
|
|
22
|
+
if (failure.code === 'ENOBUFS' &&
|
|
23
|
+
options?.returnPartialOutputOnMaxBuffer === true &&
|
|
24
|
+
options.maxBufferBytes !== undefined) {
|
|
25
|
+
return stdout.slice(0, options.maxBufferBytes);
|
|
26
|
+
}
|
|
27
|
+
if (typeof failure.status === 'number' &&
|
|
28
|
+
options?.allowedExitCodes?.includes(failure.status)) {
|
|
29
|
+
return stdout;
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
12
34
|
}
|
|
13
35
|
export function tryGit(runner, args) {
|
|
14
36
|
try {
|
|
@@ -75,7 +97,7 @@ function unquoteGitPath(rawPath) {
|
|
|
75
97
|
unquoted = trimmed.slice(1, -1);
|
|
76
98
|
}
|
|
77
99
|
}
|
|
78
|
-
return unquoted.replace(/^[ab]\//, '');
|
|
100
|
+
return unquoted.replace(/^[ab]\//, '').replace(/^\.\//, '');
|
|
79
101
|
}
|
|
80
102
|
function readDiffGitPath(input, startIndex) {
|
|
81
103
|
let index = startIndex;
|
|
@@ -162,6 +184,38 @@ export function parseGitDiffToFiles(diff) {
|
|
|
162
184
|
flushCurrent();
|
|
163
185
|
return files;
|
|
164
186
|
}
|
|
187
|
+
function getUntrackedReviewFiles(runner) {
|
|
188
|
+
const untrackedPaths = runner(['ls-files', '--others', '--exclude-standard', '-z'])
|
|
189
|
+
.split('\0')
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
if (untrackedPaths.length === 0) {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
const files = [];
|
|
195
|
+
let remainingDiffChars = MAX_DIFF_CHARS;
|
|
196
|
+
for (const filename of untrackedPaths) {
|
|
197
|
+
if (remainingDiffChars <= 0) {
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
// The two-file form also works with Git versions before 2.51.
|
|
201
|
+
const diff = runner([
|
|
202
|
+
'diff',
|
|
203
|
+
'--no-index',
|
|
204
|
+
'--no-ext-diff',
|
|
205
|
+
'--no-color',
|
|
206
|
+
'--',
|
|
207
|
+
'/dev/null',
|
|
208
|
+
`./${filename}`,
|
|
209
|
+
], {
|
|
210
|
+
allowedExitCodes: [1],
|
|
211
|
+
maxBufferBytes: remainingDiffChars,
|
|
212
|
+
returnPartialOutputOnMaxBuffer: true,
|
|
213
|
+
});
|
|
214
|
+
files.push(...parseGitDiffToFiles(diff));
|
|
215
|
+
remainingDiffChars -= diff.length;
|
|
216
|
+
}
|
|
217
|
+
return files;
|
|
218
|
+
}
|
|
165
219
|
export function getLocalReviewFiles({ runner, source, }) {
|
|
166
220
|
if (source === 'staged') {
|
|
167
221
|
const diff = runner(['diff', '--staged', '--no-ext-diff', '--no-color']);
|
|
@@ -183,7 +237,7 @@ export function getLocalReviewFiles({ runner, source, }) {
|
|
|
183
237
|
const mergeBase = runner(['merge-base', baseBranch, 'HEAD']).trim();
|
|
184
238
|
const diff = runner(['diff', '--no-ext-diff', '--no-color', mergeBase]);
|
|
185
239
|
return {
|
|
186
|
-
files: parseGitDiffToFiles(diff),
|
|
240
|
+
files: [...parseGitDiffToFiles(diff), ...getUntrackedReviewFiles(runner)],
|
|
187
241
|
baseBranch,
|
|
188
242
|
};
|
|
189
243
|
}
|
|
@@ -2,6 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { logger } from '../../core/logging.js';
|
|
5
|
+
import { buildTranslationReviewGuidance } from './translation-review-policy.js';
|
|
5
6
|
const MAX_BODY_CHARS = 4000;
|
|
6
7
|
let cachedStandardsSection = null;
|
|
7
8
|
const REVIEW_TASK_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -75,5 +76,6 @@ export function buildReviewPromptVariables({ repoOwner, repoName, prNumber, prTi
|
|
|
75
76
|
PR_CONVERSATION: formatPrConversation(prConversation),
|
|
76
77
|
DIFF_SECTION: fullDiffSection,
|
|
77
78
|
STANDARDS_SECTION: standardsSection,
|
|
79
|
+
TRANSLATION_REVIEW_GUIDANCE: buildTranslationReviewGuidance(repoOwner, repoName),
|
|
78
80
|
};
|
|
79
81
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const ENGLISH_SOURCE_FIRST_REPOSITORIES = new Set([
|
|
2
|
+
'doist/todoist-web',
|
|
3
|
+
'doist/todoist-android',
|
|
4
|
+
'doist/todoist-ios',
|
|
5
|
+
'doist/todoist-electron',
|
|
6
|
+
'doist/todoist-macos',
|
|
7
|
+
]);
|
|
8
|
+
export function usesEnglishSourceFirstTranslations(repoOwner, repoName) {
|
|
9
|
+
return ENGLISH_SOURCE_FIRST_REPOSITORIES.has(`${repoOwner}/${repoName}`.toLowerCase());
|
|
10
|
+
}
|
|
11
|
+
export function buildTranslationReviewGuidance(repoOwner, repoName) {
|
|
12
|
+
if (!usesEnglishSourceFirstTranslations(repoOwner, repoName)) {
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
return `## Repository-specific Translation Workflow
|
|
16
|
+
|
|
17
|
+
This repository uses an English-source-first workflow: feature PRs change the English source strings, automation sends them to the repository's translation service, and translated locale files arrive through a later automated sync.
|
|
18
|
+
|
|
19
|
+
- Do not create a finding or summary note merely because translated locale files or generated locale bundles are unchanged in the feature PR.
|
|
20
|
+
- Do not ask the author to pull, update, generate, or commit translations manually. If the PR description already mentions the translation-service follow-up, including Transifex, do not repeat it.
|
|
21
|
+
- Continue to report concrete i18n implementation errors introduced by the diff, such as incorrect translation API or gettext usage, broken interpolation placeholders, pluralization or context mistakes, and patterns that prevent source-string extraction.`;
|
|
22
|
+
}
|
|
23
|
+
export function isTranslationSyncOnlyFinding(repoOwner, repoName, finding) {
|
|
24
|
+
return (usesEnglishSourceFirstTranslations(repoOwner, repoName) &&
|
|
25
|
+
finding.translationResolution === 'automated_sync_resolves');
|
|
26
|
+
}
|
|
27
|
+
export function filterTranslationSyncOnlyFindings({ repoOwner, repoName, findings, }) {
|
|
28
|
+
const keptFindings = [];
|
|
29
|
+
const droppedFindings = [];
|
|
30
|
+
for (const finding of findings) {
|
|
31
|
+
const destination = isTranslationSyncOnlyFinding(repoOwner, repoName, finding)
|
|
32
|
+
? droppedFindings
|
|
33
|
+
: keptFindings;
|
|
34
|
+
destination.push(finding);
|
|
35
|
+
}
|
|
36
|
+
return { findings: keptFindings, droppedFindings };
|
|
37
|
+
}
|
|
@@ -73,6 +73,8 @@ $DIFF_SECTION
|
|
|
73
73
|
- Keep code ranges tight and comments easy to understand without close reading.
|
|
74
74
|
- **Do not flag dependency or tooling versions as nonexistent, outdated, or "too new."** Your training data has a cutoff, so you can't know which versions of GitHub Actions, npm/PyPI/etc. packages, or their APIs are currently published. Never tell the author a version doesn't exist, is wrong, or should be downgraded based on your own knowledge — and never claim a library feature or API is unavailable because you assume the project uses an older major version. Raise a version-related finding only when the diff or checked-out repo contains concrete contradicting evidence (e.g., the manifest/lockfile pins a different version, or the referenced symbol is absent from the dependency's actual source on disk).
|
|
75
75
|
|
|
76
|
+
$TRANSLATION_REVIEW_GUIDANCE
|
|
77
|
+
|
|
76
78
|
## Writing Style
|
|
77
79
|
|
|
78
80
|
- Write clearly, concisely, and directly. Be engaging without sounding generic.
|
|
@@ -97,6 +99,20 @@ At the beginning of each finding title, tag it with a priority level:
|
|
|
97
99
|
|
|
98
100
|
## Output Format
|
|
99
101
|
|
|
102
|
+
For every finding, answer this internal classification question: assuming the repository's normal
|
|
103
|
+
translation automation completes successfully, would the issue be fully resolved without changing
|
|
104
|
+
application code, source-string keys, extraction configuration, lookup behavior, placeholders,
|
|
105
|
+
pluralization, or locale context?
|
|
106
|
+
|
|
107
|
+
- Use `automated_sync_resolves` only when the answer is clearly yes.
|
|
108
|
+
- Use `code_change_required_or_uncertain` for all non-translation findings, concrete i18n
|
|
109
|
+
correctness problems, and any uncertain case.
|
|
110
|
+
- Keep this classification out of the finding body.
|
|
111
|
+
- Missing or stale translated content, requests to pull or commit locale artifacts, and advice to
|
|
112
|
+
wait for the automated sync are `automated_sync_resolves` when no code-level problem remains.
|
|
113
|
+
- Broken extraction, keys, lookups, placeholders, pluralization, or locale handling are
|
|
114
|
+
`code_change_required_or_uncertain`, even when their symptom is a missing translation.
|
|
115
|
+
|
|
100
116
|
Return strict JSON in this exact shape:
|
|
101
117
|
|
|
102
118
|
```json
|
|
@@ -107,7 +123,8 @@ Return strict JSON in this exact shape:
|
|
|
107
123
|
"path": "relative file path exactly as shown in the diff above",
|
|
108
124
|
"line": 42,
|
|
109
125
|
"side": "RIGHT",
|
|
110
|
-
"body": "[P2] Brief description of the issue and actionable fix."
|
|
126
|
+
"body": "[P2] Brief description of the issue and actionable fix.",
|
|
127
|
+
"translationResolution": "code_change_required_or_uncertain"
|
|
111
128
|
}
|
|
112
129
|
]
|
|
113
130
|
}
|