fix: keep Gitea links on configured forge (Closes #1072)
All checks were successful
CI / lint (pull_request) Successful in 2m47s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m8s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-18 08:58:35 +00:00
parent 0472e88d91
commit 79f6cecab4
9 changed files with 153 additions and 123 deletions

View File

@ -289,6 +289,11 @@ export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqli
uvicorn src.main:app --host 127.0.0.1 --port 8000 uvicorn src.main:app --host 127.0.0.1 --port 8000
``` ```
`GITEA_URL` is also the trust boundary for every Gitea resource link returned
to the browser. Configure the externally reachable scheme, host, and subpath
(for example `https://forge.example.com/git`); cross-origin, downgraded, and
same-host links outside that subpath are discarded.
For a systemd deployment, keep those values in a root-readable environment file For a systemd deployment, keep those values in a root-readable environment file
(`chmod 600`), reference it with `EnvironmentFile=`, and keep secrets out of the unit (`chmod 600`), reference it with `EnvironmentFile=`, and keep secrets out of the unit
command line and repository. Liveness alone does not prove that operators can use the command line and repository. Liveness alone does not prove that operators can use the

View File

@ -445,7 +445,7 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
return None return None
repository = item.get("repository") repository = item.get("repository")
repository = repository if isinstance(repository, dict) else {} repository = repository if isinstance(repository, dict) else {}
url = _safe_web_url(item.get("html_url")) url = _safe_gitea_web_url(item.get("html_url"))
if not ( if not (
isinstance(item.get("number"), int) isinstance(item.get("number"), int)
and isinstance(item.get("title"), str) and isinstance(item.get("title"), str)
@ -614,7 +614,7 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
if isinstance(label, dict) and isinstance(label.get("name"), str) if isinstance(label, dict) and isinstance(label.get("name"), str)
], ],
"assignees": assignee_names, "assignees": assignee_names,
"url": _safe_web_url(issue.get("html_url")), "url": _safe_gitea_web_url(issue.get("html_url")),
"claimable": actual_kind == "issue" and state == "open" and not assignee_names, "claimable": actual_kind == "issue" and state == "open" and not assignee_names,
"reopenable": actual_kind == "issue" and state == "closed", "reopenable": actual_kind == "issue" and state == "closed",
"assigned_to_me": bool(login and login in assignee_names), "assigned_to_me": bool(login and login in assignee_names),
@ -650,7 +650,7 @@ def _normalize_available_issue(item: Any) -> dict | None:
"assignees": [], "assignees": [],
"updated_at": item.get("updated_at", "") "updated_at": item.get("updated_at", "")
if isinstance(item.get("updated_at"), str) else "", if isinstance(item.get("updated_at"), str) else "",
"url": _safe_web_url(item.get("html_url")), "url": _safe_gitea_web_url(item.get("html_url")),
} }
@ -756,13 +756,6 @@ async def issues() -> WorkItems:
) )
def _safe_web_url(value: Any) -> str:
if not isinstance(value, str):
return ""
parsed = urlsplit(value)
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
def _safe_gitea_web_url(value: Any) -> str: def _safe_gitea_web_url(value: Any) -> str:
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
return "" return ""
@ -819,8 +812,8 @@ def _normalize_notifications(threads: Any) -> list[dict]:
subject = thread.get("subject") subject = thread.get("subject")
repository = repository if isinstance(repository, dict) else {} repository = repository if isinstance(repository, dict) else {}
subject = subject if isinstance(subject, dict) else {} subject = subject if isinstance(subject, dict) else {}
subject_url = _safe_web_url(subject.get("html_url")) subject_url = _safe_gitea_web_url(subject.get("html_url"))
latest_url = _safe_web_url(subject.get("latest_comment_html_url")) latest_url = _safe_gitea_web_url(subject.get("latest_comment_html_url"))
number_text = ( number_text = (
urlsplit(subject_url).path.rstrip("/").rsplit("/", 1)[-1] urlsplit(subject_url).path.rstrip("/").rsplit("/", 1)[-1]
if subject_url if subject_url
@ -1053,8 +1046,8 @@ async def notification_detail(thread_id: int) -> dict:
comment = comment if isinstance(comment, dict) else {} comment = comment if isinstance(comment, dict) else {}
user_value = comment.get("user") user_value = comment.get("user")
user = user_value if isinstance(user_value, dict) else {} user = user_value if isinstance(user_value, dict) else {}
subject_url = _safe_web_url(subject.get("html_url")) subject_url = _safe_gitea_web_url(subject.get("html_url"))
latest_url = _safe_web_url(comment.get("html_url")) or _safe_web_url( latest_url = _safe_gitea_web_url(comment.get("html_url")) or _safe_gitea_web_url(
subject.get("latest_comment_html_url") subject.get("latest_comment_html_url")
) )
assignee_values = subject_detail.get("assignees") assignee_values = subject_detail.get("assignees")
@ -1236,7 +1229,7 @@ async def upload_notification_attachment(
if not isinstance(attachment, dict): if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object") raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name") name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url")) url = _safe_gitea_web_url(attachment.get("browser_download_url"))
size = attachment.get("size") size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int): if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment") raise ValueError("Gitea did not confirm the attachment")
@ -1275,7 +1268,7 @@ def _normalize_issue_comment(comment: dict) -> dict:
"created_at": comment.get("created_at", "") "created_at": comment.get("created_at", "")
if isinstance(comment.get("created_at"), str) if isinstance(comment.get("created_at"), str)
else "", else "",
"url": _safe_web_url(comment.get("html_url")), "url": _safe_gitea_web_url(comment.get("html_url")),
} }
@ -1357,7 +1350,7 @@ async def upload_assigned_issue_attachment(
if not isinstance(attachment, dict): if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object") raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name") name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url")) url = _safe_gitea_web_url(attachment.get("browser_download_url"))
size = attachment.get("size") size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int): if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment") raise ValueError("Gitea did not confirm the attachment")
@ -1383,7 +1376,7 @@ async def upload_preview_attachment(
if not isinstance(attachment, dict): if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object") raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name") name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url")) url = _safe_gitea_web_url(attachment.get("browser_download_url"))
size = attachment.get("size") size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int): if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment") raise ValueError("Gitea did not confirm the attachment")
@ -1410,7 +1403,7 @@ async def upload_assigned_pull_attachment(
if not isinstance(attachment, dict): if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object") raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name") name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url")) url = _safe_gitea_web_url(attachment.get("browser_download_url"))
size = attachment.get("size") size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int): if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment") raise ValueError("Gitea did not confirm the attachment")
@ -1586,7 +1579,7 @@ async def create_issue(
"updated_at": issue.get("updated_at", "") "updated_at": issue.get("updated_at", "")
if isinstance(issue.get("updated_at"), str) if isinstance(issue.get("updated_at"), str)
else "", else "",
"url": _safe_web_url(issue.get("html_url")), "url": _safe_gitea_web_url(issue.get("html_url")),
} }
@ -1640,7 +1633,7 @@ async def claim_available_issue(repository: str, number: int) -> dict:
"assignees": logins, "assignees": logins,
"updated_at": confirmed.get("updated_at", "") "updated_at": confirmed.get("updated_at", "")
if isinstance(confirmed.get("updated_at"), str) else "", if isinstance(confirmed.get("updated_at"), str) else "",
"url": _safe_web_url(confirmed.get("html_url")), "url": _safe_gitea_web_url(confirmed.get("html_url")),
} }
@ -1700,7 +1693,7 @@ async def reopen_issue(repository: str, number: int) -> dict:
"assignees": logins, "assignees": logins,
"updated_at": confirmed.get("updated_at", "") "updated_at": confirmed.get("updated_at", "")
if isinstance(confirmed.get("updated_at"), str) else "", if isinstance(confirmed.get("updated_at"), str) else "",
"url": _safe_web_url(confirmed.get("html_url")), "url": _safe_gitea_web_url(confirmed.get("html_url")),
} }
@ -2256,7 +2249,7 @@ async def issue_detail(repository: str, number: int) -> dict:
and isinstance(issue["milestone"].get("title"), str) and isinstance(issue["milestone"].get("title"), str)
else None else None
), ),
"url": _safe_web_url(issue.get("html_url")), "url": _safe_gitea_web_url(issue.get("html_url")),
"labels": [ "labels": [
label["name"] for label in labels label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str) if isinstance(label, dict) and isinstance(label.get("name"), str)
@ -2297,7 +2290,7 @@ async def issue_dependencies(repository: str, number: int, limit: int = 20) -> l
"number": dependency_number, "number": dependency_number,
"title": item.get("title", "") if isinstance(item.get("title"), str) else "", "title": item.get("title", "") if isinstance(item.get("title"), str) else "",
"state": "open", "state": "open",
"url": _safe_web_url(item.get("html_url")), "url": _safe_gitea_web_url(item.get("html_url")),
}) })
return dependencies return dependencies
@ -2425,7 +2418,7 @@ async def _update_issue_content(
"body": body, "body": body,
"state": issue.get("state", "open"), "state": issue.get("state", "open"),
"updated_at": issue.get("updated_at", ""), "updated_at": issue.get("updated_at", ""),
"url": _safe_web_url(issue.get("html_url")), "url": _safe_gitea_web_url(issue.get("html_url")),
} }
raise IssueEditConflictError("issue changed upstream") raise IssueEditConflictError("issue changed upstream")
@ -2450,7 +2443,7 @@ async def _update_issue_content(
"body": body, "body": body,
"state": confirmed.get("state", "open"), "state": confirmed.get("state", "open"),
"updated_at": confirmed.get("updated_at", ""), "updated_at": confirmed.get("updated_at", ""),
"url": _safe_web_url(confirmed.get("html_url")), "url": _safe_gitea_web_url(confirmed.get("html_url")),
} }
@ -2518,7 +2511,7 @@ async def resolve_work_route(
"number": number, "number": number,
"title": target.get("title", "") if isinstance(target.get("title"), str) else "", "title": target.get("title", "") if isinstance(target.get("title"), str) else "",
"state": state, "state": state,
"url": _safe_web_url(target.get("html_url")), "url": _safe_gitea_web_url(target.get("html_url")),
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}), **({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
**({ **({
"is_filed": True, "is_filed": True,
@ -2623,7 +2616,7 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
"number": number, "number": number,
"title": pull.get("title") if isinstance(pull.get("title"), str) else "", "title": pull.get("title") if isinstance(pull.get("title"), str) else "",
"body": pull.get("body") if isinstance(pull.get("body"), str) else "", "body": pull.get("body") if isinstance(pull.get("body"), str) else "",
"url": _safe_web_url(pull.get("html_url")), "url": _safe_gitea_web_url(pull.get("html_url")),
"author": user.get("login") if isinstance(user.get("login"), str) else "", "author": user.get("login") if isinstance(user.get("login"), str) else "",
"head_sha": sha, "head_sha": sha,
"state": pull.get("state") if isinstance(pull.get("state"), str) else "", "state": pull.get("state") if isinstance(pull.get("state"), str) else "",
@ -2865,7 +2858,7 @@ async def submit_pull_review(
return { return {
"id": review.get("id"), "id": review.get("id"),
"state": review.get("state") or "COMMENT", "state": review.get("state") or "COMMENT",
"url": _safe_web_url(review.get("html_url")), "url": _safe_gitea_web_url(review.get("html_url")),
} }

View File

@ -20,7 +20,7 @@ async def test_notification_page_preserves_upstream_total_without_loading_other_
"subject": { "subject": {
"title": "Retry failed deploy", "title": "Retry failed deploy",
"type": "Issue", "type": "Issue",
"html_url": "https://forge.example/stackchain/api/issues/7", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
}, },
}], }],
) )
@ -170,8 +170,8 @@ async def test_unread_notifications_are_normalized_for_mobile_handoff():
"title": "Retry failed deploy", "title": "Retry failed deploy",
"type": "Issue", "type": "Issue",
"state": "open", "state": "open",
"html_url": "https://forge.example/stackchain/api/issues/7", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", "latest_comment_html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
}, },
}, },
{"id": 43, "repository": None, "subject": None}, {"id": 43, "repository": None, "subject": None},
@ -193,8 +193,8 @@ async def test_unread_notifications_are_normalized_for_mobile_handoff():
"title": "Retry failed deploy", "title": "Retry failed deploy",
"subject_type": "Issue", "subject_type": "Issue",
"state": "open", "state": "open",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", "url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
"subject_url": "https://forge.example/stackchain/api/issues/7", "subject_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
}, },
{ {
"id": 43, "id": 43,
@ -245,8 +245,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"state": "open", "state": "open",
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7", "url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
"latest_comment_url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9", "latest_comment_url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
"html_url": "https://forge.example/stackchain/api/issues/7", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", "latest_comment_html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
}, },
}) })
if request.url.path.endswith("/issues/7"): if request.url.path.endswith("/issues/7"):
@ -262,7 +262,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"body": "Logs point to the worker timeout.", "body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z", "created_at": "2026-08-06T12:30:00Z",
"user": {"login": "alexander"}, "user": {"login": "alexander"},
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
}) })
if request.url.path.endswith("/issues/7/comments"): if request.url.path.endswith("/issues/7/comments"):
return httpx.Response(200, json=[{ return httpx.Response(200, json=[{
@ -270,7 +270,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"body": "Logs point to the worker timeout.", "body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z", "created_at": "2026-08-06T12:30:00Z",
"user": {"login": "alexander"}, "user": {"login": "alexander"},
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
}], headers={"X-Total-Count": "1"}) }], headers={"X-Total-Count": "1"})
return httpx.Response(404) return httpx.Response(404)
@ -292,13 +292,13 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"title": "Retry failed deploy", "title": "Retry failed deploy",
"subject_type": "Issue", "subject_type": "Issue",
"state": "open", "state": "open",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", "url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
"subject_body": "Deploy fails after **three** retries.", "subject_body": "Deploy fails after **three** retries.",
"latest_comment": { "latest_comment": {
"author": "alexander", "author": "alexander",
"body": "Logs point to the worker timeout.", "body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z", "created_at": "2026-08-06T12:30:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", "url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
}, },
"issue": {"number": 7, "assignees": [], "claimable": True}, "issue": {"number": 7, "assignees": [], "claimable": True},
"acknowledge_supported": True, "acknowledge_supported": True,
@ -362,7 +362,7 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
"user": {"login": "timmy"}, "user": {"login": "timmy"},
"body": "Please retry the worker.", "body": "Please retry the worker.",
"created_at": "2026-08-07T19:00:00Z", "created_at": "2026-08-07T19:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-91",
}) })
gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
@ -388,7 +388,7 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
"author": "timmy", "author": "timmy",
"body": "Please retry the worker.", "body": "Please retry the worker.",
"created_at": "2026-08-07T19:00:00Z", "created_at": "2026-08-07T19:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", "url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-91",
} }

