#!/bin/bash
set -euo pipefail
readonly CONNECT_TIMEOUT=15
readonly API_MAX_TIME=120
readonly CJ_TIMEOUT=30
readonly GIT_TIMEOUT=300
readonly PAGE_SIZE=50
readonly IMPL_MAX_LINES=200
readonly FORK_POLL_INTERVAL=2
readonly FORK_POLL_COUNT=30
readonly API_RETRY_COUNT=3
readonly API_RETRY_DELAY=2
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { printf "${GREEN}[INFO]${NC} %s\n" "$*"; }
warn() { printf "${YELLOW}[NOTE]${NC} %s\n" "$*"; }
error() { printf "${RED}[ERROR]${NC} %s\n" "$*"; exit 1; }
step() { printf "\n${BLUE}===== %s =====${NC}\n" "$1"; }
confirm() {
if [[ ! -t 0 ]]; then
warn "stdin 非终端,无法确认,默认拒绝"
return 1
fi
local v; read -rp "$1 (y/n): " v; [[ "$v" =~ ^[Yy]$ ]]; }
read_secret() {
if [[ ! -t 0 ]]; then
error "stdin 非终端,无法安全输入密码,请使用交互式终端运行"
fi
local prompt="$1" varname="$2" value="" char
trap 'echo; trap - INT; return 130' INT
printf "%s" "$prompt"
while IFS= read -r -n1 -s char; do
if [[ -z "$char" ]]; then
printf '\n'; break
elif [[ "$char" == $'\x7f' || "$char" == $'\x08' ]]; then
if [[ -n "$value" ]]; then value="${value%?}"; printf '\b \b'; fi
else
value+="$char"; printf '*'
fi
done
trap - INT
printf -v "$varname" '%s' "$value"
}
format_members_md() {
local out=""
for m in "${MEMBERS[@]+"${MEMBERS[@]}"}"; do
IFS='|' read -r m_name m_gcuser m_email m_role <<< "$m"
out+=" - ${m_name},${m_role:-成员}"$'\n'
done
printf '%s' "$out"
}
build_coauthor_trailers() {
local out="" sep=""
for m in "${MEMBERS[@]+"${MEMBERS[@]}"}"; do
IFS='|' read -r m_name m_gcuser m_email m_role <<< "$m"
out="${out}${sep}Co-authored-by: ${m_gcuser} <${m_email}>"
sep=$'\n'
done
printf '%s' "$out"
}
build_pr_body() {
local members_md
members_md=$(format_members_md)
printf '%s\n' '## 团队信息' "- 团队名称:${TEAM_SLUG}" "- 所属单位:${SCHOOL_FULL}" '- 团队成员:' "${members_md}" '## 算子信息' "- 算子名称:${OP_BASE_NAME}" "- CANNJudge 题目:${CJ_PROB_TITLE}" '' '## 算子实现介绍' "${IMPL_DESC}"
}
write_temp_json() {
local filepath="$1" content="$2"
printf '%s' "$content" > "$filepath"
chmod 600 "$filepath"
}
TMPDIR=""
CURL_CONFIG=""
GIT_UA="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
cleanup() {
if [[ -n "$TMPDIR" && -d "$TMPDIR" ]]; then
rm -rf "$TMPDIR"
fi
}
trap 'warn "正在中断,清理临时文件..."; cleanup; exit 130' INT TERM HUP
trap cleanup EXIT
TMPDIR=$(mktemp -d)
chmod 700 "$TMPDIR"
CURL_CONFIG="${TMPDIR}/.curl_config"
for cmd in jq curl python3 git; do
command -v "$cmd" >/dev/null 2>&1 || error "缺少必要依赖: $cmd,请先安装"
done
python3 -c "import requests" 2>/dev/null || error "缺少 Python 依赖: requests,请运行 pip install requests"
CLA_SIGN_URL="https://clasign.osinfra.cn/sign-cla/68cbd4a3dbabc050b436cdd4/individual"
GC_API="https://api.gitcode.com/api/v5"
write_curl_config() {
printf 'header = "Authorization: Bearer %s"\n' "$GC_TOKEN" > "$CURL_CONFIG"
printf 'header = "User-Agent: %s"\n' "$GIT_UA" >> "$CURL_CONFIG"
printf 'header = "Accept: application/json, text/plain, */*"\n' >> "$CURL_CONFIG"
printf 'header = "Referer: https://gitcode.com/"\n' >> "$CURL_CONFIG"
chmod 600 "$CURL_CONFIG"
}
API_CALL_SCRIPT="${TMPDIR}/api_call.sh"
cat > "$API_CALL_SCRIPT" << 'ACEOF'
method="$1"; url="$2"; curl_config="$3"; resp_file="$4"; stderr_file="$5"
shift 5
wfmt=$'\n%{http_code}'
attempt=1; max_retry=${_API_RETRY:-3}; retry_delay=${_API_DELAY:-2}; connect_timeout=${_CONN_TIMEOUT:-15}; max_time=${_API_MAX:-120}
while true; do
result=$(curl -sL --connect-timeout "$connect_timeout" --max-time "$max_time" -w "$wfmt" -X "$method" -K "$curl_config" "$@" "$url" 2>"$stderr_file") || true
if [[ -n "$result" ]]; then
http=$(printf '%s\n' "$result" | tail -1)
if [[ "$http" =~ ^5[0-9][0-9]$ ]] && [[ "$attempt" -lt "$max_retry" ]]; then
printf '[NOTE] 服务端错误 HTTP %s,%ds 后重试...\n' "$http" "$retry_delay" >&2
sleep "$retry_delay"
attempt=$((attempt + 1))
continue
fi
printf '%s\n' "$result" > "$resp_file"
break
fi
if [[ "$attempt" -ge "$max_retry" ]]; then
err_content=""
if [[ -f "$stderr_file" ]]; then err_content=$(cat "$stderr_file" 2>/dev/null); fi
printf '[NOTE] API 请求失败: %s\n' "$err_content" >&2
printf '\n0\n' > "$resp_file"
rm -f "$stderr_file" 2>/dev/null || true
exit 1
fi
printf '[NOTE] API 请求失败 (attempt %d/%d),%ds 后重试...\n' "$attempt" "$max_retry" "$retry_delay" >&2
sleep "$retry_delay"
attempt=$((attempt + 1))
done
rm -f "$stderr_file" 2>/dev/null || true
ACEOF
chmod +x "$API_CALL_SCRIPT"
api_call() {
local method="$1"; local url="$2"; shift 2
local stderr_file="${TMPDIR}/curl_stderr_$$"
local resp_file="${TMPDIR}/api_resp_$$"
_API_RETRY=$API_RETRY_COUNT _API_DELAY=$API_RETRY_DELAY _CONN_TIMEOUT=$CONNECT_TIMEOUT _API_MAX=$API_MAX_TIME \
bash "$API_CALL_SCRIPT" "$method" "$url" "$CURL_CONFIG" "$resp_file" "$stderr_file" "$@" || true
cat "$resp_file"
}
http_code() {
local code
code=$(printf '%s\n' "$1" | tail -1)
printf '%s' "${code:-0}"
}
http_body() {
local body
body=$(printf '%s\n' "$1" | sed '$d')
printf '%s' "${body}"
}
step "输入并验证信息"
while true; do
read -rp "CANNJudge 邮箱: " CJ_EMAIL
read_secret "CANNJudge 密码: " CJ_PASSWORD
if [[ -z "$CJ_EMAIL" || -z "$CJ_PASSWORD" ]]; then warn "邮箱和密码不能为空"; continue; fi
CJ_LOGIN_JSON=$(jq -n --arg email "$CJ_EMAIL" --arg password "$CJ_PASSWORD" \
'{email: $email, password: $password}')
CJ_LOGIN_FILE="${TMPDIR}/cj_login_body.json"
write_temp_json "$CJ_LOGIN_FILE" "$CJ_LOGIN_JSON"
CJ_RESP=$(curl -s --connect-timeout "$CONNECT_TIMEOUT" --max-time "$CJ_TIMEOUT" \
-w "\n%{http_code}" -X POST -H "Content-Type: application/json" \
-d @"$CJ_LOGIN_FILE" "https://cannjudge.cn/api/users/login" 2>/dev/null) || true
rm -f "$CJ_LOGIN_FILE"
CJ_HTTP=$(http_code "$CJ_RESP"); CJ_BODY=$(http_body "$CJ_RESP")
if [[ -z "$CJ_BODY" || "$CJ_HTTP" == "0" ]]; then
warn "网络请求失败,请检查网络连接"; continue
fi
if [[ "$CJ_HTTP" == "200" ]]; then
CJ_USER_ID=$(echo "$CJ_BODY" | jq -r '._id // empty')
CJ_NICKNAME=$(echo "$CJ_BODY" | jq -r '.nickname // "N/A"')
if [[ -z "$CJ_USER_ID" ]]; then warn "解析用户ID失败,请重试"; continue; fi
info "CANNJudge 登录成功! 用户: ${CJ_NICKNAME}"; break
else
err_msg=$(echo "$CJ_BODY" | jq -r '.message // .msg // empty' 2>/dev/null) || true
warn "登录失败 — HTTP ${CJ_HTTP}${err_msg:+: $err_msg},请重试"
fi
done
while true; do
read -rp "CANNJudge 题目地址 (如 https://cannjudge.cn/public/op_challenge_jiangshan_final/batch_to_space): " CJ_PROBLEM_URL
if [[ -z "$CJ_PROBLEM_URL" ]]; then warn "不能为空"; continue; fi
if [[ ! "$CJ_PROBLEM_URL" =~ ^https://cannjudge\.cn/ ]]; then
warn "URL 必须以 https://cannjudge.cn/ 开头"; continue
fi
CJ_HOST=$(echo "$CJ_PROBLEM_URL" | sed -E 's|^https://([^/]+)/.*|\1|')
if [[ "$CJ_HOST" != "cannjudge.cn" ]]; then
warn "URL 域名必须为 cannjudge.cn,当前: ${CJ_HOST}"; continue
fi
CJ_URL_PATH=$(echo "$CJ_PROBLEM_URL" | sed 's|/*$||' | sed 's|^https://cannjudge.cn||')
CJ_URL_PATH=$(echo "$CJ_URL_PATH" | sed 's|/submit$||')
OP_PROBLEM_NAME=$(echo "$CJ_URL_PATH" | awk -F/ '{print $NF}')
if [[ -z "$OP_PROBLEM_NAME" ]]; then
warn "无法从 URL 中提取题目名"; continue
fi
if [[ ! "$OP_PROBLEM_NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then
warn "题目名包含非法字符,仅允许字母/数字/短横线/下划线"; continue
fi
CJ_PR_RESP=$(curl -s --connect-timeout "$CONNECT_TIMEOUT" --max-time "$CJ_TIMEOUT" \
-w "\n%{http_code}" "https://cannjudge.cn/api/problems/name/${OP_PROBLEM_NAME}" 2>/dev/null) || true
CJ_PR_HTTP=$(http_code "$CJ_PR_RESP"); CJ_PR_BODY=$(http_body "$CJ_PR_RESP")
if [[ -z "$CJ_PR_BODY" || "$CJ_PR_HTTP" == "0" ]]; then
warn "网络请求失败,请检查网络连接"; continue
fi
if [[ "$CJ_PR_HTTP" != "200" ]]; then
warn "题目不存在 — HTTP ${CJ_PR_HTTP},请检查地址"; continue
fi
CJ_PROBLEM_ID=$(echo "$CJ_PR_BODY" | jq -r '._id // empty')
CJ_PROB_TITLE=$(echo "$CJ_PR_BODY" | jq -r '.title // .name')
if [[ -z "$CJ_PROBLEM_ID" ]]; then warn "解析题目信息失败"; continue; fi
CJ_FOUND=""; CJ_SKIP=0
while true; do
CJ_PAGE=$(curl -s --connect-timeout "$CONNECT_TIMEOUT" --max-time "$CJ_TIMEOUT" \
"https://cannjudge.cn/api/submissions/user/${CJ_USER_ID}?skip=${CJ_SKIP}&limit=${PAGE_SIZE}" 2>/dev/null) || true
CJ_PAGE_LEN=$(echo "$CJ_PAGE" | jq 'length' 2>/dev/null) || true
if [[ "${CJ_PAGE_LEN:-0}" -eq 0 ]]; then break; fi
CJ_FOUND=$(echo "$CJ_PAGE" | jq -r --arg pid "$CJ_PROBLEM_ID" \
'[.[] | select(.problem._id == $pid and .status == "Pass")] | .[0] | .create_time // empty' 2>/dev/null) || true
if [[ -n "$CJ_FOUND" ]] || [[ "$CJ_PAGE_LEN" -lt "$PAGE_SIZE" ]]; then break; fi
CJ_SKIP=$((CJ_SKIP + PAGE_SIZE))
done
if [[ -z "$CJ_FOUND" ]]; then
warn "题目存在但无 Pass 提交,请先在 CANNJudge 上通过"
confirm "仍要继续" || continue
else
info "题目: ${CJ_PROB_TITLE} | 最新 Pass: $(date -d "$CJ_FOUND" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "$CJ_FOUND")"
fi
break
done
OP_BASE_NAME="$OP_PROBLEM_NAME"
info "算子名: ${OP_BASE_NAME}"
echo ""
while true; do
read -rp "GitCode 用户名: " GC_USER
echo " 访问令牌获取: GitCode → 设置 → 访问令牌 → 新建令牌 (勾选全部权限)"
read_secret "GitCode 访问令牌: " GC_TOKEN
if [[ -z "$GC_USER" || -z "$GC_TOKEN" ]]; then warn "用户名和令牌不能为空"; continue; fi
write_curl_config
GC_VR=$(api_call GET "${GC_API}/user") || true
GC_VHTTP=$(http_code "$GC_VR"); GC_VBODY=$(http_body "$GC_VR")
if [[ -z "$GC_VBODY" || "$GC_VHTTP" == "0" ]]; then
warn "网络请求失败,请检查网络连接"; continue
fi
if [[ "$GC_VHTTP" == "200" ]]; then
GC_LOGIN_NAME=$(echo "$GC_VBODY" | jq -r '.login // .username // empty')
GC_EMAIL=$(echo "$GC_VBODY" | jq -r '.email // empty')
if [[ -z "$GC_LOGIN_NAME" ]]; then warn "解析用户信息失败"; continue; fi
info "GitCode 认证成功! 用户: ${GC_LOGIN_NAME}, 默认邮箱: ${GC_EMAIL:-未设置}"
if [[ "$GC_LOGIN_NAME" != "$GC_USER" ]]; then
error "令牌用户 ${GC_LOGIN_NAME} 与输入 ${GC_USER} 不一致,请使用匹配的令牌"
fi
break
else
warn "GitCode 认证失败 — HTTP ${GC_VHTTP},请检查用户名和访问令牌"
fi
done
echo ""
while true; do
read -rp "用户邮箱 (CLA 签署邮箱): " USER_EMAIL
if [[ -z "$USER_EMAIL" ]]; then warn "邮箱不能为空"; continue; fi
if [[ ! "$USER_EMAIL" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then
warn "邮箱格式不正确"; continue
fi
break
done
if [[ -n "${GC_EMAIL:-}" && "${GC_EMAIL}" != "${USER_EMAIL}" ]]; then
warn "GitCode 默认邮箱 ${GC_EMAIL} 与 CLA 邮箱 ${USER_EMAIL} 不一致,通过网页创建 PR 时 CLA 校验会失败(网页操作使用默认邮箱),建议到 GitCode 设置页面修改默认邮箱为 ${USER_EMAIL}"
elif [[ -z "${GC_EMAIL:-}" ]]; then
warn "GitCode 账号未设置默认邮箱,建议设置为 ${USER_EMAIL}"
fi
echo ""
warn "竞赛仓要求所有队员签署 CLA 才能提交 PR"
echo " 签署链接: ${CLA_SIGN_URL}"
while true; do
confirm "确认你自己 (GitCode: ${GC_LOGIN_NAME}, 邮箱: ${USER_EMAIL}) 已签署 CLA" && break
warn "请先在浏览器中打开上述链接签署 CLA"
echo " 签署链接: ${CLA_SIGN_URL}"
done
echo ""
while true; do
read -rp "目标仓库地址 (如 https://gitcode.com/cann/cann-ops-competitions/tree/master/03_university/CANN-ops-jiangshan-challenge-2026/preliminary/submissions): " TARGET_URL
if [[ ! "$TARGET_URL" =~ ^https://gitcode\.com/([^/]+)/([^/]+)(/-)?/tree/([^/]+)/(.+)$ ]]; then
warn "URL 格式不正确,应为: https://gitcode.com/{owner}/{repo}/tree/{branch}/{path}"; continue
fi
REPO_OWNER="${BASH_REMATCH[1]}"; REPO_NAME="${BASH_REMATCH[2]}"
TARGET_BRANCH="${BASH_REMATCH[4]}"; TARGET_DIR="${BASH_REMATCH[5]}"
TARGET_DIR="${TARGET_DIR%/}"
if [[ "${REPO_OWNER}/${REPO_NAME}" != "cann/cann-ops-competitions" ]]; then
warn "必须是竞赛仓 cann/cann-ops-competitions,当前: ${REPO_OWNER}/${REPO_NAME}"; continue
fi
LAST_DIR=$(basename "$TARGET_DIR")
if [[ "$LAST_DIR" != "submissions" ]]; then
warn "路径最后一级必须是 submissions,当前: ${LAST_DIR}"; continue
fi
RR=$(api_call GET "${GC_API}/repos/${REPO_OWNER}/${REPO_NAME}") || true
RHTTP=$(http_code "$RR")
if [[ -z "$(http_body "$RR")" || "$RHTTP" == "0" ]]; then
warn "网络请求失败,请检查网络连接"; continue
fi
if [[ "$RHTTP" != "200" ]]; then
warn "仓库不可访问 — HTTP ${RHTTP},请检查令牌权限"; continue
fi
ENCODED_DIR=$(printf '%s' "$TARGET_DIR" | jq -sRr '@uri')
DR=$(api_call GET "${GC_API}/repos/${REPO_OWNER}/${REPO_NAME}/contents/${ENCODED_DIR}?ref=${TARGET_BRANCH}") || true
DHTTP=$(http_code "$DR")
if [[ "$DHTTP" != "200" ]]; then
warn "路径 ${TARGET_DIR} 在 ${TARGET_BRANCH} 分支上不存在 — HTTP ${DHTTP},请检查 URL"; continue
fi
info "竞赛仓验证通过: 路径: ${TARGET_DIR}"; break
done
echo ""
while true; do
read -rp "比赛名称 (如 算子挑战赛江山赛区预赛): " COMPETITION_NAME
[[ -n "$COMPETITION_NAME" ]] && break
warn "比赛名称不能为空"
done
echo ""
while true; do
read -rp "学校代码缩写 (如 nju, seu, hitsz): " SCHOOL_CODE
if [[ -z "$SCHOOL_CODE" ]]; then warn "学校代码不能为空"; continue; fi
if [[ ! "$SCHOOL_CODE" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]]; then
warn "只能包含字母、数字、短横线和下划线,且以字母或数字开头"; continue
fi
break
done
while true; do
read -rp "队伍名称 (短横线分隔,如 op-pioneers): " TEAM_SLUG
if [[ -z "$TEAM_SLUG" ]]; then warn "队伍名称不能为空"; continue; fi
if [[ ! "$TEAM_SLUG" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]]; then
warn "只能包含字母、数字、短横线和下划线,且以字母或数字开头"; continue
fi
break
done
TEAM_DIR_NAME="${SCHOOL_CODE}_${TEAM_SLUG}"
SUBMIT_DIR="${TARGET_DIR}/${TEAM_DIR_NAME}"
info "提交目录: ${SUBMIT_DIR}/"
echo ""
while true; do
read -rp "联系人姓名: " CONTACT_NAME
[[ -n "$CONTACT_NAME" ]] && break
warn "联系人姓名不能为空"
done
while true; do
read -rp "所属单位 (学校全称): " SCHOOL_FULL
[[ -n "$SCHOOL_FULL" ]] && break
warn "所属单位不能为空"
done
echo ""
MEMBERS=()
read -rp "你的分工 (如 算子实现): " SUBMITTER_ROLE
if [[ "$CONTACT_NAME" == *"|"* || "$GC_LOGIN_NAME" == *"|"* || "$USER_EMAIL" == *"|"* || "${SUBMITTER_ROLE:-}" == *"|"* ]]; then
error "输入不能包含 | 字符"
fi
MEMBERS+=("${CONTACT_NAME}|${GC_LOGIN_NAME}|${USER_EMAIL}|${SUBMITTER_ROLE:-负责人}")
info "已添加成员: ${CONTACT_NAME} — 你"
echo ""
info "其他团队成员"
info " 成员邮箱必须与 CLA 签署邮箱一致,否则 CLA 校验会失败"
while true; do
read -rp "添加新成员? (y/n): " add_member
[[ ! "$add_member" =~ ^[Yy]$ ]] && break
while true; do
read -rp " 姓名: " m_name
[[ -n "$m_name" ]] && break
warn "姓名不能为空"
done
while true; do
read -rp " GitCode 用户名: " m_gcuser
if [[ -z "$m_gcuser" ]]; then warn "用户名不能为空"; continue; fi
info "验证 GitCode 用户 ${m_gcuser}..."
M_UV=$(api_call GET "${GC_API}/users/${m_gcuser}") || true
M_UV_HTTP=$(http_code "$M_UV")
if [[ "$M_UV_HTTP" != "200" ]]; then
warn "GitCode 用户 ${m_gcuser} 不存在 — HTTP ${M_UV_HTTP},请检查用户名"
continue
fi
M_UV_BODY=$(http_body "$M_UV")
m_gc_email=$(echo "$M_UV_BODY" | jq -r '.email // empty' 2>/dev/null)
m_gc_name=$(echo "$M_UV_BODY" | jq -r '.name // .login // empty' 2>/dev/null)
info "用户验证通过: ${m_gc_name}${m_gc_email:+, 默认邮箱: ${m_gc_email}}"
break
done
while true; do
echo " 请输入该成员签署 CLA 时使用的邮箱(必须与 GitCode 默认邮箱一致,否则 PR 将无法合入)"
read -rp " 邮箱: " m_email
if [[ -z "$m_email" ]]; then warn "邮箱不能为空"; continue; fi
if [[ ! "$m_email" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then
warn "邮箱格式不正确"; continue
fi
if [[ -n "$m_gc_email" && "$m_gc_email" != "null" && "$m_gc_email" != "$m_email" ]]; then
warn "GitCode 默认邮箱 ${m_gc_email} 与输入邮箱 ${m_email} 不一致,CLA 校验可能失败"
confirm "仍要使用此邮箱" || continue
fi
break
done
read -rp " 分工 (可选): " m_role
if [[ "$m_name" == *"|"* || "$m_gcuser" == *"|"* || "$m_email" == *"|"* || "${m_role:-}" == *"|"* ]]; then
warn "输入不能包含 | 字符,请重新输入"
continue
fi
warn "该队员是否已签署 CLA? 签署链接: ${CLA_SIGN_URL}"
read -rp "已签署? (y/n): " mc
if [[ ! "$mc" =~ ^[Yy]$ ]]; then
warn "已跳过 ${m_name} — 未确认 CLA,该队员不会出现在提交中"
continue
fi
MEMBERS+=("${m_name}|${m_gcuser}|${m_email}|${m_role:-成员}")
info "已添加成员: ${m_name}"
done
echo ""
if [[ -n "${EDITOR:-}" ]] && command -v "${EDITOR%% *}" >/dev/null 2>&1; then
info "算子实现介绍 — 将打开编辑器 ${EDITOR},保存退出即完成"
IMPL_FILE="${TMPDIR}/impl_desc.txt"
cat > "$IMPL_FILE" <<'EOF'
EOF
${EDITOR} "$IMPL_FILE" 2>/dev/null || warn "编辑器退出异常,使用空描述"
IMPL_DESC=$(grep -v '^#' "$IMPL_FILE" 2>/dev/null || true)
rm -f "$IMPL_FILE"
else
info "算子实现介绍 — 用于 PR 描述,多行输入,单独输入 END 结束"
IMPL_FILE="${TMPDIR}/impl_desc.txt"
> "$IMPL_FILE"
IMPL_LINE_COUNT=0
while true; do
read -rp "> " line
[[ "$line" == "END" ]] && break
IMPL_LINE_COUNT=$((IMPL_LINE_COUNT + 1))
if [[ "$IMPL_LINE_COUNT" -ge "$IMPL_MAX_LINES" ]]; then
warn "已达到最大行数 ${IMPL_MAX_LINES},自动结束输入"
break
fi
printf '%s\n' "$line" >> "$IMPL_FILE"
done
IMPL_DESC=$(cat "$IMPL_FILE")
rm -f "$IMPL_FILE"
fi
echo ""
step "信息汇总"
info "算子: ${OP_BASE_NAME}"
info "比赛: ${COMPETITION_NAME}"
info "队伍: ${TEAM_DIR_NAME} — ${SCHOOL_FULL}"
info "目录: ${SUBMIT_DIR}/"
info "PR 标题: [团队提交] ${COMPETITION_NAME} ${OP_BASE_NAME} 算子提交:${TEAM_SLUG}"
info "成员数: ${#MEMBERS[@]}"
confirm "确认以上信息无误,开始提交" || error "已取消"
step "从 CANNJudge 下载算子代码"
CJ_DIR="${TMPDIR}/cannjudge_code"
mkdir -p "$CJ_DIR"
CJ_PY_SCRIPT="${TMPDIR}/download_cj.py"
cat > "$CJ_PY_SCRIPT" << 'PYEOF'
import requests, json, os, sys
email=os.environ['CJ_EMAIL']; password=os.environ['CJ_PASSWORD']
op_name=os.environ['OP_PROBLEM_NAME']; user_id=os.environ['CJ_USER_ID']
op_id=os.environ['CJ_PROBLEM_ID']; output_dir=os.environ['CJ_DIR']
page_size=int(os.environ.get('_CJ_PAGE_SIZE','50'))
try:
s=requests.Session()
r=s.post('https://cannjudge.cn/api/users/login',json={'email':email,'password':password},timeout=30)
if r.status_code!=200: print('ERROR:LOGIN_FAIL',file=sys.stderr); sys.exit(1)
subs=[]; skip=0; ps=[]
while not ps:
r=s.get(f'https://cannjudge.cn/api/submissions/user/{user_id}',params={'skip':skip,'limit':page_size},timeout=30); r.raise_for_status()
page=r.json()
if not page: break
ps=[x for x in page if x.get('problem',{}).get('_id','')==op_id and x['status']=='Pass']
if not ps: skip+=page_size
if not ps: print('ERROR:NO_PASS',file=sys.stderr); sys.exit(2)
sid=ps[0]['_id']; print(f'最新Pass: {sid}')
r=s.get(f'https://cannjudge.cn/api/submissions/{sid}',params={'userId':user_id},timeout=30); r.raise_for_status()
res=r.json()
if not res.get('can_view_code'): print('ERROR:NO_PERM',file=sys.stderr); sys.exit(3)
saved=[]
if 'files' in res and isinstance(res['files'],list):
for f in res['files']:
fp=os.path.join(output_dir,f['path'])
real_fp=os.path.realpath(fp); real_out=os.path.realpath(output_dir)
if not real_fp.startswith(real_out+os.sep): print(f'ERROR:PATH_TRAVERSAL:{f["path"]}',file=sys.stderr); sys.exit(8)
os.makedirs(os.path.dirname(fp),exist_ok=True)
with open(fp,'w') as fh: fh.write(f['content']); saved.append(f['path'])
if not saved: print('ERROR:NO_FILES',file=sys.stderr); sys.exit(4)
print(f'已保存{len(saved)}个文件')
except requests.exceptions.Timeout: print('ERROR:TIMEOUT',file=sys.stderr); sys.exit(5)
except requests.exceptions.ConnectionError: print('ERROR:NETWORK',file=sys.stderr); sys.exit(6)
except Exception as e: print(f'ERROR:{e}',file=sys.stderr); sys.exit(7)
PYEOF
CJ_EMAIL="$CJ_EMAIL" CJ_PASSWORD="$CJ_PASSWORD" OP_PROBLEM_NAME="$OP_PROBLEM_NAME" \
CJ_USER_ID="$CJ_USER_ID" CJ_DIR="$CJ_DIR" CJ_PROBLEM_ID="$CJ_PROBLEM_ID" \
_CJ_PAGE_SIZE=$PAGE_SIZE \
PYTHONWARNINGS=ignore python3 "$CJ_PY_SCRIPT" || error "CANNJudge 下载失败,请检查网络和账号"
unset CJ_PASSWORD
rm -f "$CJ_PY_SCRIPT"
CJ_FILE_COUNT=$(find "$CJ_DIR" -type f ! -name '_meta.json' ! -name 'legacy_*' | wc -l)
CJ_FILE_COUNT=${CJ_FILE_COUNT##* }
if [[ "$CJ_FILE_COUNT" -eq 0 ]]; then
error "下载完成但没有有效文件,请检查 CANNJudge 提交"
fi
info "下载完成 — ${CJ_FILE_COUNT} 个文件:"
find "$CJ_DIR" -type f ! -name '_meta.json' ! -name 'legacy_*' -print0 | sort -z | while IFS= read -r -d '' f; do echo " ${f#$CJ_DIR/}"; done
step "Fork 仓库"
FORK_REPO="${GC_LOGIN_NAME}/${REPO_NAME}"
info "检查 fork: ${FORK_REPO}"
FCHK=$(api_call GET "${GC_API}/repos/${FORK_REPO}") || true
FCHK_HTTP=$(http_code "$FCHK")
if [[ "$FCHK_HTTP" == "200" ]]; then
info "Fork 已存在: ${FORK_REPO},将同步使用"
else
info "创建 fork..."
printf 'header = "Content-Type: application/json"\n' >> "$CURL_CONFIG"
FR=$(api_call POST "${GC_API}/repos/${REPO_OWNER}/${REPO_NAME}/forks") || true
write_curl_config
FHTTP2=$(http_code "$FR")
if [[ "$FHTTP2" != "200" && "$FHTTP2" != "201" && "$FHTTP2" != "202" ]]; then
FBODY=$(http_body "$FR")
error "Fork 失败 — HTTP ${FHTTP2}: ${FBODY}"
fi
info "Fork 创建成功,等待就绪..."
for i in $(seq 1 "$FORK_POLL_COUNT"); do
printf '\r 等待 fork 就绪... [%2ds/%ds]' "$((i * FORK_POLL_INTERVAL))" "$((FORK_POLL_COUNT * FORK_POLL_INTERVAL))"
sleep "$FORK_POLL_INTERVAL"
FCHK=$(api_call GET "${GC_API}/repos/${FORK_REPO}") || true
FCHK_HTTP=$(http_code "$FCHK")
[[ "$FCHK_HTTP" == "200" ]] && break
done
printf '\n'
if [[ "$FCHK_HTTP" != "200" ]]; then
error "Fork 仓库等待超时,请稍后手动重试"
fi
fi
FORK_PUBLIC_URL="https://gitcode.com/${GC_LOGIN_NAME}/${REPO_NAME}.git"
UPSTREAM_URL="https://gitcode.com/${REPO_OWNER}/${REPO_NAME}.git"
FORK_AUTH_URL="https://oauth2:${GC_TOKEN}@gitcode.com/${GC_LOGIN_NAME}/${REPO_NAME}.git"
step "克隆仓库"
REPO_DIR="${TMPDIR}/repo"
info "克隆 fork..."
timeout "$GIT_TIMEOUT" git -c "http.userAgent=${GIT_UA}" \
clone "$FORK_AUTH_URL" "$REPO_DIR" 2>&1 \
|| error "克隆失败或超时,请检查 GitCode 令牌权限和网络"
git -C "$REPO_DIR" remote set-url origin "$FORK_PUBLIC_URL"
git -C "$REPO_DIR" remote add upstream "$UPSTREAM_URL" 2>/dev/null || true
info "同步上游最新代码..."
set +e
FETCH_ERR=$(timeout "$GIT_TIMEOUT" git -C "$REPO_DIR" fetch upstream "$TARGET_BRANCH" 2>&1)
FETCH_RC=$?
set -e
if [[ "$FETCH_RC" -eq 0 ]]; then
if git -C "$REPO_DIR" rev-parse "upstream/${TARGET_BRANCH}" >/dev/null 2>&1; then
set +e
MERGE_ERR=$(git -C "$REPO_DIR" merge "upstream/${TARGET_BRANCH}" --ff-only 2>&1)
MERGE_RC=$?
set -e
if [[ "$MERGE_RC" -ne 0 ]]; then
warn "无法快进合并,fork 与上游有分叉"
if confirm "尝试创建合并提交"; then
set +e
MERGE_ERR=$(git -C "$REPO_DIR" merge "upstream/${TARGET_BRANCH}" --no-edit 2>&1)
MERGE_RC=$?
set -e
fi
fi
if [[ "$MERGE_RC" -ne 0 ]]; then
warn "合并上游代码有冲突: ${MERGE_ERR}"
if git -C "$REPO_DIR" diff --name-only --diff-filter=U 2>/dev/null | grep -q .; then
error "存在未解决的合并冲突,请手动处理后重新运行"
fi
fi
fi
else
warn "无法 fetch upstream: ${FETCH_ERR},继续使用 fork 当前状态"
fi
PR_BRANCH="submit/${TEAM_DIR_NAME}/${OP_BASE_NAME}"
info "创建分支: ${PR_BRANCH}"
BRANCH_BASE="upstream/${TARGET_BRANCH}"
if ! git -C "$REPO_DIR" rev-parse "$BRANCH_BASE" >/dev/null 2>&1; then
BRANCH_BASE="origin/${TARGET_BRANCH}"
fi
if ! git -C "$REPO_DIR" checkout -b "$PR_BRANCH" "$BRANCH_BASE" 2>/dev/null; then
if git -C "$REPO_DIR" checkout "$PR_BRANCH" 2>/dev/null; then
warn "分支 ${PR_BRANCH} 已存在,重置为最新上游代码"
warn "即将执行 git reset --hard ${BRANCH_BASE},这会丢弃分支上的本地修改"
confirm "确认重置" || error "已取消,请手动处理分支后重新运行"
git -C "$REPO_DIR" reset --hard "$BRANCH_BASE" 2>/dev/null \
|| error "无法重置分支到 ${BRANCH_BASE}"
else
error "无法创建或切换到分支 ${PR_BRANCH}"
fi
fi
git -C "$REPO_DIR" config --local user.name "$GC_LOGIN_NAME"
git -C "$REPO_DIR" config --local user.email "$USER_EMAIL"
step "组织提交目录"
SUBMIT_FULL_DIR="${REPO_DIR}/${SUBMIT_DIR}"
CODE_DIR="${SUBMIT_FULL_DIR}/code/${OP_BASE_NAME}"
if [[ -d "$SUBMIT_FULL_DIR" ]]; then
warn "提交目录 ${SUBMIT_DIR}/ 已存在"
confirm "是否覆盖" || error "请手动处理已存在的目录后重新运行"
rm -rf "$SUBMIT_FULL_DIR"
fi
mkdir -p "$CODE_DIR"
info "目标结构: ${SUBMIT_DIR}/"
echo " ├── README.md"
echo " └── code/"
echo " └── ${OP_BASE_NAME}/"
COPIED_COUNT=0
while IFS= read -r -d '' f; do
rel="${f#$CJ_DIR/}"
d=$(dirname "${CODE_DIR}/${rel}")
mkdir -p "$d"
if cp "$f" "${CODE_DIR}/${rel}" 2>/dev/null; then
COPIED_COUNT=$((COPIED_COUNT + 1))
else
warn "复制失败: ${rel}"
fi
done < <(find "$CJ_DIR" -type f ! -name '_meta.json' ! -name 'legacy_*' -print0)
if [[ "$COPIED_COUNT" -eq 0 ]]; then
error "没有复制任何文件到提交目录"
fi
info "已复制 ${COPIED_COUNT} 个文件"
info "代码目录结构:"
cd "$CODE_DIR" && find . -type f | sort | while IFS= read -r line; do echo " ${line}"; done; cd - >/dev/null
step "生成 README.md"
MEMBERS_MD=$(format_members_md)
{
printf '%s\n' '## 团队信息' '' \
"- 团队名称:${TEAM_SLUG}" \
"- 所属单位:${SCHOOL_FULL}" \
'- 团队成员:'
printf '%s' "$MEMBERS_MD"
printf '%s\n' "- 联系人:${CONTACT_NAME}" \
"- 联系邮箱:${USER_EMAIL}" \
'' \
'## 算子信息' '' \
"- 算子名称:${OP_BASE_NAME}" \
"- CANNJudge 题目:${CJ_PROB_TITLE}"
} > "${SUBMIT_FULL_DIR}/README.md"
if [[ ! -f "${SUBMIT_FULL_DIR}/README.md" ]]; then
error "README.md 生成失败"
fi
info "README.md 已生成"
step "提交代码"
git -C "$REPO_DIR" add -A "$SUBMIT_DIR"
if git -C "$REPO_DIR" diff --cached --quiet 2>/dev/null; then
warn "没有变更需要提交 — 目录可能已存在且内容相同"
confirm "仍要继续推送" || error "已取消"
fi
COAUTHOR_TRAILERS=$(build_coauthor_trailers)
COMMIT_ARGS=(-m "submit ${OP_BASE_NAME} operator for ${TEAM_DIR_NAME}")
if [[ -n "$COAUTHOR_TRAILERS" ]]; then
COMMIT_ARGS+=(-m "$COAUTHOR_TRAILERS")
fi
set +e
COMMIT_ERR=$(git -C "$REPO_DIR" commit "${COMMIT_ARGS[@]}" 2>&1)
COMMIT_RC=$?
set -e
if [[ "$COMMIT_RC" -ne 0 ]]; then
if git -C "$REPO_DIR" diff --cached --quiet 2>/dev/null; then
error "没有变更需要提交,无法创建 PR"
else
error "git commit 失败: ${COMMIT_ERR}"
fi
fi
info "推送到 fork..."
set +e
PUSH_OUTPUT=$(timeout "$GIT_TIMEOUT" git -c "http.userAgent=${GIT_UA}" \
-C "$REPO_DIR" push --force "$FORK_AUTH_URL" "$PR_BRANCH" 2>&1)
PUSH_RC=$?
set -e
if [[ "$PUSH_RC" -ne 0 ]]; then
error "推送失败 — exit code ${PUSH_RC}: ${PUSH_OUTPUT}"
fi
step "创建 Pull Request"
EXISTING_PR_BODY=""
PR_PAGE=1
while true; do
EXISTING_PR_RESP=$(api_call GET "${GC_API}/repos/${REPO_OWNER}/${REPO_NAME}/pulls?state=open&per_page=100&page=${PR_PAGE}") || true
EXISTING_PR_HTTP=$(http_code "$EXISTING_PR_RESP")
if [[ "$EXISTING_PR_HTTP" != "200" ]]; then break; fi
PR_PAGE_BODY=$(http_body "$EXISTING_PR_RESP")
if [[ -z "$PR_PAGE_BODY" || "$PR_PAGE_BODY" == "[]" ]]; then break; fi
EXISTING_PR_BODY=$(echo "$EXISTING_PR_BODY" "$PR_PAGE_BODY" | jq -s 'add')
PR_PAGE_LEN=$(echo "$PR_PAGE_BODY" | jq 'length' 2>/dev/null) || true
if [[ "${PR_PAGE_LEN:-0}" -lt 100 ]]; then break; fi
PR_PAGE=$((PR_PAGE + 1))
done
if [[ -n "$EXISTING_PR_BODY" ]]; then
EXISTING_PR_URL=$(echo "$EXISTING_PR_BODY" | jq -r --arg branch "$PR_BRANCH" --arg user "$GC_LOGIN_NAME" '[.[] | select(.head.ref == $branch and (.head.user.login // .head.user.username) == $user)] | .[0].html_url // .[0].web_url // empty' 2>/dev/null)
if [[ -n "$EXISTING_PR_URL" ]]; then
warn "已有 open PR: ${EXISTING_PR_URL},将更新而非新建"
PR_URL="$EXISTING_PR_URL"
PR_NUMBER=$(echo "$EXISTING_PR_BODY" | jq -r --arg branch "$PR_BRANCH" --arg user "$GC_LOGIN_NAME" '[.[] | select(.head.ref == $branch and (.head.user.login // .head.user.username) == $user)] | .[0].number // .[0].iid // "unknown"' 2>/dev/null)
info "PR #${PR_NUMBER}: ${PR_URL}"
info "更新 PR 标题和描述..."
PR_UPDATE_TITLE="[团队提交] ${COMPETITION_NAME} ${OP_BASE_NAME} 算子提交:${TEAM_SLUG}"
PR_UPDATE_BODY=$(build_pr_body)
PR_UPDATE_JSON=$(jq -n --arg title "$PR_UPDATE_TITLE" --arg body "$PR_UPDATE_BODY" \
'{title: $title, body: $body}' 2>/dev/null) || true
if [[ -n "$PR_UPDATE_JSON" ]]; then
PR_UPDATE_FILE="${TMPDIR}/pr_update_body.json"
write_temp_json "$PR_UPDATE_FILE" "$PR_UPDATE_JSON"
printf 'header = "Content-Type: application/json"\ndata = "@%s"\n' "$PR_UPDATE_FILE" >> "$CURL_CONFIG"
UPD_RESP=$(api_call PATCH "${GC_API}/repos/${REPO_OWNER}/${REPO_NAME}/pulls/${PR_NUMBER}") || true
write_curl_config
rm -f "$PR_UPDATE_FILE"
UPD_HTTP=$(http_code "$UPD_RESP")
if [[ "$UPD_HTTP" == "200" ]]; then
info "PR 标题和描述已更新"
else
warn "PR 更新失败 — HTTP ${UPD_HTTP},PR 内容可能为旧版本"
fi
fi
fi
fi
if [[ -z "${PR_URL:-}" ]]; then
PR_TITLE="[团队提交] ${COMPETITION_NAME} ${OP_BASE_NAME} 算子提交:${TEAM_SLUG}"
PR_DESCRIPTION=$(build_pr_body)
PR_JSON=$(jq -n --arg title "$PR_TITLE" --arg body "$PR_DESCRIPTION" \
--arg head "${GC_LOGIN_NAME}:${PR_BRANCH}" --arg base "$TARGET_BRANCH" \
'{title: $title, body: $body, head: $head, base: $base}' 2>/dev/null) || true
if [[ -z "$PR_JSON" ]]; then
error "构建 PR 请求体失败"
fi
PR_BODY_FILE="${TMPDIR}/pr_body.json"
write_temp_json "$PR_BODY_FILE" "$PR_JSON"
printf 'header = "Content-Type: application/json"\ndata = "@%s"\n' "$PR_BODY_FILE" >> "$CURL_CONFIG"
PR_RESP=$(api_call POST "${GC_API}/repos/${REPO_OWNER}/${REPO_NAME}/pulls") || true
write_curl_config
rm -f "$PR_BODY_FILE"
PR_HTTP=$(http_code "$PR_RESP"); PR_API_RESP=$(http_body "$PR_RESP")
if [[ -z "$PR_API_RESP" || "$PR_HTTP" == "0" ]]; then
error "PR 创建请求失败,请检查网络连接"
fi
if [[ "$PR_HTTP" == "200" || "$PR_HTTP" == "201" ]]; then
PR_URL=$(echo "$PR_API_RESP" | jq -r '.html_url // .web_url // .url // empty')
PR_NUMBER=$(echo "$PR_API_RESP" | jq -r '.number // .iid // empty')
if [[ -z "$PR_URL" || -z "$PR_NUMBER" ]]; then
warn "PR 创建成功但解析响应失败"
PR_URL="unknown"; PR_NUMBER="unknown"
fi
info "PR 创建成功! #${PR_NUMBER}: ${PR_URL}"
else
PR_ERR_MSG=$(echo "$PR_API_RESP" | jq -r '.message // .error // .msg // empty' 2>/dev/null) || true
if [[ "$PR_HTTP" == "400" || "$PR_HTTP" == "409" ]] && echo "$PR_ERR_MSG" | grep -qi "already exists"; then
warn "同分支已有 PR,重新查询..."
FRESH_PR_RESP=$(api_call GET "${GC_API}/repos/${REPO_OWNER}/${REPO_NAME}/pulls?state=open&per_page=100&head=${GC_LOGIN_NAME}:${PR_BRANCH}") || true
FRESH_PR_HTTP=$(http_code "$FRESH_PR_RESP")
FRESH_PR_BODY=$(http_body "$FRESH_PR_RESP")
EXISTING_PR_URL2=$(echo "$FRESH_PR_BODY" | jq -r '.[0].html_url // .[0].web_url // empty' 2>/dev/null)
EXISTING_PR_NUM2=$(echo "$FRESH_PR_BODY" | jq -r '.[0].number // .[0].iid // empty' 2>/dev/null)
if [[ -n "$EXISTING_PR_URL2" ]]; then
PR_URL="$EXISTING_PR_URL2"; PR_NUMBER="$EXISTING_PR_NUM2"
info "已有 PR #${PR_NUMBER}: ${PR_URL}"
else
error "PR 创建冲突但未找到已有 PR,请手动检查"
fi
else
if [[ -z "$PR_ERR_MSG" ]]; then
PR_ERR_MSG=$(echo "$PR_API_RESP" | head -c 500)
fi
error "PR 创建失败 — HTTP ${PR_HTTP}${PR_ERR_MSG:+: $PR_ERR_MSG}"
fi
fi
fi
step "触发编译流水线"
COMPILE_BODY_FILE="${TMPDIR}/compile_body.json"
printf '{"body": "/compile"}' > "$COMPILE_BODY_FILE"
chmod 600 "$COMPILE_BODY_FILE"
printf 'header = "Content-Type: application/json"\ndata = "@%s"\n' "$COMPILE_BODY_FILE" >> "$CURL_CONFIG"
COMPILE_RESP=$(api_call POST "${GC_API}/repos/${REPO_OWNER}/${REPO_NAME}/pulls/${PR_NUMBER}/comments") || true
write_curl_config
rm -f "$COMPILE_BODY_FILE"
COMPILE_HTTP=$(http_code "$COMPILE_RESP")
if [[ "$COMPILE_HTTP" == "201" || "$COMPILE_HTTP" == "200" ]]; then
info "已评论 /compile,编译流水线已触发"
else
warn "评论 /compile 失败 — HTTP ${COMPILE_HTTP},请手动在 PR 页面评论 /compile"
fi
echo ""
echo "============================================"
info "提交完成!"
info " 算子: ${OP_BASE_NAME}"
info " 队伍: ${TEAM_DIR_NAME}"
info " 目录: ${SUBMIT_DIR}/"
info " PR: #${PR_NUMBER} ${PR_URL}"
echo "============================================"
echo ""
warn "后续步骤: 打开 PR 页面确认 CLA 检查和编译流水线通过: ${PR_URL}"
echo ""
warn "如果 CLA 显示为 no,请确认所有团队成员的以下三者一致:"
echo " - CLA 签署时使用的邮箱"
echo " - GitCode 账号的默认邮箱"
echo " - git commit 中的作者邮箱"
echo " 不一致时请修改 GitCode 默认邮箱后重新签署 CLA"
echo " 签署链接: ${CLA_SIGN_URL}"
unset GC_TOKEN