pi-mono-all 1.0.0

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.
Files changed (161) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENCE.md +7 -0
  3. package/node_modules/pi-common/package.json +22 -0
  4. package/node_modules/pi-common/src/auth-config.ts +290 -0
  5. package/node_modules/pi-common/src/auth.ts +63 -0
  6. package/node_modules/pi-common/src/cache.ts +60 -0
  7. package/node_modules/pi-common/src/errors.ts +47 -0
  8. package/node_modules/pi-common/src/http-client.ts +118 -0
  9. package/node_modules/pi-common/src/index.ts +7 -0
  10. package/node_modules/pi-common/src/rate-limiter.ts +32 -0
  11. package/node_modules/pi-common/src/tool-result.ts +27 -0
  12. package/node_modules/pi-mono-ask-user-question/CHANGELOG.md +185 -0
  13. package/node_modules/pi-mono-ask-user-question/README.md +226 -0
  14. package/node_modules/pi-mono-ask-user-question/index.ts +923 -0
  15. package/node_modules/pi-mono-ask-user-question/package.json +29 -0
  16. package/node_modules/pi-mono-auto-fix/CHANGELOG.md +59 -0
  17. package/node_modules/pi-mono-auto-fix/README.md +77 -0
  18. package/node_modules/pi-mono-auto-fix/index.ts +488 -0
  19. package/node_modules/pi-mono-auto-fix/package.json +23 -0
  20. package/node_modules/pi-mono-btw/CHANGELOG.md +180 -0
  21. package/node_modules/pi-mono-btw/README.md +24 -0
  22. package/node_modules/pi-mono-btw/index.ts +499 -0
  23. package/node_modules/pi-mono-btw/package.json +29 -0
  24. package/node_modules/pi-mono-clear/CHANGELOG.md +180 -0
  25. package/node_modules/pi-mono-clear/README.md +40 -0
  26. package/node_modules/pi-mono-clear/index.ts +45 -0
  27. package/node_modules/pi-mono-clear/package.json +29 -0
  28. package/node_modules/pi-mono-context/CHANGELOG.md +12 -0
  29. package/node_modules/pi-mono-context/README.md +74 -0
  30. package/node_modules/pi-mono-context/index.ts +641 -0
  31. package/node_modules/pi-mono-context/package.json +29 -0
  32. package/node_modules/pi-mono-context-guard/CHANGELOG.md +195 -0
  33. package/node_modules/pi-mono-context-guard/README.md +81 -0
  34. package/node_modules/pi-mono-context-guard/index.ts +212 -0
  35. package/node_modules/pi-mono-context-guard/package.json +23 -0
  36. package/node_modules/pi-mono-figma/CHANGELOG.md +59 -0
  37. package/node_modules/pi-mono-figma/README.md +236 -0
  38. package/node_modules/pi-mono-figma/__tests__/code-connect.test.ts +32 -0
  39. package/node_modules/pi-mono-figma/__tests__/figma-assets.test.ts +38 -0
  40. package/node_modules/pi-mono-figma/__tests__/figma-component-hints.test.ts +23 -0
  41. package/node_modules/pi-mono-figma/__tests__/figma-implementation-layout.test.ts +47 -0
  42. package/node_modules/pi-mono-figma/__tests__/figma-search.test.ts +51 -0
  43. package/node_modules/pi-mono-figma/__tests__/figma-summarizer.test.ts +65 -0
  44. package/node_modules/pi-mono-figma/__tests__/fixtures/complex-auto-layout.json +115 -0
  45. package/node_modules/pi-mono-figma/__tests__/fixtures/component-instance.json +50 -0
  46. package/node_modules/pi-mono-figma/__tests__/fixtures/hidden-and-vectors.json +28 -0
  47. package/node_modules/pi-mono-figma/__tests__/fixtures/variables-and-styles.json +40 -0
  48. package/node_modules/pi-mono-figma/docs/live-selection-bridge.md +16 -0
  49. package/node_modules/pi-mono-figma/index.ts +6 -0
  50. package/node_modules/pi-mono-figma/package.json +33 -0
  51. package/node_modules/pi-mono-figma/skills/figma/SKILL.md +143 -0
  52. package/node_modules/pi-mono-figma/src/code-connect.ts +110 -0
  53. package/node_modules/pi-mono-figma/src/figma-assets.ts +146 -0
  54. package/node_modules/pi-mono-figma/src/figma-cache.ts +6 -0
  55. package/node_modules/pi-mono-figma/src/figma-client.ts +471 -0
  56. package/node_modules/pi-mono-figma/src/figma-component-hints.ts +87 -0
  57. package/node_modules/pi-mono-figma/src/figma-implementation.ts +264 -0
  58. package/node_modules/pi-mono-figma/src/figma-schemas.ts +139 -0
  59. package/node_modules/pi-mono-figma/src/figma-search.ts +195 -0
  60. package/node_modules/pi-mono-figma/src/figma-summarizer.ts +673 -0
  61. package/node_modules/pi-mono-figma/src/figma-tokens.ts +57 -0
  62. package/node_modules/pi-mono-figma/src/figma-tools.ts +352 -0
  63. package/node_modules/pi-mono-linear/CHANGELOG.md +44 -0
  64. package/node_modules/pi-mono-linear/README.md +159 -0
  65. package/node_modules/pi-mono-linear/index.ts +6 -0
  66. package/node_modules/pi-mono-linear/package.json +30 -0
  67. package/node_modules/pi-mono-linear/skills/linear/SKILL.md +107 -0
  68. package/node_modules/pi-mono-linear/src/linear-client.ts +339 -0
  69. package/node_modules/pi-mono-linear/src/linear-queries.ts +101 -0
  70. package/node_modules/pi-mono-linear/src/linear-schemas.ts +90 -0
  71. package/node_modules/pi-mono-linear/src/linear-tools.ts +362 -0
  72. package/node_modules/pi-mono-loop/CHANGELOG.md +163 -0
  73. package/node_modules/pi-mono-loop/README.md +54 -0
  74. package/node_modules/pi-mono-loop/index.ts +291 -0
  75. package/node_modules/pi-mono-loop/package.json +26 -0
  76. package/node_modules/pi-mono-multi-edit/CHANGELOG.md +232 -0
  77. package/node_modules/pi-mono-multi-edit/README.md +244 -0
  78. package/node_modules/pi-mono-multi-edit/__tests__/classic.test.ts +277 -0
  79. package/node_modules/pi-mono-multi-edit/__tests__/diff.test.ts +77 -0
  80. package/node_modules/pi-mono-multi-edit/__tests__/patch.test.ts +287 -0
  81. package/node_modules/pi-mono-multi-edit/benchmark-edits.ts +966 -0
  82. package/node_modules/pi-mono-multi-edit/classic.ts +435 -0
  83. package/node_modules/pi-mono-multi-edit/diff.ts +143 -0
  84. package/node_modules/pi-mono-multi-edit/index.ts +266 -0
  85. package/node_modules/pi-mono-multi-edit/package.json +37 -0
  86. package/node_modules/pi-mono-multi-edit/patch.ts +463 -0
  87. package/node_modules/pi-mono-multi-edit/types.ts +53 -0
  88. package/node_modules/pi-mono-multi-edit/workspace.ts +85 -0
  89. package/node_modules/pi-mono-review/CHANGELOG.md +190 -0
  90. package/node_modules/pi-mono-review/README.md +30 -0
  91. package/node_modules/pi-mono-review/common.ts +930 -0
  92. package/node_modules/pi-mono-review/index.ts +8 -0
  93. package/node_modules/pi-mono-review/package.json +29 -0
  94. package/node_modules/pi-mono-review/review-tui.ts +194 -0
  95. package/node_modules/pi-mono-review/review.ts +119 -0
  96. package/node_modules/pi-mono-review/reviewer.ts +339 -0
  97. package/node_modules/pi-mono-sentinel/CHANGELOG.md +158 -0
  98. package/node_modules/pi-mono-sentinel/README.md +87 -0
  99. package/node_modules/pi-mono-sentinel/__tests__/output-scanner.test.ts +109 -0
  100. package/node_modules/pi-mono-sentinel/__tests__/permissions.test.ts +202 -0
  101. package/node_modules/pi-mono-sentinel/__tests__/whitelist.test.ts +59 -0
  102. package/node_modules/pi-mono-sentinel/guards/execution-tracker.ts +281 -0
  103. package/node_modules/pi-mono-sentinel/guards/output-scanner.ts +232 -0
  104. package/node_modules/pi-mono-sentinel/guards/permission-gate.ts +170 -0
  105. package/node_modules/pi-mono-sentinel/index.ts +43 -0
  106. package/node_modules/pi-mono-sentinel/package.json +26 -0
  107. package/node_modules/pi-mono-sentinel/patterns/permissions.ts +175 -0
  108. package/node_modules/pi-mono-sentinel/patterns/read-targets.ts +104 -0
  109. package/node_modules/pi-mono-sentinel/patterns/secrets.ts +143 -0
  110. package/node_modules/pi-mono-sentinel/session.ts +95 -0
  111. package/node_modules/pi-mono-sentinel/specs/2026/04/sentinel/001-permission-gate.md +145 -0
  112. package/node_modules/pi-mono-sentinel/types.ts +39 -0
  113. package/node_modules/pi-mono-sentinel/whitelist.ts +86 -0
  114. package/node_modules/pi-mono-simplify/CHANGELOG.md +163 -0
  115. package/node_modules/pi-mono-simplify/README.md +56 -0
  116. package/node_modules/pi-mono-simplify/index.ts +78 -0
  117. package/node_modules/pi-mono-simplify/package.json +29 -0
  118. package/node_modules/pi-mono-status-line/CHANGELOG.md +180 -0
  119. package/node_modules/pi-mono-status-line/README.md +96 -0
  120. package/node_modules/pi-mono-status-line/basic.ts +89 -0
  121. package/node_modules/pi-mono-status-line/expert.ts +689 -0
  122. package/node_modules/pi-mono-status-line/index.ts +54 -0
  123. package/node_modules/pi-mono-status-line/package.json +29 -0
  124. package/node_modules/pi-mono-team-mode/CHANGELOG.md +278 -0
  125. package/node_modules/pi-mono-team-mode/README.md +246 -0
  126. package/node_modules/pi-mono-team-mode/__tests__/agent-manager-transient.test.ts +75 -0
  127. package/node_modules/pi-mono-team-mode/__tests__/delegation-manager.test.ts +118 -0
  128. package/node_modules/pi-mono-team-mode/__tests__/formatters.test.ts +104 -0
  129. package/node_modules/pi-mono-team-mode/__tests__/model-config.test.ts +272 -0
  130. package/node_modules/pi-mono-team-mode/__tests__/notification-box.test.ts +34 -0
  131. package/node_modules/pi-mono-team-mode/__tests__/parallel-utils.test.ts +32 -0
  132. package/node_modules/pi-mono-team-mode/__tests__/pi-stream-parser.test.ts +64 -0
  133. package/node_modules/pi-mono-team-mode/__tests__/prompts.test.ts +106 -0
  134. package/node_modules/pi-mono-team-mode/__tests__/store.test.ts +164 -0
  135. package/node_modules/pi-mono-team-mode/__tests__/tasks.test.ts +267 -0
  136. package/node_modules/pi-mono-team-mode/__tests__/teammate-specs.test.ts +114 -0
  137. package/node_modules/pi-mono-team-mode/__tests__/widget.test.ts +41 -0
  138. package/node_modules/pi-mono-team-mode/__tests__/worktree.test.ts +78 -0
  139. package/node_modules/pi-mono-team-mode/core/chain-utils.ts +90 -0
  140. package/node_modules/pi-mono-team-mode/core/fs-utils.ts +44 -0
  141. package/node_modules/pi-mono-team-mode/core/model-config.ts +432 -0
  142. package/node_modules/pi-mono-team-mode/core/parallel-utils.ts +48 -0
  143. package/node_modules/pi-mono-team-mode/core/prompts.ts +158 -0
  144. package/node_modules/pi-mono-team-mode/core/store.ts +156 -0
  145. package/node_modules/pi-mono-team-mode/core/tasks.ts +99 -0
  146. package/node_modules/pi-mono-team-mode/core/teammate-specs.ts +124 -0
  147. package/node_modules/pi-mono-team-mode/core/types.ts +160 -0
  148. package/node_modules/pi-mono-team-mode/index.ts +825 -0
  149. package/node_modules/pi-mono-team-mode/managers/agent-manager.ts +654 -0
  150. package/node_modules/pi-mono-team-mode/managers/delegation-manager.ts +211 -0
  151. package/node_modules/pi-mono-team-mode/managers/task-manager.ts +238 -0
  152. package/node_modules/pi-mono-team-mode/managers/team-manager.ts +59 -0
  153. package/node_modules/pi-mono-team-mode/package.json +33 -0
  154. package/node_modules/pi-mono-team-mode/runtime/pi-stream-parser.ts +194 -0
  155. package/node_modules/pi-mono-team-mode/runtime/subprocess.ts +183 -0
  156. package/node_modules/pi-mono-team-mode/runtime/transient-session.ts +196 -0
  157. package/node_modules/pi-mono-team-mode/runtime/worktree.ts +90 -0
  158. package/node_modules/pi-mono-team-mode/ui/formatters.ts +149 -0
  159. package/node_modules/pi-mono-team-mode/ui/notification-box.ts +55 -0
  160. package/node_modules/pi-mono-team-mode/ui/widget.ts +94 -0
  161. package/package.json +76 -0
