@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,255 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { logger } from '../../core/logging.js';
|
|
4
|
+
import { escapeRegExp } from '../../core/regexp.js';
|
|
5
|
+
import { runMultiFocusReviewEngine } from './engines/multi-focus.js';
|
|
6
|
+
import { getEnabledReviewModels } from './engines/shared.js';
|
|
7
|
+
import {} from './engines/types.js';
|
|
8
|
+
import { buildAnnotatedDiff, buildDiffSection } from './diff.js';
|
|
9
|
+
import { buildMultiFocusSharedPrompt } from './multi-focus-prompt.js';
|
|
10
|
+
import { loadStandardsSection } from './prompt.js';
|
|
11
|
+
import { isReviewModePilotRepository, runReviewModePass } from './review-mode.js';
|
|
12
|
+
const REVIEW_GUIDANCE_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
|
|
13
|
+
const MASKED_PR_AUTHOR_LABEL = 'PR author';
|
|
14
|
+
const OPENROUTER_SESSION_REPO_SEGMENT_MAX_LENGTH = 96;
|
|
15
|
+
function getGuidanceFilePresence(workspaceDir) {
|
|
16
|
+
return Object.fromEntries(REVIEW_GUIDANCE_FILES.map((fileName) => [
|
|
17
|
+
fileName,
|
|
18
|
+
fs.existsSync(`${workspaceDir}/${fileName}`),
|
|
19
|
+
]));
|
|
20
|
+
}
|
|
21
|
+
function maskPrAuthorInConversation(prConversation, prAuthor) {
|
|
22
|
+
const normalizedPrAuthor = prAuthor.trim();
|
|
23
|
+
if (!prConversation || !normalizedPrAuthor || normalizedPrAuthor === 'unknown') {
|
|
24
|
+
return prConversation;
|
|
25
|
+
}
|
|
26
|
+
const authorLinePattern = new RegExp(`(^- Author:\\s*)${escapeRegExp(normalizedPrAuthor)}(?=\\s*$)`, 'gim');
|
|
27
|
+
return {
|
|
28
|
+
...prConversation,
|
|
29
|
+
promptText: prConversation.promptText.replace(authorLinePattern, `$1${MASKED_PR_AUTHOR_LABEL}`),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function buildEnginePrompt({ input, diffSection, truncated, standardsSection, prConversation, }) {
|
|
33
|
+
const sharedPromptArgs = {
|
|
34
|
+
repoOwner: input.owner,
|
|
35
|
+
repoName: input.repo,
|
|
36
|
+
prNumber: input.prNumber,
|
|
37
|
+
prTitle: input.prTitle,
|
|
38
|
+
prBody: input.prBody,
|
|
39
|
+
prConversation,
|
|
40
|
+
prAuthor: 'unknown',
|
|
41
|
+
baseRef: input.baseBranch,
|
|
42
|
+
headRef: input.branch,
|
|
43
|
+
diffSection,
|
|
44
|
+
truncated,
|
|
45
|
+
standardsSection,
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
prompt: buildMultiFocusSharedPrompt(sharedPromptArgs),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function getMissingRequiredReviewModels(availableModels, requiredReviewModels = []) {
|
|
52
|
+
return requiredReviewModels.filter((model) => !availableModels.includes(model));
|
|
53
|
+
}
|
|
54
|
+
function toOpenRouterSessionSegment(value, fallback) {
|
|
55
|
+
const normalized = value
|
|
56
|
+
.trim()
|
|
57
|
+
.toLowerCase()
|
|
58
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
59
|
+
.replace(/^-+|-+$/g, '')
|
|
60
|
+
.slice(0, OPENROUTER_SESSION_REPO_SEGMENT_MAX_LENGTH);
|
|
61
|
+
return normalized || fallback;
|
|
62
|
+
}
|
|
63
|
+
export function buildOpenRouterReviewSessionId({ owner, repo, prNumber, prompt, }) {
|
|
64
|
+
const repoSegment = toOpenRouterSessionSegment(`${owner}-${repo}`, 'repo');
|
|
65
|
+
const promptHash = createHash('sha256').update(prompt).digest('hex').slice(0, 32);
|
|
66
|
+
return `doistbot-review-${repoSegment}-pr-${prNumber}-${promptHash}`;
|
|
67
|
+
}
|
|
68
|
+
export async function runSharedReview(input) {
|
|
69
|
+
const { annotatedFiles, lineLookup } = buildAnnotatedDiff(input.files);
|
|
70
|
+
if (annotatedFiles.length === 0) {
|
|
71
|
+
logger.info('No diff generated, skipping review');
|
|
72
|
+
return {
|
|
73
|
+
status: 'skipped',
|
|
74
|
+
reason: 'no_diff',
|
|
75
|
+
availableModels: [],
|
|
76
|
+
metricModels: [],
|
|
77
|
+
providerFailures: [],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const { diffSection, truncated } = buildDiffSection(annotatedFiles);
|
|
81
|
+
if (!diffSection.trim()) {
|
|
82
|
+
logger.info('Diff section empty, skipping review');
|
|
83
|
+
return {
|
|
84
|
+
status: 'skipped',
|
|
85
|
+
reason: 'empty_diff',
|
|
86
|
+
availableModels: [],
|
|
87
|
+
metricModels: [],
|
|
88
|
+
providerFailures: [],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const configuredAvailableModels = getEnabledReviewModels(input.apiKeys, input.reviewModelsEnabled);
|
|
92
|
+
if (configuredAvailableModels.length === 0) {
|
|
93
|
+
logger.error('No enabled review models with API keys are available, skipping review', {
|
|
94
|
+
review_models_enabled: input.reviewModelsEnabled,
|
|
95
|
+
review_focuses_enabled: input.reviewFocusesEnabled,
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
status: 'skipped',
|
|
99
|
+
reason: 'no_models',
|
|
100
|
+
availableModels: [],
|
|
101
|
+
metricModels: [...input.reviewModelsEnabled],
|
|
102
|
+
mode: 'multi_focus',
|
|
103
|
+
providerFailures: [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const missingRequiredModels = getMissingRequiredReviewModels(configuredAvailableModels, input.requiredReviewModels);
|
|
107
|
+
if (missingRequiredModels.length > 0) {
|
|
108
|
+
logger.error('Required review models with API keys are unavailable, skipping review', {
|
|
109
|
+
review_models_enabled: input.reviewModelsEnabled,
|
|
110
|
+
review_focuses_enabled: input.reviewFocusesEnabled,
|
|
111
|
+
available_models: configuredAvailableModels,
|
|
112
|
+
missing_required_models: missingRequiredModels,
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
status: 'skipped',
|
|
116
|
+
reason: 'no_models',
|
|
117
|
+
availableModels: configuredAvailableModels,
|
|
118
|
+
metricModels: [...input.reviewModelsEnabled],
|
|
119
|
+
mode: 'multi_focus',
|
|
120
|
+
providerFailures: [],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const prConversationPromise = input.loadPrConversation?.() ?? Promise.resolve(undefined);
|
|
124
|
+
const prConversation = maskPrAuthorInConversation(await prConversationPromise, input.prAuthor);
|
|
125
|
+
const standardsSection = loadStandardsSection();
|
|
126
|
+
const { prompt } = buildEnginePrompt({
|
|
127
|
+
input,
|
|
128
|
+
diffSection,
|
|
129
|
+
truncated,
|
|
130
|
+
standardsSection,
|
|
131
|
+
prConversation,
|
|
132
|
+
});
|
|
133
|
+
logger.info('Review prompt prepared', {
|
|
134
|
+
engine: 'multi_focus',
|
|
135
|
+
prompt_length: prompt.length,
|
|
136
|
+
pr_body_length: input.prBody.trim().slice(0, 4000).length,
|
|
137
|
+
pr_conversation_length: prConversation?.promptText.trim().length ?? 0,
|
|
138
|
+
diff_length: diffSection.length,
|
|
139
|
+
standards_length: standardsSection.length,
|
|
140
|
+
annotated_files_count: annotatedFiles.length,
|
|
141
|
+
files_in_pr_count: input.files.length,
|
|
142
|
+
diff_truncated: truncated,
|
|
143
|
+
guidance_files: getGuidanceFilePresence(input.workspaceDir),
|
|
144
|
+
});
|
|
145
|
+
input.onProgress?.({ type: 'prompt_prepared', fileCount: annotatedFiles.length });
|
|
146
|
+
const engineContext = {
|
|
147
|
+
owner: input.owner,
|
|
148
|
+
repo: input.repo,
|
|
149
|
+
prNumber: input.prNumber,
|
|
150
|
+
workspaceDir: input.workspaceDir,
|
|
151
|
+
apiKeys: input.apiKeys,
|
|
152
|
+
reviewModelsEnabled: input.reviewModelsEnabled,
|
|
153
|
+
...(input.reviewModelsByFocus ? { reviewModelsByFocus: input.reviewModelsByFocus } : {}),
|
|
154
|
+
...(input.geminiFallbackFocuses
|
|
155
|
+
? { geminiFallbackFocuses: input.geminiFallbackFocuses }
|
|
156
|
+
: {}),
|
|
157
|
+
...(input.reviewSummaryModel ? { reviewSummaryModel: input.reviewSummaryModel } : {}),
|
|
158
|
+
openRouterSessionId: buildOpenRouterReviewSessionId({
|
|
159
|
+
owner: input.owner,
|
|
160
|
+
repo: input.repo,
|
|
161
|
+
prNumber: input.prNumber,
|
|
162
|
+
prompt,
|
|
163
|
+
}),
|
|
164
|
+
};
|
|
165
|
+
const reviewModeContext = isReviewModePilotRepository(input.owner, input.repo)
|
|
166
|
+
? input.reviewMode
|
|
167
|
+
: undefined;
|
|
168
|
+
if (reviewModeContext)
|
|
169
|
+
input.onProgress?.({ type: 'review_mode' });
|
|
170
|
+
const [{ review, availableModels, metricModels, mode, providerFailures }, reviewModeResult] = await Promise.all([
|
|
171
|
+
runMultiFocusReviewEngine({
|
|
172
|
+
ctx: engineContext,
|
|
173
|
+
prompt,
|
|
174
|
+
standardsSection,
|
|
175
|
+
lineLookup,
|
|
176
|
+
prTitle: input.prTitle,
|
|
177
|
+
prBody: input.prBody,
|
|
178
|
+
lowPriorityFindingPlacement: input.lowPriorityFindingPlacement,
|
|
179
|
+
enabledFocuses: input.reviewFocusesEnabled,
|
|
180
|
+
onProgress: input.onProgress,
|
|
181
|
+
}),
|
|
182
|
+
reviewModeContext
|
|
183
|
+
? runReviewModePass({
|
|
184
|
+
owner: input.owner,
|
|
185
|
+
repo: input.repo,
|
|
186
|
+
prTitle: input.prTitle,
|
|
187
|
+
prBody: input.prBody,
|
|
188
|
+
conversation: prConversation?.promptText,
|
|
189
|
+
files: input.files,
|
|
190
|
+
diff: diffSection,
|
|
191
|
+
diffTruncated: truncated,
|
|
192
|
+
apiKeys: input.apiKeys,
|
|
193
|
+
reviewModelsEnabled: input.reviewModelsEnabled,
|
|
194
|
+
reviewSummaryModel: input.reviewSummaryModel,
|
|
195
|
+
workspaceDir: input.workspaceDir,
|
|
196
|
+
context: reviewModeContext,
|
|
197
|
+
})
|
|
198
|
+
: undefined,
|
|
199
|
+
]);
|
|
200
|
+
if (availableModels.length === 0) {
|
|
201
|
+
logger.error('No enabled review models with API keys are available, skipping review', {
|
|
202
|
+
review_models_enabled: input.reviewModelsEnabled,
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
status: 'skipped',
|
|
206
|
+
reason: 'no_models',
|
|
207
|
+
availableModels,
|
|
208
|
+
metricModels,
|
|
209
|
+
mode,
|
|
210
|
+
providerFailures,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (!review) {
|
|
214
|
+
logger.warn('Review returned no results, skipping');
|
|
215
|
+
return {
|
|
216
|
+
status: 'skipped',
|
|
217
|
+
reason: 'no_review',
|
|
218
|
+
availableModels,
|
|
219
|
+
metricModels,
|
|
220
|
+
mode,
|
|
221
|
+
providerFailures,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (!review.summary.trim() && review.comments.length === 0) {
|
|
225
|
+
logger.warn('Review returned empty content, skipping');
|
|
226
|
+
return {
|
|
227
|
+
status: 'skipped',
|
|
228
|
+
reason: 'empty_review',
|
|
229
|
+
availableModels,
|
|
230
|
+
metricModels,
|
|
231
|
+
mode,
|
|
232
|
+
providerFailures,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (reviewModeResult) {
|
|
236
|
+
review.reviewMode = reviewModeResult.classification;
|
|
237
|
+
if (reviewModeResult.note) {
|
|
238
|
+
review.summary = `${review.summary.trim()}\n\n${reviewModeResult.note}`.trim();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
logger.info('Review completed', {
|
|
242
|
+
engine: 'multi_focus',
|
|
243
|
+
mode,
|
|
244
|
+
comments_count: review.comments.length,
|
|
245
|
+
});
|
|
246
|
+
input.onProgress?.({ type: 'formatting_results' });
|
|
247
|
+
return {
|
|
248
|
+
status: 'reviewed',
|
|
249
|
+
review,
|
|
250
|
+
availableModels,
|
|
251
|
+
metricModels,
|
|
252
|
+
mode,
|
|
253
|
+
providerFailures,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { SpanStatusCode } from '@opentelemetry/api';
|
|
2
|
+
import { WORKSPACE_DIR } from '../../core/environment.js';
|
|
3
|
+
import { logger } from '../../core/logging.js';
|
|
4
|
+
import { DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_OPENROUTER_MODELS, DEFAULT_PI_MODELS, invokePiPrompt, PI_PROVIDER_BY_DOISTBOT_PROVIDER, } from '../../core/pi.js';
|
|
5
|
+
import { genAiProviderName, withPhase } from '../../core/tracing.js';
|
|
6
|
+
import { resolveReviewProviderCredential } from '../../providers/credentials.js';
|
|
7
|
+
import { extractJsonCandidatesFromResponse } from '../../providers/helpers.js';
|
|
8
|
+
const REVIEW_SUMMARY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
const DEFAULT_REVIEW_SUMMARY_THINKING_LEVEL = 'medium';
|
|
10
|
+
// One model span per summary attempt; a fallback chain emits one span per model.
|
|
11
|
+
const SUMMARY_PHASE = 'review.summary';
|
|
12
|
+
const DIFF_SIDE = {
|
|
13
|
+
LEFT: 'LEFT',
|
|
14
|
+
RIGHT: 'RIGHT',
|
|
15
|
+
};
|
|
16
|
+
function getWorkspaceDir() {
|
|
17
|
+
return WORKSPACE_DIR;
|
|
18
|
+
}
|
|
19
|
+
function getReviewSummaryModelNameForLog({ provider, piProviderId, modelName, }) {
|
|
20
|
+
if (modelName) {
|
|
21
|
+
return modelName;
|
|
22
|
+
}
|
|
23
|
+
const resolvedPiProviderId = piProviderId ?? PI_PROVIDER_BY_DOISTBOT_PROVIDER[provider];
|
|
24
|
+
return ((resolvedPiProviderId === 'openrouter' ? DEFAULT_OPENROUTER_MODELS[provider] : undefined) ??
|
|
25
|
+
DEFAULT_PI_MODELS[provider]);
|
|
26
|
+
}
|
|
27
|
+
export function buildGeminiFlashReviewSummaryModel(credential) {
|
|
28
|
+
if (credential === undefined) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const resolvedCredential = resolveReviewProviderCredential(credential);
|
|
32
|
+
if (!resolvedCredential) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
provider: 'gemini',
|
|
37
|
+
credential,
|
|
38
|
+
modelName: DEFAULT_GEMINI_FLASH_MODEL,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function failedReviewSummaryRequest({ reason, error, timedOut, durationMs, outputChars, }) {
|
|
42
|
+
return {
|
|
43
|
+
status: 'failed',
|
|
44
|
+
reason,
|
|
45
|
+
...(error ? { error } : {}),
|
|
46
|
+
...(timedOut !== undefined ? { timedOut } : {}),
|
|
47
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
48
|
+
...(outputChars !== undefined ? { outputChars } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function getRequestFailureReason(result) {
|
|
52
|
+
if (result.timedOut) {
|
|
53
|
+
return 'timeout';
|
|
54
|
+
}
|
|
55
|
+
return result.error.toLowerCase().includes('did not produce an assistant response')
|
|
56
|
+
? 'no_assistant_response'
|
|
57
|
+
: 'request_failed';
|
|
58
|
+
}
|
|
59
|
+
export async function requestReviewSummary({ credential, provider = 'zai', modelName, prompt, workspaceDir, }) {
|
|
60
|
+
const resolvedCredential = resolveReviewProviderCredential(credential);
|
|
61
|
+
if (!resolvedCredential) {
|
|
62
|
+
return failedReviewSummaryRequest({ reason: 'missing_credential' });
|
|
63
|
+
}
|
|
64
|
+
const resolvedModelName = modelName ?? resolvedCredential.modelName;
|
|
65
|
+
const logModelName = getReviewSummaryModelNameForLog({
|
|
66
|
+
provider,
|
|
67
|
+
piProviderId: resolvedCredential.piProviderId,
|
|
68
|
+
modelName: resolvedModelName,
|
|
69
|
+
});
|
|
70
|
+
// Span wraps the actual model invoke (after the credential check), so a
|
|
71
|
+
// missing-credential config error returns before any span and never shows a fake model attempt.
|
|
72
|
+
return withPhase(SUMMARY_PHASE, {
|
|
73
|
+
'gen_ai.operation.name': 'chat',
|
|
74
|
+
'gen_ai.provider.name': genAiProviderName(provider),
|
|
75
|
+
'gen_ai.request.model': logModelName,
|
|
76
|
+
}, async (span) => {
|
|
77
|
+
const result = await runReviewSummaryRequest({
|
|
78
|
+
provider,
|
|
79
|
+
resolvedCredential,
|
|
80
|
+
resolvedModelName,
|
|
81
|
+
logModelName,
|
|
82
|
+
prompt,
|
|
83
|
+
workspaceDir,
|
|
84
|
+
});
|
|
85
|
+
if (result.status === 'failed') {
|
|
86
|
+
span.setStatus({
|
|
87
|
+
code: SpanStatusCode.ERROR,
|
|
88
|
+
message: result.error ?? result.reason,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async function runReviewSummaryRequest({ provider, resolvedCredential, resolvedModelName, logModelName, prompt, workspaceDir, }) {
|
|
95
|
+
try {
|
|
96
|
+
const thinkingLevel = provider === 'zai' ? 'high' : DEFAULT_REVIEW_SUMMARY_THINKING_LEVEL;
|
|
97
|
+
logger.info('Review summary model request', {
|
|
98
|
+
provider,
|
|
99
|
+
modelName: logModelName,
|
|
100
|
+
promptLength: prompt.length,
|
|
101
|
+
timeoutMs: REVIEW_SUMMARY_TIMEOUT_MS,
|
|
102
|
+
thinkingLevel,
|
|
103
|
+
});
|
|
104
|
+
const result = await invokePiPrompt({
|
|
105
|
+
provider,
|
|
106
|
+
apiKey: resolvedCredential.apiKey,
|
|
107
|
+
...(resolvedCredential.piProviderId
|
|
108
|
+
? { piProviderId: resolvedCredential.piProviderId }
|
|
109
|
+
: {}),
|
|
110
|
+
...(resolvedModelName ? { modelName: resolvedModelName } : {}),
|
|
111
|
+
prompt,
|
|
112
|
+
cwd: workspaceDir ?? getWorkspaceDir(),
|
|
113
|
+
timeoutMs: REVIEW_SUMMARY_TIMEOUT_MS,
|
|
114
|
+
capabilityPreset: 'read-only',
|
|
115
|
+
thinkingLevel,
|
|
116
|
+
});
|
|
117
|
+
if (!result.ok) {
|
|
118
|
+
const reason = getRequestFailureReason(result);
|
|
119
|
+
logger.warn('Review summary model request failed', {
|
|
120
|
+
reason,
|
|
121
|
+
provider,
|
|
122
|
+
modelName: logModelName,
|
|
123
|
+
error: result.error,
|
|
124
|
+
timedOut: result.timedOut,
|
|
125
|
+
durationMs: result.durationMs,
|
|
126
|
+
promptLength: prompt.length,
|
|
127
|
+
});
|
|
128
|
+
return failedReviewSummaryRequest({
|
|
129
|
+
reason,
|
|
130
|
+
error: result.error,
|
|
131
|
+
timedOut: result.timedOut,
|
|
132
|
+
durationMs: result.durationMs,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const output = result.output;
|
|
136
|
+
if (!output?.trim()) {
|
|
137
|
+
return failedReviewSummaryRequest({
|
|
138
|
+
reason: 'empty_output',
|
|
139
|
+
durationMs: result.durationMs,
|
|
140
|
+
outputChars: output?.length ?? 0,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const jsonCandidates = extractJsonCandidatesFromResponse(output);
|
|
144
|
+
if (jsonCandidates.length === 0) {
|
|
145
|
+
logger.warn('Review summary model output missing JSON', {
|
|
146
|
+
provider,
|
|
147
|
+
modelName: logModelName,
|
|
148
|
+
durationMs: result.durationMs,
|
|
149
|
+
outputChars: output.length,
|
|
150
|
+
});
|
|
151
|
+
return failedReviewSummaryRequest({
|
|
152
|
+
reason: 'missing_json',
|
|
153
|
+
durationMs: result.durationMs,
|
|
154
|
+
outputChars: output.length,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
let parsed;
|
|
158
|
+
let parsedSuccessfully = false;
|
|
159
|
+
let parseError;
|
|
160
|
+
for (const jsonCandidate of jsonCandidates) {
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(jsonCandidate);
|
|
163
|
+
parsedSuccessfully = true;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
parseError = error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!parsedSuccessfully) {
|
|
171
|
+
logger.warn('Review summary model output contained invalid JSON', {
|
|
172
|
+
provider,
|
|
173
|
+
modelName: logModelName,
|
|
174
|
+
error: parseError instanceof Error ? parseError.message : String(parseError),
|
|
175
|
+
durationMs: result.durationMs,
|
|
176
|
+
outputChars: output.length,
|
|
177
|
+
});
|
|
178
|
+
return failedReviewSummaryRequest({
|
|
179
|
+
reason: 'invalid_json',
|
|
180
|
+
error: parseError instanceof Error ? parseError.message : String(parseError),
|
|
181
|
+
durationMs: result.durationMs,
|
|
182
|
+
outputChars: output.length,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
186
|
+
return failedReviewSummaryRequest({
|
|
187
|
+
reason: 'invalid_schema',
|
|
188
|
+
durationMs: result.durationMs,
|
|
189
|
+
outputChars: output.length,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const summaryResult = parsed;
|
|
193
|
+
if (typeof summaryResult.summary !== 'string') {
|
|
194
|
+
return failedReviewSummaryRequest({
|
|
195
|
+
reason: 'invalid_schema',
|
|
196
|
+
durationMs: result.durationMs,
|
|
197
|
+
outputChars: output.length,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (!summaryResult.summary.trim()) {
|
|
201
|
+
return failedReviewSummaryRequest({
|
|
202
|
+
reason: 'empty_summary',
|
|
203
|
+
durationMs: result.durationMs,
|
|
204
|
+
outputChars: output.length,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
logger.info('Review summary model response', {
|
|
208
|
+
provider,
|
|
209
|
+
modelName: logModelName,
|
|
210
|
+
durationMs: result.durationMs,
|
|
211
|
+
outputChars: output.length,
|
|
212
|
+
summaryChars: summaryResult.summary.trim().length,
|
|
213
|
+
});
|
|
214
|
+
return {
|
|
215
|
+
status: 'succeeded',
|
|
216
|
+
summary: summaryResult.summary,
|
|
217
|
+
comments: Array.isArray(summaryResult.comments) ? summaryResult.comments : [],
|
|
218
|
+
durationMs: result.durationMs,
|
|
219
|
+
outputChars: output.length,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
logger.error('Review summary model request or parsing failed', {
|
|
224
|
+
error: error instanceof Error ? error.message : String(error),
|
|
225
|
+
});
|
|
226
|
+
return failedReviewSummaryRequest({
|
|
227
|
+
reason: 'request_failed',
|
|
228
|
+
error: error instanceof Error ? error.message : String(error),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
export function sanitizeInlineComments(comments, lineLookup) {
|
|
233
|
+
if (!Array.isArray(comments)) {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
const sanitized = [];
|
|
237
|
+
for (const comment of comments) {
|
|
238
|
+
const filePath = typeof comment.path === 'string' ? comment.path.trim() : '';
|
|
239
|
+
const line = Number(comment.line);
|
|
240
|
+
let side = typeof comment.side === 'string'
|
|
241
|
+
? comment.side.toUpperCase()
|
|
242
|
+
: DIFF_SIDE.RIGHT;
|
|
243
|
+
const body = typeof comment.body === 'string' ? comment.body.trim() : '';
|
|
244
|
+
if (!filePath || !Number.isInteger(line) || !body) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (side !== DIFF_SIDE.LEFT && side !== DIFF_SIDE.RIGHT) {
|
|
248
|
+
logger.warn('Invalid side for comment, defaulting to RIGHT', {
|
|
249
|
+
filePath,
|
|
250
|
+
line,
|
|
251
|
+
side,
|
|
252
|
+
});
|
|
253
|
+
side = DIFF_SIDE.RIGHT;
|
|
254
|
+
}
|
|
255
|
+
const fileLines = lineLookup.get(filePath);
|
|
256
|
+
if (!fileLines) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const validLines = side === DIFF_SIDE.LEFT ? fileLines.old : fileLines.new;
|
|
260
|
+
if (!validLines.has(line)) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
sanitized.push({
|
|
264
|
+
path: filePath,
|
|
265
|
+
line,
|
|
266
|
+
side,
|
|
267
|
+
body,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return sanitized;
|
|
271
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const ENGLISH_SOURCE_FIRST_REPOSITORIES = new Set([
|
|
2
|
+
'todoist/todoist-web',
|
|
3
|
+
'todoist/todoist-android',
|
|
4
|
+
'todoist/todoist-ios',
|
|
5
|
+
'todoist/todoist-electron',
|
|
6
|
+
'todoist/todoist-macos',
|
|
7
|
+
]);
|
|
8
|
+
export function usesEnglishSourceFirstTranslations(repoOwner, repoName) {
|
|
9
|
+
return ENGLISH_SOURCE_FIRST_REPOSITORIES.has(`${repoOwner}/${repoName}`.toLowerCase().replace(/^doist\//, 'todoist/'));
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ReviewHygieneDecisionKind, ReviewHygieneExclusionReason, ReviewHygieneReasonCode, } from 'doistbot-async-routing-contracts';
|
|
2
|
+
import { parseBaseContext, requiredEnv } from '../../core/environment.js';
|
|
3
|
+
export function parseReviewSlicePlanContext(env = process.env) {
|
|
4
|
+
const dryRun = env.DRY_RUN?.toLowerCase() === 'true';
|
|
5
|
+
return {
|
|
6
|
+
...parseBaseContext(env),
|
|
7
|
+
baseBranch: requiredEnv('BASE_BRANCH', env),
|
|
8
|
+
headSha: requiredEnv('HEAD_SHA', env),
|
|
9
|
+
runId: requiredEnv('SLICE_PLAN_RUN_ID', env),
|
|
10
|
+
reviewHygiene: parseReviewHygieneDecision(requiredEnv('REVIEW_HYGIENE_DECISION', env)),
|
|
11
|
+
geminiApiKey: requiredEnv('GEMINI_API_KEY', env),
|
|
12
|
+
...(dryRun
|
|
13
|
+
? { dryRun: true }
|
|
14
|
+
: { dryRun: false, commsAlertKey: requiredEnv('COMMS_ALERT_KEY', env) }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function parseReviewHygieneDecision(serialized) {
|
|
18
|
+
let value;
|
|
19
|
+
try {
|
|
20
|
+
value = JSON.parse(serialized);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
throw new Error(`Invalid REVIEW_HYGIENE_DECISION JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
24
|
+
}
|
|
25
|
+
if (!isRecord(value)) {
|
|
26
|
+
throw new Error('Invalid REVIEW_HYGIENE_DECISION: expected an object');
|
|
27
|
+
}
|
|
28
|
+
if (value.kind !== ReviewHygieneDecisionKind.Warn &&
|
|
29
|
+
value.kind !== ReviewHygieneDecisionKind.Block) {
|
|
30
|
+
throw new Error('Invalid REVIEW_HYGIENE_DECISION: expected warn or block');
|
|
31
|
+
}
|
|
32
|
+
if (!isReviewHygieneStats(value.stats) ||
|
|
33
|
+
!Array.isArray(value.reasons) ||
|
|
34
|
+
!value.reasons.every(isReviewHygieneReason)) {
|
|
35
|
+
throw new Error('Invalid REVIEW_HYGIENE_DECISION: missing stats or reasons');
|
|
36
|
+
}
|
|
37
|
+
if (value.exclusionSummary !== undefined &&
|
|
38
|
+
!isReviewHygieneExclusionSummary(value.exclusionSummary)) {
|
|
39
|
+
throw new Error('Invalid REVIEW_HYGIENE_DECISION: invalid exclusion summary');
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function isReviewHygieneReason(value) {
|
|
44
|
+
return (isRecord(value) &&
|
|
45
|
+
Object.values(ReviewHygieneReasonCode).includes(value.code) &&
|
|
46
|
+
isFiniteNumber(value.actual) &&
|
|
47
|
+
isFiniteNumber(value.limit));
|
|
48
|
+
}
|
|
49
|
+
function isReviewHygieneExclusionSummary(value) {
|
|
50
|
+
return (isRecord(value) &&
|
|
51
|
+
['files', 'additions', 'deletions', 'changedLines', 'reviewLoadLines'].every((key) => isFiniteNumber(value[key])) &&
|
|
52
|
+
Array.isArray(value.reasons) &&
|
|
53
|
+
value.reasons.every((reason) => Object.values(ReviewHygieneExclusionReason).includes(reason)));
|
|
54
|
+
}
|
|
55
|
+
function isFiniteNumber(value) {
|
|
56
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
57
|
+
}
|
|
58
|
+
function isReviewHygieneStats(value) {
|
|
59
|
+
if (!isRecord(value)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return ['additions', 'deletions', 'changedFiles', 'changedLines', 'reviewLoadLines'].every((key) => isFiniteNumber(value[key]));
|
|
63
|
+
}
|
|
64
|
+
function isRecord(value) {
|
|
65
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|