From a86e533c3d7d6b330fe8adbc4c6e81b1dc86f42a Mon Sep 17 00:00:00 2001 From: jennyhickson <61183013+jennyhickson@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:19:44 +0000 Subject: [PATCH 1/8] create new script --- sbin/gh_manage_milestones | 140 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100755 sbin/gh_manage_milestones diff --git a/sbin/gh_manage_milestones b/sbin/gh_manage_milestones new file mode 100755 index 00000000..33b8ffb2 --- /dev/null +++ b/sbin/gh_manage_milestones @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# ---------------------------------------------------------------------------- +# (C) Crown copyright Met Office. All rights reserved. +# The file LICENCE, distributed with this code, contains details of the terms +# under which the code may be used. +# ---------------------------------------------------------------------------- + +# Script to create, update and close milestones in multiple GitHub repositories +# Requires GitHub CLI: https://cli.github.com/ and Admin privileges to the repos + +set -euo pipefail + +usage() { + cat < [options] + -t Title of milestone to be created, updated or closed. + -c Close milestone. Otherwise will create a new milestone or + update an existing milestone. + -d <due on> Milestone due date. Format: YYYY-MM-DDTHH:MM:SSZ + -p <description> Description of the milestone + -n Dry Run, print actions without making changes + -h, --help Show this help message + +Examples: + # Create a new milestone + ${0##*/} -t <title> [-d YYYY-MM-DDTHH:MM:SSZ] [-p <description>] + + # Update all milestones with a new date + ${0##*/} -t <title> -d <YYYY-MM-DDTHH:MM:SSZ> + + # Close a milestone + ${0##*/} -t <title> -c +EOF + exit 1 +} + +# -- Defaults +STATE="open" +TITLE="" +DUE="" +DESC="" +DRY_RUN=0 + +# -- Parse options +while getopts "t:d:p:cnh-:" opt; do + case $opt in + t) TITLE="$OPTARG" ;; + c) STATE="closed" ;; + d) DUE="$OPTARG" ;; + p) DESC="$OPTARG" ;; + n) DRY_RUN=1 ;; + h) usage ;; + -) [ "$OPTARG" = "help" ] && usage ;; + *) usage ;; + esac +done + +# -- Modify milestones in relevant repositories +repos=( + "MetOffice/um" + "MetOffice/jules" + "MetOffice/lfric_apps" + "MetOffice/lfric_core" + "MetOffice/ukca" + "MetOffice/casim" + "MetOffice/socrates" + "MetOffice/um_doc" + "MetOffice/simulation-systems" + "MetOffice/SimSys_Scripts" + "MetOffice/git_playground" +) +# +# -- Helper functions +get_milestone_number(){ + local repo_name="$1" + + gh api /repos/${repo_name}/milestones --jq ".[] | select(.title == \"${TITLE}\") | .number" +} + +run_command(){ + local gh_command="$1" + + if (( DRY_RUN )); then + echo "[DRY RUN] $gh_command" + else + eval "$gh_command" + fi +} + +# -- Change milestone for each repository + +for repo in "${repos[@]}"; do + echo "Processing milestone in repository: $repo" + + + # -- If milestone exists then fetch the number + NUMBER=0 + if [[ $(gh api /repos/${repo}/milestones --jq ".[] | select(.title == \"${TITLE}\")") ]]; then + NUMBER=$(get_milestone_number "${repo}") + echo "${TITLE} in ${repo} is milestone ${NUMBER}" + else + echo "Milestone does not exist in ${repo}" + fi + + # -- Build GH command from optional arguments. + GH_COMMAND="gh api -f \"title=${TITLE}\"" + if [[ ${DUE} ]]; then + GH_COMMAND=$GH_COMMAND" -f \"due_on=${DUE}\"" + fi + if [[ ${DESC} ]]; then + GH_COMMAND=$GH_COMMAND" -f \"description=${DESC}\"" + fi + + if [[ "$STATE" == "open" ]]; then + + if (( NUMBER )); then + echo "Updating milestone" + GH_COMMAND=$GH_COMMAND" --method PATCH" + GH_COMMAND=$GH_COMMAND" /repos/${repo}/milestones/${NUMBER}" + run_command "${GH_COMMAND}" + else + echo "Creating new milestone" + GH_COMMAND=$GH_COMMAND" --method POST" + GH_COMMAND=$GH_COMMAND" /repos/${repo}/milestones" + run_command "${GH_COMMAND}" + fi + + else + + if (( NUMBER )); then + echo "Closing milestone" + GH_COMMAND=$GH_COMMAND" --method PATCH" + GH_COMMAND=$GH_COMMAND" /repos/${repo}/milestones/${NUMBER}" + GH_COMMAND=$GH_COMMAND" -f \"state=closed\"" + run_command "${GH_COMMAND}" + fi + fi + +done From 611c4bf8b289f46638927bb5ddd74dee17ac4431 Mon Sep 17 00:00:00 2001 From: jennyhickson <61183013+jennyhickson@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:34:37 +0000 Subject: [PATCH 2/8] tidy up --- sbin/gh_manage_milestones | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/sbin/gh_manage_milestones b/sbin/gh_manage_milestones index 33b8ffb2..7d25161b 100755 --- a/sbin/gh_manage_milestones +++ b/sbin/gh_manage_milestones @@ -74,10 +74,14 @@ repos=( # -- Helper functions get_milestone_number(){ local repo_name="$1" - gh api /repos/${repo_name}/milestones --jq ".[] | select(.title == \"${TITLE}\") | .number" } +check_exists(){ + local repo_name="$1" + gh api /repos/${repo_name}/milestones --jq ".[] | select(.title == \"${TITLE}\")" +} + run_command(){ local gh_command="$1" @@ -88,6 +92,7 @@ run_command(){ fi } + # -- Change milestone for each repository for repo in "${repos[@]}"; do @@ -96,7 +101,7 @@ for repo in "${repos[@]}"; do # -- If milestone exists then fetch the number NUMBER=0 - if [[ $(gh api /repos/${repo}/milestones --jq ".[] | select(.title == \"${TITLE}\")") ]]; then + if [[ $(check_exists "${repo}") ]]; then NUMBER=$(get_milestone_number "${repo}") echo "${TITLE} in ${repo} is milestone ${NUMBER}" else @@ -112,6 +117,7 @@ for repo in "${repos[@]}"; do GH_COMMAND=$GH_COMMAND" -f \"description=${DESC}\"" fi + # -- Create or update the milestone if [[ "$STATE" == "open" ]]; then if (( NUMBER )); then @@ -125,7 +131,8 @@ for repo in "${repos[@]}"; do GH_COMMAND=$GH_COMMAND" /repos/${repo}/milestones" run_command "${GH_COMMAND}" fi - + + # -- Close the milestone else if (( NUMBER )); then From 6634e90c6ff26475972b8ddce5d55b2084d6408f Mon Sep 17 00:00:00 2001 From: jennyhickson <61183013+jennyhickson@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:45:12 +0000 Subject: [PATCH 3/8] fix lint issue --- sbin/gh_manage_milestones | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sbin/gh_manage_milestones b/sbin/gh_manage_milestones index 7d25161b..a5c647b3 100755 --- a/sbin/gh_manage_milestones +++ b/sbin/gh_manage_milestones @@ -74,12 +74,12 @@ repos=( # -- Helper functions get_milestone_number(){ local repo_name="$1" - gh api /repos/${repo_name}/milestones --jq ".[] | select(.title == \"${TITLE}\") | .number" + gh api /repos/"${repo_name}"/milestones --jq ".[] | select(.title == \"${TITLE}\") | .number" } check_exists(){ local repo_name="$1" - gh api /repos/${repo_name}/milestones --jq ".[] | select(.title == \"${TITLE}\")" + gh api /repos/"${repo_name}"/milestones --jq ".[] | select(.title == \"${TITLE}\")" } run_command(){ From 569fd86e609e0056186b033998e6c84c533de6c4 Mon Sep 17 00:00:00 2001 From: jennyhickson <61183013+jennyhickson@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:06:06 +0000 Subject: [PATCH 4/8] refactor checking for existing milestones --- sbin/gh_manage_milestones | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/sbin/gh_manage_milestones b/sbin/gh_manage_milestones index a5c647b3..6a0a7092 100755 --- a/sbin/gh_manage_milestones +++ b/sbin/gh_manage_milestones @@ -77,11 +77,6 @@ get_milestone_number(){ gh api /repos/"${repo_name}"/milestones --jq ".[] | select(.title == \"${TITLE}\") | .number" } -check_exists(){ - local repo_name="$1" - gh api /repos/"${repo_name}"/milestones --jq ".[] | select(.title == \"${TITLE}\")" -} - run_command(){ local gh_command="$1" @@ -100,9 +95,8 @@ for repo in "${repos[@]}"; do # -- If milestone exists then fetch the number - NUMBER=0 - if [[ $(check_exists "${repo}") ]]; then - NUMBER=$(get_milestone_number "${repo}") + NUMBER=$(get_milestone_number "${repo}") + if (( NUMBER )); then echo "${TITLE} in ${repo} is milestone ${NUMBER}" else echo "Milestone does not exist in ${repo}" From 55ab0ec8dd931a4d40c1a38000455dfbc3c9f887 Mon Sep 17 00:00:00 2001 From: jennyhickson <61183013+jennyhickson@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:43:01 +0000 Subject: [PATCH 5/8] replace command runner --- sbin/gh_manage_milestones | 61 ++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/sbin/gh_manage_milestones b/sbin/gh_manage_milestones index 6a0a7092..d2908cd9 100755 --- a/sbin/gh_manage_milestones +++ b/sbin/gh_manage_milestones @@ -58,16 +58,16 @@ done # -- Modify milestones in relevant repositories repos=( - "MetOffice/um" - "MetOffice/jules" - "MetOffice/lfric_apps" - "MetOffice/lfric_core" - "MetOffice/ukca" - "MetOffice/casim" - "MetOffice/socrates" - "MetOffice/um_doc" - "MetOffice/simulation-systems" - "MetOffice/SimSys_Scripts" +# "MetOffice/um" +# "MetOffice/jules" +# "MetOffice/lfric_apps" +# "MetOffice/lfric_core" +# "MetOffice/ukca" +# "MetOffice/casim" +# "MetOffice/socrates" +# "MetOffice/um_doc" +# "MetOffice/simulation-systems" +# "MetOffice/SimSys_Scripts" "MetOffice/git_playground" ) # @@ -77,14 +77,18 @@ get_milestone_number(){ gh api /repos/"${repo_name}"/milestones --jq ".[] | select(.title == \"${TITLE}\") | .number" } -run_command(){ - local gh_command="$1" +run_gh_api(){ + local method="$1" + local endpoint="$2" + shift 2 + local -a api_args=("$@") if (( DRY_RUN )); then - echo "[DRY RUN] $gh_command" - else - eval "$gh_command" + echo "[DRY RUN] gh api --method ${method} ${endpoint} ${api_args[*]}" + return 0 fi + + gh api --method "${method}" "${endpoint}" "${api_args[@]}" > /dev/null } @@ -103,38 +107,29 @@ for repo in "${repos[@]}"; do fi # -- Build GH command from optional arguments. - GH_COMMAND="gh api -f \"title=${TITLE}\"" - if [[ ${DUE} ]]; then - GH_COMMAND=$GH_COMMAND" -f \"due_on=${DUE}\"" - fi - if [[ ${DESC} ]]; then - GH_COMMAND=$GH_COMMAND" -f \"description=${DESC}\"" - fi + gh_args=(-f "title=\"${TITLE}\"") + [[ -n "$DUE" ]] && gh_args+=(-f "due_on=${DUE}") + [[ -n "$DESC" ]] && gh_args+=(-f "description=\"${DESC}\"") + # -- Create or update the milestone if [[ "$STATE" == "open" ]]; then if (( NUMBER )); then echo "Updating milestone" - GH_COMMAND=$GH_COMMAND" --method PATCH" - GH_COMMAND=$GH_COMMAND" /repos/${repo}/milestones/${NUMBER}" - run_command "${GH_COMMAND}" + run_gh_api PATCH "/repos/${repo}/milestones/${NUMBER}" "${gh_args[@]}" else echo "Creating new milestone" - GH_COMMAND=$GH_COMMAND" --method POST" - GH_COMMAND=$GH_COMMAND" /repos/${repo}/milestones" - run_command "${GH_COMMAND}" + run_gh_api POST "/repos/${repo}/milestones" "${gh_args[@]}" fi # -- Close the milestone else if (( NUMBER )); then - echo "Closing milestone" - GH_COMMAND=$GH_COMMAND" --method PATCH" - GH_COMMAND=$GH_COMMAND" /repos/${repo}/milestones/${NUMBER}" - GH_COMMAND=$GH_COMMAND" -f \"state=closed\"" - run_command "${GH_COMMAND}" + echo "Closing milestone #${NUMBER}" + gh_args+=(-f "state=closed") + run_gh_api PATCH "/repos/${repo}/milestones/${NUMBER}" "${gh_args[@]}" fi fi From faa3fd96306103c85c40b05935e13c54be920e57 Mon Sep 17 00:00:00 2001 From: jennyhickson <61183013+jennyhickson@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:42:11 +0000 Subject: [PATCH 6/8] Add list and delete functions --- sbin/gh_manage_milestones | 176 ++++++++++++++++++++++++++++---------- 1 file changed, 131 insertions(+), 45 deletions(-) diff --git a/sbin/gh_manage_milestones b/sbin/gh_manage_milestones index d2908cd9..e5797e33 100755 --- a/sbin/gh_manage_milestones +++ b/sbin/gh_manage_milestones @@ -11,12 +11,28 @@ set -euo pipefail +# -- Modify milestones in relevant repositories +repos=( + "MetOffice/um" + "MetOffice/jules" + "MetOffice/lfric_apps" + "MetOffice/lfric_core" + "MetOffice/ukca" + "MetOffice/casim" + "MetOffice/socrates" + "MetOffice/um_doc" + "MetOffice/simulation-systems" + "MetOffice/SimSys_Scripts" + "MetOffice/git_playground" +) + usage() { cat <<EOF Usage: ${0##*/} -t <title> [options] - -t <title> Title of milestone to be created, updated or closed. - -c Close milestone. Otherwise will create a new milestone or - update an existing milestone. + -t <title> Title of milestone to be created, updated, closed or deleted. + -m <mode> Mode: update (default), list, close, delete. Update mode will + create a milestone if it doesn't exist. + -s <state> State to list. State can be: open (default), closed or all. -d <due on> Milestone due date. Format: YYYY-MM-DDTHH:MM:SSZ -p <description> Description of the milestone -n Dry Run, print actions without making changes @@ -29,13 +45,23 @@ Examples: # Update all milestones with a new date ${0##*/} -t <title> -d <YYYY-MM-DDTHH:MM:SSZ> + # List all open milestones + ${0##*/} -m list + + # List all milestones (open and closed) + ${0##*/} -m list -s all + # Close a milestone - ${0##*/} -t <title> -c + ${0##*/} -t <title> -m close + + # Delete a milestone (permanent) + ${0##*/} -t <title> -m delete EOF exit 1 } # -- Defaults +MODE="update" STATE="open" TITLE="" DUE="" @@ -43,10 +69,11 @@ DESC="" DRY_RUN=0 # -- Parse options -while getopts "t:d:p:cnh-:" opt; do +while getopts "t:m:s:d:p:nh-:" opt; do case $opt in t) TITLE="$OPTARG" ;; - c) STATE="closed" ;; + m) MODE="$OPTARG" ;; + s) STATE="$OPTARG" ;; d) DUE="$OPTARG" ;; p) DESC="$OPTARG" ;; n) DRY_RUN=1 ;; @@ -56,20 +83,6 @@ while getopts "t:d:p:cnh-:" opt; do esac done -# -- Modify milestones in relevant repositories -repos=( -# "MetOffice/um" -# "MetOffice/jules" -# "MetOffice/lfric_apps" -# "MetOffice/lfric_core" -# "MetOffice/ukca" -# "MetOffice/casim" -# "MetOffice/socrates" -# "MetOffice/um_doc" -# "MetOffice/simulation-systems" -# "MetOffice/SimSys_Scripts" - "MetOffice/git_playground" -) # # -- Helper functions get_milestone_number(){ @@ -91,46 +104,119 @@ run_gh_api(){ gh api --method "${method}" "${endpoint}" "${api_args[@]}" > /dev/null } +confirm_deletion() { + if (( DRY_RUN )); then + return 0 + fi -# -- Change milestone for each repository + echo "WARNING: You are about to PERMANENTLY DELETE milestone '${TITLE}' from ${#repos[@]} repositories." + echo "This action CANNOT be undone." + echo "" + read -p "Type 'yes' to confirm deletion: " -r confirmation + echo "" -for repo in "${repos[@]}"; do - echo "Processing milestone in repository: $repo" + if [[ "$confirmation" != "yes" ]]; then + echo "Deletion cancelled." + exit 0 + fi + + echo "Proceeding with deletion..." + echo "" +} +list_milestones() { + local repo="$1" + local state="$2" - # -- If milestone exists then fetch the number - NUMBER=$(get_milestone_number "${repo}") - if (( NUMBER )); then - echo "${TITLE} in ${repo} is milestone ${NUMBER}" + local milestones + milestones=$(gh api "/repos/${repo}/milestones?state=${state}&per_page=100" \ + --jq '.[] | " #\(.number) \(.title) [\(.state)] (\(.open_issues) open / \(.closed_issues) closed)" + + (if .due_on then " - Due: \(.due_on)" else "" end)' 2>/dev/null) + + if [[ -n "$milestones" ]]; then + echo "$milestones" else - echo "Milestone does not exist in ${repo}" + echo " No milestones found" fi + echo "" +} - # -- Build GH command from optional arguments. - gh_args=(-f "title=\"${TITLE}\"") - [[ -n "$DUE" ]] && gh_args+=(-f "due_on=${DUE}") - [[ -n "$DESC" ]] && gh_args+=(-f "description=\"${DESC}\"") + +# +# ---- +# - # -- Create or update the milestone - if [[ "$STATE" == "open" ]]; then +# Check deletion once for all repos +if [[ "$MODE" == "delete" ]]; then + confirm_deletion +fi + + +# -- Change milestone for each repository - if (( NUMBER )); then - echo "Updating milestone" - run_gh_api PATCH "/repos/${repo}/milestones/${NUMBER}" "${gh_args[@]}" +for repo in "${repos[@]}"; do + echo "$repo" + + # -- List all milestones in requested state + if [[ "$MODE" == "list" ]]; then + + echo " → Listing milestones (state: ${STATE})" + list_milestones "${repo}" "${STATE}" + + else + # -- All other modes act on specific milestones. + # -- If milestone exists then fetch the number + number=$(get_milestone_number "${repo}") + if (( number )); then + echo " → Found existing milestone ${TITLE} #${number}" else - echo "Creating new milestone" - run_gh_api POST "/repos/${repo}/milestones" "${gh_args[@]}" + echo " → Milestone does not exist" fi - # -- Close the milestone - else + # -- Build GH command from optional arguments. + gh_args=(-f "title=\"${TITLE}\"") + [[ -n "$DUE" ]] && gh_args+=(-f "due_on=${DUE}") + [[ -n "$DESC" ]] && gh_args+=(-f "description=\"${DESC}\"") + + + # -- Create or update the milestone + if [[ "$MODE" == "update" ]]; then + + if (( number )); then + echo " → Updating milestone #${number}" + run_gh_api PATCH "/repos/${repo}/milestones/${number}" "${gh_args[@]}" + else + echo " → Creating new milestone" + run_gh_api POST "/repos/${repo}/milestones" "${gh_args[@]}" + fi + + # -- Close the milestone + elif [[ "$MODE" == "close" ]]; then + + if (( number )); then + echo " → Closing milestone #${number}" + gh_args+=(-f "state=closed") + run_gh_api PATCH "/repos/${repo}/milestones/${number}" "${gh_args[@]}" + else + echo " → Skipping" + fi + + # -- Delete the milestone + elif [[ "$MODE" == "delete" ]]; then + + if (( number )); then + echo " → Deleting milestone #${number}" + run_gh_api DELETE "/repos/${repo}/milestones/${number}" + else + echo " → Skipping" + fi - if (( NUMBER )); then - echo "Closing milestone #${NUMBER}" - gh_args+=(-f "state=closed") - run_gh_api PATCH "/repos/${repo}/milestones/${NUMBER}" "${gh_args[@]}" fi + + echo "" + fi + done From 576cf1ac2fad4a491f0845dc9ac823b14c856920 Mon Sep 17 00:00:00 2001 From: jennyhickson <61183013+jennyhickson@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:45:23 +0000 Subject: [PATCH 7/8] capitalise repos --- sbin/gh_manage_milestones | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sbin/gh_manage_milestones b/sbin/gh_manage_milestones index e5797e33..148f6ecd 100755 --- a/sbin/gh_manage_milestones +++ b/sbin/gh_manage_milestones @@ -12,7 +12,7 @@ set -euo pipefail # -- Modify milestones in relevant repositories -repos=( +REPOS=( "MetOffice/um" "MetOffice/jules" "MetOffice/lfric_apps" @@ -109,7 +109,7 @@ confirm_deletion() { return 0 fi - echo "WARNING: You are about to PERMANENTLY DELETE milestone '${TITLE}' from ${#repos[@]} repositories." + echo "WARNING: You are about to PERMANENTLY DELETE milestone '${TITLE}' from ${#REPOS[@]} repositories." echo "This action CANNOT be undone." echo "" read -p "Type 'yes' to confirm deletion: " -r confirmation @@ -155,7 +155,7 @@ fi # -- Change milestone for each repository -for repo in "${repos[@]}"; do +for repo in "${REPOS[@]}"; do echo "$repo" # -- List all milestones in requested state From 8dfe7925f64857db922eb51426744674a5b5304d Mon Sep 17 00:00:00 2001 From: jennyhickson <61183013+jennyhickson@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:51:28 +0000 Subject: [PATCH 8/8] add error catch for mode --- sbin/gh_manage_milestones | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sbin/gh_manage_milestones b/sbin/gh_manage_milestones index 148f6ecd..e0a459cc 100755 --- a/sbin/gh_manage_milestones +++ b/sbin/gh_manage_milestones @@ -212,6 +212,12 @@ for repo in "${REPOS[@]}"; do echo " → Skipping" fi + else + + echo "Mode ${MODE} not recognised" + echo "Options: update, list, close or delete" + exit 0 + fi echo ""