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 docs/operator-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
10 changes: 10 additions & 0 deletions src/reposteward/inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/reposteward/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
112 changes: 112 additions & 0 deletions tests/test_inbox.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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",
Expand Down