Skip to content
Merged
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
17 changes: 15 additions & 2 deletions .github/workflows/noema-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ jobs:
env:
OIDC_AUDIENCE: ${{ vars.NOEMA_OIDC_AUDIENCE || 'cwl-noema-review' }}
TOKEN_EXCHANGE_URL: ${{ vars.NOEMA_TOKEN_EXCHANGE_URL || vars.NOEMA_EXCHANGE_URL || '' }}
NOEMA_REVIEW_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN }}
run: |
set -euo pipefail

Expand All @@ -159,6 +160,18 @@ jobs:
exit 0
}

# PAT fallback: when a NOEMA_REVIEW_TOKEN secret is present, use it as
# the reviewer identity directly and skip the OIDC app-token exchange.
# This lets the independent second reviewer satisfy the two-reviewer
# merge rule without deploying the Noema Worker. The secret value is
# never emitted as a step output; the review step reads it from secrets.
if [ -n "${NOEMA_REVIEW_TOKEN:-}" ]; then
echo "available=true" >>"$GITHUB_OUTPUT"
echo "source=pat" >>"$GITHUB_OUTPUT"
echo "::notice::Noema reviewer using the NOEMA_REVIEW_TOKEN secret fallback identity."
exit 0
fi

if [ -z "${TOKEN_EXCHANGE_URL:-}" ]; then
mark_unconfigured "Noema app token exchange unconfigured: NOEMA_TOKEN_EXCHANGE_URL or NOEMA_EXCHANGE_URL is not configured; Noema review skipped until the exchange service is deployed."
fi
Expand Down Expand Up @@ -212,9 +225,9 @@ jobs:
- name: Run Noema LLM review and submit verdict
if: env.PR_NUMBER != ''
env:
GH_TOKEN: ${{ steps.noema_app_token.outputs.token }}
GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_app_token.outputs.token }}
NOEMA_APP_TOKEN_AVAILABLE: ${{ steps.noema_app_token.outputs.available }}
NOEMA_REVIEW_TOKEN_SOURCE: noema-review-app-oidc
NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.noema_app_token.outputs.source == 'pat' && 'noema-review-pat' || 'noema-review-app-oidc' }}
NOEMA_LLM_API_URL: ${{ vars.NOEMA_LLM_API_URL || '' }}
NOEMA_LLM_MODEL: ${{ vars.NOEMA_LLM_MODEL || '' }}
NOEMA_LLM_API_KEY: ${{ secrets.NOEMA_LLM_API_KEY || '' }}
Expand Down
296 changes: 296 additions & 0 deletions .github/workflows/pr-review-merge-scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,25 @@ on:
type: string
schedule:
- cron: "*/30 * * * *"
# Hourly org-wide sweep cadence for the org-queue-sweep job below. Target
# repositories only receive scheduler runs on PR events, review/security
# workflow completion, and protected-branch pushes; a PR whose approval or
# required checks land AFTER its last event has no later trigger and sits
# approved-but-unmerged until a human pushes something. The sweep closes
# that gap on a fixed heartbeat.
- cron: "17 * * * *"
workflow_dispatch:
inputs:
dry_run:
description: Print planned actions without mutating PRs
required: false
default: false
type: boolean
org_sweep:
description: Run the organization-wide queue sweep instead of the single-repository scan
required: false
default: false
type: boolean
max_prs:
description: Maximum open PRs to inspect
required: false
Expand Down Expand Up @@ -139,6 +151,8 @@ jobs:
scan-pr-queue:
# workflow_dispatch review runs do not reliably carry pull_requests metadata.
# Without this guard, one completed central review can wake a repo-wide scan.
# The org-sweep cron and org_sweep dispatches are handled by org-queue-sweep
# below; skipping them here avoids a duplicate same-repository scan.
if: >-
(
github.event_name != 'pull_request_target' ||
Expand All @@ -150,6 +164,14 @@ jobs:
github.event.workflow_run.conclusion != 'cancelled' &&
github.event.workflow_run.pull_requests[0].number
)
) &&
(
github.event_name != 'schedule' ||
github.event.schedule != '17 * * * *'
) &&
(
github.event_name != 'workflow_dispatch' ||
inputs.org_sweep != true
)
runs-on: ubuntu-latest
permissions:
Expand Down Expand Up @@ -364,3 +386,277 @@ jobs:
args+=(--no-update-branches)
fi
python3 scripts/ci/pr_review_merge_scheduler.py "${args[@]}"

