@@ -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