View File

@ -47,7 +47,7 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages(
"title": "Older review", "title": "Older review",
"state": "open", "state": "open",
"repository": {"full_name": "stackchain/api"}, "repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/pulls/51", "html_url": "http://127.0.0.1:3000/stackchain/api/pulls/51",
}], }],
) )
@ -76,7 +76,7 @@ async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_dist
"id": 878, "number": 878, "title": "Completed delegation", "state": "closed", "id": 878, "number": 878, "title": "Completed delegation", "state": "closed",
"updated_at": "2026-08-15T12:00:00Z", "updated_at": "2026-08-15T12:00:00Z",
"repository": {"full_name": "stackchain/dashboard"}, "repository": {"full_name": "stackchain/dashboard"},
"html_url": "https://forge.example/stackchain/dashboard/issues/878", "html_url": "http://127.0.0.1:3000/stackchain/dashboard/issues/878",
}]) }])
gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
@ -94,7 +94,7 @@ async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_dist
"id": 878, "number": 878, "title": "Completed delegation", "state": "closed", "id": 878, "number": 878, "title": "Completed delegation", "state": "closed",
"updated_at": "2026-08-15T12:00:00Z", "updated_at": "2026-08-15T12:00:00Z",
"repository": {"full_name": "stackchain/dashboard"}, "repository": {"full_name": "stackchain/dashboard"},
"html_url": "https://forge.example/stackchain/dashboard/issues/878", "html_url": "http://127.0.0.1:3000/stackchain/dashboard/issues/878",
"work_reasons": ["created_by_me"], "work_reasons": ["created_by_me"],
}], }],
} }
@ -106,7 +106,7 @@ async def test_issue_context_merges_self_assigned_and_filed_streams_without_dupl
del page, limit del page, limit
common = { common = {
"id": 7, "number": 7, "title": "My own filing", "state": "open", "id": 7, "number": 7, "title": "My own filing", "state": "open",
"repository": {"full_name": "stackchain/api"}, "html_url": "https://forge.example/issues/7", "repository": {"full_name": "stackchain/api"}, "html_url": "http://127.0.0.1:3000/issues/7",
} }
if stream == "issue": if stream == "issue":
return {"items": [{**common, "assignees": [{"login": "timmy"}]}], "page": 1, "total": 1, "has_more": False} return {"items": [{**common, "assignees": [{"login": "timmy"}]}], "page": 1, "total": 1, "has_more": False}
@ -816,7 +816,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
"repository": {"full_name": "stackchain/mobile"}, "repository": {"full_name": "stackchain/mobile"},
"updated_at": "2026-08-06T12:00:00Z", "updated_at": "2026-08-06T12:00:00Z",
"due_date": "2026-08-09T23:59:59Z", "due_date": "2026-08-09T23:59:59Z",
"html_url": "https://forge.example/stackchain/mobile/issues/7", "html_url": "http://127.0.0.1:3000/stackchain/mobile/issues/7",
}] }]
async def pulls(): async def pulls():
@ -831,7 +831,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
"work_reasons": ["assigned_to_me", "review_requested"], "work_reasons": ["assigned_to_me", "review_requested"],
"repository": {"full_name": "stackchain/api"}, "repository": {"full_name": "stackchain/api"},
"updated_at": "2026-08-06T11:00:00Z", "updated_at": "2026-08-06T11:00:00Z",
"html_url": "https://forge.example/stackchain/api/pulls/7", "html_url": "http://127.0.0.1:3000/stackchain/api/pulls/7",
}] }]
monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(main, "current_user", user)
@ -864,7 +864,7 @@ async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypat
return { return {
"title": "Review API", "title": "Review API",
"body": "Please check the retry flow.", "body": "Please check the retry flow.",
"html_url": "https://forge.example/stackchain/api/pulls/7", "html_url": "http://127.0.0.1:3000/stackchain/api/pulls/7",
"user": {"login": "alex"}, "user": {"login": "alex"},
"head": {"sha": "abc123"}, "head": {"sha": "abc123"},
} }

