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
92 changes: 86 additions & 6 deletions .github/workflows/pr-review-merge-scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ concurrency:
github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number) ||
github.event_name == 'workflow_call' && inputs.pr_number != '' && format('pr-{0}', inputs.pr_number) ||
github.event_name == 'workflow_call' && inputs.base_branch != '' && format('call-{0}', inputs.base_branch) ||
github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && format('target-{0}-pr-{1}', github.event.client_payload.target_repository, github.event.client_payload.pr_number) ||
github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && format('pr-{0}', github.event.client_payload.pr_number) ||
github.event_name == 'repository_dispatch' && github.run_id ||
Comment on lines 94 to 99
github.ref }}
Expand Down Expand Up @@ -227,6 +228,68 @@ jobs:
echo "token=$app_token"
} >>"$GITHUB_OUTPUT"

- name: Bind targeted central dispatch to live pull request metadata
id: dispatch_target
if: >-
github.event_name == 'repository_dispatch' &&
github.event.client_payload.target_repository != ''
env:
GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }}
MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.scheduler_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }}
TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository }}
TARGET_PR_NUMBER: ${{ github.event.client_payload.pr_number }}
run: |
set -euo pipefail

if [ "$GITHUB_REPOSITORY" != "ContextualWisdomLab/.github" ]; then
echo "::error::Cross-repository targeted dispatch is only allowed from ContextualWisdomLab/.github."
exit 1
fi
if [[ ! "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9._-]+$ ]]; then
echo "::error::target_repository must name a ContextualWisdomLab repository."
exit 1
fi
if [[ ! "$TARGET_PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::pr_number must be a positive integer for targeted dispatch."
exit 1
fi
if [ "$MUTATION_TOKEN_SOURCE" = "github-token" ]; then
echo "::error::Targeted central dispatch has no sibling-repository credential. Configure PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, or the OpenCode app token exchange."
exit 1
fi

pull_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${TARGET_PR_NUMBER}")"
live_state="$(jq -r '.state // empty' <<<"$pull_json")"
base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_json")"
base_branch="$(jq -r '.base.ref // empty' <<<"$pull_json")"
base_sha="$(jq -r '.base.sha // empty' <<<"$pull_json")"
head_sha="$(jq -r '.head.sha // empty' <<<"$pull_json")"

if [ "$live_state" != "open" ] || [ "$base_repository" != "$TARGET_REPOSITORY" ]; then
echo "::error::Targeted dispatch must resolve to an open PR whose base repository exactly matches target_repository."
exit 1
fi
if [[ ! "$base_branch" =~ ^[A-Za-z0-9._/-]+$ ]] ||
[[ "$base_branch" == *".."* ]] || [[ "$base_branch" == *"@{"* ]] ||
[[ "$base_branch" == .* ]] || [[ "$base_branch" == */.* ]] ||
[[ "$base_branch" == */ ]] || [[ "$base_branch" == *. ]]; then
echo "::error::Live PR base ref is not safe for targeted scheduler arguments."
exit 1
fi
if [[ ! "$base_sha" =~ ^[0-9a-fA-F]{40}$ ]] || [[ ! "$head_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "::error::Live PR metadata did not provide exact 40-character base and head SHAs."
exit 1
fi

{
echo "repository=$TARGET_REPOSITORY"
echo "pr_number=$TARGET_PR_NUMBER"
echo "base_branch=$base_branch"
echo "base_sha=$base_sha"
echo "head_sha=$head_sha"
} >>"$GITHUB_OUTPUT"
echo "Targeted scheduler bound to ${TARGET_REPOSITORY}#${TARGET_PR_NUMBER} at head ${head_sha} (base ${base_branch}@${base_sha})."

- name: Resolve trusted scheduler source ref
id: trusted_source
env:
Expand Down Expand Up @@ -406,21 +469,34 @@ jobs:
env:
GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }}
SCHEDULER_ACTIONS_TOKEN: ${{ github.token }}
# A targeted central run controls only central Actions. The app/PAT in
# GH_TOKEN reads and mutates the sibling PR, while github.token inspects
# and dispatches the central required workflows without making a
# doomed sibling Actions API request first.
SCHEDULER_ACTIONS_REPOSITORY: ${{ steps.dispatch_target.outputs.repository != '' && github.repository || '' }}
# Same-repository dispatch credential: when this scheduler runs inside
# ContextualWisdomLab/.github (the repository the required workflows are
# dispatched on), the runner token can dispatch them without any
# cross-repository PAT. The scheduler only uses it when
# GITHUB_REPOSITORY equals the dispatch repository.
SCHEDULER_DISPATCH_TOKEN: ${{ github.token }}
SCHEDULER_READ_TOKEN: ${{ github.token }}
SCHEDULER_READ_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }}
SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.scheduler_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }}
SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github
# Bound the aggregate central model-review queue across all target
# repositories. Per-run REVIEW_DISPATCH_LIMIT alone cannot prevent a
# large set of concurrent scheduler runs from saturating Actions.
ACTIVE_OPENCODE_REVIEW_LIMIT: ${{ vars.ACTIVE_OPENCODE_REVIEW_LIMIT || '16' }}
SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }}
SCHEDULER_TARGET_REPOSITORY: ${{ steps.dispatch_target.outputs.repository || github.repository }}
SCHEDULER_TARGET_BASE_BRANCH: ${{ steps.dispatch_target.outputs.base_branch || env.DEFAULT_BRANCH }}
SCHEDULER_TARGET_PR_NUMBER: ${{ steps.dispatch_target.outputs.pr_number || env.PULL_REQUEST_NUMBER }}
SCHEDULER_TARGET_HEAD_SHA: ${{ steps.dispatch_target.outputs.head_sha || '' }}
run: |
set -euo pipefail
project_flow="$PROJECT_FLOW_INPUT"
if [ -z "$project_flow" ]; then
case "$DEFAULT_BRANCH" in
case "$SCHEDULER_TARGET_BASE_BRANCH" in
main|master) project_flow="github-flow" ;;
develop) project_flow="git-flow" ;;
*) project_flow="github-flow" ;;
Expand All @@ -435,17 +511,20 @@ jobs:
branch_update_limit="1"
fi
args=(
--repo "$GITHUB_REPOSITORY"
--base-branch "$DEFAULT_BRANCH"
--repo "$SCHEDULER_TARGET_REPOSITORY"
--base-branch "$SCHEDULER_TARGET_BASE_BRANCH"
--max-prs "$MAX_PRS"
--project-flow "$project_flow"
--review-workflow "Required OpenCode Review"
--review-dispatch-limit "$review_dispatch_limit"
--branch-update-limit "$branch_update_limit"
--stale-opencode-minutes "$STALE_OPENCODE_MINUTES"
)
if [ -n "$PULL_REQUEST_NUMBER" ]; then
args+=(--pr-number "$PULL_REQUEST_NUMBER")
if [ -n "$SCHEDULER_TARGET_PR_NUMBER" ]; then
args+=(--pr-number "$SCHEDULER_TARGET_PR_NUMBER")
fi
if [ -n "$SCHEDULER_TARGET_HEAD_SHA" ]; then
args+=(--expected-head-sha "$SCHEDULER_TARGET_HEAD_SHA")
fi
if [ "$DRY_RUN" = "true" ]; then
args+=(--dry-run)
Expand Down Expand Up @@ -671,6 +750,7 @@ jobs:
SCHEDULER_DISPATCH_TOKEN: ${{ github.token }}
SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.sweep_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }}
SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github
ACTIVE_OPENCODE_REVIEW_LIMIT: ${{ vars.ACTIVE_OPENCODE_REVIEW_LIMIT || '16' }}
SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }}
run: |
set -euo pipefail
Expand Down
152 changes: 111 additions & 41 deletions scripts/ci/noema_review_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import argparse
import base64
import http.client
import ipaddress
import json
import os
Expand All @@ -29,6 +30,10 @@
"opencode-review-control-v1",
)
REVIEW_BODY_HEAD_SHA_RE = re.compile(r"Head SHA:\s*`([0-9a-fA-F]{40})`")
NOEMA_REVIEW_MARKER_HEAD_RE = re.compile(
r"<!--\s*noema-review-gate\b[^>]*\bhead_sha=([^\s>]+)",
re.IGNORECASE,
)
IGNORED_RUNNING_CHECKS = {
"approve-after-primary-review",
"noema-review",
Expand Down Expand Up @@ -260,14 +265,18 @@ def blocking_checks(pr: dict[str, Any]) -> list[str]:
def existing_noema_review(pr: dict[str, Any], actor: str) -> bool:
"""Return whether Noema already reviewed the current head."""
head_sha = str(pr.get("headRefOid") or "")
marker = "<!-- noema-review-gate"
for review in (((pr.get("reviews") or {}).get("nodes")) or []):
if review_commit(review) != head_sha:
if not review_matches_current_head(review, head_sha):
continue
if str(review.get("state") or "").upper() not in {"APPROVED", "CHANGES_REQUESTED", "COMMENTED"}:
continue
if review_author(review) == actor or marker in str(review.get("body") or ""):
return True
if not actor or review_author(review) != actor:
continue
body = str(review.get("body") or "")
marker_heads = NOEMA_REVIEW_MARKER_HEAD_RE.findall(body)
if marker_heads and any(value.lower() != head_sha.lower() for value in marker_heads):
continue
return True
return False


Expand Down Expand Up @@ -419,6 +428,81 @@ def redirect_request(
raise urllib.error.HTTPError(req.full_url, code, msg, headers, fp)


class PinnedHTTPSConnection(http.client.HTTPSConnection):
"""Connect to one validated numeric address while preserving TLS SNI."""

def __init__(self, hostname: str, port: int, pinned_ip: str, *, timeout: float) -> None:
"""Configure a direct HTTPS connection pinned to ``pinned_ip``."""
super().__init__(hostname, port=port, timeout=timeout)
self._pinned_ip = pinned_ip
self._create_connection = self._create_pinned_connection

def _create_pinned_connection(
self,
address: tuple[str, int],
timeout: object,
source_address: tuple[str, int] | None,
) -> socket.socket:
"""Open the socket to the validated IP instead of resolving the hostname again."""
return socket.create_connection(
(self._pinned_ip, address[1]),
timeout=timeout,
source_address=source_address,
)


def validated_https_endpoint(api_url: str) -> tuple[str, int, str, str]:
"""Return hostname, port, pinned public IP, and request target for an HTTPS URL."""
if any(ord(character) < 32 or ord(character) == 127 for character in api_url):
raise ValueError("NOEMA_LLM_API_URL cannot contain control characters")
if not api_url.lower().startswith("https://"):
raise ValueError("NOEMA_LLM_API_URL must use https://")

parsed = urllib.parse.urlparse(api_url)
if parsed.scheme.lower() != "https":
raise ValueError("NOEMA_LLM_API_URL must use the https scheme")
hostname = (parsed.hostname or "").lower()
if not hostname:
raise ValueError("URL must have a valid hostname")
if parsed.username is not None or parsed.password is not None:
raise ValueError("NOEMA_LLM_API_URL cannot contain user information")
if parsed.fragment:
raise ValueError("NOEMA_LLM_API_URL cannot contain a fragment")
if hostname in {"localhost", "localhost.localdomain"} or hostname.endswith(".localhost"):
raise ValueError("URL cannot target localhost")
try:
port = parsed.port or 443
except ValueError as exc:
raise ValueError("NOEMA_LLM_API_URL contains an invalid port") from exc

try:
addrinfo = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise ValueError("NOEMA_LLM_API_URL hostname could not be resolved") from exc
if not addrinfo:
raise ValueError("NOEMA_LLM_API_URL hostname resolved to no addresses")

public_addresses: list[str] = []
for result in addrinfo:
ip_text = str(result[4][0])
try:
ip = ipaddress.ip_address(ip_text)
except ValueError as exc:
raise ValueError("NOEMA_LLM_API_URL resolved to an invalid IP address") from exc
if not ip.is_global:
raise ValueError("URL cannot target non-public IP addresses")
normalized = ip.compressed
if normalized not in public_addresses:
public_addresses.append(normalized)

target = parsed.path or "/"
if parsed.params:
target += f";{parsed.params}"
if parsed.query:
target += f"?{parsed.query}"
return hostname, port, public_addresses[0], target


def extract_json_object(text: str) -> dict[str, Any]:
"""Extract a JSON object from a strict or lightly wrapped LLM response."""
stripped = text.strip()
Expand All @@ -445,32 +529,7 @@ def call_llm(
model = os.environ.get("NOEMA_LLM_MODEL", "").strip() or "noema-default"
if not api_url or not api_key:
raise RuntimeError("Noema LLM review unavailable: NOEMA_LLM_API_URL or NOEMA_LLM_API_KEY is not configured.")
if not (api_url.lower().startswith("http://") or api_url.lower().startswith("https://")):
raise ValueError(
"URL scheme must be http or https; NOEMA_LLM_API_URL must start "
"with http:// or https:// to prevent SSRF vulnerabilities"
)
parsed = urllib.parse.urlparse(api_url)
if parsed.scheme.lower() not in {"http", "https"}:
raise ValueError("URL scheme must be http or https; NOEMA_LLM_API_URL must start with http:// or https://")
hostname = (parsed.hostname or "").lower()
if not hostname:
raise ValueError("URL must have a valid hostname")
if hostname in {"localhost", "localhost.localdomain"} or hostname.endswith(".localhost"):
raise ValueError("URL cannot target localhost")
try:
addrinfo = socket.getaddrinfo(hostname, None)
except socket.gaierror:
pass
else:
for result in addrinfo:
ip_str = result[4][0]
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
continue
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_unspecified:
raise ValueError("URL cannot target internal IP addresses")
hostname, port, pinned_ip, request_target = validated_https_endpoint(api_url)

prompt = {
"role": "user",
Expand Down Expand Up @@ -501,18 +560,23 @@ def call_llm(
prompt,
],
}
request = urllib.request.Request(
api_url,
data=json.dumps(payload).encode("utf-8"),
headers={
"authorization": f"Bearer {api_key}",
"content-type": "application/json",
},
method="POST",
)
opener = urllib.request.build_opener(NoRedirectHandler())
with opener.open(request, timeout=120) as response: # nosec B310
connection = PinnedHTTPSConnection(hostname, port, pinned_ip, timeout=120)
try:
connection.request(
"POST",
request_target,
body=json.dumps(payload).encode("utf-8"),
headers={
"authorization": f"Bearer {api_key}",
"content-type": "application/json",
},
)
response = connection.getresponse()
raw = response.read().decode("utf-8")
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"Noema LLM endpoint returned HTTP {response.status}")
finally:
connection.close()
data = json.loads(raw)
content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip()
verdict = extract_json_object(content)
Expand Down Expand Up @@ -581,6 +645,12 @@ def inspect_and_review(repo: str, number: int) -> int:
"""Inspect PR state and submit Noema's LLM review when gates are clean."""
pr = fetch_pr(repo, number)
actor = current_actor()
if not actor:
print(
"Current token actor could not be verified; Noema review skipped so "
"GitHub never counts an unverified credential as an independent reviewer."
)
return 0
if actor in PRIMARY_REVIEW_AUTHORS:
print(
f"Current token actor {actor!r} is already a primary review actor; "
Expand Down
Loading
Loading