org-queue-sweep:
# Organization-wide approved-PR fallback sweep. Event-driven scheduler runs
# in target repositories stop retrying once their triggering event is
# consumed, so a PR that becomes mergeable AFTER its last event (approval
# published after the scheduler pass, required merge-preview checks landing
# late, a base-branch policy blocker clearing) stays approved-but-unmerged
# with no later trigger. This job re-runs the same trusted scheduler against
# every organization repository on an hourly heartbeat so each such PR is
# merged, branch-updated, or leaves a concrete per-PR blocker reason in this
# log. It never bypasses policy: all mutations go through the same guarded
# scheduler contract as the per-repository runs.
if: >-
github.repository == 'ContextualWisdomLab/.github' &&
(
(github.event_name == 'schedule' && github.event.schedule == '17 * * * *') ||
(github.event_name == 'workflow_dispatch' && inputs.org_sweep == true)
)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
actions: write
checks: read
contents: write
id-token: write
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ inputs.dry_run == true }}
ORG_SWEEP_OWNER: ContextualWisdomLab
ORG_SWEEP_MAX_PRS: ${{ vars.ORG_SWEEP_MAX_PRS || '30' }}
ORG_SWEEP_REVIEW_DISPATCH_LIMIT: ${{ vars.ORG_SWEEP_REVIEW_DISPATCH_LIMIT || '1' }}
ORG_SWEEP_STALE_QUEUE_HOURS: ${{ vars.ORG_SWEEP_STALE_QUEUE_HOURS || '24' }}
STALE_OPENCODE_MINUTES: ${{ vars.STALE_OPENCODE_MINUTES || '420' }}
steps:
- name: Exchange OpenCode app token for sweep mutations
id: sweep_app_token
env:
OIDC_AUDIENCE: opencode-github-action
OPENCODE_API_BASE_URL: https://api.opencode.ai
run: |
set -euo pipefail

mark_unavailable() {
echo "available=false" >>"$GITHUB_OUTPUT"
}

if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then
echo "OpenCode app token exchange unavailable: OIDC request environment is missing."
mark_unavailable
exit 0
fi

request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}"
separator="&"
case "$request_url" in
*\?*) ;;
*) separator="?" ;;
esac

if ! oidc_response="$(
curl -fsS \
-H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${request_url}${separator}audience=${OIDC_AUDIENCE}"
)"; then
echo "OpenCode app token exchange unavailable: OIDC token request did not complete."
mark_unavailable
exit 0
fi

oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")"
if [ -z "$oidc_token" ]; then
echo "OpenCode app token exchange unavailable: OIDC token response was empty."
mark_unavailable
exit 0
fi

if ! token_response="$(
curl -fsS \
-X POST \
-H "Authorization: Bearer ${oidc_token}" \
"${OPENCODE_API_BASE_URL}/exchange_github_app_token"
)"; then
echo "OpenCode app token exchange unavailable: app token request did not complete."
mark_unavailable
exit 0
fi

app_token="$(jq -r '.token // empty' <<<"$token_response")"
if [ -z "$app_token" ]; then
echo "OpenCode app token exchange unavailable: app token response was empty."
mark_unavailable
exit 0
fi

echo "::add-mask::$app_token"
{
echo "available=true"
echo "token=$app_token"
} >>"$GITHUB_OUTPUT"

