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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ jobs:
ai/tests/test-log-step-done.sh
ai/skills/ran/scripts/tests/test-ran-report.sh
ai/helpers/tests/test-repo-context.sh
bin/lib/test-git-pr.sh
2 changes: 1 addition & 1 deletion ai/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Write as the user in all public-facing content. Don't refer to yourself as an AI

**Always use `gh` CLI** for GitHub operations. Never use GitHub MCP server tools.

**Never post PR review comments without explicit user approval.** See the `github-pr-operations` skill for endpoint reference and thread-resolution commands.
**Never post a PR review comment or a reply to a human reviewer without explicit user approval.** A reply to a bot reviewer's comment (Copilot, ReviewHog, Greptile, Graphite) may also go out unasked under a skill's explicit `--unattended` mode, which is what lets an unattended sweep leave bot threads resolved. See the `github-pr-operations` skill for endpoint reference and thread-resolution commands.

## Project-Specific Workflow

Expand Down
3 changes: 3 additions & 0 deletions ai/bin/log-command.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ step=$(canonical_step "$raw") || exit 0
derive_org_repo || exit 0
repo_context_is_path_safe || exit 0

# Not resolve_branch_name: this hook fires on every prompt in every repo and
# must never make a network call. A detached checkout loses the started record
# and keeps the completion one log-step-done.sh writes.
branch=$(git branch --show-current 2> /dev/null)
[ -n "$branch" ] || exit 0
log_file=$(command_log_path "$REPO_ORG" "$REPO_REPO" "$branch") || exit 0
Expand Down
4 changes: 2 additions & 2 deletions ai/bin/log-step-done.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ command_step_declared "$step" || die "'$step' is not a step in COMMAND_STEP_TABL
derive_org_repo || die "not a GitHub repository"
repo_context_is_path_safe || die "org or repo is not safe as a path component"

branch=$(git branch --show-current 2> /dev/null)
[ -n "$branch" ] || die "detached HEAD: no branch to record against"
branch=$(resolve_branch_name network) ||
die "no branch to record against: HEAD is detached, RAN_BRANCH is unset, and no PR has this commit as its head"
log_file=$(command_log_path "$REPO_ORG" "$REPO_REPO" "$branch") || die "branch name has no safe log filename"
sha=$(git rev-parse --short HEAD 2> /dev/null) || die "no commits on this branch"

Expand Down
48 changes: 47 additions & 1 deletion ai/helpers/repo-context.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Derive the GitHub org/repo of the current directory's origin remote.
# Name the GitHub org, repo, and branch of the current directory's checkout.
#
# Usage: source repo-context.sh, then: derive_org_repo || <no-github fallback>
#
Expand All @@ -25,6 +25,52 @@ derive_org_repo() {
return 1
}

# Name the branch this checkout's work belongs to, and print it.
#
# An agent harness (PostHog Desktop, cloud runners) checks the PR head out
# detached, where `git branch --show-current` is empty. RAN_BRANCH lets a caller
# that already resolved the PR skip the last tier. Pass "network" to allow that
# tier, which asks GitHub which PR has HEAD as its head commit; a caller that
# must never touch the network omits the argument and gets the other tiers.
#
# A branch resolved through RAN_BRANCH or the network tier is cached in a file
# under this checkout's private git dir. That location is worktree-specific
# even for a linked worktree. A later call in the same checkout reads the
# cache instead of asking for RAN_BRANCH again or hitting the network. This is
# what lets `/ran` read a detached checkout's branch after a commit has moved
# HEAD off the PR head it was resolved from.
#
# Usage: resolve_branch_name [network]
# Returns 1 when no tier answers.
resolve_branch_name() {
local branch head_sha git_dir cache_file
branch=$(git branch --show-current 2> /dev/null) || branch=""
if [ -z "$branch" ]; then
git_dir=$(git rev-parse --git-dir 2> /dev/null) || git_dir=""
cache_file="${git_dir:+${git_dir}/ran-branch}"
branch="${RAN_BRANCH:-}"
[ -n "$branch" ] || [ -z "$cache_file" ] || branch=$(cat "$cache_file" 2> /dev/null) || branch=""
if [ -z "$branch" ] && [ "${1:-}" = "network" ]; then
head_sha=$(git rev-parse HEAD 2> /dev/null) || head_sha=""
if [ -n "$head_sha" ]; then
# The endpoint also lists PRs this commit merged into, so a detached
# main would otherwise resolve to whatever landed last. Only an exact
# head.sha match is this commit's own PR.
branch=$(env GIT_PR_HEAD_SHA="$head_sha" GH_PAGER= \
gh api "repos/{owner}/{repo}/commits/${head_sha}/pulls" \
--jq '[.[] | select(.head.sha == $ENV.GIT_PR_HEAD_SHA)] | sort_by(.state != "open") | .[0].head.ref' \
2> /dev/null) || branch=""
if [ "$branch" = "null" ]; then
branch=""
fi
fi
fi
[ -z "$branch" ] || [ -z "$cache_file" ] || printf '%s\n' "$branch" > "$cache_file" 2> /dev/null || true
fi
[ -n "$branch" ] || return 1
printf '%s\n' "$branch"
}

