Skip to content

Commit 2e07c75

Browse files
authored
Merge pull request #44 from githubnext/copilot/enforce-single-pr-per-program
Enforce single-PR-per-program invariant: preserve-branch-name, existing_pr/head_branch, no-suffix prose
2 parents 716a4eb + 1dfeaa6 commit 2e07c75

5 files changed

Lines changed: 475 additions & 7 deletions

File tree

.github/workflows/scripts/autoloop_scheduler.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import re
4242
import sys
4343
import urllib.error
44+
import urllib.parse
4445
import urllib.request
4546
from datetime import datetime, timedelta, timezone
4647

@@ -345,6 +346,87 @@ def _parse_target_metric_from_file(path):
345346
return None
346347

347348

349+
# ---------------------------------------------------------------------------
350+
# Existing PR lookup (single-PR-per-program invariant)
351+
# ---------------------------------------------------------------------------
352+
353+
354+
def _http_get_json(url, headers, timeout=30):
355+
"""Open ``url`` and return ``(parsed_body, link_header)``.
356+
357+
Returns ``(None, None)`` on any HTTP/network error so callers can fall
358+
through to the next strategy. Broken out into a module-level helper so
359+
tests can monkey-patch it without touching ``urllib`` directly.
360+
"""
361+
try:
362+
req = urllib.request.Request(url, headers=headers)
363+
with urllib.request.urlopen(req, timeout=timeout) as resp:
364+
body = json.loads(resp.read().decode())
365+
link_header = resp.headers.get("link") or resp.headers.get("Link")
366+
return body, link_header
367+
except (urllib.error.URLError, urllib.error.HTTPError, ValueError, OSError):
368+
return None, None
369+
370+
371+
def find_existing_pr_for_branch(repo, program_name, github_token, http_get_json=_http_get_json):
372+
"""Look up the open draft PR (if any) for ``autoloop/{program_name}``.
373+
374+
Returns the PR number, or ``None`` if none is found.
375+
376+
The single-PR-per-program invariant requires that we never open a second
377+
draft PR for the same program. The agent uses the returned ``existing_pr``
378+
to decide between ``create-pull-request`` (only if ``None``) and
379+
``push-to-pull-request-branch`` (always preferred when an open PR exists).
380+
381+
We also tolerate legacy framework-suffixed branch names of the form
382+
``autoloop/{program}-<6-40 hex chars>`` so installations upgrading from
383+
before ``preserve-branch-name: true`` was set find their in-flight PR
384+
rather than opening a second one.
385+
"""
386+
if not repo or not program_name or not github_token:
387+
return None
388+
owner = repo.split("/", 1)[0]
389+
canonical_branch = "autoloop/{}".format(program_name)
390+
headers = {
391+
"Authorization": "token {}".format(github_token),
392+
"Accept": "application/vnd.github.v3+json",
393+
}
394+
# Strategy 1: exact canonical branch name via the head= filter.
395+
head_q = urllib.parse.quote("{}:{}".format(owner, canonical_branch), safe="")
396+
url = "https://api.github.com/repos/{}/pulls?head={}&state=open".format(repo, head_q)
397+
body, _ = http_get_json(url, headers)
398+
if isinstance(body, list) and body:
399+
first = body[0]
400+
if isinstance(first, dict) and first.get("number"):
401+
return first["number"]
402+
403+
# Strategy 2: paginate open PRs and match either a legacy framework-suffixed
404+
# branch (``autoloop/{name}-<6-40 hex>``) or a ``[Autoloop: {name}]`` title prefix.
405+
suffix_regex = re.compile(
406+
r"^autoloop/" + re.escape(program_name) + r"(-[0-9a-f]{6,40})?$"
407+
)
408+
title_prefix = "[Autoloop: {}]".format(program_name)
409+
next_url = "https://api.github.com/repos/{}/pulls?state=open&per_page=100".format(repo)
410+
while next_url:
411+
body, link_header = http_get_json(next_url, headers)
412+
if not isinstance(body, list):
413+
break
414+
for pr in body:
415+
if not isinstance(pr, dict):
416+
continue
417+
head_ref = ""
418+
head = pr.get("head") or {}
419+
if isinstance(head, dict):
420+
head_ref = head.get("ref") or ""
421+
if suffix_regex.match(head_ref):
422+
return pr.get("number")
423+
title = pr.get("title")
424+
if isinstance(title, str) and title.startswith(title_prefix):
425+
return pr.get("number")
426+
next_url = parse_link_header(link_header)
427+
return None
428+
429+
348430
# ---------------------------------------------------------------------------
349431
# Selection
350432
# ---------------------------------------------------------------------------
@@ -441,7 +523,15 @@ def main():
441523
print("NO_PROGRAMS_FOUND")
442524
with open(OUTPUT_FILE, "w") as f:
443525
json.dump(
444-
{"due": [], "skipped": [], "unconfigured": [], "no_programs": True}, f
526+
{
527+
"due": [],
528+
"skipped": [],
529+
"unconfigured": [],
530+
"no_programs": True,
531+
"head_branch": None,
532+
"existing_pr": None,
533+
},
534+
f,
445535
)
446536
sys.exit(0)
447537

