diff --git a/docs/operator-guide.zh-CN.md b/docs/operator-guide.zh-CN.md index 1ee0e95..5f649f6 100644 --- a/docs/operator-guide.zh-CN.md +++ b/docs/operator-guide.zh-CN.md @@ -286,6 +286,11 @@ uv run reposteward list --all uv run reposteward inbox --repo owner/repository --format text ``` +Portfolio 只读取开放 PR;只有开放快照完整时,当一个 tracked submitted PR 已不在该快照中, +Inbox 才会在 RepoSteward 本地原生合并审计的最新终态精确为 `merged` 或 `already_merged` 时隐藏 +该历史项目。Portfolio 读取失败或不完整,以及缺失、失败、未知或 closed-unmerged 合并结果仍显示 +为 `refresh_required`;开放 PR 的新鲜在线事实始终优先。 + 需要跨进程、账号或 Harness 保存批量待办顺序时,可先把稳定控制面引用写入本地任务队列;enqueue 不会执行任务、调用 Harness 或写入 GitHub: diff --git a/src/reposteward/inbox.py b/src/reposteward/inbox.py index b92090d..1345833 100644 --- a/src/reposteward/inbox.py +++ b/src/reposteward/inbox.py @@ -9,6 +9,7 @@ MAX_TEXT_ITEMS = 100 PULL_NUMBER = re.compile(r"/pull/([1-9][0-9]*)/?$") FAILED_CHECKS = {"failure", "timed_out", "cancelled", "action_required"} +MERGED_OUTCOMES = {"merged", "already_merged"} def _canonical_digest(value: object) -> str: @@ -61,6 +62,7 @@ def build_maintainer_inbox( proposals: list[dict[str, Any]], runs: list[dict[str, Any]], portfolio: dict[str, Any] | None, + merge_outcomes: dict[int, str] | None = None, observed_at: str, error: str = "", limit: int = 50, @@ -138,6 +140,14 @@ def build_maintainer_inbox( if status != "submitted": continue pull = pulls.get(pull_number) + if ( + pull is None + and pull_number + and portfolio_complete + and str((merge_outcomes or {}).get(pull_number) or "").casefold() + in MERGED_OUTCOMES + ): + continue if pull is None or not pull.get("facts_complete"): items.append( _item( diff --git a/src/reposteward/pipeline.py b/src/reposteward/pipeline.py index fb70f71..328a0c7 100644 --- a/src/reposteward/pipeline.py +++ b/src/reposteward/pipeline.py @@ -797,6 +797,7 @@ def maintainer_inbox(self, repository: str, *, limit: int = 50) -> dict[str, Any proposals=self.store.staged_issue_proposals(policy.name), runs=self.store.latest_runs_for_repository(policy.name), portfolio=portfolio, + merge_outcomes=self.store.latest_merge_outcomes(policy.name), observed_at=observed_at, error=error, limit=min(max(limit, 1), 500), diff --git a/tests/test_inbox.py b/tests/test_inbox.py index 3c172a0..3869cc8 100644 --- a/tests/test_inbox.py +++ b/tests/test_inbox.py @@ -1,8 +1,12 @@ from __future__ import annotations import unittest +from types import SimpleNamespace +from unittest.mock import Mock +from reposteward.github import GitHubError from reposteward.inbox import build_maintainer_inbox, render_inbox_text +from reposteward.pipeline import Pipeline def _pull( @@ -127,6 +131,114 @@ def test_incomplete_untracked_pull_requires_refresh(self) -> None: self.assertEqual(result["items"][0]["reason_code"], "refresh_required") self.assertEqual(result["items"][0]["priority"], 80) + def test_native_merged_outcomes_suppress_absent_tracked_pulls(self) -> None: + result = build_maintainer_inbox( + "owner/repo", + proposals=[], + runs=[ + _run("merged", "submitted", issue=1, pull=10), + _run("already", "submitted", issue=2, pull=11), + ], + portfolio={"snapshot": {"complete": True, "pull_requests": []}}, + merge_outcomes={10: "merged", 11: "already_merged"}, + observed_at="2026-08-22T01:00:00+00:00", + ) + + self.assertTrue(result["complete"]) + self.assertEqual(result["items"], []) + + def test_merged_outcome_does_not_suppress_without_complete_portfolio(self) -> None: + for portfolio, error in ( + (None, "rate limit"), + ({"snapshot": {"complete": False, "pull_requests": []}}, ""), + ): + with self.subTest(portfolio=portfolio): + result = build_maintainer_inbox( + "owner/repo", + proposals=[], + runs=[_run("merged", "submitted", issue=1, pull=10)], + portfolio=portfolio, + merge_outcomes={10: "merged"}, + observed_at="2026-08-22T01:00:00+00:00", + error=error, + ) + + self.assertIn( + (10, "refresh_required"), + [ + (item["pull_number"], item["reason_code"]) + for item in result["items"] + ], + ) + + def test_open_facts_and_non_merged_outcomes_remain_visible(self) -> None: + result = build_maintainer_inbox( + "owner/repo", + proposals=[], + runs=[ + _run("open", "submitted", issue=1, pull=10), + _run("failed", "submitted", issue=2, pull=20), + _run("unknown", "submitted", issue=3, pull=30), + ], + portfolio={ + "snapshot": { + "complete": True, + "pull_requests": [_pull(10)], + } + }, + merge_outcomes={10: "merged", 20: "failed", 30: "outcome_unknown"}, + observed_at="2026-08-22T01:00:00+00:00", + ) + + self.assertEqual( + [(item["pull_number"], item["reason_code"]) for item in result["items"]], + [ + (20, "refresh_required"), + (30, "refresh_required"), + (10, "merge_check_required"), + ], + ) + + def test_pipeline_passes_native_merge_outcomes_without_extra_github_reads( + self, + ) -> None: + pipeline = Pipeline.__new__(Pipeline) + pipeline.policy = Mock(return_value=SimpleNamespace(name="owner/repo")) + pipeline.portfolio_snapshot = Mock( + return_value={"snapshot": {"complete": True, "pull_requests": []}} + ) + pipeline.store = Mock() + pipeline.store.staged_issue_proposals.return_value = [] + pipeline.store.latest_runs_for_repository.return_value = [ + _run("merged", "submitted", issue=1, pull=10) + ] + pipeline.store.latest_merge_outcomes.return_value = {10: "merged"} + + result = pipeline.maintainer_inbox("OWNER/REPO") + + self.assertEqual(result["items"], []) + pipeline.portfolio_snapshot.assert_called_once_with("owner/repo") + pipeline.store.latest_merge_outcomes.assert_called_once_with("owner/repo") + + def test_pipeline_refresh_failure_keeps_locally_merged_run_visible(self) -> None: + pipeline = Pipeline.__new__(Pipeline) + pipeline.policy = Mock(return_value=SimpleNamespace(name="owner/repo")) + pipeline.portfolio_snapshot = Mock(side_effect=GitHubError("rate limit")) + pipeline.store = Mock() + pipeline.store.staged_issue_proposals.return_value = [] + pipeline.store.latest_runs_for_repository.return_value = [ + _run("merged", "submitted", issue=1, pull=10) + ] + pipeline.store.latest_merge_outcomes.return_value = {10: "merged"} + + result = pipeline.maintainer_inbox("owner/repo") + + self.assertFalse(result["complete"]) + self.assertEqual( + [item["reason_code"] for item in result["items"]], + ["github_refresh_failed", "refresh_required"], + ) + def test_empty_inbox_has_stable_text_and_digest(self) -> None: arguments = { "repository": "owner/repo",