-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapi.py
More file actions
171 lines (135 loc) · 6.89 KB
/
Copy pathapi.py
File metadata and controls
171 lines (135 loc) · 6.89 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
"""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``) so
the suite can test it host-free; the FastAPI imports stay lazy inside the build_*
functions (fastapi is the host's at runtime).
"""
from __future__ import annotations
import json
import shutil
from .gh_cli import bad_repo, 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"
_PR_FIELDS = "number,title,state,author,labels,url,createdAt,isDraft,headRefName,reviewDecision"
def gh_available() -> bool:
"""Whether the `gh` CLI is on PATH (the board shows a hint when it isn't)."""
return shutil.which("gh") is not None
def _norm_state(state: str) -> 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 ("open", "closed", "merged", "all") 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)
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 rc != 0:
return {"error": f"Error (gh exit {rc}): {serr[:300]}"}
try:
return {"items": json.loads(out or "[]")}
except json.JSONDecodeError:
return {"error": f"Error: could not parse gh output: {out[:200]}"}
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/reviewDecision).
"""
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 rc != 0:
return {"error": f"Error (gh exit {rc}): {serr[:300]}"}
try:
return {"items": json.loads(out or "[]")}
except json.JSONDecodeError:
return {"error": f"Error: could not parse gh output: {out[:200]}"}
def _repos(cfg: dict) -> list[str]:
return [str(r).strip() for r in (cfg.get("repos") or []) if str(r).strip()]
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):
"""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.
"""
from fastapi import APIRouter, Body
from .gh_issue import IssueRequest, effective_default_repo, file_issue, labels_for, resolve_repo
get_cfg = cfg if callable(cfg) else (lambda: cfg)
router = APIRouter()
@router.get("/config")
async def _config() -> dict:
current = get_cfg()
repos = _repos(current)
default = effective_default_repo(current.get("default_repo", ""), 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,
"gh_available": gh_available(),
}
@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:
current = get_cfg()
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()
repo = resolve_repo(body.get("repo"), effective_default_repo(current.get("default_repo", ""), _repos(current)))
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:
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