@@ -513,6 +603,20 @@ def main():
513603
if forced_program and selected:
514604
print("FORCED: running program '{}' (manual dispatch)".format(forced_program))
515605

606+
# Look up the existing draft PR (if any) for the selected program, so the
607+
# agent can enforce the single-PR-per-program invariant: never call
608+
# create-pull-request when a PR for autoloop/{name} already exists.
609+
# head_branch is always the canonical name (no suffix, no hash).
610+
head_branch = None
611+
existing_pr = None
612+
if selected:
613+
head_branch = "autoloop/{}".format(selected)
614+
try:
615+
existing_pr = find_existing_pr_for_branch(repo, selected, github_token)
616+
except Exception as e: # noqa: BLE001 -- best-effort lookup
617+
print(" Warning: existing PR lookup failed for {}: {}".format(selected, e))
618+
existing_pr = None
619+
516620
result = {
517621
"selected": selected,
518622
"selected_file": selected_file,
@@ -525,6 +629,8 @@ def main():
525629
"skipped": skipped,
526630
"unconfigured": unconfigured,
527631
"no_programs": False,
632+
"head_branch": head_branch,
633+
"existing_pr": existing_pr,
528634
}
529635

530636
with open(OUTPUT_FILE, "w") as f:

tests/test_scheduler_e2e.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,24 @@ def test_no_programs_found(self, workdir):
257257
assert proc.returncode == 0, proc.stderr
258258
assert out["unconfigured"] == ["example"]
259259
assert out["selected"] is None
260+
261+
def test_head_branch_set_when_program_selected(self, workdir):
262+
"""``head_branch`` is exactly ``autoloop/{name}`` for the selected program."""
263+
_write_program(workdir, "coverage")
264+
proc, out = _run_scheduler(str(workdir))
265+
assert proc.returncode == 0, proc.stderr
266+
assert out["selected"] == "coverage"
267+
assert out["head_branch"] == "autoloop/coverage"
268+
# The bogus DNS repo means the PR API call fails → existing_pr is None.
269+
assert out["existing_pr"] is None
270+
271+
def test_head_branch_null_when_nothing_selected(self, workdir):
272+
"""When no program is due, ``head_branch`` is ``null`` in the output."""
273+
# Empty programs dir → bootstrap creates an unconfigured template, which
274+
# does NOT count as selected. So head_branch should be null.
275+
shutil.rmtree(workdir / ".autoloop" / "programs")
276+
proc, out = _run_scheduler(str(workdir))
277+
assert proc.returncode == 0, proc.stderr
278+
assert out["selected"] is None
279+
assert out["head_branch"] is None
280+
assert out["existing_pr"] is None

