From 6dba0bc225ea528e083780dd95e1f32faffd86c8 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Sat, 12 Sep 2026 11:09:36 -0700 Subject: [PATCH] Normalize GitHub bot login suffix --- docs/devin-work-orders.md | 4 +++- src/code_mower/devin_work_orders.py | 12 +++++++++++- tests/test_devin_work_orders.py | 21 +++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/devin-work-orders.md b/docs/devin-work-orders.md index 5e21352b..f592657d 100644 --- a/docs/devin-work-orders.md +++ b/docs/devin-work-orders.md @@ -69,7 +69,9 @@ multiple candidates, extra issue links, wrong repository, forks, wrong author nu ID/login, wrong base/head branch, closed-unmerged PRs, and any disagreement about the exact 40-character head SHA fail closed. An exact-bound merged PR is accepted for retrospective crash recovery; it must pass the same three fresh observations as an open -PR. Later rounds must retain the original PR number. +PR. The author ID always matches exactly; login comparison allows only GitHub's terminal +`[bot]` suffix alias between actor and GraphQL spellings for that same numeric account. +Later rounds must retain the original PR number. `GitHub` can instead be implemented by an embedding client under the documented bounded protocol; it must query GitHub independently, never normalize provider assertions into observations. The built-in adapter's `runner(query, variables, headers)` and the Devin diff --git a/src/code_mower/devin_work_orders.py b/src/code_mower/devin_work_orders.py index f309c60c..2d88a322 100644 --- a/src/code_mower/devin_work_orders.py +++ b/src/code_mower/devin_work_orders.py @@ -68,6 +68,16 @@ def _positive(value) -> bool: return type(value) is int and 0 < value <= 2**53 - 1 +def _same_author_login(expected, observed) -> bool: + """Compare one GitHub account across actor and GraphQL bot spellings.""" + if not isinstance(expected, str) or not isinstance(observed, str): + return False + suffix = "[bot]" + expected_base = expected[:-len(suffix)] if expected.endswith(suffix) else expected + observed_base = observed[:-len(suffix)] if observed.endswith(suffix) else observed + return expected_base == observed_base + + @dataclass(frozen=True, repr=False) class WorkOrder: """Construct only after the caller's trusted-author/work-order policy succeeds. @@ -341,7 +351,7 @@ def _verify(self, order, claim, round_number): or pr.linked_issues != ((order.repository, order.issue),) or type(pr.linked_issues[0][1]) is not int or type(pr.author_id) is not int or pr.author_id != order.author_id - or pr.author_login != order.author_login + or not _same_author_login(order.author_login, pr.author_login) or pr.head_repository != order.repository or pr.head_branch != order.branch or pr.head_sha != claim["head_sha"] or pr.base_branch != order.base or pr.state not in {"open", "merged"}): diff --git a/tests/test_devin_work_orders.py b/tests/test_devin_work_orders.py index b6e7438e..4d561ff7 100644 --- a/tests/test_devin_work_orders.py +++ b/tests/test_devin_work_orders.py @@ -187,6 +187,27 @@ def test_exact_pr_can_be_recovered_before_or_after_merge(self): with self.assertRaisesRegex(RemoteError, "pull_request_binding"): self.run_order("collect") + def test_exact_author_id_accepts_only_the_terminal_bot_login_alias(self): + self.run_order("dispatch") + self.complete() + original = self.github.pr + + self.github.pr = replace(original, author_login="builder") + self.assertEqual(self.run_order("collect")["verified_pr"]["author_id"], 123) + + rejected = ( + {"author_id": 456, "author_login": "builder"}, + {"author_login": "imposter"}, + {"author_login": "builder[bot]-other"}, + {"author_login": "builder[bot][bot]"}, + {"author_login": None}, + ) + for mutation in rejected: + with self.subTest(mutation=mutation): + self.github.pr = replace(original, **mutation) + with self.assertRaisesRegex(RemoteError, "pull_request_binding"): + self.run_order("collect") + def test_completion_validation_and_private_adapter_failures(self): self.run_order("dispatch") self.complete()