View File

@ -131,7 +131,7 @@ async def test_global_search_specific_type_uses_one_full_width_scoped_stream():
"title": "Review mobile search", "title": "Review mobile search",
"state": "open", "state": "open",
"repository": {"full_name": "stackchain/web"}, "repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9", "html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
}]) }])
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -161,7 +161,7 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
"number": 42, "number": 42,
"title": "Repair mobile queue", "title": "Repair mobile queue",
"state": "open", "state": "open",
"url": "https://forge.example/stackchain/api/issues/42", "url": "http://127.0.0.1:3000/stackchain/api/issues/42",
}], }],
"partial": False, "partial": False,
} }
@ -180,7 +180,7 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
"number": 42, "number": 42,
"title": "Repair mobile queue", "title": "Repair mobile queue",
"state": "open", "state": "open",
"url": "https://forge.example/stackchain/api/issues/42", "url": "http://127.0.0.1:3000/stackchain/api/issues/42",
}], "partial": False} }], "partial": False}
@ -200,7 +200,7 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
"author": "alex", "author": "alex",
"labels": ["P1"], "labels": ["P1"],
"assignees": [], "assignees": [],
"url": "https://forge.example/stackchain/api/issues/42", "url": "http://127.0.0.1:3000/stackchain/api/issues/42",
"claimable": True, "claimable": True,
"assigned_to_me": False, "assigned_to_me": False,
} }
@ -233,7 +233,7 @@ async def test_global_search_preview_conversation_returns_a_bounded_authorized_p
"author": "alexander", "author": "alexander",
"body": "Current **decision**", "body": "Current **decision**",
"created_at": "2026-08-14T12:00:00Z", "created_at": "2026-08-14T12:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42#issuecomment-7", "url": "http://127.0.0.1:3000/stackchain/api/issues/42#issuecomment-7",
}], }],
"page": 3, "page": 3,
"older_page": 2, "older_page": 2,
@ -278,7 +278,7 @@ async def test_global_search_preview_reply_is_idempotent_for_an_authorized_pull(
"author": "timmy", "author": "timmy",
"body": body, "body": body,
"created_at": "2026-08-14T12:30:00Z", "created_at": "2026-08-14T12:30:00Z",
"url": "https://forge.example/stackchain/api/pulls/42#issuecomment-91", "url": "http://127.0.0.1:3000/stackchain/api/pulls/42#issuecomment-91",
} }
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
@ -345,7 +345,7 @@ async def test_global_search_preview_attachment_is_idempotent_for_exact_visible_
async def upload(repository, number, filename, content_type, content): async def upload(repository, number, filename, content_type, content):
uploads.append((repository, number, filename, content_type, content)) uploads.append((repository, number, filename, content_type, content))
return {"name": filename, "url": "https://forge.example/evidence/photo.png", "size": len(content)} return {"name": filename, "url": "http://127.0.0.1:3000/evidence/photo.png", "size": len(content)}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload) monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
@ -367,7 +367,7 @@ async def test_global_search_preview_attachment_is_idempotent_for_exact_visible_
assert first.status_code == replay.status_code == 201 assert first.status_code == replay.status_code == 201
assert replay.json() == first.json() assert replay.json() == first.json()
assert first.json()["markdown"] == "![photo.png](<https://forge.example/evidence/photo.png>)" assert first.json()["markdown"] == "![photo.png](<http://127.0.0.1:3000/evidence/photo.png>)"
assert previews == [("stackchain/api", "pull", 42)] assert previews == [("stackchain/api", "pull", 42)]
assert uploads == [("stackchain/api", 42, "photo.png", "image/png", image)] assert uploads == [("stackchain/api", 42, "photo.png", "image/png", image)]
@ -408,11 +408,11 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
return httpx.Response(200, json=[{ return httpx.Response(200, json=[{
"id": 4, "number": 42, "title": "Repair queue", "state": "open", "id": 4, "number": 42, "title": "Repair queue", "state": "open",
"repository": {"full_name": "stackchain/api"}, "repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/42", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
}, { }, {
"id": 4, "number": 42, "title": "Repair queue", "state": "open", "id": 4, "number": 42, "title": "Repair queue", "state": "open",
"repository": {"full_name": "stackchain/api"}, "repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/42", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
}, { }, {
"id": 5, "number": 43, "title": "Unsafe", "state": "open", "id": 5, "number": 43, "title": "Unsafe", "state": "open",
"repository": {"full_name": "stackchain/api"}, "repository": {"full_name": "stackchain/api"},
@ -421,7 +421,7 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
return httpx.Response(200, json=[{ return httpx.Response(200, json=[{
"id": 8, "number": 9, "title": "Improve search", "state": "closed", "id": 8, "number": 9, "title": "Improve search", "state": "closed",
"repository": {"full_name": "stackchain/web"}, "repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9", "html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
}]) }])
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -440,11 +440,11 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
"items": [{ "items": [{
"kind": "issue", "repository": "stackchain/api", "number": 42, "kind": "issue", "repository": "stackchain/api", "number": 42,
"title": "Repair queue", "state": "open", "title": "Repair queue", "state": "open",
"url": "https://forge.example/stackchain/api/issues/42", "url": "http://127.0.0.1:3000/stackchain/api/issues/42",
}, { }, {
"kind": "pull", "repository": "stackchain/web", "number": 9, "kind": "pull", "repository": "stackchain/web", "number": 9,
"title": "Improve search", "state": "closed", "title": "Improve search", "state": "closed",
"url": "https://forge.example/stackchain/web/pulls/9", "url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
}], }],
"partial": False, "partial": False,
"has_more": False, "has_more": False,
@ -454,6 +454,38 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
} }
@pytest.mark.anyio
async def test_global_search_keeps_results_on_the_configured_forge_origin(monkeypatch):
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example/git")
async def handler(request):
if request.url.params["type"] == "pulls":
return httpx.Response(200, json=[])
return httpx.Response(200, json=[{
"id": 1, "number": 41, "title": "Foreign", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://attacker.example/stackchain/api/issues/41",
}, {
"id": 3, "number": 43, "title": "Downgraded", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "http://forge.example/git/stackchain/api/issues/43",
}, {
"id": 2, "number": 42, "title": "Relative", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "/git/stackchain/api/issues/42#issuecomment-7",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search("queue", 10)
finally:
await gitea_proxy.stop_client()
assert [(item["number"], item["url"]) for item in result["items"]] == [
(42, "https://forge.example/git/stackchain/api/issues/42#issuecomment-7")
]
@pytest.mark.anyio @pytest.mark.anyio
async def test_global_search_returns_healthy_stream_when_other_stream_fails(): async def test_global_search_returns_healthy_stream_when_other_stream_fails():
async def handler(request): async def handler(request):
@ -464,7 +496,7 @@ async def test_global_search_returns_healthy_stream_when_other_stream_fails():
"title": "Improve search", "title": "Improve search",
"state": "open", "state": "open",
"repository": {"full_name": "stackchain/web"}, "repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9", "html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
}]) }])
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -480,7 +512,7 @@ async def test_global_search_returns_healthy_stream_when_other_stream_fails():
"number": 9, "number": 9,
"title": "Improve search", "title": "Improve search",
"state": "open", "state": "open",
"url": "https://forge.example/stackchain/web/pulls/9", "url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
}], }],
"partial": True, "partial": True,
"has_more": True, "has_more": True,
@ -509,7 +541,7 @@ async def test_global_search_retries_a_failed_stream_without_advancing_its_page(
"title": f'{params["type"]} {number}', "title": f'{params["type"]} {number}',
"state": "open", "state": "open",
"repository": {"full_name": "stackchain/web"}, "repository": {"full_name": "stackchain/web"},
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}', "html_url": f'http://127.0.0.1:3000/stackchain/web/{params["type"]}/{number}',
}]) }])
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -546,7 +578,7 @@ async def test_global_search_odd_limit_keeps_both_streams_contiguous_across_page
"title": f'{params["type"]} {number}', "title": f'{params["type"]} {number}',
"state": "open", "state": "open",
"repository": {"full_name": "stackchain/web"}, "repository": {"full_name": "stackchain/web"},
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}', "html_url": f'http://127.0.0.1:3000/stackchain/web/{params["type"]}/{number}',
} for number in range(start, start + stream_limit)]) } for number in range(start, start + stream_limit)])
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -579,7 +611,7 @@ async def test_global_search_caps_combined_results_to_requested_limit():
"title": f"{kind} {number}", "title": f"{kind} {number}",
"state": "open", "state": "open",
"repository": {"full_name": "stackchain/web"}, "repository": {"full_name": "stackchain/web"},
"html_url": f"https://forge.example/stackchain/web/{kind}/{number}", "html_url": f"http://127.0.0.1:3000/stackchain/web/{kind}/{number}",
} for number in (1, 2)] } for number in (1, 2)]
return httpx.Response(200, json=items) return httpx.Response(200, json=items)
@ -607,7 +639,7 @@ async def test_global_search_pages_each_stream_and_balances_results():
"title": f"{kind} page {page} result {number}", "title": f"{kind} page {page} result {number}",
"state": "open", "state": "open",
"repository": {"full_name": "stackchain/web"}, "repository": {"full_name": "stackchain/web"},
"html_url": f"https://forge.example/stackchain/web/{kind}/{page * 10 + number}", "html_url": f"http://127.0.0.1:3000/stackchain/web/{kind}/{page * 10 + number}",
} for number in (1, 2, 3, 4)] } for number in (1, 2, 3, 4)]
return httpx.Response(200, json=items) return httpx.Response(200, json=items)
@ -650,7 +682,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
"title": "Repair queue", "title": "Repair queue",
"body": "Keep mobile operators moving.", "body": "Keep mobile operators moving.",
"state": "open", "state": "open",
"html_url": "https://forge.example/stackchain/api/issues/42", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
"user": {"login": "alex"}, "user": {"login": "alex"},
"labels": [{"name": "P1"}, None], "labels": [{"name": "P1"}, None],
"assignees": [], "assignees": [],
@ -672,7 +704,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
"author": "alex", "author": "alex",
"labels": ["P1"], "labels": ["P1"],
"assignees": [], "assignees": [],
"url": "https://forge.example/stackchain/api/issues/42", "url": "http://127.0.0.1:3000/stackchain/api/issues/42",
"claimable": True, "claimable": True,
"reopenable": False, "reopenable": False,
"assigned_to_me": False, "assigned_to_me": False,
@ -690,7 +722,7 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
"number": 9, "number": 9,
"title": "Improve search", "title": "Improve search",
"state": "open", "state": "open",
"html_url": "https://forge.example/stackchain/web/pulls/9", "html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"pull_request": {"merged": False}, "pull_request": {"merged": False},
"assignees": [], "assignees": [],
}) })
@ -722,7 +754,7 @@ async def test_work_preview_marks_authoritative_requested_pull_review_actionable
"number": 9, "number": 9,
"title": "Improve search", "title": "Improve search",
"state": "open", "state": "open",
"html_url": "https://forge.example/stackchain/web/pulls/9", "html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"pull_request": {"merged": False}, "pull_request": {"merged": False},
"assignees": [], "assignees": [],
}) })
@ -746,7 +778,7 @@ async def test_work_preview_offers_reopen_only_for_closed_issues():
"number": 42, "number": 42,
"title": "Resume work", "title": "Resume work",
"state": "closed", "state": "closed",
"html_url": "https://forge.example/stackchain/api/issues/42", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
"assignees": [], "assignees": [],
}) })

