470 lines
13 KiB
Bash
Executable File
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 "$@"
|