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
222 changes: 222 additions & 0 deletions .github/workflows/auto-assign-reviewer.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
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. 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:
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: 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")
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 4: 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 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."
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."
172 changes: 172 additions & 0 deletions .github/workflows/validate-review-map.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
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")

# 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
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 real GitHub users
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MAP_JSON: /tmp/review-map.json
run: |
set -euo pipefail

# 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 '
[
(.bot_authors // [])[],
(.squads[].members // [])[],
(.squads[].tl // empty),
(.squads[].specialists // {} | to_entries[].value[])
] | unique | .[]' "$MAP_JSON"
)

# 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
case "$login" in
*"[bot]") continue ;;
esac
if printf '%s\n' "$EXEMPT" | grep -Fxq "$login"; then
continue
fi
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
exit 1
fi
echo "logins: OK"
Loading
Loading