diff --git a/.gitea/scripts/review-check.sh b/.gitea/scripts/review-check.sh index a693a71b2..61e445ad8 100755 --- a/.gitea/scripts/review-check.sh +++ b/.gitea/scripts/review-check.sh @@ -100,11 +100,12 @@ printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE" # (bash trap 'function' EXIT expands variables at trap-fire time, not def time). PR_JSON=$(mktemp) REVIEWS_JSON=$(mktemp) +COMMENTS_JSON=$(mktemp) TEAM_PROBE_TMP=$(mktemp) NA_STATUSES_TMP="" # declared here so cleanup() always has the var cleanup() { - rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$TEAM_PROBE_TMP" "${NA_STATUSES_TMP-}" + rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$COMMENTS_JSON" "$TEAM_PROBE_TMP" "${NA_STATUSES_TMP-}" } trap cleanup EXIT @@ -229,7 +230,58 @@ if [ -z "$CANDIDATES" ]; then [ -n "${_rid:-}" ] && echo "::error:: review id=${_rid} by '${_rl}': RE-SUBMIT via POST ${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews with {\"event\":\"APPROVED\"} (correct enum) — do NOT edit the DB." done fi - echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates yet)" + + # --- Fallback (internal#348): check issue comments for agent-approval --- + # core-qa-agent and core-security-agent approve via issue comments, NOT + # the reviews API. The reviews API returns zero entries for comment-only + # approvals. This fallback reads PR issue comments and extracts logins that: + # 1. Posted a comment matching the agent-prefix pattern for this gate: + # qa → "[core-qa-agent] APPROVED" + # security → "[core-security-agent] APPROVED" + # OR posted a generic approval keyword (word-anchored, case-insensitive): + # APPROVED / LGTM / ACCEPTED + # 2. Are not the PR author + # 3. The team-membership probe below is the authoritative filter. + AGENT_PATTERN="" + case "$TEAM" in + qa) AGENT_PATTERN="\\[core-qa-agent\\]" ;; + security) AGENT_PATTERN="\\[core-security-agent\\]" ;; + esac + HTTP_CODE=$(curl -sS -o "$COMMENTS_JSON" -w '%{http_code}' \ + -K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/comments") + debug "GET /issues/${PR_NUMBER}/comments → HTTP ${HTTP_CODE}" + if [ "$HTTP_CODE" = "200" ]; then + # JQ expression: select non-author comments that match either the + # agent-prefix pattern (case-insensitive) OR a generic approval keyword. + JQ_APPROVALS=' + .[] | + select(.user.login != $author) | + . as $cmt | + if ($agent_pattern | length) > 0 and ($cmt.body // "" | test($agent_pattern; "i")) then + $cmt.user.login + elif ($cmt.body // "" | test("\\b(APPROVED|LGTM|ACCEPTED)\\b"; "i")) then + $cmt.user.login + else + empty + end + ' + CANDIDATES=$(jq -r \ + --arg author "$PR_AUTHOR" \ + --arg agent_pattern "$AGENT_PATTERN" \ + "$JQ_APPROVALS" \ + "$COMMENTS_JSON" 2>/dev/null | sort -u) + debug "comment-based approval candidates: $(echo "$CANDIDATES" | tr '\n' ' ')" + + if [ -n "$CANDIDATES" ]; then + echo "::notice::${TEAM}-review: reviews API found no APPROVED reviews; found $(echo "$CANDIDATES" | wc -w | xargs) comment-based approval candidate(s) — verifying team membership..." + fi + else + debug "could not fetch issue comments (HTTP ${HTTP_CODE})" + fi +fi + +if [ -z "${CANDIDATES:-}" ]; then + echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates from reviews API or issue comments)" exit 1 fi diff --git a/.gitea/scripts/tests/_review_check_fixture.py b/.gitea/scripts/tests/_review_check_fixture.py index 51cc423f5..a637d98d9 100644 --- a/.gitea/scripts/tests/_review_check_fixture.py +++ b/.gitea/scripts/tests/_review_check_fixture.py @@ -17,6 +17,9 @@ Scenarios: T8_team_not_member — team membership → 404 (not a member) → exit 1 T9_team_403 — team membership → 403 (token not in team) → exit 1 T14_non_default_base — open PR targeting staging → script exits 0 (no-op) + T15_comments_agent_approval — reviews empty; comments have "[core-qa-agent] APPROVED" → exit 0 + T16_comments_generic_approval — reviews empty; comments have "APPROVED" by team member → exit 0 + T17_comments_no_approval — reviews empty; comments have no approval keywords → exit 1 Usage: FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080 @@ -97,7 +100,9 @@ class Handler(http.server.BaseHTTPRequestHandler): # GET /repos/{owner}/{name}/pulls/{pr_number}/reviews m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/pulls/(\d+)/reviews$", path) if m: - if sc in ("T4_reviews_empty", "T5_reviews_only_author"): + if sc in ("T4_reviews_empty", "T5_reviews_only_author", + "T15_comments_agent_approval", "T16_comments_generic_approval", + "T17_comments_no_approval"): return self._json(200, []) if sc == "T6_reviews_dismissed": return self._json(200, [{ @@ -116,6 +121,28 @@ class Handler(http.server.BaseHTTPRequestHandler): {"state": "APPROVED", "dismissed": False, "user": {"login": "core-devops"}, "commit_id": "abc1234"}, ]) + # GET /repos/{owner}/{name}/issues/{pr_number}/comments + m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/issues/(\d+)/comments$", path) + if m: + if sc == "T15_comments_agent_approval": + return self._json(200, [ + {"user": {"login": "core-qa-agent"}, "body": "[core-qa-agent] APPROVED this PR. Good changes.", "id": 1}, + {"user": {"login": "alice"}, "body": "I authored this PR", "id": 2}, + {"user": {"login": "random-user"}, "body": "Looks okay to me", "id": 3}, + ]) + if sc == "T16_comments_generic_approval": + return self._json(200, [ + {"user": {"login": "core-qa-agent"}, "body": "APPROVED — all acceptance criteria met", "id": 1}, + {"user": {"login": "alice"}, "body": "-authored", "id": 2}, + ]) + if sc == "T17_comments_no_approval": + return self._json(200, [ + {"user": {"login": "alice"}, "body": "I authored this PR", "id": 1}, + {"user": {"login": "random-user"}, "body": "Looks okay to me", "id": 2}, + ]) + # Default scenarios (T1–T9, T14): no comments + return self._json(200, []) + # GET /teams/{team_id}/members/{username} m = re.match(r"^/api/v1/teams/(\d+)/members/([^/]+)$", path) if m: @@ -127,6 +154,12 @@ class Handler(http.server.BaseHTTPRequestHandler): # T7_team_member: member return self._empty(204) + # GET /repos/{owner}/{name}/statuses/{sha} — for N/A declaration check + m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/statuses/([a-f0-9]+)$", path) + if m: + # All comment-based scenarios have no N/A declarations + return self._json(200, []) + return self._json(404, {"path": path, "msg": "fixture: no route"}) def do_POST(self): diff --git a/.gitea/scripts/tests/test_review_check.sh b/.gitea/scripts/tests/test_review_check.sh index ed6169bfa..9eb663e26 100755 --- a/.gitea/scripts/tests/test_review_check.sh +++ b/.gitea/scripts/tests/test_review_check.sh @@ -334,6 +334,31 @@ assert_contains "T12 jq: core-devops (non-author APPROVED) in candidates" "core- assert_eq "T12 jq: alice (author) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^alice$' || true)" assert_eq "T12 jq: carol (dismissed) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^carol$' || true)" +# T15 — comment-based approval via agent prefix pattern → exit 0 +echo +echo "== T15 comment agent-prefix approval ==" +T15_OUT=$(run_review_check "T15_comments_agent_approval") +T15_RC=$(cat "$FIX_STATE_DIR/last_rc") +assert_eq "T15 exit code 0 (agent-comment approval + team member)" "0" "$T15_RC" +assert_contains "T15 comment fallback notice" "comment-based approval" "$T15_OUT" +assert_contains "T15 core-qa-agent APPROVED" "APPROVED by core-qa-agent" "$T15_OUT" + +# T16 — comment-based approval via generic APPROVED keyword → exit 0 +echo +echo "== T16 comment generic keyword approval ==" +T16_OUT=$(run_review_check "T16_comments_generic_approval") +T16_RC=$(cat "$FIX_STATE_DIR/last_rc") +assert_eq "T16 exit code 0 (generic-approval comment + team member)" "0" "$T16_RC" +assert_contains "T16 comment fallback notice" "comment-based approval" "$T16_OUT" + +# T17 — no approval keywords in comments → exit 1 +echo +echo "== T17 comments with no approval keywords ==" +T17_OUT=$(run_review_check "T17_comments_no_approval") +T17_RC=$(cat "$FIX_STATE_DIR/last_rc") +assert_eq "T17 exit code 1 (no candidates from comments)" "1" "$T17_RC" +assert_contains "T17 no candidates error" "no candidates from reviews API or issue comments" "$T17_OUT" + echo echo "------" echo "PASS=$PASS FAIL=$FAIL" diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index ba79ce137..c62740bfd 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -81,7 +81,7 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) { spellCheck={false} rows={12} className="w-full bg-surface-card border border-line rounded p-2 text-[10px] font-mono text-ink focus:outline-none focus:border-accent resize-none" /> - {error &&