Merge pull request #419 from Molecule-AI/feat/gh-agent-attribution
feat(workspace): gh-wrapper — auto-tag agent PRs + issues with role
This commit is contained in:
commit
18837c44ca
@ -38,6 +38,17 @@ COPY policies/ ./policies/
|
||||
RUN ln -s /app/a2a_cli.py /usr/local/bin/a2a && chmod +x /app/a2a_cli.py /app/a2a_mcp_server.py && \
|
||||
ln -s /app/molecule_ai_status.py /usr/local/bin/molecule-monorepo-status && chmod +x /app/molecule_ai_status.py
|
||||
|
||||
# gh wrapper — auto-prefixes PR / issue titles with the agent role + appends
|
||||
# a body footer. Every agent in the template shares one GitHub PAT so plain
|
||||
# `gh pr list` can't distinguish workspaces; the wrapper reads GIT_AUTHOR_NAME
|
||||
# (set by the platform provisioner, "Molecule AI <Role>") and rewrites the
|
||||
# title/body accordingly. Fails open when the env is missing. Anything that
|
||||
# isn't `gh pr create` or `gh issue create` passes through untouched.
|
||||
# /usr/local/bin is earlier in PATH than /usr/bin/gh so this shadows the
|
||||
# real binary without renaming it.
|
||||
COPY scripts/gh-wrapper.sh /usr/local/bin/gh
|
||||
RUN chmod +x /usr/local/bin/gh
|
||||
|
||||
# Dirs and permissions
|
||||
RUN mkdir -p /workspace /plugins /home/agent/.claude /home/agent/.config /home/agent/.local && \
|
||||
chown -R agent:agent /app /home/agent /workspace
|
||||
|
||||
132
workspace-template/scripts/gh-wrapper.sh
Normal file
132
workspace-template/scripts/gh-wrapper.sh
Normal file
@ -0,0 +1,132 @@
|
||||
#!/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[@]}"
|
||||
114
workspace-template/tests/test_gh_wrapper.sh
Normal file
114
workspace-template/tests/test_gh_wrapper.sh
Normal file
@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-test the gh-wrapper behaviour with a fake gh binary that echoes
|
||||
# back its argv. Runs entirely in-process (no Docker), so it's cheap to
|
||||
# run per-CI-job. Tests the behaviour table in scripts/gh-wrapper.sh.
|
||||
#
|
||||
# Invoked by CI's Python Lint & Test job via a subprocess shell-out, or
|
||||
# locally via `bash tests/test_gh_wrapper.sh`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WRAPPER="$HERE/../scripts/gh-wrapper.sh"
|
||||
|
||||
if [[ ! -x "$WRAPPER" ]]; then
|
||||
echo "FAIL: wrapper not executable: $WRAPPER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fake gh: prints every arg on its own line, prefixed by "ARG:". Lets
|
||||
# tests introspect what the wrapper passed through.
|
||||
FAKE_GH_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$FAKE_GH_DIR"' EXIT
|
||||
cat > "$FAKE_GH_DIR/gh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
for a in "$@"; do
|
||||
printf 'ARG:%s\n' "$a"
|
||||
done
|
||||
EOF
|
||||
chmod +x "$FAKE_GH_DIR/gh"
|
||||
|
||||
# Make the wrapper use the fake gh by overriding the hardcoded path via
|
||||
# a temporary symlink trick: copy the wrapper to a temp location and
|
||||
# sed-replace the REAL_GH default with our fake.
|
||||
WRAPPER_UNDER_TEST=$(mktemp)
|
||||
trap 'rm -f "$WRAPPER_UNDER_TEST"' EXIT
|
||||
sed "s|REAL_GH=/usr/bin/gh|REAL_GH=$FAKE_GH_DIR/gh|" "$WRAPPER" > "$WRAPPER_UNDER_TEST"
|
||||
chmod +x "$WRAPPER_UNDER_TEST"
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
assert_contains() {
|
||||
local name="$1" haystack="$2" needle="$3"
|
||||
if [[ "$haystack" == *"$needle"* ]]; then
|
||||
pass=$((pass + 1))
|
||||
echo " PASS: $name"
|
||||
else
|
||||
fail=$((fail + 1))
|
||||
echo " FAIL: $name" >&2
|
||||
echo " expected to contain: $needle" >&2
|
||||
echo " got: $haystack" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local name="$1" haystack="$2" needle="$3"
|
||||
if [[ "$haystack" == *"$needle"* ]]; then
|
||||
fail=$((fail + 1))
|
||||
echo " FAIL: $name — should not contain: $needle" >&2
|
||||
echo " got: $haystack" >&2
|
||||
else
|
||||
pass=$((pass + 1))
|
||||
echo " PASS: $name"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "--- passthrough (no subcommand transform) ---"
|
||||
out=$(GIT_AUTHOR_NAME="Molecule AI Frontend Engineer" "$WRAPPER_UNDER_TEST" pr list --state open)
|
||||
assert_contains "pr list passthrough" "$out" "ARG:list"
|
||||
assert_not_contains "pr list no prefix" "$out" "[Frontend"
|
||||
|
||||
echo "--- pr create with role ---"
|
||||
out=$(GIT_AUTHOR_NAME="Molecule AI Backend Engineer" "$WRAPPER_UNDER_TEST" pr create --title "fix: auth" --body "Short description")
|
||||
assert_contains "pr create title prefix" "$out" "ARG:[Backend Engineer] fix: auth"
|
||||
assert_contains "pr create body footer" "$out" "_Opened by: Molecule AI Backend Engineer_"
|
||||
|
||||
echo "--- issue create with = form ---"
|
||||
out=$(GIT_AUTHOR_NAME="Molecule AI PM" "$WRAPPER_UNDER_TEST" issue create --title="bug: foo" --body="details")
|
||||
assert_contains "issue create --title= prefix" "$out" "ARG:--title=[PM] bug: foo"
|
||||
assert_contains "issue create --body= footer" "$out" "_Opened by: Molecule AI PM_"
|
||||
|
||||
echo "--- idempotent title re-prefix ---"
|
||||
out=$(GIT_AUTHOR_NAME="Molecule AI DevRel Engineer" "$WRAPPER_UNDER_TEST" pr create --title "[DevRel Engineer] already prefixed")
|
||||
assert_not_contains "no double prefix" "$out" "[DevRel Engineer] [DevRel Engineer]"
|
||||
|
||||
echo "--- idempotent body footer ---"
|
||||
already="original body
|
||||
|
||||
---
|
||||
_Opened by: Molecule AI UIUX Designer_"
|
||||
out=$(GIT_AUTHOR_NAME="Molecule AI UIUX Designer" "$WRAPPER_UNDER_TEST" pr create --title "x" --body "$already")
|
||||
# Count how many times the footer marker appears — should be exactly 1.
|
||||
count=$(echo "$out" | grep -c "_Opened by: Molecule AI UIUX Designer_" || true)
|
||||
if [[ "$count" -eq 1 ]]; then
|
||||
pass=$((pass + 1)); echo " PASS: footer not double-appended"
|
||||
else
|
||||
fail=$((fail + 1)); echo " FAIL: footer count=$count (want 1)" >&2
|
||||
fi
|
||||
|
||||
echo "--- missing GIT_AUTHOR_NAME — passes through ---"
|
||||
out=$(unset GIT_AUTHOR_NAME; "$WRAPPER_UNDER_TEST" pr create --title "fix: foo")
|
||||
assert_not_contains "no role means no prefix" "$out" "[M"
|
||||
assert_contains "raw title survives" "$out" "ARG:fix: foo"
|
||||
|
||||
echo "--- wrong prefix in GIT_AUTHOR_NAME — passes through ---"
|
||||
out=$(GIT_AUTHOR_NAME="Some Random Human" "$WRAPPER_UNDER_TEST" pr create --title "fix: foo")
|
||||
assert_not_contains "non-Molecule author means no prefix" "$out" "[S"
|
||||
assert_contains "raw title survives (wrong prefix)" "$out" "ARG:fix: foo"
|
||||
|
||||
echo
|
||||
echo "================================"
|
||||
echo "gh-wrapper: $pass passed, $fail failed"
|
||||
echo "================================"
|
||||
[[ $fail -eq 0 ]]
|
||||
Loading…
Reference in New Issue
Block a user