Skip to content

Commit d76dada

Browse files
committed
Initial dependabot report
1 parent a760372 commit d76dada

5 files changed

Lines changed: 1235 additions & 0 deletions

File tree

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
name: 'Dependabot Scan'
2+
description: >
3+
Scans one repository for open Dependabot pull requests and the current state of its
4+
Dependabot update jobs, classifies each PR, and writes the result as JSON. Read-only,
5+
so it is shared by the reporting and triage workflows.
6+
7+
inputs:
8+
repo:
9+
description: 'Full repository path (e.g. spring-cloud/spring-cloud-build)'
10+
required: true
11+
project:
12+
description: 'Spring Cloud project key (e.g. spring-cloud-build)'
13+
required: true
14+
type:
15+
description: 'Repository flavor: oss or commercial'
16+
required: true
17+
maintained-branches:
18+
description: >
19+
Comma-separated branches this repo actively maintains, from projects.json
20+
(branches.scheduled). A PR against anything else is reported as invalid.
21+
Comma-separated rather than JSON because a matrix cannot carry structured data
22+
through an expression without toJson pretty-printing it and breaking the YAML.
23+
required: true
24+
extra-branches:
25+
description: >
26+
Comma-separated branches that are maintained but are not release branches, so they
27+
never appear in projects.json. Dependabot legitimately targets these.
28+
required: false
29+
default: 'docs-build'
30+
releaser-map-file:
31+
description: >
32+
Path to a JSON file of {type: {project: {version: train}}}, used to resolve which
33+
GitHub Project a PR belongs to. Empty disables project resolution.
34+
required: false
35+
default: ''
36+
token:
37+
description: 'GitHub token with read access to the repository'
38+
required: true
39+
40+
outputs:
41+
result-file:
42+
description: 'Path of the JSON file written'
43+
value: ${{ steps.scan.outputs.result-file }}
44+
safe-name:
45+
description: 'Filename-safe form of the repo, for artifact naming'
46+
value: ${{ steps.scan.outputs.safe-name }}
47+
48+
runs:
49+
using: composite
50+
steps:
51+
- name: Scan repository
52+
id: scan
53+
shell: bash
54+
env:
55+
GH_TOKEN: ${{ inputs.token }}
56+
REPO: ${{ inputs.repo }}
57+
PROJECT: ${{ inputs.project }}
58+
TYPE: ${{ inputs.type }}
59+
MAINTAINED_BRANCHES: ${{ inputs.maintained-branches }}
60+
EXTRA_BRANCHES: ${{ inputs.extra-branches }}
61+
RELEASER_MAP_FILE: ${{ inputs.releaser-map-file }}
62+
run: |
63+
node - << 'JSEOF'
64+
const fs = require('fs');
65+
const { execFileSync } = require('child_process');
66+
67+
const REPO = process.env.REPO;
68+
const PROJECT = process.env.PROJECT;
69+
const TYPE = process.env.TYPE;
70+
71+
const parseJson = (raw, fallback) => {
72+
try { return JSON.parse(raw); } catch (_) { return fallback; }
73+
};
74+
75+
const maintained = new Set();
76+
for (const source of [process.env.MAINTAINED_BRANCHES, process.env.EXTRA_BRANCHES]) {
77+
for (const b of (source || '').split(',')) {
78+
const t = b.trim();
79+
if (t) maintained.add(t);
80+
}
81+
}
82+
83+
// {type: {project: {version: train}}}, written once by the calling workflow so
84+
// the releaser config is fetched a single time rather than once per repo.
85+
let releaserMap = {};
86+
const mapFile = (process.env.RELEASER_MAP_FILE || '').trim();
87+
if (mapFile && fs.existsSync(mapFile)) {
88+
const all = parseJson(fs.readFileSync(mapFile, 'utf8'), {}) || {};
89+
releaserMap = (all[TYPE] || {})[PROJECT] || {};
90+
}
91+
92+
const gh = args => {
93+
try {
94+
return { ok: true, out: execFileSync('gh', args,
95+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 26 }) };
96+
} catch (err) {
97+
return { ok: false, out: '', err: (err.stderr || err.message || '').split('\n')[0].trim() };
98+
}
99+
};
100+
101+
// GitHub's API - the GraphQL endpoint behind `gh pr list` especially - returns
102+
// the occasional 502/504. Without a retry those surface as "0 open PRs", which
103+
// reads as good news and is the most dangerous way for this report to be wrong.
104+
const ghRetry = (args, attempts = 3) => {
105+
let last;
106+
for (let attempt = 1; attempt <= attempts; attempt++) {
107+
last = gh(args);
108+
if (last.ok) return last;
109+
if (attempt < attempts) {
110+
console.log(` attempt ${attempt} failed (${last.err}) - retrying`);
111+
execFileSync('sleep', [String(attempt * 3)]);
112+
}
113+
}
114+
return last;
115+
};
116+
117+
const warnings = [];
118+
119+
// ── Is the repository readable at all? ──────────────────────────────────────
120+
// `gh pr list` answers with an empty array and exit 0 for a repository it cannot
121+
// see, so a renamed, deleted or permission-denied repo would otherwise be
122+
// reported as simply having no open Dependabot PRs. One explicit existence check
123+
// turns that silent zero into a visible error.
124+
const repoResp = ghRetry(['api', `repos/${REPO}`, '--jq', '.full_name']);
125+
const repoUnreadable = !repoResp.ok;
126+
if (repoUnreadable) {
127+
warnings.push(`repository is not readable: ${repoResp.err}`);
128+
}
129+
130+
// ── Dependabot update jobs ──────────────────────────────────────────────────
131+
// Dependabot's own update runs surface as normal workflow runs with
132+
// event=dynamic and path=dynamic/dependabot/dependabot-updates. The run name
133+
// encodes the ecosystem and directory, e.g. "maven in /. - Update #1510829977".
134+
const updateJobs = [];
135+
let updateJobsFailed = false;
136+
const runsResp = ghRetry(['api',
137+
`repos/${REPO}/actions/runs?actor=dependabot%5Bbot%5D&event=dynamic&per_page=100`]);
138+
if (!runsResp.ok) {
139+
updateJobsFailed = true;
140+
warnings.push(`could not list Dependabot update runs: ${runsResp.err}`);
141+
} else {
142+
const runs = parseJson(runsResp.out, {}).workflow_runs || [];
143+
// Runs come back newest-first, so the first entry seen for a given
144+
// ecosystem/directory/branch is that update's current state. Older runs for
145+
// the same key are history and are ignored - the question is "is this update
146+
// broken now?", not "has it ever failed?".
147+
const seen = new Set();
148+
for (const run of runs) {
149+
const m = (run.name || '').match(/^(\S+) in (\S+) - Update #(\d+)$/);
150+
const ecosystem = m ? m[1] : (run.name || 'unknown');
151+
const directory = m ? m[2] : '?';
152+
const key = `${ecosystem}|${directory}|${run.head_branch}`;
153+
if (seen.has(key)) continue;
154+
seen.add(key);
155+
updateJobs.push({
156+
ecosystem, directory,
157+
branch: run.head_branch,
158+
status: run.status,
159+
conclusion: run.conclusion || 'none',
160+
url: run.html_url,
161+
createdAt: run.created_at,
162+
});
163+
}
164+
}
165+
const failingUpdateJobs = updateJobs.filter(j => j.conclusion === 'failure');
166+
167+
// ── Open Dependabot pull requests ───────────────────────────────────────────
168+
const PR_FIELDS = 'number,title,url,baseRefName,createdAt,updatedAt,headRefOid,' +
169+
'mergeable,mergeStateStatus,statusCheckRollup,milestone';
170+
171+
const listPrs = () => {
172+
const r = ghRetry(['pr', 'list', '--repo', REPO, '--author', 'app/dependabot',
173+
'--state', 'open', '--limit', '100', '--json', PR_FIELDS]);
174+
if (!r.ok) {
175+
warnings.push(`could not list Dependabot PRs: ${r.err}`);
176+
return null;
177+
}
178+
return parseJson(r.out, []);
179+
};
180+
181+
// A repo whose PR list could not be read is reported as unscannable rather than
182+
// as having no open PRs - see prListFailed in the result below.
183+
let prs = listPrs();
184+
const prListFailed = prs === null;
185+
if (prListFailed) prs = [];
186+
187+
// GitHub computes mergeability lazily and returns UNKNOWN until it has. Asking
188+
// for the PR is itself what triggers the computation, so a single re-read a few
189+
// seconds later resolves it in practice.
190+
if (prs.some(pr => pr.mergeable === 'UNKNOWN')) {
191+
execFileSync('sleep', ['5']);
192+
const retried = listPrs();
193+
if (retried) prs = retried;
194+
}
195+
196+
// A check is either a CheckRun (status + conclusion) or a legacy StatusContext
197+
// (state). Both appear in statusCheckRollup, so they are normalised to one shape.
198+
const FAILING = new Set(['FAILURE', 'TIMED_OUT', 'ERROR', 'STARTUP_FAILURE', 'CANCELLED']);
199+
const PASSING = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
200+
201+
const normalizeCheck = c => {
202+
if (c.__typename === 'StatusContext' || c.state) {
203+
return { name: c.context || 'status', state: (c.state || '').toUpperCase(), url: c.targetUrl || '' };
204+
}
205+
const status = (c.status || '').toUpperCase();
206+
if (status && status !== 'COMPLETED') {
207+
return { name: c.name || 'check', state: 'PENDING', url: c.detailsUrl || '' };
208+
}
209+
return { name: c.name || 'check', state: (c.conclusion || 'PENDING').toUpperCase(), url: c.detailsUrl || '' };
210+
};
211+
212+
// Project version of a branch: strip <parent> so its <version> is not mistaken
213+
// for the project's own, then take the first <version> that remains.
214+
const versionCache = new Map();
215+
const branchVersion = branch => {
216+
if (versionCache.has(branch)) return versionCache.get(branch);
217+
let version = null;
218+
const r = gh(['api', `repos/${REPO}/contents/pom.xml?ref=${branch}`, '--jq', '.content']);
219+
if (r.ok) {
220+
const pom = Buffer.from(r.out.trim(), 'base64').toString('utf8');
221+
const m = pom.replace(/<parent>[\s\S]*?<\/parent>/, '').match(/<version>([^<]+)<\/version>/);
222+
if (m) version = m[1].trim();
223+
}
224+
versionCache.set(branch, version);
225+
return version;
226+
};
227+
228+
// Existing milestone titles, fetched once and reused for every PR in this repo.
229+
let milestoneTitles = null;
230+
const milestoneExists = title => {
231+
if (milestoneTitles === null) {
232+
const r = gh(['api', `repos/${REPO}/milestones?state=open&per_page=100`,
233+
'--jq', '[.[].title]']);
234+
milestoneTitles = r.ok ? new Set(parseJson(r.out, [])) : new Set();
235+
if (!r.ok) warnings.push(`could not list milestones: ${r.err}`);
236+
}
237+
return milestoneTitles.has(title);
238+
};
239+
240+
const scanned = prs.map(pr => {
241+
const checks = (pr.statusCheckRollup || []).map(normalizeCheck);
242+
const failing = checks.filter(c => FAILING.has(c.state));
243+
const pending = checks.filter(c => !FAILING.has(c.state) && !PASSING.has(c.state));
244+
245+
const isMaintained = maintained.has(pr.baseRefName);
246+
247+
// First match wins.
248+
//
249+
// The order matters most for retired branches: those are locked, so their PRs
250+
// report mergeStateStatus BLOCKED even with every check green. Testing
251+
// maintenance first keeps a locked branch from ever being described in terms
252+
// of its checks - the actionable fact is that the branch is gone.
253+
//
254+
// Checks are read from statusCheckRollup rather than inferred from
255+
// mergeStateStatus, which conflates too many causes to be useful here.
256+
// A green PR that GitHub still refuses to merge (branch protection, an
257+
// unreported required check) is called "blocked" rather than "ready", so that
258+
// everything reported as ready can genuinely be merged.
259+
let state;
260+
if (!isMaintained) state = 'unmaintained';
261+
else if (pr.mergeable === 'CONFLICTING') state = 'conflicting';
262+
else if (failing.length) state = 'failing';
263+
else if (pending.length) state = 'pending';
264+
else if (pr.mergeable !== 'MERGEABLE') state = 'unknown';
265+
else if (pr.mergeStateStatus === 'BLOCKED') state = 'blocked';
266+
else state = 'ready';
267+
268+
// Milestone and project are only meaningful for a branch we still maintain.
269+
let expectedMilestone = null, milestoneState = 'n/a';
270+
let expectedProject = null, projectState = 'n/a';
271+
272+
if (isMaintained) {
273+
const version = branchVersion(pr.baseRefName);
274+
if (!version) {
275+
milestoneState = 'unresolved';
276+
projectState = 'unresolved';
277+
} else {
278+
expectedMilestone = version.replace(/-SNAPSHOT$/, '');
279+
const current = pr.milestone?.title || null;
280+
if (current === expectedMilestone) milestoneState = 'set';
281+
else if (current) milestoneState = 'mismatch';
282+
else if (!milestoneExists(expectedMilestone)) milestoneState = 'missing';
283+
else milestoneState = 'unset';
284+
285+
// Boards are OSS-only by design; commercial PRs get a milestone alone.
286+
if (TYPE === 'oss') {
287+
expectedProject = releaserMap[version] || null;
288+
projectState = expectedProject ? 'resolved' : 'unresolved';
289+
}
290+
}
291+
}
292+
293+
return {
294+
number: pr.number,
295+
title: pr.title,
296+
url: pr.url,
297+
baseRefName: pr.baseRefName,
298+
createdAt: pr.createdAt,
299+
headRefOid: pr.headRefOid,
300+
mergeable: pr.mergeable,
301+
mergeStateStatus: pr.mergeStateStatus,
302+
currentMilestone: pr.milestone?.title || null,
303+
state,
304+
failingChecks: failing.map(c => c.name),
305+
pendingChecks: pending.map(c => c.name),
306+
expectedMilestone, milestoneState,
307+
expectedProject, projectState,
308+
};
309+
});
310+
311+
const countOf = s => scanned.filter(p => p.state === s).length;
312+
const counts = {
313+
open: scanned.length,
314+
ready: countOf('ready'),
315+
blocked: countOf('blocked'),
316+
failing: countOf('failing'),
317+
conflicting: countOf('conflicting'),
318+
pending: countOf('pending'),
319+
unknown: countOf('unknown'),
320+
unmaintained: countOf('unmaintained'),
321+
};
322+
323+
const result = {
324+
project: PROJECT, repo: REPO, type: TYPE,
325+
counts,
326+
repoUnreadable,
327+
prListFailed,
328+
updateJobsFailed,
329+
updateJobsChecked: updateJobs.length,
330+
failingUpdateJobs,
331+
prs: scanned,
332+
warnings,
333+
};
334+
335+
const safe = `${REPO}`.replace(/\//g, '-');
336+
const file = `result-${safe}.json`;
337+
fs.writeFileSync(file, JSON.stringify(result, null, 2));
338+
339+
console.log(`${REPO}: ${counts.open} open PR(s) - ` +
340+
`${counts.ready} ready, ${counts.blocked} blocked, ${counts.failing} failing, ` +
341+
`${counts.conflicting} conflicting, ${counts.pending} pending, ` +
342+
`${counts.unknown} unknown, ${counts.unmaintained} unmaintained`);
343+
console.log(`${REPO}: ${failingUpdateJobs.length} failing update job(s) of ${updateJobs.length} tracked`);
344+
for (const w of warnings) console.log(` warning: ${w}`);
345+
346+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `result-file=${file}\n`);
347+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `safe-name=${safe}\n`);
348+
JSEOF

0 commit comments

Comments
 (0)