View File

@ -680,7 +680,7 @@ async def test_gitea_edit_issue_confirms_already_applied_content_after_lost_resp
"updated_at": "2026-08-07T10:02:00Z", "updated_at": "2026-08-07T10:02:00Z",
"assignees": [{"login": "timmy"}], "assignees": [{"login": "timmy"}],
"pull_request": None, "pull_request": None,
"html_url": "https://forge.example/stackchain/api/issues/17", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}) })
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -699,7 +699,7 @@ async def test_gitea_edit_issue_confirms_already_applied_content_after_lost_resp
assert result == { assert result == {
"repository": "stackchain/api", "number": 17, "title": "My draft", "repository": "stackchain/api", "number": 17, "title": "My draft",
"body": "Draft body", "state": "open", "updated_at": "2026-08-07T10:02:00Z", "body": "Draft body", "state": "open", "updated_at": "2026-08-07T10:02:00Z",
"url": "https://forge.example/stackchain/api/issues/17", "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
} }
@ -720,7 +720,7 @@ async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
return httpx.Response(200, json={ return httpx.Response(200, json={
"number": 17, "title": "Clarified", "body": "New body", "state": "open", "number": 17, "title": "Clarified", "body": "New body", "state": "open",
"updated_at": "2026-08-07T10:01:00Z", "updated_at": "2026-08-07T10:01:00Z",
"html_url": "https://forge.example/stackchain/api/issues/17", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}) })
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -740,7 +740,7 @@ async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
assert result == { assert result == {
"repository": "stackchain/api", "number": 17, "title": "Clarified", "repository": "stackchain/api", "number": 17, "title": "Clarified",
"body": "New body", "state": "open", "updated_at": "2026-08-07T10:01:00Z", "body": "New body", "state": "open", "updated_at": "2026-08-07T10:01:00Z",
"url": "https://forge.example/stackchain/api/issues/17", "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
} }
@ -804,7 +804,7 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
"labels": ["P0"], "labels": ["P0"],
"assignees": [assignee], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z", "updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17", "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
} }
monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "current_user", user)
@ -1014,7 +1014,7 @@ async def test_repository_search_endpoint_returns_minimal_visible_matches(monkey
"id": 151, "id": 151,
"name": "mobile", "name": "mobile",
"full_name": "stackchain/mobile", "full_name": "stackchain/mobile",
"html_url": "https://forge.example/stackchain/mobile", "html_url": "http://127.0.0.1:3000/stackchain/mobile",
"description": "secret context that the picker does not need", "description": "secret context that the picker does not need",
"private": True, "private": True,
}] }]
@ -1042,7 +1042,7 @@ async def test_repository_page_endpoint_and_issue_creation_support_later_reposit
return { return {
"items": [{ "items": [{
"id": 51, "name": "later", "full_name": "stackchain/later", "id": 51, "name": "later", "full_name": "stackchain/later",
"description": "", "html_url": "https://forge.example/stackchain/later", "description": "", "html_url": "http://127.0.0.1:3000/stackchain/later",
"updated_at": "2026-08-09T12:00:00Z", "updated_at": "2026-08-09T12:00:00Z",
}], }],
"page": 2, "total": 51, "has_more": False, "page": 2, "total": 51, "has_more": False,
@ -1061,7 +1061,7 @@ async def test_repository_page_endpoint_and_issue_creation_support_later_reposit
"id": 403, "number": 403, "title": title, "state": "open", "id": 403, "number": 403, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee], "repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-09T12:00:00Z", "updated_at": "2026-08-09T12:00:00Z",
"url": "https://forge.example/stackchain/later/issues/403", "url": "http://127.0.0.1:3000/stackchain/later/issues/403",
} }
monkeypatch.setattr(main.gitea_proxy, "repo_page", page) monkeypatch.setattr(main.gitea_proxy, "repo_page", page)
@ -1087,7 +1087,7 @@ def test_context_exposes_truthful_repository_pagination():
repositories = gitea_proxy.RepositoryItems( repositories = gitea_proxy.RepositoryItems(
[{ [{
"id": 1, "name": "api", "full_name": "stackchain/api", "id": 1, "name": "api", "full_name": "stackchain/api",
"description": "", "html_url": "https://forge.example/stackchain/api", "description": "", "html_url": "http://127.0.0.1:3000/stackchain/api",
"updated_at": "2026-08-09T12:00:00Z", "updated_at": "2026-08-09T12:00:00Z",
}], }],
{"page": 1, "total": 51, "has_more": True}, {"page": 1, "total": 51, "has_more": True},
@ -1124,7 +1124,7 @@ async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypa
"milestone": {"id": 9, "title": "August RC"}, "milestone": {"id": 9, "title": "August RC"},
"due_date": "2026-08-31T23:59:59Z", "due_date": "2026-08-31T23:59:59Z",
"updated_at": "2026-08-07T20:00:00Z", "updated_at": "2026-08-07T20:00:00Z",
"url": "https://forge.example/stackchain/api/issues/221", "url": "http://127.0.0.1:3000/stackchain/api/issues/221",
} }
monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "current_user", user)
@ -1176,7 +1176,7 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
"id": 81, "number": 17, "title": title, "state": "open", "id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee], "repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z", "updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17", "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
} }
monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "current_user", user)
@ -1222,7 +1222,7 @@ async def test_completed_issue_creation_replays_after_ledger_reconstruction(monk
"id": 201, "number": 201, "title": title, "state": "open", "id": 201, "number": 201, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee], "repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T15:00:00Z", "updated_at": "2026-08-07T15:00:00Z",
"url": "https://forge.example/stackchain/api/issues/201", "url": "http://127.0.0.1:3000/stackchain/api/issues/201",
} }
database = tmp_path / "issues.sqlite3" database = tmp_path / "issues.sqlite3"
@ -1280,7 +1280,7 @@ async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_
"id": 81, "number": 17, "title": title, "state": "open", "id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee], "repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z", "updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17", "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
} }
monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "current_user", user)
@ -1324,7 +1324,7 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
"id": 81, "number": 17, "title": title, "state": "open", "id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee], "repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z", "updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17", "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
} }
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01) monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
@ -1367,7 +1367,7 @@ async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
"id": 81, "number": 17, "title": title, "state": "open", "id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee], "repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z", "updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17", "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
} }
ledger = IdempotencyLedger( ledger = IdempotencyLedger(
@ -1407,7 +1407,7 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
return httpx.Response(201, json={ return httpx.Response(201, json={
"id": 81, "number": 17, "title": "Capture mobile work", "state": "open", "id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
"updated_at": "2026-08-07T03:00:00Z", "updated_at": "2026-08-07T03:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/17", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
"assignees": [{"login": "timmy"}], "assignees": [{"login": "timmy"}],
"labels": [{"id": 3, "name": "P0"}], "labels": [{"id": 3, "name": "P0"}],
}) })
@ -1441,7 +1441,7 @@ async def test_gitea_create_issue_omits_assignment_and_confirms_no_owner():
return httpx.Response(201, json={ return httpx.Response(201, json={
"id": 83, "number": 19, "title": "Backlog capture", "state": "open", "id": 83, "number": 19, "title": "Backlog capture", "state": "open",
"updated_at": "2026-08-07T03:00:00Z", "updated_at": "2026-08-07T03:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/19", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/19",
"assignees": [], "labels": [], "assignees": [], "labels": [],
}) })
@ -1634,7 +1634,7 @@ async def test_gitea_create_issue_requires_confirmed_self_assignment():
async def handler(_request): async def handler(_request):
return httpx.Response(201, json={ return httpx.Response(201, json={
"id": 81, "number": 17, "title": "Capture mobile work", "state": "open", "id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
"html_url": "https://forge.example/stackchain/api/issues/17", "assignees": [], "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17", "assignees": [],
}) })
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -1657,14 +1657,14 @@ async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_
return httpx.Response(200, json={ return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Available", "state": "open", "id": 81, "number": 17, "title": "Available", "state": "open",
"assignees": None, "pull_request": None, "labels": [], "assignees": None, "pull_request": None, "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}) })
if request.method == "GET" and request.url.path == "/api/v1/user": if request.method == "GET" and request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"}) return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={ return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Available", "state": "open", "id": 81, "number": 17, "title": "Available", "state": "open",
"assignees": [{"login": "timmy"}], "labels": [], "assignees": [{"login": "timmy"}], "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}) })
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -1693,14 +1693,14 @@ async def test_gitea_reopen_issue_rechecks_closed_state_and_confirms_self_assign
return httpx.Response(200, json={ return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Resume work", "state": "closed", "id": 81, "number": 17, "title": "Resume work", "state": "closed",
"assignees": [], "pull_request": None, "labels": [{"name": "P1"}], "assignees": [], "pull_request": None, "labels": [{"name": "P1"}],
"html_url": "https://forge.example/stackchain/api/issues/17", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}) })
if request.method == "GET" and request.url.path == "/api/v1/user": if request.method == "GET" and request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"}) return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={ return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Resume work", "state": "open", "id": 81, "number": 17, "title": "Resume work", "state": "open",
"assignees": [{"login": "timmy"}], "labels": [{"name": "P1"}], "assignees": [{"login": "timmy"}], "labels": [{"name": "P1"}],
"html_url": "https://forge.example/stackchain/api/issues/17", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}) })
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -1719,7 +1719,7 @@ async def test_gitea_reopen_issue_rechecks_closed_state_and_confirms_self_assign
"id": 81, "number": 17, "title": "Resume work", "state": "open", "id": 81, "number": 17, "title": "Resume work", "state": "open",
"repository": "stackchain/api", "labels": ["P1"], "repository": "stackchain/api", "labels": ["P1"],
"assignees": ["timmy"], "updated_at": "", "assignees": ["timmy"], "updated_at": "",
"url": "https://forge.example/stackchain/api/issues/17", "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
} }
@ -1734,7 +1734,7 @@ async def test_gitea_reopen_issue_retry_accepts_already_open_self_assigned_issue
return httpx.Response(200, json={ return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Resume work", "state": "open", "id": 81, "number": 17, "title": "Resume work", "state": "open",
"assignees": [{"login": "timmy"}], "pull_request": None, "labels": [], "assignees": [{"login": "timmy"}], "pull_request": None, "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}) })
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -1757,7 +1757,7 @@ async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkey
return { return {
"id": 81, "number": number, "title": "Available", "state": "open", "id": 81, "number": number, "title": "Available", "state": "open",
"repository": repository, "labels": [], "assignees": ["timmy"], "repository": repository, "labels": [], "assignees": ["timmy"],
"url": "https://forge.example/stackchain/api/issues/17", "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
} }
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim) monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
@ -1797,7 +1797,7 @@ async def test_reopen_issue_endpoint_returns_confirmed_resumable_work_item(monke
return { return {
"id": 81, "number": number, "title": "Resume work", "state": "open", "id": 81, "number": number, "title": "Resume work", "state": "open",
"repository": repository, "labels": ["P1"], "assignees": ["timmy"], "repository": repository, "labels": ["P1"], "assignees": ["timmy"],
"url": "https://forge.example/stackchain/api/issues/17", "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
} }
monkeypatch.setattr(main.gitea_proxy, "reopen_issue", reopen, raising=False) monkeypatch.setattr(main.gitea_proxy, "reopen_issue", reopen, raising=False)
@ -2564,7 +2564,7 @@ async def test_gitea_issue_comment_posts_body_and_returns_safe_identity():
"id": 82, "id": 82,
"body": "Ready to ship", "body": "Ready to ship",
"created_at": "2026-08-07T10:10:00Z", "created_at": "2026-08-07T10:10:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-82", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-82",
"user": {"login": "timmy"}, "user": {"login": "timmy"},
}, },
) )
@ -2584,7 +2584,7 @@ async def test_gitea_issue_comment_posts_body_and_returns_safe_identity():
"author": "timmy", "author": "timmy",
"body": "Ready to ship", "body": "Ready to ship",
"created_at": "2026-08-07T10:10:00Z", "created_at": "2026-08-07T10:10:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-82", "url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-82",
} }
@ -2602,7 +2602,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"number": 3, "number": 3,
"title": "Restore signing service", "title": "Restore signing service",
"state": "open", "state": "open",
"html_url": "https://forge.example/stackchain/platform/issues/3", "html_url": "http://127.0.0.1:3000/stackchain/platform/issues/3",
"repository": {"full_name": "stackchain/platform"}, "repository": {"full_name": "stackchain/platform"},
}, },
{ {
@ -2621,7 +2621,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"id": 81, "id": 81,
"body": "Latest update", "body": "Latest update",
"created_at": "2026-08-07T10:00:00Z", "created_at": "2026-08-07T10:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-81", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-81",
"user": {"login": "sam"}, "user": {"login": "sam"},
} }
], ],
@ -2635,7 +2635,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"body": "Full issue context", "body": "Full issue context",
"updated_at": "2026-08-07T09:59:00Z", "updated_at": "2026-08-07T09:59:00Z",
"milestone": {"id": 9, "title": "August RC", "description": "not exposed"}, "milestone": {"id": 9, "title": "August RC", "description": "not exposed"},
"html_url": "https://forge.example/stackchain/api/issues/7", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
"labels": [{"name": "P1"}], "labels": [{"name": "P1"}],
"assignees": [{"login": "timmy"}], "assignees": [{"login": "timmy"}],
}, },
@ -2661,7 +2661,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"updated_at": "2026-08-07T09:59:00Z", "updated_at": "2026-08-07T09:59:00Z",
"due_date": None, "due_date": None,
"milestone": {"id": 9, "title": "August RC"}, "milestone": {"id": 9, "title": "August RC"},
"url": "https://forge.example/stackchain/api/issues/7", "url": "http://127.0.0.1:3000/stackchain/api/issues/7",
"labels": ["P1"], "labels": ["P1"],
"assignees": ["timmy"], "assignees": ["timmy"],
"dependencies_available": True, "dependencies_available": True,
@ -2671,7 +2671,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"number": 3, "number": 3,
"title": "Restore signing service", "title": "Restore signing service",
"state": "open", "state": "open",
"url": "https://forge.example/stackchain/platform/issues/3", "url": "http://127.0.0.1:3000/stackchain/platform/issues/3",
} }
], ],
"comments": [ "comments": [
@ -2680,7 +2680,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"author": "sam", "author": "sam",
"body": "Latest update", "body": "Latest update",
"created_at": "2026-08-07T10:00:00Z", "created_at": "2026-08-07T10:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81", "url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-81",
} }
], ],
"conversation": { "conversation": {
@ -2690,7 +2690,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"author": "sam", "author": "sam",
"body": "Latest update", "body": "Latest update",
"created_at": "2026-08-07T10:00:00Z", "created_at": "2026-08-07T10:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81", "url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-81",
} }
], ],
"page": 1, "page": 1,
@ -2984,7 +2984,7 @@ async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmat
return httpx.Response(200, json=[{ return httpx.Response(200, json=[{
"number": 9, "state": "open", "title": "Restore API", "number": 9, "state": "open", "title": "Restore API",
"repository": {"full_name": "stackchain/api"}, "repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/9", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/9",
}] if mutated else []) }] if mutated else [])
if request.method == "POST" and path.endswith("/dependencies"): if request.method == "POST" and path.endswith("/dependencies"):
return httpx.Response(201, json={}) return httpx.Response(201, json={})
@ -3003,7 +3003,7 @@ async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmat
assert json.loads(mutation.content) == {"owner": "stackchain", "repo": "api", "index": 9} assert json.loads(mutation.content) == {"owner": "stackchain", "repo": "api", "index": 9}
assert result["dependencies"] == [{ assert result["dependencies"] == [{
"repository": "stackchain/api", "number": 9, "title": "Restore API", "repository": "stackchain/api", "number": 9, "title": "Restore API",
"state": "open", "url": "https://forge.example/stackchain/api/issues/9", "state": "open", "url": "http://127.0.0.1:3000/stackchain/api/issues/9",
}] }]
@ -3013,7 +3013,7 @@ async def test_gitea_dependency_retry_returns_canonical_state_without_duplicate_
dependency = { dependency = {
"number": 9, "state": "open", "title": "Restore API", "number": 9, "state": "open", "title": "Restore API",
"repository": {"full_name": "stackchain/api"}, "repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/9", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/9",
} }
async def handler(request): async def handler(request):