tests/test_scheduling.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,3 +770,208 @@ def test_lock_creds_before_merge(self):
770770
f"'Configure Git credentials' (index {cred_idx}) must come before "
771771
f"merge step (index {merge_idx}). Steps: {steps}"
772772
)
773+
774+
775+
# ---------------------------------------------------------------------------
776+
# Single-PR-per-program invariant: safe-outputs config + existing_pr lookup
777+
# (issue: enforce single-PR-per-program invariant)
778+
# ---------------------------------------------------------------------------
779+
780+
class TestSafeOutputsConfig:
781+
"""Verify the safe-outputs config that defends the single-PR invariant.
782+
783+
Without `preserve-branch-name: true`, the gh-aw framework auto-suffixes
784+
branch names on every run, breaking the single-long-running-branch model.
785+
Without `max: 1` on both create-pull-request and push-to-pull-request-branch,
786+
the agent could emit a create+create or create+push pair in the same iteration.
787+
"""
788+
789+
def _frontmatter(self):
790+
import os
791+
wf_path = os.path.join(os.path.dirname(__file__), "..", "workflows", "autoloop.md")
792+
with open(wf_path) as f:
793+
content = f.read()
794+
# Frontmatter is the first --- ... --- block
795+
m = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
796+
assert m, "Could not find YAML frontmatter in workflows/autoloop.md"
797+
return m.group(1)
798+
799+
def test_create_pr_preserves_branch_name(self):
800+
fm = self._frontmatter()
801+
# create-pull-request block must contain preserve-branch-name: true
802+
m = re.search(r"create-pull-request:\s*\n((?:\s{4}.*\n)+)", fm)
803+
assert m, "Could not find create-pull-request block in safe-outputs"
804+
block = m.group(1)
805+
assert "preserve-branch-name: true" in block, (
806+
"create-pull-request must set 'preserve-branch-name: true' to keep "
807+
"the canonical branch name autoloop/{program}; otherwise gh-aw "
808+
"appends a hex salt and breaks the single-PR invariant.\n"
809+
f"Block: {block}"
810+
)
811+
812+
def test_create_pr_max_is_one(self):
813+
fm = self._frontmatter()
814+
m = re.search(r"create-pull-request:\s*\n((?:\s{4}.*\n)+)", fm)
815+
assert m
816+
block = m.group(1)
817+
assert re.search(r"^\s*max:\s*1\s*$", block, re.MULTILINE), (
818+
"create-pull-request must set 'max: 1' — the invariant is one "
819+
"safe-output of either create or push per iteration, never two.\n"
820+
f"Block: {block}"
821+
)
822+
823+
def test_push_to_pr_max_is_one(self):
824+
fm = self._frontmatter()
825+
m = re.search(r"push-to-pull-request-branch:\s*\n((?:\s{4}.*\n)+)", fm)
826+
assert m, "Could not find push-to-pull-request-branch block in safe-outputs"
827+
block = m.group(1)
828+
assert re.search(r"^\s*max:\s*1\s*$", block, re.MULTILINE), (
829+
"push-to-pull-request-branch must set 'max: 1'.\n"
830+
f"Block: {block}"
831+
)
832+
833+
834+
class TestProseGuidance:
835+
"""Verify the prose guidance enforcing the single-PR invariant is present."""
836+
837+
def _content(self):
838+
import os
839+
wf_path = os.path.join(os.path.dirname(__file__), "..", "workflows", "autoloop.md")
840+
with open(wf_path) as f:
841+
return f.read()
842+
843+
def test_branch_name_warning_present(self):
844+
c = self._content()
845+
assert "Branch Name Must Be Exact" in c, (
846+
"Missing the 'Branch Name Must Be Exact' warning that tells the "
847+
"agent to never use suffixed branch names."
848+
)
849+
assert "no suffixes" in c.lower(), "Warning should mention 'no suffixes'"
850+
851+
def test_common_mistakes_section_present(self):
852+
c = self._content()
853+
assert "## Common Mistakes to Avoid" in c, (
854+
"Missing the 'Common Mistakes to Avoid' section."
855+
)
856+
857+
def test_step5_uses_existing_pr(self):
858+
c = self._content()
859+
# Step 5 accept flow must reference existing_pr from autoloop.json
860+
assert "existing_pr" in c, (
861+
"Workflow prose must instruct the agent to consult the "
862+
"`existing_pr` field from /tmp/gh-aw/autoloop.json."
863+
)
864+
assert "head_branch" in c, (
865+
"Workflow prose must instruct the agent to use the `head_branch` "
866+
"field from /tmp/gh-aw/autoloop.json."
867+
)
868+
869+
870+
# ---------------------------------------------------------------------------
871+
# find_existing_pr_for_branch helper — tolerant lookup of the open draft PR
872+
# ---------------------------------------------------------------------------
873+
874+
875+
def _run_find_existing_pr(program, mock_responses):
876+
"""Invoke ``find_existing_pr_for_branch`` with a stubbed HTTP client.
877+
878+
``mock_responses`` is a list of dicts: ``{ url_match, status, body, link }``.
879+
The first entry whose ``url_match`` substring is contained in the requested
880+
URL wins. The optional ``link`` field is returned as the Link response
881+
header (used by pagination via ``parse_link_header``). ``status`` is kept
882+
for parity with the previous JS-based stub but only the ``200`` path is
883+
exercised — non-200 responses surface as ``(None, None)`` from the real
884+
``_http_get_json``, which the helper here mirrors when ``status != 200``.
885+
"""
886+
def stub(url, headers, timeout=30):
887+
for r in mock_responses:
888+
if r["url_match"] in url:
889+
if r.get("status", 200) != 200:
890+
return None, None
891+
return r.get("body"), r.get("link")
892+
return None, None
893+
894+
return autoloop_scheduler.find_existing_pr_for_branch(
895+
"owner/repo", program, "TOKEN", http_get_json=stub
896+
)
897+
898+
899+
class TestFindExistingPRForBranch:
900+
"""The tolerant PR lookup that closes the single-PR-per-program invariant."""
901+
902+
def test_returns_null_when_no_pr_exists(self):
903+
# Strategy 1 returns []; strategy 2 returns []
904+
responses = [
905+
{"url_match": "head=owner%3Aautoloop%2Fcoverage", "status": 200, "body": []},
906+
{"url_match": "/pulls?state=open", "status": 200, "body": []},
907+
]
908+
assert _run_find_existing_pr("coverage", responses) is None
909+
910+
def test_finds_pr_with_canonical_branch_name(self):
911+
responses = [
912+
{
913+
"url_match": "head=owner%3Aautoloop%2Fcoverage",
914+
"status": 200,
915+
"body": [{"number": 42, "head": {"ref": "autoloop/coverage"}, "title": "[Autoloop] x"}],
916+
},
917+
]
918+
assert _run_find_existing_pr("coverage", responses) == 42
919+
920+
def test_finds_pr_with_legacy_hex_suffix(self):
921+
# Strategy 1 finds nothing (the open PR has a suffixed branch name);
922+
# Strategy 2 falls back to listing all open PRs and matches the suffix regex.
923+
responses = [
924+
{"url_match": "head=owner%3Aautoloop%2Fcoverage", "status": 200, "body": []},
925+
{
926+
"url_match": "/pulls?state=open",
927+
"status": 200,
928+
"body": [
929+
{"number": 99, "head": {"ref": "autoloop/coverage-8724e9f9"}, "title": "[Autoloop] x"},
930+
],
931+
},
932+
]
933+
assert _run_find_existing_pr("coverage", responses) == 99
934+
935+
def test_finds_pr_via_title_prefix_fallback(self):
936+
# Branch name doesn't match suffix pattern, but title prefix does
937+
responses = [
938+
{"url_match": "head=owner%3Aautoloop%2Fcoverage", "status": 200, "body": []},
939+
{
940+
"url_match": "/pulls?state=open",
941+
"status": 200,
942+
"body": [
943+
{"number": 7, "head": {"ref": "totally-different-branch"}, "title": "[Autoloop: coverage] iter 3"},
944+
],
945+
},
946+
]
947+
assert _run_find_existing_pr("coverage", responses) == 7
948+
949+
def test_does_not_match_unrelated_program(self):
950+
# autoloop/coverage-extras is a different program, not a hex suffix
951+
responses = [
952+
{"url_match": "head=owner%3Aautoloop%2Fcoverage", "status": 200, "body": []},
953+
{
954+
"url_match": "/pulls?state=open",
955+
"status": 200,
956+
"body": [
957+
{"number": 11, "head": {"ref": "autoloop/coverage-extras"}, "title": "[Autoloop] other"},
958+
],
959+
},
960+
]
961+
assert _run_find_existing_pr("coverage", responses) is None
962+
963+
def test_does_not_match_other_program_with_similar_name(self):
964+
# Program name with regex-special-ish characters (underscore is fine, but
965+
# we want to make sure the regex is properly anchored to ^...$).
966+
responses = [
967+
{"url_match": "head=owner%3Aautoloop%2Fsignal_processing", "status": 200, "body": []},
968+
{
969+
"url_match": "/pulls?state=open",
970+
"status": 200,
971+
"body": [
972+
# Branch for a different program that happens to share a prefix
973+
{"number": 5, "head": {"ref": "autoloop/signal"}, "title": "[Autoloop] other"},
974+
],
975+
},
976+
]
977+
assert _run_find_existing_pr("signal_processing", responses) is None

0 commit comments

Comments
 (0)