@@ -0,0 +1,930 @@
1
+ import { complete, Type, type AssistantMessage, type Tool, type ToolCall, type ToolResultMessage, type UserMessage } from "@mariozechner/pi-ai";
2
+ import { formatSize, truncateHead, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+
4
+ export const PERSIST_ENTRY_TYPE = "review-session";
5
+ export const MAX_DIFF_BYTES = 150_000;
6
+ export const MAX_DIFF_LINES = 5_000;
7
+ export const MAX_COMMENTS = 25;
8
+
9
+ export const REVIEW_SYSTEM_PROMPT = `You are a senior code reviewer.
10
+
11
+ Review the supplied pull request / merge request diff and find issues that are important enough to leave as inline review comments.
12
+
13
+ Use the report_finding tool for every actionable inline review finding.
14
+
15
+ After reporting all findings, output ONLY valid JSON with this shape:
16
+ {
17
+ "summary": "short summary of overall review"
18
+ }
19
+
20
+ If there are no worthwhile review comments, do not call report_finding and return a summary JSON object.
21
+
22
+ Rules:
23
+ - Only comment on changed code visible in the diff.
24
+ - Prefer fewer, high-signal comments over many weak ones.
25
+ - Focus on bugs, security issues, correctness, regressions, edge cases, data integrity, and maintainability concerns.
26
+ - Ignore purely stylistic nits unless they hide a real problem.
27
+ - Every finding must be specific and actionable.
28
+ - Use P0 only for release-blocking or security-critical issues, P1 for high-priority correctness/regression issues, P2 for medium-priority maintainability or edge-case issues, and P3 for low-priority suggestions.
29
+ - Confidence is a number from 0 to 1.
30
+ - The line number must refer to the NEW/RIGHT side of the diff.
31
+ - Each added ('+') and context (' ') line in the diff is prefixed with its NEW-file line number in the form 'L<number>: '. You MUST copy that exact number into the line_start/line_end fields. Removed ('-') lines have no L prefix and cannot be commented on.
32
+ - If a finding spans multiple adjacent added/changed lines, set "line_start" to the L<number> of the first line and "line_end" to the L<number> of the last line.
33
+ - Never exceed 25 findings.`;
34
+
35
+ export const REVIEW_JSON_SYSTEM_PROMPT = `You are a senior code reviewer.
36
+
37
+ Review the supplied pull request / merge request diff and find issues that are important enough to leave as inline review comments.
38
+
39
+ Output ONLY valid JSON. No markdown fences. No prose outside JSON.
40
+
41
+ Return an object with this shape:
42
+ {
43
+ "summary": "short summary of overall review",
44
+ "comments": [
45
+ {
46
+ "title": "short imperative title",
47
+ "file": "path/to/file.ts",
48
+ "line": 123,
49
+ "endLine": 126,
50
+ "priority": "P0|P1|P2|P3",
51
+ "confidence": 0.8,
52
+ "body": "Actionable review comment"
53
+ }
54
+ ]
55
+ }
56
+
57
+ Rules:
58
+ - Only comment on changed code visible in the diff.
59
+ - Prefer fewer, high-signal comments over many weak ones.
60
+ - Focus on bugs, security issues, correctness, regressions, edge cases, data integrity, and maintainability concerns.
61
+ - Ignore purely stylistic nits unless they hide a real problem.
62
+ - Every comment must be specific and actionable.
63
+ - Use P0 only for release-blocking or security-critical issues, P1 for high-priority correctness/regression issues, P2 for medium-priority maintainability or edge-case issues, and P3 for low-priority suggestions.
64
+ - Confidence is a number from 0 to 1.
65
+ - The line number must refer to the NEW/RIGHT side of the diff.
66
+ - Each added ('+') and context (' ') line in the diff is prefixed with its NEW-file line number in the form 'L<number>: '. You MUST copy that exact number into the "line" field. Removed ('-') lines have no L prefix and cannot be commented on.
67
+ - If a comment spans multiple adjacent added/changed lines, set "line" to the L<number> of the first line and "endLine" to the L<number> of the last line.
68
+ - If there are no worthwhile review comments, return an empty comments array.
69
+ - Never exceed 25 comments.`;
70
+
71
+ export type ReviewPlatform = "github" | "gitlab";
72
+ export type CommentSeverity = "error" | "warning" | "suggestion" | "info";
73
+ export type CommentStatus = "pending" | "approved" | "dismissed" | "edited";
74
+ export type FindingPriority = "P0" | "P1" | "P2" | "P3";
75
+
76
+ export interface FindingPriorityInfo {
77
+ ord: 0 | 1 | 2 | 3;
78
+ symbol: "●" | "▲" | "◆" | "○";
79
+ color: "error" | "warning" | "accent" | "muted";
80
+ }
81
+
82
+ export const PRIORITY_LABELS: FindingPriority[] = ["P0", "P1", "P2", "P3"];
83
+
84
+ const PRIORITY_INFO: Record<FindingPriority, FindingPriorityInfo> = {
85
+ P0: { ord: 0, symbol: "●", color: "error" },
86
+ P1: { ord: 1, symbol: "▲", color: "warning" },
87
+ P2: { ord: 2, symbol: "◆", color: "accent" },
88
+ P3: { ord: 3, symbol: "○", color: "muted" },
89
+ };
90
+
91
+ const ReportFindingParams = Type.Object({
92
+ title: Type.String({ description: "Short imperative finding title", examples: ["Guard missing head SHA before submitting comments"] }),
93
+ body: Type.String({ description: "Actionable problem explanation" }),
94
+ priority: Type.Union([Type.Literal("P0"), Type.Literal("P1"), Type.Literal("P2"), Type.Literal("P3")], {
95
+ description: "Finding priority",
96
+ }),
97
+ confidence: Type.Number({ minimum: 0, maximum: 1, description: "Confidence score from 0 to 1", examples: [0.8] }),
98
+ file_path: Type.String({ description: "Changed file path" }),
99
+ line_start: Type.Number({ description: "Start line on the NEW/RIGHT side of the diff" }),
100
+ line_end: Type.Number({ description: "End line on the NEW/RIGHT side of the diff" }),
101
+ });
102
+
103
+ const REPORT_FINDING_TOOL_NAME = "report_finding";
104
+
105
+ const REPORT_FINDING_TOOL = {
106
+ name: REPORT_FINDING_TOOL_NAME,
107
+ description: "Report one actionable code review finding for the current PR/MR diff.",
108
+ parameters: ReportFindingParams,
109
+ } satisfies Tool<typeof ReportFindingParams>;
110
+
111
+ export interface ReportFindingDetails {
112
+ title: string;
113
+ body: string;
114
+ priority: FindingPriority;
115
+ confidence: number;
116
+ file_path: string;
117
+ line_start: number;
118
+ line_end: number;
119
+ }
120
+
121
+ export interface ReviewComment {
122
+ id: string;
123
+ title: string;
124
+ file: string;
125
+ line: number;
126
+ endLine?: number;
127
+ priority: FindingPriority;
128
+ confidence: number;
129
+ /** Legacy compatibility. Prefer priority for new UI and model output. */
130
+ severity?: CommentSeverity;
131
+ body: string;
132
+ codeContext?: string;
133
+ status: CommentStatus;
134
+ originalBody?: string;
135
+ }
136
+
137
+ export interface ReviewTarget {
138
+ platform: ReviewPlatform;
139
+ url: string;
140
+ host: string;
141
+ repoPath: string;
142
+ number: number;
143
+ title?: string;
144
+ headSha: string;
145
+ baseSha?: string;
146
+ startSha?: string;
147
+ }
148
+
149
+ export interface ReviewSession {
150
+ target: ReviewTarget;
151
+ summary: string;
152
+ comments: ReviewComment[];
153
+ stats?: string;
154
+ createdAt: number;
155
+ submittedAt?: number;
156
+ }
157
+
158
+ export interface ReviewResult {
159
+ session: ReviewSession;
160
+ approved: number;
161
+ dismissed: number;
162
+ edited: number;
163
+ cancelled: boolean;
164
+ submitted?: number;
165
+ failed?: string[];
166
+ }
167
+
168
+ export type ReviewAction =
169
+ | { type: "submit" }
170
+ | { type: "cancel" }
171
+ | { type: "edit"; index: number };
172
+
173
+ export interface ReviewSource {
174
+ target: ReviewTarget;
175
+ title?: string;
176
+ description?: string;
177
+ diff: string;
178
+ stats?: string;
179
+ patchByFile: Record<string, string>;
180
+ }
181
+
182
+ export interface SubmissionResult {
183
+ submitted: number;
184
+ failed: string[];
185
+ }
186
+
187
+ type ExecResult = { stdout: string; stderr: string; code: number };
188
+ type ExecFn = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => Promise<ExecResult>;
189
+
190
+ function ensureOk(result: ExecResult, context: string): string {
191
+ if (result.code !== 0) {
192
+ throw new Error(`${context}: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`);
193
+ }
194
+ return result.stdout;
195
+ }
196
+
197
+ function parseJson<T>(text: string): T {
198
+ return JSON.parse(text) as T;
199
+ }
200
+
201
+ function isRecord(value: unknown): value is Record<string, unknown> {
202
+ return typeof value === "object" && value !== null && !Array.isArray(value);
203
+ }
204
+
205
+ export function isFindingPriority(value: unknown): value is FindingPriority {
206
+ return value === "P0" || value === "P1" || value === "P2" || value === "P3";
207
+ }
208
+
209
+ function isCommentSeverity(value: unknown): value is CommentSeverity {
210
+ return value === "error" || value === "warning" || value === "suggestion" || value === "info";
211
+ }
212
+
213
+ export function getPriorityInfo(priority: FindingPriority): FindingPriorityInfo {
214
+ return PRIORITY_INFO[priority] ?? PRIORITY_INFO.P3;
215
+ }
216
+
217
+ export function severityToPriority(severity: CommentSeverity | undefined): FindingPriority {
218
+ switch (severity) {
219
+ case "error":
220
+ return "P1";
221
+ case "warning":
222
+ return "P2";
223
+ case "suggestion":
224
+ case "info":
225
+ default:
226
+ return "P3";
227
+ }
228
+ }
229
+
230
+ export function priorityToSeverity(priority: FindingPriority): CommentSeverity {
231
+ switch (priority) {
232
+ case "P0":
233
+ case "P1":
234
+ return "error";
235
+ case "P2":
236
+ return "warning";
237
+ case "P3":
238
+ return "suggestion";
239
+ }
240
+ }
241
+
242
+ function parseConfidence(value: unknown, fallback = 0.75): number {
243
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 ? value : fallback;
244
+ }
245
+
246
+ function summarizeTitle(text: string): string {
247
+ const firstLine = text
248
+ .split("\n")
249
+ .map((line) => line.trim())
250
+ .find(Boolean);
251
+ if (!firstLine) return "Review finding";
252
+ const stripped = firstLine.replace(/^#+\s*/, "").replace(/^\*\*(.*?)\*\*:?\s*$/, "$1").trim();
253
+ return stripped.length > 96 ? `${stripped.slice(0, 93).trimEnd()}…` : stripped;
254
+ }
255
+
256
+ function stripDuplicatedTitle(title: string, body: string): string {
257
+ const trimmedBody = body.trim();
258
+ if (!trimmedBody) return "";
259
+ const normalizedTitle = title.trim().replace(/[:.]+$/, "").toLowerCase();
260
+ const lines = trimmedBody.split("\n");
261
+ const first = (lines[0] || "")
262
+ .trim()
263
+ .replace(/^#+\s*/, "")
264
+ .replace(/^\*\*(.*?)\*\*:?\s*$/, "$1")
265
+ .replace(/[:.]+$/, "")
266
+ .toLowerCase();
267
+ if (normalizedTitle && first === normalizedTitle && lines.length > 1) return lines.slice(1).join("\n").trim();
268
+ return trimmedBody;
269
+ }
270
+
271
+ export function getCommentPriority(comment: Partial<Pick<ReviewComment, "priority" | "severity">>): FindingPriority {
272
+ return isFindingPriority(comment.priority) ? comment.priority : severityToPriority(comment.severity);
273
+ }
274
+
275
+ export function getCommentConfidence(comment: Partial<Pick<ReviewComment, "confidence">>): number {
276
+ return parseConfidence(comment.confidence);
277
+ }
278
+
279
+ export function getCommentTitle(comment: Partial<Pick<ReviewComment, "body" | "title">>): string {
280
+ return typeof comment.title === "string" && comment.title.trim() ? comment.title.trim() : summarizeTitle(comment.body || "");
281
+ }
282
+
283
+ export function getCommentBody(comment: Partial<Pick<ReviewComment, "body">>): string {
284
+ return typeof comment.body === "string" ? comment.body.trim() : "";
285
+ }
286
+
287
+ export function formatReviewCommentBody(comment: ReviewComment): string {
288
+ const title = getCommentTitle(comment);
289
+ const body = getCommentBody(comment);
290
+ return body ? `**${title}**\n\n${body}` : `**${title}**`;
291
+ }
292
+
293
+ export function parseReportFindingDetails(value: unknown): ReportFindingDetails | undefined {
294
+ if (!isRecord(value)) return undefined;
295
+
296
+ const title = typeof value.title === "string" && value.title.trim() ? value.title.trim() : undefined;
297
+ const body = typeof value.body === "string" && value.body.trim() ? value.body.trim() : undefined;
298
+ const priority = isFindingPriority(value.priority) ? value.priority : undefined;
299
+ const confidence = parseConfidence(value.confidence, Number.NaN);
300
+ const filePath = typeof value.file_path === "string" && value.file_path.trim() ? value.file_path.trim() : undefined;
301
+ const lineStart = typeof value.line_start === "number" && Number.isFinite(value.line_start) ? value.line_start : undefined;
302
+ const lineEnd = typeof value.line_end === "number" && Number.isFinite(value.line_end) ? value.line_end : undefined;
303
+
304
+ if (
305
+ title === undefined ||
306
+ body === undefined ||
307
+ priority === undefined ||
308
+ Number.isNaN(confidence) ||
309
+ filePath === undefined ||
310
+ lineStart === undefined ||
311
+ lineEnd === undefined
312
+ ) {
313
+ return undefined;
314
+ }
315
+
316
+ return { title, body, priority, confidence, file_path: filePath, line_start: lineStart, line_end: lineEnd };
317
+ }
318
+
319
+ function hydrateComment(comment: ReviewComment): ReviewComment {
320
+ const priority = getCommentPriority(comment);
321
+ const title = getCommentTitle(comment);
322
+ return {
323
+ ...comment,
324
+ title,
325
+ priority,
326
+ confidence: getCommentConfidence(comment),
327
+ severity: comment.severity && isCommentSeverity(comment.severity) ? comment.severity : priorityToSeverity(priority),
328
+ body: stripDuplicatedTitle(title, getCommentBody(comment)),
329
+ };
330
+ }
331
+
332
+ export function cloneSession(session: ReviewSession): ReviewSession {
333
+ return {
334
+ ...session,
335
+ comments: session.comments.map((comment) => hydrateComment({ ...comment })),
336
+ };
337
+ }
338
+
339
+ export function getLatestReviewSession(ctx: {
340
+ sessionManager: { getBranch(): Array<{ type: string; customType?: string; data?: unknown }> };
341
+ }): ReviewSession | null {
342
+ const entries = ctx.sessionManager.getBranch();
343
+ for (let i = entries.length - 1; i >= 0; i--) {
344
+ const entry = entries[i] as { type: string; customType?: string; data?: ReviewSession };
345
+ if (entry.type === "custom" && entry.customType === PERSIST_ENTRY_TYPE && entry.data?.target) {
346
+ return entry.data;
347
+ }
348
+ }
349
+ return null;
350
+ }
351
+
352
+ export function renderSummary(session: ReviewSession): string {
353
+ const comments = session.comments.map((comment) => hydrateComment(comment));
354
+ const counts = {
355
+ P0: comments.filter((c) => getCommentPriority(c) === "P0").length,
356
+ P1: comments.filter((c) => getCommentPriority(c) === "P1").length,
357
+ P2: comments.filter((c) => getCommentPriority(c) === "P2").length,
358
+ P3: comments.filter((c) => getCommentPriority(c) === "P3").length,
359
+ };
360
+
361
+ const parts = [
362
+ `Review for ${session.target.url}`,
363
+ session.summary ? `Summary: ${session.summary}` : undefined,
364
+ session.stats ? `Stats: ${session.stats}` : undefined,
365
+ `Findings: ${comments.length} total` +
366
+ (counts.P0 ? `, ${counts.P0} P0` : "") +
367
+ (counts.P1 ? `, ${counts.P1} P1` : "") +
368
+ (counts.P2 ? `, ${counts.P2} P2` : "") +
369
+ (counts.P3 ? `, ${counts.P3} P3` : ""),
370
+ ].filter(Boolean) as string[];
371
+
372
+ if (comments.length > 0) {
373
+ parts.push("");
374
+ for (const comment of comments) {
375
+ const end = comment.endLine && comment.endLine !== comment.line ? `-${comment.endLine}` : "";
376
+ parts.push(
377
+ `- ${comment.file}:${comment.line}${end} [${getCommentPriority(comment)} ${(getCommentConfidence(comment) * 100).toFixed(0)}%] ${getCommentTitle(comment)}`,
378
+ );
379
+ }
380
+ parts.push("");
381
+ parts.push("Open /review-tui to inspect, edit, toggle, and submit these findings.");
382
+ }
383
+ return parts.join("\n");
384
+ }
385
+
386
+ export function parseReviewUrl(rawUrl: string): ReviewTarget {
387
+ let url: URL;
388
+ try {
389
+ url = new URL(rawUrl.trim());
390
+ } catch {
391
+ throw new Error("Expected a valid GitHub or GitLab merge request / pull request URL");
392
+ }
393
+
394
+ const host = url.host;
395
+ const path = url.pathname.replace(/\/+$/, "");
396
+ const segments = path.split("/").filter(Boolean);
397
+
398
+ const githubPullIndex = segments.indexOf("pull");
399
+ if (githubPullIndex >= 0 && githubPullIndex >= 2) {
400
+ const repoPath = `${segments[0]}/${segments[1]}`;
401
+ const number = Number(segments[githubPullIndex + 1]);
402
+ if (!Number.isFinite(number)) throw new Error("Invalid GitHub pull request URL");
403
+ return { platform: "github", url: rawUrl.trim(), host, repoPath, number, headSha: "" };
404
+ }
405
+
406
+ const mrIndex = segments.indexOf("merge_requests");
407
+ if (mrIndex >= 0) {
408
+ const dashIndex = segments.indexOf("-");
409
+ const repoSegments = dashIndex >= 0 ? segments.slice(0, dashIndex) : segments.slice(0, mrIndex - 1);
410
+ const repoPath = repoSegments.join("/");
411
+ const number = Number(segments[mrIndex + 1]);
412
+ if (!repoPath || !Number.isFinite(number)) throw new Error("Invalid GitLab merge request URL");
413
+ return { platform: "gitlab", url: rawUrl.trim(), host, repoPath, number, headSha: "" };
414
+ }
415
+
416
+ throw new Error("URL must be a GitHub pull request or GitLab merge request URL");
417
+ }
418
+
419
+ function buildGithubFilePatch(file: {
420
+ filename: string;
421
+ patch?: string;
422
+ previous_filename?: string;
423
+ }): string {
424
+ const oldPath = file.previous_filename || file.filename;
425
+ const newPath = file.filename;
426
+ return [
427
+ `diff --git a/${oldPath} b/${newPath}`,
428
+ `--- a/${oldPath}`,
429
+ `+++ b/${newPath}`,
430
+ file.patch || "[patch unavailable: binary file or diff too large]",
431
+ ].join("\n");
432
+ }
433
+
434
+ function buildGitlabFilePatch(change: { old_path: string; new_path: string; diff?: string }): string {
435
+ return [
436
+ `diff --git a/${change.old_path} b/${change.new_path}`,
437
+ `--- a/${change.old_path}`,
438
+ `+++ b/${change.new_path}`,
439
+ change.diff || "[patch unavailable]",
440
+ ].join("\n");
441
+ }
442
+
443
+ export async function fetchReviewSource(exec: ExecFn, targetInput: ReviewTarget, signal?: AbortSignal): Promise<ReviewSource> {
444
+ if (targetInput.platform === "github") {
445
+ const [owner, repo] = targetInput.repoPath.split("/");
446
+ const metaResult = await exec("gh", ["api", "--hostname", targetInput.host, `repos/${owner}/${repo}/pulls/${targetInput.number}`], {
447
+ timeout: 30_000,
448
+ signal,
449
+ });
450
+ const filesResult = await exec(
451
+ "gh",
452
+ ["api", "--hostname", targetInput.host, "--paginate", "--slurp", `repos/${owner}/${repo}/pulls/${targetInput.number}/files?per_page=100`],
453
+ { timeout: 60_000, signal },
454
+ );
455
+
456
+ const meta = parseJson<{ title?: string; body?: string; head?: { sha?: string }; base?: { sha?: string } }>(
457
+ ensureOk(metaResult, "Failed to fetch GitHub PR metadata"),
458
+ );
459
+ const pages = parseJson<Array<Array<{ filename: string; patch?: string; previous_filename?: string }>>>(
460
+ ensureOk(filesResult, "Failed to fetch GitHub PR files"),
461
+ );
462
+ const files = pages.flat();
463
+ const patchByFile: Record<string, string> = {};
464
+ for (const file of files) patchByFile[file.filename] = buildGithubFilePatch(file);
465
+ return {
466
+ target: {
467
+ ...targetInput,
468
+ title: meta.title,
469
+ headSha: meta.head?.sha || "",
470
+ baseSha: meta.base?.sha,
471
+ },
472
+ title: meta.title,
473
+ description: meta.body,
474
+ diff: Object.values(patchByFile).join("\n\n"),
475
+ stats: `${files.length} changed file${files.length !== 1 ? "s" : ""}`,
476
+ patchByFile,
477
+ };
478
+ }
479
+
480
+ const encodedRepo = encodeURIComponent(targetInput.repoPath);
481
+ const metaResult = await exec(
482
+ "glab",
483
+ ["api", "--hostname", targetInput.host, `projects/${encodedRepo}/merge_requests/${targetInput.number}`],
484
+ { timeout: 30_000, signal },
485
+ );
486
+ const changesResult = await exec(
487
+ "glab",
488
+ ["api", "--hostname", targetInput.host, `projects/${encodedRepo}/merge_requests/${targetInput.number}/changes`],
489
+ { timeout: 60_000, signal },
490
+ );
491
+
492
+ const meta = parseJson<{ title?: string; description?: string; diff_refs?: { base_sha?: string; head_sha?: string; start_sha?: string } }>(
493
+ ensureOk(metaResult, "Failed to fetch GitLab MR metadata"),
494
+ );
495
+ const changesPayload = parseJson<{ changes?: Array<{ old_path: string; new_path: string; diff?: string }> }>(
496
+ ensureOk(changesResult, "Failed to fetch GitLab MR changes"),
497
+ );
498
+ const changes = changesPayload.changes || [];
499
+ const patchByFile: Record<string, string> = {};
500
+ for (const change of changes) patchByFile[change.new_path] = buildGitlabFilePatch(change);
501
+ return {
502
+ target: {
503
+ ...targetInput,
504
+ title: meta.title,
505
+ headSha: meta.diff_refs?.head_sha || "",
506
+ baseSha: meta.diff_refs?.base_sha,
507
+ startSha: meta.diff_refs?.start_sha,
508
+ },
509
+ title: meta.title,
510
+ description: meta.description,
511
+ diff: Object.values(patchByFile).join("\n\n"),
512
+ stats: `${changes.length} changed file${changes.length !== 1 ? "s" : ""}`,
513
+ patchByFile,
514
+ };
515
+ }
516
+
517
+ export function extractPatchContext(patch: string | undefined, targetLine: number, endLine?: number): string | undefined {
518
+ if (!patch) return undefined;
519
+ const lines = patch.split("\n");
520
+ const wantedStart = Math.min(targetLine, endLine ?? targetLine);
521
+ const wantedEnd = Math.max(targetLine, endLine ?? targetLine);
522
+ let i = 0;
523
+
524
+ while (i < lines.length) {
525
+ const header = lines[i]!;
526
+ const match = header.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
527
+ if (!match) {
528
+ i++;
529
+ continue;
530
+ }
531
+
532
+ let newLine = Number(match[1]);
533
+ const hunk: string[] = [header];
534
+ i++;
535
+ let contains = false;
536
+
537
+ while (i < lines.length && !lines[i]!.startsWith("@@ ")) {
538
+ const line = lines[i]!;
539
+ hunk.push(line);
540
+ if (line.startsWith("+") || line.startsWith(" ")) {
541
+ if (newLine >= wantedStart && newLine <= wantedEnd) contains = true;
542
+ newLine++;
543
+ }
544
+ i++;
545
+ }
546
+
547
+ if (contains) return hunk.join("\n");
548
+ }
549
+
550
+ return undefined;
551
+ }
552
+
553
+ export function annotateDiffWithLineNumbers(diff: string): string {
554
+ const lines = diff.split("\n");
555
+ const out: string[] = [];
556
+ let newLine: number | null = null;
557
+ for (const line of lines) {
558
+ if (line.startsWith("diff --git ")) {
559
+ newLine = null;
560
+ out.push(line);
561
+ continue;
562
+ }
563
+ const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
564
+ if (hunkMatch) {
565
+ newLine = Number(hunkMatch[1]);
566
+ out.push(line);
567
+ continue;
568
+ }
569
+ if (newLine == null) {
570
+ out.push(line);
571
+ continue;
572
+ }
573
+ if (line.startsWith("+") && !line.startsWith("+++")) {
574
+ out.push(`L${newLine}: ${line}`);
575
+ newLine++;
576
+ } else if (line.startsWith(" ")) {
577
+ out.push(`L${newLine}: ${line}`);
578
+ newLine++;
579
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
580
+ out.push(` ${line}`);
581
+ } else {
582
+ out.push(line);
583
+ }
584
+ }
585
+ return out.join("\n");
586
+ }
587
+
588
+ export function safeJsonParse(text: string): { summary?: string; comments?: Array<Record<string, unknown>> } {
589
+ try {
590
+ return JSON.parse(text) as { summary?: string; comments?: Array<Record<string, unknown>> };
591
+ } catch {
592
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
593
+ if (fenced) return JSON.parse(fenced);
594
+ const objectMatch = text.match(/\{[\s\S]*\}$/);
595
+ if (objectMatch) return JSON.parse(objectMatch[0]);
596
+ throw new Error("Model did not return valid JSON review output");
597
+ }
598
+ }
599
+
600
+ function getResponseText(response: AssistantMessage): string {
601
+ return response.content
602
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
603
+ .map((c) => c.text)
604
+ .join("\n")
605
+ .trim();
606
+ }
607
+
608
+ function getToolCalls(response: AssistantMessage): ToolCall[] {
609
+ return response.content.filter((c): c is ToolCall => c.type === "toolCall");
610
+ }
611
+
612
+ function parseSummary(text: string, fallback: string | undefined): string | undefined {
613
+ if (!text.trim()) return fallback;
614
+ try {
615
+ const parsed = safeJsonParse(text);
616
+ return typeof parsed.summary === "string" && parsed.summary.trim() ? parsed.summary.trim() : fallback;
617
+ } catch {
618
+ const firstLine = text
619
+ .split("\n")
620
+ .map((line) => line.trim())
621
+ .find(Boolean);
622
+ return firstLine || fallback;
623
+ }
624
+ }
625
+
626
+ function createToolResult(toolCall: ToolCall, content: string, details: ReportFindingDetails | undefined, isError = false): ToolResultMessage<ReportFindingDetails | undefined> {
627
+ return {
628
+ role: "toolResult",
629
+ toolCallId: toolCall.id,
630
+ toolName: toolCall.name,
631
+ content: [{ type: "text", text: content }],
632
+ details,
633
+ isError,
634
+ timestamp: Date.now(),
635
+ };
636
+ }
637
+
638
+ async function runStructuredReview(
639
+ model: any,
640
+ auth: { apiKey: string; headers?: Record<string, string> },
641
+ userMessage: UserMessage,
642
+ signal?: AbortSignal,
643
+ ): Promise<{ summary?: string; findings: ReportFindingDetails[]; finalText: string; usedToolCalls: boolean }> {
644
+ const messages: Array<UserMessage | AssistantMessage | ToolResultMessage> = [userMessage];
645
+ const findings: ReportFindingDetails[] = [];
646
+ let finalText = "";
647
+ let usedToolCalls = false;
648
+
649
+ for (let turn = 0; turn < 10; turn++) {
650
+ const response = await complete(
651
+ model,
652
+ { systemPrompt: REVIEW_SYSTEM_PROMPT, messages, tools: [REPORT_FINDING_TOOL] },
653
+ { apiKey: auth.apiKey, headers: auth.headers, signal },
654
+ );
655
+
656
+ if (response.stopReason === "aborted") throw new Error("Review aborted");
657
+ if (response.stopReason === "error") throw new Error(response.errorMessage || "Structured review failed");
658
+
659
+ messages.push(response);
660
+ const toolCalls = getToolCalls(response);
661
+ if (toolCalls.length === 0) {
662
+ finalText = getResponseText(response);
663
+ return {
664
+ summary: parseSummary(finalText, findings.length ? "Review generated" : "No actionable comments found"),
665
+ findings,
666
+ finalText,
667
+ usedToolCalls,
668
+ };
669
+ }
670
+
671
+ usedToolCalls = true;
672
+ for (const toolCall of toolCalls) {
673
+ if (toolCall.name !== REPORT_FINDING_TOOL_NAME) {
674
+ messages.push(createToolResult(toolCall, `Unknown tool: ${toolCall.name}`, undefined, true));
675
+ continue;
676
+ }
677
+
678
+ const details = parseReportFindingDetails(toolCall.arguments);
679
+ if (!details) {
680
+ messages.push(createToolResult(toolCall, "Invalid finding payload. Required: title, body, priority, confidence, file_path, line_start, line_end.", undefined, true));
681
+ continue;
682
+ }
683
+
684
+ if (findings.length >= MAX_COMMENTS) {
685
+ messages.push(createToolResult(toolCall, `Finding ignored: maximum of ${MAX_COMMENTS} findings already recorded.`, details));
686
+ continue;
687
+ }
688
+
689
+ findings.push(details);
690
+ const location = `${details.file_path}:${details.line_start}${details.line_end !== details.line_start ? `-${details.line_end}` : ""}`;
691
+ messages.push(
692
+ createToolResult(
693
+ toolCall,
694
+ `Finding recorded: ${details.priority} ${details.title}\nLocation: ${location}\nConfidence: ${(details.confidence * 100).toFixed(0)}%`,
695
+ details,
696
+ ),
697
+ );
698
+ }
699
+ }
700
+
701
+ throw new Error("Structured review did not finish after reporting findings");
702
+ }
703
+
704
+ async function runJsonReview(
705
+ model: any,
706
+ auth: { apiKey: string; headers?: Record<string, string> },
707
+ userMessage: UserMessage,
708
+ signal?: AbortSignal,
709
+ ): Promise<{ summary?: string; comments?: Array<Record<string, unknown>> }> {
710
+ const response = await complete(
711
+ model,
712
+ { systemPrompt: REVIEW_JSON_SYSTEM_PROMPT, messages: [userMessage] },
713
+ { apiKey: auth.apiKey, headers: auth.headers, signal },
714
+ );
715
+
716
+ if (response.stopReason === "aborted") throw new Error("Review aborted");
717
+ if (response.stopReason === "error") throw new Error(response.errorMessage || "Review failed");
718
+ return safeJsonParse(getResponseText(response));
719
+ }
720
+
721
+ export async function buildReviewSession(
722
+ exec: ExecFn,
723
+ model: any,
724
+ modelRegistry: {
725
+ getApiKeyAndHeaders(model: any): Promise<{ ok: boolean; apiKey?: string; headers?: Record<string, string>; error?: string }>;
726
+ },
727
+ url: string,
728
+ signal?: AbortSignal,
729
+ ): Promise<ReviewSession> {
730
+ const target = parseReviewUrl(url);
731
+ const source = await fetchReviewSource(exec, target, signal);
732
+ if (!source.diff.trim()) {
733
+ throw new Error("No diff available for this review target");
734
+ }
735
+
736
+ const truncation = truncateHead(source.diff, {
737
+ maxBytes: MAX_DIFF_BYTES,
738
+ maxLines: MAX_DIFF_LINES,
739
+ });
740
+ const rawDiff = truncation.truncated
741
+ ? `${truncation.content}\n\n[Diff truncated: showing ${truncation.outputLines}/${truncation.totalLines} lines, ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}]`
742
+ : truncation.content;
743
+ const diff = annotateDiffWithLineNumbers(rawDiff);
744
+
745
+ const auth = await modelRegistry.getApiKeyAndHeaders(model);
746
+ if (!auth.ok || !auth.apiKey) {
747
+ throw new Error(auth.ok ? `No API key for ${model.provider}` : auth.error || "Missing API key");
748
+ }
749
+
750
+ const userMessage: UserMessage = {
751
+ role: "user",
752
+ content: [
753
+ {
754
+ type: "text",
755
+ text:
756
+ `Target: ${source.target.url}\n` +
757
+ (source.title ? `Title: ${source.title}\n` : "") +
758
+ (source.description ? `Description:\n${source.description}\n\n` : "") +
759
+ (source.stats ? `Stats: ${source.stats}\n\n` : "") +
760
+ `Diff:\n\n${diff}`,
761
+ },
762
+ ],
763
+ timestamp: Date.now(),
764
+ };
765
+
766
+ let parsed: { summary?: string; comments?: Array<Record<string, unknown>> } | undefined;
767
+ let comments: ReviewComment[];
768
+ let summary: string | undefined;
769
+
770
+ try {
771
+ const structured = await runStructuredReview(model, { apiKey: auth.apiKey, headers: auth.headers }, userMessage, signal);
772
+ if (structured.usedToolCalls) {
773
+ comments = normalizeReportFindings(structured.findings, source.patchByFile);
774
+ summary = structured.summary;
775
+ } else {
776
+ parsed = safeJsonParse(structured.finalText);
777
+ comments = normalizeComments(parsed.comments, source.patchByFile);
778
+ summary = typeof parsed.summary === "string" ? parsed.summary : structured.summary;
779
+ }
780
+ } catch (error) {
781
+ // Some provider/model combinations do not support ad-hoc tool calls through complete().
782
+ // Fall back to the legacy JSON path while keeping the new priority/title/confidence schema.
783
+ parsed = await runJsonReview(model, { apiKey: auth.apiKey, headers: auth.headers }, userMessage, signal);
784
+ comments = normalizeComments(parsed.comments, source.patchByFile);
785
+ summary = typeof parsed.summary === "string" ? parsed.summary : undefined;
786
+ }
787
+
788
+ return {
789
+ target: source.target,
790
+ summary: summary || (comments.length ? "Review generated" : "No actionable comments found"),
791
+ comments,
792
+ stats: source.stats,
793
+ createdAt: Date.now(),
794
+ };
795
+ }
796
+
797
+ export function normalizeComments(raw: Array<Record<string, unknown>> | undefined, patchByFile: Record<string, string>): ReviewComment[] {
798
+ const comments: ReviewComment[] = [];
799
+ for (const [index, item] of (raw || []).entries()) {
800
+ const file = typeof item.file === "string" ? item.file : undefined;
801
+ const line = typeof item.line === "number" ? item.line : undefined;
802
+ const endLine = typeof item.endLine === "number" ? item.endLine : undefined;
803
+ const severity = isCommentSeverity(item.severity) ? item.severity : undefined;
804
+ const priority = isFindingPriority(item.priority) ? item.priority : severityToPriority(severity);
805
+ const confidence = parseConfidence(item.confidence);
806
+ const rawBody = typeof item.body === "string" ? item.body.trim() : undefined;
807
+ const title = typeof item.title === "string" && item.title.trim() ? item.title.trim() : rawBody ? summarizeTitle(rawBody) : undefined;
808
+ const body = title && rawBody ? stripDuplicatedTitle(title, rawBody) : rawBody;
809
+ if (!file || !line || !title || !body || !patchByFile[file]) continue;
810
+ comments.push({
811
+ id: `review-${Date.now()}-${index}`,
812
+ title,
813
+ file,
814
+ line,
815
+ endLine,
816
+ priority,
817
+ confidence,
818
+ severity: severity || priorityToSeverity(priority),
819
+ body,
820
+ codeContext: extractPatchContext(patchByFile[file], line, endLine),
821
+ status: "pending",
822
+ });
823
+ }
824
+ comments.sort((a, b) => getPriorityInfo(getCommentPriority(a)).ord - getPriorityInfo(getCommentPriority(b)).ord || a.file.localeCompare(b.file) || a.line - b.line);
825
+ return comments.slice(0, MAX_COMMENTS);
826
+ }
827
+
828
+ export function normalizeReportFindings(findings: ReportFindingDetails[], patchByFile: Record<string, string>): ReviewComment[] {
829
+ const comments: ReviewComment[] = [];
830
+ for (const [index, finding] of findings.entries()) {
831
+ const file = finding.file_path;
832
+ if (!patchByFile[file]) continue;
833
+ const line = finding.line_start;
834
+ const endLine = finding.line_end;
835
+ comments.push({
836
+ id: `review-${Date.now()}-${index}`,
837
+ title: finding.title,
838
+ file,
839
+ line,
840
+ endLine,
841
+ priority: finding.priority,
842
+ confidence: finding.confidence,
843
+ severity: priorityToSeverity(finding.priority),
844
+ body: stripDuplicatedTitle(finding.title, finding.body),
845
+ codeContext: extractPatchContext(patchByFile[file], line, endLine),
846
+ status: "pending",
847
+ });
848
+ }
849
+ comments.sort((a, b) => getPriorityInfo(getCommentPriority(a)).ord - getPriorityInfo(getCommentPriority(b)).ord || a.file.localeCompare(b.file) || a.line - b.line);
850
+ return comments.slice(0, MAX_COMMENTS);
851
+ }
852
+
853
+ export async function submitReviewComments(
854
+ exec: ExecFn,
855
+ target: ReviewTarget,
856
+ comments: ReviewComment[],
857
+ signal?: AbortSignal,
858
+ ): Promise<SubmissionResult> {
859
+ if (target.platform === "github") {
860
+ const [owner, repo] = target.repoPath.split("/");
861
+ let submitted = 0;
862
+ const failed: string[] = [];
863
+ for (const comment of comments) {
864
+ const startLine = comment.endLine ? Math.min(comment.line, comment.endLine) : undefined;
865
+ const endLine = comment.endLine ? Math.max(comment.line, comment.endLine) : comment.line;
866
+ const args = [
867
+ "api",
868
+ "--hostname",
869
+ target.host,
870
+ `repos/${owner}/${repo}/pulls/${target.number}/comments`,
871
+ "-f",
872
+ `body=${formatReviewCommentBody(comment)}`,
873
+ "-f",
874
+ `commit_id=${target.headSha}`,
875
+ "-f",
876
+ `path=${comment.file}`,
877
+ "-F",
878
+ `line=${endLine}`,
879
+ "-f",
880
+ "side=RIGHT",
881
+ ] as string[];
882
+ if (startLine !== undefined && startLine !== endLine) {
883
+ args.push("-F", `start_line=${startLine}`, "-f", "start_side=RIGHT");
884
+ }
885
+ const result = await exec("gh", args, { timeout: 30_000, signal });
886
+ if (result.code === 0) submitted++;
887
+ else failed.push(`${comment.file}:${comment.line} — ${result.stderr.trim() || "submission failed"}`);
888
+ }
889
+ return { submitted, failed };
890
+ }
891
+
892
+ const encodedRepo = encodeURIComponent(target.repoPath);
893
+ let submitted = 0;
894
+ const failed: string[] = [];
895
+ for (const comment of comments) {
896
+ const result = await exec(
897
+ "glab",
898
+ [
899
+ "api",
900
+ "--hostname",
901
+ target.host,
902
+ `projects/${encodedRepo}/merge_requests/${target.number}/discussions`,
903
+ "-f",
904
+ `body=${formatReviewCommentBody(comment)}`,
905
+ "-f",
906
+ "position[position_type]=text",
907
+ "-f",
908
+ `position[base_sha]=${target.baseSha || ""}`,
909
+ "-f",
910
+ `position[start_sha]=${target.startSha || target.baseSha || ""}`,
911
+ "-f",
912
+ `position[head_sha]=${target.headSha}`,
913
+ "-f",
914
+ `position[old_path]=${comment.file}`,
915
+ "-f",
916
+ `position[new_path]=${comment.file}`,
917
+ "-F",
918
+ `position[new_line]=${comment.line}`,
919
+ ],
920
+ { timeout: 30_000, signal },
921
+ );
922
+ if (result.code === 0) submitted++;
923
+ else failed.push(`${comment.file}:${comment.line} — ${result.stderr.trim() || "submission failed"}`);
924
+ }
925
+ return { submitted, failed };
926
+ }
927
+
928
+ export function persistReviewSession(pi: ExtensionAPI, session: ReviewSession): void {
929
+ pi.appendEntry(PERSIST_ENTRY_TYPE, session);
930
+ }