diff --git a/.github/workflows/eval-dispatch.yml b/.github/workflows/eval-dispatch.yml index 09a67206..81df5615 100644 --- a/.github/workflows/eval-dispatch.yml +++ b/.github/workflows/eval-dispatch.yml @@ -34,8 +34,8 @@ jobs: timeout-minutes: 5 permissions: contents: read - issues: write - pull-requests: read + # 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。 + pull-requests: write steps: - name: Check out default branch tooling uses: actions/checkout@v4 @@ -69,12 +69,10 @@ jobs: 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 + gh api --method POST \ + "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --raw-field body="$body" \ + > /dev/null exit 1 - name: Verify reviewed PR head @@ -103,13 +101,9 @@ jobs: set -euo pipefail placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。" response="$( - jq -n --arg body "$placeholder_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" + gh api --method POST \ + "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --raw-field body="$placeholder_body" )" comment_id="$( printf '%s' "$response" \ @@ -277,13 +271,9 @@ jobs: fi body=""$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。" response="$( - jq -n --arg body "$body" '{body: $body}' \ - | curl --fail --silent --show-error \ - -X PATCH \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - --data @- \ - "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" + gh api --method PATCH \ + "repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \ + --raw-field body="$body" )" printf '%s' "$response" \ | jq -e \ @@ -299,12 +289,8 @@ jobs: DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }} run: | failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。" - jq -n --arg body "$failure_body" '{body: $body}' \ - | curl --fail --silent --show-error \ - -X PATCH \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - --data @- \ - "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \ + gh api --method PATCH \ + "repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \ + --raw-field body="$failure_body" \ > /dev/null \ || true diff --git a/test/scripts/eval_dispatch_workflow_test.go b/test/scripts/eval_dispatch_workflow_test.go index a81cbc61..569f7953 100644 --- a/test/scripts/eval_dispatch_workflow_test.go +++ b/test/scripts/eval_dispatch_workflow_test.go @@ -44,6 +44,84 @@ func TestEvalDispatchWorkflowUsesRepositoryPermissionAndReviewedSHA(t *testing.T } } +func TestEvalDispatchWorkflowCanWritePRConversationComments(t *testing.T) { + t.Parallel() + + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "eval-dispatch.yml")) + if err != nil { + t.Fatalf("read eval-dispatch workflow: %v", err) + } + workflow := string(data) + + dispatchStart := strings.Index(workflow, "jobs:\n dispatch:") + if dispatchStart < 0 { + t.Fatal("eval-dispatch workflow missing dispatch job") + } + stepsOffset := strings.Index(workflow[dispatchStart:], "\n steps:") + if stepsOffset < 0 { + t.Fatal("eval-dispatch workflow missing dispatch steps") + } + permissions := workflow[dispatchStart : dispatchStart+stepsOffset] + if !containsTrimmedLine(permissions, "pull-requests: write") { + t.Error("dispatch job must grant write permission for PR conversation comments") + } + if containsTrimmedLine(permissions, "pull-requests: read") { + t.Error("dispatch job still limits pull requests to read-only") + } + if containsTrimmedLine(permissions, "issues: write") { + t.Error("dispatch job must keep a single write domain instead of granting issue-wide writes") + } + + commentWrites := []struct { + step string + method string + endpoint string + }{ + { + step: "Reply usage on parse failure", + method: "POST", + endpoint: "issues/${PR_NUMBER}/comments", + }, + { + step: "Create dispatch placeholder", + method: "POST", + endpoint: "issues/${PR_NUMBER}/comments", + }, + { + step: "Finalize dispatch marker", + method: "PATCH", + endpoint: "issues/comments/${DISPATCH_COMMENT_ID}", + }, + { + step: "Mark dispatch preparation failure", + method: "PATCH", + endpoint: "issues/comments/${DISPATCH_COMMENT_ID}", + }, + } + for _, write := range commentWrites { + write := write + t.Run(write.step, func(t *testing.T) { + t.Parallel() + step := evalDispatchWorkflowStep(t, workflow, write.step) + if !strings.Contains(step, "gh api --method "+write.method) { + t.Errorf("step %q must use gh api so GitHub's HTTP error message remains visible", write.step) + } + if !strings.Contains(step, write.endpoint) { + t.Errorf("step %q missing comment endpoint %q", write.step, write.endpoint) + } + for _, forbidden := range []string{"curl --fail", "Authorization: Bearer", "2>/dev/null"} { + if strings.Contains(step, forbidden) { + t.Errorf("step %q hides or manually transports API diagnostics via %q", write.step, forbidden) + } + } + }) + } +} + func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) { t.Parallel() @@ -102,7 +180,7 @@ func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) { "ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}", "ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}", "", - "-X PATCH", + "gh api --method PATCH", "issues/comments/${DISPATCH_COMMENT_ID}", } { if !strings.Contains(workflow, want) { @@ -120,6 +198,30 @@ func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) { } } +func evalDispatchWorkflowStep(t *testing.T, workflow, name string) string { + t.Helper() + + marker := " - name: " + name + "\n" + start := strings.Index(workflow, marker) + if start < 0 { + t.Fatalf("eval-dispatch workflow missing step %q", name) + } + rest := workflow[start+len(marker):] + if end := strings.Index(rest, "\n - name: "); end >= 0 { + return rest[:end] + } + return rest +} + +func containsTrimmedLine(text, want string) bool { + for _, line := range strings.Split(text, "\n") { + if strings.TrimSpace(line) == want { + return true + } + } + return false +} + func TestEvalPollValidatePython(t *testing.T) { t.Parallel()