chore(github): add workspace helper scripts
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Local Python bytecode caches
|
||||
__pycache__/
|
||||
@@ -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 } }"}'
|
||||
```
|
||||
Executable
+114
@@ -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
|
||||
Executable
+160
@@ -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 "$@"
|
||||
}
|
||||
Executable
+78
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
Executable
+37
@@ -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' \
|
||||
"$@"
|
||||
Executable
+302
@@ -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
|
||||
Executable
+133
@@ -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 "$@"
|
||||
Executable
+278
@@ -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 "$@"
|
||||
Executable
+469
@@ -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 "$@"
|
||||
Executable
+2018
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user