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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,11 @@ API failures abort rather than indicate an empty queue; Codex setup failures sto
without blocking an issue, and timeouts stop its process group. A final report is
not completion proof: the runner checks GitHub issue/PR state for progress, while
the agent must complete and verify CI, merge and cleanup under `AGENTS.md`.
Progress counts use every page of the repository's merged and open PR listings,
matching issue references in PR bodies without relying on the search index.
Inspect an issue's merged count and newest open citing PR with
`scripts/issue_loop.sh --print-progress N`; an API failure returns an error
without reporting a partial count.
See `scripts/issue_loop.sh --help` for limits, paths and stop behavior.

## Contributing files
Expand Down
1 change: 1 addition & 0 deletions references/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,4 @@ Curated open-source projects relevant to office2pdf's architecture (OOXML → IR
|----------|------|
| [Meta-Harness (arXiv:2603.28052)](https://arxiv.org/abs/2603.28052) | Harness search from raw prior traces ([detail](meta-harness.md)) |
| [OpenAI Codex](https://github.com/openai/codex) | Headless CLI, JSONL events and structured reports ([detail](codex.md)) |
| [GitHub CLI](https://github.com/cli/cli) | Complete PR enumeration for progress detection ([detail](github-cli-pagination.md)) |
13 changes: 13 additions & 0 deletions references/github-cli-pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# GitHub CLI pagination

- Implementation: <https://github.com/cli/cli/tree/trunk/pkg/cmd/api>
- API manual: <https://cli.github.com/manual/gh_api>
- `gh api graphql --paginate` follows the connection's `pageInfo` until
`hasNextPage` is false. The query must accept `$endCursor: String` and select
both `hasNextPage` and `endCursor`.
- `-F owner='{owner}' -F name='{repo}'` resolves the current repository; other
field arguments become GraphQL variables.
- Used by `scripts/issue_loop.sh` to enumerate merged/open repository PRs. A
bounded search can miss older PRs or recently merged PRs absent from the index.
- Capture the complete command output before reporting progress, and propagate
its failure status so a failed later page cannot become a partial count.
50 changes: 45 additions & 5 deletions scripts/issue_loop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
# # merged-PR preflight note
# scripts/issue_loop.sh --print-next-issue # the issue the next run takes; files a
# # main-red issue first if main is red
# scripts/issue_loop.sh --print-progress N # the pull requests the loop counts as
# # issue #N's progress
#
# Stop it gracefully with `touch target/issue-loop-logs/STOP` (finishes the current
# issue first) or Ctrl+C. Each run's prompt, report, outcome and full transcript land
Expand Down Expand Up @@ -67,14 +69,15 @@ MAX_ISSUES=0
USAGE_LIMIT_LOG="" # diagnostic: report why one run counted as rate-limited
PRINT_PROMPT="" # diagnostic: print one issue's next prompt and exit
PRINT_NEXT_ISSUE=0 # diagnostic: pick (and file, if main is red) the next issue
PRINT_PROGRESS="" # diagnostic: print one issue's merged and open citing PRs
REFLECT_EVERY=0 # 0 = never run the prompt-improvement proposer
HISTORY="$SCRIPT_DIR/issue_loop_history.py"

usage() { awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0; }

while (( $# )); do
case "$1" in
--agent|--model|--label|--blocked-label|--skip-label|--prompt-file|--max-turns|--log-dir|--expect-user|--cargo-target-dir|--min-free-gb|--usage-limit-reason|--max-issues|--max-runtime|--codex-sandbox|--reflect-every|--print-prompt)
--agent|--model|--label|--blocked-label|--skip-label|--prompt-file|--max-turns|--log-dir|--expect-user|--cargo-target-dir|--min-free-gb|--usage-limit-reason|--max-issues|--max-runtime|--codex-sandbox|--reflect-every|--print-prompt|--print-progress)
(( $# >= 2 )) && [[ -n "$2" && "$2" != --* ]] || { echo "missing value for $1" >&2; exit 2; } ;;
esac
case "$1" in
Expand All @@ -96,6 +99,7 @@ while (( $# )); do
--usage-limit-reason) shift; USAGE_LIMIT_LOG="${1:-}" ;;
--print-prompt) shift; PRINT_PROMPT="${1:-}" ;;
--print-next-issue) PRINT_NEXT_ISSUE=1 ;;
--print-progress) shift; PRINT_PROGRESS="${1:-}" ;;
--reflect-every) shift; REFLECT_EVERY="${1:-0}" ;;
-h|--help) usage ;;
*) echo "unknown argument: $1 (try --help)" >&2; exit 2 ;;
Expand Down Expand Up @@ -266,16 +270,44 @@ pick_issue() {
gh issue list --state open --search "$search" --json number --jq '.[0].number' 2>/dev/null
}

