Skip to content

Commit 7d3271e

Browse files
committed
feat: add script to create GitHub milestones for tags with associated PRs
1 parent 322e0ed commit 7d3271e

File tree

1 file changed

+243
-0
lines changed

1 file changed

+243
-0
lines changed

.github/milestones.sh

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
show_help() {
5+
cat <<EOF
6+
Usage:
7+
GITHUB_TOKEN=ghp_xxx ./create_milestone_for_tag.sh [--dry-run] owner repo tag [base]
8+
9+
Positional arguments:
10+
owner GitHub owner/org (required)
11+
repo GitHub repository name (required)
12+
tag Tag to create milestone for (required)
13+
base Optional base tag/branch/sha to compute commits from (optional)
14+
15+
Flags:
16+
--dry-run Print what would be done, do not call APIs that modify data.
17+
--help Show this help and exit.
18+
19+
Examples:
20+
GITHUB_TOKEN=ghp_xxx ./create_milestone_for_tag.sh octocat Hello-World v1.2.0
21+
./create_milestone_for_tag.sh --dry-run octocat Hello-World v1.2.0
22+
./create_milestone_for_tag.sh octocat Hello-World v1.2.0 previous-tag --dry-run
23+
EOF
24+
}
25+
26+
# Parse args: flags can be anywhere
27+
DRYRUN=0
28+
POSITIONAL=()
29+
for arg in "$@"; do
30+
case "$arg" in
31+
--dry-run) DRYRUN=1; shift || true ;;
32+
--help) show_help; exit 0 ;;
33+
*) POSITIONAL+=("$arg"); shift || true ;;
34+
esac
35+
done
36+
37+
# After parsing, positional args are in POSITIONAL array
38+
if [[ ${#POSITIONAL[@]} -lt 3 ]]; then
39+
echo "ERROR: owner, repo and tag are required."
40+
show_help
41+
exit 1
42+
fi
43+
44+
OWNER="${POSITIONAL[0]}"
45+
REPO="${POSITIONAL[1]}"
46+
TAG="${POSITIONAL[2]}"
47+
BASE="${POSITIONAL[3]:-}" # optional
48+
49+
if [[ $DRYRUN -eq 1 ]]; then
50+
echo "⚠️ Dry-run mode enabled — no changes will be made."
51+
fi
52+
53+
if [[ -z "${GITHUB_TOKEN:-}" ]]; then
54+
echo "ERROR: GITHUB_TOKEN environment variable must be set (PAT with repo permissions)."
55+
exit 1
56+
fi
57+
58+
API="https://api.github.com"
59+
AUTH="Authorization: token ${GITHUB_TOKEN}"
60+
ACCEPT="Accept: application/vnd.github.groot-preview+json"
61+
62+
if ! command -v jq >/dev/null 2>&1; then
63+
echo "ERROR: jq is required. Install jq and try again."
64+
exit 1
65+
fi
66+
if ! command -v git >/dev/null 2>&1; then
67+
echo "ERROR: git is required. Install git and try again."
68+
exit 1
69+
fi
70+
71+
# If base not provided, attempt to find previous tag ordered by creation date (descending)
72+
if [[ -z "$BASE" ]]; then
73+
if git rev-parse --verify "$TAG" >/dev/null 2>&1; then
74+
mapfile -t TAGS < <(git tag --sort=-creatordate)
75+
PREV_TAG=""
76+
found=0
77+
for i in "${!TAGS[@]}"; do
78+
if [[ "${TAGS[$i]}" == "$TAG" ]]; then
79+
found=1
80+
if [[ $((i+1)) -lt ${#TAGS[@]} ]]; then
81+
PREV_TAG="${TAGS[$((i+1))]}"
82+
fi
83+
break
84+
fi
85+
done
86+
if [[ $found -eq 0 ]]; then
87+
echo "Warning: tag '$TAG' not found locally. You may need to 'git fetch --tags'."
88+
BASE=""
89+
else
90+
if [[ -n "$PREV_TAG" ]]; then
91+
echo "Detected previous tag: $PREV_TAG"
92+
BASE="$PREV_TAG"
93+
else
94+
echo "No previous tag found (this looks like the oldest tag). Continuing with single tag commit."
95+
BASE=""
96+
fi
97+
fi
98+
else
99+
echo "Warning: tag '$TAG' not found locally. Continuing; will attempt API-based fallback if needed."
100+
BASE=""
101+
fi
102+
fi
103+
104+
# Build commit list: if BASE is empty, use the single tag's commit; otherwise range base..tag
105+
COMMITS=()
106+
if [[ -n "$BASE" ]]; then
107+
# verify both reachable locally; if not present, try to fetch tags/branches from remote
108+
if ! git rev-parse --verify "$BASE" >/dev/null 2>&1 || ! git rev-parse --verify "$TAG" >/dev/null 2>&1; then
109+
echo "One of base or tag is not present locally. Attempting to fetch tags/heads from origin..."
110+
git fetch --tags --prune --no-recurse-submodules --quiet || true
111+
fi
112+
113+
# Re-check
114+
git rev-parse --verify "$BASE" >/dev/null 2>&1 || { echo "ERROR: base '$BASE' not found locally after fetch"; exit 1; }
115+
git rev-parse --verify "$TAG" >/dev/null 2>&1 || { echo "ERROR: tag '$TAG' not found locally after fetch"; exit 1; }
116+
117+
# list commits from base (exclusive) to tag (inclusive)
118+
while IFS= read -r sha; do
119+
COMMITS+=("$sha")
120+
done < <(git rev-list --reverse "${BASE}..${TAG}")
121+
else
122+
# try to get tag commit locally; if not present, fall back to GitHub compare API to get commits
123+
if git rev-parse --verify "$TAG" >/dev/null 2>&1; then
124+
TAG_SHA="$(git rev-list -n 1 "$TAG")"
125+
COMMITS+=("$TAG_SHA")
126+
else
127+
echo "Tag not present locally. Falling back to GitHub compare API to get commits for tag ${TAG}..."
128+
# We attempt to compare default branch...tag to get commits — best-effort
129+
default_branch="$(curl -sSL -H "$AUTH" "${API}/repos/${OWNER}/${REPO}" | jq -r '.default_branch')"
130+
if [[ -z "$default_branch" || "$default_branch" == "null" ]]; then
131+
echo "ERROR: could not determine default branch via API."
132+
exit 1
133+
fi
134+
# Use compare endpoint: default_branch...tag
135+
cmp=$(curl -sSL -H "$AUTH" "${API}/repos/${OWNER}/${REPO}/compare/${default_branch}...${TAG}")
136+
if echo "$cmp" | jq -e 'has("commits")' >/dev/null 2>&1; then
137+
mapfile -t commits_from_api < <(echo "$cmp" | jq -r '.commits[]?.sha')
138+
COMMITS+=("${commits_from_api[@]}")
139+
else
140+
echo "API compare did not return commits. Response:"
141+
echo "$cmp" | jq -C .
142+
exit 1
143+
fi
144+
fi
145+
fi
146+
147+
echo "Found ${#COMMITS[@]} commit(s)."
148+
149+
# For each commit, call GitHub API to get associated PRs
150+
declare -A PRS_MAP=()
151+
for sha in "${COMMITS[@]}"; do
152+
echo "Querying PRs for commit $sha..."
153+
resp="$(curl -sSL -H "$AUTH" -H "$ACCEPT" \
154+
"${API}/repos/${OWNER}/${REPO}/commits/${sha}/pulls")"
155+
156+
if echo "$resp" | jq -e 'has("message")' >/dev/null 2>&1; then
157+
msg=$(echo "$resp" | jq -r '.message // empty')
158+
if [[ -n "$msg" ]]; then
159+
echo "GitHub API error for commit $sha: $msg"
160+
echo "Full response:"
161+
echo "$resp" | jq -C .
162+
exit 1
163+
fi
164+
fi
165+
166+
pr_numbers=$(echo "$resp" | jq -r '.[]?.number' || true)
167+
if [[ -n "$pr_numbers" ]]; then
168+
while IFS= read -r pr; do
169+
if [[ -n "$pr" ]]; then
170+
PRS_MAP["$pr"]=1
171+
echo " -> PR #$pr"
172+
fi
173+
done <<<"$pr_numbers"
174+
fi
175+
done
176+
177+
if [[ ${#PRS_MAP[@]} -eq 0 ]]; then
178+
echo "No PRs associated with commits found. Exiting."
179+
exit 0
180+
fi
181+
182+
PR_LIST=()
183+
for k in "${!PRS_MAP[@]}"; do PR_LIST+=("$k"); done
184+
echo "Total unique PRs to update: ${#PR_LIST[@]}"
185+
186+
# Check existing milestones for a matching title
187+
echo "Checking for existing milestone named '$TAG'..."
188+
MILESTONES_RESP="$(curl -sSL -H "$AUTH" "${API}/repos/${OWNER}/${REPO}/milestones?state=all&per_page=100")"
189+
MILESTONE_NUMBER="$(echo "$MILESTONES_RESP" | jq -r --arg TITLE "$TAG" '.[] | select(.title==$TITLE) | .number' | head -n1 || true)"
190+
191+
if [[ -n "$MILESTONE_NUMBER" && "$MILESTONE_NUMBER" != "null" ]]; then
192+
echo "Found existing milestone '$TAG' (number: $MILESTONE_NUMBER)."
193+
else
194+
if [[ $DRYRUN -eq 1 ]]; then
195+
echo "Would create milestone '$TAG' (dry-run)."
196+
MILESTONE_NUMBER="DUMMY_ID"
197+
else
198+
echo "Creating milestone '$TAG'..."
199+
create_resp="$(curl -sSL -H "$AUTH" -H "Content-Type: application/json" \
200+
-d "{\"title\": \"${TAG}\", \"description\": \"Auto-created milestone for tag ${TAG}\"}" \
201+
"${API}/repos/${OWNER}/${REPO}/milestones")"
202+
203+
if echo "$create_resp" | jq -e 'has("message")' >/dev/null 2>&1; then
204+
err=$(echo "$create_resp" | jq -r '.message // empty')
205+
echo "Error creating milestone: $err"
206+
echo "Response: $create_resp" | jq -C .
207+
exit 1
208+
fi
209+
210+
MILESTONE_NUMBER="$(echo "$create_resp" | jq -r '.number')"
211+
if [[ -z "$MILESTONE_NUMBER" || "$MILESTONE_NUMBER" == "null" ]]; then
212+
echo "Failed to create milestone. Response: $create_resp"
213+
exit 1
214+
fi
215+
echo "Created milestone '${TAG}' (number: $MILESTONE_NUMBER)."
216+
fi
217+
fi
218+
219+
# Patch each PR (issues endpoint) to set milestone
220+
for prnum in "${PR_LIST[@]}"; do
221+
if [[ $DRYRUN -eq 1 ]]; then
222+
echo "Would update PR #${prnum} -> milestone ${MILESTONE_NUMBER}"
223+
continue
224+
fi
225+
226+
echo "Updating PR #${prnum} -> milestone ${MILESTONE_NUMBER}..."
227+
patch_resp="$(curl -sSL -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
228+
-d "{\"milestone\": ${MILESTONE_NUMBER}}" \
229+
"${API}/repos/${OWNER}/${REPO}/issues/${prnum}")"
230+
231+
if echo "$patch_resp" | jq -e 'has("message")' >/dev/null 2>&1; then
232+
err=$(echo "$patch_resp" | jq -r '.message // empty')
233+
echo "Warning: failed to update PR #${prnum}: $err"
234+
echo "Response: $patch_resp" | jq -C .
235+
# continue so we attempt remaining PRs
236+
continue
237+
fi
238+
239+
echo "PR #${prnum} updated."
240+
done
241+
242+
echo "Done."
243+
exit 0

0 commit comments

Comments
 (0)