View File

@ -356,7 +356,7 @@ async def test_gitea_attachment_revalidates_assignment_and_sends_multipart():
"id": 9, "id": 9,
"name": "checkout.png", "name": "checkout.png",
"size": len(PNG_BYTES), "size": len(PNG_BYTES),
"browser_download_url": "https://forge.example/attachments/checkout.png", "browser_download_url": "http://127.0.0.1:3000/attachments/checkout.png",
}) })
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -379,7 +379,7 @@ async def test_gitea_attachment_revalidates_assignment_and_sends_multipart():
assert PNG_BYTES in upload.content assert PNG_BYTES in upload.content
assert result == { assert result == {
"name": "checkout.png", "name": "checkout.png",
"url": "https://forge.example/attachments/checkout.png", "url": "http://127.0.0.1:3000/attachments/checkout.png",
"size": len(PNG_BYTES), "size": len(PNG_BYTES),
} }
@ -514,7 +514,7 @@ async def test_gitea_pull_attachment_revalidates_assignment_before_exact_pull_up
}) })
return httpx.Response(201, json={ return httpx.Response(201, json={
"name": "pull.png", "size": len(PNG_BYTES), "name": "pull.png", "size": len(PNG_BYTES),
"browser_download_url": "https://forge.example/attachments/pull.png", "browser_download_url": "http://127.0.0.1:3000/attachments/pull.png",
}) })
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -531,4 +531,4 @@ async def test_gitea_pull_attachment_revalidates_assignment_before_exact_pull_up
("POST", "/api/v1/repos/stackchain/web/issues/31/assets"), ("POST", "/api/v1/repos/stackchain/web/issues/31/assets"),
] ]
assert PNG_BYTES in requests[-1].content assert PNG_BYTES in requests[-1].content
assert result["url"] == "https://forge.example/attachments/pull.png" assert result["url"] == "http://127.0.0.1:3000/attachments/pull.png"

