Skip to content

Commit fdd8e8f

Browse files
Libba LawrenceCopilot
andcommitted
Add /chronus add slash-command auto-fix workflow
On chronus-verify failure, post a sticky PR comment instructing the contributor to comment '/chronus add [kind]' for a one-click fix. Add chronus-fix.yml: an issue_comment-triggered workflow that verifies commenter permissions, rejects fork PRs (security), parses the requested kind, runs 'chronus add <package> --kind X --message <PR title>' for each missing package, and pushes the result back to the PR branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3c3c6aa commit fdd8e8f

3 files changed

Lines changed: 380 additions & 4 deletions

File tree

.github/workflows/chronus-fix.yml

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
name: Chronus Fix (slash command)
2+
3+
# Listens for `/chronus add [kind]` comments on PRs and commits a Chronus
4+
# change entry derived from the PR title back to the PR branch.
5+
#
6+
# Security note: this workflow runs in default-branch context with a
7+
# write-scoped GITHUB_TOKEN. Because we install the chronus tooling pinned
8+
# at .github/chronus/* from the PR head, we restrict execution to PRs that
9+
# originate from the same repository. Fork PRs receive a comment instructing
10+
# the contributor to run the command locally instead.
11+
12+
on:
13+
issue_comment:
14+
types: [created]
15+
16+
permissions:
17+
contents: write
18+
issues: write
19+
pull-requests: write
20+
21+
concurrency:
22+
group: chronus-fix-${{ github.event.issue.number }}
23+
cancel-in-progress: false
24+
25+
jobs:
26+
chronus-fix:
27+
name: Apply chronus add via slash command
28+
runs-on: ubuntu-latest
29+
if: >-
30+
github.event.issue.pull_request != null &&
31+
startsWith(github.event.comment.body, '/chronus add')
32+
steps:
33+
- name: Verify commenter has write permission
34+
uses: actions/github-script@v7
35+
with:
36+
script: |
37+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
38+
owner: context.repo.owner,
39+
repo: context.repo.repo,
40+
username: context.payload.comment.user.login,
41+
});
42+
if (!['admin', 'maintain', 'write'].includes(data.permission)) {
43+
core.setFailed(
44+
`User ${context.payload.comment.user.login} lacks write permission ` +
45+
`(has: ${data.permission}).`
46+
);
47+
}
48+
49+
- name: React 👀 to the comment
50+
uses: actions/github-script@v7
51+
with:
52+
script: |
53+
await github.rest.reactions.createForIssueComment({
54+
owner: context.repo.owner,
55+
repo: context.repo.repo,
56+
comment_id: context.payload.comment.id,
57+
content: 'eyes',
58+
});
59+
60+
- name: Get PR metadata and reject fork PRs
61+
id: pr
62+
uses: actions/github-script@v7
63+
with:
64+
script: |
65+
const { data: pr } = await github.rest.pulls.get({
66+
owner: context.repo.owner,
67+
repo: context.repo.repo,
68+
pull_number: context.payload.issue.number,
69+
});
70+
71+
const isFork = pr.head.repo.full_name !== pr.base.repo.full_name;
72+
if (isFork) {
73+
await github.rest.issues.createComment({
74+
owner: context.repo.owner,
75+
repo: context.repo.repo,
76+
issue_number: context.payload.issue.number,
77+
body: [
78+
'⚠️ `/chronus add` cannot push to fork PR branches.',
79+
'',
80+
'Please run the following locally and push the resulting file:',
81+
'',
82+
'```bash',
83+
'azpysdk changelog add',
84+
'```',
85+
].join('\n'),
86+
});
87+
core.setFailed('Fork PRs are not supported by /chronus add.');
88+
return;
89+
}
90+
91+
// Validate ref shape (defensive — refs come from the API but we
92+
// pass them to git below). Allow standard branch characters only.
93+
if (!/^[A-Za-z0-9._/\-]+$/.test(pr.head.ref) ||
94+
!/^[A-Za-z0-9._/\-]+$/.test(pr.base.ref)) {
95+
core.setFailed(`Refusing unusual ref name. head=${pr.head.ref} base=${pr.base.ref}`);
96+
return;
97+
}
98+
99+
core.setOutput('head_ref', pr.head.ref);
100+
core.setOutput('head_sha', pr.head.sha);
101+
core.setOutput('base_ref', pr.base.ref);
102+
core.setOutput('title', pr.title);
103+
104+
- name: Parse requested kind from comment
105+
id: kind
106+
env:
107+
BODY: ${{ github.event.comment.body }}
108+
run: |
109+
set -euo pipefail
110+
# Extract the third whitespace-separated token: "/chronus add <kind>".
111+
token="$(printf '%s' "$BODY" | awk '{print $3}' | tr -d '\r')"
112+
case "${token,,}" in
113+
""|internal) kind="internal" ;;
114+
fix|bug) kind="fix" ;;
115+
feature|features) kind="feature" ;;
116+
breaking) kind="breaking" ;;
117+
deprecation|deprecated) kind="deprecation" ;;
118+
dependencies|deps) kind="dependencies" ;;
119+
*)
120+
echo "::error::Unknown kind '$token'. Allowed: internal, fix, feature, breaking, deprecation, dependencies."
121+
exit 1
122+
;;
123+
esac
124+
echo "kind=$kind" >> "$GITHUB_OUTPUT"
125+
126+
- name: Checkout PR head
127+
uses: actions/checkout@v4
128+
with:
129+
# We're already restricted to same-repo PRs above, so the default
130+
# repository (github.repository) is the right source.
131+
ref: ${{ steps.pr.outputs.head_sha }}
132+
fetch-depth: 0
133+
persist-credentials: true
134+
token: ${{ secrets.GITHUB_TOKEN }}
135+
136+
- name: Fetch base branch for chronus diff
137+
env:
138+
BASE_REF: ${{ steps.pr.outputs.base_ref }}
139+
run: |
140+
set -euo pipefail
141+
# Chronus reads `baseBranch` from .chronus/config.yaml (currently
142+
# "main") and diffs against it via git. Make sure that branch ref
143+
# exists locally.
144+
git fetch origin "$BASE_REF:$BASE_REF" || git fetch origin "$BASE_REF"
145+
146+
- name: Setup Node
147+
uses: actions/setup-node@v4
148+
with:
149+
node-version: lts/*
150+
cache: npm
151+
cache-dependency-path: .github/chronus/package-lock.json
152+
153+
- name: Install pinned chronus
154+
run: npm ci
155+
working-directory: .github/chronus
156+
157+
- name: Determine packages missing a change entry
158+
id: missing
159+
run: |
160+
set -uo pipefail
161+
# `chronus verify` exits non-zero when entries are missing. Capture
162+
# output and parse the package list. Don't fail this step on
163+
# non-zero exit because the missing-entries case is exactly what
164+
# we want to handle.
165+
out="$(.github/chronus/node_modules/.bin/chronus verify 2>&1)" || true
166+
printf '%s\n' "$out"
167+
168+
# Extract anything that looks like sdk/<service>/<package>.
169+
pkgs="$(printf '%s\n' "$out" | grep -oE 'sdk/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+' | sort -u || true)"
170+
if [ -z "$pkgs" ]; then
171+
echo "found=0" >> "$GITHUB_OUTPUT"
172+
else
173+
echo "found=1" >> "$GITHUB_OUTPUT"
174+
{
175+
echo 'packages<<CHRONUS_EOF'
176+
printf '%s\n' "$pkgs"
177+
echo 'CHRONUS_EOF'
178+
} >> "$GITHUB_OUTPUT"
179+
fi
180+
181+
- name: Add chronus entry per package
182+
if: steps.missing.outputs.found == '1'
183+
env:
184+
KIND: ${{ steps.kind.outputs.kind }}
185+
MESSAGE: ${{ steps.pr.outputs.title }}
186+
PACKAGES: ${{ steps.missing.outputs.packages }}
187+
run: |
188+
set -euo pipefail
189+
while IFS= read -r pkg_path; do
190+
[ -z "$pkg_path" ] && continue
191+
pkg_name="$(basename "$pkg_path")"
192+
echo "::group::chronus add $pkg_name ($KIND)"
193+
# `chronus add` takes the package name as a positional argument.
194+
.github/chronus/node_modules/.bin/chronus add \
195+
"$pkg_name" \
196+
--kind "$KIND" \
197+
--message "$MESSAGE"
198+
echo "::endgroup::"
199+
done <<< "$PACKAGES"
200+
201+
- name: Commit and push
202+
id: push
203+
if: steps.missing.outputs.found == '1'
204+
env:
205+
HEAD_REF: ${{ steps.pr.outputs.head_ref }}
206+
ACTOR: ${{ github.event.comment.user.login }}
207+
ACTOR_ID: ${{ github.event.comment.user.id }}
208+
PR_NUMBER: ${{ github.event.issue.number }}
209+
run: |
210+
set -euo pipefail
211+
# Defensive validation — reject unusual branch names.
212+
git check-ref-format --branch "$HEAD_REF"
213+
214+
git config user.name 'github-actions[bot]'
215+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
216+
git add .chronus/changes/
217+
if git diff --cached --quiet; then
218+
echo "Nothing staged — chronus add did not produce a file."
219+
echo "pushed=0" >> "$GITHUB_OUTPUT"
220+
exit 0
221+
fi
222+
223+
msg="$(printf 'Add chronus changelog entry [skip ci]\n\nTriggered by /chronus add comment on PR #%s.\n\nCo-authored-by: %s <%s+%s@users.noreply.github.com>' \
224+
"$PR_NUMBER" "$ACTOR" "$ACTOR_ID" "$ACTOR")"
225+
git commit -m "$msg"
226+
227+
if git push origin "HEAD:refs/heads/$HEAD_REF"; then
228+
echo "pushed=1" >> "$GITHUB_OUTPUT"
229+
else
230+
echo "pushed=0" >> "$GITHUB_OUTPUT"
231+
echo "::warning::Push failed. The PR branch may have moved; ask the user to retry."
232+
fi
233+
234+
- name: React 🚀 and reply on success
235+
if: success() && steps.push.outputs.pushed == '1'
236+
uses: actions/github-script@v7
237+
env:
238+
KIND: ${{ steps.kind.outputs.kind }}
239+
TITLE: ${{ steps.pr.outputs.title }}
240+
with:
241+
script: |
242+
await github.rest.reactions.createForIssueComment({
243+
owner: context.repo.owner,
244+
repo: context.repo.repo,
245+
comment_id: context.payload.comment.id,
246+
content: 'rocket',
247+
});
248+
await github.rest.issues.createComment({
249+
owner: context.repo.owner,
250+
repo: context.repo.repo,
251+
issue_number: context.payload.issue.number,
252+
body: [
253+
'✅ Pushed a Chronus change entry to this PR.',
254+
'',
255+
'- **Kind:** `' + process.env.KIND + '`',
256+
'- **Description:** _' + process.env.TITLE + '_',
257+
'',
258+
'Edit the file under `.chronus/changes/` if you\'d like to refine the description.',
259+
].join('\n'),
260+
});
261+
262+
- name: Reply on no-op
263+
if: success() && steps.missing.outputs.found != '1'
264+
uses: actions/github-script@v7
265+
with:
266+
script: |
267+
await github.rest.issues.createComment({
268+
owner: context.repo.owner,
269+
repo: context.repo.repo,
270+
issue_number: context.payload.issue.number,
271+
body: [
272+
'ℹ️ Could not detect any packages missing a Chronus change entry.',
273+
'',
274+
'Either every changed package already has an entry, or `chronus verify`',
275+
'failed for an unrelated reason — check the workflow run for details.',
276+
].join('\n'),
277+
});
278+
279+
- name: Reply on push failure
280+
if: success() && steps.missing.outputs.found == '1' && steps.push.outputs.pushed != '1'
281+
uses: actions/github-script@v7
282+
with:
283+
script: |
284+
await github.rest.reactions.createForIssueComment({
285+
owner: context.repo.owner,
286+
repo: context.repo.repo,
287+
comment_id: context.payload.comment.id,
288+
content: 'confused',
289+
});
290+
await github.rest.issues.createComment({
291+
owner: context.repo.owner,
292+
repo: context.repo.repo,
293+
issue_number: context.payload.issue.number,
294+
body: [
295+
'⚠️ Could not push a Chronus entry to this PR branch.',
296+
'',
297+
'The branch may have moved while the bot was working. Please',
298+
'comment `/chronus add` again, or run `azpysdk changelog add` locally.',
299+
].join('\n'),
300+
});
301+
302+
- name: React 👎 on failure
303+
if: failure()
304+
uses: actions/github-script@v7
305+
with:
306+
script: |
307+
await github.rest.reactions.createForIssueComment({
308+
owner: context.repo.owner,
309+
repo: context.repo.repo,
310+
comment_id: context.payload.comment.id,
311+
content: '-1',
312+
});

.github/workflows/chronus-verify.yml

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ jobs:
1414
runs-on: ubuntu-latest
1515
permissions:
1616
contents: read
17+
pull-requests: write
1718
steps:
1819
- uses: actions/checkout@v4
1920
with:
@@ -30,10 +31,70 @@ jobs:
3031
working-directory: .github/chronus
3132

3233
- name: Run chronus verify
34+
id: verify
3335
run: .github/chronus/node_modules/.bin/chronus verify
3436

35-
- name: Chronus verification failed – see docs
36-
if: failure()
37+
- name: Post sticky PR comment with one-click fix instructions
38+
if: failure() && steps.verify.conclusion == 'failure' && github.event.pull_request.head.repo.full_name == github.repository
39+
uses: actions/github-script@v7
40+
with:
41+
script: |
42+
const HEADER = '<!-- chronus-verify-sticky -->';
43+
const body = [
44+
HEADER,
45+
'### 📝 Missing changelog entry',
46+
'',
47+
'This PR touches package source under `sdk/*/*/**` but no Chronus',
48+
'change description was found. CI requires every user-affecting',
49+
'change to have one.',
50+
'',
51+
'#### ⚡ One-click fix',
52+
'',
53+
'**Comment `/chronus add` on this PR** and a bot will commit a',
54+
'changelog entry for you, derived from your PR title.',
55+
'',
56+
'Customise the entry kind by appending it to the command:',
57+
'',
58+
'- `/chronus add` &nbsp;→&nbsp; defaults to `internal`',
59+
'- `/chronus add fix` &nbsp;→&nbsp; bug fix',
60+
'- `/chronus add feature` &nbsp;→&nbsp; new feature',
61+
'- `/chronus add breaking` &nbsp;→&nbsp; breaking change',
62+
'- `/chronus add deprecation` &nbsp;→&nbsp; deprecation',
63+
'- `/chronus add dependencies` &nbsp;→&nbsp; dependency bump',
64+
'',
65+
'> ℹ️ For PRs from forks, run the command locally instead:',
66+
'>',
67+
'> ```bash',
68+
'> azpysdk changelog add',
69+
'> ```',
70+
'>',
71+
'See [`doc/dev/changelog_updates.md`](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/changelog_updates.md) for full instructions.',
72+
].join('\n');
73+
74+
const { data: comments } = await github.rest.issues.listComments({
75+
owner: context.repo.owner,
76+
repo: context.repo.repo,
77+
issue_number: context.payload.pull_request.number,
78+
});
79+
const existing = comments.find(c => c.body && c.body.startsWith(HEADER));
80+
if (existing) {
81+
await github.rest.issues.updateComment({
82+
owner: context.repo.owner,
83+
repo: context.repo.repo,
84+
comment_id: existing.id,
85+
body,
86+
});
87+
} else {
88+
await github.rest.issues.createComment({
89+
owner: context.repo.owner,
90+
repo: context.repo.repo,
91+
issue_number: context.payload.pull_request.number,
92+
body,
93+
});
94+
}
95+
96+
- name: Emit annotation on failure
97+
if: failure() && steps.verify.conclusion == 'failure'
3798
run: |
38-
echo "::error::Chronus verification failed. Add a change description with 'azpysdk changelog add' (or 'npx chronus add' if you prefer raw Chronus)."
99+
echo "::error::Chronus verification failed. Comment '/chronus add' on this PR for an automated fix, or run 'azpysdk changelog add' locally."
39100
echo "::error::See https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/changelog_updates.md for instructions."

0 commit comments

Comments
 (0)