- name: Resolve trusted scheduler source ref
id: trusted_source
env:
JOB_CONTEXT_JSON: ${{ toJSON(job) }}
GITHUB_CONTEXT_JSON: ${{ toJSON(github) }}
run: |
set -euo pipefail
python3 <<'PY' >>"$GITHUB_OUTPUT"
import json
import os
import re
import sys

try:
job_context = json.loads(os.environ.get("JOB_CONTEXT_JSON") or "{}")
github_context = json.loads(os.environ.get("GITHUB_CONTEXT_JSON") or "{}")
except json.JSONDecodeError as exc:
print(f"::error::Could not parse GitHub workflow context JSON: {exc}", file=sys.stderr)
raise SystemExit(1)

trusted_repository = str(
job_context.get("workflow_repository") or "ContextualWisdomLab/.github"
).strip()
trusted_ref = str(
job_context.get("workflow_sha") or github_context.get("workflow_sha") or ""
).strip()
workflow_ref = str(
job_context.get("workflow_ref") or github_context.get("workflow_ref") or ""
).strip()

if not trusted_ref:
trusted_ref = "main"
prefix = "ContextualWisdomLab/.github/.github/workflows/pr-review-merge-scheduler.yml@"
if workflow_ref.startswith(prefix):
trusted_ref = workflow_ref.split("@", 1)[1]

if trusted_repository != "ContextualWisdomLab/.github":
print("::error::Trusted scheduler workflow repository resolved outside ContextualWisdomLab/.github.", file=sys.stderr)
raise SystemExit(1)
if not re.fullmatch(r"[0-9a-fA-F]{40}|refs/[^\s]+|[A-Za-z0-9._/-]+", trusted_ref):
print("::error::Trusted scheduler workflow ref resolved to an invalid value.", file=sys.stderr)
raise SystemExit(1)

print(f"repository={trusted_repository}")
print(f"ref={trusted_ref}")
PY

- name: Materialize trusted scheduler
env:
GH_TOKEN: ${{ github.token }}
TRUSTED_SOURCE_REF: ${{ steps.trusted_source.outputs.ref }}
run: |
set -euo pipefail
if [[ ! "$TRUSTED_SOURCE_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "::error::Trusted scheduler source ref must resolve to the immutable workflow commit SHA before archive materialization."
exit 1
fi
trusted_archive="${RUNNER_TEMP}/trusted-scheduler-source.tar.gz"
api_url="${GITHUB_API_URL:-https://api.github.com}"
curl -fsSL \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-o "$trusted_archive" \
"${api_url}/repos/ContextualWisdomLab/.github/tarball/${TRUSTED_SOURCE_REF}"
tar -xzf "$trusted_archive" -C "$GITHUB_WORKSPACE" --strip-components=1
test -f scripts/ci/pr_review_merge_scheduler.py

- name: Self-test scheduler
run: python3 scripts/ci/pr_review_merge_scheduler.py --self-test

- name: Sweep organization repository queues
env:
GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.sweep_app_token.outputs.token || github.token }}
SCHEDULER_ACTIONS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.sweep_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.sweep_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }}
SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github
SCHEDULER_REQUIRED_WORKFLOW_REF: main
SCHEDULER_ALLOW_CROSS_REPO_WORKFLOW_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }}
run: |
set -euo pipefail
if [ "$SCHEDULER_MUTATION_TOKEN_SOURCE" = "github-token" ]; then
# github.token is repository-scoped to .github and cannot mutate
# sibling repositories; a sweep with it would silently do nothing.
echo "::error::Organization queue sweep has no cross-repository mutation credential. Configure the PR_REVIEW_MERGE_TOKEN or OPENCODE_APPROVE_TOKEN secret (or keep the OpenCode app token exchange available) so approved PRs in target repositories can be merged or updated."
exit 1
fi
echo "Sweep mutation token source: $SCHEDULER_MUTATION_TOKEN_SOURCE"

