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
69 changes: 51 additions & 18 deletions .agents/skills/create_pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,13 @@ gh pr create --web

So the mention stays, and a real request is added alongside it. **A PR is not complete until `gh pr edit --add-reviewer` has succeeded and been verified.**

A resolution failure must fall back, never no-op. When no owner resolves, assign `dannyneira`, matching the fallback the release docs workflow already uses (`.github/workflows/release-docs-update.yml`, "Assign last docs PR reviewer"). An unassignable reviewer is a problem to surface, not a reason to ship an unreviewed PR.
A resolution failure must fall back, never no-op. When no owner resolves — the common case for a small, ambient/agent-generated docs PR — prefer the run's requester next, then a secondary human fallback, and only then `dannyneira` as the final safety net (the release docs workflow keeps its own last-resort `dannyneira` fallback too — `.github/workflows/release-docs-update.yml`, "Assign last docs PR reviewer"). An unassignable reviewer is a problem to surface, not a reason to ship an unreviewed PR.

The full priority chain, in order: (1) the CODEOWNERS/git-blame owner from `suggest_reviewers.py`; (2) the run's requester, resolved via this repo's own `.agents/skills/create_pr/resolve_reviewer.py --user <requester_slack_id>` and a runtime-supplied private override map; (3) a secondary human fallback, currently `hongyi-chen` ("HYC"); (4) `dannyneira`.

Step 2's resolver is a docs-repo-local script, not `factory-agents`' `scripts/factory-resolve-reviewer`: that script lives in the separate `factory-agents` repo and is not checked out alongside a normal `warpdotdev/docs` clone, so calling it by that relative path fails in a real docs run and silently falls through to the next tier. The invoking factory must mount its private Slack-to-GitHub override map and set `REVIEWER_OVERRIDES_PATH` before calling `resolve_reviewer.py`; never commit Slack user IDs or mappings to this repository. The helper does nothing else — no public-email search, no cross-repo assumptions — so the requester tier resolves from a plain docs checkout when that private runtime context is available. Like `factory-resolve-reviewer`, it never guesses: an unavailable map or unresolved Slack id prints nothing and the chain moves to the next tier.

The factory-level private override map owns requester identity mappings. This repository stores no real Slack IDs: keep `REQUESTER_SLACK_ID` as a runtime value and use a placeholder in examples and tests.

Two details below are load-bearing, and getting either wrong reintroduces the silent drop this section exists to prevent:

Expand All @@ -286,7 +292,9 @@ Two details below are load-bearing, and getting either wrong reintroduces the si

