Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ The review agent follows the same pre-script / sandbox / post-script pipeline as
1. **Pre-script** validates inputs and fetches PR metadata. On a re-review, it also fetches current review-thread state, applies verified human dismissals to the prior-finding ledger, and discards the raw thread content before sandbox creation.
2. **Sandbox** — the agent runs the `pr-review` orchestrator skill. The orchestrator runs a security-triage pre-pass for large PRs, then dispatches the specialized dimension sub-agents in parallel (plus the risk-assessment sub-agent when enabled), each covering a distinct review dimension (correctness, security, intent & coherence, style & conventions, docs currency, user experience, and optionally cross-repo contracts — whichever personas are staged; see [Add, replace or disable a review persona](#add-replace-or-disable-a-review-persona)). Sub-agents run concurrently and return structured findings. The orchestrator collects, deduplicates, and synthesizes findings across dimensions, runs PR-level checks (scope authorization, protected paths), and produces a structured JSON review result. The agent cannot push files, edit code, or push — it is strictly read-only.
3. **Validation loop** — the output is checked against a schema. The review harness runs a single iteration (see [Time budget](#time-budget)), so a failed validation is not retried and the review is lost. Contract change: `finding.category` in `schemas/review-result.schema.json` is a closed enum (the dimension categories in `pr-review` `references/re-review.md` plus `protected-path`, `provenance-warning`, `scope-authorization-implicit`, `sub-agent-failure`), no longer any non-empty string. Anything that produces review results must emit only these categories. The `pr-review` orchestrator remaps stray dimensional categories in step 6b to limit this risk.
4. **Post-script** posts the review on the PR. On GitHub, it also resolves still-open review threads whose only comments are outdated inline comments authored by the review agent, so stale comments do not remain in the PR's unresolved-review state.
4. **Post-script** posts the review on the PR. On GitHub and GitLab, it also resolves still-open review threads where every non-system note is authored by the authenticated review user (`GET /user` on GitLab) and the thread is outdated (detected via `isOutdated` on GitHub, and on GitLab when the first note's `position.head_sha` differs from the reviewed commit's `head_sha`, skipping cleanup if the MR's live `diff_refs.head_sha` has moved since the review was generated), so stale comments do not remain in the PR's unresolved-review state.

On re-review, the sandbox receives a validated `fullsend:review-findings-v2` projection containing `action`, severity, category, file, optional line, id, optional `actionable`, and an `{id, status}` disposition per prior id—not prior descriptions, remediation text, rationales, evidence, or review-thread bodies. Immediately before sandbox creation, the host fetches current thread state and marks eligible findings dismissed only when a verified write-or-higher human other than the PR author resolved a thread containing a trusted review-bot comment with the exact finding-id stamp. Unstamped thread comments stay open; path and line are not used as a fallback. A legacy marker without ids is assigned one before sandbox ingress. Resolved and human-dismissed findings stay in the projection with their anchor so they are recognised and not raised again. A null file retains PR-level category context without serving as a path or severity anchor. The pre-script continues to accept v1 markers for existing comments. Only GitHub `app-verified` re-reviews use file-backed records for remediation-candidate matching.

Expand Down
100 changes: 97 additions & 3 deletions scripts/lib/gitlab-review-ops.lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
# Bundled into pre-review.sh and post-review.sh via review-ops.lib.sh.
# All functions use curl against the GitLab REST API.
#
# Expected globals (set by forge_parse_pr_url):
# Expected globals (set by forge_parse_pr_url or caller):
# REPO — plain project path (e.g., "group/project")
# REPO_ENCODED — URL-encoded project path (e.g., "group%2Fproject")
# PR_NUMBER — merge request IID
# GITLAB_HOST — API host (e.g., "gitlab.com")
# RESULT_FILE — (optional) path to reviewed agent-result JSON (for head_sha)
#
# Expected env vars:
# PR_URL — HTML URL of the merge request
Expand Down Expand Up @@ -176,9 +177,102 @@ forge_get_finding_comment_links() {
echo '{}'
}

# GitHub-only: GitLab discussions have no isOutdated equivalent in this
# issue's scope. No-op so post-review can call this unconditionally.
# Resolve still-open review threads on GitLab whose only comments are outdated
# inline comments authored by the review agent. Best-effort: failures log a
# warning and return success so they do not fail the post-review step.
forge_resolve_outdated_review_threads() {
if [[ -z "${REPO_ENCODED:-}" || -z "${PR_NUMBER:-}" ]]; then
return 0
fi

local reviewed_sha=""
if [[ -n "${RESULT_FILE:-}" && -f "${RESULT_FILE}" ]]; then
reviewed_sha=$(jq -r '(.head_sha // empty) | strings' "${RESULT_FILE}" 2>/dev/null) || reviewed_sha=""
fi
if [[ -z "${reviewed_sha}" ]]; then
echo "::warning::Could not determine reviewed head SHA — skipping outdated-thread resolution"
return 0
fi

local mr_info live_head_sha
if ! mr_info=$(_gitlab_api GET "/projects/${REPO_ENCODED}/merge_requests/${PR_NUMBER}" 2>/dev/null); then
echo "::warning::Failed to fetch MR details for #${PR_NUMBER} — skipping outdated-thread resolution"
return 0
fi
live_head_sha=$(echo "${mr_info}" | jq -r '[.diff_refs.head_sha?, .sha? | strings | select(length > 0)] | first // empty' 2>/dev/null) || live_head_sha=""
if [[ -z "${live_head_sha}" ]]; then
echo "::warning::Could not determine MR head SHA — skipping outdated-thread resolution"
return 0
fi
if [[ "${live_head_sha}" != "${reviewed_sha}" ]]; then
echo "MR head moved ($(_gha_sanitize "${reviewed_sha}") -> $(_gha_sanitize "${live_head_sha}")) — skipping outdated-thread resolution"
return 0
fi

local current_sha="${reviewed_sha}"

local current_user bot_user_id
if ! current_user=$(_gitlab_api GET "/user" 2>/dev/null); then
echo "::warning::Could not determine review identity — skipping outdated-thread resolution"
return 0
fi
bot_user_id=$(echo "${current_user}" | jq -r '(.id? | select(. != null and . != "")) | tostring' 2>/dev/null) || bot_user_id=""
if [[ -z "${bot_user_id}" ]]; then
echo "::warning::Could not determine review identity — skipping outdated-thread resolution"
return 0
fi

local page=1 all_discussions="[]"
while [[ "${page}" -le 10 ]]; do
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
local response
response=$(_gitlab_api GET "/projects/${REPO_ENCODED}/merge_requests/${PR_NUMBER}/discussions?per_page=100&page=${page}" 2>/dev/null) || {
echo "::warning::Failed to fetch MR discussions for #${PR_NUMBER} while resolving outdated threads" >&2
break
}
if [[ -z "${response}" || "${response}" == "[]" || "${response}" != "["* ]]; then
break
fi
all_discussions=$(jq -c -s '.[0] + .[1]' <<< "${all_discussions} ${response}" 2>/dev/null) || break
local count
count=$(jq 'length' <<< "${response}" 2>/dev/null) || count=0
[[ "${count}" -lt 100 ]] && break
page=$((page + 1))
done

local discussion_ids
discussion_ids=$(echo "${all_discussions}" | jq -r --arg current_sha "${current_sha}" --arg bot_id "${bot_user_id}" '
.[]
| select(.id != null)
| .notes |= (map(select(.system != true)) // [])
| select(.notes | type == "array" and length > 0)
| select(.notes | any(.resolvable == true and .resolved == false))
| select(.notes | all((.author.id | tostring) == $bot_id))
| select((.notes[0].position.head_sha // empty) != "" and .notes[0].position.head_sha != $current_sha)
| .id
' 2>/dev/null) || discussion_ids=""

if [[ -z "${discussion_ids}" ]]; then
return 0
fi

local resolved=0 failed=0
while IFS= read -r disc_id; do
[[ -z "${disc_id}" ]] && continue
if _gitlab_api PUT "/projects/${REPO_ENCODED}/merge_requests/${PR_NUMBER}/discussions/${disc_id}?resolved=true" >/dev/null 2>&1; then
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
resolved=$((resolved + 1))
else
failed=$((failed + 1))
echo "::warning::Failed to resolve review thread $(_gha_sanitize "${disc_id}")"
fi
done <<< "${discussion_ids}"

if [[ "${resolved}" -gt 0 ]]; then
echo "Resolved ${resolved} outdated review-agent thread(s)"
fi
if [[ "${failed}" -gt 0 ]]; then
echo "::warning::Failed to resolve ${failed} outdated review thread(s)"
fi

return 0
}

Expand Down
Loading
Loading