# Numbers of the pull requests in state $1 whose body cites issue #$2, ascending.
# Enumerate the repository connection: search can lag an older PR's merge and
# caps results at 1,000. Neither a search nor a newest-PR window proves the count.
# Capture all pages before printing so a later-page error cannot count as progress.
citing_prs() {
local state filter found
case "$1" in
merged) state=MERGED ;;
open) state=OPEN ;;
*) return 1 ;;
esac
filter=".data.repository.pullRequests.nodes[] | select(.body // \"\" | test(\"#$2\\\\b\")) | .number"
found="$(gh api graphql --paginate -F owner='{owner}' -F name='{repo}' -f state="$state" \
-f query='query($owner: String!, $name: String!, $state: PullRequestState!, $endCursor: String) {
repository(owner: $owner, name: $name) {
pullRequests(states: [$state], first: 100, after: $endCursor,
orderBy: {field: CREATED_AT, direction: ASC}) {
nodes { number body }
pageInfo { hasNextPage endCursor }
}
}
}' --jq "$filter" 2>/dev/null)" || return 1
printf '%s\n' "$found" | sed '/^$/d' | sort -nu
}

# Merged PRs whose body cites the issue. This — not only a closed issue — is the
# progress signal, because a large issue is often advanced one milestone per run.
merged_refs() {
gh pr list --state merged --limit 30 --json body \
--jq "[.[] | select(.body // \"\" | test(\"#$1\\\\b\"))] | length" 2>/dev/null
local prs
prs="$(citing_prs merged "$1")" || return 1
grep -c . <<<"$prs" || true
}

# The newest open PR whose body cites the issue, or nothing.
open_ref_pr() {
gh pr list --state open --limit 30 --json number,body \
--jq "[.[] | select(.body // \"\" | test(\"#$1\\\\b\"))][0].number // empty" 2>/dev/null
local prs
prs="$(citing_prs open "$1")" || return 1
tail -n 1 <<<"$prs"
}

