Skip to content

Commit 49ecf53

Browse files
committed
Add pycmdcheck triage workflow
1 parent 0063885 commit 49ecf53

2 files changed

Lines changed: 377 additions & 2 deletions

File tree

.github/ISSUE_TEMPLATE/submit-software-for-review.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ Submitting Author: (@github_handle)
1111
All current maintainers: (@github_handle1, @github_handle2)
1212
Package Name: Package name here
1313
One-Line Description of Package: Description here
14-
Repository Link:
15-
Version submitted:
14+
Repository Link: https://github.com/owner/repo
15+
Version submitted: v0.1.0
1616
EiC: TBD
1717
Editor: TBD
1818
Reviewer 1: TBD
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
name: pycmdcheck triage
2+
3+
on:
4+
issues:
5+
types:
6+
- opened
7+
- edited
8+
- labeled
9+
10+
concurrency:
11+
group: pycmdcheck-triage-${{ github.event.issue.number }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
triage:
16+
if: >-
17+
github.event.issue.pull_request == null &&
18+
(
19+
github.event.label.name == '0/pre-review-checks' ||
20+
contains(toJson(github.event.issue.labels), '0/pre-review-checks')
21+
)
22+
runs-on: ubuntu-latest
23+
permissions:
24+
contents: read
25+
issues: write
26+
27+
steps:
28+
- name: Check out workflow repository
29+
uses: actions/checkout@v4
30+
31+
- name: Set up Python
32+
uses: actions/setup-python@v5
33+
with:
34+
python-version: '3.12'
35+
36+
- name: Parse submission fields
37+
id: parse
38+
uses: actions/github-script@v7
39+
with:
40+
script: |
41+
const body = context.payload.issue.body || '';
42+
43+
function getField(label) {
44+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
45+
const match = body.match(new RegExp(`^${escaped}:\\s*(.+)$`, 'mi'));
46+
return match ? match[1].trim() : '';
47+
}
48+
49+
function normalizeRepositoryLink(rawValue) {
50+
if (!rawValue) {
51+
return { isValid: false, error: 'No `Repository Link:` value was provided in the issue body.' };
52+
}
53+
54+
const cleaned = rawValue.replace(/^<|>$/g, '').trim();
55+
let url;
56+
57+
try {
58+
url = new URL(cleaned);
59+
} catch (error) {
60+
return {
61+
isValid: false,
62+
error: `Could not parse the repository URL: ${cleaned}`,
63+
};
64+
}
65+
66+
const host = url.hostname.toLowerCase();
67+
if (host !== 'github.com' && host !== 'www.github.com') {
68+
return {
69+
isValid: false,
70+
error: 'Automated triage currently supports public GitHub repository URLs only.',
71+
};
72+
}
73+
74+
const parts = url.pathname.split('/').filter(Boolean);
75+
if (parts.length < 2) {
76+
return {
77+
isValid: false,
78+
error: 'Repository Link must point to a GitHub repository, for example https://github.com/owner/repo.',
79+
};
80+
}
81+
82+
const owner = parts[0];
83+
const repo = parts[1].replace(/\.git$/, '');
84+
const slug = `${owner}/${repo}`;
85+
86+
return {
87+
isValid: true,
88+
slug,
89+
cloneUrl: `https://github.com/${slug}.git`,
90+
webUrl: `https://github.com/${slug}`,
91+
};
92+
}
93+
94+
const packageName = getField('Package Name') || 'Unknown package';
95+
const submittedVersion = getField('Version submitted');
96+
const repositoryLink = getField('Repository Link');
97+
const normalized = normalizeRepositoryLink(repositoryLink);
98+
99+
core.setOutput('package_name', packageName);
100+
core.setOutput('submitted_version', submittedVersion);
101+
core.setOutput('repository_link', repositoryLink);
102+
core.setOutput('is_valid', normalized.isValid ? 'true' : 'false');
103+
core.setOutput('repo_slug', normalized.slug || '');
104+
core.setOutput('clone_url', normalized.cloneUrl || '');
105+
core.setOutput('web_url', normalized.webUrl || repositoryLink || '');
106+
core.setOutput('error', normalized.error || '');
107+
108+
- name: Clone submitted repository
109+
if: steps.parse.outputs.is_valid == 'true'
110+
env:
111+
CLONE_URL: ${{ steps.parse.outputs.clone_url }}
112+
run: git clone --depth 1 "$CLONE_URL" submitted-package
113+
114+
- name: Check out submitted version when provided
115+
if: steps.parse.outputs.is_valid == 'true'
116+
id: git_ref
117+
env:
118+
SUBMITTED_VERSION: ${{ steps.parse.outputs.submitted_version }}
119+
run: |
120+
set -euo pipefail
121+
version="$(printf '%s' "$SUBMITTED_VERSION" | xargs)"
122+
123+
if [ -z "$version" ] || [ "$version" = "TBD" ]; then
124+
echo "status=default_branch" >> "$GITHUB_OUTPUT"
125+
exit 0
126+
fi
127+
128+
cd submitted-package
129+
git fetch --depth 1 origin "$version" || git fetch --depth 1 --tags origin
130+
131+
if git checkout "$version"; then
132+
echo "status=checked_out" >> "$GITHUB_OUTPUT"
133+
echo "resolved_ref=$version" >> "$GITHUB_OUTPUT"
134+
else
135+
echo "status=missing_ref" >> "$GITHUB_OUTPUT"
136+
echo "resolved_ref=$version" >> "$GITHUB_OUTPUT"
137+
fi
138+
139+
- name: Install pycmdcheck
140+
if: steps.parse.outputs.is_valid == 'true'
141+
run: |
142+
python -m pip install --upgrade pip
143+
python -m pip install git+https://github.com/coatless-py-pkg/pycmdcheck.git@66b1bb1db129a825a3d7f78940662a92183cbf52
144+
145+
- name: Run safe pycmdcheck triage
146+
if: steps.parse.outputs.is_valid == 'true'
147+
id: triage
148+
run: |
149+
set +e
150+
pycmdcheck check submitted-package \
151+
--format json \
152+
--output pycmdcheck-results.json \
153+
--only ST001 \
154+
--only ST002 \
155+
--only ST003 \
156+
--only ST004 \
157+
--only ST005 \
158+
--only ST006 \
159+
--only ST007 \
160+
--only MT001 \
161+
--only MT002 \
162+
--only MT003 \
163+
--only MT004 \
164+
--only MT005 \
165+
--only MT006 \
166+
--only MT007 \
167+
--only MT008 \
168+
--only MT009 \
169+
--only MT010 \
170+
--only DC001 \
171+
--only DC002 \
172+
--only DC003 \
173+
--only DC004 \
174+
--only RL001 \
175+
--only RL002 \
176+
--only RL003 \
177+
--only TS001 \
178+
--only TS004
179+
exit_code=$?
180+
set -e
181+
182+
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
183+
184+
- name: Check for CI configuration
185+
if: steps.parse.outputs.is_valid == 'true'
186+
id: ci
187+
run: |
188+
set -euo pipefail
189+
190+
if compgen -G "submitted-package/.github/workflows/*.yml" > /dev/null || \
191+
compgen -G "submitted-package/.github/workflows/*.yaml" > /dev/null || \
192+
[ -f "submitted-package/.circleci/config.yml" ] || \
193+
[ -f "submitted-package/.gitlab-ci.yml" ] || \
194+
[ -f "submitted-package/azure-pipelines.yml" ] || \
195+
[ -f "submitted-package/.travis.yml" ]; then
196+
echo "status=passed" >> "$GITHUB_OUTPUT"
197+
echo "message=Continuous integration configuration detected." >> "$GITHUB_OUTPUT"
198+
else
199+
echo "status=failed" >> "$GITHUB_OUTPUT"
200+
echo "message=No CI configuration files were detected in the submitted repository." >> "$GITHUB_OUTPUT"
201+
fi
202+
203+
- name: Build triage comment
204+
id: comment
205+
if: always()
206+
uses: actions/github-script@v7
207+
env:
208+
PACKAGE_NAME: ${{ steps.parse.outputs.package_name }}
209+
REPOSITORY_URL: ${{ steps.parse.outputs.web_url }}
210+
SUBMITTED_VERSION: ${{ steps.parse.outputs.submitted_version }}
211+
PARSE_VALID: ${{ steps.parse.outputs.is_valid }}
212+
PARSE_ERROR: ${{ steps.parse.outputs.error }}
213+
GIT_REF_STATUS: ${{ steps.git_ref.outputs.status }}
214+
GIT_REF_VALUE: ${{ steps.git_ref.outputs.resolved_ref }}
215+
TRIAGE_EXIT_CODE: ${{ steps.triage.outputs.exit_code }}
216+
CI_STATUS: ${{ steps.ci.outputs.status }}
217+
CI_MESSAGE: ${{ steps.ci.outputs.message }}
218+
with:
219+
script: |
220+
const fs = require('fs');
221+
222+
const marker = '<!-- pyopensci-pycmdcheck-triage -->';
223+
const packageName = process.env.PACKAGE_NAME || 'Unknown package';
224+
const repoUrl = process.env.REPOSITORY_URL || '';
225+
const submittedVersion = process.env.SUBMITTED_VERSION || '';
226+
const parseValid = process.env.PARSE_VALID === 'true';
227+
const parseError = process.env.PARSE_ERROR || '';
228+
const gitRefStatus = process.env.GIT_REF_STATUS || '';
229+
const gitRefValue = process.env.GIT_REF_VALUE || '';
230+
const triageExitCode = process.env.TRIAGE_EXIT_CODE || '';
231+
const ciStatus = process.env.CI_STATUS || '';
232+
const ciMessage = process.env.CI_MESSAGE || '';
233+
234+
function renderFailure(result) {
235+
const parts = [`- **${result.check_id}**`, `(${result.severity}, ${result.status})`];
236+
if (result.message) {
237+
parts.push(`: ${result.message}`);
238+
}
239+
240+
let line = parts.join(' ');
241+
if (result.hint) {
242+
line += `\n Hint: ${result.hint}`;
243+
}
244+
return line;
245+
}
246+
247+
let body = `${marker}\n## Automated pycmdcheck Triage\n`;
248+
body += '\n';
249+
body += `Package: **${packageName}**\n`;
250+
if (repoUrl) {
251+
body += `Repository: ${repoUrl}\n`;
252+
}
253+
if (submittedVersion) {
254+
body += `Submitted version: ${submittedVersion}\n`;
255+
}
256+
257+
if (!parseValid) {
258+
body += '\nThe workflow could not run because the submission issue did not contain a usable public GitHub repository URL.\n';
259+
body += `\nReason: ${parseError}\n`;
260+
body += '\nPlease update the `Repository Link:` field and edit the issue to rerun triage.\n';
261+
core.setOutput('body', body);
262+
return;
263+
}
264+
265+
if (gitRefStatus === 'missing_ref' && gitRefValue) {
266+
body += `\nNote: the workflow could not check out the submitted version \`${gitRefValue}\`, so the default branch was checked instead.\n`;
267+
} else if (gitRefStatus === 'checked_out' && gitRefValue) {
268+
body += `\nChecked out submitted ref: \`${gitRefValue}\`.\n`;
269+
} else {
270+
body += '\nChecked the repository default branch.\n';
271+
}
272+
273+
body += '\nThis triage run is intentionally restricted to static packaging and documentation checks. It does not run tests, import the package, or build/install the project.\n';
274+
275+
let results = null;
276+
if (fs.existsSync('pycmdcheck-results.json')) {
277+
results = JSON.parse(fs.readFileSync('pycmdcheck-results.json', 'utf8'));
278+
}
279+
280+
if (!results) {
281+
body += '\nThe workflow did not produce a pycmdcheck results file. Please inspect the workflow run logs.\n';
282+
core.setOutput('body', body);
283+
return;
284+
}
285+
286+
const summary = results.summary || {};
287+
body += '\n### Summary\n';
288+
body += `- Passed: ${summary.passed ?? 0}\n`;
289+
body += `- Failed: ${summary.failed ?? 0}\n`;
290+
body += `- Warnings: ${summary.warnings ?? 0}\n`;
291+
body += `- Notes: ${summary.notes ?? 0}\n`;
292+
body += `- Exit code: ${triageExitCode || 'unknown'}\n`;
293+
294+
body += '\n### CI Configuration\n';
295+
if (ciStatus === 'passed') {
296+
body += `- Passed: ${ciMessage}\n`;
297+
} else if (ciStatus === 'failed') {
298+
body += `- Failed: ${ciMessage}\n`;
299+
} else {
300+
body += '- Unable to determine CI configuration status.\n';
301+
}
302+
303+
const failures = [];
304+
for (const group of results.groups || []) {
305+
for (const result of group.results || []) {
306+
if (result.status === 'failed' || result.status === 'errored') {
307+
failures.push(result);
308+
}
309+
}
310+
}
311+
312+
if (ciStatus === 'failed') {
313+
failures.push({
314+
check_id: 'PYO-CI',
315+
severity: 'warning',
316+
status: 'failed',
317+
message: ciMessage,
318+
hint: 'Add CI configuration such as GitHub Actions, CircleCI, GitLab CI, Travis CI, or Azure Pipelines.',
319+
});
320+
}
321+
322+
body += '\n### Findings\n';
323+
if (failures.length === 0) {
324+
body += '- No issues were found in the current automated triage subset.\n';
325+
} else {
326+
const displayed = failures.slice(0, 20).map(renderFailure).join('\n');
327+
body += `${displayed}\n`;
328+
329+
if (failures.length > 20) {
330+
body += `- Additional findings omitted: ${failures.length - 20}\n`;
331+
}
332+
}
333+
334+
body += '\n### Scope\n';
335+
body += '- Included: repository structure, packaging metadata, documentation presence, changelog/version checks, test directory presence, and test naming conventions.\n';
336+
body += '- Excluded: test execution, coverage, import/build/install checks, dependency audits, and network-heavy link validation.\n';
337+
338+
core.setOutput('body', body);
339+
340+
- name: Upsert triage comment
341+
if: always()
342+
uses: actions/github-script@v7
343+
env:
344+
COMMENT_BODY: ${{ steps.comment.outputs.body }}
345+
with:
346+
script: |
347+
const marker = '<!-- pyopensci-pycmdcheck-triage -->';
348+
const body = process.env.COMMENT_BODY;
349+
350+
const comments = await github.paginate(github.rest.issues.listComments, {
351+
owner: context.repo.owner,
352+
repo: context.repo.repo,
353+
issue_number: context.issue.number,
354+
per_page: 100,
355+
});
356+
357+
const existing = comments.find((comment) =>
358+
comment.user?.type === 'Bot' && comment.body?.includes(marker)
359+
);
360+
361+
if (existing) {
362+
await github.rest.issues.updateComment({
363+
owner: context.repo.owner,
364+
repo: context.repo.repo,
365+
comment_id: existing.id,
366+
body,
367+
});
368+
} else {
369+
await github.rest.issues.createComment({
370+
owner: context.repo.owner,
371+
repo: context.repo.repo,
372+
issue_number: context.issue.number,
373+
body,
374+
});
375+
}

0 commit comments

Comments
 (0)