```bash
PR=123
FALLBACK_REVIEWER=dannyneira
REQUESTER_SLACK_ID="" # this run's requester Slack user id, when known
SECONDARY_FALLBACK_REVIEWER=hongyi-chen # HYC - confirmed second-tier fallback
FALLBACK_REVIEWER=dannyneira # final safety net; never remove

# 1. Resolve the owning engineer(s). For missing_docs drift-watch runs, use the
# ownership resolver with the source files behind the change; see the
Expand All @@ -296,13 +304,26 @@ REVIEWERS=$(python3 .agents/skills/missing_docs/scripts/suggest_reviewers.py \
--reviewers-only --warp ../warp --warp-server ../warp-server \
warp:app/src/settings/ssh.rs < /dev/null)

# 2. Never let an empty resolution drop the request. Track that this was a
# fallback so step 6 does not report it as an owner who was requested.
# 2. No code-owner resolved. For a small, ambient/agent-generated PR with no
# clear owner - the common case here - prefer the run's requester over
# paging dannyneira: resolve their GitHub handle with this docs-local helper
# and the invoking factory's private REVIEWER_OVERRIDES_PATH map. The helper
# is callable from a plain docs checkout, unlike factory-agents'
# scripts/factory-resolve-reviewer, which lives in a separate repo that
# isn't checked out alongside this one.
# Fall to the secondary human fallback next, and only then to the final
# dannyneira safety net. Never let an empty resolution drop the request.
# Track that this was a fallback so step 6 does not report it as an owner
# who was requested.
RESOLUTION_WAS_EMPTY=0
if [[ -z "$REVIEWERS" ]]; then
echo "warning: no owner resolved - falling back to $FALLBACK_REVIEWER"
REVIEWERS="$FALLBACK_REVIEWER"
RESOLUTION_WAS_EMPTY=1
if [[ -n "$REQUESTER_SLACK_ID" ]]; then
REVIEWERS=$(python3 .agents/skills/create_pr/resolve_reviewer.py --user "$REQUESTER_SLACK_ID")
fi
[[ -z "$REVIEWERS" ]] && REVIEWERS="$SECONDARY_FALLBACK_REVIEWER"
[[ -z "$REVIEWERS" ]] && REVIEWERS="$FALLBACK_REVIEWER"
echo "warning: no owner resolved - falling back to $REVIEWERS"
fi

# 3. Request each reviewer separately so one bad entry cannot drop the rest.
Expand Down Expand Up @@ -350,24 +371,35 @@ has_reviewer() {
# e.g. by a human) makes $REQUESTED non-empty even though the fallback was
# never assigned, which would skip re-requesting it here and then have the
# next step falsely report it as requested when it never landed.
if (( RESOLUTION_WAS_EMPTY )); then
if ! has_reviewer "$FALLBACK_REVIEWER"; then
gh pr edit "$PR" --repo warpdotdev/docs --add-reviewer "$FALLBACK_REVIEWER" ||
echo "warning: fallback $FALLBACK_REVIEWER could not be requested"
request_fallback() {
local candidate="$1"
if ! has_reviewer "$candidate"; then
gh pr edit "$PR" --repo warpdotdev/docs --add-reviewer "$candidate" ||
echo "warning: fallback $candidate could not be requested"
REQUESTED=$(read_requested)
fi
}

if (( RESOLUTION_WAS_EMPTY )); then
request_fallback "$REVIEWERS"
if [[ "$REVIEWERS" != "$FALLBACK_REVIEWER" ]] && ! has_reviewer "$REVIEWERS"; then
# The settled-on fallback (requester tier or HYC) was rejected - a
# rejection at this tier must still reach the final dannyneira safety net
# rather than stopping here.
echo "warning: $REVIEWERS rejected - advancing to final fallback $FALLBACK_REVIEWER"
REVIEWERS="$FALLBACK_REVIEWER"
request_fallback "$REVIEWERS"
fi
elif [[ -z "$REQUESTED" ]]; then
gh pr edit "$PR" --repo warpdotdev/docs --add-reviewer "$FALLBACK_REVIEWER" ||
echo "warning: fallback $FALLBACK_REVIEWER could not be requested"
REQUESTED=$(read_requested)
request_fallback "$FALLBACK_REVIEWER"
fi

if [[ -z "$REQUESTED" ]]; then
echo "ERROR: no reviewer is on PR $PR - not even the fallback landed"
exit 1
fi
if (( RESOLUTION_WAS_EMPTY )) && ! has_reviewer "$FALLBACK_REVIEWER"; then
echo "ERROR: fallback $FALLBACK_REVIEWER could not be requested on PR $PR" \
if (( RESOLUTION_WAS_EMPTY )) && ! has_reviewer "$REVIEWERS"; then
echo "ERROR: fallback $REVIEWERS could not be requested on PR $PR" \
"(existing reviewers: $REQUESTED); report this run as failed."
exit 1
fi
Expand All @@ -384,9 +416,10 @@ for R in "${WANT[@]}"; do
done

if (( RESOLUTION_WAS_EMPTY )); then
# Step 6 already guaranteed the fallback landed (or exited above), so this
# always reports a true outcome, not just "nothing resolved."
echo "note: no owner resolved for PR $PR; fallback $FALLBACK_REVIEWER requested"
# Step 6 already guaranteed the settled-on fallback landed (or exited
# above), so this always reports a true outcome, not just "nothing
# resolved."
echo "note: no owner resolved for PR $PR; fallback $REVIEWERS requested"
elif (( ${#MISSING[@]} == ${#WANT[@]} )); then
# Owners resolved and none of them are on the PR. It has a reviewer, but not
# the right one, and that must not read as success.
Expand Down
78 changes: 78 additions & 0 deletions .agents/skills/create_pr/resolve_reviewer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Resolve a task requester's Slack user id to a GitHub handle, for this repo.

This docs-repo-local helper covers only the requester tier. Its private
Slack-to-GitHub mapping is supplied at runtime through
`REVIEWER_OVERRIDES_PATH`; it is intentionally not committed to this
repository. This keeps requester identity data in the factory-level private
override map while allowing a normal docs checkout to resolve a requester when
that map is mounted by the invoking environment.

It intentionally does the bare minimum and nothing more — no public-email
search, no cross-repo assumptions, no guessing. A missing map or unresolved
Slack id prints nothing (exit 0) and the caller's chain moves to the next tier.

Usage:
REVIEWER_OVERRIDES_PATH=/private/path/reviewer_overrides.json \
python3 resolve_reviewer.py --user <slack_id>

Prints the resolved GitHub handle to stdout, or nothing when unresolved.
"""
import argparse
import json
import os
import sys


def load_overrides(path):
"""Return a {slack_id: github_handle} map from a private override map.

Returns an empty map when its runtime path is absent, missing, malformed,
or has no usable entries — a missing override is not an error, it just
fails to resolve.
"""
if not path:
return {}
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (FileNotFoundError, OSError, json.JSONDecodeError):
return {}
users = data.get("users") if isinstance(data, dict) else None
if not isinstance(users, list):
return {}
indexed = {}
for user in users:
if not isinstance(user, dict):
continue
slack_id = str(user.get("slack_id", "")).strip()
github = str(user.get("github", "")).strip()
if slack_id and github:
indexed[slack_id] = github
return indexed


def main(argv=None):
parser = argparse.ArgumentParser(
prog="resolve_reviewer.py",
description="Resolve a Slack user id to a GitHub handle via a private override map.",
)
parser.add_argument("--user", dest="user", help="Slack user id to resolve.")
parser.add_argument(
"--overrides",
help="Private override-map path; defaults to REVIEWER_OVERRIDES_PATH.",
)
args = parser.parse_args(argv)

if not args.user:
return 0
handle = load_overrides(
args.overrides or os.environ.get("REVIEWER_OVERRIDES_PATH")
).get(args.user)
if handle:
print(handle)
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading