Repository navigation
Expand file tree
/
Copy pathgh_issue.py
More file actions
348 lines (299 loc) · 15.1 KB
/
Copy pathgh_issue.py
File metadata and controls
348 lines (299 loc) · 15.1 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
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
"""The `/issue` chat control command — file a GitHub issue from a chat message.
`/issue` is the USER-ONLY chat path for filing an issue: the host exposes it as a
chat control command (like `/goal`) via `registry.register_chat_command("issue", …)`,
so a person can file from the composer on any agent — including a read-only one —
without the model being involved. The `github_create_issue` AGENT tool exists too
(write_tools.py), behind the per-agent `github.write` gate: a PM/coding agent with
write on can file autonomously; a research agent without it cannot. Both paths run
the SAME body gate (`missing_sections` / `infer_kind` / `labels_for` below), so what
the command refuses the tool refuses — and what passes here passes protoAgent's CI
issue gate too (the regexes are kept in lockstep with it). This module is the pure,
host-free logic.
`run_issue_command(rest, *, default_repo)` takes everything after the `/issue` token
(the host already matched it) and returns the reply string. The issue body is checked
against the SAME requirements the CI issue gate enforces (a non-trivial body + a
Problem/Motivation section; `--bug` also wants repro/evidence; `--feature` wants a
proposed-direction or acceptance section), so an issue filed here always passes.
Repo resolution (no silent misrouting): explicit `--repo` > the plugin's configured
`github.default_repo` (passed in as `default_repo`; itself resolved from default_repo
> first of repos > the host's project registry > a checkout's origin remote, see
projects.py) > `GITHUB_DEFAULT_REPO` / `GH_REPO` env (logged at INFO when it fires —
it's the one non-obvious source) > an error asking for one. Auth rides on `gh_cli`
(the `github.token` secret, `GITHUB_TOKEN`/`GH_TOKEN`, or ambient `gh auth`);
`gh issue create` needs write scope.
"""
from __future__ import annotations
import logging
import os
import re
import shlex
from dataclasses import dataclass, field
from .gh_cli import REPO_RE, check_gh_error, run_gh
log = logging.getLogger("protoagent.plugins.github")
# Section detectors — kept in LOCKSTEP with protoAgent's CI issue gate
# (.github/workflows/issue-gate.yml: hasProblem / hasRepro / hasProposal / hasAcceptance)
# so the local check and the server-side gate can never disagree about what
# "conforms" means: a body this gate passes must pass CI, and vice versa. When the
# host's regexes change, change these — tests/test_gh_issue.py pins a sample per
# alternative.
_SECTION_RES = {
"problem": re.compile(
r"problem|what'?s? wrong|motivation|background|context|summary|observed|symptom|idea|current behavior|\bwhat\b",
re.I,
),
"repro": re.compile(r"repro|reproduce|steps|evidence|expected|actual|observed|symptom|root cause", re.I),
"proposal": re.compile(r"propos|solution|approach|direction|fix|design|\bwork\b|plan", re.I),
"acceptance": re.compile(r"acceptance|done when|success criteria|definition of done", re.I),
}
# A heading (#..######) or a bold line (**…**) — same shape the gate matches.
_HEADING_RE = re.compile(r"^\s*(?:#{1,6}\s+|\*\*\s*)(.+?)(?:\s*\*\*)?\s*$")
_BUG_SCAFFOLD = (
"## Problem / what's wrong\n<what's broken, and where — name the file/subsystem>\n\n"
"## Steps to reproduce / evidence\n<minimal steps, logs, or a stack trace>\n\n"
"## Expected vs. actual\n<what you expected vs. what happened>\n\n"
"## Acceptance\n<how we'll know it's fixed>\n"
)
_FEATURE_SCAFFOLD = (
"## Problem / motivation\n<what gap or pain motivates this>\n\n"
"## Proposed direction\n<sketch the approach; note trade-offs>\n\n"
"## Acceptance\n<verifiable criteria for done>\n"
)
_GENERIC_SCAFFOLD = (
"## Problem\n<what's wrong or what you want, and why it matters>\n\n## Acceptance\n<verifiable criteria for done>\n"
)
@dataclass
class IssueRequest:
title: str
body: str
kind: str # "bug" | "feature" | "generic"
repo: str | None = None
labels: list[str] = field(default_factory=list)
dry_run: bool = False
def _has_section(body: str, key: str) -> bool:
rx = _SECTION_RES[key]
for line in body.splitlines():
m = _HEADING_RE.match(line)
if m and rx.search(m.group(1)):
return True
return False
def _scaffold(kind: str) -> str:
return {"bug": _BUG_SCAFFOLD, "feature": _FEATURE_SCAFFOLD}.get(kind, _GENERIC_SCAFFOLD)
def scaffold_for(kind: str) -> str:
"""The fill-in scaffold for an issue ``kind`` — what both the `/issue` command and
the `github_create_issue` tool hand back when the body fails the gate."""
return _scaffold(kind)
def missing_sections(body: str, kind: str) -> list[str]:
"""The gate-required sections absent from ``body`` for this issue ``kind``."""
miss: list[str] = []
# Same metric as the CI gate (collapsed-whitespace length >= 80), so an issue
# that passes here is guaranteed to clear the server-side gate.
if len(" ".join(body.split())) < 80:
miss.append("a substantive description (>= 80 chars)")
if not _has_section(body, "problem"):
miss.append("a Problem / What's-wrong / Motivation section")
if kind == "bug" and not _has_section(body, "repro"):
miss.append("Steps to reproduce / Evidence / Expected-vs-actual")
if kind == "feature" and not (_has_section(body, "proposal") or _has_section(body, "acceptance")):
miss.append("a Proposed-direction or Acceptance section")
return miss
def infer_kind(kind: str, labels: list[str] | None = None) -> str:
"""The gate ``kind`` to enforce: an explicit ``bug`` / ``feature`` wins; a
``generic`` request carrying a ``bug`` / ``enhancement`` LABEL is promoted to
that kind — because the CI gate keys on the labels, so a ``labels="bug"`` issue
filed without a repro section would be refused server-side anyway."""
k = (kind or "generic").strip().lower()
if k in ("bug", "feature"):
return k
low = {(lbl or "").strip().lower() for lbl in (labels or [])}
if "bug" in low:
return "bug"
if "enhancement" in low:
return "feature"
return "generic"
def labels_for(kind: str, extra: list[str] | None = None) -> list[str]:
"""Labels for an issue of this ``kind`` — the type label first (``bug`` /
``enhancement``), then any extras, de-duped in order."""
out: list[str] = []
if kind == "bug":
out.append("bug")
elif kind == "feature":
out.append("enhancement")
for lbl in extra or []:
if lbl and lbl not in out:
out.append(lbl)
return out
def current_default(default_repo) -> str:
"""Materialise a default repo that may be a plain string OR a zero-arg getter.
The tools take a GETTER (``__init__.py``) so a ``default_repo``/``repos``/registry
edit — or an ``onboard_project`` mid-session — is seen by the very next tool call,
not at the next register(). A raising getter reads as "no default"."""
if callable(default_repo):
try:
return str(default_repo() or "").strip()
except Exception: # noqa: BLE001 — a broken getter must not kill a tool call
log.debug("[github] default-repo getter failed", exc_info=True)
return ""
return str(default_repo or "").strip()
def default_repo_error(value: str) -> str | None:
"""The NAMED error for a malformed configured default (#23): ``github.default_repo``
must be ``owner/name``. ``None`` when it's blank (unset is fine) or well-formed."""
v = (value or "").strip()
if not v or REPO_RE.match(v):
return None
return (
f"Error: github.default_repo must be 'owner/name' (got {v!r}) — fix it in Settings ▸ GitHub, "
"or pass repo='owner/name' explicitly."
)
def resolve_repo(explicit: str | None, default_repo="") -> str | None:
"""Target repo: explicit ``--repo`` > configured default > GITHUB_DEFAULT_REPO
/ GH_REPO env > ``None`` (caller errors — there is no silent default).
``default_repo`` may be a string or a zero-arg getter (evaluated only when no
explicit repo was passed — see ``current_default``). The env step is the only
source a person can't see in Settings, so it's logged at INFO whenever it's what
actually decided the repo (never silently)."""
chosen = (explicit or "").strip() or current_default(default_repo)
if chosen:
return chosen
for var in ("GITHUB_DEFAULT_REPO", "GH_REPO"):
val = (os.environ.get(var) or "").strip()
if val:
log.info("[github] no repo passed or configured — using %s=%s from the environment", var, val)
return val
return None
def effective_default_repo(default_repo: str, repos=None) -> str:
"""The preselected default repo for the dialog + the ``/issue`` command: the
explicit ``github.default_repo`` if set, else the first entry in the
``github.repos`` picker list (explicit ∪ registry ∪ checkout remotes), else
``""`` (env still applies via ``resolve_repo``). Keeps the command, the tools
and the dialog agreeing on the default. A malformed explicit default is returned
as-is so the caller's ``bad_repo`` / ``default_repo_error`` names it instead of
silently routing to the next candidate.
``repos`` may be a list OR a zero-arg getter — the getter is only called when no
explicit default is set, so computing the picker (which may fork git to read
checkout remotes) is skipped on the common configured path."""
if (default_repo or "").strip():
return default_repo.strip()
if callable(repos):
try:
repos = repos()
except Exception: # noqa: BLE001 — a broken picker getter reads as "no picker"
log.debug("[github] repos getter failed", exc_info=True)
repos = []
for r in repos or []:
if (r or "").strip():
return r.strip()
return ""
async def file_issue(req: IssueRequest) -> dict:
"""Validate ``req`` against the gate rules, then create the issue via ``gh``
(or, for ``dry_run``, report what would be filed). Returns a structured
result the chat command and the console dialog both render:
- ``{"ok": False, "missing": [...], "kind": ...}`` — body fails the gate;
- ``{"ok": False, "error": "..."}`` — ``gh`` failed (auth / label / repo);
- ``{"ok": True, "dry_run": True, ...}`` — preview only, nothing created;
- ``{"ok": True, "url": "...", "repo": ..., "labels": [...]}`` — created.
Assumes ``req.repo`` is already set + validated (the callers do that).
"""
miss = missing_sections(req.body, req.kind)
if miss:
return {"ok": False, "missing": miss, "kind": req.kind}
if req.dry_run:
return {
"ok": True,
"dry_run": True,
"repo": req.repo,
"title": req.title,
"body": req.body,
"labels": req.labels,
}
args = ["issue", "create", "--repo", req.repo, "--title", req.title, "--body", req.body]
for lbl in req.labels:
args += ["--label", lbl]
rc, out, serr = await run_gh(args, timeout=45)
err = check_gh_error(rc, serr)
if err:
if "could not add label" in serr.lower() or "not found" in serr.lower():
err += f" (the label may not exist in {req.repo}; create it or drop the label.)"
return {"ok": False, "error": err}
url = out.strip().splitlines()[-1] if out.strip() else ""
return {"ok": True, "url": url, "repo": req.repo, "labels": req.labels}
def _parse(rest: str, *, default_repo: str = "") -> IssueRequest | str:
"""Parse the raw ``/issue`` argument string (everything after the token) into a
request, or an error string. The first line carries the title + flags; everything
after the first newline is the body, taken verbatim."""
first, _, body = rest.partition("\n")
body = body.strip()
kind = "generic"
labels: list[str] = []
repo: str | None = None
dry_run = False
# Tokenize only the first line (title + flags); the body is taken verbatim.
try:
tokens = shlex.split(first)
except ValueError:
tokens = first.split()
title_parts: list[str] = []
i = 0
while i < len(tokens):
tok = tokens[i]
low = tok.lower()
if low in ("--bug", "--fix"):
kind = "bug"
elif low in ("--feature", "--feat", "--enhancement"):
kind = "feature"
elif low in ("--dry-run", "--dryrun"):
dry_run = True
elif low.startswith("--repo"):
val = tok.split("=", 1)[1] if "=" in tok else (tokens[i + 1] if i + 1 < len(tokens) else "")
if "=" not in tok:
i += 1
repo = val.strip()
elif low.startswith("--label"):
val = tok.split("=", 1)[1] if "=" in tok else (tokens[i + 1] if i + 1 < len(tokens) else "")
if "=" not in tok:
i += 1
labels += [s.strip() for s in val.split(",") if s.strip()]
else:
title_parts.append(tok)
i += 1
title = " ".join(title_parts).strip()
kind = infer_kind(kind, labels) # `--label bug` demands the repro section, like CI
labels = labels_for(kind, labels)
explicit_repo = repo
repo = resolve_repo(repo, default_repo)
if not title:
return (
"Usage: `/issue <title> [--bug|--feature] [--repo owner/name] [--dry-run]` "
"then the body on the following lines.\n\n"
"Scaffold to fill in:\n```\n" + _scaffold(kind) + "```"
)
if not repo:
return (
"No target repo. Pass `--repo owner/name`, or set `github.default_repo` "
"in Settings (or the `GITHUB_DEFAULT_REPO` env var)."
)
if not REPO_RE.match(repo):
if not (explicit_repo or "").strip():
return default_repo_error(repo) or f"Error: repo must be 'owner/name' (got {repo!r})."
return f"Error: --repo must be 'owner/name' (got {repo!r})."
return IssueRequest(title=title, body=body, kind=kind, repo=repo, labels=labels, dry_run=dry_run)
async def run_issue_command(rest: str, *, default_repo: str = "") -> str:
"""Handle a ``/issue`` chat command: parse ``rest`` (the text after the token),
validate against the gate, then create the issue (or report what's missing / what
a dry-run would do). Returns the reply string the host short-circuits the turn
with. The host has already matched the ``/issue`` token via the chat-command seam."""
parsed = _parse(rest, default_repo=default_repo)
if isinstance(parsed, str):
return parsed # usage / scaffold / validation error
result = await file_issue(parsed)
if not result["ok"] and result.get("missing"):
return (
"Not filed — this issue is missing " + "; ".join(result["missing"]) + ".\n\n"
"Add the section(s) and resend. Scaffold for a "
f"{parsed.kind} issue:\n```\n" + _scaffold(parsed.kind) + "```"
)
if not result["ok"]:
return result.get("error", "Error filing issue.")
label_note = f" · labels: {', '.join(parsed.labels)}" if parsed.labels else ""
if result.get("dry_run"):
return f"Dry run — would create in **{parsed.repo}**{label_note}:\n\n**{parsed.title}**\n\n{parsed.body}"
return f"Filed in {parsed.repo}{label_note}: {result.get('url') or '(created)'}"