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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "found-issues",
"version": "3.10.8",
"version": "3.10.9",
"description": "Markdown-based issue tracker for AI agents. Claude logs out-of-scope issues it spots while working; entries auto-flip to fixed on PR merge or commit-fix.",
"author": {
"name": "AltDoug",
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "found-issues",
"version": "3.10.8",
"version": "3.10.9",
"description": "Markdown-based issue tracker for AI agents. The agent logs out-of-scope issues it spots while working; entries auto-flip to fixed on PR merge or commit-fix.",
"author": {
"name": "AltDoug",
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.10.9] - 2026-10-27

A fix PR whose landing branch was merged but kept is moved to the default branch, instead of merging into a branch nothing reads any more.

### Fixed
- **A fix PR no longer merges into a landing branch that was merged and kept** (ledger `lib/autofix-ship.sh:658`). A fix cut from a session's branch opens its PR into that branch. 3.10.7 handled the case where the session's PR merges and the branch is deleted, because GitHub then moves the fix PR to the default branch on its own. With `delete_branch_on_merge` off the branch stays, GitHub moves nothing, and the fix PR merged into a branch that had already landed, so the fix never reached the default branch. merge-when-green (and the `--guard` watcher for an armed auto-merge) now checks the PR's base: when it was merged into the default branch by a PR from this repo and its tip is still exactly the head that merged, the PR is retargeted to the default branch (`retargeted to <default>` in the log). The retargeted PR still carries the landing branch's own commits, and its checks ran against that branch, so it is then always brought up to date with the default branch, even when GitHub reports no conflict: after a squash merge only the fix's own change is rebuilt there (the 3.10.7 rebuild now also reads this manual base change), otherwise the default branch is merged in; either way checks run again before it merges. A landing branch with commits after its merge is still in use and stays the base (sessions reuse their branch), and so does a protected branch (a kept `develop` or release branch). A retarget gh refuses stops the watch without merging, and the guard also turns the PR's auto-merge off so GitHub cannot merge it into the old branch; after a retarget the guard arms auto-merge again. A failed default-branch lookup is tried again on the next poll. The 3.10.7 rebuild also ignores a fork PR merged from a branch of the same name.
- **A sweep test no longer fails on SIGPIPE** (ledger `tests/autofix-sweep.bats:1067`). Test-only: `git log | grep -q` under `pipefail` could fail with status 141 when grep stopped at its first match; the log is now read into a variable first.

## [3.10.8] - 2026-10-27

A fix PR whose checks fail once on a CI hiccup gets its failed runs re-run once instead of stopping for good.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
[![Claude Code plugin](https://img.shields.io/badge/Claude%20Code-plugin-orange.svg)](https://docs.claude.com/en/docs/claude-code/plugins)
[![Codex plugin](https://img.shields.io/badge/Codex-plugin-black.svg)](AGENTS.md#installing-for-codex)

**7 lifecycle hooks · 14 slash commands · 2033 tests on Linux/macOS · zero manual bookkeeping**
**7 lifecycle hooks · 14 slash commands · 2044 tests on Linux/macOS · zero manual bookkeeping**

![demo](hero.gif)

Expand Down
2 changes: 1 addition & 1 deletion bin/found-issues
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

set -euo pipefail

readonly FI_VERSION="3.10.8"
readonly FI_VERSION="3.10.9"

# Statusline fast path (lib/segment-cache.sh): `status --format=segment` runs
# every few seconds per session, so it is answered from a content-checked
Expand Down
4 changes: 2 additions & 2 deletions docs/found-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,5 @@ Maintained automatically by Claude. See <https://github.com/AltDoug/found-issues
- [fixed] 2026-10-22 lib/autofix-queue.sh:469 — an entry logged with --fix from a ledger-only branch (a 'log it in its own PR' worktree, or a release branch about to merge) queues an item whose landing branch is that branch; the Stop hook claims it at once, so the fix targets a docs branch that is deleted on merge (2026-10-22: items 20261022-162905-24133 on release/3.10.5 and 20261022-185825-14702 on docs/log-first-touch-flake had to be cancelled and re-queued from main) (suggested: when the landing branch's diff against the default branch touches only docs/found-issues*.md, land on the default branch) (decide: When an entry is logged on a branch whose only change is the ledger [a log PR], should its auto-fix land on the default branch instead of that branch?) (PR: AltDoug/found-issues#283) (fixed: 2026-10-23)
- [fixed] 2026-10-26 lib/autofix-ship.sh:656 — an auto-fix PR cut from a non-default landing branch is stranded when that branch's own PR is squash-merged first: GitHub deletes the landing branch and retargets the fix PR to main, which now carries the whole pre-squash phase history, so merge-when-green sees the ledger diff and stops with 'changes docs/found-issues.md by more than its own annotation ... needs a person'. Seen 2026-10-23 on AltDoug/bruhsailer-helper#112 (branched from origin/worktree-gsd-phase-16.1, opened 06:37Z; phase PR #111 squash-merged 06:39Z; #112 ended at 54 files, closed and redone by hand as #122) (suggested: in merge-when-green, when the PR base has changed from the recorded landing branch to the default branch, rebase the fi/ branch's own commits onto origin/<default> with git rebase --onto origin/<default> <old-landing-tip> and force-push the fi/ branch before re-checking the ledger diff) (fix: medium) (PR: AltDoug/found-issues#287) (fixed: 2026-10-26)
- [fixed] 2026-10-27 lib/autofix-ship.sh:511 — merge-when-green gives up on the first failed check ('checks failed — not merging') and nothing re-runs it, so a one-off CI infrastructure failure strands the fix PR for good; the message reaches only spawn.log while the item's run log says 'shipped'. Seen 2026-10-24 on ByteTechSoftwares/sayciao#320: supabase-legs failed on 'toomanyrequests: Data limit exceeded' pulling ECR images, app/zapier/static green, PR still open 2026-10-27 (suggested: on the first 'fail' re-run the head's failed GitHub Actions runs once with gh run rerun --failed and keep polling; a second fail stops as now; the guard does the same for an armed auto-merge) (fix: medium) (PR: AltDoug/found-issues#290) (fixed: 2026-10-27)
- [open] 2026-10-27 lib/autofix-ship.sh:658 — the 3.10.7 squash rebuild runs only after GitHub's AutomaticBaseChangeSucceededEvent, which needs the landing branch deleted; in a repo with delete_branch_on_merge off the landing branch survives its squash merge, so a fix PR still merges into that dead branch and never reaches the default branch. Seen on ByteTechSoftwares/sayciao#320 (base gsd/phase-24-resume squash-merged into main as #318 on 2026-10-25, branch kept, 49 ahead / 6 behind main) (suggested: before merging, if the base has a merged PR into the default branch whose squash is not an ancestor-carrier of the base tip, retarget with gh pr edit --base and reuse _fi_af_mwg_squashed with that PR as #M) (decide: When a fix PR's landing branch was squash-merged into the default branch but not deleted [no GitHub retarget], should merge-when-green retarget the PR to the default branch and rebuild it there, or stop and report?)
- [open] 2026-10-27 tests/autofix-sweep.bats:1067 — 'a continuation's PR leaves the PR branch ledger alone' flaked on the Mac 2026-10-27 with status 141: git log ... | grep -q under pipefail, grep -q exits on the first match and git log dies of SIGPIPE (suggested: capture the log into a variable first, then grep it, as other tests do) (fix: small)
- [open] 2026-10-27 lib/autofix-ship.sh:658 — the 3.10.7 squash rebuild runs only after GitHub's AutomaticBaseChangeSucceededEvent, which needs the landing branch deleted; in a repo with delete_branch_on_merge off the landing branch survives its squash merge, so a fix PR still merges into that dead branch and never reaches the default branch. Seen on ByteTechSoftwares/sayciao#320 (base gsd/phase-24-resume squash-merged into main as #318 on 2026-10-25, branch kept, 49 ahead / 6 behind main) (suggested: before merging, if the base has a merged PR into the default branch whose squash is not an ancestor-carrier of the base tip, retarget with gh pr edit --base and reuse _fi_af_mwg_squashed with that PR as #M) (decide: When a fix PR's landing branch was squash-merged into the default branch but not deleted [no GitHub retarget], should merge-when-green retarget the PR to the default branch and rebuild it there, or stop and report?) (PR: AltDoug/found-issues#292)
- [open] 2026-10-27 tests/autofix-sweep.bats:1067 — 'a continuation's PR leaves the PR branch ledger alone' flaked on the Mac 2026-10-27 with status 141: git log ... | grep -q under pipefail, grep -q exits on the first match and git log dies of SIGPIPE (suggested: capture the log into a variable first, then grep it, as other tests do) (fix: small) (PR: AltDoug/found-issues#292)
90 changes: 82 additions & 8 deletions lib/autofix-ship.sh
Original file line number Diff line number Diff line change
Expand Up @@ -492,13 +492,37 @@ fi_af_merge_when_green() {
[[ "$settle" =~ ^[0-9]+$ ]] || settle=120
[[ -n "$slug" ]] || slug="$(fi_repo_id 2>/dev/null || true)"
[[ -n "$slug" ]] || { fi_err "autofix: merge-when-green needs a GitHub repo (--repo owner/name)"; return 1; }
FI_AF_MWG_FIXES=0
FI_AF_MWG_FIXES=0 FI_AF_MWG_DEFAULT=""
local lrc landed=0
for (( i = 0; i < polls; i++ )); do
v="$(gh pr view "$n" --repo "$slug" --json state,statusCheckRollup,mergeable,baseRefName,headRefName,headRefOid --jq "$jqf" 2>/dev/null || true)"
IFS='|' read -r st ck mg ho br hr <<<"$v" || true
[[ "$st $ck" == "OPEN fail" && "$mg" != CONFLICTING ]] || red_sha=""
if [[ "$st" == OPEN && "$mg" == CONFLICTING ]]; then
_fi_af_mwg_unconflict "$n" "$slug" "$br" "$hr" || return 1
if [[ "$st" == OPEN ]]; then
lrc=0; _fi_af_mwg_landed "$n" "$slug" "$br" || lrc=$?
if (( lrc == 2 )); then
# The guard's armed auto-merge would still merge into the dead branch.
(( guard )) && _fi_af_mwg_disarm "$n" "$slug"
return 1
fi
(( lrc == 0 )) && { landed=1; nones=0; sleep "$pause"; continue; }
fi
# After a retarget the PR still carries the landing branch's own commits
# and checks run against that branch, so it is brought up to date with
# the default branch (rebuilt after a squash merge) even when GitHub
# reports no conflict.
if [[ "$st" == OPEN && ( "$mg" == CONFLICTING || "$landed" == 1 ) ]]; then
if ! _fi_af_mwg_unconflict "$n" "$slug" "$br" "$hr"; then
(( landed && guard )) && _fi_af_mwg_disarm "$n" "$slug"
return 1
fi
if (( landed )); then
landed=0
# A base change can drop an armed auto-merge; the guard arms it again.
if (( guard )) && ! gh pr merge "$n" --auto --squash --repo "$slug" >/dev/null 2>&1; then
fi_err "autofix: PR #$n: arming auto-merge again after the retarget failed — it will not merge on its own"
fi
fi
nones=0; sleep "$pause"; continue
fi
case "$st $ck" in
Expand Down Expand Up @@ -588,6 +612,55 @@ _fi_af_guard_unlock() {
return 0
}

FI_AF_MWG_DEFAULT=""

# 3.10.9 (ledger lib/autofix-ship.sh:658): a fix PR's landing branch was
# merged into the default branch but kept (delete_branch_on_merge off), so
# GitHub never retargeted the PR (sayciao #320 into gsd/phase-24-resume after
# #318). When the branch's tip is still exactly the head that merged, the PR
# is retargeted to the default branch; the caller then brings it up to date
# (_fi_af_mwg_unconflict, whose _fi_af_mwg_squashed reads the manual base
# change and rebuilds after a squash merge). A branch with work after its
# merge stays the base (sayciao's moved on: a fix merged there reaches main
# with its next PR), and so does a protected one (a kept develop or release
# branch); a deleted one GitHub retargets itself. Fork PRs that share the
# branch name are ignored.
# rc 0 = retargeted; rc 1 = nothing to do; rc 2 = the retarget failed.
_fi_af_mwg_landed() {
local n="$1" slug="$2" base="$3" def m oid tip enc
[[ -n "$base" ]] || return 1
if [[ -z "$FI_AF_MWG_DEFAULT" ]]; then
# Only a successful lookup is kept: a failed one is tried on the next poll.
FI_AF_MWG_DEFAULT="$(gh repo view "$slug" --json defaultBranchRef --jq '.defaultBranchRef.name // ""' 2>/dev/null || true)"
[[ -n "$FI_AF_MWG_DEFAULT" ]] || return 1
fi
def="$FI_AF_MWG_DEFAULT"
[[ "$base" != "$def" ]] || return 1
m="$(gh pr list --repo "$slug" --head "$base" --base "$def" --state merged --limit 5 --json number,headRefOid,baseRefName,isCrossRepository \
--jq '[.[] | select(.isCrossRepository | not)][0] // {} | "\(.number // "")|\(.headRefOid // "")|\(.baseRefName // "")"' 2>/dev/null || true)"
[[ "${m##*|}" == "$def" ]] || return 1
m="${m%|*}" oid="${m#*|}" m="${m%%|*}"
[[ "$m" =~ ^[0-9]+$ && "$oid" =~ ^[0-9a-f]{40}$ ]] || return 1
enc="$(jq -rn --arg b "$base" '$b | @uri')"
tip="$(gh api "repos/$slug/branches/$enc" --jq '"\(.commit.sha // "")|\(.protected // false)"' 2>/dev/null || true)"
[[ "${tip#*|}" == false && "${tip%%|*}" == "$oid" ]] || return 1
if ! gh pr edit "$n" --repo "$slug" --base "$def" >/dev/null 2>&1; then
fi_err "autofix: PR #$n: its landing branch $base was merged into $def (PR #$m), and retargeting it to $def failed — not merging"; return 2
fi
printf 'PR #%s: its landing branch %s was merged into %s (PR #%s) with nothing since; retargeted to %s\n' "$n" "$base" "$def" "$m" "$def"
return 0
}

# Turn off <N>'s armed auto-merge when the guard gives up on a PR that would
# otherwise merge where it should not.
_fi_af_mwg_disarm() {
if gh pr merge "$1" --disable-auto --repo "$2" >/dev/null 2>&1; then
fi_err "autofix: PR #$1: auto-merge turned off"
else
fi_err "autofix: PR #$1: turning auto-merge off failed — it may still merge into its old base"
fi
}

# Squash-merge <N>; a refusal's message is kept in FI_AF_MWG_ERR.
_fi_af_mwg_merge() {
local n="$1" slug="$2"
Expand Down Expand Up @@ -707,7 +780,8 @@ _fi_af_is_ledger() {

# 3.10.7 (ledger lib/autofix-ship.sh:656): GitHub retargets a fix PR to the
# default branch when the session branch it was cut from merges and is
# deleted. After a squash merge the PR still carries that branch's commits,
# deleted (3.10.9: or _fi_af_mwg_landed retargets it when the branch was
# kept, a BaseRefChangedEvent). After a squash merge the PR still carries that branch's commits,
# so merging the base in conflicts with more than the PR's own annotation.
# Then the PR's own change since it was cut (the merge base of the merged
# PR's head and this PR's head) is rebuilt as one commit on the base, the
Expand All @@ -718,15 +792,15 @@ _fi_af_is_ledger() {
_fi_af_mwg_squashed() {
local n="$1" slug="$2" wt="$3" base="$4" head="$5" ev old m oid cut was why f lrc subj cmd
local ann="(PR: $slug#$n)" code=() ledgers=() tlog="${3%/*}/rebuild-tests.log"
local q='query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){timelineItems(itemTypes:[AUTOMATIC_BASE_CHANGE_SUCCEEDED_EVENT],last:1){nodes{... on AutomaticBaseChangeSucceededEvent{oldBase newBase}}}}}}'
local q='query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){timelineItems(itemTypes:[AUTOMATIC_BASE_CHANGE_SUCCEEDED_EVENT,BASE_REF_CHANGED_EVENT],last:1){nodes{... on AutomaticBaseChangeSucceededEvent{oldBase newBase} ... on BaseRefChangedEvent{previousRefName currentRefName}}}}}}'
ev="$(gh api graphql -f query="$q" -f o="${slug%%/*}" -f r="${slug#*/}" -F n="$n" \
--jq '.data.repository.pullRequest.timelineItems.nodes[-1] // {} | "\(.oldBase // "")|\(.newBase // "")"' 2>/dev/null || true)"
--jq '.data.repository.pullRequest.timelineItems.nodes[-1] // {} | "\(.oldBase // .previousRefName // "")|\(.newBase // .currentRefName // "")"' 2>/dev/null || true)"
old="${ev%%|*}"
[[ -n "$old" && "$old" != "$base" && "${ev#*|}" == "$base" ]] || return 1
# Only the PR that merged that branch into this base (a reused name or a
# fork's branch of the same name is another PR).
m="$(gh pr list --repo "$slug" --head "$old" --base "$base" --state merged --limit 1 --json number,headRefOid,baseRefName \
--jq '.[0] // {} | "\(.number // "")|\(.headRefOid // "")|\(.baseRefName // "")"' 2>/dev/null || true)"
m="$(gh pr list --repo "$slug" --head "$old" --base "$base" --state merged --limit 5 --json number,headRefOid,baseRefName,isCrossRepository \
--jq '[.[] | select(.isCrossRepository | not)][0] // {} | "\(.number // "")|\(.headRefOid // "")|\(.baseRefName // "")"' 2>/dev/null || true)"
[[ "${m##*|}" == "$base" ]] || return 1
m="${m%|*}" oid="${m#*|}" m="${m%%|*}"
[[ "$m" =~ ^[0-9]+$ && "$oid" =~ ^[0-9a-f]{40}$ ]] || return 1
Expand Down
Loading
Loading