219 lines
8.2 KiB
Bash
Executable File
219 lines
8.2 KiB
Bash
Executable File
#!/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
|