Skip to content

Commit 7baf8df

Browse files
ivanthaclaude
andcommitted
ci: unified PR comment, squash check, fork support
Replace 3 separate bot comments with a single combined PR comment. Switch pull_request to pull_request_target for fork auto-approval. Add squash enforcement (must be exactly 1 commit). Remove manual /lint comment trigger. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6d439c6 commit 7baf8df

1 file changed

Lines changed: 111 additions & 177 deletions

File tree

.github/workflows/lint.yml

Lines changed: 111 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
name: Lint
22

33
on:
4-
pull_request:
4+
pull_request_target:
55
branches: [main]
66
push:
77
branches: [main]
8-
issue_comment:
9-
types: [created]
108

119
concurrency:
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:
2422
jobs:
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

Comments
 (0)