Files
ljwx-deploy/scripts/github/project-v2-update.sh
T

470 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=scripts/github/_gh_env.sh
source "$SCRIPT_DIR/_gh_env.sh"
# shellcheck source=scripts/github/_gh_target.sh
source "$SCRIPT_DIR/_gh_target.sh"
PROJECT_OWNER=""
PROJECT_NUMBER=""
PROJECT_URL=""
PROJECT_ID=""
ITEM_ID=""
CONTENT_URL=""
CONTENT_ID=""
TARGET_REPO=""
ISSUE_NUMBER=""
PULL_NUMBER=""
FIELD_NAME=""
FIELD_ID=""
OPTION_NAME=""
OPTION_ID=""
TEXT_VALUE=""
DRY_RUN=0
SKIP_PREFLIGHT=0
usage() {
cat <<'EOF'
Usage:
bash scripts/github/project-v2-update.sh [options]
Project selectors:
--project-url URL
--project-owner OWNER --project-number N
--project-id ID
Content selectors:
--content-url URL
--repo OWNER/REPO --issue N
--repo OWNER/REPO --pr N
--content-id NODE_ID
--item-id ID
Field update:
--field-name NAME
--field-id ID
--single-select VALUE
--option-id ID
--text VALUE
Behavior:
--dry-run
--skip-preflight
-h, --help
EOF
}
fail() {
echo "[错误] $1" >&2
exit 1
}
require_command() {
local command_name="$1"
if ! command -v "$command_name" >/dev/null 2>&1; then
fail "缺少命令: ${command_name}"
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--project-url)
PROJECT_URL="${2:?missing value for --project-url}"
shift 2
;;
--project-owner)
PROJECT_OWNER="${2:?missing value for --project-owner}"
shift 2
;;
--project-number)
PROJECT_NUMBER="${2:?missing value for --project-number}"
shift 2
;;
--project-id)
PROJECT_ID="${2:?missing value for --project-id}"
shift 2
;;
--content-url)
CONTENT_URL="${2:?missing value for --content-url}"
shift 2
;;
--content-id)
CONTENT_ID="${2:?missing value for --content-id}"
shift 2
;;
--repo)
TARGET_REPO="${2:?missing value for --repo}"
shift 2
;;
--issue)
ISSUE_NUMBER="${2:?missing value for --issue}"
shift 2
;;
--pr)
PULL_NUMBER="${2:?missing value for --pr}"
shift 2
;;
--item-id)
ITEM_ID="${2:?missing value for --item-id}"
shift 2
;;
--field-name)
FIELD_NAME="${2:?missing value for --field-name}"
shift 2
;;
--field-id)
FIELD_ID="${2:?missing value for --field-id}"
shift 2
;;
--single-select)
OPTION_NAME="${2:?missing value for --single-select}"
shift 2
;;
--option-id)
OPTION_ID="${2:?missing value for --option-id}"
shift 2
;;
--text)
TEXT_VALUE="${2:?missing value for --text}"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
--skip-preflight)
SKIP_PREFLIGHT=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "未知参数: $1"
;;
esac
done
if [[ -n "$PROJECT_URL" ]]; then
local resolved=""
resolved="$(gh_target_resolve_project_from_url "$PROJECT_URL")"
PROJECT_OWNER="$(printf '%s' "$resolved" | cut -f1)"
PROJECT_NUMBER="$(printf '%s' "$resolved" | cut -f2)"
fi
if [[ -z "$PROJECT_ID" && ( -z "$PROJECT_OWNER" || -z "$PROJECT_NUMBER" ) ]]; then
fail "必须提供 --project-id 或 --project-url / --project-owner + --project-number"
fi
if [[ -n "$CONTENT_URL" ]]; then
local content_parts=""
content_parts="$(gh_target_resolve_content_from_url "$CONTENT_URL")"
TARGET_REPO="$(printf '%s' "$content_parts" | cut -f1)"
local content_kind=""
content_kind="$(printf '%s' "$content_parts" | cut -f2)"
local content_number=""
content_number="$(printf '%s' "$content_parts" | cut -f3)"
if [[ "$content_kind" == "issue" ]]; then
ISSUE_NUMBER="$content_number"
else
PULL_NUMBER="$content_number"
fi
fi
if [[ -n "$ISSUE_NUMBER" && -n "$PULL_NUMBER" ]]; then
fail "--issue 与 --pr 只能使用一个"
fi
if [[ -n "$TEXT_VALUE" && ( -n "$OPTION_NAME" || -n "$OPTION_ID" ) ]]; then
fail "--text 与 --single-select/--option-id 只能使用一种"
fi
if [[ -n "$OPTION_NAME" && -n "$OPTION_ID" ]]; then
fail "--single-select 与 --option-id 只能使用一个"
fi
if [[ -n "$FIELD_NAME" && -n "$FIELD_ID" ]]; then
fail "--field-name 与 --field-id 只能使用一个"
fi
if [[ -z "$ITEM_ID" && -z "$CONTENT_ID" && -z "$ISSUE_NUMBER" && -z "$PULL_NUMBER" ]]; then
fail "必须提供 --item-id、--content-id、--content-url、--issue 或 --pr"
fi
}
project_probe_payload() {
jq -nc \
--arg owner "$PROJECT_OWNER" \
--argjson number "$PROJECT_NUMBER" \
'{
query: "query($owner:String!,$number:Int!){ organization(login:$owner){ projectV2(number:$number){ id title viewerCanUpdate closed } } user(login:$owner){ projectV2(number:$number){ id title viewerCanUpdate closed } } }",
variables: { owner: $owner, number: $number }
}'
}
resolve_project_json() {
if [[ -n "$PROJECT_ID" ]]; then
jq -nc --arg id "$PROJECT_ID" '{id: $id}'
return 0
fi
bash "$SCRIPT_DIR/github_api.sh" \
--graphql \
--data "$(project_probe_payload)" \
| jq '(.data.organization.projectV2 // .data.user.projectV2) as $project
| if $project == null then error("project not found") else $project end'
}
resolve_content_payload() {
if [[ -n "$ISSUE_NUMBER" ]]; then
printf 'repos/%s/issues/%s\n' "$TARGET_REPO" "$ISSUE_NUMBER"
return 0
fi
if [[ -n "$PULL_NUMBER" ]]; then
printf 'repos/%s/pulls/%s\n' "$TARGET_REPO" "$PULL_NUMBER"
return 0
fi
printf '%s\n' ""
}
resolve_content_json() {
if [[ -n "$CONTENT_ID" ]]; then
jq -nc --arg id "$CONTENT_ID" '{node_id: $id}'
return 0
fi
local endpoint=""
endpoint="$(resolve_content_payload)"
if [[ -z "$endpoint" ]]; then
fail "缺少内容解析参数"
fi
bash "$SCRIPT_DIR/github_api.sh" "$endpoint"
}
project_fields_payload() {
jq -nc \
--arg project "$1" \
'{
query: "query($project:ID!){ node(id:$project){ ... on ProjectV2 { fields(first:100){ nodes { __typename ... on ProjectV2Field { id name dataType } ... on ProjectV2SingleSelectField { id name dataType options { id name } } ... on ProjectV2IterationField { id name dataType } } } } } }",
variables: { project: $project }
}'
}
project_items_payload() {
jq -nc \
--arg project "$1" \
--arg after "${2:-}" \
'{
query: "query($project:ID!,$after:String){ node(id:$project){ ... on ProjectV2 { items(first:100, after:$after){ pageInfo { hasNextPage endCursor } nodes { id content { __typename ... on Issue { id url } ... on PullRequest { id url } } } } } } }",
variables: { project: $project, after: (if $after == "" then null else $after end) }
}'
}
add_item_payload() {
jq -nc \
--arg project "$1" \
--arg content "$2" \
'{
query: "mutation($project:ID!,$content:ID!){ addProjectV2ItemById(input:{projectId:$project, contentId:$content}) { item { id } } }",
variables: { project: $project, content: $content }
}'
}
update_single_select_payload() {
jq -nc \
--arg project "$1" \
--arg item "$2" \
--arg field "$3" \
--arg option "$4" \
'{
query: "mutation($project:ID!,$item:ID!,$field:ID!,$option:String!){ updateProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field,value:{singleSelectOptionId:$option}}) { projectV2Item { id } } }",
variables: { project: $project, item: $item, field: $field, option: $option }
}'
}
update_text_payload() {
jq -nc \
--arg project "$1" \
--arg item "$2" \
--arg field "$3" \
--arg text "$4" \
'{
query: "mutation($project:ID!,$item:ID!,$field:ID!,$text:String!){ updateProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field,value:{text:$text}}) { projectV2Item { id } } }",
variables: { project: $project, item: $item, field: $field, text: $text }
}'
}
find_item_id() {
local project_id="$1"
local content_id="$2"
local after=""
local page=""
local item_id=""
local has_next=""
while true; do
page="$(
bash "$SCRIPT_DIR/github_api.sh" \
--graphql \
--data "$(project_items_payload "$project_id" "$after")"
)"
item_id="$(
printf '%s' "$page" \
| jq -r --arg content_id "$content_id" '.data.node.items.nodes[] | select(.content.id == $content_id) | .id' \
| head -n1
)"
if [[ -n "$item_id" ]]; then
printf '%s\n' "$item_id"
return 0
fi
has_next="$(printf '%s' "$page" | jq -r '.data.node.items.pageInfo.hasNextPage')"
if [[ "$has_next" != "true" ]]; then
return 0
fi
after="$(printf '%s' "$page" | jq -r '.data.node.items.pageInfo.endCursor')"
done
}
main() {
local project_json=""
local project_id=""
local content_json=""
local content_id=""
local item_id=""
local fields_json=""
local field_json=""
local field_id=""
local option_id=""
parse_args "$@"
require_command "jq"
gh_env_prepare
gh_env_require_token
if [[ "$SKIP_PREFLIGHT" != "1" && -n "$PROJECT_OWNER" && -n "$PROJECT_NUMBER" ]]; then
if [[ -n "$TARGET_REPO" ]]; then
bash "$SCRIPT_DIR/preflight.sh" --repo "$TARGET_REPO" --project-owner "$PROJECT_OWNER" --project-number "$PROJECT_NUMBER" >/dev/null
else
bash "$SCRIPT_DIR/preflight.sh" --project-owner "$PROJECT_OWNER" --project-number "$PROJECT_NUMBER" >/dev/null
fi
fi
project_json="$(resolve_project_json)"
project_id="$(printf '%s' "$project_json" | jq -r '.id')"
if [[ -n "$ITEM_ID" ]]; then
item_id="$ITEM_ID"
else
content_json="$(resolve_content_json)"
content_id="$(printf '%s' "$content_json" | jq -r '.node_id')"
if [[ -z "$content_id" || "$content_id" == "null" ]]; then
fail "无法解析内容 node_id"
fi
item_id="$(find_item_id "$project_id" "$content_id")"
if [[ -z "$item_id" ]]; then
if [[ "$DRY_RUN" == "1" ]]; then
echo "[dry-run] addProjectV2ItemById project=${project_id} content=${content_id}"
item_id="TBD-ITEM"
else
item_id="$(
bash "$SCRIPT_DIR/github_api.sh" \
--graphql \
--data "$(add_item_payload "$project_id" "$content_id")" \
| jq -r '.data.addProjectV2ItemById.item.id'
)"
echo "[完成] 已将内容加入 Project: ${item_id}"
fi
fi
fi
if [[ -z "$FIELD_NAME" && -z "$FIELD_ID" ]]; then
echo "[完成] Project item 已就绪: ${item_id}"
exit 0
fi
if [[ -z "$FIELD_ID" ]]; then
fields_json="$(
bash "$SCRIPT_DIR/github_api.sh" \
--graphql \
--data "$(project_fields_payload "$project_id")"
)"
field_json="$(
printf '%s' "$fields_json" \
| jq -c --arg field_name "$FIELD_NAME" '
.data.node.fields.nodes[]
| select((.name | ascii_downcase) == ($field_name | ascii_downcase))
' \
| head -n1
)"
if [[ -z "$field_json" ]]; then
fail "未找到 Project 字段: ${FIELD_NAME}"
fi
field_id="$(printf '%s' "$field_json" | jq -r '.id')"
else
field_id="$FIELD_ID"
fi
if [[ -n "$OPTION_NAME" && -z "$OPTION_ID" ]]; then
if [[ -z "$field_json" ]]; then
fields_json="$(
bash "$SCRIPT_DIR/github_api.sh" \
--graphql \
--data "$(project_fields_payload "$project_id")"
)"
field_json="$(
printf '%s' "$fields_json" \
| jq -c --arg field_id "$field_id" '.data.node.fields.nodes[] | select(.id == $field_id)' \
| head -n1
)"
fi
option_id="$(printf '%s' "$field_json" | jq -r --arg option_name "$OPTION_NAME" '
.options[]? | select((.name | ascii_downcase) == ($option_name | ascii_downcase)) | .id
' | head -n1)"
if [[ -z "$option_id" ]]; then
fail "未找到字段选项: ${OPTION_NAME}"
fi
else
option_id="$OPTION_ID"
fi
if [[ "$DRY_RUN" == "1" ]]; then
if [[ -n "$TEXT_VALUE" ]]; then
echo "[dry-run] updateProjectV2ItemFieldValue project=${project_id} item=${item_id} field=${field_id} text=${TEXT_VALUE}"
else
echo "[dry-run] updateProjectV2ItemFieldValue project=${project_id} item=${item_id} field=${field_id} option=${option_id}"
fi
exit 0
fi
if [[ -n "$TEXT_VALUE" ]]; then
bash "$SCRIPT_DIR/github_api.sh" \
--graphql \
--data "$(update_text_payload "$project_id" "$item_id" "$field_id" "$TEXT_VALUE")" >/dev/null
echo "[完成] 已更新 Project 文本字段: ${item_id}"
exit 0
fi
if [[ -z "$option_id" ]]; then
fail "缺少 Project 字段更新值,请提供 --single-select / --option-id / --text"
fi
bash "$SCRIPT_DIR/github_api.sh" \
--graphql \
--data "$(update_single_select_payload "$project_id" "$item_id" "$field_id" "$option_id")" >/dev/null
echo "[完成] 已更新 Project 单选字段: ${item_id}"
}
main "$@"