Skip to content

Commit 2a82f09

Browse files
committed
feat(ci): add /explain-ci bot command for PR failure help
Adds a new GitHub Actions workflow that responds to `/explain-ci` comments on pull requests. When triggered, nextcloud-bot posts a summary of all currently-failing CI checks with a plain-language description of what each check validates and step-by-step local fix instructions. Covers all 40 developer-facing PR workflows: PHPUnit (all DB backends), linting (ESLint, Stylelint, PHP, PHP-CS-Fixer), Psalm, Cypress, Node build/tests, REUSE, OpenAPI, Rector, Behat integration tests, external storage tests, object storage tests, block checks (fixup commits, conventional commits, outdated 3rdparty), Code checkers, and CodeQL. Uses the same slash-command pattern as /compile and /update-3rdparty: issue_comment trigger (works on fork PRs), COMMAND_BOT_PAT for bot identity, and peter-evans/create-or-update-comment for the +1 reaction acknowledgement. AI-Assisted-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Anna Larch <anna@nextcloud.com>
1 parent e290384 commit 2a82f09

1 file changed

Lines changed: 397 additions & 0 deletions

File tree

.github/workflows/ci-bot.yml

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: MIT
3+
4+
name: CI bot
5+
6+
on:
7+
issue_comment:
8+
types: [created]
9+
10+
permissions:
11+
contents: read
12+
pull-requests: write
13+
actions: read
14+
15+
concurrency:
16+
group: ci-bot-${{ github.event.issue.number }}
17+
cancel-in-progress: true
18+
19+
jobs:
20+
explain:
21+
runs-on: ubuntu-latest-low
22+
23+
# Only on PR comments starting with /explain-ci
24+
if: github.event.issue.pull_request != '' && startsWith(github.event.comment.body, '/explain-ci')
25+
26+
steps:
27+
- name: Acknowledge command
28+
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
29+
with:
30+
token: ${{ secrets.COMMAND_BOT_PAT }}
31+
repository: ${{ github.event.repository.full_name }}
32+
comment-id: ${{ github.event.comment.id }}
33+
reactions: '+1'
34+
35+
- name: Post CI failure summary
36+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
37+
with:
38+
github-token: ${{ secrets.COMMAND_BOT_PAT }}
39+
script: |
40+
const WORKFLOW_EXPLANATIONS = {
41+
'Lint eslint': {
42+
purpose: 'Checks JavaScript/TypeScript/Vue source files for ESLint rule violations.',
43+
fix: [
44+
'Run `npm run lint` to see all violations.',
45+
'Run `npm run lint:fix` to auto-fix many of them, then review remaining errors manually.',
46+
],
47+
},
48+
'Lint stylelint': {
49+
purpose: 'Checks CSS/SCSS/Vue style blocks for Stylelint rule violations.',
50+
fix: [
51+
'Run `npm run stylelint` to see all violations.',
52+
'Run `npm run stylelint:fix` to auto-fix many of them.',
53+
],
54+
},
55+
'Lint php': {
56+
purpose: 'Runs `php -l` across all PHP files to detect syntax errors.',
57+
fix: [
58+
'Check the job log for the file and line number reported.',
59+
'Fix the PHP syntax error shown (missing bracket, comma, or invalid token).',
60+
],
61+
},
62+
'Lint php-cs': {
63+
purpose: 'Enforces PHP coding-style rules defined in `.php-cs-fixer.dist.php`.',
64+
fix: [
65+
'Run: `composer run cs:fix`',
66+
'Review the diff with `git diff`, then commit the formatted files.',
67+
],
68+
},
69+
'PHPUnit SQLite': {
70+
purpose: 'Runs the PHPUnit test suite against a SQLite database.',
71+
fix: [
72+
'Run the failing test locally: `NOCOVERAGE=1 ./autotest.sh sqlite path/to/SpecificTest.php`',
73+
],
74+
},
75+
'PHPUnit MariaDB': {
76+
purpose: 'Runs the PHPUnit test suite against a MariaDB database to catch DB-specific issues.',
77+
fix: [
78+
'Run locally: `NOCOVERAGE=1 ./autotest.sh mariadb path/to/SpecificTest.php`',
79+
'If MariaDB is unavailable locally, reproduce with SQLite first, then check if the failure is DB-specific.',
80+
],
81+
},
82+
'PHPUnit mysql': {
83+
purpose: 'Runs the PHPUnit test suite against a MySQL database.',
84+
fix: [
85+
'Run locally: `NOCOVERAGE=1 ./autotest.sh mysql path/to/SpecificTest.php`',
86+
],
87+
},
88+
'PHPUnit PostgreSQL': {
89+
purpose: 'Runs the PHPUnit test suite against a PostgreSQL database.',
90+
fix: [
91+
'Run locally: `NOCOVERAGE=1 ./autotest.sh pgsql path/to/SpecificTest.php`',
92+
],
93+
},
94+
'PHPUnit OCI': {
95+
purpose: 'Runs the PHPUnit test suite against Oracle Database (OCI).',
96+
fix: [
97+
'OCI is not easily available locally; check the job logs for the specific test failure.',
98+
'Try reproducing with SQLite first: `NOCOVERAGE=1 ./autotest.sh sqlite path/to/SpecificTest.php`',
99+
'If SQLite passes, the failure is OCI-specific — examine raw SQL in the test or migration.',
100+
],
101+
},
102+
'PHPUnit nodb': {
103+
purpose: 'Runs PHPUnit tests that do not require a database (unit tests and mocked integration tests).',
104+
fix: [
105+
'Run locally: `NOCOVERAGE=1 ./autotest.sh sqlite path/to/SpecificTest.php`',
106+
],
107+
},
108+
'PHPUnit 32bits': {
109+
purpose: 'Runs the PHPUnit suite in a 32-bit PHP environment to catch integer overflow and platform-specific issues.',
110+
fix: [
111+
'Check the job log for the specific failing assertion.',
112+
'Common causes: integer arithmetic that overflows 32-bit, or bitwise operations with unexpected sign extension.',
113+
'Fix by using float or string where needed for large numbers.',
114+
],
115+
},
116+
'PHPUnit memcached': {
117+
purpose: 'Runs PHPUnit tests with a Memcached cache backend to validate cache-layer behaviour.',
118+
fix: [
119+
'Check the job log for the failing test.',
120+
'Reproduce locally with SQLite first. If that passes, the issue is Memcached-specific — inspect cache code paths.',
121+
],
122+
},
123+
'PHPUnit sharding': {
124+
purpose: 'Runs PHPUnit tests with database sharding enabled to validate multi-database configurations.',
125+
fix: [
126+
'Check the job log for the failing test.',
127+
'Reproduce with SQLite first; if it passes, the issue is sharding-specific.',
128+
],
129+
},
130+
'PHPUnit primary object store': {
131+
purpose: 'Runs PHPUnit tests with an object store (S3-compatible) as the primary storage backend.',
132+
fix: [
133+
'Check the job log for the failing test.',
134+
'Failures often indicate code that assumes local filesystem paths; adjust to use storage abstraction APIs.',
135+
],
136+
},
137+
'PHPUnit files_external generic': {
138+
purpose: 'Runs files_external integration tests with a local storage backend.',
139+
fix: [
140+
'Run locally: `NOCOVERAGE=1 ./autotest.sh sqlite apps/files_external/tests/`',
141+
],
142+
},
143+
'PHPUnit files_external FTP': {
144+
purpose: 'Runs files_external integration tests against an FTP server.',
145+
fix: [
146+
'Check the job log for the specific test failure.',
147+
'Review changes to `apps/files_external/lib/Storage/FTP.php`.',
148+
],
149+
},
150+
'PHPUnit files_external S3': {
151+
purpose: 'Runs files_external integration tests against an S3-compatible object store.',
152+
fix: [
153+
'Check the job log for the specific test failure.',
154+
'Review changes to `apps/files_external/lib/Storage/AmazonS3.php`.',
155+
],
156+
},
157+
'PHPUnit files_external sFTP': {
158+
purpose: 'Runs files_external integration tests against an sFTP server.',
159+
fix: [
160+
'Check the job log for the specific test failure.',
161+
'Review changes to `apps/files_external/lib/Storage/SFTP.php`.',
162+
],
163+
},
164+
'PHPUnit files_external SMB': {
165+
purpose: 'Runs files_external integration tests against an SMB/CIFS share.',
166+
fix: [
167+
'Check the job log for the specific test failure.',
168+
'Review changes to the SMB storage backend in `apps/files_external/lib/Storage/`.',
169+
],
170+
},
171+
'PHPUnit files_external WebDAV': {
172+
purpose: 'Runs files_external integration tests against a WebDAV server.',
173+
fix: [
174+
'Check the job log for the specific test failure.',
175+
'Review changes to `apps/files_external/lib/Storage/DAV.php`.',
176+
],
177+
},
178+
'Samba Kerberos SSO': {
179+
purpose: 'Runs Kerberos SSO integration tests against a Samba server to validate Active Directory authentication.',
180+
fix: [
181+
'Check the job log for the specific test failure.',
182+
'Failures usually relate to changes in auth middleware, LDAP, or Kerberos ticket handling.',
183+
],
184+
},
185+
'Object storage S3': {
186+
purpose: 'Runs integration tests with S3 as the primary object store backend.',
187+
fix: [
188+
'Check the job log for the failing test.',
189+
'Review changes to `lib/private/Files/ObjectStore/`.',
190+
],
191+
},
192+
'Object storage azure': {
193+
purpose: 'Runs integration tests with Azure Blob Storage as the primary object store backend.',
194+
fix: [
195+
'Check the job log for the failing test.',
196+
'Review changes to `lib/private/Files/ObjectStore/Azure.php`.',
197+
],
198+
},
199+
'Object storage Swift': {
200+
purpose: 'Runs integration tests with OpenStack Swift as the primary object store backend.',
201+
fix: [
202+
'Check the job log for the failing test.',
203+
'Review changes to `lib/private/Files/ObjectStore/Swift.php`.',
204+
],
205+
},
206+
'S3 primary storage integration tests': {
207+
purpose: 'Runs higher-level integration tests specifically for S3 as the primary storage engine.',
208+
fix: [
209+
'Check the job log for the failing test.',
210+
'Review changes touching `lib/private/Files/ObjectStore/` or S3-related configuration.',
211+
],
212+
},
213+
'Integration sqlite': {
214+
purpose: 'Runs Behat/integration test scenarios against a SQLite-backed Nextcloud instance.',
215+
fix: [
216+
'Check the job log for the failing scenario and feature file path.',
217+
'Run locally: `./vendor/bin/behat --config tests/Integration/config/behat.yml <feature-file>`',
218+
],
219+
},
220+
'DAV integration tests': {
221+
purpose: 'Runs WebDAV protocol integration tests (PROPFIND, MKCOL, PUT, etc.) against a live Nextcloud instance.',
222+
fix: [
223+
'Check the job log for the failing scenario.',
224+
'Run locally: `./vendor/bin/behat --config apps/dav/tests/integration/config/behat.yml`',
225+
],
226+
},
227+
'Litmus integration tests': {
228+
purpose: 'Runs the litmus WebDAV test suite to validate RFC compliance of the DAV endpoint.',
229+
fix: [
230+
'Check the litmus output in the job log for the failing test case.',
231+
'Failures indicate a regression in WebDAV PROPFIND/PROPPATCH handling; review `apps/dav/lib/`.',
232+
],
233+
},
234+
'Psalm static code analysis': {
235+
purpose: 'Runs Psalm to find type errors, undefined variables, and other static analysis issues across the PHP codebase.',
236+
fix: [
237+
'Run locally: `./psalm.sh`',
238+
'Fix the reported type errors or add type annotations.',
239+
'If the baseline needs updating: `composer run psalm -- --update-baseline`, then commit the updated `psalm-baseline.xml`.',
240+
],
241+
},
242+
'Cypress': {
243+
purpose: 'Runs end-to-end browser tests against a live Nextcloud instance using Cypress.',
244+
fix: [
245+
'Check the job log and artifacts for screenshots/videos of the failing spec.',
246+
'Run locally: `npm run cypress:run -- --spec "cypress/e2e/path/to/failing.cy.ts"`',
247+
'Or open interactively: `npm run cypress:open`',
248+
'Ensure your local Nextcloud instance is running before executing Cypress.',
249+
],
250+
},
251+
'Node': {
252+
purpose: 'Builds JavaScript/TypeScript/Vue assets and verifies no uncommitted compiled files remain.',
253+
fix: [
254+
'Run: `npm ci && npm run build`',
255+
'Then commit the regenerated compiled assets: `git add js/ && git commit -m "build: recompile assets"`',
256+
],
257+
},
258+
'Node tests': {
259+
purpose: 'Runs JavaScript unit tests (Jest/Vitest) and collects coverage.',
260+
fix: [
261+
'Run locally: `npm run test`',
262+
'Check the test output for the specific failing spec file and assertion.',
263+
],
264+
},
265+
'REUSE Compliance Check': {
266+
purpose: 'Validates that every file carries a valid SPDX-FileCopyrightText and SPDX-License-Identifier header (REUSE spec).',
267+
fix: [
268+
'Add SPDX headers to every new file. Example for PHP/JS/TS:',
269+
'`// SPDX-FileCopyrightText: 2026 Your Name <you@example.com>`',
270+
'`// SPDX-License-Identifier: AGPL-3.0-or-later`',
271+
'For YAML/shell use `#` comments; for Vue files use `<!-- ... -->` in the template.',
272+
'Verify locally: `reuse lint` (install with `pip install reuse`).',
273+
],
274+
},
275+
'OpenAPI': {
276+
purpose: 'Checks that the OpenAPI specification files are up-to-date with the current route and response type annotations.',
277+
fix: [
278+
'Run: `./occ openapi:generate`',
279+
'Commit the updated `openapi.json` / `openapi-*.json` files.',
280+
],
281+
},
282+
'Rector': {
283+
purpose: 'Runs Rector in strict mode to detect code patterns that should be modernised according to project rules.',
284+
fix: [
285+
'Run locally: `composer run rector:strict`',
286+
'Review the diff with `git diff`, then commit the changes.',
287+
],
288+
},
289+
'Block fixup and squash commits': {
290+
purpose: 'Rejects PRs that contain `fixup!` or `squash!` commits, which must be squashed before merging.',
291+
fix: [
292+
'Squash away the fixup commits with an interactive rebase:',
293+
'`git rebase -i HEAD~N` (replace N with the number of commits to review)',
294+
'Mark fixup commits as `fixup` or `squash` in the editor, then force-push.',
295+
],
296+
},
297+
'Block unconventional commits': {
298+
purpose: 'Enforces the Conventional Commits format (e.g. `feat:`, `fix:`, `chore:`) for all commit messages on the PR.',
299+
fix: [
300+
'Rewrite commit messages that do not follow the Conventional Commits spec.',
301+
'Format: `<type>(<optional scope>): <description>`',
302+
'Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.',
303+
'Use `git rebase -i HEAD~N` to reword individual commits, then force-push.',
304+
],
305+
},
306+
'Block merging with outdated 3rdparty/': {
307+
purpose: 'Ensures the `3rdparty/` submodule points to the latest commit on the target branch.',
308+
fix: [
309+
'Update the submodule: `git submodule update --remote 3rdparty`',
310+
'Commit the result: `git add 3rdparty && git commit -m "chore: Update 3rdparty"`',
311+
],
312+
},
313+
'Code checkers': {
314+
purpose: 'Runs PHP-based consistency checkers: autoloader integrity, translation file validity, .htaccess rules, expected file list, and Psalm app coverage.',
315+
fix: [
316+
'Check the job log to identify which specific checker failed, then run it locally:',
317+
'- Autoloader: `bash ./build/autoloaderchecker.sh`',
318+
'- Translations: `php ./build/translation-checker.php`',
319+
'- Htaccess: `php ./build/htaccess-checker.php`',
320+
'- Files list: `php ./build/files-checker.php`',
321+
'- Psalm app coverage: `sh ./build/psalm-checker.sh`',
322+
],
323+
},
324+
'CodeQL Advanced': {
325+
purpose: 'Runs GitHub CodeQL security analysis on JavaScript/TypeScript and GitHub Actions workflow files.',
326+
fix: [
327+
'Go to the "Security" tab of this repository and review the CodeQL alert linked in the job log.',
328+
'Fix the identified security issue — click "Show more" on the alert for remediation guidance.',
329+
],
330+
},
331+
}
332+
333+
// Resolve PR head SHA
334+
const pull = await github.rest.pulls.get({
335+
owner: context.repo.owner,
336+
repo: context.repo.repo,
337+
pull_number: context.issue.number,
338+
})
339+
const headSha = pull.data.head.sha
340+
341+
// Find completed workflow runs for this commit
342+
const { data: runs } = await github.rest.actions.listWorkflowRunsForRepo({
343+
owner: context.repo.owner,
344+
repo: context.repo.repo,
345+
head_sha: headSha,
346+
status: 'completed',
347+
per_page: 100,
348+
})
349+
350+
// Deduplicate by workflow name, keeping only the most recent run per workflow
351+
const latestByName = new Map()
352+
for (const run of runs.workflow_runs) {
353+
const existing = latestByName.get(run.name)
354+
if (!existing || new Date(run.updated_at) > new Date(existing.updated_at)) {
355+
latestByName.set(run.name, run)
356+
}
357+
}
358+
359+
const failed = [...latestByName.values()].filter(r => r.conclusion === 'failure')
360+
361+
if (failed.length === 0) {
362+
await github.rest.issues.createComment({
363+
owner: context.repo.owner,
364+
repo: context.repo.repo,
365+
issue_number: context.issue.number,
366+
body: `No failing CI checks found for commit \`${headSha.substring(0, 7)}\`. All completed checks are passing (some may still be running).`,
367+
})
368+
return
369+
}
370+
371+
// Build summary comment
372+
const shortSha = headSha.substring(0, 7)
373+
const sections = failed.map(run => {
374+
const info = WORKFLOW_EXPLANATIONS[run.name]
375+
if (!info) {
376+
return `### ${run.name}\n\nPlease [check the run logs](${run.html_url}) for details.`
377+
}
378+
const fixLines = info.fix.map(line => `- ${line}`).join('\n')
379+
return `### ${run.name}\n\n**What this checks:** ${info.purpose}\n\n**How to fix locally:**\n${fixLines}\n\n[View failed run](${run.html_url})`
380+
})
381+
382+
const body = [
383+
`## CI failure summary`,
384+
``,
385+
`Found **${failed.length} failing check(s)** on commit \`${shortSha}\`:`,
386+
``,
387+
`---`,
388+
``,
389+
sections.join('\n\n---\n\n'),
390+
].join('\n')
391+
392+
await github.rest.issues.createComment({
393+
owner: context.repo.owner,
394+
repo: context.repo.repo,
395+
issue_number: context.issue.number,
396+
body,
397+
})

0 commit comments

Comments
 (0)