# The captures come from a remote URL, which whoever set the remote controls:
# git@github.com:../evil.git parses as an org of "..". Any caller that builds a
# filesystem path out of REPO_ORG/REPO_REPO must pass this first, or the path
Expand Down
95 changes: 94 additions & 1 deletion ai/helpers/tests/test-repo-context.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# Tests for derive_org_repo across origin URL shapes.
# Tests for derive_org_repo across origin URL shapes, and for resolve_branch_name
# across its tiers.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand Down Expand Up @@ -40,6 +41,98 @@ check "github subdomain rejected" "$(try_url 'https://foo.github.com/org/repo')"
check "ssh:// url accepted" "$(try_url 'ssh://git@github.com/PostHog/posthog.git')" "PostHog/posthog"
check "outside a repo" "$( (cd "$(mktemp -d)" && if derive_org_repo; then echo yes; else echo none; fi) )" "none"

# ── resolve_branch_name ─────────────────────────────────────────────────────

SHIM_DIR=$(mktemp -d)
trap 'rm -rf "$SHIM_DIR"' EXIT
cat > "${SHIM_DIR}/gh" <<'SHIM'
#!/usr/bin/env bash
# SELF stands in for the sha the caller asked about, so a fixture can say "this
# PR's head is the commit under test" without the test knowing the sha.
[ "$1" = api ] || exit 1
printf '%s' "${GH_API_JSON-[]}" | sed "s/SELF/${GIT_PR_HEAD_SHA}/g" | jq -r "${4-.}"
SHIM
chmod +x "${SHIM_DIR}/gh"

# Prints resolve_branch_name's answer, or "none", from a throwaway repo. Pass
# "detach" as the first argument to run it with no branch checked out; the rest
# become resolve_branch_name's own arguments.
try_branch() { # [detach] [network]
local d detach=""
[[ "${1-}" == detach ]] && { detach=yes; shift; }
d=$(mktemp -d)
git -C "$d" init -q -b haacked/work
# A CI runner has no global git identity, and an unborn HEAD makes the
# detach below read "HEAD" as a path instead.
git -C "$d" -c user.email=test@example.com -c user.name=Test \
-c commit.gpgsign=false commit -q --allow-empty -m first
[[ -n "$detach" ]] && git -C "$d" checkout -q --detach
(cd "$d" && PATH="${SHIM_DIR}:${PATH}" \
bash -c 'source "$1"; shift; resolve_branch_name "$@" || echo none' _ "${SCRIPT_DIR}/../repo-context.sh" "$@")
rm -rf "$d"
}

check "attached checkout" "$(try_branch)" "haacked/work"
check "RAN_BRANCH does not override a real branch" "$(RAN_BRANCH=other try_branch)" "haacked/work"
check "detached with no tier left" "$(try_branch detach)" "none"
check "detached falls back to RAN_BRANCH" "$(RAN_BRANCH=haacked/env try_branch detach)" "haacked/env"
check "detached without the network argument never asks GitHub" \
"$(GH_API_JSON='[{"head":{"sha":"x","ref":"haacked/pr"},"state":"open"}]' try_branch detach)" "none"

