molecule-core/workspace-template/scripts/gh-wrapper.sh
rabbitblood 067a8333ce feat(workspace): gh-wrapper — auto-tag agent PRs + issues with role
Every agent in the template currently uses the same GitHub PAT, so
\`gh pr list\` shows every PR as authored by the CEO's account with
no signal which agent opened each one. Commits already carry
per-agent authors (GIT_AUTHOR_NAME from #402). This wrapper extends
the identity split to the PR/issue metadata surface layer that
commit attribution can't reach.

## How it works

A tiny bash script installed at \`/usr/local/bin/gh\`, which sits
earlier in PATH than the real binary at \`/usr/bin/gh\`. For \`gh pr
create\` and \`gh issue create\`:

- Title gets prefixed with \`[Role Name]\` — e.g. \`[Frontend Engineer]
  fix: canvas grid index\`
- Body gets \`\n\n---\n_Opened by: Molecule AI <Role>_\` appended

Role is read from \`GIT_AUTHOR_NAME\` which the platform provisioner
sets to \`Molecule AI <Role>\` (shipped with #402). Accepts both
\`--title X\` and \`--title=X\` forms. Same for \`--body\`.

Anything that isn't \`gh pr create\` or \`gh issue create\` (e.g.
\`gh pr list\`, \`gh issue view\`, \`gh run watch\`) passes through
untouched. No behaviour change for read-side operations.

## Idempotent

- If the title already starts with \`[...]\` the wrapper does not
  re-prefix. \`gh pr edit\` flows that resubmit title won't layer
  multiple tags.
- If the body already contains \`Opened by: Molecule AI\` the footer
  is not re-appended.

## Fail-open

When \`GIT_AUTHOR_NAME\` is absent or doesn't start with \`Molecule
AI \`, the wrapper exec's the real gh with unchanged args. No call
is ever blocked by this script.

## Test coverage

\`tests/test_gh_wrapper.sh\` — 12 cases, no network, no Docker:
- Passthrough for non-create subcommands (pr list)
- pr create title prefix + body footer
- issue create with \`--title=X\` \`--body=X\` equals-form
- Idempotent title re-prefix
- Idempotent body footer (count = 1 after two applies)
- Missing GIT_AUTHOR_NAME → passthrough, title preserved
- Malformed GIT_AUTHOR_NAME (not "Molecule AI ...") → passthrough

All 12 pass. Test script is standalone bash + a temp fake gh binary
that echoes argv; safe to run in CI's Python Lint & Test job via
subprocess shell-out.

## Deployment note

This lands in the workspace image. Existing containers keep their
old /usr/bin/gh until the image is rebuilt and they're re-provisioned
(POST /workspaces/:id/restart {}). No migration required; the wrapper
just starts tagging PRs once the new image is rolled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 03:10:46 -07:00

133 lines
4.3 KiB
Bash

#!/usr/bin/env bash
# gh wrapper — auto-prefixes PR + issue titles with the agent role and
# appends an "Opened by: Molecule AI <Role>" footer to bodies. Shadows
# the real `gh` binary (installed at /usr/bin/gh) because /usr/local/bin
# is earlier in PATH in the workspace image.
#
# Why: every agent in the molecule-dev template shares one GitHub token
# (the CEO's PAT), so `gh pr list` shows every PR as authored by the
# same human user. This wrapper preserves the real gh behaviour while
# injecting the agent's identity into the PR/issue metadata so the
# list + body reveal WHICH agent opened each item. Commit authors are
# already per-agent via GIT_AUTHOR_NAME (shipped in the provisioner);
# this handles the PR/issue surface layer the commit layer can't reach.
#
# Role is derived from GIT_AUTHOR_NAME which the platform sets to
# "Molecule AI <Role Name>" at container provision time. If GIT_AUTHOR_NAME
# is missing or doesn't follow the expected prefix, the wrapper passes
# through unmodified — fail-open so no call is ever BLOCKED by this
# script.
#
# Behaviour table:
#
# gh pr create --title "fix: foo" ...
# → title becomes "[Frontend Engineer] fix: foo"
# → body gets "\n\n---\n_Opened by: Molecule AI Frontend Engineer_\n" appended
#
# gh issue create --title "..." ...
# → same title + body transforms
#
# gh <anything else>
# → passes through untouched
#
# Idempotence: if the title already starts with "[" + any characters + "]",
# the wrapper does NOT re-prefix. Rerunning `gh pr edit` won't layer
# multiple "[Role] [Role] ..." prefixes. Same for body footer — we check
# for the exact "Opened by: Molecule AI" marker and skip if present.
set -euo pipefail
REAL_GH=/usr/bin/gh
if [[ ! -x "$REAL_GH" ]]; then
# Fallback: find the real gh wherever it landed.
REAL_GH=$(command -v /usr/bin/gh /opt/gh/bin/gh /usr/local/bin/gh-original 2>/dev/null | head -1)
if [[ -z "$REAL_GH" ]]; then
echo "gh-wrapper: real gh binary not found" >&2
exit 127
fi
fi
# Extract the agent role from GIT_AUTHOR_NAME ("Molecule AI <Role>").
# If missing or malformed, skip all transforms.
role=""
if [[ -n "${GIT_AUTHOR_NAME:-}" && "${GIT_AUTHOR_NAME}" == "Molecule AI "* ]]; then
role="${GIT_AUTHOR_NAME#Molecule AI }"
fi
# Subcommand must be pr or issue, followed by `create`, to trigger the
# transform. Everything else is a passthrough.
if [[ $# -lt 2 || ( "$1" != "pr" && "$1" != "issue" ) || "$2" != "create" ]]; then
exec "$REAL_GH" "$@"
fi
if [[ -z "$role" ]]; then
# No role detected — behave exactly like real gh. Don't eat arguments
# trying to be clever.
exec "$REAL_GH" "$@"
fi
# Walk the args, rewriting --title / --body in place. Preserve every
# other flag untouched. Accept both "--title X" and "--title=X" forms.
new_args=()
i=1
while (( i <= $# )); do
arg="${!i}"
case "$arg" in
--title)
next_i=$((i + 1))
val="${!next_i:-}"
if [[ "$val" == \[*\]* ]]; then
# Already prefixed — leave alone.
new_args+=("$arg" "$val")
else
new_args+=("$arg" "[$role] $val")
fi
i=$((i + 2))
continue
;;
--title=*)
val="${arg#--title=}"
if [[ "$val" == \[*\]* ]]; then
new_args+=("$arg")
else
new_args+=("--title=[$role] $val")
fi
i=$((i + 1))
continue
;;
--body)
next_i=$((i + 1))
val="${!next_i:-}"
if [[ "$val" == *"Opened by: Molecule AI"* ]]; then
new_args+=("$arg" "$val")
else
new_args+=("$arg" "${val}
---
_Opened by: Molecule AI ${role}_")
fi
i=$((i + 2))
continue
;;
--body=*)
val="${arg#--body=}"
if [[ "$val" == *"Opened by: Molecule AI"* ]]; then
new_args+=("$arg")
else
new_args+=("--body=${val}
---
_Opened by: Molecule AI ${role}_")
fi
i=$((i + 1))
continue
;;
*)
new_args+=("$arg")
i=$((i + 1))
;;
esac
done
exec "$REAL_GH" "${new_args[@]}"