11name : Lint
22
33on :
4- pull_request :
4+ pull_request_target :
55 branches : [main]
66 push :
77 branches : [main]
8- issue_comment :
9- types : [created]
108
119concurrency :
1210 group : >-
13- lint -${{
14- github.event_name == 'issue_comment '
15- && format('pr-{0}', github.event.issue .number)
11+ ci -${{
12+ github.event_name == 'pull_request_target '
13+ && format('pr-{0}', github.event.pull_request .number)
1614 || github.ref
1715 }}
1816 cancel-in-progress : true
@@ -24,21 +22,18 @@ permissions:
2422jobs :
2523 frontend-lint :
2624 name : Frontend Lint
27- if : >-
28- github.event_name != 'issue_comment'
29- || (
30- github.event.issue.pull_request
31- && contains(github.event.comment.body, '/lint')
32- && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
33- )
3425 runs-on : ubuntu-latest
26+ outputs :
27+ eslint : ${{ steps.eslint.outcome }}
28+ prettier : ${{ steps.prettier.outcome }}
3529 defaults :
3630 run :
3731 working-directory : imagelab-frontend
3832 steps :
3933 - uses : actions/checkout@v4
4034 with :
41- ref : ${{ github.event_name == 'issue_comment' && format('refs/pull/{0}/head', github.event.issue.number) || '' }}
35+ repository : ${{ github.event.pull_request.head.repo.full_name || github.repository }}
36+ ref : ${{ github.event.pull_request.head.sha || '' }}
4237 - uses : actions/setup-node@v4
4338 with :
4439 node-version : 20
@@ -56,77 +51,24 @@ jobs:
5651 continue-on-error : true
5752 run : npx prettier --check "src/**/*.{ts,tsx,js,jsx,json,css}"
5853
59- - name : Comment on PR
60- if : github.event_name != 'push' && (steps.eslint.outcome == 'failure' || steps.prettier.outcome == 'failure')
61- continue-on-error : true
62- uses : actions/github-script@v7
63- with :
64- script : |
65- const marker = '<!-- lint-frontend-bot -->';
66- let body = marker + '\n## Frontend Lint Failed\n\n';
67- if ('${{ steps.eslint.outcome }}' === 'failure') {
68- body += '**ESLint:** Run `npx eslint --fix .` in `imagelab-frontend/`\n\n';
69- }
70- if ('${{ steps.prettier.outcome }}' === 'failure') {
71- body += '**Prettier:** Run `npx prettier --write "src/**/*.{ts,tsx,js,jsx,json,css}"` in `imagelab-frontend/`\n\n';
72- }
73- const { data: comments } = await github.rest.issues.listComments({
74- owner: context.repo.owner, repo: context.repo.repo,
75- issue_number: context.issue.number,
76- });
77- const existing = comments.find(c => c.body.includes(marker));
78- if (existing) {
79- await github.rest.issues.updateComment({
80- owner: context.repo.owner, repo: context.repo.repo,
81- comment_id: existing.id, body,
82- });
83- } else {
84- await github.rest.issues.createComment({
85- owner: context.repo.owner, repo: context.repo.repo,
86- issue_number: context.issue.number, body,
87- });
88- }
89-
90- - name : Delete stale comment
91- if : github.event_name != 'push' && steps.eslint.outcome == 'success' && steps.prettier.outcome == 'success'
92- continue-on-error : true
93- uses : actions/github-script@v7
94- with :
95- script : |
96- const marker = '<!-- lint-frontend-bot -->';
97- const { data: comments } = await github.rest.issues.listComments({
98- owner: context.repo.owner, repo: context.repo.repo,
99- issue_number: context.issue.number,
100- });
101- const existing = comments.find(c => c.body.includes(marker));
102- if (existing) {
103- await github.rest.issues.deleteComment({
104- owner: context.repo.owner, repo: context.repo.repo,
105- comment_id: existing.id,
106- });
107- }
108-
10954 - name : Fail if checks failed
11055 if : steps.eslint.outcome == 'failure' || steps.prettier.outcome == 'failure'
11156 run : exit 1
11257
11358 backend-lint :
11459 name : Backend Lint
115- if : >-
116- github.event_name != 'issue_comment'
117- || (
118- github.event.issue.pull_request
119- && contains(github.event.comment.body, '/lint')
120- && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
121- )
12260 runs-on : ubuntu-latest
61+ outputs :
62+ ruff-lint : ${{ steps.ruff-lint.outcome }}
63+ ruff-format : ${{ steps.ruff-format.outcome }}
12364 defaults :
12465 run :
12566 working-directory : imagelab-backend
12667 steps :
12768 - uses : actions/checkout@v4
12869 with :
129- ref : ${{ github.event_name == 'issue_comment' && format('refs/pull/{0}/head', github.event.issue.number) || '' }}
70+ repository : ${{ github.event.pull_request.head.repo.full_name || github.repository }}
71+ ref : ${{ github.event.pull_request.head.sha || '' }}
13072 - uses : astral-sh/setup-uv@v4
13173 - uses : actions/setup-python@v5
13274 with :
@@ -143,121 +85,132 @@ jobs:
14385 continue-on-error : true
14486 run : uv run ruff format --check .
14587
146- - name : Comment on PR
147- if : github.event_name != 'push' && (steps.ruff-lint.outcome == 'failure' || steps.ruff-format.outcome == 'failure')
148- continue-on-error : true
149- uses : actions/github-script@v7
150- with :
151- script : |
152- const marker = '<!-- lint-backend-bot -->';
153- let body = marker + '\n## Backend Lint Failed\n\n';
154- if ('${{ steps.ruff-lint.outcome }}' === 'failure') {
155- body += '**Ruff lint:** Run `uv run ruff check --fix .` in `imagelab-backend/`\n\n';
156- }
157- if ('${{ steps.ruff-format.outcome }}' === 'failure') {
158- body += '**Ruff format:** Run `uv run ruff format .` in `imagelab-backend/`\n\n';
159- }
160- const { data: comments } = await github.rest.issues.listComments({
161- owner: context.repo.owner, repo: context.repo.repo,
162- issue_number: context.issue.number,
163- });
164- const existing = comments.find(c => c.body.includes(marker));
165- if (existing) {
166- await github.rest.issues.updateComment({
167- owner: context.repo.owner, repo: context.repo.repo,
168- comment_id: existing.id, body,
169- });
170- } else {
171- await github.rest.issues.createComment({
172- owner: context.repo.owner, repo: context.repo.repo,
173- issue_number: context.issue.number, body,
174- });
175- }
176-
177- - name : Delete stale comment
178- if : github.event_name != 'push' && steps.ruff-lint.outcome == 'success' && steps.ruff-format.outcome == 'success'
179- continue-on-error : true
180- uses : actions/github-script@v7
181- with :
182- script : |
183- const marker = '<!-- lint-backend-bot -->';
184- const { data: comments } = await github.rest.issues.listComments({
185- owner: context.repo.owner, repo: context.repo.repo,
186- issue_number: context.issue.number,
187- });
188- const existing = comments.find(c => c.body.includes(marker));
189- if (existing) {
190- await github.rest.issues.deleteComment({
191- owner: context.repo.owner, repo: context.repo.repo,
192- comment_id: existing.id,
193- });
194- }
195-
19688 - name : Fail if checks failed
19789 if : steps.ruff-lint.outcome == 'failure' || steps.ruff-format.outcome == 'failure'
19890 run : exit 1
19991
20092 rebase-check :
20193 name : Rebase Check
202- if : >-
203- github.event_name == 'pull_request'
204- || (
205- github.event_name == 'issue_comment'
206- && github.event.issue.pull_request
207- && contains(github.event.comment.body, '/lint')
208- && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
209- )
94+ if : github.event_name == 'pull_request_target'
21095 runs-on : ubuntu-latest
96+ outputs :
97+ behind_count : ${{ steps.rebase.outputs.behind_count }}
98+ merge_commits : ${{ steps.rebase.outputs.merge_commits }}
21199 steps :
212100 - uses : actions/checkout@v4
213101 with :
214102 fetch-depth : 0
215- ref : ${{ github.event_name == 'issue_comment' && format('refs/pull/{0}/head', github.event.issue.number) || '' }}
103+
104+ - name : Fetch PR head
105+ env :
106+ PR_NUMBER : ${{ github.event.pull_request.number }}
107+ run : git fetch origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr-head"
216108
217109 - name : Check rebase status
218110 id : rebase
219111 run : |
220- git fetch origin main
221-
222- # Check for merge commits in the PR branch
223- MERGE_COMMITS=$(git log --merges origin/main..HEAD --oneline)
224-
225- # Check how many commits the branch is behind main
226- BEHIND_COUNT=$(git rev-list --count HEAD..origin/main)
112+ MERGE_COMMITS=$(git log --merges origin/main..origin/pr-head --oneline)
113+ BEHIND_COUNT=$(git rev-list --count origin/pr-head..origin/main)
227114
228115 echo "merge_commits<<EOF" >> "$GITHUB_OUTPUT"
229116 echo "$MERGE_COMMITS" >> "$GITHUB_OUTPUT"
230117 echo "EOF" >> "$GITHUB_OUTPUT"
231118 echo "behind_count=$BEHIND_COUNT" >> "$GITHUB_OUTPUT"
232119
233- - name : Comment if rebase needed
234- if : steps.rebase.outputs.behind_count != '0' || steps.rebase.outputs.merge_commits != ''
235- continue-on-error : true
120+ pr-comment :
121+ name : PR Comment
122+ needs : [frontend-lint, backend-lint, rebase-check]
123+ if : always() && github.event_name == 'pull_request_target'
124+ runs-on : ubuntu-latest
125+ steps :
126+ - name : Post or delete combined comment
236127 uses : actions/github-script@v7
237128 env :
238- BEHIND_COUNT : ${{ steps.rebase.outputs.behind_count }}
239- MERGE_COMMITS : ${{ steps.rebase.outputs.merge_commits }}
129+ ESLINT : ${{ needs.frontend-lint.outputs.eslint }}
130+ PRETTIER : ${{ needs.frontend-lint.outputs.prettier }}
131+ RUFF_LINT : ${{ needs.backend-lint.outputs.ruff-lint }}
132+ RUFF_FORMAT : ${{ needs.backend-lint.outputs.ruff-format }}
133+ BEHIND_COUNT : ${{ needs.rebase-check.outputs.behind_count }}
134+ MERGE_COMMITS : ${{ needs.rebase-check.outputs.merge_commits }}
135+ PR_COMMITS : ${{ github.event.pull_request.commits }}
240136 with :
241137 script : |
242- const marker = '<!-- rebase-bot -->';
243- const behind = parseInt(process.env.BEHIND_COUNT);
244- const mergeCommits = (process.env.MERGE_COMMITS || '').trim();
245-
246- let body = marker + '\n## Rebase Required\n\n';
247- if (behind > 0) {
248- body += `Your branch is **${behind} commit(s) behind \`main\`**. Please rebase onto the latest \`main\`.\n\n`;
249- }
250- if (mergeCommits) {
251- body += '**Merge commits detected** in this PR. Please use rebase instead of merge:\n\n';
252- body += '```\n' + mergeCommits + '\n```\n\n';
253- }
254- body += '### How to fix\n```bash\ngit fetch origin\ngit rebase origin/main\ngit push --force-with-lease\n```\n';
138+ const marker = '<!-- ci-bot -->';
139+ const oldMarkers = ['<!-- lint-frontend-bot -->', '<!-- lint-backend-bot -->', '<!-- rebase-bot -->'];
140+ const prNumber = context.payload.pull_request.number;
255141
256142 const { data: comments } = await github.rest.issues.listComments({
257143 owner: context.repo.owner, repo: context.repo.repo,
258- issue_number: context.issue.number ,
144+ issue_number: prNumber ,
259145 });
146+
147+ // Clean up old individual comments from previous workflow version
148+ for (const old of oldMarkers) {
149+ const c = comments.find(c => c.body.includes(old));
150+ if (c) {
151+ await github.rest.issues.deleteComment({
152+ owner: context.repo.owner, repo: context.repo.repo,
153+ comment_id: c.id,
154+ });
155+ }
156+ }
157+
158+ // Build combined comment sections
159+ const sections = [];
160+
161+ const eslintFailed = process.env.ESLINT === 'failure';
162+ const prettierFailed = process.env.PRETTIER === 'failure';
163+ if (eslintFailed || prettierFailed) {
164+ let s = '### Frontend\n\n';
165+ if (eslintFailed) s += '- **ESLint:** Run `npx eslint --fix .` in `imagelab-frontend/`\n';
166+ if (prettierFailed) s += '- **Prettier:** Run `npx prettier --write "src/**/*.{ts,tsx,js,jsx,json,css}"` in `imagelab-frontend/`\n';
167+ sections.push(s);
168+ }
169+
170+ const ruffLintFailed = process.env.RUFF_LINT === 'failure';
171+ const ruffFormatFailed = process.env.RUFF_FORMAT === 'failure';
172+ if (ruffLintFailed || ruffFormatFailed) {
173+ let s = '### Backend\n\n';
174+ if (ruffLintFailed) s += '- **Ruff lint:** Run `uv run ruff check --fix .` in `imagelab-backend/`\n';
175+ if (ruffFormatFailed) s += '- **Ruff format:** Run `uv run ruff format .` in `imagelab-backend/`\n';
176+ sections.push(s);
177+ }
178+
179+ const behind = parseInt(process.env.BEHIND_COUNT || '0');
180+ const mergeCommits = (process.env.MERGE_COMMITS || '').trim();
181+ if (behind > 0 || mergeCommits) {
182+ let s = '### Rebase\n\n';
183+ if (behind > 0) s += `Your branch is **${behind} commit(s) behind \`main\`**. Please rebase.\n\n`;
184+ if (mergeCommits) s += '**Merge commits detected** — please use rebase instead of merge:\n\n```\n' + mergeCommits + '\n```\n\n';
185+ sections.push(s);
186+ }
187+
188+ const prCommits = parseInt(process.env.PR_COMMITS || '1');
189+ if (prCommits > 1) {
190+ sections.push(`### Squash\n\nYour PR has **${prCommits} commits**. Please squash into a single commit.\n`);
191+ }
192+
260193 const existing = comments.find(c => c.body.includes(marker));
194+
195+ if (sections.length === 0) {
196+ if (existing) {
197+ await github.rest.issues.deleteComment({
198+ owner: context.repo.owner, repo: context.repo.repo,
199+ comment_id: existing.id,
200+ });
201+ }
202+ return;
203+ }
204+
205+ let body = marker + '\n## PR Review\n\n';
206+ body += sections.join('\n');
207+
208+ if (behind > 0 || mergeCommits || prCommits > 1) {
209+ body += '\n### How to fix\n```bash\ngit fetch origin\ngit rebase -i origin/main # mark all but first commit as "squash"\ngit push --force-with-lease\n```\n';
210+ }
211+
212+ body += '\n---\n*This comment updates automatically on each push.*\n';
213+
261214 if (existing) {
262215 await github.rest.issues.updateComment({
263216 owner: context.repo.owner, repo: context.repo.repo,
@@ -266,25 +219,6 @@ jobs:
266219 } else {
267220 await github.rest.issues.createComment({
268221 owner: context.repo.owner, repo: context.repo.repo,
269- issue_number: context.issue.number, body,
270- });
271- }
272-
273- - name : Delete stale comment
274- if : steps.rebase.outputs.behind_count == '0' && steps.rebase.outputs.merge_commits == ''
275- continue-on-error : true
276- uses : actions/github-script@v7
277- with :
278- script : |
279- const marker = '<!-- rebase-bot -->';
280- const { data: comments } = await github.rest.issues.listComments({
281- owner: context.repo.owner, repo: context.repo.repo,
282- issue_number: context.issue.number,
283- });
284- const existing = comments.find(c => c.body.includes(marker));
285- if (existing) {
286- await github.rest.issues.deleteComment({
287- owner: context.repo.owner, repo: context.repo.repo,
288- comment_id: existing.id,
222+ issue_number: prNumber, body,
289223 });
290224 }
0 commit comments