-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapi.py
More file actions
227 lines (179 loc) · 10.2 KB
/
Copy pathapi.py
File metadata and controls
227 lines (179 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
"""HTTP for the GitHub console board — the view PAGE + its gated data routes.
Two routers, the notes-plugin pattern (ADR 0026/0038/0042):
- the view PAGE under the PUBLIC ``/plugins/github`` prefix (a browser iframe
page-load can't carry a bearer, so the page itself is public chrome); and
- the DATA routes under the GATED ``/api/plugins/github`` prefix (the operator
bearer gate), fetched from inside the loaded page with the postMessage handshake
token (the DS plugin-kit's ``apiFetch``).
The data logic lives in plain async functions (``fetch_issues`` / ``fetch_prs`` /
``status.compute_status``) so the suite can test it host-free; the FastAPI imports
stay lazy inside the build_* functions (fastapi is the host's at runtime).
``GET /status`` is the first-run probe (v0.6.0): is `gh` installed, authenticated,
as whom — the views render a setup card from it when something's missing, and
``/config`` names a malformed ``default_repo`` (#23) instead of feeding it to `gh`.
"""
from __future__ import annotations
import asyncio
from .gh_cli import bad_repo, check_gh_error, parse_json, resolve_gh, run_gh
# The JSON fields we ask `gh` for — kept lean: enough for a board row + the detail link.
_ISSUE_FIELDS = "number,title,state,author,labels,url,createdAt,comments"
# `baseRefName` + `mergeStateStatus` are for `github_list_prs` (read_tools reuses fetch_prs);
# the board rows simply ignore them.
_PR_FIELDS = (
"number,title,state,author,labels,url,createdAt,isDraft,headRefName,baseRefName,reviewDecision,mergeStateStatus"
)
def gh_available() -> bool:
"""Whether the `gh` CLI can be found (PATH, then the usual install dirs) — the
same resolver the runner uses, so this and a tool can never disagree."""
return resolve_gh() is not None
_PR_STATES = ("open", "closed", "merged", "all")
_ISSUE_STATES = ("open", "closed", "all") # issues have no "merged" — `gh issue list --state merged` errors
def _norm_state(state: str, allowed: tuple[str, ...] = _PR_STATES) -> str | None:
"""Normalise the state filter to what `gh` accepts, or None if invalid."""
s = (state or "open").strip().lower()
return s if s in allowed else None
async def fetch_issues(repo: str, state: str = "open", limit: int = 30) -> dict:
"""List issues for ``repo`` as ``{"items": [...]}`` (or ``{"error": "..."}``).
Each item is the raw `gh issue list --json` row (number/title/state/author/
labels/url/createdAt/comments). PRs are excluded — `gh issue list` already omits them.
"""
if err := bad_repo(repo):
return {"error": err}
norm = _norm_state(state, _ISSUE_STATES)
if norm is None:
return {"error": f"Error: state must be open|closed|all (got {state!r})."}
capped = max(1, min(int(limit), 100))
rc, out, serr = await run_gh(
["issue", "list", "--repo", repo, "--state", norm, "--limit", str(capped), "--json", _ISSUE_FIELDS]
)
if gh_err := check_gh_error(rc, serr, repo=repo):
return {"error": gh_err}
items, perr = parse_json(out or "[]", list) # a non-list body is an error, never a crash
return {"error": perr} if perr else {"items": items}
async def fetch_prs(repo: str, state: str = "open", limit: int = 30) -> dict:
"""List pull requests for ``repo`` as ``{"items": [...]}`` (or ``{"error": "..."}``).
Each item is the raw `gh pr list --json` row (adds isDraft/headRefName/baseRefName/
reviewDecision/mergeStateStatus). Shared by the board's /prs route and the
`github_list_prs` tool, so the two can never disagree about a PR's state.
"""
if err := bad_repo(repo):
return {"error": err}
norm = _norm_state(state)
if norm is None:
return {"error": f"Error: state must be open|closed|merged|all (got {state!r})."}
capped = max(1, min(int(limit), 100))
rc, out, serr = await run_gh(
["pr", "list", "--repo", repo, "--state", norm, "--limit", str(capped), "--json", _PR_FIELDS]
)
if gh_err := check_gh_error(rc, serr, repo=repo):
return {"error": gh_err}
items, perr = parse_json(out or "[]", list) # a non-list body is an error, never a crash
return {"error": perr} if perr else {"items": items}
def _repos(cfg: dict) -> list[str]:
"""The picker list — the explicit ``github.repos`` entries (first, operator order)
UNION the host's ADR 0095 managed-projects registry (v0.115.0+) UNION the repos
parsed from the registered checkouts' ``origin`` remotes (last). ``[]`` with no host
and nothing configured. See projects.py."""
from .projects import effective_repos
return effective_repos(cfg.get("repos"))
def resolve_config(cfg: dict) -> dict:
"""The resolved picker/default for a config dict — ONE computation shared by
``/config``, ``/status`` and the issue route, so they can't disagree:
``{"repos": [...], "default_repo": "...", "default_repo_error": str|None}``.
A malformed ``github.default_repo`` (not ``owner/name``, #23) is NOT fed into the
picker or used as the default — it's reported as the named ``default_repo_error``
and the default falls through to the first good picker entry (the views render
the error so the person fixes the field rather than seeing a raw `gh` failure).
"""
from .gh_issue import default_repo_error, effective_default_repo
raw_default = str(cfg.get("default_repo") or "").strip()
err = default_repo_error(raw_default)
repos = _repos(cfg)
default = effective_default_repo("" if err else raw_default, repos)
# The picker is built from `repos`. A very common config sets `default_repo` but
# leaves `repos` empty — without folding the default in, the picker has zero
# options and the board shows "nothing to select" even though a repo IS configured.
# Surface the resolved default as a selectable option (first), deduped.
selectable = [default, *repos] if default and default not in repos else repos
return {"repos": selectable, "default_repo": default, "default_repo_error": err}
def build_view_router():
"""The PAGES — served under the PUBLIC ``/plugins/github`` prefix (ungated): the
read-only board (``/view``) and the compact file-an-issue form (``/new-issue``)."""
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from .view import NEW_ISSUE_PAGE, PAGE
router = APIRouter()
@router.get("/view")
async def _view():
return HTMLResponse(PAGE)
@router.get("/new-issue")
async def _new_issue():
return HTMLResponse(NEW_ISSUE_PAGE)
return router
def build_data_router(cfg, registry=None):
"""The board's DATA routes — mounted under the GATED ``/api/plugins/github`` prefix.
``cfg`` is either the config dict OR a zero-arg callable returning it. Pass a callable
(e.g. ``registry.live_config``) so the board reflects config edits WITHOUT a server
restart: a hot-reload can't re-mount this router, but reading the config per request
picks up the freshly-saved repos/default_repo. A plain dict (tests, older host) is a
fixed snapshot. ``/issue`` reuses the SAME gate-checked `file_issue` path as the
`/issue` chat command, so the dialog and the command can never diverge.
``registry`` (optional) is the host registry: ``/status`` reports its result to the
``report_setup_gap`` seam through it, so the operator banner clears on the very
Re-check that sees `gh` installed / signed in. ``None`` ⇒ status only.
The picker/default resolution may parse git remotes (blocking, cached) — every
route runs it in a worker thread, never on the event loop.
"""
from fastapi import APIRouter, Body
from .gh_issue import IssueRequest, file_issue, labels_for, resolve_repo
from .status import compute_and_report
get_cfg = cfg if callable(cfg) else (lambda: cfg)
async def _resolved() -> dict:
current = get_cfg() or {}
return await asyncio.to_thread(resolve_config, current)
router = APIRouter()
@router.get("/config")
async def _config() -> dict:
resolved = await _resolved()
return {**resolved, "gh_available": gh_available()}
@router.get("/status")
async def _status() -> dict:
"""The first-run probe: `gh` path + version, auth state (login/host), the token
source, and the resolved repos — never raises (a failure is ``error``). Reports
to the host's setup-gap seam (clears on recovery) when a registry was given."""
resolved = await _resolved()
st = await compute_and_report(registry, resolved["default_repo"], resolved["repos"])
st["default_repo_error"] = resolved["default_repo_error"]
return st
@router.get("/issues")
async def _issues(repo: str, state: str = "open") -> dict:
return await fetch_issues(repo, state)
@router.get("/prs")
async def _prs(repo: str, state: str = "open") -> dict:
return await fetch_prs(repo, state)
@router.post("/issue")
async def _create_issue(body: dict = Body(...)) -> dict:
resolved = await _resolved()
kind = (body.get("kind") or "generic").lower()
if kind not in ("bug", "feature", "generic"):
kind = "generic"
title = (body.get("title") or "").strip()
issue_body = (body.get("body") or "").strip()
explicit = str(body.get("repo") or "").strip()
repo = resolve_repo(explicit, resolved["default_repo"])
labels = labels_for(kind, [str(x) for x in (body.get("labels") or [])])
dry_run = bool(body.get("dry_run"))
if not title:
return {"ok": False, "error": "Title is required."}
if not repo:
# No usable repo anywhere — and if the configured default is the reason
# (malformed, #23), say THAT, by name, rather than "set one".
if resolved["default_repo_error"]:
return {"ok": False, "error": resolved["default_repo_error"]}
return {"ok": False, "error": "No target repo — set one in Settings ▸ GitHub, or pick one."}
if bad_repo(repo):
return {"ok": False, "error": f"Repo must be 'owner/name' (got {repo!r})."}
return await file_issue(
IssueRequest(title=title, body=issue_body, kind=kind, repo=repo, labels=labels, dry_run=dry_run)
)
return router