@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.
- package/.agents/skills/find-bugs/SKILL.md +75 -0
- package/.agents/skills/vercel-react-best-practices/AGENTS.md +2934 -0
- package/.agents/skills/vercel-react-best-practices/SKILL.md +136 -0
- package/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md +55 -0
- package/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md +42 -0
- package/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md +39 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md +38 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md +80 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md +51 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-parallel.md +28 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md +99 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md +59 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-conditional.md +31 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md +49 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md +35 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-preload.md +50 -0
- package/.agents/skills/vercel-react-best-practices/rules/client-event-listeners.md +74 -0
- package/.agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md +71 -0
- package/.agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md +48 -0
- package/.agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md +56 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md +107 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md +80 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md +28 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-cache-storage.md +70 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md +32 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-early-exit.md +50 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md +45 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-index-maps.md +37 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-length-check-first.md +49 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md +82 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md +24 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md +57 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-activity.md +26 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md +40 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md +38 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md +46 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +30 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md +28 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md +75 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md +39 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md +45 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +40 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md +29 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md +74 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md +58 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +38 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md +44 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +45 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +35 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md +40 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +73 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md +73 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md +96 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md +41 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md +76 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md +65 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md +83 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-serialization.md +38 -0
- package/.claude/settings.json +57 -0
- package/.claude/settings.local.json +88 -0
- package/.claude/skills/agent-prompt/SKILL.md +54 -0
- package/.claude/skills/agent-prompt/references/agentic-patterns.md +94 -0
- package/.claude/skills/agent-prompt/references/anti-patterns.md +140 -0
- package/.claude/skills/agent-prompt/references/context-design.md +124 -0
- package/.claude/skills/agent-prompt/references/core-principles.md +75 -0
- package/.claude/skills/agent-prompt/references/model-guidance.md +118 -0
- package/.claude/skills/agent-prompt/references/output-formats.md +98 -0
- package/.claude/skills/agent-prompt/references/skill-structure.md +115 -0
- package/.claude/skills/agent-prompt/references/system-prompts.md +115 -0
- package/.claude/skills/notseer/SKILL.md +131 -0
- package/.claude/skills/skill-writer/SKILL.md +140 -0
- package/.claude/skills/testing-guidelines/SKILL.md +132 -0
- package/.claude/skills/warden-skill/SKILL.md +250 -0
- package/.claude/skills/warden-skill/references/config-schema.md +133 -0
- package/.dex/config.toml +2 -0
- package/.github/workflows/ci.yml +33 -0
- package/.github/workflows/release.yml +54 -0
- package/.github/workflows/warden.yml +40 -0
- package/AGENTS.md +89 -0
- package/CONTRIBUTING.md +60 -0
- package/LICENSE +105 -0
- package/README.md +43 -0
- package/SPEC.md +263 -0
- package/action.yml +87 -0
- package/assets/favicon.png +0 -0
- package/assets/warden-icon-bw.svg +5 -0
- package/assets/warden-icon-purple.png +0 -0
- package/assets/warden-icon-purple.svg +5 -0
- package/docs/.claude/settings.local.json +11 -0
- package/docs/astro.config.mjs +43 -0
- package/docs/package.json +19 -0
- package/docs/pnpm-lock.yaml +4000 -0
- package/docs/public/favicon.svg +5 -0
- package/docs/src/components/Code.astro +141 -0
- package/docs/src/components/PackageManagerTabs.astro +183 -0
- package/docs/src/components/Terminal.astro +212 -0
- package/docs/src/layouts/Base.astro +380 -0
- package/docs/src/pages/cli.astro +167 -0
- package/docs/src/pages/config.astro +394 -0
- package/docs/src/pages/guide.astro +449 -0
- package/docs/src/pages/index.astro +490 -0
- package/docs/src/styles/global.css +551 -0
- package/docs/tsconfig.json +3 -0
- package/docs/vercel.json +5 -0
- package/eslint.config.js +33 -0
- package/package.json +73 -0
- package/src/action/index.ts +1 -0
- package/src/action/main.ts +868 -0
- package/src/cli/args.test.ts +477 -0
- package/src/cli/args.ts +415 -0
- package/src/cli/commands/add.ts +447 -0
- package/src/cli/commands/init.test.ts +136 -0
- package/src/cli/commands/init.ts +132 -0
- package/src/cli/commands/setup-app/browser.ts +38 -0
- package/src/cli/commands/setup-app/credentials.ts +45 -0
- package/src/cli/commands/setup-app/manifest.ts +48 -0
- package/src/cli/commands/setup-app/server.ts +172 -0
- package/src/cli/commands/setup-app.ts +156 -0
- package/src/cli/commands/sync.ts +114 -0
- package/src/cli/context.ts +131 -0
- package/src/cli/files.test.ts +155 -0
- package/src/cli/files.ts +89 -0
- package/src/cli/fix.test.ts +310 -0
- package/src/cli/fix.ts +387 -0
- package/src/cli/git.test.ts +119 -0
- package/src/cli/git.ts +318 -0
- package/src/cli/index.ts +14 -0
- package/src/cli/main.ts +672 -0
- package/src/cli/output/box.ts +235 -0
- package/src/cli/output/formatters.test.ts +187 -0
- package/src/cli/output/formatters.ts +269 -0
- package/src/cli/output/icons.ts +13 -0
- package/src/cli/output/index.ts +44 -0
- package/src/cli/output/ink-runner.tsx +337 -0
- package/src/cli/output/jsonl.test.ts +347 -0
- package/src/cli/output/jsonl.ts +126 -0
- package/src/cli/output/reporter.ts +435 -0
- package/src/cli/output/tasks.ts +374 -0
- package/src/cli/output/tty.test.ts +117 -0
- package/src/cli/output/tty.ts +60 -0
- package/src/cli/output/verbosity.test.ts +40 -0
- package/src/cli/output/verbosity.ts +31 -0
- package/src/cli/terminal.test.ts +148 -0
- package/src/cli/terminal.ts +301 -0
- package/src/config/index.ts +3 -0
- package/src/config/loader.test.ts +313 -0
- package/src/config/loader.ts +103 -0
- package/src/config/schema.ts +168 -0
- package/src/config/writer.test.ts +119 -0
- package/src/config/writer.ts +84 -0
- package/src/diff/classify.test.ts +162 -0
- package/src/diff/classify.ts +92 -0
- package/src/diff/coalesce.test.ts +208 -0
- package/src/diff/coalesce.ts +133 -0
- package/src/diff/context.test.ts +226 -0
- package/src/diff/context.ts +201 -0
- package/src/diff/index.ts +4 -0
- package/src/diff/parser.test.ts +212 -0
- package/src/diff/parser.ts +149 -0
- package/src/event/context.ts +132 -0
- package/src/event/index.ts +2 -0
- package/src/event/schedule-context.ts +101 -0
- package/src/examples/examples.integration.test.ts +66 -0
- package/src/examples/index.test.ts +101 -0
- package/src/examples/index.ts +122 -0
- package/src/examples/setup.ts +25 -0
- package/src/index.ts +115 -0
- package/src/output/dedup.test.ts +419 -0
- package/src/output/dedup.ts +607 -0
- package/src/output/github-checks.test.ts +300 -0
- package/src/output/github-checks.ts +476 -0
- package/src/output/github-issues.ts +329 -0
- package/src/output/index.ts +5 -0
- package/src/output/issue-renderer.ts +197 -0
- package/src/output/renderer.test.ts +727 -0
- package/src/output/renderer.ts +217 -0
- package/src/output/stale.test.ts +375 -0
- package/src/output/stale.ts +155 -0
- package/src/output/types.ts +34 -0
- package/src/sdk/index.ts +1 -0
- package/src/sdk/runner.test.ts +806 -0
- package/src/sdk/runner.ts +1232 -0
- package/src/skills/index.ts +36 -0
- package/src/skills/loader.test.ts +300 -0
- package/src/skills/loader.ts +423 -0
- package/src/skills/remote.test.ts +704 -0
- package/src/skills/remote.ts +604 -0
- package/src/triggers/matcher.test.ts +277 -0
- package/src/triggers/matcher.ts +152 -0
- package/src/types/index.ts +194 -0
- package/src/utils/async.ts +18 -0
- package/src/utils/index.test.ts +84 -0
- package/src/utils/index.ts +50 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +8 -0
- package/vitest.integration.config.ts +11 -0
- package/warden.toml +19 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { Octokit } from '@octokit/rest';
|
|
4
|
+
import type { SkillReport, Finding } from '../types/index.js';
|
|
5
|
+
import { renderIssueBody, renderNoFindingsUpdate } from './issue-renderer.js';
|
|
6
|
+
import { parsePatch } from '../diff/parser.js';
|
|
7
|
+
|
|
8
|
+
export interface IssueResult {
|
|
9
|
+
issueNumber: number;
|
|
10
|
+
issueUrl: string;
|
|
11
|
+
created: boolean; // true if new, false if updated
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CreateIssueOptions {
|
|
15
|
+
title: string;
|
|
16
|
+
commitSha: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create or update a GitHub issue with findings.
|
|
21
|
+
* Searches for existing open issue by title prefix, updates if found.
|
|
22
|
+
*/
|
|
23
|
+
export async function createOrUpdateIssue(
|
|
24
|
+
octokit: Octokit,
|
|
25
|
+
owner: string,
|
|
26
|
+
repo: string,
|
|
27
|
+
reports: SkillReport[],
|
|
28
|
+
options: CreateIssueOptions
|
|
29
|
+
): Promise<IssueResult | null> {
|
|
30
|
+
const { title, commitSha } = options;
|
|
31
|
+
const allFindings = reports.flatMap((r) => r.findings);
|
|
32
|
+
const now = new Date();
|
|
33
|
+
|
|
34
|
+
// Search for existing open issue with matching title
|
|
35
|
+
const existingIssue = await findExistingIssue(octokit, owner, repo, title);
|
|
36
|
+
|
|
37
|
+
// Render the issue body
|
|
38
|
+
const body = allFindings.length > 0
|
|
39
|
+
? renderIssueBody(reports, {
|
|
40
|
+
commitSha,
|
|
41
|
+
runTimestamp: now,
|
|
42
|
+
repoOwner: owner,
|
|
43
|
+
repoName: repo,
|
|
44
|
+
})
|
|
45
|
+
: renderNoFindingsUpdate(commitSha, now);
|
|
46
|
+
|
|
47
|
+
if (existingIssue) {
|
|
48
|
+
// Update existing issue
|
|
49
|
+
await octokit.issues.update({
|
|
50
|
+
owner,
|
|
51
|
+
repo,
|
|
52
|
+
issue_number: existingIssue.number,
|
|
53
|
+
body,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
issueNumber: existingIssue.number,
|
|
58
|
+
issueUrl: existingIssue.html_url,
|
|
59
|
+
created: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Skip creating new issue if no findings
|
|
64
|
+
if (allFindings.length === 0) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Create new issue
|
|
69
|
+
const { data: newIssue } = await octokit.issues.create({
|
|
70
|
+
owner,
|
|
71
|
+
repo,
|
|
72
|
+
title,
|
|
73
|
+
body,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
issueNumber: newIssue.number,
|
|
78
|
+
issueUrl: newIssue.html_url,
|
|
79
|
+
created: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function findExistingIssue(
|
|
84
|
+
octokit: Octokit,
|
|
85
|
+
owner: string,
|
|
86
|
+
repo: string,
|
|
87
|
+
title: string
|
|
88
|
+
): Promise<{ number: number; html_url: string } | null> {
|
|
89
|
+
try {
|
|
90
|
+
// Search for open issues with exact title match
|
|
91
|
+
const { data: issues } = await octokit.issues.listForRepo({
|
|
92
|
+
owner,
|
|
93
|
+
repo,
|
|
94
|
+
state: 'open',
|
|
95
|
+
per_page: 100,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const matching = issues.find((issue) => issue.title === title);
|
|
99
|
+
return matching ? { number: matching.number, html_url: matching.html_url } : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface FixPRResult {
|
|
106
|
+
prNumber: number;
|
|
107
|
+
prUrl: string;
|
|
108
|
+
branch: string;
|
|
109
|
+
fixCount: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface CreateFixPROptions {
|
|
113
|
+
branchPrefix: string;
|
|
114
|
+
baseBranch: string;
|
|
115
|
+
baseSha: string;
|
|
116
|
+
repoPath: string;
|
|
117
|
+
triggerName: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a PR with fixes applied.
|
|
122
|
+
* Uses GitHub Git API to create branch, apply changes, and open PR.
|
|
123
|
+
*/
|
|
124
|
+
export async function createFixPR(
|
|
125
|
+
octokit: Octokit,
|
|
126
|
+
owner: string,
|
|
127
|
+
repo: string,
|
|
128
|
+
findings: Finding[],
|
|
129
|
+
options: CreateFixPROptions
|
|
130
|
+
): Promise<FixPRResult | null> {
|
|
131
|
+
const { branchPrefix, baseBranch, baseSha, repoPath, triggerName } = options;
|
|
132
|
+
|
|
133
|
+
// Collect fixable findings (have suggestedFix.diff and location.path)
|
|
134
|
+
const fixable = findings.filter(
|
|
135
|
+
(f) => f.suggestedFix?.diff && f.location?.path
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (fixable.length === 0) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Group fixes by file
|
|
143
|
+
const fixesByFile = new Map<string, Finding[]>();
|
|
144
|
+
for (const finding of fixable) {
|
|
145
|
+
// We know location exists because of the filter above
|
|
146
|
+
const path = finding.location?.path;
|
|
147
|
+
if (!path) continue;
|
|
148
|
+
const existing = fixesByFile.get(path) ?? [];
|
|
149
|
+
existing.push(finding);
|
|
150
|
+
fixesByFile.set(path, existing);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Generate branch name with timestamp
|
|
154
|
+
const timestamp = Date.now();
|
|
155
|
+
const safeTriggerName = triggerName.replace(/[^a-zA-Z0-9-]/g, '-');
|
|
156
|
+
const branchName = `${branchPrefix}/${safeTriggerName}-${timestamp}`;
|
|
157
|
+
|
|
158
|
+
// Apply fixes and create blobs for modified files
|
|
159
|
+
const treeItems: {
|
|
160
|
+
path: string;
|
|
161
|
+
mode: '100644';
|
|
162
|
+
type: 'blob';
|
|
163
|
+
sha: string;
|
|
164
|
+
}[] = [];
|
|
165
|
+
|
|
166
|
+
let fixCount = 0;
|
|
167
|
+
|
|
168
|
+
for (const [filePath, fileFindings] of fixesByFile) {
|
|
169
|
+
try {
|
|
170
|
+
// Read current file content
|
|
171
|
+
const fullPath = join(repoPath, filePath);
|
|
172
|
+
let content = readFileSync(fullPath, 'utf-8');
|
|
173
|
+
|
|
174
|
+
// Sort findings by line number descending to apply from bottom to top
|
|
175
|
+
const sortedFindings = [...fileFindings].sort((a, b) => {
|
|
176
|
+
const aLine = a.location?.startLine ?? 0;
|
|
177
|
+
const bLine = b.location?.startLine ?? 0;
|
|
178
|
+
return bLine - aLine;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Apply each fix
|
|
182
|
+
for (const finding of sortedFindings) {
|
|
183
|
+
const diff = finding.suggestedFix?.diff;
|
|
184
|
+
if (!diff) continue;
|
|
185
|
+
try {
|
|
186
|
+
content = applyDiffToContent(content, diff);
|
|
187
|
+
fixCount++;
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error(`Failed to apply fix for ${finding.title}: ${err}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Create blob with modified content
|
|
194
|
+
const { data: blob } = await octokit.git.createBlob({
|
|
195
|
+
owner,
|
|
196
|
+
repo,
|
|
197
|
+
content: Buffer.from(content).toString('base64'),
|
|
198
|
+
encoding: 'base64',
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
treeItems.push({
|
|
202
|
+
path: filePath,
|
|
203
|
+
mode: '100644',
|
|
204
|
+
type: 'blob',
|
|
205
|
+
sha: blob.sha,
|
|
206
|
+
});
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.error(`Failed to process fixes for ${filePath}: ${err}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (treeItems.length === 0 || fixCount === 0) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Create tree with new blobs
|
|
217
|
+
const { data: tree } = await octokit.git.createTree({
|
|
218
|
+
owner,
|
|
219
|
+
repo,
|
|
220
|
+
base_tree: baseSha,
|
|
221
|
+
tree: treeItems,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Create commit
|
|
225
|
+
const { data: commit } = await octokit.git.createCommit({
|
|
226
|
+
owner,
|
|
227
|
+
repo,
|
|
228
|
+
message: `fix: Apply ${fixCount} automated ${fixCount === 1 ? 'fix' : 'fixes'} from Warden\n\nTrigger: ${triggerName}\n\nCo-Authored-By: Warden <noreply@getsentry.com>`,
|
|
229
|
+
tree: tree.sha,
|
|
230
|
+
parents: [baseSha],
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Create branch
|
|
234
|
+
await octokit.git.createRef({
|
|
235
|
+
owner,
|
|
236
|
+
repo,
|
|
237
|
+
ref: `refs/heads/${branchName}`,
|
|
238
|
+
sha: commit.sha,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Create PR
|
|
242
|
+
const { data: pr } = await octokit.pulls.create({
|
|
243
|
+
owner,
|
|
244
|
+
repo,
|
|
245
|
+
title: `fix: Warden automated fixes for ${triggerName}`,
|
|
246
|
+
head: branchName,
|
|
247
|
+
base: baseBranch,
|
|
248
|
+
body: [
|
|
249
|
+
'## Summary',
|
|
250
|
+
'',
|
|
251
|
+
`This PR contains ${fixCount} automated ${fixCount === 1 ? 'fix' : 'fixes'} generated by Warden.`,
|
|
252
|
+
'',
|
|
253
|
+
'### Applied Fixes',
|
|
254
|
+
'',
|
|
255
|
+
...fixable.map((f) => {
|
|
256
|
+
const path = f.location?.path ?? 'unknown';
|
|
257
|
+
const line = f.location?.startLine ?? 0;
|
|
258
|
+
return `- **${f.title}** (${path}:${line})`;
|
|
259
|
+
}),
|
|
260
|
+
'',
|
|
261
|
+
'---',
|
|
262
|
+
'*Generated by [Warden](https://github.com/getsentry/warden)*',
|
|
263
|
+
].join('\n'),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
prNumber: pr.number,
|
|
268
|
+
prUrl: pr.html_url,
|
|
269
|
+
branch: branchName,
|
|
270
|
+
fixCount,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Apply a unified diff to file content.
|
|
276
|
+
* Returns the modified content.
|
|
277
|
+
*/
|
|
278
|
+
function applyDiffToContent(content: string, diff: string): string {
|
|
279
|
+
const hunks = parsePatch(diff);
|
|
280
|
+
if (hunks.length === 0) {
|
|
281
|
+
throw new Error('No valid hunks found in diff');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const lines = content.split('\n');
|
|
285
|
+
|
|
286
|
+
// Sort hunks by oldStart in descending order to apply from bottom to top
|
|
287
|
+
const sortedHunks = [...hunks].sort((a, b) => b.oldStart - a.oldStart);
|
|
288
|
+
|
|
289
|
+
for (const hunk of sortedHunks) {
|
|
290
|
+
// Parse hunk lines into operations
|
|
291
|
+
const oldLines: string[] = [];
|
|
292
|
+
const newLines: string[] = [];
|
|
293
|
+
|
|
294
|
+
for (const line of hunk.lines) {
|
|
295
|
+
if (line.startsWith('-')) {
|
|
296
|
+
oldLines.push(line.slice(1));
|
|
297
|
+
} else if (line.startsWith('+')) {
|
|
298
|
+
newLines.push(line.slice(1));
|
|
299
|
+
} else if (line.startsWith(' ') || line === '') {
|
|
300
|
+
// Context line - should match in both
|
|
301
|
+
const contextLine = line.startsWith(' ') ? line.slice(1) : line;
|
|
302
|
+
oldLines.push(contextLine);
|
|
303
|
+
newLines.push(contextLine);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The start index is 0-based (hunk.oldStart is 1-based)
|
|
308
|
+
const startIndex = hunk.oldStart - 1;
|
|
309
|
+
|
|
310
|
+
// Verify the old lines match (context check)
|
|
311
|
+
for (let i = 0; i < oldLines.length; i++) {
|
|
312
|
+
const lineIndex = startIndex + i;
|
|
313
|
+
if (lineIndex >= lines.length) {
|
|
314
|
+
throw new Error(`Hunk context mismatch: line ${lineIndex + 1} doesn't exist`);
|
|
315
|
+
}
|
|
316
|
+
if (lines[lineIndex] !== oldLines[i]) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`Hunk context mismatch at line ${lineIndex + 1}: ` +
|
|
319
|
+
`expected "${oldLines[i]}", got "${lines[lineIndex]}"`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Replace the old lines with new lines
|
|
325
|
+
lines.splice(startIndex, oldLines.length, ...newLines);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return lines.join('\n');
|
|
329
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { SEVERITY_ORDER } from '../types/index.js';
|
|
2
|
+
import type { SkillReport, Finding, Severity } from '../types/index.js';
|
|
3
|
+
import { countBySeverity } from '../cli/output/formatters.js';
|
|
4
|
+
import { escapeHtml } from '../utils/index.js';
|
|
5
|
+
|
|
6
|
+
const SEVERITY_EMOJI: Record<Severity, string> = {
|
|
7
|
+
critical: ':rotating_light:',
|
|
8
|
+
high: ':warning:',
|
|
9
|
+
medium: ':orange_circle:',
|
|
10
|
+
low: ':large_blue_circle:',
|
|
11
|
+
info: ':information_source:',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export interface IssueRenderOptions {
|
|
15
|
+
/** Commit SHA for linking to code */
|
|
16
|
+
commitSha: string;
|
|
17
|
+
/** When the scan was run */
|
|
18
|
+
runTimestamp: Date;
|
|
19
|
+
/** Repository owner for constructing file links */
|
|
20
|
+
repoOwner?: string;
|
|
21
|
+
/** Repository name for constructing file links */
|
|
22
|
+
repoName?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Render skill reports as a GitHub issue body.
|
|
27
|
+
*/
|
|
28
|
+
export function renderIssueBody(
|
|
29
|
+
reports: SkillReport[],
|
|
30
|
+
options: IssueRenderOptions
|
|
31
|
+
): string {
|
|
32
|
+
const { commitSha, runTimestamp, repoOwner, repoName } = options;
|
|
33
|
+
const lines: string[] = [];
|
|
34
|
+
|
|
35
|
+
// Header with timestamp and commit
|
|
36
|
+
const shortSha = commitSha.slice(0, 7);
|
|
37
|
+
const timestamp = runTimestamp.toISOString();
|
|
38
|
+
|
|
39
|
+
lines.push('## Warden Scheduled Scan Results');
|
|
40
|
+
lines.push('');
|
|
41
|
+
lines.push(`**Run:** ${timestamp}`);
|
|
42
|
+
lines.push(`**Commit:** \`${shortSha}\``);
|
|
43
|
+
lines.push('');
|
|
44
|
+
|
|
45
|
+
// Collect all findings
|
|
46
|
+
const allFindings = reports.flatMap((r) => r.findings);
|
|
47
|
+
|
|
48
|
+
if (allFindings.length === 0) {
|
|
49
|
+
lines.push(':white_check_mark: **No issues found**');
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push('The scheduled scan completed without finding any issues.');
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push('---');
|
|
54
|
+
lines.push('*Generated by [Warden](https://github.com/getsentry/warden)*');
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Severity summary table
|
|
59
|
+
const counts = countBySeverity(allFindings);
|
|
60
|
+
lines.push('### Summary');
|
|
61
|
+
lines.push('');
|
|
62
|
+
lines.push('| Severity | Count |');
|
|
63
|
+
lines.push('|----------|-------|');
|
|
64
|
+
|
|
65
|
+
for (const severity of ['critical', 'high', 'medium', 'low', 'info'] as Severity[]) {
|
|
66
|
+
if (counts[severity] > 0) {
|
|
67
|
+
lines.push(`| ${SEVERITY_EMOJI[severity]} ${severity} | ${counts[severity]} |`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
lines.push('');
|
|
71
|
+
|
|
72
|
+
// Findings grouped by file
|
|
73
|
+
lines.push('### Findings');
|
|
74
|
+
lines.push('');
|
|
75
|
+
|
|
76
|
+
// Sort findings by severity, then by file
|
|
77
|
+
const sortedFindings = [...allFindings].sort((a, b) => {
|
|
78
|
+
const severityDiff = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
|
|
79
|
+
if (severityDiff !== 0) return severityDiff;
|
|
80
|
+
const aPath = a.location?.path ?? '';
|
|
81
|
+
const bPath = b.location?.path ?? '';
|
|
82
|
+
return aPath.localeCompare(bPath);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const byFile = groupFindingsByFile(sortedFindings);
|
|
86
|
+
const canLink = repoOwner && repoName;
|
|
87
|
+
|
|
88
|
+
for (const [file, fileFindings] of Object.entries(byFile)) {
|
|
89
|
+
if (canLink) {
|
|
90
|
+
lines.push(`#### [\`${file}\`](https://github.com/${repoOwner}/${repoName}/blob/${commitSha}/${file})`);
|
|
91
|
+
} else {
|
|
92
|
+
lines.push(`#### \`${file}\``);
|
|
93
|
+
}
|
|
94
|
+
lines.push('');
|
|
95
|
+
|
|
96
|
+
for (const finding of fileFindings) {
|
|
97
|
+
lines.push(renderFindingItem(finding, { commitSha, repoOwner, repoName }));
|
|
98
|
+
}
|
|
99
|
+
lines.push('');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// General findings (no location)
|
|
103
|
+
const noLocation = sortedFindings.filter((f) => !f.location);
|
|
104
|
+
if (noLocation.length > 0) {
|
|
105
|
+
lines.push('#### General');
|
|
106
|
+
lines.push('');
|
|
107
|
+
for (const finding of noLocation) {
|
|
108
|
+
lines.push(renderFindingItem(finding, { commitSha, repoOwner, repoName }));
|
|
109
|
+
}
|
|
110
|
+
lines.push('');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Per-skill summaries if multiple skills
|
|
114
|
+
if (reports.length > 1) {
|
|
115
|
+
lines.push('### Skill Summaries');
|
|
116
|
+
lines.push('');
|
|
117
|
+
for (const report of reports) {
|
|
118
|
+
lines.push(`**${report.skill}:** ${escapeHtml(report.summary)}`);
|
|
119
|
+
lines.push('');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Footer
|
|
124
|
+
lines.push('---');
|
|
125
|
+
lines.push('*Generated by [Warden](https://github.com/getsentry/warden)*');
|
|
126
|
+
|
|
127
|
+
return lines.join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function groupFindingsByFile(findings: Finding[]): Record<string, Finding[]> {
|
|
131
|
+
const groups: Record<string, Finding[]> = {};
|
|
132
|
+
for (const finding of findings) {
|
|
133
|
+
if (finding.location) {
|
|
134
|
+
const path = finding.location.path;
|
|
135
|
+
groups[path] ??= [];
|
|
136
|
+
groups[path].push(finding);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return groups;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatLineRange(loc: { startLine: number; endLine?: number }): string {
|
|
143
|
+
if (loc.endLine && loc.endLine !== loc.startLine) {
|
|
144
|
+
return `L${loc.startLine}-L${loc.endLine}`;
|
|
145
|
+
}
|
|
146
|
+
return `L${loc.startLine}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface LinkContext {
|
|
150
|
+
commitSha: string;
|
|
151
|
+
repoOwner?: string;
|
|
152
|
+
repoName?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderFindingItem(finding: Finding, ctx: LinkContext): string {
|
|
156
|
+
const { commitSha, repoOwner, repoName } = ctx;
|
|
157
|
+
const canLink = repoOwner && repoName && finding.location;
|
|
158
|
+
|
|
159
|
+
let locationStr = '';
|
|
160
|
+
if (finding.location) {
|
|
161
|
+
const lineRange = formatLineRange(finding.location);
|
|
162
|
+
if (canLink) {
|
|
163
|
+
const lineAnchor = finding.location.endLine
|
|
164
|
+
? `L${finding.location.startLine}-L${finding.location.endLine}`
|
|
165
|
+
: `L${finding.location.startLine}`;
|
|
166
|
+
locationStr = ` ([${lineRange}](https://github.com/${repoOwner}/${repoName}/blob/${commitSha}/${finding.location.path}#${lineAnchor}))`;
|
|
167
|
+
} else {
|
|
168
|
+
locationStr = ` (${lineRange})`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let line = `- ${SEVERITY_EMOJI[finding.severity]} **${escapeHtml(finding.title)}**${locationStr}`;
|
|
173
|
+
line += `\n ${escapeHtml(finding.description)}`;
|
|
174
|
+
|
|
175
|
+
if (finding.suggestedFix) {
|
|
176
|
+
line += `\n *Suggested fix:* ${escapeHtml(finding.suggestedFix.description)}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return line;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Render a brief status update for when no new findings are found.
|
|
184
|
+
*/
|
|
185
|
+
export function renderNoFindingsUpdate(commitSha: string, runTimestamp: Date): string {
|
|
186
|
+
const shortSha = commitSha.slice(0, 7);
|
|
187
|
+
const timestamp = runTimestamp.toISOString();
|
|
188
|
+
|
|
189
|
+
return [
|
|
190
|
+
'## Latest Scan: No Issues Found',
|
|
191
|
+
'',
|
|
192
|
+
`:white_check_mark: Scan completed at ${timestamp} (commit \`${shortSha}\`) with no issues.`,
|
|
193
|
+
'',
|
|
194
|
+
'---',
|
|
195
|
+
'*Generated by [Warden](https://github.com/getsentry/warden)*',
|
|
196
|
+
].join('\n');
|
|
197
|
+
}
|