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