accounts/scripts/bootstrap_project.sh

219 lines
8.2 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# scripts/bootstrap_project.sh
# Initialize or reconcile a GitHub Projects v2 board for a USER account.
# Requires: gh >= 2.50, jq, awk, sed
set -euo pipefail
CFG="${1:-.github/project.yml}"
if ! command -v gh >/dev/null; then
echo "Please install GitHub CLI: https://cli.github.com/" >&2
exit 1
fi
if ! command -v jq >/dev/null; then
echo "Please install jq (brew install jq / apt-get install jq)" >&2
exit 1
fi
# ====== REPO CONTEXT ======
OWNER="svc-design" # GitHub USER (not org)
REPO="XControl" # target repo for issues
# ====== Read minimal config from YAML ======
if [ ! -f "$CFG" ]; then
echo "Config not found: $CFG" >&2
exit 1
fi
TITLE=$(awk -F': *' '/^title:/ {sub(/^"/,"",$2); sub(/"$/,"",$2); print $2; exit}' "$CFG")
DESC=$(awk -F': *' '/^short_description:/ {sub(/^"/,"",$2); sub(/"$/,"",$2); print $2; exit}' "$CFG")
[ -z "${TITLE:-}" ] && { echo "title missing in $CFG"; exit 1; }
echo "Ensuring project: '$TITLE' under user '$OWNER' ..."
# ====== Find or create project (USER space) ======
PID=$(gh api graphql -f query='
query($owner:String!, $q:String!){
user(login:$owner){
projectsV2(first:50, query:$q){ nodes{ id title } }
}
}' -F owner="$OWNER" -F q="$TITLE" \
| jq -r --arg t "$TITLE" '.data.user.projectsV2.nodes[]? | select(.title==$t) | .id' || true)
if [ -z "${PID:-}" ]; then
echo "Creating project (Projects v2 in USER space)..."
PID=$(gh project create --owner "$OWNER" --title "$TITLE" --format json | jq -r '.id')
else
echo "Project exists. ID: $PID"
fi
# ----- Set description via GraphQL (gh project edit expects number; use node id instead) -----
if [ -n "${DESC:-}" ]; then
gh api graphql -f query='
mutation($pid:ID!, $desc:String!){
updateProjectV2(input:{projectId:$pid, shortDescription:$desc}){ projectV2{ id } }
}' -F pid="$PID" -F desc="$DESC" >/dev/null || true
fi
echo "Project ID: $PID"
# ====== Fields: normalize Status; ensure Milestone & Priority ======
# Use CLI to list fields to avoid GraphQL union pitfalls
field_id_by_name () { # name -> id or ""
gh project field-list "$PID" --format json | jq -r --arg n "$1" '.[] | select(.name==$n) | .id'
}
echo "Normalizing Status field options (Todo / In Progress / Done) ..."
STATUS_FIELD_ID=$(field_id_by_name "Status")
if [ -z "$STATUS_FIELD_ID" ]; then
# Projects v2 默认自带 Status极少数情况下不存在时创建
STATUS_FIELD_ID=$(gh project field-create "$PID" --name "Status" --data-type SINGLE_SELECT --format json | jq -r '.id')
fi
# helper: ensure an option exists on a single-select field
ensure_option_on_field () { # fieldId, optionName
local fid="$1" oname="$2"
local oid
oid=$(gh api -X POST graphql -f query='
query($pid:ID!, $fid:ID!) {
node(id:$pid){
... on ProjectV2 {
field(id:$fid){
... on ProjectV2SingleSelectField { options { id name } }
}
}
}
}' -F pid="$PID" -F fid="$fid" \
| jq -r --arg n "$oname" '.data.node.field.options[]? | select(.name==$n) | .id')
if [ -z "$oid" ] || [ "$oid" = "null" ]; then
gh api -X POST graphql -f query='
mutation($pid:ID!, $fid:ID!, $name:String!){
updateProjectV2SingleSelectField(input:{
projectId:$pid, fieldId:$fid, options:[{name:$name}]
}){ projectV2 { id } }
}' -F pid="$PID" -F fid="$fid" -F name="$oname" >/dev/null
fi
}
for s in "Todo" "In Progress" "Done"; do
ensure_option_on_field "$STATUS_FIELD_ID" "$s"
done
echo "Ensuring custom fields Milestone & Priority ..."
MILESTONE_FID=$(field_id_by_name "Milestone")
if [ -z "$MILESTONE_FID" ]; then
MILESTONE_FID=$(gh project field-create "$PID" --name "Milestone" --data-type SINGLE_SELECT --format json | jq -r '.id')
fi
PRIORITY_FID=$(field_id_by_name "Priority")
if [ -z "$PRIORITY_FID" ]; then
PRIORITY_FID=$(gh project field-create "$PID" --name "Priority" --data-type SINGLE_SELECT --format json | jq -r '.id')
fi
for opt in MVP Stability; do ensure_option_on_field "$MILESTONE_FID" "$opt"; done
for opt in P0 P1 P2; do ensure_option_on_field "$PRIORITY_FID" "$opt"; done
# ====== Views: Kanban + two tables ======
echo "Ensuring views (Kanban, MVP (Table), Stability (Table)) ..."
create_view() { # name, layout=BOARD|TABLE
local name="$1" layout="$2"
local exists
exists=$(gh api graphql -f query='
query($pid:ID!){
node(id:$pid){ ... on ProjectV2 { views(first:50){ nodes{ id name } } } }
}' -F pid="$PID" | jq -r --arg n "$name" '.data.node.views.nodes[]? | select(.name==$n) | .id')
if [ -z "$exists" ]; then
gh api graphql -f query='
mutation($pid:ID!, $name:String!, $layout:ProjectV2ViewLayout!){
createProjectV2View(input:{projectId:$pid, name:$name, layout:$layout}){ projectView{ id } }
}' -F pid="$PID" -F name="$name" -F layout="$layout" >/dev/null || true
fi
}
create_view "Kanban" "BOARD"
create_view "MVP (Table)" "TABLE"
create_view "Stability (Table)" "TABLE"
# ====== Helpers to set field values ======
# get option id by name for a field
opt_id () { # fieldId, optionName
gh api -X POST graphql -f query='
query($pid:ID!, $fid:ID!) {
node(id:$pid){
... on ProjectV2 {
field(id:$fid){
... on ProjectV2SingleSelectField { options { id name } }
}
}
}
}' -F pid="$PID" -F fid="$1" \
| jq -r --arg n "$2" '.data.node.field.options[]? | select(.name==$n) | .id'
}
set_single_select() { # itemId, fieldId, optionName
local item="$1" fid="$2" oname="$3"
local oid; oid=$(opt_id "$fid" "$oname")
[ -z "$oid" ] && { echo "Option '$oname' not found on field $fid"; return 1; }
gh api -X POST graphql -f query='
mutation($pid:ID!, $iid:ID!, $fid:ID!, $oid:String!) {
updateProjectV2ItemFieldValue(input:{
projectId:$pid, itemId:$iid, fieldId:$fid,
value:{ singleSelectOptionId:$oid }
}){ projectV2Item { id } }
}' -F pid="$PID" -F iid="$item" -F fid="$fid" -F oid="$oid" >/dev/null
}
set_status() { # itemId, "Todo|In Progress|Done"
set_single_select "$1" "$STATUS_FIELD_ID" "$2"
}
# ====== Add items from YAML (create issues if missing) ======
add_issue_item() { # issue number, title, milestone, status
local num="$1" title="$2" milestone="$3" status="$4"
local node_id
if gh issue view "$num" -R "$OWNER/$REPO" &>/dev/null; then
node_id=$(gh api graphql -F owner="$OWNER" -F name="$REPO" -F number="$num" -f query='
query($owner:String!,$name:String!,$number:Int!){
repository(owner:$owner,name:$name){ issue(number:$number){ id } }
}' | jq -r '.data.repository.issue.id')
else
gh issue create -R "$OWNER/$REPO" -t "$title" -b "$title" >/dev/null
node_id=$(gh api graphql -F owner="$OWNER" -F name="$REPO" -F number="$num" -f query='
query($owner:String!,$name:String!,$number:Int!){
repository(owner:$owner,name:$name){ issue(number:$number){ id } }
}' | jq -r '.data.repository.issue.id')
fi
local item_id
item_id=$(gh project item-add "$PID" --id "$node_id" --format json | jq -r '.id')
set_single_select "$item_id" "$MILESTONE_FID" "$milestone"
set_status "$item_id" "$status"
}
parse_items() {
awk '
$1=="-"{initem=1; ref=""; title=""; status=""; milestone=""; next}
initem && $1=="ref:" {ref=$2; next}
initem && $1=="title:" {sub("title: ",""); title=$0; gsub(/^"|?"$/,"",title); next}
initem && $1=="status:" {status=$2; next}
initem && $1=="milestone:" {milestone=$2; next}
initem && /^[[:space:]]*$/ {next}
initem && /^[^ ]/ { if (title!="") print ref "|" title "|" status "|" milestone; initem=0 }
END{ if (initem && title!="") print ref "|" title "|" status "|" milestone; }
' "$CFG"
}
echo "Adding items from $CFG ..."
while IFS="|" read -r ref title status milestone; do
ref="${ref// /}"
title="$(echo "$title" | sed 's/^"//; s/"$//')"
add_issue_item "$ref" "$title" "$milestone" "$status"
done < <(parse_items)
echo "All set. Opening project in browser…"
gh project view "$PID" --web || true
cat <<'NOTE'
Tips:
- If you see scope errors, refresh gh token scopes:
gh auth refresh -h github.com -s project -s read:project -s repo
- Projects created via CLI are private by default; change visibility in the web UI if needed.
- Your account is a USER (not org); we always target user space with --owner <username>.
NOTE