MATCHING='[{"head":{"sha":"SELF","ref":"haacked/pr"},"state":"open"}]'
check "detached resolves the head ref of the PR whose head is this commit" \
"$(GH_API_JSON="$MATCHING" try_branch detach network)" "haacked/pr"
check "detached rejects a PR this commit only merged into" \
"$(GH_API_JSON='[{"head":{"sha":"deadbeef","ref":"haacked/merged-into"},"state":"open"}]' try_branch detach network)" "none"
check "detached prefers the open PR" \
"$(GH_API_JSON='[{"head":{"sha":"SELF","ref":"a"},"state":"closed"},{"head":{"sha":"SELF","ref":"b"},"state":"open"}]' try_branch detach network)" "b"
check "detached with no associated PR" "$(GH_API_JSON='[]' try_branch detach network)" "none"

# ── resolve_branch_name cache tier ──────────────────────────────────────────
# A branch resolved via RAN_BRANCH or the network tier persists to a file
# under the checkout's private git dir. A later call in the same checkout, run
# as a separate process, reads that file instead of RAN_BRANCH or the network.
# This is what lets `/ran` read a detached checkout's branch after a commit
# has moved HEAD off the PR head it was resolved from.

CACHE_REPO=$(mktemp -d)
NETWORK_REPO=$(mktemp -d)
trap 'rm -rf "$SHIM_DIR" "$CACHE_REPO" "$NETWORK_REPO"' EXIT

git -C "$CACHE_REPO" init -q -b haacked/work
git -C "$CACHE_REPO" -c user.email=test@example.com -c user.name=Test \
-c commit.gpgsign=false commit -q --allow-empty -m first
git -C "$CACHE_REPO" checkout -q --detach

resolve_in_cache_repo() { # [args...] -> prints resolve_branch_name's answer or "none"
(cd "$CACHE_REPO" && PATH="${SHIM_DIR}:${PATH}" \
bash -c 'source "$1"; shift; resolve_branch_name "$@" || echo none' _ "${SCRIPT_DIR}/../repo-context.sh" "$@")
}

RAN_BRANCH=haacked/cached resolve_in_cache_repo > /dev/null
check "a RAN_BRANCH-resolved branch persists for a later call with no RAN_BRANCH" \
"$(resolve_in_cache_repo)" "haacked/cached"
check "the cache tier never asks GitHub" \
"$(GH_API_JSON='[{"head":{"sha":"x","ref":"haacked/other"},"state":"open"}]' resolve_in_cache_repo network)" "haacked/cached"

git -C "$NETWORK_REPO" init -q -b haacked/work
git -C "$NETWORK_REPO" -c user.email=test@example.com -c user.name=Test \
-c commit.gpgsign=false commit -q --allow-empty -m first
git -C "$NETWORK_REPO" checkout -q --detach
NETWORK_SHA=$(git -C "$NETWORK_REPO" rev-parse HEAD)

resolve_in_network_repo() { # [args...] -> prints resolve_branch_name's answer or "none"
(cd "$NETWORK_REPO" && PATH="${SHIM_DIR}:${PATH}" \
bash -c 'source "$1"; shift; resolve_branch_name "$@" || echo none' _ "${SCRIPT_DIR}/../repo-context.sh" "$@")
}

MATCH_NETWORK=$(printf '[{"head":{"sha":"%s","ref":"haacked/from-network"},"state":"open"}]' "$NETWORK_SHA")
GH_API_JSON="$MATCH_NETWORK" resolve_in_network_repo network > /dev/null
check "a network-resolved branch persists for a later call with no network argument" \
"$(resolve_in_network_repo)" "haacked/from-network"
check "the cache tier wins over a subsequent network miss, as a commit moving HEAD off the PR head causes" \
"$(GH_API_JSON='[]' resolve_in_network_repo network)" "haacked/from-network"

echo ""
echo "Passed: ${passes}, Failed: ${failures}"
[[ "${failures}" -eq 0 ]]
Loading
Loading