chore(github): add workspace helper scripts

This commit is contained in:
deploy-promoter[bot]
2026-03-08 08:30:56 +01:00
parent 5a26154ad2
commit 8dfe5a2f38
12 changed files with 3681 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Local Python bytecode caches
__pycache__/
+73
View File
@@ -0,0 +1,73 @@
# GitHub Sandbox Helpers
These scripts keep GitHub CLI and API state inside the current workspace instead of relying on a user home directory or system keychain.
## Files
- `cxgh`: launch Codex with `workspace-write` + `network_access=true`
- `github_api.sh`: call GitHub REST or GraphQL with explicit token auth, timeout, and retry
- `preflight.sh`: verify DNS, token, rate limit, repository access, and Project V2 access before mutating GitHub state
- `issue-comment.sh`: create an issue comment via direct REST API
- `project-v2-update.sh`: add or update a Project V2 item via direct GraphQL API
- `_gh_env.sh`: prepare `GH_CONFIG_DIR` and `XDG_*` directories in the workspace
- `_gh_retry.sh`: retry `gh` CLI commands for transient network failures
- `config.toml.example`: minimal Codex config for GitHub-heavy sessions
## Quick Start
```bash
export GH_TOKEN="<token>"
bash scripts/github/cxgh "查看当前 repo 的 open issues,并给 #150 添加进展评论"
```
Preflight:
```bash
export GH_TOKEN="<token>"
bash scripts/github/preflight.sh \
--repo BrunoGaoSZ/ljwx-deploy \
--project-url 'https://github.com/orgs/BrunoGaoSZ/projects/3/views/1'
```
## Direct API Example
```bash
export GH_TOKEN="<token>"
bash scripts/github/github_api.sh repos/BrunoGaoSZ/ljwx-deploy/issues/150
```
Issue comment:
```bash
export GH_TOKEN="<token>"
bash scripts/github/issue-comment.sh \
--repo BrunoGaoSZ/ljwx-deploy \
--issue 150 \
--body 'Progress update'
```
Project update:
```bash
export GH_TOKEN="<token>"
bash scripts/github/project-v2-update.sh \
--project-url 'https://github.com/orgs/BrunoGaoSZ/projects/3/views/1' \
--content-url 'https://github.com/BrunoGaoSZ/ljwx-deploy/issues/150' \
--field-name 'Status' \
--single-select 'In progress'
```
GraphQL:
```bash
export GH_TOKEN="<token>"
bash scripts/github/github_api.sh \
--graphql \
--data '{"query":"query { viewer { login } }"}'
```
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
gh_env_workspace_root() {
if [[ -n "${CXGH_WORKSPACE_ROOT:-}" ]]; then
printf '%s\n' "$CXGH_WORKSPACE_ROOT"
return 0
fi
if git rev-parse --show-toplevel >/dev/null 2>&1; then
git rev-parse --show-toplevel
return 0
fi
pwd
}
gh_env_prepare() {
local workspace_root="${1:-}"
if [[ -z "$workspace_root" ]]; then
workspace_root="$(gh_env_workspace_root)"
fi
workspace_root="$(cd "$workspace_root" && pwd)"
local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
if [[ -n "$token" ]]; then
export GH_TOKEN="$token"
export GITHUB_TOKEN="${GITHUB_TOKEN:-$token}"
fi
export GH_PROMPT_DISABLED="${GH_PROMPT_DISABLED:-1}"
export GH_NO_UPDATE_NOTIFIER="${GH_NO_UPDATE_NOTIFIER:-1}"
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$workspace_root/.xdg-config}"
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$workspace_root/.xdg-cache}"
export XDG_STATE_HOME="${XDG_STATE_HOME:-$workspace_root/.xdg-state}"
export GH_CONFIG_DIR="${GH_CONFIG_DIR:-$workspace_root/.gh}"
mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME" "$GH_CONFIG_DIR"
}
gh_env_require_token() {
local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
if [[ -z "$token" ]]; then
echo "[错误] 未设置 GH_TOKEN 或 GITHUB_TOKEN" >&2
return 1
fi
}
gh_env_rest_api_base_url() {
local host="${1:-${GH_HOST:-github.com}}"
if [[ "$host" == "github.com" ]]; then
printf '%s\n' "https://api.github.com"
return 0
fi
printf 'https://%s/api/v3\n' "$host"
}
gh_env_graphql_api_url() {
local host="${1:-${GH_HOST:-github.com}}"
if [[ "$host" == "github.com" ]]; then
printf '%s\n' "https://api.github.com/graphql"
return 0
fi
printf 'https://%s/api/graphql\n' "$host"
}
gh_env_print_exports() {
printf 'GH_PROMPT_DISABLED=%q\n' "$GH_PROMPT_DISABLED"
printf 'GH_NO_UPDATE_NOTIFIER=%q\n' "$GH_NO_UPDATE_NOTIFIER"
printf 'XDG_CONFIG_HOME=%q\n' "$XDG_CONFIG_HOME"
printf 'XDG_CACHE_HOME=%q\n' "$XDG_CACHE_HOME"
printf 'XDG_STATE_HOME=%q\n' "$XDG_STATE_HOME"
printf 'GH_CONFIG_DIR=%q\n' "$GH_CONFIG_DIR"
if [[ -n "${GH_HOST:-}" ]]; then
printf 'GH_HOST=%q\n' "$GH_HOST"
fi
if [[ -n "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]]; then
echo "GH_TOKEN=<set>"
else
echo "GH_TOKEN=<unset>"
fi
}
gh_env_usage() {
cat <<'EOF'
Usage:
bash scripts/github/_gh_env.sh --print-root
bash scripts/github/_gh_env.sh --print-env [workspace-root]
EOF
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
set -euo pipefail
case "${1:-}" in
--print-root)
gh_env_workspace_root
;;
--print-env)
gh_env_prepare "${2:-}"
gh_env_print_exports
;;
-h|--help|"")
gh_env_usage
;;
*)
echo "[错误] 未知参数: $1" >&2
gh_env_usage >&2
exit 1
;;
esac
fi
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
gh_retry_temp_failure_rc() {
printf '%s' "${GH_RETRY_TEMP_FAILURE_RC:-75}"
}
gh_retry_timeout_cmd() {
if command -v timeout >/dev/null 2>&1; then
printf '%s\n' "timeout"
return 0
fi
if command -v gtimeout >/dev/null 2>&1; then
printf '%s\n' "gtimeout"
return 0
fi
return 1
}
gh_retry_is_temp_failure_rc() {
local rc="${1:-0}"
[[ "$rc" -eq "$(gh_retry_temp_failure_rc)" ]]
}
gh_retry_compact_message() {
local message="$1"
message="${message//$'\r'/ }"
message="$(printf '%s' "$message" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//')"
if [[ -z "$message" ]]; then
message="unknown GitHub API error"
fi
printf '%s' "$message"
}
gh_retry_is_transient_error() {
local lowered
lowered="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
case "$lowered" in
*"could not resolve host"*|\
*"temporary failure in name resolution"*|\
*"name or service not known"*|\
*"no such host"*|\
*"server misbehaving"*|\
*"error connecting to api.github.com"*|\
*"dial tcp"*|\
*"i/o timeout"*|\
*"timed out"*|\
*"timed out after"*|\
*"command timed out"*|\
*"timeout awaiting response headers"*|\
*"tls handshake timeout"*|\
*"connection reset"*|\
*"connection refused"*|\
*"connection aborted"*|\
*"context deadline exceeded"*|\
*"secondary rate limit"*|\
*"rate limit exceeded"*|\
*"retry-after"*|\
*"econnreset"*|\
*"http 429"*|\
*"http 502"*|\
*"http 503"*|\
*"http 504"*|\
*"bad gateway"*|\
*"service unavailable"*|\
*"gateway timeout"*)
return 0
;;
esac
return 1
}
gh_retry_execute() {
local timeout_sec="${GH_RETRY_COMMAND_TIMEOUT_SEC:-60}"
local timeout_bin=""
if timeout_bin="$(gh_retry_timeout_cmd)"; then
"$timeout_bin" "$timeout_sec" "$@"
return $?
fi
"$@"
}
gh_retry_command() {
local label="$1"
shift
local max_attempts="${GH_RETRY_MAX_ATTEMPTS:-4}"
local base_delay="${GH_RETRY_BASE_DELAY_SEC:-2}"
local max_delay="${GH_RETRY_MAX_DELAY_SEC:-15}"
local temp_rc
temp_rc="$(gh_retry_temp_failure_rc)"
local stdout_file=""
local stderr_file=""
stdout_file="$(mktemp)"
stderr_file="$(mktemp)"
local attempt=1
local delay="$base_delay"
local rc=0
local stderr_text=""
local summary=""
local timeout_sec="${GH_RETRY_COMMAND_TIMEOUT_SEC:-60}"
while (( attempt <= max_attempts )); do
if gh_retry_execute "$@" >"$stdout_file" 2>"$stderr_file"; then
cat "$stdout_file"
rm -f "$stdout_file" "$stderr_file"
return 0
fi
rc=$?
stderr_text="$(cat "$stderr_file")"
if [[ "$rc" -eq 124 || "$rc" -eq 137 ]]; then
stderr_text="${stderr_text:-command timed out after ${timeout_sec}s}"
fi
if ! gh_retry_is_transient_error "$stderr_text"; then
if [[ -n "$stderr_text" ]]; then
printf '%s\n' "$stderr_text" >&2
fi
rm -f "$stdout_file" "$stderr_file"
return "$rc"
fi
summary="$(gh_retry_compact_message "$stderr_text")"
if (( attempt == max_attempts )); then
printf '[错误] GitHub 临时连接失败,%s 已重试 %s 次: %s\n' "$label" "$max_attempts" "$summary" >&2
rm -f "$stdout_file" "$stderr_file"
return "$temp_rc"
fi
printf '[警告] GitHub 临时连接失败,%s 第 %s/%s 次失败,%s 秒后重试: %s\n' \
"$label" "$attempt" "$max_attempts" "$delay" "$summary" >&2
sleep "$delay"
attempt=$((attempt + 1))
delay=$((delay * 2))
if (( delay > max_delay )); then
delay="$max_delay"
fi
done
rm -f "$stdout_file" "$stderr_file"
return "$temp_rc"
}
gh_api_retry() {
local label="$1"
shift
gh_retry_command "$label" gh api "$@"
}
gh_graphql_retry() {
local label="$1"
shift
gh_retry_command "$label" gh api graphql "$@"
}
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
gh_target_fail() {
echo "[错误] $1" >&2
return 1
}
gh_target_resolve_repo_from_remote() {
local remote_url=""
local owner=""
local repo=""
remote_url="$(git config --get remote.origin.url || true)"
if [[ -z "$remote_url" ]]; then
gh_target_fail "无法从 git remote.origin.url 解析仓库"
return 1
fi
if [[ "$remote_url" =~ ^git@github\.com:([^/]+)/(.+)\.git$ ]]; then
owner="${BASH_REMATCH[1]}"
repo="${BASH_REMATCH[2]}"
elif [[ "$remote_url" =~ ^https://github\.com/([^/]+)/(.+)\.git$ ]]; then
owner="${BASH_REMATCH[1]}"
repo="${BASH_REMATCH[2]}"
elif [[ "$remote_url" =~ ^https://github\.com/([^/]+)/(.+)$ ]]; then
owner="${BASH_REMATCH[1]}"
repo="${BASH_REMATCH[2]}"
else
gh_target_fail "不支持的 GitHub remote URL: ${remote_url}"
return 1
fi
printf '%s/%s\n' "$owner" "$repo"
}
gh_target_resolve_project_from_url() {
local url="$1"
local owner=""
local number=""
local owner_type=""
if [[ "$url" =~ ^https://github\.com/orgs/([^/]+)/projects/([0-9]+)(/.*)?$ ]]; then
owner="${BASH_REMATCH[1]}"
number="${BASH_REMATCH[2]}"
owner_type="organization"
elif [[ "$url" =~ ^https://github\.com/users/([^/]+)/projects/([0-9]+)(/.*)?$ ]]; then
owner="${BASH_REMATCH[1]}"
number="${BASH_REMATCH[2]}"
owner_type="user"
else
gh_target_fail "无法解析 GitHub Project URL: ${url}"
return 1
fi
printf '%s\t%s\t%s\n' "$owner" "$number" "$owner_type"
}
gh_target_resolve_content_from_url() {
local url="$1"
local repo=""
local kind=""
local number=""
if [[ "$url" =~ ^https://github\.com/([^/]+/[^/]+)/issues/([0-9]+)(/.*)?$ ]]; then
repo="${BASH_REMATCH[1]}"
number="${BASH_REMATCH[2]}"
kind="issue"
elif [[ "$url" =~ ^https://github\.com/([^/]+/[^/]+)/pull/([0-9]+)(/.*)?$ ]]; then
repo="${BASH_REMATCH[1]}"
number="${BASH_REMATCH[2]}"
kind="pull"
else
gh_target_fail "无法解析 GitHub 内容 URL: ${url}"
return 1
fi
printf '%s\t%s\t%s\n' "$repo" "$kind" "$number"
}
+17
View File
@@ -0,0 +1,17 @@
# Recommended config for GitHub-heavy Codex sessions.
# Prefer launching with scripts/github/cxgh so GH/XDG directories stay inside the workspace.
personality = "pragmatic"
model = "gpt-5.4"
model_reasoning_effort = "high"
approval_policy = "never"
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
network_access = true
[projects."/root/codes"]
trust_level = "trusted"
[notice]
hide_rate_limit_model_nudge = true
+37
View File
@@ -0,0 +1,37 @@
#!/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"
usage() {
cat <<'EOF'
Usage:
bash scripts/github/cxgh "使用 gh 更新 issue 并同步 project"
bash scripts/github/cxgh --help
Environment:
GH_TOKEN / GITHUB_TOKEN Required
CXGH_WORKSPACE_ROOT Optional workspace root for .gh / .xdg-* directories
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || "$#" -eq 0 ]]; then
usage
exit 0
fi
if ! command -v codex >/dev/null 2>&1; then
echo "[错误] 未找到 codex 命令" >&2
exit 1
fi
gh_env_prepare
gh_env_require_token
exec codex \
-a never \
-s workspace-write \
-c 'sandbox_workspace_write.network_access=true' \
"$@"
+302
View File
@@ -0,0 +1,302 @@
#!/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"
METHOD="GET"
GRAPHQL=0
REQUEST_PATH=""
REQUEST_DATA=""
REQUEST_DATA_FILE=""
PRINT_HEADERS=0
HOST_OVERRIDE=""
declare -a EXTRA_HEADERS=()
usage() {
cat <<'EOF'
Usage:
bash scripts/github/github_api.sh [options] <rest-path>
bash scripts/github/github_api.sh --graphql --data '{"query":"query { viewer { login } }"}'
Options:
--method METHOD HTTP method, default GET
--graphql Call the GraphQL endpoint instead of REST
--host HOST Override GH_HOST / GitHub Enterprise host
--data JSON Inline JSON request body
--data-file FILE Read JSON request body from file
--header VALUE Additional header, repeatable
--include-headers Print response headers to stderr
-h, --help Show help
Environment:
GH_TOKEN / GITHUB_TOKEN Required for authentication
GH_API_CONNECT_TIMEOUT_SEC Default 10
GH_API_MAX_TIME_SEC Default 60
GH_API_RETRY_MAX_ATTEMPTS Default 5
GH_API_RETRY_BASE_DELAY_SEC Default 2
GH_API_RETRY_MAX_DELAY_SEC Default 20
EOF
}
fail() {
echo "[错误] $1" >&2
exit 1
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--method)
METHOD="${2:?missing value for --method}"
shift 2
;;
--graphql)
GRAPHQL=1
shift
;;
--host)
HOST_OVERRIDE="${2:?missing value for --host}"
shift 2
;;
--data)
REQUEST_DATA="${2:?missing value for --data}"
shift 2
;;
--data-file)
REQUEST_DATA_FILE="${2:?missing value for --data-file}"
shift 2
;;
--header)
EXTRA_HEADERS+=("${2:?missing value for --header}")
shift 2
;;
--include-headers)
PRINT_HEADERS=1
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-*)
fail "未知参数: $1"
;;
*)
if [[ -n "$REQUEST_PATH" ]]; then
fail "REST 路径只能提供一个"
fi
REQUEST_PATH="$1"
shift
;;
esac
done
if [[ -n "$REQUEST_DATA" && -n "$REQUEST_DATA_FILE" ]]; then
fail "--data 与 --data-file 只能使用一个"
fi
if [[ "$METHOD" == "GET" && ( -n "$REQUEST_DATA" || -n "$REQUEST_DATA_FILE" ) ]]; then
METHOD="POST"
fi
if [[ "$GRAPHQL" -eq 1 && -z "$REQUEST_DATA" && -z "$REQUEST_DATA_FILE" ]]; then
fail "GraphQL 调用必须提供 --data 或 --data-file"
fi
if [[ "$GRAPHQL" -eq 0 && -z "$REQUEST_PATH" ]]; then
fail "REST 调用必须提供路径,例如 repos/OWNER/REPO/issues"
fi
}
normalize_rest_url() {
local path="$1"
if [[ "$path" =~ ^https?:// ]]; then
printf '%s\n' "$path"
return 0
fi
path="${path#/}"
printf '%s/%s\n' "$(gh_env_rest_api_base_url "$HOST_OVERRIDE")" "$path"
}
resolve_request_url() {
if [[ "$GRAPHQL" -eq 1 ]]; then
gh_env_graphql_api_url "$HOST_OVERRIDE"
return 0
fi
normalize_rest_url "$REQUEST_PATH"
}
retryable_http_code() {
case "${1:-0}" in
408|429|500|502|503|504)
return 0
;;
esac
return 1
}
retryable_curl_rc() {
case "${1:-0}" in
6|7|18|28|35|52|55|56)
return 0
;;
esac
return 1
}
extract_retry_after() {
local header_file="$1"
awk 'BEGIN{IGNORECASE=1} /^retry-after:/ {gsub("\r","",$2); print $2; exit}' "$header_file"
}
body_summary() {
local body_file="$1"
local summary
summary="$(tr '\n' ' ' <"$body_file" | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//')"
if [[ -z "$summary" ]]; then
summary="empty response body"
fi
printf '%s' "$summary"
}
perform_request() {
gh_env_prepare
gh_env_require_token
local connect_timeout="${GH_API_CONNECT_TIMEOUT_SEC:-10}"
local max_time="${GH_API_MAX_TIME_SEC:-60}"
local max_attempts="${GH_API_RETRY_MAX_ATTEMPTS:-5}"
local base_delay="${GH_API_RETRY_BASE_DELAY_SEC:-2}"
local max_delay="${GH_API_RETRY_MAX_DELAY_SEC:-20}"
local attempt=1
local delay="$base_delay"
local url=""
url="$(resolve_request_url)"
local header_file=""
local body_file=""
local stderr_file=""
header_file="$(mktemp)"
body_file="$(mktemp)"
stderr_file="$(mktemp)"
local curl_rc=0
local http_code=""
local retry_after=""
local summary=""
local data_args=()
local -a curl_header_args=()
if [[ -n "$REQUEST_DATA" ]]; then
data_args+=(--data "$REQUEST_DATA")
elif [[ -n "$REQUEST_DATA_FILE" ]]; then
data_args+=(--data "@${REQUEST_DATA_FILE}")
fi
if (( ${#data_args[@]} > 0 )); then
curl_header_args+=(-H 'Content-Type: application/json')
fi
for header in "${EXTRA_HEADERS[@]}"; do
curl_header_args+=(-H "$header")
done
while (( attempt <= max_attempts )); do
: >"$header_file"
: >"$body_file"
: >"$stderr_file"
set +e
http_code="$(
curl \
--silent \
--show-error \
--output "$body_file" \
--dump-header "$header_file" \
--write-out '%{http_code}' \
--connect-timeout "$connect_timeout" \
--max-time "$max_time" \
--request "$METHOD" \
-H "Authorization: Bearer ${GH_TOKEN:-${GITHUB_TOKEN:-}}" \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2022-11-28' \
"${data_args[@]}" \
"${curl_header_args[@]}" \
"$url" \
2>"$stderr_file"
)"
curl_rc=$?
set -e
if [[ "$curl_rc" -eq 0 && "$http_code" =~ ^2 ]]; then
if [[ "$PRINT_HEADERS" -eq 1 ]]; then
cat "$header_file" >&2
fi
cat "$body_file"
rm -f "$header_file" "$body_file" "$stderr_file"
return 0
fi
if retryable_curl_rc "$curl_rc" || retryable_http_code "$http_code"; then
if (( attempt == max_attempts )); then
if [[ "$curl_rc" -ne 0 ]]; then
summary="$(tr '\n' ' ' <"$stderr_file" | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//')"
else
summary="$(body_summary "$body_file")"
fi
echo "[错误] GitHub API 重试耗尽: $summary" >&2
rm -f "$header_file" "$body_file" "$stderr_file"
exit 75
fi
retry_after="$(extract_retry_after "$header_file")"
if [[ -n "$retry_after" && "$retry_after" =~ ^[0-9]+$ ]]; then
delay="$retry_after"
fi
if [[ "$curl_rc" -ne 0 ]]; then
summary="$(tr '\n' ' ' <"$stderr_file" | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//')"
else
summary="$(body_summary "$body_file")"
fi
echo "[警告] GitHub API 调用失败,第 ${attempt}/${max_attempts} 次,${delay} 秒后重试: ${summary}" >&2
sleep "$delay"
attempt=$((attempt + 1))
delay=$((delay * 2))
if (( delay > max_delay )); then
delay="$max_delay"
fi
continue
fi
if [[ "$PRINT_HEADERS" -eq 1 ]]; then
cat "$header_file" >&2
fi
if [[ -s "$body_file" ]]; then
cat "$body_file" >&2
elif [[ -s "$stderr_file" ]]; then
cat "$stderr_file" >&2
else
echo "[错误] GitHub API 调用失败,HTTP ${http_code:-unknown}" >&2
fi
rm -f "$header_file" "$body_file" "$stderr_file"
if [[ "$curl_rc" -eq 0 ]]; then
exit 1
fi
exit "$curl_rc"
done
}
parse_args "$@"
perform_request
+133
View File
@@ -0,0 +1,133 @@
#!/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"
TARGET_REPO=""
ISSUE_NUMBER=""
BODY_TEXT=""
BODY_FILE=""
DRY_RUN=0
SKIP_PREFLIGHT=0
usage() {
cat <<'EOF'
Usage:
bash scripts/github/issue-comment.sh [options]
Options:
--repo OWNER/REPO Target repository, default from git remote
--issue NUMBER Issue number
--body TEXT Comment body text
--body-file FILE Comment body file
--dry-run Print request payload without sending
--skip-preflight Skip GitHub preflight checks
-h, --help Show help
EOF
}
fail() {
echo "[错误] $1" >&2
exit 1
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--repo)
TARGET_REPO="${2:?missing value for --repo}"
shift 2
;;
--issue)
ISSUE_NUMBER="${2:?missing value for --issue}"
shift 2
;;
--body)
BODY_TEXT="${2:?missing value for --body}"
shift 2
;;
--body-file)
BODY_FILE="${2:?missing value for --body-file}"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
--skip-preflight)
SKIP_PREFLIGHT=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "未知参数: $1"
;;
esac
done
if [[ -z "$TARGET_REPO" ]]; then
TARGET_REPO="$(gh_target_resolve_repo_from_remote)"
fi
if [[ -z "$ISSUE_NUMBER" ]]; then
fail "必须提供 --issue"
fi
if [[ -n "$BODY_TEXT" && -n "$BODY_FILE" ]]; then
fail "--body 与 --body-file 只能使用一个"
fi
if [[ -z "$BODY_TEXT" && -z "$BODY_FILE" ]]; then
fail "必须提供 --body 或 --body-file"
fi
}
comment_body() {
if [[ -n "$BODY_FILE" ]]; then
cat "$BODY_FILE"
return 0
fi
printf '%s' "$BODY_TEXT"
}
main() {
local body=""
local payload=""
local response=""
parse_args "$@"
gh_env_prepare
gh_env_require_token
if [[ "$SKIP_PREFLIGHT" != "1" ]]; then
bash "$SCRIPT_DIR/preflight.sh" --repo "$TARGET_REPO" >/dev/null
fi
body="$(comment_body)"
payload="$(jq -nc --arg body "$body" '{body: $body}')"
if [[ "$DRY_RUN" == "1" ]]; then
echo "[dry-run] POST repos/${TARGET_REPO}/issues/${ISSUE_NUMBER}/comments"
printf '%s\n' "$payload" | jq .
exit 0
fi
response="$(
bash "$SCRIPT_DIR/github_api.sh" \
--method POST \
"repos/${TARGET_REPO}/issues/${ISSUE_NUMBER}/comments" \
--data "$payload"
)"
echo "[完成] 已创建 issue 评论: $(printf '%s' "$response" | jq -r '.html_url // .url')"
}
main "$@"
+278
View File
@@ -0,0 +1,278 @@
#!/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"
TARGET_REPO=""
PROJECT_OWNER=""
PROJECT_NUMBER=""
PROJECT_URL=""
JSON_OUTPUT=0
usage() {
cat <<'EOF'
Usage:
bash scripts/github/preflight.sh [options]
Options:
--repo OWNER/REPO Verify repository access
--project-owner OWNER Verify GitHub Project owner
--project-number N Verify GitHub Project number
--project-url URL Parse owner/number from Project URL
--json Print machine-readable JSON
-h, --help Show 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
--repo)
TARGET_REPO="${2:?missing value for --repo}"
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-url)
PROJECT_URL="${2:?missing value for --project-url}"
shift 2
;;
--json)
JSON_OUTPUT=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 [[ -n "$PROJECT_OWNER" && -z "$PROJECT_NUMBER" ]]; then
fail "使用 GitHub Project 时必须同时提供 --project-number"
fi
if [[ -z "$PROJECT_OWNER" && -n "$PROJECT_NUMBER" ]]; then
fail "使用 GitHub Project 时必须同时提供 --project-owner"
fi
}
github_rest_host() {
local host="${GH_HOST:-github.com}"
if [[ "$host" == "github.com" ]]; then
printf '%s\n' "api.github.com"
return 0
fi
printf '%s\n' "$host"
}
dns_probe_json() {
local host="$1"
local addresses_csv=""
if command -v getent >/dev/null 2>&1; then
addresses_csv="$(
getent ahosts "$host" 2>/dev/null \
| awk '{print $1}' \
| sort -u \
| paste -sd, -
)"
if [[ -n "$addresses_csv" ]]; then
jq -nc \
--arg host "$host" \
--arg addresses_csv "$addresses_csv" \
'{host: $host, addresses: ($addresses_csv | split(",") | map(select(length > 0)))}'
return 0
fi
fi
python3 - "$host" <<'PY'
import json
import time
import socket
import sys
host = sys.argv[1]
addresses = []
last_error = None
for _ in range(3):
try:
for family, _, _, _, sockaddr in socket.getaddrinfo(host, 443, proto=socket.IPPROTO_TCP):
addr = sockaddr[0]
if addr not in addresses:
addresses.append(addr)
break
except OSError as exc:
last_error = exc
time.sleep(1)
if not addresses and last_error is not None:
print(json.dumps({"host": host, "addresses": [], "warning": str(last_error)}, ensure_ascii=False))
raise SystemExit(0)
print(json.dumps({"host": host, "addresses": addresses}, ensure_ascii=False))
PY
}
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 }
}'
}
main() {
local dns_json=""
local viewer_json=""
local repo_json="null"
local project_raw="null"
local project_json="null"
local host=""
local viewer_login=""
local rate_remaining=""
local rate_reset=""
local scopes=""
parse_args "$@"
require_command "jq"
require_command "curl"
require_command "python3"
gh_env_prepare
gh_env_require_token
host="$(github_rest_host)"
dns_json="$(dns_probe_json "$host")"
viewer_json="$(
bash "$SCRIPT_DIR/github_api.sh" \
--graphql \
--data '{"query":"query { viewer { login } rateLimit { remaining resetAt } }"}'
)"
viewer_login="$(printf '%s' "$viewer_json" | jq -r '.data.viewer.login // empty')"
rate_remaining="$(printf '%s' "$viewer_json" | jq -r '.data.rateLimit.remaining // empty')"
rate_reset="$(printf '%s' "$viewer_json" | jq -r '.data.rateLimit.resetAt // empty')"
scopes="$(
curl \
--silent \
--show-error \
--head \
--connect-timeout "${GH_API_CONNECT_TIMEOUT_SEC:-10}" \
--max-time "${GH_API_MAX_TIME_SEC:-60}" \
-H "Authorization: Bearer ${GH_TOKEN:-${GITHUB_TOKEN:-}}" \
-H 'Accept: application/vnd.github+json' \
"$(gh_env_rest_api_base_url)/" \
| awk 'BEGIN{IGNORECASE=1} /^x-oauth-scopes:/ {sub(/\r$/,""); sub(/^x-oauth-scopes:[[:space:]]*/,""); print; exit}'
)"
if [[ -n "$TARGET_REPO" ]]; then
repo_json="$(
bash "$SCRIPT_DIR/github_api.sh" "repos/${TARGET_REPO}" \
| jq '{full_name, private, default_branch, html_url}'
)"
fi
if [[ -n "$PROJECT_OWNER" && -n "$PROJECT_NUMBER" ]]; then
project_raw="$(
bash "$SCRIPT_DIR/github_api.sh" \
--graphql \
--data "$(project_probe_payload)"
)"
project_json="$(
printf '%s' "$project_raw" \
| jq '(.data.organization.projectV2 // .data.user.projectV2) as $project
| if $project == null then null else $project end'
)"
if [[ "$project_json" == "null" ]]; then
fail "GitHub Project 不可访问: ${PROJECT_OWNER}/${PROJECT_NUMBER}"
fi
fi
if [[ "$JSON_OUTPUT" == "1" ]]; then
jq -nc \
--arg viewer_login "$viewer_login" \
--arg rate_remaining "$rate_remaining" \
--arg rate_reset "$rate_reset" \
--arg scopes "$scopes" \
--arg repo "$TARGET_REPO" \
--arg project_owner "$PROJECT_OWNER" \
--arg project_number "$PROJECT_NUMBER" \
--argjson dns "$dns_json" \
--argjson repo_data "$repo_json" \
--argjson project_data "$project_json" \
'{
dns: $dns,
viewer: {
login: $viewer_login,
rate_remaining: $rate_remaining,
rate_reset_at: $rate_reset,
scopes: $scopes
},
repo: (if $repo == "" then null else ($repo_data + { target: $repo }) end),
project: (if $project_owner == "" then null else ($project_data + { owner: $project_owner, number: ($project_number|tonumber) }) end)
}'
return 0
fi
if [[ "$(printf '%s' "$dns_json" | jq -r '.addresses | length')" -gt 0 ]]; then
echo "[OK] DNS: $(printf '%s' "$dns_json" | jq -r '.host') -> $(printf '%s' "$dns_json" | jq -r '.addresses | join(", ")')"
else
echo "[警告] DNS: $(printf '%s' "$dns_json" | jq -r '.host') 解析结果为空,继续用 GitHub API 实测连通性"
fi
echo "[OK] Token: ${viewer_login}"
echo "[OK] Rate limit: remaining=${rate_remaining}, reset_at=${rate_reset}"
if [[ -n "$scopes" ]]; then
echo "[OK] Scopes: ${scopes}"
fi
if [[ -n "$TARGET_REPO" ]]; then
echo "[OK] Repo: $(printf '%s' "$repo_json" | jq -r '.full_name') (default_branch=$(printf '%s' "$repo_json" | jq -r '.default_branch'))"
fi
if [[ -n "$PROJECT_OWNER" && -n "$PROJECT_NUMBER" ]]; then
echo "[OK] Project: ${PROJECT_OWNER}/${PROJECT_NUMBER} -> $(printf '%s' "$project_json" | jq -r '.title')"
echo "[OK] Project update access: $(printf '%s' "$project_json" | jq -r '.viewerCanUpdate')"
fi
}
main "$@"
+469
View File
@@ -0,0 +1,469 @@
#!/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 "$@"
File diff suppressed because it is too large Load Diff