diff --git a/.github/workflows/eval-dispatch.yml b/.github/workflows/eval-dispatch.yml new file mode 100644 index 00000000..c3be2d19 --- /dev/null +++ b/.github/workflows/eval-dispatch.yml @@ -0,0 +1,117 @@ +name: PR Eval Dispatch + +# `/eval [cases=]` PR 评论 → 触发内网评测流水线,报告由内网 bot 回贴。 +# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码; +# maintainer 打出 /eval 即代表已审阅 PR 代码(内网 CI 将构建并执行该 head)。 +# 触发通道与凭证全部经 secrets 注入,文件内不出现任何内网信息。 + +on: + issue_comment: + types: + - created + +permissions: {} + +concurrency: + group: eval-dispatch-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + dispatch: + name: Dispatch internal evaluation + if: >- + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/eval') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: write + pull-requests: read + steps: + - name: Check out default branch tooling + uses: actions/checkout@v4 + + - name: Parse /eval command + id: parse + continue-on-error: true + env: + COMMENT_BODY: ${{ github.event.comment.body }} + run: python3 scripts/ci/eval_comment_parse.py + + - name: Reply usage on parse failure + if: steps.parse.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number }} + PARSE_ERROR: ${{ steps.parse.outputs.error }} + run: | + body="❌ /eval 命令解析失败:${PARSE_ERROR}" + jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \ + -X POST \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + --data @- \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null + exit 1 + + - name: Resolve PR head SHA + id: pr + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number }} + run: | + head_sha="$(curl --fail --silent --show-error \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" | jq -r '.head.sha')" + if [ -z "$head_sha" ] || [ "$head_sha" = "null" ]; then + echo "Failed to resolve PR head SHA." >&2 + exit 1 + fi + echo "head_sha=${head_sha}" >> "$GITHUB_OUTPUT" + + - name: Trigger internal evaluation pipeline + env: + EVAL_TRIGGER_TOKEN: ${{ secrets.EVAL_TRIGGER_TOKEN }} + EVAL_TRIGGER_URL: ${{ secrets.EVAL_TRIGGER_URL }} + PR_NUMBER: ${{ github.event.issue.number }} + PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + PRODUCTS: ${{ steps.parse.outputs.products }} + CASES_REF: ${{ steps.parse.outputs.cases_ref }} + run: | + if [ -z "$EVAL_TRIGGER_TOKEN" ] || [ -z "$EVAL_TRIGGER_URL" ]; then + echo "EVAL_TRIGGER_URL / EVAL_TRIGGER_TOKEN not configured; cannot dispatch." >&2 + exit 1 + fi + curl --fail --silent --show-error \ + -X POST \ + -F "token=${EVAL_TRIGGER_TOKEN}" \ + -F "ref=main" \ + -F "variables[PR_NUMBER]=${PR_NUMBER}" \ + -F "variables[PR_HEAD_SHA]=${PR_HEAD_SHA}" \ + -F "variables[PRODUCTS]=${PRODUCTS}" \ + -F "variables[CASES_REF]=${CASES_REF}" \ + "$EVAL_TRIGGER_URL" + echo "Internal evaluation dispatched." + + - name: Acknowledge on PR + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number }} + PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + PRODUCTS: ${{ steps.parse.outputs.products }} + CASES_REF: ${{ steps.parse.outputs.cases_ref }} + run: | + cases_note="" + if [ -n "$CASES_REF" ]; then + cases_note=",用例版本 \`${CASES_REF}\`" + fi + body="🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。内网评测流水线运行结束后将由 bot 回贴报告(首行为基线对比头条)。" + jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \ + -X POST \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + --data @- \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null diff --git a/scripts/ci/eval_comment_parse.py b/scripts/ci/eval_comment_parse.py new file mode 100644 index 00000000..de4433f1 --- /dev/null +++ b/scripts/ci/eval_comment_parse.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""解析 PR 评论中的 `/eval [cases=]` 触发命令。 + +供 .github/workflows/eval-dispatch.yml 调用:评论体经环境变量 COMMENT_BODY +传入(避免 argv 注入),解析结果以 GitHub Actions output(products / cases_ref) +写出;格式非法时把单行错误写入 output `error` 并以非零退出。 + +产品集显式必填;`cases=` 为用例仓库版本逃生舱(破坏性变更配对验证)。 +""" + +import os +import re +import sys + +PRODUCT_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$") +USAGE = "用法: /eval [,...] [cases=];示例: /eval drive,doc cases=feat/x" + + +def parse(body: str): + lines = [line.strip() for line in (body or "").splitlines() if line.strip()] + if not lines: + raise ValueError(f"评论为空。{USAGE}") + tokens = lines[0].split() + if tokens[0] != "/eval": + raise ValueError(f"首行必须以 /eval 命令开头。{USAGE}") + if len(tokens) < 2: + raise ValueError(f"产品集显式必填。{USAGE}") + + products = [p for p in tokens[1].split(",") if p] + if not products: + raise ValueError(f"产品集显式必填。{USAGE}") + for product in products: + if not PRODUCT_RE.match(product): + raise ValueError(f"产品名非法: {product}。{USAGE}") + + cases_ref = "" + for extra in tokens[2:]: + if extra.startswith("cases="): + cases_ref = extra[len("cases="):] + if not cases_ref or not REF_RE.match(cases_ref): + raise ValueError(f"cases 引用非法: {extra}。{USAGE}") + else: + raise ValueError(f"未知参数: {extra}。{USAGE}") + + return ",".join(products), cases_ref + + +def _write_outputs(pairs): + lines = [f"{key}={value}" for key, value in pairs] + output_path = os.environ.get("GITHUB_OUTPUT", "") + if output_path: + with open(output_path, "a", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + print("\n".join(lines)) + + +def main() -> int: + try: + products, cases_ref = parse(os.environ.get("COMMENT_BODY", "")) + except ValueError as exc: + _write_outputs([("error", str(exc))]) + print(str(exc), file=sys.stderr) + return 1 + _write_outputs([("products", products), ("cases_ref", cases_ref)]) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/eval_comment_parse_test.py b/scripts/ci/eval_comment_parse_test.py new file mode 100644 index 00000000..bbf76178 --- /dev/null +++ b/scripts/ci/eval_comment_parse_test.py @@ -0,0 +1,50 @@ +import pytest + +from eval_comment_parse import parse + + +def test_single_product(): + assert parse("/eval drive") == ("drive", "") + + +def test_multiple_products(): + assert parse("/eval drive,doc,edu-app") == ("drive,doc,edu-app", "") + + +def test_cases_ref_escape_hatch(): + assert parse("/eval drive cases=feat/drive-latest") == ("drive", "feat/drive-latest") + + +def test_extra_lines_after_command_are_ignored(): + body = "/eval drive\n\n顺便说明:这个 PR 只动了 drive 的 --latest。" + assert parse(body) == ("drive", "") + + +def test_missing_products_rejected(): + with pytest.raises(ValueError, match="产品集显式必填"): + parse("/eval") + + +def test_similar_command_prefix_rejected(): + with pytest.raises(ValueError, match="/eval 命令开头"): + parse("/evaluate drive") + + +def test_illegal_product_name_rejected(): + with pytest.raises(ValueError, match="产品名非法"): + parse("/eval drive;rm") + + +def test_unknown_extra_token_rejected(): + with pytest.raises(ValueError, match="未知参数"): + parse("/eval drive --force") + + +def test_illegal_cases_ref_rejected(): + with pytest.raises(ValueError, match="cases 引用非法"): + parse("/eval drive cases=$(whoami)") + + +def test_empty_comment_rejected(): + with pytest.raises(ValueError, match="评论为空"): + parse(" \n ")