View File

@ -237,7 +237,7 @@ async def test_gitea_review_submission_checks_head_then_maps_decision_upstream()
json={ json={
"id": 91, "id": 91,
"state": "APPROVED", "state": "APPROVED",
"html_url": "https://forge.example/reviews/91", "html_url": "http://127.0.0.1:3000/reviews/91",
}, },
) )
@ -257,7 +257,7 @@ async def test_gitea_review_submission_checks_head_then_maps_decision_upstream()
assert result == { assert result == {
"id": 91, "id": 91,
"state": "APPROVED", "state": "APPROVED",
"url": "https://forge.example/reviews/91", "url": "http://127.0.0.1:3000/reviews/91",
} }

View File

@ -40,7 +40,7 @@ async def test_notification_attachment_endpoint_resolves_exact_pull_server_side(
async def upload(thread_id, filename, content_type, content): async def upload(thread_id, filename, content_type, content):
calls.append((thread_id, filename, content_type, content)) calls.append((thread_id, filename, content_type, content))
return {"name": filename, "url": "https://forge.example/a/proof.webp", "size": len(content)} return {"name": filename, "url": "http://127.0.0.1:3000/a/proof.webp", "size": len(content)}
monkeypatch.setattr(main.gitea_proxy, "upload_notification_attachment", upload, raising=False) monkeypatch.setattr(main.gitea_proxy, "upload_notification_attachment", upload, raising=False)
transport = httpx.ASGITransport(app=main.app) transport = httpx.ASGITransport(app=main.app)
@ -52,7 +52,7 @@ async def test_notification_attachment_endpoint_resolves_exact_pull_server_side(
) )
assert response.status_code == 201 assert response.status_code == 201
assert response.json()["markdown"] == "![proof.png](<https://forge.example/a/proof.webp>)" assert response.json()["markdown"] == "![proof.png](<http://127.0.0.1:3000/a/proof.webp>)"
assert calls == [(527, "proof.png", "image/png", PNG)] assert calls == [(527, "proof.png", "image/png", PNG)]
assert main.request_body_limit("POST", "/api/v1/notifications/527/attachments") == 2 * 1024 * 1024 + 64 * 1024 assert main.request_body_limit("POST", "/api/v1/notifications/527/attachments") == 2 * 1024 * 1024 + 64 * 1024
@ -73,7 +73,7 @@ async def test_proxy_notification_upload_trusts_only_matching_gitea_subject_path
}) })
return httpx.Response(201, json={ return httpx.Response(201, json={
"name": "proof.png", "size": len(PNG), "name": "proof.png", "size": len(PNG),
"browser_download_url": "https://forge.example/a/proof.png", "browser_download_url": "http://127.0.0.1:3000/a/proof.png",
}) })
gitea_proxy.start_client(transport=httpx.MockTransport(handler)) gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@ -86,7 +86,7 @@ async def test_proxy_notification_upload_trusts_only_matching_gitea_subject_path
("GET", "/api/v1/notifications/threads/527"), ("GET", "/api/v1/notifications/threads/527"),
("POST", "/api/v1/repos/stackchain/web/issues/31/assets"), ("POST", "/api/v1/repos/stackchain/web/issues/31/assets"),
] ]
assert result["url"] == "https://forge.example/a/proof.png" assert result["url"] == "http://127.0.0.1:3000/a/proof.png"
@pytest.mark.anyio @pytest.mark.anyio
@ -157,7 +157,7 @@ const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{i
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}}; complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}}; const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body instanceof FormData?'multipart':options.body?JSON.parse(options.body):null}}); calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body instanceof FormData?'multipart':options.body?JSON.parse(options.body):null}});
if(url.endsWith('/attachments'))return{{markdown:'![phone.webp](https://forge.example/phone.webp)'}}; if(url.endsWith('/attachments'))return{{markdown:'![phone.webp](http://127.0.0.1:3000/phone.webp)'}};
if(url.endsWith('/reply') && replyAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}} if(url.endsWith('/reply') && replyAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
return url.endsWith('/reply')?{{id:8}}:{{status:'read'}}; return url.endsWith('/reply')?{{id:8}}:{{status:'read'}};
}}; }};
@ -178,5 +178,5 @@ const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity'
assert [call["key"] for call in output["calls"][:3]] == [ assert [call["key"] for call in output["calls"][:3]] == [
"reply-image:attachment", "reply-image:reply", "reply-image:reply" "reply-image:attachment", "reply-image:reply", "reply-image:reply"
] ]
assert output["calls"][2]["body"] == {"body": "![phone.webp](https://forge.example/phone.webp)"} assert output["calls"][2]["body"] == {"body": "![phone.webp](http://127.0.0.1:3000/phone.webp)"}
assert output["result"]["confirmed"] == [{"status": "read"}] assert output["result"]["confirmed"] == [{"status": "read"}]