@sentry/warden 0.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 (199) hide show
  1. package/.agents/skills/find-bugs/SKILL.md +75 -0
  2. package/.agents/skills/vercel-react-best-practices/AGENTS.md +2934 -0
  3. package/.agents/skills/vercel-react-best-practices/SKILL.md +136 -0
  4. package/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md +55 -0
  5. package/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md +42 -0
  6. package/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md +39 -0
  7. package/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md +38 -0
  8. package/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md +80 -0
  9. package/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md +51 -0
  10. package/.agents/skills/vercel-react-best-practices/rules/async-parallel.md +28 -0
  11. package/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md +99 -0
  12. package/.agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md +59 -0
  13. package/.agents/skills/vercel-react-best-practices/rules/bundle-conditional.md +31 -0
  14. package/.agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md +49 -0
  15. package/.agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md +35 -0
  16. package/.agents/skills/vercel-react-best-practices/rules/bundle-preload.md +50 -0
  17. package/.agents/skills/vercel-react-best-practices/rules/client-event-listeners.md +74 -0
  18. package/.agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md +71 -0
  19. package/.agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md +48 -0
  20. package/.agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md +56 -0
  21. package/.agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md +107 -0
  22. package/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md +80 -0
  23. package/.agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md +28 -0
  24. package/.agents/skills/vercel-react-best-practices/rules/js-cache-storage.md +70 -0
  25. package/.agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md +32 -0
  26. package/.agents/skills/vercel-react-best-practices/rules/js-early-exit.md +50 -0
  27. package/.agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md +45 -0
  28. package/.agents/skills/vercel-react-best-practices/rules/js-index-maps.md +37 -0
  29. package/.agents/skills/vercel-react-best-practices/rules/js-length-check-first.md +49 -0
  30. package/.agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md +82 -0
  31. package/.agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md +24 -0
  32. package/.agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md +57 -0
  33. package/.agents/skills/vercel-react-best-practices/rules/rendering-activity.md +26 -0
  34. package/.agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
  35. package/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md +40 -0
  36. package/.agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md +38 -0
  37. package/.agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md +46 -0
  38. package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
  39. package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +30 -0
  40. package/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md +28 -0
  41. package/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md +75 -0
  42. package/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md +39 -0
  43. package/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md +45 -0
  44. package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +40 -0
  45. package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md +29 -0
  46. package/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md +74 -0
  47. package/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md +58 -0
  48. package/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +38 -0
  49. package/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md +44 -0
  50. package/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +45 -0
  51. package/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +35 -0
  52. package/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md +40 -0
  53. package/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +73 -0
  54. package/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md +73 -0
  55. package/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md +96 -0
  56. package/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md +41 -0
  57. package/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md +76 -0
  58. package/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md +65 -0
  59. package/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md +83 -0
  60. package/.agents/skills/vercel-react-best-practices/rules/server-serialization.md +38 -0
  61. package/.claude/settings.json +57 -0
  62. package/.claude/settings.local.json +88 -0
  63. package/.claude/skills/agent-prompt/SKILL.md +54 -0
  64. package/.claude/skills/agent-prompt/references/agentic-patterns.md +94 -0
  65. package/.claude/skills/agent-prompt/references/anti-patterns.md +140 -0
  66. package/.claude/skills/agent-prompt/references/context-design.md +124 -0
  67. package/.claude/skills/agent-prompt/references/core-principles.md +75 -0
  68. package/.claude/skills/agent-prompt/references/model-guidance.md +118 -0
  69. package/.claude/skills/agent-prompt/references/output-formats.md +98 -0
  70. package/.claude/skills/agent-prompt/references/skill-structure.md +115 -0
  71. package/.claude/skills/agent-prompt/references/system-prompts.md +115 -0
  72. package/.claude/skills/notseer/SKILL.md +131 -0
  73. package/.claude/skills/skill-writer/SKILL.md +140 -0
  74. package/.claude/skills/testing-guidelines/SKILL.md +132 -0
  75. package/.claude/skills/warden-skill/SKILL.md +250 -0
  76. package/.claude/skills/warden-skill/references/config-schema.md +133 -0
  77. package/.dex/config.toml +2 -0
  78. package/.github/workflows/ci.yml +33 -0
  79. package/.github/workflows/release.yml +54 -0
  80. package/.github/workflows/warden.yml +40 -0
  81. package/AGENTS.md +89 -0
  82. package/CONTRIBUTING.md +60 -0
  83. package/LICENSE +105 -0
  84. package/README.md +43 -0
  85. package/SPEC.md +263 -0
  86. package/action.yml +87 -0
  87. package/assets/favicon.png +0 -0
  88. package/assets/warden-icon-bw.svg +5 -0
  89. package/assets/warden-icon-purple.png +0 -0
  90. package/assets/warden-icon-purple.svg +5 -0
  91. package/docs/.claude/settings.local.json +11 -0
  92. package/docs/astro.config.mjs +43 -0
  93. package/docs/package.json +19 -0
  94. package/docs/pnpm-lock.yaml +4000 -0
  95. package/docs/public/favicon.svg +5 -0
  96. package/docs/src/components/Code.astro +141 -0
  97. package/docs/src/components/PackageManagerTabs.astro +183 -0
  98. package/docs/src/components/Terminal.astro +212 -0
  99. package/docs/src/layouts/Base.astro +380 -0
  100. package/docs/src/pages/cli.astro +167 -0
  101. package/docs/src/pages/config.astro +394 -0
  102. package/docs/src/pages/guide.astro +449 -0
  103. package/docs/src/pages/index.astro +490 -0
  104. package/docs/src/styles/global.css +551 -0
  105. package/docs/tsconfig.json +3 -0
  106. package/docs/vercel.json +5 -0
  107. package/eslint.config.js +33 -0
  108. package/package.json +73 -0
  109. package/src/action/index.ts +1 -0
  110. package/src/action/main.ts +868 -0
  111. package/src/cli/args.test.ts +477 -0
  112. package/src/cli/args.ts +415 -0
  113. package/src/cli/commands/add.ts +447 -0
  114. package/src/cli/commands/init.test.ts +136 -0
  115. package/src/cli/commands/init.ts +132 -0
  116. package/src/cli/commands/setup-app/browser.ts +38 -0
  117. package/src/cli/commands/setup-app/credentials.ts +45 -0
  118. package/src/cli/commands/setup-app/manifest.ts +48 -0
  119. package/src/cli/commands/setup-app/server.ts +172 -0
  120. package/src/cli/commands/setup-app.ts +156 -0
  121. package/src/cli/commands/sync.ts +114 -0
  122. package/src/cli/context.ts +131 -0
  123. package/src/cli/files.test.ts +155 -0
  124. package/src/cli/files.ts +89 -0
  125. package/src/cli/fix.test.ts +310 -0
  126. package/src/cli/fix.ts +387 -0
  127. package/src/cli/git.test.ts +119 -0
  128. package/src/cli/git.ts +318 -0
  129. package/src/cli/index.ts +14 -0
  130. package/src/cli/main.ts +672 -0
  131. package/src/cli/output/box.ts +235 -0
  132. package/src/cli/output/formatters.test.ts +187 -0
  133. package/src/cli/output/formatters.ts +269 -0
  134. package/src/cli/output/icons.ts +13 -0
  135. package/src/cli/output/index.ts +44 -0
  136. package/src/cli/output/ink-runner.tsx +337 -0
  137. package/src/cli/output/jsonl.test.ts +347 -0
  138. package/src/cli/output/jsonl.ts +126 -0
  139. package/src/cli/output/reporter.ts +435 -0
  140. package/src/cli/output/tasks.ts +374 -0
  141. package/src/cli/output/tty.test.ts +117 -0
  142. package/src/cli/output/tty.ts +60 -0
  143. package/src/cli/output/verbosity.test.ts +40 -0
  144. package/src/cli/output/verbosity.ts +31 -0
  145. package/src/cli/terminal.test.ts +148 -0
  146. package/src/cli/terminal.ts +301 -0
  147. package/src/config/index.ts +3 -0
  148. package/src/config/loader.test.ts +313 -0
  149. package/src/config/loader.ts +103 -0
  150. package/src/config/schema.ts +168 -0
  151. package/src/config/writer.test.ts +119 -0
  152. package/src/config/writer.ts +84 -0
  153. package/src/diff/classify.test.ts +162 -0
  154. package/src/diff/classify.ts +92 -0
  155. package/src/diff/coalesce.test.ts +208 -0
  156. package/src/diff/coalesce.ts +133 -0
  157. package/src/diff/context.test.ts +226 -0
  158. package/src/diff/context.ts +201 -0
  159. package/src/diff/index.ts +4 -0
  160. package/src/diff/parser.test.ts +212 -0
  161. package/src/diff/parser.ts +149 -0
  162. package/src/event/context.ts +132 -0
  163. package/src/event/index.ts +2 -0
  164. package/src/event/schedule-context.ts +101 -0
  165. package/src/examples/examples.integration.test.ts +66 -0
  166. package/src/examples/index.test.ts +101 -0
  167. package/src/examples/index.ts +122 -0
  168. package/src/examples/setup.ts +25 -0
  169. package/src/index.ts +115 -0
  170. package/src/output/dedup.test.ts +419 -0
  171. package/src/output/dedup.ts +607 -0
  172. package/src/output/github-checks.test.ts +300 -0
  173. package/src/output/github-checks.ts +476 -0
  174. package/src/output/github-issues.ts +329 -0
  175. package/src/output/index.ts +5 -0
  176. package/src/output/issue-renderer.ts +197 -0
  177. package/src/output/renderer.test.ts +727 -0
  178. package/src/output/renderer.ts +217 -0
  179. package/src/output/stale.test.ts +375 -0
  180. package/src/output/stale.ts +155 -0
  181. package/src/output/types.ts +34 -0
  182. package/src/sdk/index.ts +1 -0
  183. package/src/sdk/runner.test.ts +806 -0
  184. package/src/sdk/runner.ts +1232 -0
  185. package/src/skills/index.ts +36 -0
  186. package/src/skills/loader.test.ts +300 -0
  187. package/src/skills/loader.ts +423 -0
  188. package/src/skills/remote.test.ts +704 -0
  189. package/src/skills/remote.ts +604 -0
  190. package/src/triggers/matcher.test.ts +277 -0
  191. package/src/triggers/matcher.ts +152 -0
  192. package/src/types/index.ts +194 -0
  193. package/src/utils/async.ts +18 -0
  194. package/src/utils/index.test.ts +84 -0
  195. package/src/utils/index.ts +50 -0
  196. package/tsconfig.json +25 -0
  197. package/vitest.config.ts +8 -0
  198. package/vitest.integration.config.ts +11 -0
  199. package/warden.toml +19 -0
