Skip to content

Commit dec1964

Browse files
Update dependabot-cursor-review
1 parent ec6c449 commit dec1964

File tree

1 file changed

+384
-0
lines changed

1 file changed

+384
-0
lines changed
Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
# Managed by repo-content-updater
2+
# Dependabot Cursor Review workflow
3+
#
4+
# Runs Cursor CLI analysis for Dependabot PRs by using:
5+
# - Dependabot PR body release notes + commit list
6+
# - An upstream dependency checkout
7+
# - Local usage hints in the target repo
8+
#
9+
# Source documentation: https://cursor.com/docs/cli/github-actions
10+
name: Dependabot Cursor Review
11+
12+
on:
13+
pull_request:
14+
types: [opened, synchronize, reopened]
15+
workflow_dispatch:
16+
inputs:
17+
pr_number:
18+
description: "Dependabot PR number to analyze"
19+
required: true
20+
type: number
21+
22+
concurrency:
23+
group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', github.workflow_ref, github.event.inputs.pr_number) || github.event_name == 'pull_request' && format('{0}-{1}', github.workflow_ref, github.event.pull_request.number) || github.run_id }}
24+
cancel-in-progress: true
25+
26+
permissions:
27+
contents: read
28+
pull-requests: write
29+
30+
jobs:
31+
dependabot-review:
32+
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]')
33+
runs-on: ubuntu-latest
34+
timeout-minutes: 15
35+
steps:
36+
- name: Resolve target PR context
37+
id: target_pr
38+
uses: actions/github-script@v7
39+
with:
40+
script: |
41+
let pr;
42+
if (context.eventName === 'pull_request') {
43+
pr = context.payload.pull_request;
44+
} else {
45+
const raw = context.payload.inputs?.pr_number;
46+
const prNumber = Number(raw);
47+
if (!Number.isInteger(prNumber) || prNumber <= 0) {
48+
core.setFailed(`Invalid pr_number input: ${raw}`);
49+
return;
50+
}
51+
const { data } = await github.rest.pulls.get({
52+
owner: context.repo.owner,
53+
repo: context.repo.repo,
54+
pull_number: prNumber,
55+
});
56+
pr = data;
57+
}
58+
59+
if (!pr) {
60+
core.setFailed('Could not resolve target pull request context.');
61+
return;
62+
}
63+
if (pr.user?.login !== 'dependabot[bot]') {
64+
core.setFailed(`Target PR #${pr.number} is not opened by dependabot[bot].`);
65+
return;
66+
}
67+
68+
core.setOutput('number', String(pr.number));
69+
core.setOutput('title', pr.title || '');
70+
core.setOutput('body', pr.body || '');
71+
core.setOutput('head_sha', pr.head?.sha || '');
72+
73+
- name: Checkout repository
74+
uses: actions/checkout@v6
75+
with:
76+
ref: ${{ steps.target_pr.outputs.head_sha }}
77+
persist-credentials: false
78+
79+
- name: Install Cursor CLI
80+
shell: bash
81+
run: |
82+
installer="$(mktemp)"
83+
trap 'rm -f "$installer"' EXIT
84+
curl https://cursor.com/install -fsSL -o "$installer"
85+
if [ ! -s "$installer" ]; then
86+
echo "Cursor installer download was empty."
87+
exit 1
88+
fi
89+
bash "$installer"
90+
for bin_dir in "$HOME/.cursor/bin" "$HOME/.local/bin"; do
91+
if [ -d "$bin_dir" ]; then
92+
echo "$bin_dir" >> "$GITHUB_PATH"
93+
export PATH="$bin_dir:$PATH"
94+
fi
95+
done
96+
if ! command -v agent >/dev/null 2>&1; then
97+
echo "Could not locate 'agent' binary after Cursor CLI install."
98+
exit 1
99+
fi
100+
101+
- name: Extract Dependabot comment context
102+
id: dependabot_context
103+
uses: actions/github-script@v7
104+
with:
105+
script: |
106+
const fs = require('fs');
107+
const body = ${{ toJSON(steps.target_pr.outputs.body) }} || '';
108+
109+
function escapeRegex(text) {
110+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
111+
}
112+
113+
function extractDetailsSection(text, summaryLabel) {
114+
const summaryRe = new RegExp(
115+
`<details[^>]*>\\s*<summary>\\s*${escapeRegex(summaryLabel)}\\s*</summary>`,
116+
'i'
117+
);
118+
const summaryMatch = summaryRe.exec(text);
119+
if (!summaryMatch) return '';
120+
121+
const start = summaryMatch.index;
122+
const tagRe = /<details\b[^>]*>|<\/details>/gi;
123+
tagRe.lastIndex = start;
124+
let depth = 0;
125+
let end = -1;
126+
let tag;
127+
128+
while ((tag = tagRe.exec(text)) !== null) {
129+
if (tag[0].toLowerCase().startsWith('<details')) {
130+
depth += 1;
131+
} else {
132+
depth -= 1;
133+
}
134+
if (depth === 0) {
135+
end = tagRe.lastIndex;
136+
break;
137+
}
138+
}
139+
140+
if (end === -1) return '';
141+
const block = text.slice(start, end);
142+
return block
143+
.replace(summaryRe, '')
144+
.replace(/<\/details>\s*$/i, '')
145+
.trim();
146+
}
147+
148+
const releaseNotes = extractDetailsSection(body, 'Release notes');
149+
const commits = extractDetailsSection(body, 'Commits');
150+
// Prefer the dependency link from Dependabot's lead sentence.
151+
// Fallback to any GitHub repo URL if that sentence format changes.
152+
const dependencyRepoMatch = body.match(
153+
/^\s*(?:Bumps|Update(?:s|d)?)\s+\[[^\]]+\]\(\s*https?:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?:[\/#?][^)\s]*)?\s*\)/im
154+
);
155+
const repoMatch = dependencyRepoMatch || body.match(
156+
/https?:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?=\/|$|[)\]>\s"'?#])/i
157+
);
158+
const upstreamRepo = repoMatch ? repoMatch[1].replace(/\.git$/i, '') : '';
159+
const title = ${{ toJSON(steps.target_pr.outputs.title) }} || '';
160+
const titleMatch =
161+
title.match(/[Uu]pdate\s+(.+?)\s+requirement\s+from\s+([^\s]+)\s+to\s+([^\s]+)/) ||
162+
title.match(/[Bb]ump\s+(.+?)\s+from\s+([^\s]+)\s+to\s+([^\s]+)/) ||
163+
title.match(/[Uu]pdate\s+(.+?)\s+from\s+([^\s]+)\s+to\s+([^\s]+)/);
164+
const packageName = titleMatch ? titleMatch[1].trim() : '';
165+
const fromVersion = titleMatch ? titleMatch[2].trim() : '';
166+
const toVersion = titleMatch ? titleMatch[3].trim() : '';
167+
168+
if (!upstreamRepo) {
169+
core.setFailed('Dependabot PR body is missing an upstream GitHub repository link.');
170+
return;
171+
}
172+
const normalizedReleaseNotes =
173+
releaseNotes || 'Dependabot did not include a Release notes details section.';
174+
const normalizedCommits =
175+
commits || 'Dependabot did not include a Commits details section.';
176+
177+
const out = {
178+
prNumber: Number('${{ steps.target_pr.outputs.number }}'),
179+
upstreamRepo,
180+
packageName,
181+
fromVersion,
182+
toVersion,
183+
releaseNotes: normalizedReleaseNotes,
184+
commits: normalizedCommits,
185+
};
186+
fs.writeFileSync('dependabot_comment_context.json', JSON.stringify(out, null, 2));
187+
fs.writeFileSync('dependabot_release_notes.md', normalizedReleaseNotes);
188+
fs.writeFileSync('dependabot_commits.md', normalizedCommits);
189+
core.setOutput('upstream_repo', upstreamRepo);
190+
core.setOutput('package_name', packageName);
191+
core.setOutput('from_version', fromVersion);
192+
core.setOutput('to_version', toVersion);
193+
194+
- name: Prepare upstream checkout directory
195+
run: mkdir -p ".upstream-dependency"
196+
197+
- name: Checkout upstream repository
198+
uses: actions/checkout@v6
199+
with:
200+
repository: ${{ steps.dependabot_context.outputs.upstream_repo }}
201+
path: .upstream-dependency
202+
fetch-depth: 0
203+
persist-credentials: false
204+
205+
- name: Remove upstream agent-instruction files
206+
shell: bash
207+
run: |
208+
rm -rf .upstream-dependency/.cursor || true
209+
rm -f .upstream-dependency/.cursorrules || true
210+
rm -f .upstream-dependency/.cursorignore || true
211+
rm -f .upstream-dependency/AGENTS.md || true
212+
rm -f .upstream-dependency/CLAUDE.md || true
213+
214+
- name: Gather local usage hints
215+
shell: bash
216+
env:
217+
PACKAGE_NAME: ${{ steps.dependabot_context.outputs.package_name }}
218+
run: |
219+
if [ -z "$PACKAGE_NAME" ]; then
220+
echo "No package detected from Dependabot metadata." > package_usage.txt
221+
else
222+
{
223+
echo "Search pattern: $PACKAGE_NAME"
224+
echo
225+
rg -n --fixed-strings --hidden --glob '!.git' --glob '!node_modules' --glob '!.upstream-dependency/**' -- "$PACKAGE_NAME" . || true
226+
} > package_usage.txt
227+
fi
228+
229+
- name: Run Cursor analysis
230+
timeout-minutes: 10
231+
env:
232+
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
233+
PR_TITLE: ${{ steps.target_pr.outputs.title }}
234+
PR_BODY: ${{ steps.target_pr.outputs.body }}
235+
PACKAGE_NAME: ${{ steps.dependabot_context.outputs.package_name }}
236+
FROM_VERSION: ${{ steps.dependabot_context.outputs.from_version }}
237+
TO_VERSION: ${{ steps.dependabot_context.outputs.to_version }}
238+
shell: bash
239+
run: |
240+
if [ -z "$CURSOR_API_KEY" ]; then
241+
echo '{"result":"CURSOR_API_KEY is not set; analysis was skipped."}' > cursor_output.json
242+
exit 0
243+
fi
244+
245+
python3 - <<'PY'
246+
import os
247+
248+
def read_file(path):
249+
try:
250+
with open(path, "r", encoding="utf-8") as f:
251+
return f.read()
252+
except FileNotFoundError:
253+
return ""
254+
255+
prompt = f"""
256+
This is a Dependabot PR review request.
257+
258+
PR title:
259+
{os.getenv("PR_TITLE", "")}
260+
261+
PR body:
262+
{os.getenv("PR_BODY", "")}
263+
264+
Package metadata:
265+
- package: {os.getenv("PACKAGE_NAME", "")}
266+
- from: {os.getenv("FROM_VERSION", "")}
267+
- to: {os.getenv("TO_VERSION", "")}
268+
269+
Dependabot comment context JSON:
270+
{read_file("dependabot_comment_context.json")}
271+
272+
Dependabot Release notes:
273+
{read_file("dependabot_release_notes.md")}
274+
275+
Dependabot Commits:
276+
{read_file("dependabot_commits.md")}
277+
278+
Local usage hints (non-authoritative grep hits):
279+
{read_file("package_usage.txt")[:12000]}
280+
281+
Repository layout:
282+
- Current repository root: .
283+
- Upstream dependency repository: .upstream-dependency (full git history is available)
284+
285+
Please produce:
286+
1) Where in this repo the dependency appears to be used (treat grep hints as directional, not exhaustive).
287+
2) Whether those usage sites intersect with likely changed APIs based on release notes, commits, and direct inspection of .upstream-dependency.
288+
3) Risks / unknowns.
289+
4) Recommendation: merge / merge-with-caveats / hold.
290+
Do not include intermediate reasoning or self-talk.
291+
Keep it concise and actionable.
292+
""".strip()
293+
294+
with open("cursor_prompt.txt", "w", encoding="utf-8") as f:
295+
f.write(prompt)
296+
PY
297+
298+
if ! agent -f --mode ask -p --output-format json < cursor_prompt.txt > cursor_output.json; then
299+
# Fallback for older CLI behavior that may require a prompt argument.
300+
# Bound prompt size to avoid shell argument length limits.
301+
FALLBACK_PROMPT="$(python3 -c 'from pathlib import Path; max_bytes = 60000; raw = Path("cursor_prompt.txt").read_bytes(); text = raw[:max_bytes].decode("utf-8", errors="ignore"); text += "\n\n[Prompt truncated for CLI argument compatibility.]" if len(raw) > max_bytes else ""; print(text, end="")')"
302+
agent -f --mode ask -p --output-format json "$FALLBACK_PROMPT" > cursor_output.json
303+
fi
304+
305+
- name: Post or update PR comment
306+
uses: actions/github-script@v7
307+
with:
308+
script: |
309+
const fs = require('fs');
310+
const marker = '<!-- cursor-dependabot-review -->';
311+
const maxLen = 60000;
312+
313+
function readText(path, fallback = '') {
314+
try {
315+
return fs.readFileSync(path, 'utf8');
316+
} catch (_) {
317+
return fallback;
318+
}
319+
}
320+
321+
const raw = readText('cursor_output.json', '{"result":"No Cursor output generated."}');
322+
let parsed;
323+
try {
324+
parsed = JSON.parse(raw);
325+
} catch (_) {
326+
parsed = { result: raw };
327+
}
328+
329+
const analysis =
330+
parsed.result ||
331+
parsed.output ||
332+
parsed.text ||
333+
parsed.message ||
334+
raw;
335+
const analysisText =
336+
typeof analysis === 'string'
337+
? analysis
338+
: JSON.stringify(analysis, null, 2) || String(analysis);
339+
340+
const body = `${marker}
341+
## 🤖 Cursor Dependency Analysis
342+
343+
${analysisText.slice(0, maxLen)}`;
344+
345+
const { owner, repo } = context.repo;
346+
const issue_number = Number('${{ steps.target_pr.outputs.number }}');
347+
const comments = await github.paginate(github.rest.issues.listComments, {
348+
owner,
349+
repo,
350+
issue_number,
351+
per_page: 100,
352+
});
353+
const markerComments = comments
354+
.filter((c) => typeof c.body === 'string' && c.body.includes(marker))
355+
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
356+
const latestMarker = markerComments.length > 0 ? markerComments[markerComments.length - 1] : null;
357+
358+
// Hybrid policy: default to updating the latest managed marker comment.
359+
// If any non-managed commentary (human or bot) appears after it, create a
360+
// new managed comment so prior discussion stays anchored to older analysis.
361+
const hasNonManagedCommentaryAfterLatest =
362+
latestMarker &&
363+
comments.some(
364+
(c) =>
365+
c.id !== latestMarker.id &&
366+
!(typeof c.body === 'string' && c.body.includes(marker)) &&
367+
new Date(c.created_at).getTime() > new Date(latestMarker.created_at).getTime()
368+
);
369+
370+
if (latestMarker && !hasNonManagedCommentaryAfterLatest) {
371+
await github.rest.issues.updateComment({
372+
owner,
373+
repo,
374+
comment_id: latestMarker.id,
375+
body,
376+
});
377+
} else {
378+
await github.rest.issues.createComment({
379+
owner,
380+
repo,
381+
issue_number,
382+
body,
383+
});
384+
}

0 commit comments

Comments
 (0)