build_prompt() {
Expand Down Expand Up @@ -360,6 +392,14 @@ if (( PRINT_NEXT_ISSUE )); then
exit $?
fi

if [[ -n "$PRINT_PROGRESS" ]]; then
[[ "$PRINT_PROGRESS" =~ ^[1-9][0-9]*$ ]] || { echo "invalid issue number: $PRINT_PROGRESS" >&2; exit 2; }
merged="$(merged_refs "$PRINT_PROGRESS")" || exit 1
pr_open="$(open_ref_pr "$PRINT_PROGRESS")" || exit 1
printf 'merged=%s\nopen=%s\n' "$merged" "$pr_open"
exit 0
fi

for tool in "$AGENT" gh git python3; do
command -v "$tool" >/dev/null || { echo "missing required tool: $tool" >&2; exit 1; }
done
Expand Down
4 changes: 2 additions & 2 deletions scripts/tests/test_issue_loop_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ def setUp(self):
sys.exit(0)
pending = [n for n in state['issues'] if n not in state['closed']]
print(pending[0] if pending else 'null')
elif args[:2] == ['pr', 'list']:
elif args[:2] == ['api', 'graphql']:
if os.environ.get('PR_API_ERROR'): sys.exit(1)
print('0' if 'merged' in args else '')
print('')
elif args[:2] == ['issue', 'view']:
if os.environ.get('STATE_API_ERROR') and 'state' in args: sys.exit(1)
if os.environ.get('LABEL_API_ERROR') and 'labels' in args: sys.exit(1)
Expand Down
180 changes: 180 additions & 0 deletions scripts/tests/test_issue_loop_progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""Tests for how the issue loop reads an issue's progress from its pull requests.

The loop calls a run a milestone when more merged pull requests cite the issue
after the run than before it, and `pr-open` when an open one cites it. A newest-PR
window can lose an older citing PR while a run merges another, hiding progress.
Search indexing delays and result caps also make search counts incomplete.

Progress is read through `--print-progress` against a fake `gh` on PATH. The
fake serves paginated repository PR data, can fail after a successful page,
and runs the loop's own `--jq` filters through `jq`. Search-index membership
is independent of repository listing visibility.
"""

import json
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path

LOOP = Path(__file__).resolve().parents[1] / "issue_loop.sh"

FAKE_GH = r'''#!/usr/bin/env python3
import json, os, re, subprocess, sys
args = sys.argv[1:]

def option(name, default=None):
return args[args.index(name) + 1] if name in args else default

if args[:2] != ["api", "graphql"]:
sys.exit("unexpected gh call: %s" % args)
fields = dict(arg.split("=", 1) for arg in args if "=" in arg)
query = fields["query"]
prs = [pr for pr in json.loads(os.environ["FAKE_PRS"])
if pr["state"].upper() == fields["state"]]
prs.sort(key=lambda pr: pr["number"], reverse="direction: DESC" in query)
page_size = int(re.search(r"first:\s*(\d+)", query).group(1))
paginated = ("--paginate" in args and "after: $endCursor" in query
and "pageInfo" in query and "hasNextPage" in query and "endCursor" in query)
if not paginated:
prs = prs[:page_size]
pages = [prs[i:i + page_size] for i in range(0, len(prs), page_size)] or [[]]
for page_number, page in enumerate(pages, 1):
if page_number == int(os.environ.get("FAKE_ERROR_PAGE", "0")):
sys.exit("HTTP 502")
rows = {"data": {"repository": {"pullRequests": {
"nodes": [{"number": pr["number"], "body": pr["body"]} for pr in page],
"pageInfo": {"hasNextPage": page_number < len(pages), "endCursor": str(page_number)}
}}}}
sys.stdout.write(subprocess.run(["jq", "-r", option("--jq")], input=json.dumps(rows),
capture_output=True, text=True, check=True).stdout)

'''


def pr(number: int, body: str, state: str = "merged", indexed: bool = True) -> dict:
return {"number": number, "body": body, "state": state, "indexed": indexed}


def unrelated(first: int, count: int, state: str = "merged") -> list[dict]:
return [pr(number, "Related: #1874", state) for number in range(first, first + count)]


@unittest.skipUnless(shutil.which("jq"), "the fake gh evaluates --jq filters with jq")
class ProgressTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
root = Path(self.tmp.name)
bin_dir = root / "bin"
bin_dir.mkdir()
gh = bin_dir / "gh"
gh.write_text(FAKE_GH, encoding="utf-8")
gh.chmod(0o755)
self.env = {**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"}
self.logs = root / "logs"

def tearDown(self):
self.tmp.cleanup()

def run_progress(self, issue: int, prs: list[dict], **fake: str) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", str(LOOP), "--log-dir", str(self.logs), "--print-progress", str(issue)],
capture_output=True,
text=True,
env={**self.env, "FAKE_PRS": json.dumps(prs), **fake},
)

def progress(self, issue: int, prs: list[dict]) -> dict[str, str]:
done = self.run_progress(issue, prs)
self.assertEqual(done.returncode, 0, done.stderr)
return dict(line.split("=", 1) for line in done.stdout.splitlines())

def test_a_milestone_merged_while_newer_merges_push_out_an_older_citer_counts(self):
# #2217's second attempt: one citing pull request is the oldest of the
# newest thirty when the run starts; the run merges another citer
# while an unrelated one merges too.
before = [pr(100, "Related: #2217")] + unrelated(101, 29)
after = before + unrelated(130, 1) + [pr(131, "Related: #2217")]

self.assertEqual(self.progress(2217, before)["merged"], "1")
self.assertEqual(self.progress(2217, after)["merged"], "2")

def test_a_citing_pull_request_older_than_the_newest_thirty_counts(self):
prs = [pr(1, "Fixes part of it.\n\nRelated: #2224")] + unrelated(2, 40)

self.assertEqual(self.progress(2224, prs)["merged"], "1")

def test_a_pull_request_search_has_not_indexed_yet_still_counts(self):
prs = unrelated(1, 5) + [pr(6, "Related: #2217", indexed=False)]

self.assertEqual(self.progress(2217, prs)["merged"], "1")

def test_an_old_pull_request_merged_before_search_indexing_still_counts(self):
prs = [pr(1, "Related: #2217", indexed=False)] + unrelated(2, 140)

self.assertEqual(self.progress(2217, prs)["merged"], "1")

def test_more_than_one_thousand_citing_pull_requests_are_all_counted(self):
prs = [pr(number, "Related: #2217") for number in range(1, 1002)]

self.assertEqual(self.progress(2217, prs)["merged"], "1001")

def test_repeated_pull_request_entries_count_once(self):
prs = [pr(5, "Related: #2217"), pr(6, "TODO(#2217) stays open"), pr(5, "Related: #2217")]

self.assertEqual(self.progress(2217, prs)["merged"], "2")

def test_a_body_that_only_contains_the_digits_does_not_count(self):
# Only a `#N` citation counts, not a bare number or a longer issue number.
prs = [pr(7, "Measured 2217 glyphs. Related: #22170"), pr(8, "Related: #1874")]

self.assertEqual(self.progress(2217, prs)["merged"], "0")

def test_an_open_citing_pull_request_older_than_the_newest_thirty_is_found(self):
prs = [pr(1, "Related: #2056", "open")] + unrelated(2, 30, "open")

self.assertEqual(self.progress(2056, prs)["open"], "1")

def test_the_newest_open_citing_pull_request_is_reported(self):
prs = [pr(3, "Related: #2056", "open"), pr(9, "Related: #2056", "open")]
prs += unrelated(10, 3, "open") + [pr(20, "Related: #2056")]

self.assertEqual(self.progress(2056, prs), {"merged": "1", "open": "9"})

def test_no_citing_pull_request_reports_none(self):
self.assertEqual(self.progress(2056, unrelated(1, 3)), {"merged": "0", "open": ""})

def test_a_failed_listing_is_an_error_not_a_count(self):
# Reading zero would record the run as `no-progress` and count it
# toward the blocked label; the loop must stop with a state error.
done = self.run_progress(2217, [pr(5, "Related: #2217")], FAKE_ERROR_PAGE="1")

self.assertNotEqual(done.returncode, 0)
self.assertNotIn("merged=", done.stdout)

def test_a_later_page_failure_does_not_report_a_partial_count(self):
prs = [pr(number, "Related: #2217") for number in range(1, 102)]
done = self.run_progress(2217, prs, FAKE_ERROR_PAGE="2")

self.assertNotEqual(done.returncode, 0)
self.assertNotIn("merged=", done.stdout)

def test_an_open_listing_failure_does_not_report_merged_progress(self):
prs = [pr(1, "Related: #2217")]
prs += [pr(number, "Related: #2217", "open") for number in range(2, 103)]
done = self.run_progress(2217, prs, FAKE_ERROR_PAGE="2")

self.assertNotEqual(done.returncode, 0)
self.assertEqual(done.stdout, "")

def test_the_newest_open_citer_is_found_on_a_later_page(self):
prs = [pr(1, "Related: #2217", "open")]
prs += unrelated(2, 100, "open") + [pr(102, "Related: #2217", "open", indexed=False)]

self.assertEqual(self.progress(2217, prs)["open"], "102")


if __name__ == "__main__":
unittest.main()
Loading