@@ -0,0 +1,607 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { Octokit } from '@octokit/rest';
3
+ import Anthropic from '@anthropic-ai/sdk';
4
+ import { z } from 'zod';
5
+ import type { Finding } from '../types/index.js';
6
+
7
+ /**
8
+ * Parsed marker data from a Warden comment.
9
+ */
10
+ export interface WardenMarker {
11
+ path: string;
12
+ line: number;
13
+ contentHash: string;
14
+ }
15
+
16
+ /**
17
+ * Existing comment from GitHub (either Warden or external).
18
+ */
19
+ export interface ExistingComment {
20
+ id: number;
21
+ path: string;
22
+ line: number;
23
+ title: string;
24
+ description: string;
25
+ contentHash: string;
26
+ /** GraphQL node ID for the review thread (used to resolve stale comments) */
27
+ threadId?: string;
28
+ /** Whether the thread has been resolved (resolved comments are used for dedup but not stale detection) */
29
+ isResolved?: boolean;
30
+ /** Whether this is a Warden-generated comment */
31
+ isWarden?: boolean;
32
+ /** Skills that have already detected this issue (for Warden comments) */
33
+ skills?: string[];
34
+ /** The raw comment body (needed for updating Warden comments) */
35
+ body?: string;
36
+ /** GraphQL node ID for the comment (needed for adding reactions) */
37
+ commentNodeId?: string;
38
+ }
39
+
40
+ /**
41
+ * Type of action to take for a duplicate finding.
42
+ */
43
+ export type DuplicateActionType = 'update_warden' | 'react_external';
44
+
45
+ /**
46
+ * Action to take for a duplicate finding.
47
+ */
48
+ export interface DuplicateAction {
49
+ type: DuplicateActionType;
50
+ finding: Finding;
51
+ existingComment: ExistingComment;
52
+ /** Whether this was a hash match or semantic match */
53
+ matchType: 'hash' | 'semantic';
54
+ }
55
+
56
+ /**
57
+ * Result of deduplication with actions for duplicates.
58
+ */
59
+ export interface DeduplicateResult {
60
+ /** Findings that are not duplicates - should be posted */
61
+ newFindings: Finding[];
62
+ /** Actions to take for duplicate findings */
63
+ duplicateActions: DuplicateAction[];
64
+ }
65
+
66
+ /**
67
+ * Generate a short content hash from title and description.
68
+ * Used for exact-match deduplication.
69
+ */
70
+ export function generateContentHash(title: string, description: string): string {
71
+ const content = `${title}\n${description}`;
72
+ return createHash('sha256').update(content).digest('hex').slice(0, 8);
73
+ }
74
+
75
+ /**
76
+ * Generate the marker HTML comment to embed in comment body.
77
+ * Format: <!-- warden:v1:{path}:{line}:{contentHash} -->
78
+ */
79
+ export function generateMarker(path: string, line: number, contentHash: string): string {
80
+ return `<!-- warden:v1:${path}:${line}:${contentHash} -->`;
81
+ }
82
+
83
+ /**
84
+ * Parse a Warden marker from a comment body.
85
+ * Returns null if no valid marker is found.
86
+ */
87
+ export function parseMarker(body: string): WardenMarker | null {
88
+ const match = body.match(/<!-- warden:v1:([^:]+):(\d+):([a-f0-9]+) -->/);
89
+ if (!match) {
90
+ return null;
91
+ }
92
+
93
+ // Capture groups are guaranteed to exist when the regex matches
94
+ const [, path, lineStr, contentHash] = match as [string, string, string, string];
95
+
96
+ return {
97
+ path,
98
+ line: parseInt(lineStr, 10),
99
+ contentHash,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Parse title and description from a Warden comment body.
105
+ * Expected format: **:emoji: Title**\n\nDescription
106
+ */
107
+ export function parseWardenComment(body: string): { title: string; description: string } | null {
108
+ // Match the title pattern: **:emoji: Title** or **Title**
109
+ // Use non-greedy match to handle titles containing asterisks
110
+ const titleMatch = body.match(/\*\*(?::[a-z_]+:\s*)?(.+?)\*\*/);
111
+ if (!titleMatch || !titleMatch[1]) {
112
+ return null;
113
+ }
114
+
115
+ const title = titleMatch[1].trim();
116
+
117
+ // Get the description - everything after the title until the first ---
118
+ const titleEnd = body.indexOf('**', body.indexOf('**') + 2) + 2;
119
+ const separatorIndex = body.indexOf('---');
120
+ const descEnd = separatorIndex > -1 ? separatorIndex : body.length;
121
+
122
+ const description = body.slice(titleEnd, descEnd).trim();
123
+
124
+ return { title, description };
125
+ }
126
+
127
+ /**
128
+ * Check if a comment body is a Warden-generated comment.
129
+ */
130
+ export function isWardenComment(body: string): boolean {
131
+ return body.includes('<sub>warden:') || body.includes('<!-- warden:v1:');
132
+ }
133
+
134
+ /**
135
+ * Parse skill names from a Warden comment's attribution line.
136
+ * Handles both single skill: "<sub>warden: skill-name</sub>"
137
+ * And multiple skills: "<sub>warden: skill1, skill2</sub>"
138
+ */
139
+ export function parseWardenSkills(body: string): string[] {
140
+ const match = body.match(/<sub>warden:\s*([^<]+)<\/sub>/);
141
+ if (!match || !match[1]) {
142
+ return [];
143
+ }
144
+ return match[1]
145
+ .split(',')
146
+ .map((s) => s.trim())
147
+ .filter(Boolean);
148
+ }
149
+
150
+ /**
151
+ * Update a Warden comment body to add a new skill to the attribution.
152
+ * Changes "<sub>warden: skill1</sub>" to "<sub>warden: skill1, skill2</sub>"
153
+ * Returns null if skill is already listed or if no <sub>warden:...</sub> tag exists.
154
+ */
155
+ export function updateWardenCommentBody(body: string, newSkill: string): string | null {
156
+ const existingSkills = parseWardenSkills(body);
157
+
158
+ // If no existing <sub>warden:...</sub> tag exists, we can't update it
159
+ if (existingSkills.length === 0) {
160
+ return null;
161
+ }
162
+
163
+ // Don't update if skill already listed
164
+ if (existingSkills.includes(newSkill)) {
165
+ return null;
166
+ }
167
+
168
+ const allSkills = [...existingSkills, newSkill].join(', ');
169
+ // Use a replacer function to avoid special $ character interpretation in skill names
170
+ return body.replace(/<sub>warden:\s*[^<]+<\/sub>/, () => `<sub>warden: ${allSkills}</sub>`);
171
+ }
172
+
173
+ /** GraphQL response structure for review threads */
174
+ interface ReviewThreadNode {
175
+ id: string;
176
+ isResolved: boolean;
177
+ comments: {
178
+ nodes: {
179
+ id: string; // GraphQL node ID (for reactions)
180
+ databaseId: number;
181
+ body: string;
182
+ path: string;
183
+ line: number | null;
184
+ originalLine: number | null;
185
+ }[];
186
+ };
187
+ }
188
+
189
+ interface ReviewThreadsResponse {
190
+ repository?: {
191
+ pullRequest?: {
192
+ reviewThreads: {
193
+ pageInfo: {
194
+ hasNextPage: boolean;
195
+ endCursor: string | null;
196
+ };
197
+ nodes: ReviewThreadNode[];
198
+ };
199
+ } | null;
200
+ } | null;
201
+ }
202
+
203
+ const REVIEW_THREADS_QUERY = `
204
+ query($owner: String!, $repo: String!, $prNumber: Int!, $cursor: String) {
205
+ repository(owner: $owner, name: $repo) {
206
+ pullRequest(number: $prNumber) {
207
+ reviewThreads(first: 100, after: $cursor) {
208
+ pageInfo {
209
+ hasNextPage
210
+ endCursor
211
+ }
212
+ nodes {
213
+ id
214
+ isResolved
215
+ comments(first: 1) {
216
+ nodes {
217
+ id
218
+ databaseId
219
+ body
220
+ path
221
+ line
222
+ originalLine
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }
228
+ }
229
+ }
230
+ `;
231
+
232
+ /**
233
+ * Fetch all existing review comments for a PR (both Warden and external).
234
+ * Uses GraphQL to get thread IDs for stale comment resolution and node IDs for reactions.
235
+ */
236
+ export async function fetchExistingComments(
237
+ octokit: Octokit,
238
+ owner: string,
239
+ repo: string,
240
+ prNumber: number
241
+ ): Promise<ExistingComment[]> {
242
+ const comments: ExistingComment[] = [];
243
+
244
+ // Use GraphQL to get thread IDs along with comment data
245
+ let cursor: string | null = null;
246
+ let hasNextPage = true;
247
+
248
+ while (hasNextPage) {
249
+ const response: ReviewThreadsResponse = await octokit.graphql(REVIEW_THREADS_QUERY, {
250
+ owner,
251
+ repo,
252
+ prNumber,
253
+ cursor,
254
+ });
255
+
256
+ const pullRequest = response.repository?.pullRequest;
257
+ if (!pullRequest) {
258
+ // PR doesn't exist or was deleted
259
+ return comments;
260
+ }
261
+
262
+ const threads = pullRequest.reviewThreads;
263
+
264
+ for (const thread of threads.nodes) {
265
+ // Get the first comment in the thread
266
+ const firstComment = thread.comments.nodes[0];
267
+ if (!firstComment) {
268
+ continue;
269
+ }
270
+
271
+ const isWarden = isWardenComment(firstComment.body);
272
+ const marker = isWarden ? parseMarker(firstComment.body) : null;
273
+ const parsed = parseWardenComment(firstComment.body);
274
+
275
+ // For Warden comments, we need parsed title/description
276
+ // For external comments, we extract what we can or use body as description
277
+ const title = parsed?.title ?? '';
278
+ const description = parsed?.description ?? firstComment.body.slice(0, 500);
279
+
280
+ comments.push({
281
+ id: firstComment.databaseId,
282
+ path: marker?.path ?? firstComment.path,
283
+ line: marker?.line ?? firstComment.line ?? firstComment.originalLine ?? 0,
284
+ title,
285
+ description,
286
+ contentHash: marker?.contentHash ?? generateContentHash(title, description),
287
+ threadId: thread.id,
288
+ isResolved: thread.isResolved,
289
+ isWarden,
290
+ skills: isWarden ? parseWardenSkills(firstComment.body) : undefined,
291
+ body: firstComment.body,
292
+ commentNodeId: firstComment.id,
293
+ });
294
+ }
295
+
296
+ hasNextPage = threads.pageInfo.hasNextPage;
297
+ cursor = threads.pageInfo.endCursor;
298
+ }
299
+
300
+ return comments;
301
+ }
302
+
303
+ /**
304
+ * @deprecated Use fetchExistingComments instead
305
+ */
306
+ export async function fetchExistingWardenComments(
307
+ octokit: Octokit,
308
+ owner: string,
309
+ repo: string,
310
+ prNumber: number
311
+ ): Promise<ExistingComment[]> {
312
+ const allComments = await fetchExistingComments(octokit, owner, repo, prNumber);
313
+ return allComments.filter((c) => c.isWarden);
314
+ }
315
+
316
+ /** Schema for validating LLM deduplication response with matched indices */
317
+ const DuplicateMatchesSchema = z.array(
318
+ z.object({
319
+ findingIndex: z.number().int(),
320
+ existingIndex: z.number().int(),
321
+ })
322
+ );
323
+
324
+ /**
325
+ * Use LLM to identify which findings are semantic duplicates of existing comments.
326
+ * Returns a Map of finding ID to matched ExistingComment.
327
+ */
328
+ async function findSemanticDuplicates(
329
+ findings: Finding[],
330
+ existingComments: ExistingComment[],
331
+ apiKey: string
332
+ ): Promise<Map<string, ExistingComment>> {
333
+ if (findings.length === 0 || existingComments.length === 0) {
334
+ return new Map();
335
+ }
336
+
337
+ const client = new Anthropic({ apiKey });
338
+
339
+ const existingList = existingComments
340
+ .map((c, i) => `${i + 1}. [${c.path}:${c.line}] "${c.title}" - ${c.description}`)
341
+ .join('\n');
342
+
343
+ const findingsList = findings
344
+ .map((f, i) => {
345
+ const line = f.location?.endLine ?? f.location?.startLine;
346
+ const loc = f.location ? `${f.location.path}:${line}` : 'general';
347
+ return `${i + 1}. [${loc}] "${f.title}" - ${f.description}`;
348
+ })
349
+ .join('\n');
350
+
351
+ const prompt = `Compare these code review findings and identify duplicates.
352
+
353
+ Existing comments:
354
+ ${existingList}
355
+
356
+ New findings:
357
+ ${findingsList}
358
+
359
+ Return a JSON array of objects identifying which findings are DUPLICATES of which existing comments.
360
+ Only mark as duplicate if they describe the SAME issue at the SAME location (within a few lines).
361
+ Different issues at the same location are NOT duplicates.
362
+
363
+ Return ONLY the JSON array in this format:
364
+ [{"findingIndex": 1, "existingIndex": 2}]
365
+ where findingIndex is the 1-based index of the new finding and existingIndex is the 1-based index of the matching existing comment.
366
+ Return [] if none are duplicates.`;
367
+
368
+ try {
369
+ const response = await client.messages.create({
370
+ model: 'claude-haiku-4-5',
371
+ max_tokens: 512,
372
+ messages: [{ role: 'user', content: prompt }],
373
+ });
374
+
375
+ const content = response.content[0];
376
+ if (!content || content.type !== 'text') {
377
+ throw new Error('Unexpected response type');
378
+ }
379
+
380
+ const parsed = DuplicateMatchesSchema.parse(JSON.parse(content.text));
381
+ const matches = new Map<string, ExistingComment>();
382
+
383
+ for (const match of parsed) {
384
+ const finding = findings[match.findingIndex - 1];
385
+ const existing = existingComments[match.existingIndex - 1];
386
+ if (finding && existing) {
387
+ matches.set(finding.id, existing);
388
+ }
389
+ }
390
+
391
+ return matches;
392
+ } catch (error) {
393
+ console.warn(`LLM deduplication failed, falling back to hash-only: ${error}`);
394
+ return new Map();
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Options for deduplication.
400
+ */
401
+ export interface DeduplicateOptions {
402
+ /** Anthropic API key for LLM-based semantic deduplication */
403
+ apiKey?: string;
404
+ /** Skip LLM deduplication and only use exact hash matching */
405
+ hashOnly?: boolean;
406
+ /** Current skill name (for updating Warden comment attribution) */
407
+ currentSkill?: string;
408
+ }
409
+
410
+ const ADD_REACTION_MUTATION = `
411
+ mutation($subjectId: ID!, $content: ReactionContent!) {
412
+ addReaction(input: { subjectId: $subjectId, content: $content }) {
413
+ reaction {
414
+ content
415
+ }
416
+ }
417
+ }
418
+ `;
419
+
420
+ /**
421
+ * Update an existing Warden PR review comment via REST API.
422
+ */
423
+ export async function updateWardenComment(
424
+ octokit: Octokit,
425
+ owner: string,
426
+ repo: string,
427
+ commentId: number,
428
+ newBody: string
429
+ ): Promise<void> {
430
+ await octokit.pulls.updateReviewComment({
431
+ owner,
432
+ repo,
433
+ comment_id: commentId,
434
+ body: newBody,
435
+ });
436
+ }
437
+
438
+ /**
439
+ * Add a reaction to an existing PR review comment.
440
+ * Uses GraphQL to handle review comments.
441
+ */
442
+ export async function addReactionToComment(
443
+ octokit: Octokit,
444
+ commentNodeId: string,
445
+ reaction: 'THUMBS_UP' | 'EYES' = 'EYES'
446
+ ): Promise<void> {
447
+ await octokit.graphql(ADD_REACTION_MUTATION, {
448
+ subjectId: commentNodeId,
449
+ content: reaction,
450
+ });
451
+ }
452
+
453
+ /**
454
+ * Process duplicate actions - update Warden comments and add reactions.
455
+ * Returns counts of actions taken for logging.
456
+ */
457
+ export async function processDuplicateActions(
458
+ octokit: Octokit,
459
+ owner: string,
460
+ repo: string,
461
+ actions: DuplicateAction[],
462
+ currentSkill: string
463
+ ): Promise<{ updated: number; reacted: number; skipped: number; failed: number }> {
464
+ let updated = 0;
465
+ let reacted = 0;
466
+ let skipped = 0;
467
+ let failed = 0;
468
+
469
+ for (const action of actions) {
470
+ try {
471
+ if (action.type === 'update_warden') {
472
+ if (!action.existingComment.body) {
473
+ skipped++;
474
+ continue;
475
+ }
476
+ const newBody = updateWardenCommentBody(action.existingComment.body, currentSkill);
477
+ // Only update if body actually changed (skill wasn't already listed)
478
+ if (newBody) {
479
+ await updateWardenComment(octokit, owner, repo, action.existingComment.id, newBody);
480
+ // Update in-memory body so subsequent triggers see the updated content
481
+ action.existingComment.body = newBody;
482
+ updated++;
483
+ } else {
484
+ skipped++;
485
+ }
486
+ } else if (action.type === 'react_external') {
487
+ if (!action.existingComment.commentNodeId) {
488
+ skipped++;
489
+ continue;
490
+ }
491
+ await addReactionToComment(octokit, action.existingComment.commentNodeId);
492
+ reacted++;
493
+ }
494
+ } catch (error) {
495
+ console.warn(`Failed to process duplicate action for ${action.finding.title}: ${error}`);
496
+ failed++;
497
+ }
498
+ }
499
+
500
+ return { updated, reacted, skipped, failed };
501
+ }
502
+
503
+ /**
504
+ * Convert a Finding to an ExistingComment for cross-trigger deduplication.
505
+ * Returns null if the finding has no location.
506
+ */
507
+ export function findingToExistingComment(finding: Finding, skill?: string): ExistingComment | null {
508
+ if (!finding.location) {
509
+ return null;
510
+ }
511
+
512
+ return {
513
+ id: -1, // Newly posted comments don't have IDs yet
514
+ path: finding.location.path,
515
+ line: finding.location.endLine ?? finding.location.startLine,
516
+ title: finding.title,
517
+ description: finding.description,
518
+ contentHash: generateContentHash(finding.title, finding.description),
519
+ isWarden: true,
520
+ skills: skill ? [skill] : [],
521
+ };
522
+ }
523
+
524
+ /**
525
+ * Deduplicate findings against existing comments.
526
+ * Returns non-duplicate findings and actions to take for duplicates.
527
+ *
528
+ * Deduplication is two-pass:
529
+ * 1. Exact content hash match - instant match
530
+ * 2. LLM semantic comparison for remaining findings (if API key provided)
531
+ *
532
+ * For duplicates:
533
+ * - If matching a Warden comment: action to update attribution with new skill
534
+ * - If matching an external comment: action to add reaction
535
+ */
536
+ export async function deduplicateFindings(
537
+ findings: Finding[],
538
+ existingComments: ExistingComment[],
539
+ options: DeduplicateOptions = {}
540
+ ): Promise<DeduplicateResult> {
541
+ if (findings.length === 0 || existingComments.length === 0) {
542
+ return { newFindings: findings, duplicateActions: [] };
543
+ }
544
+
545
+ // Build a map of existing comments by location+hash for fast lookup
546
+ const existingByKey = new Map<string, ExistingComment>();
547
+ for (const c of existingComments) {
548
+ const key = `${c.path}:${c.line}:${c.contentHash}`;
549
+ existingByKey.set(key, c);
550
+ }
551
+
552
+ // First pass: find exact matches (same content at same location)
553
+ const hashDedupedFindings: Finding[] = [];
554
+ const duplicateActions: DuplicateAction[] = [];
555
+
556
+ for (const finding of findings) {
557
+ const hash = generateContentHash(finding.title, finding.description);
558
+ const line = finding.location?.endLine ?? finding.location?.startLine ?? 0;
559
+ const path = finding.location?.path ?? '';
560
+ const key = `${path}:${line}:${hash}`;
561
+
562
+ const matchingComment = existingByKey.get(key);
563
+ if (matchingComment) {
564
+ duplicateActions.push({
565
+ type: matchingComment.isWarden ? 'update_warden' : 'react_external',
566
+ finding,
567
+ existingComment: matchingComment,
568
+ matchType: 'hash',
569
+ });
570
+ } else {
571
+ hashDedupedFindings.push(finding);
572
+ }
573
+ }
574
+
575
+ if (duplicateActions.length > 0) {
576
+ console.log(`Dedup: ${duplicateActions.length} findings matched by content hash`);
577
+ }
578
+
579
+ // If hash-only mode, no API key, or no remaining findings, stop here
580
+ if (options.hashOnly || !options.apiKey || hashDedupedFindings.length === 0) {
581
+ return { newFindings: hashDedupedFindings, duplicateActions };
582
+ }
583
+
584
+ // Second pass: LLM semantic comparison for remaining findings
585
+ const semanticMatches = await findSemanticDuplicates(hashDedupedFindings, existingComments, options.apiKey);
586
+
587
+ if (semanticMatches.size > 0) {
588
+ console.log(`Dedup: ${semanticMatches.size} findings identified as semantic duplicates by LLM`);
589
+ }
590
+
591
+ const newFindings: Finding[] = [];
592
+ for (const finding of hashDedupedFindings) {
593
+ const matchingComment = semanticMatches.get(finding.id);
594
+ if (matchingComment) {
595
+ duplicateActions.push({
596
+ type: matchingComment.isWarden ? 'update_warden' : 'react_external',
597
+ finding,
598
+ existingComment: matchingComment,
599
+ matchType: 'semantic',
600
+ });
601
+ } else {
602
+ newFindings.push(finding);
603
+ }
604
+ }
605
+
606
+ return { newFindings, duplicateActions };
607
+ }