Two-bug analysis (cherry-pick breaks GitHub merge badge + sync-mirror's head=living-ip filter misses fork PRs leaving prs.github_pr NULL). Empirical verification baked in: PR #87 vs #90 contrast (own-repo merge-no-ff worked end-to-end, fork PR cherry-pick failed both bugs). 10/10 historical Forgejo merge commits propagated to GitHub with identical SHAs — answers Ship's added-scope concern with production data, not theory. Phased implementation: - Phase 1: sync-mirror github_pr backfill via .fork-pr-map (Bug #2, ~30 lines) - Phase 2: _merge_no_ff_external for gh-pr-* branches (Bug #1, ~120 lines) - Phase 3: FwazB PR #90 cleanup (Cory-approved option b) Discovered scope shrink during drafting: existing fixer is already append-only (verified against PR 4066 branch state). No fixer module changes needed — cherry-pick at merge time was the only thing rewriting SHAs. merge --no-ff preserves the existing fix-on-top-of-contributor-commit topology. Open questions for Ship: 1. Backout flag (EXTERNAL_PR_NO_FF_MERGE) overkill or prudent? 2. .fork-pr-map location (bare repo vs /opt/teleo-eval/state/) 3. Phase 1+2 separate deploys or single branch? 4. Rebase-after-fix structured-alert scope vs documented-silent-ignore 5. Merge commit message format Awaiting Ship's architecture sign-off before any code. Ganymede gets line-level once design lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
External Contributor Merge Flow — Design Doc
Author: Epimetheus Architecture review: Ship (owns merge.py, sync-mirror.sh) Code review: Ganymede (line-level, post-design-approval) Status: DRAFT — awaiting Ship's architectural sign-off
Problem statement
External GitHub contributors submit PRs via the living-ip/teleo-codex mirror.
Pipeline accepts the claim and merges the content into Forgejo main, but the
GitHub PR shows "open with no diff" — it looks abandoned to the contributor.
Two compounding bugs intersect on this path:
-
Cherry-pick breaks GitHub merge detection.
lib/merge.py::_cherry_pick_onto_maincreates a new SHA on Forgejo main. GitHub's "is PR head SHA an ancestor of main?" check returns false.merged: false, merge_commit_sha: nullforever. -
prs.github_prnot populated for fork PRs.sync-mirror.shStep 4.5 looks up GitHub PR number via?head=living-ip:$branch, but fork PR heads areFwazB:contributor/...(or<fork-owner>:<branch>), notliving-ip:. The filter misses,github_prstays NULL, andlib/github_feedback.py::on_mergedreturns early (no comment, no close) because_get_github_prrequires non-NULL.
Empirical:
| PR | head | merge mech | github_pr | merged badge | comment posted |
|---|---|---|---|---|---|
| #87 (own-repo) | living-ip:fix/... |
git merge --no-ff |
populated | ✓ true | ✓ |
| #90 (FwazB fork) | FwazB:contributor/... |
cherry-pick | NULL | ✗ false | ✗ |
Both bugs need fixes. Bug #1 is the structural one (load-bearing for the badge). Bug #2 is a sync-mirror filter issue (load-bearing for the comment/close).
Goal
External GitHub contributor opens a PR → pipeline ingests, evaluates, merges →
GitHub PR shows merged: true with badge → bot comment posted → PR closed
cleanly. No human in the loop on the success path. Failure modes (eval reject,
auto-fix, contributor force-push) handled gracefully.
Out of scope
- Agent-extraction PRs (
extract/*,reweave/*,epimetheus/*, etc.) — keep cherry-pick. They merge 70+/day, have no contributor UX surface, and the cherry-pick → linear-history rationale (auto-fixer rebase pattern, bisect friendliness) holds. /api/contributorslegacy endpoint — separate work, deferred.- PAT-in-URL credential pattern — separate security follow-up.
Design — branch-prefix conditional, scoped to gh-pr-*
Bug #1 fix: _merge_no_ff_external for gh-pr-* branches
Dispatch site (lib/merge.py::_merge_domain_queue, currently lines 736-738):
# Reweave: per-file frontmatter union (existing)
if branch.startswith("reweave/"):
merge_fn = _merge_reweave_pr(branch)
# External GitHub fork PRs: true merge with --no-ff so contributor SHA lands
# in main's history → GitHub recognizes "merged" badge.
elif branch.startswith("gh-pr-"):
merge_fn = _merge_no_ff_external(branch)
# Default: cherry-pick (extraction commits ADD new files, applies cleanly,
# linear history preserved for the bulk-extraction flow).
else:
merge_fn = _cherry_pick_onto_main(branch)
New function (lib/merge.py):
async def _merge_no_ff_external(branch: str) -> tuple[bool, str]:
"""Merge an external GitHub PR with --no-ff so contributor SHA lands in main.
Why this differs from _cherry_pick_onto_main:
- Cherry-pick rewrites SHA → GitHub never recognizes the PR as merged.
- --no-ff preserves the contributor's commit SHA in main's history.
- sync-mirror's Forgejo→GitHub propagation already handles merge commits
(verified empirically: PR #87 round-tripped cleanly with merge_commit_sha
preserved).
Mechanics:
1. Fetch latest origin/main and origin/{branch}
2. Create scratch worktree at HEAD of origin/main
3. git merge --no-ff origin/{branch} -m "Merge PR: {branch}"
4. git push origin HEAD:main
5. Cleanup worktree
Conflict handling:
- Entity conflicts: same auto-resolve pattern as cherry-pick
(--ours = main HEAD, --theirs = branch). External claims rarely touch
entities so this is a low-frequency path.
- Other conflicts: abort, return False with conflict detail. Caller marks
conflict_permanent. Manual resolution or contributor rebase required.
Idempotency: caller already gates on PR status, so re-running on a merged
PR fails at the merge step (already merged), which is the right behavior.
Returns (success, message).
"""
The merge commit message uses the branch name (which contains the GitHub PR
number via the gh-pr-{N}/... convention) so post-merge processing can
correlate back to the GitHub PR if needed.
Bug #2 fix: sync-mirror correctly populates github_pr for fork PRs
The current Step 4.5 query in sync-mirror.sh:
GH_PR_NUM=$(curl -sf "https://api.github.com/repos/$GITHUB_REPO/pulls?head=living-ip:$branch&state=all" ...)
Fails for fork PRs because head filter format is <owner>:<branch> and fork
PRs come from a different owner. Two fix options:
Option A — Query by branch name without owner prefix. GitHub API doesn't support owner-less head filter; would have to fetch all PRs and walk client-side. Expensive at scale.
Option B — Recognize the gh-pr-N/ branch prefix. When sync-mirror creates a
Forgejo branch from a fork PR (Step 2.1, already extracts the PR number from
refs/pull/{N}/head), pass that PR number directly to the Step 4.5 backfill
instead of re-querying GitHub. We already know the number — we just need to
plumb it through.
Recommendation: Option B. Cleaner, no API rate-limit risk, no client-side
walk. Implementation: in Step 2.1's existing while-loop, write the branch→PR
mapping to a temp file. In Step 4.5, read the mapping instead of querying
GitHub when the branch starts with gh-pr-.
# Step 2.1 (existing loop, add 1 line):
echo "$PR_BRANCH $pr_num" >> "$REPO_DIR/.fork-pr-map"
# Step 4.5 (replace the head-filter query for gh-pr-* branches):
if [[ "$branch" == gh-pr-* ]]; then
GH_PR_NUM=$(grep "^$branch " "$REPO_DIR/.fork-pr-map" | tail -1 | awk '{print $2}')
else
GH_PR_NUM=$(curl ... existing query for own-repo branches)
fi
The map file gets cleaned up at the start of each sync cycle (mtime check; older than 1h gets truncated). Bounded growth.
Auto-fixer mode='append' for gh-pr-* branches
Current behavior (lib/fixer.py): Worktree-based fix → commit → push (regular
push, not force). When the PR was created by the pipeline (extract/* branches),
this works because the LLM-extractor's commit is at HEAD and we're appending on
top — push succeeds.
External PR behavior (today): Same code path runs. Fork PR has FwazB's commit
at HEAD; auto-fixer creates a fix commit on top; pushes via Forgejo's branch
ref (the fork PR was mirrored as refs/heads/gh-pr-90/contributor/... on
Forgejo). Push works. Eval reset fires. Eval re-runs.
Wait — re-reading the existing fixer, it's actually already append-only. Good.
The cherry-pick at merge time was the only thing rewriting SHAs. So no fixer
change required for Option 2. The fixer commit is already at HEAD~1 from
FwazB's commit on Forgejo. When we git merge --no-ff instead of cherry-pick,
the merge commit's parent chain includes BOTH the fix commit AND FwazB's
original commit. GitHub sees FwazB's SHA in ancestry → "merged" badge.
Cross-check: Verified PR 4066's existing branch state.
$ git log refs/heads/gh-pr-90/contributor/arcium-confidential-computing-challenge --oneline
d7916d65 auto-fix: strip 2 broken wiki links ← fixer's commit
f6a59d7d claim: confidential computing reshapes... ← FwazB's commit
Both commits already on Forgejo. When merge.py cherry-picked, it picked both
commits onto main as new SHAs. With merge --no-ff, both stay intact, the merge
commit references them, GitHub sees f6a59d7d (FwazB's HEAD on his fork) in
main's ancestry, marks merged.
This means scope shrinks: the design is purely a merge.py change + a sync-mirror Step 2.1/4.5 plumbing tweak. No fixer module changes.
Edge case: contributor rebases their fork after fixer appended
Scenario: FwazB's PR is in eval. Pipeline auto-fixer pushes a fix commit to
Forgejo gh-pr-90/.... FwazB notices the original wiki-link issue, fixes it
locally, force-pushes to his fork. sync-mirror's next cycle fetches his new
SHA → tries to update the Forgejo branch → push from sync-mirror is regular
(not --force) → fails because Forgejo branch has diverged from FwazB's fork.
Today's behavior: sync-mirror logs a warning. Forgejo branch keeps the appended-fix state. Eval continues against that state. FwazB's most recent fork state is silently ignored.
Acceptable risk for hackathon. The eval-reset-on-tip-change gate will re-trigger eval if anyone force-pushes the Forgejo branch later (e.g., manual re-sync). Documented; not fixing in this PR.
Followup: sync-mirror could detect the divergence, log a structured alert,
and post a comment on the GitHub PR ("we detected your force-push but our
appended fix has diverged; please rebase against <sha> or we'll close").
Out of scope for this branch.
Test plan
| # | Scenario | Expected | Validation |
|---|---|---|---|
| 1 | External PR, clean (no auto-fix needed) | Merged with --no-ff, contributor SHA in main, GitHub badge merged: true, on_merged comment + close |
curl GitHub API for PR state after merge |
| 2 | External PR with broken wiki links | auto-fixer appends commit, eval re-runs and approves, merge --no-ff brings BOTH commits in via merge commit, GitHub badge merged: true |
log line trace + GitHub API |
| 3 | External PR rejected by eval (substantive issue) | terminate_pr fires existing path, on_closed posts rejection comment + closes GitHub PR | GitHub API after eval cycle |
| 4 | sync-mirror github_pr backfill on fork PR | prs.github_pr populated within one cron cycle (≤2 min) of mirror PR creation |
sqlite3 SELECT after sync |
| 5 | Contributor force-pushes fork mid-eval | sync-mirror logs warning, eval continues against pre-rebase state (documented behavior, not regression) | journalctl |
| 6 | Re-running merge on already-merged PR | --no-ff fails cleanly (already up to date), caller handles as no-op | manual replay |
Test 1 and 2 are the critical-path tests. 3 verifies the rejection path didn't regress. 4 isolates the github_pr backfill fix. 5 is acceptance criteria for the documented edge case. 6 is idempotency.
Production smoke test: after deploy, manually create a tiny test PR from a secondary GitHub account. Walk it through the full lifecycle. Tear down before hackathon. Cost: 5 minutes, catches integration-level issues that unit tests miss.
Backout procedure
If Option 2 misbehaves in production, revert path is one config-line toggle:
# lib/merge.py — gate on a feature flag for fast disable
if branch.startswith("gh-pr-") and config.EXTERNAL_PR_NO_FF_MERGE:
merge_fn = _merge_no_ff_external(branch)
elif ...
Default flag value: True after deploy. Set to False via env var on VPS to
fall back to cherry-pick path immediately if anything breaks. No code revert
required for a fast cutout.
Forgejo→GitHub merge commits already in main when the flag flips can't be un-merged (they're real commits), but the failure mode is the same as today: GitHub PR shows merged because the SHA is in history. No worse than current.
Migration / cleanup
FwazB's PR #90: existing artifact, can't retroactively un-cherry-pick. Manual close with explanatory comment (Ship's option b). Cory-approved.
We've merged your claim into the knowledge base via cherry-pick (commit f6a59d7d
on main). Future external PRs will use `git merge --no-ff` so the GitHub merge
badge fires correctly. This PR is being closed manually as the one historical
case before the fix lands. Thanks for the contribution!
— LivingIP pipeline
Posted via _post_comment + _close_github_pr from a one-off script
(scripts/close-fwazb-pr-90.py).
Implementation order
-
Phase 1 — sync-mirror github_pr backfill (Bug #2, ~30 lines)
- Modify Step 2.1 to write
.fork-pr-map - Modify Step 4.5 to read map for gh-pr-* branches
- Branch:
epimetheus/external-merge-flow-bug2 - This deploys independently, no dependency on Bug #1 fix
- Smoke: verify any future fork PR gets
prs.github_prpopulated within 2 min
- Modify Step 2.1 to write
-
Phase 2 — merge.py --no-ff for gh-pr- (Bug #1, ~120 lines)*
- Add
_merge_no_ff_externalfunction - Add branch-prefix dispatch case in
_merge_domain_queue - Add config flag
EXTERNAL_PR_NO_FF_MERGEfor backout - Branch:
epimetheus/external-merge-flow-bug1 - Depends on Phase 1 being live (otherwise
on_mergedstill no-ops) - Smoke: test PR end-to-end
- Add
-
Phase 3 — FwazB cleanup (~10 lines)
- Manual one-off script for PR #90
- Independent of Phase 1/2 deploy
Two phases, separately reviewable, separately deployable. Bug #2 alone gives us contributor comments on closed-via-cherry-pick PRs (already partially solves the UX). Bug #1 adds the "merged" badge.
Open questions for Ship
-
Backout flag necessary? I'm including
EXTERNAL_PR_NO_FF_MERGEconfig flag for fast cutout. Overkill given low-traffic external-PR path? Or prudent given Accelerate Solana pressure? -
.fork-pr-mapfile location. Currently proposed inside the bare repo ($REPO_DIR/.fork-pr-map). Alternative:/opt/teleo-eval/state/fork-pr-map. The bare-repo location keeps it co-located with the data it indexes (good for cleanup), but also means it gets tracked byfindpermission sweeps in the script. Either works; you decide. -
Phase 1 vs Phase 2 sequencing. I have them as separately deployable, but Phase 1 alone produces a half-fixed state (comments + close, no merged badge). Worth merging into a single branch instead, or keep separate for smaller blast radius per deploy?
-
Test plan #5 (rebase-after-fix) scope. Documenting the silent-ignore as acceptable for now. Sufficient for hackathon, or do you want me to add the structured-alert path before May 5?
-
merge --no-ffcommit message format. I have"Merge PR: {branch}". Better signal to derive the GitHub PR number from the branch and write"Merge external GitHub PR #{N}: {claim_title_or_branch_slug}"? Trades verbosity for searchability ingit log --merges.
Sending you this doc directly via Pentagon. Ganymede gets line-level review once you sign off on the architecture.