repositories_json="$(
gh api \
-H "Accept: application/vnd.github+json" \
"/orgs/${ORG_SWEEP_OWNER}/repos?per_page=100&type=all" --paginate
)"
mapfile -t sweep_targets < <(
jq -r '
.[]
| select(.archived == false and .disabled == false)
| select(.full_name != "ContextualWisdomLab/.github")
| "\(.full_name)\t\(.default_branch)"
' <<<"$repositories_json"
)
echo "Sweeping ${#sweep_targets[@]} repositories."

failures=0
for target in "${sweep_targets[@]}"; do
repo_full_name="${target%%$'\t'*}"
default_branch="${target##*$'\t'}"
echo "::group::Sweep ${repo_full_name} (base ${default_branch})"

open_pr_count="$(
gh api \
-H "Accept: application/vnd.github+json" \
"/repos/${repo_full_name}/pulls?state=open&per_page=1&base=${default_branch}" \
--jq 'length' || echo "unknown"
)"
if [ "$open_pr_count" = "0" ]; then
echo "No open PRs targeting ${default_branch}; skipping."
echo "::endgroup::"
continue
fi

args=(
--repo "$repo_full_name"
--base-branch "$default_branch"
--max-prs "$ORG_SWEEP_MAX_PRS"
--review-workflow "Required OpenCode Review"
--review-dispatch-limit "$ORG_SWEEP_REVIEW_DISPATCH_LIMIT"
--stale-opencode-minutes "$STALE_OPENCODE_MINUTES"
--trigger-reviews
--enable-auto-merge
--merge-mode direct_or_auto
--update-branches
)
if [ "$DRY_RUN" = "true" ]; then
args+=(--dry-run)
fi
if ! python3 scripts/ci/pr_review_merge_scheduler.py "${args[@]}"; then
echo "::error::Queue sweep failed for ${repo_full_name}; see the decision log above for the concrete per-PR reason."
failures=$((failures + 1))
fi

# Queue hygiene: a run still queued after ORG_SWEEP_STALE_QUEUE_HOURS
# belongs to a head that events will never revisit (closed PR, force-
# pushed branch, or runner starvation from a previous outage). Cancel
# it so the Actions queue only holds current-head work.
stale_cutoff="$(date -u -d "${ORG_SWEEP_STALE_QUEUE_HOURS} hours ago" +%Y-%m-%dT%H:%M:%SZ)"
stale_runs_json="$(
gh api \
-H "Accept: application/vnd.github+json" \
"/repos/${repo_full_name}/actions/runs?status=queued&per_page=100" \
--jq "[.workflow_runs[] | select(.created_at < \"${stale_cutoff}\") | {id, name, head_branch, created_at}]"
)" || stale_runs_json="[]"
stale_count="$(jq 'length' <<<"$stale_runs_json")"
if [ "$stale_count" -gt 0 ]; then
echo "Cancelling ${stale_count} queued run(s) older than ${ORG_SWEEP_STALE_QUEUE_HOURS}h:"
jq -r '.[] | " run \(.id) [\(.name)] on \(.head_branch) queued since \(.created_at)"' <<<"$stale_runs_json"
if [ "$DRY_RUN" != "true" ]; then
while IFS= read -r run_id; do
if ! gh api -X POST "/repos/${repo_full_name}/actions/runs/${run_id}/cancel" >/dev/null; then
echo "Could not cancel run ${run_id} in ${repo_full_name}; it may have started or finished already."
fi
done < <(jq -r '.[].id' <<<"$stale_runs_json")
fi
fi
echo "::endgroup::"
done

if [ "$failures" -gt 0 ]; then
echo "::error::Organization queue sweep completed with ${failures} repository failure(s); each failure's reason is printed in its repository group above."
exit 1
fi
echo "Organization queue sweep completed cleanly."
Loading
Loading