-
Notifications
You must be signed in to change notification settings - Fork 79
294 lines (268 loc) · 11.6 KB
/
project-status-sync.yml
File metadata and controls
294 lines (268 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
name: "Sync PR Project Status"
# Mirrors a PR's review lifecycle into the GitHub Project V2 "Status" column of
# its linked issues. This repo's convention is that issues — not PRs — are
# attached to the project, so we walk PR -> linked issues -> each issue's
# project item.
#
# Event matrix:
# pull_request_target.ready_for_review -> "In review"
# pull_request_target.review_requested -> "In review" (re-review included)
# pull_request_target.converted_to_draft -> "In progress"
# pull_request_review.submitted (state=changes_requested) -> "Requested changes"
# pull_request_review.submitted (approved / commented) -> no-op
#
# Link detection combines:
# 1. GitHub's canonical `closingIssuesReferences` (covers manual "Development"
# links and closing keywords auto-detected by GitHub).
# 2. A regex fallback over the PR body that matches `(close|fix|resolve)
# [sd|es|ed]? #<num>`. GitHub only registers closing keywords for PRs
# targeting the repo's default branch, so stacked PRs (common here via
# Graphite) never populate (1). The fallback is load-bearing — without
# it this workflow would be a near-total no-op for stacked PRs.
#
# Configuration (repository or organization variables):
# vars.PROJECT_ORGANIZATION — organization login that owns the Project V2.
# User-owned projects are not supported; the
# metadata query only resolves `organization(login:)`.
# vars.PROJECT_NUMBER — project number (integer). If unset, the
# workflow is a no-op.
#
# Secret:
# secrets.PROJECT_TOKEN — PAT or GitHub App token with `project` scope.
# The default GITHUB_TOKEN cannot write to
# Projects V2. If unset, the workflow is a no-op.
#
# Notes:
# * Uses `pull_request_target` and `pull_request_review` so the workflow has
# access to secrets for PRs opened from forks. No PR code is checked out
# or executed — only metadata is used via the GraphQL API.
# * Cross-repo references (e.g. `Resolves owner/repo#123`) are not resolved.
# Only same-repo issue numbers are followed.
# * If a linked issue is not attached to the configured project, that issue
# is skipped with a notice. No project items are created.
on:
pull_request_target:
types:
- ready_for_review
- converted_to_draft
- review_requested
pull_request_review:
types:
- submitted
permissions:
pull-requests: read
jobs:
sync-project-status:
runs-on: ubuntu-latest
env:
ORGANIZATION: ${{ vars.PROJECT_ORGANIZATION }}
PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
STATUS_FIELD_NAME: "Status"
IN_REVIEW_STATUS_NAME: "In review"
IN_PROGRESS_STATUS_NAME: "In progress"
CHANGES_REQUESTED_STATUS_NAME: "Requested changes"
steps:
- name: Resolve target status from event
id: target
env:
EVENT_ACTION: ${{ github.event.action }}
REVIEW_STATE: ${{ github.event.review.state }}
run: |
case "${GITHUB_EVENT_NAME}|${EVENT_ACTION}" in
"pull_request_target|ready_for_review" | "pull_request_target|review_requested")
target="${IN_REVIEW_STATUS_NAME}"
;;
"pull_request_target|converted_to_draft")
target="${IN_PROGRESS_STATUS_NAME}"
;;
"pull_request_review|submitted")
if [ "${REVIEW_STATE}" = "changes_requested" ]; then
target="${CHANGES_REQUESTED_STATUS_NAME}"
else
echo "::notice::Review submitted with state='${REVIEW_STATE}'. No status change."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
;;
*)
echo "::notice::Unsupported event '${GITHUB_EVENT_NAME}' action='${EVENT_ACTION}'. Skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "status_name=${target}" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Fetch Project V2 metadata
id: project
if: steps.target.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.PROJECT_TOKEN }}
TARGET_STATUS: ${{ steps.target.outputs.status_name }}
run: |
if [ -z "$ORGANIZATION" ] || [ -z "$PROJECT_NUMBER" ]; then
echo "::warning::vars.PROJECT_ORGANIZATION or vars.PROJECT_NUMBER is not configured. Skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -z "$GH_TOKEN" ]; then
echo "::warning::secrets.PROJECT_TOKEN is not configured. Skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
gh api graphql \
-f query='
query($org: String!, $number: Int!) {
organization(login: $org) {
projectV2(number: $number) {
id
fields(first: 50) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options { id name }
}
}
}
}
}
}' \
-f org="$ORGANIZATION" \
-F number="$PROJECT_NUMBER" \
> project.json
project_id=$(jq -r '.data.organization.projectV2.id // empty' project.json)
if [ -z "$project_id" ]; then
echo "::warning::Project V2 '${ORGANIZATION}/#${PROJECT_NUMBER}' not found or inaccessible. Check PROJECT_TOKEN permissions. Skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
status_field_id=$(jq -r \
--arg name "$STATUS_FIELD_NAME" \
'.data.organization.projectV2.fields.nodes[]? | select(.name == $name) | .id // empty' \
project.json)
option_id=$(jq -r \
--arg name "$STATUS_FIELD_NAME" \
--arg option "$TARGET_STATUS" \
'.data.organization.projectV2.fields.nodes[]? | select(.name == $name) | .options[]? | select(.name == $option) | .id // empty' \
project.json)
if [ -z "$status_field_id" ] || [ -z "$option_id" ]; then
echo "::warning::Status field '$STATUS_FIELD_NAME' or option '$TARGET_STATUS' not found in project. Skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "project_id=$project_id" >> "$GITHUB_OUTPUT"
echo "status_field_id=$status_field_id" >> "$GITHUB_OUTPUT"
echo "option_id=$option_id" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Collect linked issue numbers
id: issues
if: steps.target.outputs.skip != 'true' && steps.project.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.PROJECT_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
# Source 1: GitHub's canonical closingIssuesReferences.
gh api graphql \
-f query='
query($url: URI!) {
resource(url: $url) {
... on PullRequest {
closingIssuesReferences(first: 20) {
nodes { number }
}
}
}
}' \
-f url="$PR_URL" \
> closing.json
api_numbers=$(jq -r '.data.resource.closingIssuesReferences.nodes[]?.number // empty' closing.json)
# Source 2: regex fallback over PR body. Many PRs in this repo use
# `Resolves #123 (FR-123)` without a GitHub-detected link; without
# this fallback the workflow would be a near-total no-op.
body_numbers=$(
printf '%s' "$PR_BODY" \
| grep -oiE '(close[sd]?|fix(es|ed)?|resolve[sd]?)[[:space:]]+#[0-9]+' \
| grep -oE '#[0-9]+' \
| tr -d '#' \
|| true
)
numbers=$(printf '%s\n%s\n' "$api_numbers" "$body_numbers" \
| grep -E '^[0-9]+$' \
| sort -u \
|| true)
if [ -z "$numbers" ]; then
echo "::notice::No linked issues detected for PR #${{ github.event.pull_request.number }}. Nothing to update."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Detected linked issues:"
echo "$numbers" | sed 's/^/ - #/'
{
echo "numbers<<__EOF__"
echo "$numbers"
echo "__EOF__"
} >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Update project status for each linked issue
if: steps.target.outputs.skip != 'true' && steps.project.outputs.skip != 'true' && steps.issues.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.PROJECT_TOKEN }}
REPO_FULL_NAME: ${{ github.event.pull_request.base.repo.full_name }}
PROJECT_ID: ${{ steps.project.outputs.project_id }}
STATUS_FIELD_ID: ${{ steps.project.outputs.status_field_id }}
OPTION_ID: ${{ steps.project.outputs.option_id }}
TARGET_STATUS: ${{ steps.target.outputs.status_name }}
NUMBERS: ${{ steps.issues.outputs.numbers }}
run: |
owner_name="${REPO_FULL_NAME%/*}"
repo_name="${REPO_FULL_NAME#*/}"
updated=0
skipped=0
while IFS= read -r issue_number; do
[ -z "$issue_number" ] && continue
gh api graphql \
-f query='
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
projectItems(first: 20) {
nodes { id project { id } }
}
}
}
}' \
-f owner="$owner_name" \
-f name="$repo_name" \
-F number="$issue_number" \
> "issue_${issue_number}.json"
item_id=$(jq -r \
--arg pid "$PROJECT_ID" \
'[.data.repository.issue.projectItems.nodes[]? | select(.project.id == $pid) | .id] | first // empty' \
"issue_${issue_number}.json")
if [ -z "$item_id" ]; then
echo "::notice::Issue #${issue_number} is not attached to '${ORGANIZATION}/#${PROJECT_NUMBER}'. Skipping."
skipped=$((skipped + 1))
continue
fi
gh api graphql \
-f query='
mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $project
itemId: $item
fieldId: $field
value: { singleSelectOptionId: $option }
}) {
projectV2Item { id }
}
}' \
-f project="$PROJECT_ID" \
-f item="$item_id" \
-f field="$STATUS_FIELD_ID" \
-f option="$OPTION_ID" \
> /dev/null
echo "::notice::Issue #${issue_number} status set to \"${TARGET_STATUS}\"."
updated=$((updated + 1))
done <<< "$NUMBERS"
echo "::notice::PR #${{ github.event.pull_request.number }} sync summary: updated=${updated}, skipped=${skipped}."