From b1351e6efb1df290c9cdcafa2b25918cdac78c3f Mon Sep 17 00:00:00 2001 From: shuse2 Date: Tue, 28 Apr 2026 23:51:22 +0900 Subject: [PATCH 1/3] feat: add auto-assign-reviewer reusable workflow with squad/domain matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a reusable workflow that auto-requests a reviewer when a PR is opened in lisk-backend, lisk-web, lisk-infra, lisk-mobile, or lisk-contracts. The reviewer is resolved via a squad × domain matrix in review-map.yml using a 3-step cascade: 1. own-squad domain specialists (round-robin via PR# % N) 2. squad-wide fallback for designated domains (e.g. infra) 3. sibling-squad cascade The workflow is best-effort: any resolution failure soft-fails with a PR comment, never blocks merges. Idempotency check skips re-requests on the same reviewer. pull_request_target supports forks since the workflow only calls the GitHub API. Adds: - review-map.yml — single source of truth for routing - .github/workflows/auto-assign-reviewer.yml — reusable workflow - .github/workflows/validate-review-map.yml — CI lint that verifies structure and that every login is a LiskHQ org member - examples/workflows/auto-assign-reviewer-caller.yml — caller stub - docs/auto-reviewer.md — cascade and operating notes --- .github/workflows/auto-assign-reviewer.yml | 200 ++++++++++++++++++ .github/workflows/validate-review-map.yml | 155 ++++++++++++++ README.md | 8 + docs/auto-reviewer.md | 115 ++++++++++ .../workflows/auto-assign-reviewer-caller.yml | 38 ++++ review-map.yml | 81 +++++++ 6 files changed, 597 insertions(+) create mode 100644 .github/workflows/auto-assign-reviewer.yml create mode 100644 .github/workflows/validate-review-map.yml create mode 100644 docs/auto-reviewer.md create mode 100644 examples/workflows/auto-assign-reviewer-caller.yml create mode 100644 review-map.yml diff --git a/.github/workflows/auto-assign-reviewer.yml b/.github/workflows/auto-assign-reviewer.yml new file mode 100644 index 0000000..859b356 --- /dev/null +++ b/.github/workflows/auto-assign-reviewer.yml @@ -0,0 +1,200 @@ +name: Auto-assign Reviewer (Reusable) + +# Resolves a reviewer for the PR using review-map.yml from LiskHQ/workflows +# and requests them via the GitHub API. Soft-fails on any error so it never +# blocks merges. +# +# Cascade (see docs/auto-reviewer.md): +# 1. own-squad specialists for the domain (round-robin via PR# % N) +# 2. for fallback_to_squad_members_for domains: any other squad member +# 3. sibling squads in sibling_fallback_order +# 4. soft-fail with PR comment + +on: + workflow_call: + inputs: + domain: + description: 'Domain of this repo (backend|web|infra|mobile|contracts)' + required: true + type: string + map_ref: + description: 'Git ref of LiskHQ/workflows to source review-map.yml from' + required: false + type: string + default: main + +jobs: + assign: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + - name: Validate domain input + env: + DOMAIN: ${{ inputs.domain }} + run: | + case "$DOMAIN" in + backend|web|infra|mobile|contracts) ;; + *) echo "::error::Invalid domain '$DOMAIN'. Must be one of: backend, web, infra, mobile, contracts"; exit 1 ;; + esac + + - name: Sparse-checkout review-map.yml + uses: actions/checkout@v4 + with: + repository: LiskHQ/workflows + ref: ${{ inputs.map_ref }} + path: .workflow-repo + sparse-checkout: review-map.yml + sparse-checkout-cone-mode: false + + - name: Install yq and convert map to JSON + env: + MAP_FILE: .workflow-repo/review-map.yml + run: | + set -euo pipefail + if ! command -v yq >/dev/null 2>&1; then + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + fi + yq --version + # Convert YAML → JSON once. All subsequent logic uses jq with --arg. + yq -o=json '.' "$MAP_FILE" > /tmp/review-map.json + jq -e . /tmp/review-map.json >/dev/null + echo "review-map.json built ($(wc -c < /tmp/review-map.json) bytes)" + + - name: Resolve and request reviewer + id: resolve + env: + GH_TOKEN: ${{ github.token }} + MAP_JSON: /tmp/review-map.json + DOMAIN: ${{ inputs.domain }} + REPO: ${{ github.repository }} + REPO_NAME: ${{ github.event.repository.name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + set -euo pipefail + + log() { echo "::notice::$*"; } + warn() { echo "::warning::$*"; } + comment() { + # Best-effort PR comment; never fail the job if comment posting fails. + gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$1" || true + } + + # ── Map-level checks ───────────────────────────────────────────── + ENABLED=$(jq -r '.enabled' "$MAP_JSON") + if [ "$ENABLED" != "true" ]; then + log "auto-reviewer: disabled via review-map.yml (enabled=$ENABLED). Exiting." + exit 0 + fi + + if ! jq -r '.enabled_repos[]' "$MAP_JSON" | grep -Fxq "$REPO_NAME"; then + log "auto-reviewer: $REPO_NAME not in enabled_repos. Exiting." + exit 0 + fi + + # ── Bot-author skip ────────────────────────────────────────────── + if jq -r '.bot_authors[]' "$MAP_JSON" | grep -Fxq "$PR_AUTHOR"; then + log "auto-reviewer: skipping bot/external author '$PR_AUTHOR'." + exit 0 + fi + + # ── Find author's squad ────────────────────────────────────────── + AUTHOR_SQUAD=$(jq -r --arg a "$PR_AUTHOR" ' + .squads | to_entries[] + | select(.value.members | index($a)) + | .key' "$MAP_JSON" | head -n1) + + if [ -z "$AUTHOR_SQUAD" ] || [ "$AUTHOR_SQUAD" = "null" ]; then + log "auto-reviewer: author '$PR_AUTHOR' not in any squad. Skipping." + exit 0 + fi + + log "auto-reviewer: author=$PR_AUTHOR squad=$AUTHOR_SQUAD domain=$DOMAIN repo=$REPO_NAME pr=$PR_NUMBER" + + # ── Already-requested reviewers (idempotency) ──────────────────── + EXISTING=$(gh api "repos/$REPO/pulls/$PR_NUMBER/requested_reviewers" --jq '.users[].login' 2>/dev/null || true) + + # ── Cascade resolution ─────────────────────────────────────────── + SELECTED="" + REASON="" + + # Step 1: own-squad specialists for this domain + OWN_LIST=$(jq -r --arg s "$AUTHOR_SQUAD" --arg d "$DOMAIN" ' + .squads[$s].specialists[$d] // [] | .[]' "$MAP_JSON") + if [ -n "$OWN_LIST" ]; then + CAND=$(printf '%s\n' "$OWN_LIST" | grep -v -Fx "$PR_AUTHOR" || true) + if [ -n "$CAND" ]; then + COUNT=$(printf '%s\n' "$CAND" | wc -l | tr -d ' ') + IDX=$(( PR_NUMBER % COUNT )) + SELECTED=$(printf '%s\n' "$CAND" | sed -n "$((IDX + 1))p") + REASON="own-squad ($AUTHOR_SQUAD) $DOMAIN specialist (round-robin ${IDX}/${COUNT})" + fi + fi + + # Step 2: squad-wide fallback for designated domains (e.g., infra) + if [ -z "$SELECTED" ]; then + FB=$(jq -r --arg s "$AUTHOR_SQUAD" ' + .squads[$s].fallback_to_squad_members_for // [] | .[]' "$MAP_JSON") + if printf '%s\n' "$FB" | grep -Fxq "$DOMAIN"; then + SQUAD_MEMBERS=$(jq -r --arg s "$AUTHOR_SQUAD" '.squads[$s].members[]' "$MAP_JSON") + CAND=$(printf '%s\n' "$SQUAD_MEMBERS" | grep -v -Fx "$PR_AUTHOR" || true) + if [ -n "$CAND" ]; then + COUNT=$(printf '%s\n' "$CAND" | wc -l | tr -d ' ') + IDX=$(( PR_NUMBER % COUNT )) + SELECTED=$(printf '%s\n' "$CAND" | sed -n "$((IDX + 1))p") + REASON="own-squad ($AUTHOR_SQUAD) member fallback for $DOMAIN" + fi + fi + fi + + # Step 3: sibling cascade + if [ -z "$SELECTED" ]; then + ORDER=$(jq -r '.sibling_fallback_order[]' "$MAP_JSON") + for SIB in $ORDER; do + if [ "$SIB" = "$AUTHOR_SQUAD" ]; then + continue + fi + SIB_LIST=$(jq -r --arg s "$SIB" --arg d "$DOMAIN" ' + .squads[$s].specialists[$d] // [] | .[]' "$MAP_JSON") + if [ -z "$SIB_LIST" ]; then + continue + fi + CAND=$(printf '%s\n' "$SIB_LIST" | grep -v -Fx "$PR_AUTHOR" || true) + if [ -n "$CAND" ]; then + COUNT=$(printf '%s\n' "$CAND" | wc -l | tr -d ' ') + IDX=$(( PR_NUMBER % COUNT )) + SELECTED=$(printf '%s\n' "$CAND" | sed -n "$((IDX + 1))p") + REASON="sibling-squad ($SIB) $DOMAIN specialist" + break + fi + done + fi + + # Step 4: soft-fail + if [ -z "$SELECTED" ]; then + warn "auto-reviewer: no eligible reviewer found for $PR_AUTHOR / $DOMAIN." + comment "🤖 **Auto-reviewer:** could not find an eligible reviewer for domain \`$DOMAIN\` from author \`$PR_AUTHOR\` (squad \`$AUTHOR_SQUAD\`). Please assign a reviewer manually." + exit 0 + fi + + # ── Idempotency check ──────────────────────────────────────────── + if printf '%s\n' "$EXISTING" | grep -Fxq "$SELECTED"; then + log "auto-reviewer: $SELECTED already requested on PR #$PR_NUMBER. Skipping." + exit 0 + fi + + # ── Request the reviewer ───────────────────────────────────────── + log "auto-reviewer: requesting $SELECTED ($REASON)" + if ! gh api -X POST "repos/$REPO/pulls/$PR_NUMBER/requested_reviewers" \ + -f "reviewers[]=$SELECTED" >/dev/null 2>err.log; then + ERR=$(cat err.log || true) + warn "auto-reviewer: failed to request $SELECTED: $ERR" + comment "🤖 **Auto-reviewer:** tried to request review from \`@$SELECTED\` but the API rejected it (likely not a collaborator on this repo). Please assign a reviewer manually." + exit 0 + fi + + comment "🤖 **Auto-reviewer:** requested review from @$SELECTED — $REASON." + log "auto-reviewer: success." diff --git a/.github/workflows/validate-review-map.yml b/.github/workflows/validate-review-map.yml new file mode 100644 index 0000000..7ba643a --- /dev/null +++ b/.github/workflows/validate-review-map.yml @@ -0,0 +1,155 @@ +name: Validate review-map.yml + +# Lints review-map.yml on PRs that touch it. Verifies: +# - YAML is valid +# - Required top-level fields exist +# - Every github_login referenced is a real LiskHQ org member +# - Every domain key in specialists is one of: backend|web|infra|mobile|contracts +# - Every squad name in sibling_fallback_order exists under .squads +# - Each squad's TL is also in members (when not null) + +on: + pull_request: + paths: + - review-map.yml + - .github/workflows/validate-review-map.yml + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install yq and convert map to JSON + run: | + set -euo pipefail + if ! command -v yq >/dev/null 2>&1; then + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + fi + yq --version + yq -o=json '.' review-map.yml > /tmp/review-map.json + jq -e . /tmp/review-map.json >/dev/null + echo "review-map.json built ($(wc -c < /tmp/review-map.json) bytes)" + + - name: Validate structure + env: + MAP_JSON: /tmp/review-map.json + run: | + set -euo pipefail + fail=0 + err() { echo "::error file=review-map.yml::$*"; fail=1; } + + # Top-level fields + for f in enabled enabled_repos bot_authors sibling_fallback_order squads; do + if [ "$(jq -r --arg f "$f" '. | has($f)' "$MAP_JSON")" != "true" ]; then + err "missing required top-level field: $f" + fi + done + + # Domain keys must be from the allowed set + ALLOWED_DOMAINS="backend web infra mobile contracts" + while read -r d; do + [ -z "$d" ] && continue + case " $ALLOWED_DOMAINS " in + *" $d "*) ;; + *) err "unknown domain key in specialists: '$d' (allowed: $ALLOWED_DOMAINS)" ;; + esac + done < <(jq -r '[.squads[].specialists | keys[]] | unique | .[]' "$MAP_JSON") + + # sibling_fallback_order must reference existing squads + SQUADS=$(jq -r '.squads | keys[]' "$MAP_JSON") + while read -r s; do + [ -z "$s" ] && continue + if ! printf '%s\n' "$SQUADS" | grep -Fxq "$s"; then + err "sibling_fallback_order references unknown squad: '$s'" + fi + done < <(jq -r '.sibling_fallback_order[]' "$MAP_JSON") + + # Every squad in .squads should be in sibling_fallback_order (and vice versa). + ORDER=$(jq -r '.sibling_fallback_order[]' "$MAP_JSON") + while read -r s; do + [ -z "$s" ] && continue + if ! printf '%s\n' "$ORDER" | grep -Fxq "$s"; then + err "squad '$s' missing from sibling_fallback_order" + fi + done <<<"$SQUADS" + + # TL (when non-null) must appear in members + while IFS=$'\t' read -r squad tl; do + [ "$tl" = "null" ] || [ -z "$tl" ] && continue + if ! jq -r --arg s "$squad" '.squads[$s].members[]' "$MAP_JSON" | grep -Fxq "$tl"; then + err "squad '$squad' TL '$tl' is not in its members list" + fi + done < <(jq -r '.squads | to_entries[] | [.key, (.value.tl // "")] | @tsv' "$MAP_JSON") + + # fallback_to_squad_members_for entries must be valid domains + while IFS=$'\t' read -r squad domain; do + [ -z "$domain" ] && continue + case " $ALLOWED_DOMAINS " in + *" $domain "*) ;; + *) err "squad '$squad' fallback_to_squad_members_for contains unknown domain: '$domain'" ;; + esac + done < <(jq -r '.squads | to_entries[] | .key as $s | (.value.fallback_to_squad_members_for // [])[] | [$s, .] | @tsv' "$MAP_JSON") + + # Every specialist login should also appear in the squad's members list + while IFS=$'\t' read -r squad login; do + [ -z "$login" ] && continue + if ! jq -r --arg s "$squad" '.squads[$s].members[]' "$MAP_JSON" | grep -Fxq "$login"; then + err "squad '$squad' specialist '$login' is not in its members list" + fi + done < <(jq -r ' + .squads | to_entries[] | .key as $s + | (.value.specialists // {} | to_entries[] | .value[]) as $login + | [$s, $login] | @tsv' "$MAP_JSON") + + if [ "$fail" -ne 0 ]; then + exit 1 + fi + echo "structure: OK" + + - name: Verify all logins are LiskHQ org members + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MAP_JSON: /tmp/review-map.json + run: | + set -euo pipefail + + # Collect every github_login referenced anywhere in the map + LOGINS=$( + jq -r ' + [ + (.bot_authors // [])[], + (.squads[].members // [])[], + (.squads[].tl // empty), + (.squads[].specialists // {} | to_entries[].value[]) + ] | unique | .[]' "$MAP_JSON" + ) + + # Bots and known external collaborators are exempt from org-member check. + EXEMPT=$(jq -r '.bot_authors[]' "$MAP_JSON" | sort -u) + + fail=0 + while read -r login; do + [ -z "$login" ] && continue + # Skip bot logins (anything ending in [bot]) and exempt list + case "$login" in + *"[bot]") continue ;; + esac + if printf '%s\n' "$EXEMPT" | grep -Fxq "$login"; then + continue + fi + if ! gh api "/orgs/LiskHQ/members/$login" --silent 2>/dev/null; then + echo "::error file=review-map.yml::login '$login' is not a public member of LiskHQ org (or token cannot see them)" + fail=1 + fi + done <<<"$LOGINS" + + if [ "$fail" -ne 0 ]; then + echo "::warning::Some logins could not be verified. If these are private org members, the default GITHUB_TOKEN may not see them — verify manually." + exit 1 + fi + echo "logins: OK" diff --git a/README.md b/README.md index 6008819..55f6646 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ Reusable GitHub Actions workflows for Claude Code reviews. Each repo defines its - **Does**: Flexible assistant - explain code, investigate bugs, write docs, answer questions - **Example**: See `examples/workflows/claude-interactive-caller.yml` +### 4. Auto-assign Reviewer +- **File**: `auto-assign-reviewer.yml` +- **Trigger**: PR `opened` or `ready_for_review` (skips drafts, bots, and authors not in any squad) +- **Does**: Picks a reviewer using `review-map.yml` (squad × domain matrix with sibling cascade) and requests review via the GitHub API. Soft-fails — never blocks merges. +- **Config**: [`review-map.yml`](./review-map.yml) at repo root +- **Docs**: [`docs/auto-reviewer.md`](./docs/auto-reviewer.md) +- **Example**: See `examples/workflows/auto-assign-reviewer-caller.yml` + ## CLAUDE.md (Optional) Create in repo root to define review priorities: diff --git a/docs/auto-reviewer.md b/docs/auto-reviewer.md new file mode 100644 index 0000000..7e3682e --- /dev/null +++ b/docs/auto-reviewer.md @@ -0,0 +1,115 @@ +# Auto-Reviewer + +Cross-domain PR reviewer assignment for LiskHQ product repos. When an engineer +opens a PR in `lisk-backend`, `lisk-web`, `lisk-infra`, `lisk-mobile`, or +`lisk-contracts`, this workflow picks a reviewer based on the engineer's squad +and the repo's domain. + +## Why + +CODEOWNERS routes by file path; team review distributes within a chosen team. +Neither can express "given the author's squad, find the right domain +specialist." This workflow does that with one shared map. + +## Files + +- [`review-map.yml`](../review-map.yml) — squad / specialist / fallback + configuration. Single source of truth. +- [`.github/workflows/auto-assign-reviewer.yml`](../.github/workflows/auto-assign-reviewer.yml) + — reusable workflow with the cascade logic. +- [`.github/workflows/validate-review-map.yml`](../.github/workflows/validate-review-map.yml) + — CI lint that runs on PRs touching the map. +- [`examples/workflows/auto-assign-reviewer-caller.yml`](../examples/workflows/auto-assign-reviewer-caller.yml) + — caller stub each product repo copies, varying only `domain:`. + +## Cascade + +Given PR author `A`, repo domain `D`, author squad `S`: + +1. **Own-squad specialists.** If `S.specialists[D]` minus `A` is non-empty, + pick `eligible[PR# % len]`. +2. **Squad-wide fallback.** If `D ∈ S.fallback_to_squad_members_for` and step 1 + produced nothing, pick from `S.members` minus `A`. +3. **Sibling cascade.** Walk `sibling_fallback_order`. For each sibling squad + `T` (`T ≠ S`), if `T.specialists[D]` minus `A` is non-empty, pick + `eligible[PR# % len]`. +4. **Soft-fail.** Post a PR comment asking for manual assignment. Workflow + succeeds (never blocks merge). + +`PR# % N` gives stateless deterministic round-robin. Author exclusion happens +before modulo, so distribution stays balanced. + +## Squad / domain matrix + +| Squad | backend | web | infra | mobile | contracts | +|-----------|------------------------|-------------------------------------|------------|-------------------------|-----------| +| platform | sameersubudhi, ishantiw| — | Nazgolze | — | — | +| money | vardan10 | — | — | oskarleonard, Balanced02, eniolam1000752, 5heri | — | +| org | matjazv | mmarinovic, mvuco00, ikem-legend, mislavtomic | — | — | matjazv | +| global | — | — | — | — | ricott1 | + +`—` = no own-squad specialist; cascades to siblings. + +`platform.fallback_to_squad_members_for: [infra]` — when Nazgolze opens an +infra PR, fall back to any other platform member instead of jumping to +siblings. + +`sibling_fallback_order: [money, org, platform, global]`. + +## Updating the map + +1. Open a PR editing `review-map.yml`. +2. The `validate-review-map.yml` CI checks structure and that every + GitHub login is an `LiskHQ` org member. +3. Squad TL approves their own squad's section. +4. Merge. + +## Kill switches + +- **All assignment off:** set `enabled: false` in `review-map.yml`. +- **One repo off:** remove it from `enabled_repos`. +- **Skip a specific author:** add login to `bot_authors`. + +All take effect on the next PR open with no other action. + +## What does and doesn't fail the workflow + +The workflow is best-effort. It always exits 0 (so it never blocks merges) +even when: + +- The author isn't in any squad. +- No eligible reviewer is found (posts a comment instead). +- The reviewer-request API rejects the call (e.g., user isn't a + collaborator on the target repo). + +The workflow exits non-zero only when the `domain:` input is invalid (bad +caller config) — that's a configuration error worth surfacing. + +## Triggers + +`pull_request_target` on `[opened, ready_for_review]`. We don't fire on +`synchronize` (every push) — that's noise; once a reviewer is requested they +stay requested. Drafts are skipped via `if: github.event.pull_request.draft == +false` in the caller. + +`pull_request_target` is used so PRs from forks work — the workflow only +calls the GitHub API and never checks out PR code, so the security +considerations of `pull_request_target` don't apply here. + +## Adding a new repo / domain + +1. Add the repo to `enabled_repos` in `review-map.yml`. +2. If it introduces a new domain (not one of the existing 5), update: + - The `domain` input enum in `auto-assign-reviewer.yml`. + - The allowed-domains list in `validate-review-map.yml`. + - Each squad's `specialists` map (add the new domain key where + applicable). +3. Copy `examples/workflows/auto-assign-reviewer-caller.yml` into the new + repo's `.github/workflows/`, set `domain:` accordingly. + +## Adding a new squad + +1. Add the squad block under `squads:` in `review-map.yml`. +2. Add the squad name to `sibling_fallback_order` (position determines + priority). +3. List members and (optionally) per-domain specialists. diff --git a/examples/workflows/auto-assign-reviewer-caller.yml b/examples/workflows/auto-assign-reviewer-caller.yml new file mode 100644 index 0000000..61c61d7 --- /dev/null +++ b/examples/workflows/auto-assign-reviewer-caller.yml @@ -0,0 +1,38 @@ +# Example: Call the reusable auto-assign-reviewer workflow +# Copy this file to your repo's .github/workflows/ directory. +# +# Update `domain:` to match the repo: +# lisk-backend → backend +# lisk-web → web +# lisk-infra → infra +# lisk-mobile → mobile +# lisk-contracts → contracts +# +# Triggers: +# PR opened, ready_for_review (draft → ready transition). +# Drafts are skipped automatically. +# +# Uses pull_request_target so it works on PRs from forks. The reusable +# workflow only calls the GitHub API and never checks out PR code, so this +# is safe. + +name: Auto-assign Reviewer + +on: + pull_request_target: + types: [opened, ready_for_review] + +# Prevent duplicate runs for the same PR. +concurrency: + group: auto-assign-reviewer-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + assign: + if: github.event.pull_request.draft == false + uses: LiskHQ/workflows/.github/workflows/auto-assign-reviewer.yml@main + with: + domain: backend # ← change per repo + permissions: + pull-requests: write + contents: read diff --git a/review-map.yml b/review-map.yml new file mode 100644 index 0000000..b6041d4 --- /dev/null +++ b/review-map.yml @@ -0,0 +1,81 @@ +# review-map.yml +# Source of truth for cross-domain PR review routing. +# Edits via PR. For employment data, see HR system. +# +# How it's read: the auto-assign-reviewer reusable workflow sparse-checks-out +# this file and resolves a reviewer using the cascade documented in +# docs/auto-reviewer.md. + +# Master kill switch. Set to false to disable all assignment immediately. +enabled: true + +# Per-repo enablement. Remove a repo to pause assignment there. +enabled_repos: + - lisk-backend + - lisk-web + - lisk-infra + - lisk-mobile + - lisk-contracts + +# Authors to skip entirely (no assignment, exit 0). +bot_authors: + - dependabot[bot] + - renovate[bot] + - github-actions[bot] + - claude[bot] + - copilot-pull-request-reviewer[bot] + - bmijac # external contributor on lisk-web + +# When own-squad has no eligible specialist, walk siblings in this order. +sibling_fallback_order: + - money + - org + - platform + - global + +squads: + platform: + tl: ishantiw + members: + - Nazgolze + - ishantiw + - sameersubudhi + specialists: + backend: [sameersubudhi, ishantiw] + infra: [Nazgolze] + # Domains where, if no specialist is eligible, the cascade falls back to + # any other squad member (instead of jumping straight to siblings). + fallback_to_squad_members_for: [infra] + + money: + tl: 5heri + members: + - oskarleonard + - Balanced02 + - eniolam1000752 + - vardan10 + - 5heri + specialists: + backend: [vardan10] + mobile: [oskarleonard, Balanced02, eniolam1000752, 5heri] + + org: + tl: mmarinovic + members: + - mmarinovic + - mvuco00 + - ikem-legend + - matjazv + - mislavtomic + specialists: + backend: [matjazv] + web: [mmarinovic, mvuco00, ikem-legend, mislavtomic] + contracts: [matjazv] + + global: + tl: null + members: + - shuse2 + - ricott1 + specialists: + contracts: [ricott1] From 6a497653225855f43033225afda327a5888ffb87 Mon Sep 17 00:00:00 2001 From: shuse2 Date: Wed, 29 Apr 2026 00:07:53 +0900 Subject: [PATCH 2/3] fix: drop org-membership check from validate-review-map.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default GITHUB_TOKEN cannot see private org membership via /orgs/{org}/members/{user} — that endpoint requires a PAT with read:org scope. Real LiskHQ members were being flagged as errors. Switch to /users/{login} which is public. This still catches typos (the main goal of the lint). Org-membership enforcement isn't strictly necessary: if a non-collaborator is listed, the auto-assign workflow soft-fails with a PR comment at reviewer-request time. --- .github/workflows/validate-review-map.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/validate-review-map.yml b/.github/workflows/validate-review-map.yml index 7ba643a..976442d 100644 --- a/.github/workflows/validate-review-map.yml +++ b/.github/workflows/validate-review-map.yml @@ -111,14 +111,19 @@ jobs: fi echo "structure: OK" - - name: Verify all logins are LiskHQ org members + - name: Verify all logins are real GitHub users env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} MAP_JSON: /tmp/review-map.json run: | set -euo pipefail - # Collect every github_login referenced anywhere in the map + # Catches typos (non-existent logins). Does NOT enforce org + # membership — that requires a PAT with read:org scope, which + # GITHUB_TOKEN does not have. If a non-collaborator is listed, + # the auto-assign workflow will soft-fail with a PR comment at + # request time rather than blocking the map PR here. + LOGINS=$( jq -r ' [ @@ -129,27 +134,26 @@ jobs: ] | unique | .[]' "$MAP_JSON" ) - # Bots and known external collaborators are exempt from org-member check. + # Bots and external collaborators in bot_authors are exempt — they + # may not have stable user pages or may be auto-generated. EXEMPT=$(jq -r '.bot_authors[]' "$MAP_JSON" | sort -u) fail=0 while read -r login; do [ -z "$login" ] && continue - # Skip bot logins (anything ending in [bot]) and exempt list case "$login" in *"[bot]") continue ;; esac if printf '%s\n' "$EXEMPT" | grep -Fxq "$login"; then continue fi - if ! gh api "/orgs/LiskHQ/members/$login" --silent 2>/dev/null; then - echo "::error file=review-map.yml::login '$login' is not a public member of LiskHQ org (or token cannot see them)" + if ! gh api "/users/$login" --silent 2>/dev/null; then + echo "::error file=review-map.yml::login '$login' is not a real GitHub user (typo?)" fail=1 fi done <<<"$LOGINS" if [ "$fail" -ne 0 ]; then - echo "::warning::Some logins could not be verified. If these are private org members, the default GITHUB_TOKEN may not see them — verify manually." exit 1 fi echo "logins: OK" From efac568ab2ff41cfcbeb3be4fc6861250c695b6a Mon Sep 17 00:00:00 2001 From: shuse2 Date: Wed, 29 Apr 2026 00:30:22 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20add=20domain=5Fequivalence=20so=20w?= =?UTF-8?q?eb=20=E2=86=94=20mobile=20stays=20in-squad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an org engineer opens a mobile PR, route to org's web specialists instead of cascading to money. Same in reverse for money → web. Web and mobile are interchangeable review skills within a squad. Cascade now has a Step 2 that walks domain_equivalence[D] and tries own-squad specialists for each equivalent domain before falling back to the sibling cascade. Equivalence is intra-squad only; cross-squad cascade still uses the actual domain. Examples (verified): ikem-legend (org) + mobile → org.web specialists (mvuco00 / mislavtomic / mmarinovic) oskarleonard (money)+ web → money.mobile specialists (Balanced02 / eniolam1000752 / 5heri) matjazv (org) + mobile → org.web rotation Nazgolze (platform) + web → still cascades to org (platform has no web or mobile) Validates that domain_equivalence keys/values are real domains. --- .github/workflows/auto-assign-reviewer.yml | 34 ++++++++++++++++++---- .github/workflows/validate-review-map.yml | 13 +++++++++ docs/auto-reviewer.md | 26 +++++++++++++---- review-map.yml | 11 ++++++- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/.github/workflows/auto-assign-reviewer.yml b/.github/workflows/auto-assign-reviewer.yml index 859b356..8167919 100644 --- a/.github/workflows/auto-assign-reviewer.yml +++ b/.github/workflows/auto-assign-reviewer.yml @@ -6,9 +6,10 @@ name: Auto-assign Reviewer (Reusable) # # Cascade (see docs/auto-reviewer.md): # 1. own-squad specialists for the domain (round-robin via PR# % N) -# 2. for fallback_to_squad_members_for domains: any other squad member -# 3. sibling squads in sibling_fallback_order -# 4. soft-fail with PR comment +# 2. own-squad specialists for an equivalent domain (web ↔ mobile) +# 3. for fallback_to_squad_members_for domains: any other squad member +# 4. sibling squads in sibling_fallback_order (actual domain only) +# 5. soft-fail with PR comment on: workflow_call: @@ -134,7 +135,28 @@ jobs: fi fi - # Step 2: squad-wide fallback for designated domains (e.g., infra) + # Step 2: own-squad equivalent-domain specialists + # E.g., for org's mobile PR, try org's web specialists since web ↔ + # mobile are equivalent. Keeps the review inside the squad. + if [ -z "$SELECTED" ]; then + EQUIV_DOMAINS=$(jq -r --arg d "$DOMAIN" ' + .domain_equivalence[$d] // [] | .[]' "$MAP_JSON") + for EQ in $EQUIV_DOMAINS; do + EQ_LIST=$(jq -r --arg s "$AUTHOR_SQUAD" --arg d "$EQ" ' + .squads[$s].specialists[$d] // [] | .[]' "$MAP_JSON") + [ -z "$EQ_LIST" ] && continue + CAND=$(printf '%s\n' "$EQ_LIST" | grep -v -Fx "$PR_AUTHOR" || true) + if [ -n "$CAND" ]; then + COUNT=$(printf '%s\n' "$CAND" | wc -l | tr -d ' ') + IDX=$(( PR_NUMBER % COUNT )) + SELECTED=$(printf '%s\n' "$CAND" | sed -n "$((IDX + 1))p") + REASON="own-squad ($AUTHOR_SQUAD) $EQ specialist (equivalent to $DOMAIN)" + break + fi + done + fi + + # Step 3: squad-wide fallback for designated domains (e.g., infra) if [ -z "$SELECTED" ]; then FB=$(jq -r --arg s "$AUTHOR_SQUAD" ' .squads[$s].fallback_to_squad_members_for // [] | .[]' "$MAP_JSON") @@ -150,7 +172,7 @@ jobs: fi fi - # Step 3: sibling cascade + # Step 4: sibling cascade if [ -z "$SELECTED" ]; then ORDER=$(jq -r '.sibling_fallback_order[]' "$MAP_JSON") for SIB in $ORDER; do @@ -173,7 +195,7 @@ jobs: done fi - # Step 4: soft-fail + # Step 5: soft-fail if [ -z "$SELECTED" ]; then warn "auto-reviewer: no eligible reviewer found for $PR_AUTHOR / $DOMAIN." comment "🤖 **Auto-reviewer:** could not find an eligible reviewer for domain \`$DOMAIN\` from author \`$PR_AUTHOR\` (squad \`$AUTHOR_SQUAD\`). Please assign a reviewer manually." diff --git a/.github/workflows/validate-review-map.yml b/.github/workflows/validate-review-map.yml index 976442d..22ca2f2 100644 --- a/.github/workflows/validate-review-map.yml +++ b/.github/workflows/validate-review-map.yml @@ -95,6 +95,19 @@ jobs: esac done < <(jq -r '.squads | to_entries[] | .key as $s | (.value.fallback_to_squad_members_for // [])[] | [$s, .] | @tsv' "$MAP_JSON") + # domain_equivalence keys and values must all be valid domains + while IFS=$'\t' read -r k v; do + [ -z "$k" ] && continue + for d in "$k" "$v"; do + case " $ALLOWED_DOMAINS " in + *" $d "*) ;; + *) err "domain_equivalence references unknown domain: '$d'" ;; + esac + done + done < <(jq -r ' + (.domain_equivalence // {}) | to_entries[] + | .key as $k | .value[] | [$k, .] | @tsv' "$MAP_JSON") + # Every specialist login should also appear in the squad's members list while IFS=$'\t' read -r squad login; do [ -z "$login" ] && continue diff --git a/docs/auto-reviewer.md b/docs/auto-reviewer.md index 7e3682e..5a8205f 100644 --- a/docs/auto-reviewer.md +++ b/docs/auto-reviewer.md @@ -28,17 +28,33 @@ Given PR author `A`, repo domain `D`, author squad `S`: 1. **Own-squad specialists.** If `S.specialists[D]` minus `A` is non-empty, pick `eligible[PR# % len]`. -2. **Squad-wide fallback.** If `D ∈ S.fallback_to_squad_members_for` and step 1 - produced nothing, pick from `S.members` minus `A`. -3. **Sibling cascade.** Walk `sibling_fallback_order`. For each sibling squad +2. **Own-squad equivalent domain.** For each `E` in `domain_equivalence[D]`, + if `S.specialists[E]` minus `A` is non-empty, pick `eligible[PR# % len]`. + Keeps the review inside the squad when web/mobile are interchangeable. +3. **Squad-wide fallback.** If `D ∈ S.fallback_to_squad_members_for` and prior + steps produced nothing, pick from `S.members` minus `A`. +4. **Sibling cascade.** Walk `sibling_fallback_order`. For each sibling squad `T` (`T ≠ S`), if `T.specialists[D]` minus `A` is non-empty, pick - `eligible[PR# % len]`. -4. **Soft-fail.** Post a PR comment asking for manual assignment. Workflow + `eligible[PR# % len]`. Equivalence is **not** applied cross-squad. +5. **Soft-fail.** Post a PR comment asking for manual assignment. Workflow succeeds (never blocks merge). `PR# % N` gives stateless deterministic round-robin. Author exclusion happens before modulo, so distribution stays balanced. +### Domain equivalence + +```yaml +domain_equivalence: + web: [mobile] + mobile: [web] +``` + +Web and mobile are treated as interchangeable skills *within* a squad — when +an org engineer opens a mobile PR, org's web specialists handle it (instead +of cascading to money). Equivalence is intra-squad only; cross-squad +cascade always uses the actual domain. + ## Squad / domain matrix | Squad | backend | web | infra | mobile | contracts | diff --git a/review-map.yml b/review-map.yml index b6041d4..052b844 100644 --- a/review-map.yml +++ b/review-map.yml @@ -26,7 +26,16 @@ bot_authors: - copilot-pull-request-reviewer[bot] - bmijac # external contributor on lisk-web -# When own-squad has no eligible specialist, walk siblings in this order. +# Domain equivalence — within a squad, specialists for an equivalent domain +# may review when no specialist exists for the requested domain. This stays +# the review inside the squad. Equivalence does NOT apply to sibling cascade +# (cross-squad routing always uses the actual domain). +domain_equivalence: + web: [mobile] + mobile: [web] + +# When own-squad has no eligible specialist (and no equivalent), walk +# siblings in this order. sibling_fallback_order: - money - org