chore(project): add project bootstrap & workflow

This commit is contained in:
Haitao Pan 2025-08-21 16:54:16 +08:00
parent 22d083cb24
commit cc8423f0b0
6 changed files with 2306 additions and 3824 deletions

63
.github/project.yml vendored Normal file
View File

@ -0,0 +1,63 @@
# .github/project.yml
title: "Light-IDP OIDC/LDAP Stabilization"
short_description: "MVP OIDC + Session + Config, then Stability: Store, LDAP, Tests"
project:
owner: svc-design
repo: XControl
public: false
fields:
milestone:
type: single_select
options: ["MVP", "Stability"]
priority:
type: single_select
options: ["P0", "P1", "P2"]
status:
type: status
options: ["Todo", "In Progress", "Done"]
views:
- name: "Kanban"
type: board
group_by: "status"
- name: "MVP (Table)"
type: table
filter:
milestone: "MVP"
- name: "Stability (Table)"
type: table
filter:
milestone: "Stability"
items:
# Milestone 1 (MVP)
- ref: 1
title: "Implement OIDC Handlers"
status: "Todo"
milestone: "MVP"
note: "authorize/token/userinfo/discovery/jwks/logout"
- ref: 2
title: "Add Session Middleware"
status: "Todo"
milestone: "MVP"
- ref: 5
title: "Validate and Extend Config"
status: "Todo"
milestone: "MVP"
# Milestone 2 (Stability)
- ref: 3
title: "Thread-safe Persistent Store"
status: "Todo"
milestone: "Stability"
- ref: 4
title: "Improve LDAP Sync Robustness"
status: "Todo"
milestone: "Stability"
- ref: 6
title: "Introduce Unit Tests"
status: "Todo"
milestone: "Stability"

55
docs/project.md Normal file
View File

@ -0,0 +1,55 @@
使用步骤
# 1) 添加文件
# .github/project.yml
# scripts/bootstrap_project.sh
# .github/workflows/project-auto-add.yml
# 2) 本地或 CI 登录 GitHub CLI
gh auth login
# 3) 初始化 Project首次/需要重放时)
chmod +x scripts/bootstrap_project.sh
./scripts/bootstrap_project.sh .github/project.yml
# 初始化后:
- 会在 svc-design 名下创建名为 “Light-IDP OIDC/LDAP Stabilization” 的 Projects v2 看板
- 规范化 Status 选项、创建 Milestone/Priority 字段、创建「Kanban / MVP (Table) / Stability (Table)」视图
- 按 .github/project.yml 将 #1#6 加入(若尚不存在,会在 svc-design/XControl 内创建对应 issue
本地 CLI推荐命令
- 查看当前登录与作用域 gh auth status
- 直接给现有令牌追加作用域(无需重新登录)# 追加项目读写 + 组织只读 + 仓库完全权限(你脚本会创建 issue
gh auth refresh -h github.com -s project -s read:project -s read:org -s repo
如果 gh auth refresh 提示不能刷新,则用设备授权重新登录并勾选作用域:
gh auth login -h github.com -p https -s project -s read:project -s read:org -s repo
重新运行脚本 ./scripts/bootstrap_project.sh .github/project.yml
说明
Projects v2 API 目前需要“经典”PAT 作用域 project/read:project。细粒度 PAT 还没完整覆盖到 Projects v2至少在 CLI 场景下不稳定),因此建议使用 CLI 的设备授权或经典 PAT。
你已有 repo、read:org只差 read:project/project。
GitHub Actions如在 CI 中跑脚本)
如果你打算在 workflow 里运行初始化脚本,需要把权限从 project 改为 projects复数并授予写入
permissions:
contents: read
issues: write
pull-requests: write
projects: write # ← 修正这里(之前如果写成 project 会失败)
另外,使用默认的 GITHUB_TOKEN 时,需在仓库 → Settings → Actions → General 里将 Workflow permissions 设为 Read and write permissions否则对 Projects 的写操作会被拒。
# 常见坑位速查
- unknown flag: --private/--public已在你的脚本里移除现在 gh project create 默认创建私有项目,公开需要到网页端手动切换。
- 创建组织级项目失败:需要对组织具备创建 Projects 的权限;否则在你的用户空间创建(脚本已自动查找 user 与 org 两侧)。
- 版本过旧:建议 gh --version >= 2.50gh update

218
scripts/bootstrap_project.sh Executable file
View File

@ -0,0 +1,218 @@
#!/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

2
ui/dist/index.html vendored

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff