labels.yaml 唯一真相源 + 冪等同步腳本 + 14 repo 全部對齊 #9

Merged
claude-code merged 1 commits from leaf/4-labels into main 2026-08-20 05:08:06 +00:00
2 changed files with 288 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
# ISEP labels.yaml — 全 inkstone org 的標籤唯一真相源
# 規約:改標籤=改本檔 → PR → release → 跑 scripts/gitea-labels-sync.sh
# scope 帶「/」且 exclusive:true 者,同票至多一個(Gitea 平台保證,非法狀態不可表示)
labels:
# ── 狀態機(exclusive,同票只能有一個)─────────────────
- name: "s/triage"
color: "d4c5f9"
exclusive: true
description: "新進來的,還沒驗傷——還沒決定要不要做"
- name: "s/backlog"
color: "c2e0c6"
exclusive: true
description: "驗過了、確定要做,但還沒排進任何 sprint"
- name: "s/todo"
color: "ededed"
exclusive: true
description: "已排進 milestone,等人領票"
- name: "s/doing"
color: "0e8a16"
exclusive: true
description: "進行中——已 self-assign,現在有人在做"
- name: "s/review"
color: "1d76db"
exclusive: true
description: "PR 已開(含 closes #n),等總管 review 並 merge"
- name: "s/stage"
color: "5319e7"
exclusive: true
description: "已部署 stage,等 leo 實際驗收"
- name: "s/pending"
color: "fbca04"
exclusive: true
description: "卡住——等外部/等依賴,不是沒人做"
# ── 關閉分類(exclusive,關票必掛恰好一個)──────────────
- name: "close/merged"
color: "0e8a16"
exclusive: true
description: "PR merge 自動關(正常路徑)"
- name: "close/human-exec"
color: "b60205"
exclusive: true
description: "人執票完成,由人手動關"
- name: "close/duplicate"
color: "cfd3d7"
exclusive: true
description: "重複——關新的、留舊的,並指向舊票"
- name: "close/wontfix"
color: "6c757d"
exclusive: true
description: "討論後決定不做(只有 leo 可下此判斷)"
- name: "close/transferred"
color: "cfd3d7"
exclusive: true
description: "走錯棚,已在正確的 repo 重開"
- name: "close/split"
color: "cfd3d7"
exclusive: true
description: "太大,已拆成數張子票"
- name: "close/stale"
color: "cfd3d7"
exclusive: true
description: "逾期無資訊,自動關閉"
# ── 人的介入(正交維度,可疊在任何 s/* 上,故不 exclusive)──
- name: "Human"
color: "b60205"
exclusive: false
description: "👤 這件事需要 leo 親手批示/動手(花錢・不可逆・品味方向・物理動作)。與 s/* 正交"
- name: "human/exec"
color: "e99695"
exclusive: false
description: "👤 這張票的『執行者』是人(GUI-only/實體權限/法定簽署),不是審核。交付物是環境狀態改變,不是 PR"
# ── 優先序(exclusive)───────────────────────────────
- name: "p/high"
color: "b60205"
exclusive: true
description: "高——擋住交付或有時間壓力"
- name: "p/low"
color: "bfd4f2"
exclusive: true
description: "低——想做,但晚一點沒關係"
# ── 種類(exclusive)────────────────────────────────
- name: "type/bug"
color: "d73a4a"
exclusive: true
description: "壞掉的東西"
- name: "type/feature"
color: "a2eeef"
exclusive: true
description: "新能力"
- name: "type/governance"
color: "5319e7"
exclusive: true
description: "規範、流程、閘本身"
- name: "type/chore"
color: "fef2c0"
exclusive: true
description: "雜務、整理、不改行為"
# ── 結構(正交)──────────────────────────────────
- name: "hub"
color: "0052cc"
exclusive: false
description: "Tracking issue——聚合一批 leaf 的 scope 容器,本身不掛 milestone、不對應 PR"
# 舊標籤:不刪(刪掉會從既有票上被摘除,歷史就斷了),改標記封存
archive:
- name: "duplicate"
reason: "由 close/duplicate 取代"
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
# gitea-labels-sync.sh — 把 labels.yaml(唯一真相源)同步到 inkstone org 底下的受治理 repo。
#
# 用法:
# scripts/gitea-labels-sync.sh # 同步 org 內全部 repo(自動列舉)
# scripts/gitea-labels-sync.sh InkStoneCo Arcrun # 只同步指定的 repo
# DRY_RUN=1 scripts/gitea-labels-sync.sh # 只印計畫,不寫入
#
# 規則(見票 inkstone/ISEP#4):
# - 名稱在 labels.yaml 裡但 repo 沒有 → 建立
# - 名稱兩邊都有但 color/description/exclusive 不同 → 更新(PATCH
# - repo 有、labels.yaml(含 archive 段)都沒有的名稱 → 只印告警,絕不刪除
# - labels.yaml 的 archive 段:已知的舊名,不視為「非規範」,但也不會被建立/更新
# - 冪等:兩次連跑,第二次一定是 0 created / 0 updated
#
# 依賴:curl, jq, python3(+pyyaml)
# Token 來源:與 InkStoneCo 頂層同一顆 gitea remote 密碼(不落地、不進 commit)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
LABELS_YAML="${LABELS_YAML:-$REPO_ROOT/labels.yaml}"
API_BASE="https://git.uncle6.me/api/v1"
ORG="inkstone"
DRY_RUN="${DRY_RUN:-0}"
if [[ ! -f "$LABELS_YAML" ]]; then
echo "找不到真相源:$LABELS_YAML" >&2
exit 1
fi
# --- token:跟 InkStoneCo 頂層 gitea remote 拿同一把(不落地檔案、不印出來) ---
TOKEN="${GITEA_TOKEN:-}"
if [[ -z "$TOKEN" ]]; then
INKSTONE_TOP="${INKSTONE_TOP:-$HOME/Documents/tech_projects/InkStoneCo}"
if [[ -d "$INKSTONE_TOP/.git" ]]; then
TOKEN="$(git -C "$INKSTONE_TOP" remote get-url gitea 2>/dev/null | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')"
fi
fi
if [[ -z "$TOKEN" ]]; then
echo "拿不到 Gitea token——設 GITEA_TOKEN 環境變數,或確認 $INKSTONE_TOP 的 gitea remote 存在" >&2
exit 1
fi
# --- labels.yaml → JSON(一次轉換,供後面反覆查詢用) ---
LABELS_JSON="$(python3 -c "
import yaml, json, sys
with open('$LABELS_YAML') as f:
d = yaml.safe_load(f)
print(json.dumps(d))
")"
REPO_NAMES=("$@")
if [[ ${#REPO_NAMES[@]} -eq 0 ]]; then
# macOS 內建 bash 是 3.2,沒有 mapfile/readarray,改用相容寫法
REPO_NAMES=()
while IFS= read -r line; do
[[ -n "$line" ]] && REPO_NAMES+=("$line")
done < <(curl -sf -H "Authorization: token $TOKEN" \
"$API_BASE/orgs/$ORG/repos?limit=50" | python3 -c "
import json, sys
for r in json.load(sys.stdin):
print(r['name'])
")
fi
echo "受治理 repo${#REPO_NAMES[@]} 個):${REPO_NAMES[*]}"
echo "DRY_RUN=$DRY_RUN"
echo "================================================================"
TOTAL_CREATED=0
TOTAL_UPDATED=0
declare -a SUMMARY_LINES=()
for REPO in "${REPO_NAMES[@]}"; do
echo ""
echo "── $ORG/$REPO ──────────────────────────────────────────"
EXISTING_JSON="$(curl -sf -H "Authorization: token $TOKEN" \
"$API_BASE/repos/$ORG/$REPO/labels?limit=50")"
# 用 python3 一次算出這個 repo 的 create/update/warn 計畫(純比對,不動網路)
# 用暫存檔傳資料給 python(避免把含 emoji/引號的 JSON 內嵌進 -c 字串源碼裡出錯)
LABELS_TMP="$(mktemp)"
EXISTING_TMP="$(mktemp)"
printf '%s' "$LABELS_JSON" > "$LABELS_TMP"
printf '%s' "$EXISTING_JSON" > "$EXISTING_TMP"
PLAN_JSON="$(python3 -c "
import json, sys
with open('$LABELS_TMP', encoding='utf-8') as f:
labels_def = json.load(f)
with open('$EXISTING_TMP', encoding='utf-8') as f:
existing = json.load(f)
canonical = labels_def.get('labels', [])
archived_names = {a['name'] for a in labels_def.get('archive', [])}
canonical_by_name = {l['name']: l for l in canonical}
existing_by_name = {l['name']: l for l in existing}
to_create = []
to_update = []
for name, want in canonical_by_name.items():
have = existing_by_name.get(name)
if have is None:
to_create.append(want)
continue
diff = {}
if have.get('color', '').lstrip('#').lower() != want['color'].lstrip('#').lower():
diff['color'] = want['color']
if (have.get('description') or '') != (want.get('description') or ''):
diff['description'] = want.get('description', '')
if bool(have.get('exclusive', False)) != bool(want.get('exclusive', False)):
diff['exclusive'] = bool(want.get('exclusive', False))
if diff:
to_update.append({'id': have['id'], 'name': name, 'diff': diff})
extras = []
for name in existing_by_name:
if name not in canonical_by_name and name not in archived_names:
extras.append(name)
print(json.dumps({'create': to_create, 'update': to_update, 'extras': extras}))
")"
rm -f "$LABELS_TMP" "$EXISTING_TMP"
N_CREATE="$(echo "$PLAN_JSON" | jq '.create | length')"
N_UPDATE="$(echo "$PLAN_JSON" | jq '.update | length')"
N_EXTRA="$(echo "$PLAN_JSON" | jq '.extras | length')"
if [[ "$N_CREATE" -gt 0 ]]; then
echo "$PLAN_JSON" | jq -c '.create[]' | while read -r item; do
NAME="$(echo "$item" | jq -r '.name')"
echo " + 建立: $NAME"
if [[ "$DRY_RUN" != "1" ]]; then
BODY="$(echo "$item" | jq '{name, color, description: (.description // ""), exclusive: (.exclusive // false)}')"
curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "$BODY" "$API_BASE/repos/$ORG/$REPO/labels" > /dev/null
fi
done
fi
if [[ "$N_UPDATE" -gt 0 ]]; then
echo "$PLAN_JSON" | jq -c '.update[]' | while read -r item; do
NAME="$(echo "$item" | jq -r '.name')"
ID="$(echo "$item" | jq -r '.id')"
DIFF="$(echo "$item" | jq -c '.diff')"
echo " ~ 更新: $NAME ($DIFF)"
if [[ "$DRY_RUN" != "1" ]]; then
curl -sf -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "$DIFF" "$API_BASE/repos/$ORG/$REPO/labels/$ID" > /dev/null
fi
done
fi
if [[ "$N_EXTRA" -gt 0 ]]; then
echo " ⚠ 非規範標籤(不動手,只告警):$(echo "$PLAN_JSON" | jq -r '.extras | join(", ")')"
fi
echo " 小計:+$N_CREATE created, ~$N_UPDATE updated, ⚠$N_EXTRA extra"
SUMMARY_LINES+=("$REPO|$N_CREATE|$N_UPDATE|$N_EXTRA")
TOTAL_CREATED=$((TOTAL_CREATED + N_CREATE))
TOTAL_UPDATED=$((TOTAL_UPDATED + N_UPDATE))
done
echo ""
echo "================================================================"
echo "總計:${TOTAL_CREATED} created, ${TOTAL_UPDATED} updated(跨 ${#REPO_NAMES[@]} 個 repo"
echo ""
printf "%-28s %8s %8s %8s\n" "repo" "created" "updated" "extras"
for line in "${SUMMARY_LINES[@]}"; do
IFS='|' read -r r c u e <<< "$line"
printf "%-28s %8s %8s %8s\n" "$r" "$c" "$u" "$e"
done