@mohammadhprp/system-prompt 0.12.4 → 0.12.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/framework/commands/README.md +2 -6
- package/framework/commands/audit-your-codebase.md +47 -0
- package/framework/commands/explain-codebase.md +101 -0
- package/framework/commands/learn.md +1 -1
- package/framework/plugins/ponytail/README.md +1 -1
- package/framework/plugins/ponytail/capabilities.md +1 -1
- package/framework/references/standards/pull-requests.md +1 -1
- package/framework/skills/README.md +1 -0
- package/framework/skills/adhd/SKILL.md +141 -0
- package/framework/skills/adhd/examples.md +77 -0
- package/framework/skills/gh/SKILL.md +157 -0
- package/framework/skills/gh/examples.md +10 -0
- package/framework/skills/ponytail/SKILL.md +145 -0
- package/framework/skills/ponytail/references/ponytail-audit.md +18 -0
- package/framework/skills/ponytail/references/ponytail-debt.md +21 -0
- package/framework/skills/ponytail/references/ponytail-gain.md +25 -0
- package/framework/skills/ponytail/references/ponytail-help.md +18 -0
- package/framework/skills/ponytail/references/ponytail-mode.md +33 -0
- package/framework/skills/ponytail/references/ponytail-review.md +27 -0
- package/framework/skills/ponytail/references/ponytail-rules.md +31 -0
- package/framework/skills/ponytail/references/principle-boundary-discipline.md +7 -0
- package/framework/skills/ponytail/references/principle-encode-lessons-in-structure.md +13 -0
- package/framework/skills/ponytail/references/principle-fix-root-causes.md +17 -0
- package/framework/skills/ponytail/references/principle-make-operations-idempotent.md +12 -0
- package/framework/skills/ponytail/references/principle-model-the-domain.md +7 -0
- package/framework/skills/ponytail/references/principle-prove-it-works.md +27 -0
- package/framework/skills/ponytail/references/principle-sequence-verifiable-units.md +7 -0
- package/framework/skills/review/SKILL.md +106 -11
- package/framework/skills/review/examples.md +4 -3
- package/framework/skills/review/scripts/render_review.py +95 -0
- package/framework/skills/review/scripts/resolve_spec_context.py +723 -0
- package/framework/skills/review/scripts/validate_review_json.py +348 -0
- package/framework/skills/unslop/SKILL.md +34 -3
- package/framework/skills/unslop/examples.md +2 -0
- package/framework/skills/unslop/references/eval.md +44 -0
- package/package.json +1 -1
- package/src/catalog.js +6 -7
- package/framework/commands/changelog.md +0 -44
- package/framework/commands/commit.md +0 -28
- package/framework/commands/mr.md +0 -45
- package/framework/commands/pr.md +0 -39
- package/framework/commands/release.md +0 -34
- package/framework/commands/review.md +0 -24
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
API_ROOT = "https://api.github.com"
|
|
17
|
+
GRAPHQL_ROOT = f"{API_ROOT}/graphql"
|
|
18
|
+
NO_SPEC_CONTEXT_MESSAGE = "No approved or repository spec context was found for this PR."
|
|
19
|
+
REPO_ROOT = Path(
|
|
20
|
+
(os.environ.get("OZ_REPO_ROOT") or "").strip()
|
|
21
|
+
or Path(__file__).resolve().parents[4]
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_CLOSING_ISSUES_QUERY = (
|
|
25
|
+
"query($owner: String!, $name: String!, $number: Int!, $after: String) {"
|
|
26
|
+
" repository(owner: $owner, name: $name) {"
|
|
27
|
+
" pullRequest(number: $number) {"
|
|
28
|
+
" closingIssuesReferences(first: 100, after: $after) {"
|
|
29
|
+
" pageInfo { hasNextPage endCursor }"
|
|
30
|
+
" nodes {"
|
|
31
|
+
" number"
|
|
32
|
+
" repository { owner { login } name }"
|
|
33
|
+
" }"
|
|
34
|
+
" }"
|
|
35
|
+
" }"
|
|
36
|
+
" }"
|
|
37
|
+
" }"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_MANUAL_LINKED_ISSUES_QUERY = (
|
|
41
|
+
"query($owner: String!, $name: String!, $number: Int!, $after: String) {"
|
|
42
|
+
" repository(owner: $owner, name: $name) {"
|
|
43
|
+
" pullRequest(number: $number) {"
|
|
44
|
+
" timelineItems(first: 100, after: $after, itemTypes: [CONNECTED_EVENT, DISCONNECTED_EVENT]) {"
|
|
45
|
+
" pageInfo { hasNextPage endCursor }"
|
|
46
|
+
" nodes {"
|
|
47
|
+
" __typename"
|
|
48
|
+
" ... on ConnectedEvent {"
|
|
49
|
+
" subject {"
|
|
50
|
+
" __typename"
|
|
51
|
+
" ... on Issue {"
|
|
52
|
+
" number"
|
|
53
|
+
" repository { owner { login } name }"
|
|
54
|
+
" }"
|
|
55
|
+
" }"
|
|
56
|
+
" }"
|
|
57
|
+
" ... on DisconnectedEvent {"
|
|
58
|
+
" subject {"
|
|
59
|
+
" __typename"
|
|
60
|
+
" ... on Issue {"
|
|
61
|
+
" number"
|
|
62
|
+
" repository { owner { login } name }"
|
|
63
|
+
" }"
|
|
64
|
+
" }"
|
|
65
|
+
" }"
|
|
66
|
+
" }"
|
|
67
|
+
" }"
|
|
68
|
+
" }"
|
|
69
|
+
" }"
|
|
70
|
+
" }"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_token() -> str:
|
|
75
|
+
token = (os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") or "").strip()
|
|
76
|
+
if not token:
|
|
77
|
+
raise SystemExit(
|
|
78
|
+
"GH_TOKEN or GITHUB_TOKEN must be set to resolve PR spec context."
|
|
79
|
+
)
|
|
80
|
+
return token
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_args() -> argparse.Namespace:
|
|
84
|
+
parser = argparse.ArgumentParser(
|
|
85
|
+
description="Resolve approved or repository spec context for a pull request."
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--repo",
|
|
89
|
+
required=True,
|
|
90
|
+
help="Repository slug in OWNER/REPO format.",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--pr",
|
|
94
|
+
type=int,
|
|
95
|
+
required=True,
|
|
96
|
+
help="Pull request number to resolve spec context for.",
|
|
97
|
+
)
|
|
98
|
+
return parser.parse_args()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _gh_request(
|
|
102
|
+
path_or_url: str,
|
|
103
|
+
*,
|
|
104
|
+
token: str,
|
|
105
|
+
accept: str = "application/vnd.github+json",
|
|
106
|
+
params: dict[str, str] | None = None,
|
|
107
|
+
method: str = "GET",
|
|
108
|
+
payload: bytes | None = None,
|
|
109
|
+
allow_http_error: bool = False,
|
|
110
|
+
) -> tuple[int, bytes, dict[str, str]]:
|
|
111
|
+
url = path_or_url if path_or_url.startswith("https://") else f"{API_ROOT}{path_or_url}"
|
|
112
|
+
if params:
|
|
113
|
+
url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
114
|
+
request = urllib.request.Request(url, data=payload, method=method) # noqa: S310
|
|
115
|
+
request.add_header("Authorization", f"Bearer {token}")
|
|
116
|
+
request.add_header("Accept", accept)
|
|
117
|
+
request.add_header("X-GitHub-Api-Version", "2022-11-28")
|
|
118
|
+
request.add_header("User-Agent", "oz-resolve-review-spec-context")
|
|
119
|
+
if payload is not None:
|
|
120
|
+
request.add_header("Content-Type", "application/json")
|
|
121
|
+
try:
|
|
122
|
+
with urllib.request.urlopen(request) as response: # noqa: S310
|
|
123
|
+
return response.status, response.read(), dict(response.headers)
|
|
124
|
+
except urllib.error.HTTPError as exc:
|
|
125
|
+
body = exc.read() if exc.fp is not None else b""
|
|
126
|
+
if allow_http_error:
|
|
127
|
+
return exc.code, body, dict(exc.headers or {})
|
|
128
|
+
detail = body.decode("utf-8", errors="replace")[:500]
|
|
129
|
+
raise SystemExit(
|
|
130
|
+
f"GitHub API request failed ({exc.code}) for {path_or_url}: {detail}"
|
|
131
|
+
) from exc
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _gh_json(
|
|
135
|
+
path: str,
|
|
136
|
+
*,
|
|
137
|
+
token: str,
|
|
138
|
+
params: dict[str, str] | None = None,
|
|
139
|
+
allow_http_error: bool = False,
|
|
140
|
+
) -> tuple[int, Any]:
|
|
141
|
+
status, body, _headers = _gh_request(
|
|
142
|
+
path,
|
|
143
|
+
token=token,
|
|
144
|
+
params=params,
|
|
145
|
+
allow_http_error=allow_http_error,
|
|
146
|
+
)
|
|
147
|
+
return status, json.loads(body.decode("utf-8"))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_next_link(link_header: str) -> str | None:
|
|
151
|
+
if not link_header:
|
|
152
|
+
return None
|
|
153
|
+
for piece in link_header.split(","):
|
|
154
|
+
segment = piece.strip()
|
|
155
|
+
if not segment.startswith("<"):
|
|
156
|
+
continue
|
|
157
|
+
end = segment.find(">")
|
|
158
|
+
if end == -1:
|
|
159
|
+
continue
|
|
160
|
+
url = segment[1:end]
|
|
161
|
+
rel_part = segment[end + 1 :]
|
|
162
|
+
if 'rel="next"' not in rel_part:
|
|
163
|
+
continue
|
|
164
|
+
parsed = urllib.parse.urlparse(url)
|
|
165
|
+
return parsed.path + (f"?{parsed.query}" if parsed.query else "")
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _gh_paginated_json(
|
|
170
|
+
path: str,
|
|
171
|
+
*,
|
|
172
|
+
token: str,
|
|
173
|
+
params: dict[str, str] | None = None,
|
|
174
|
+
per_page: int = 100,
|
|
175
|
+
) -> list[Any]:
|
|
176
|
+
merged_params = dict(params or {})
|
|
177
|
+
merged_params.setdefault("per_page", str(per_page))
|
|
178
|
+
next_path: str | None = (
|
|
179
|
+
f"{path}?{urllib.parse.urlencode(merged_params)}" if merged_params else path
|
|
180
|
+
)
|
|
181
|
+
items: list[Any] = []
|
|
182
|
+
while next_path:
|
|
183
|
+
status, body, headers = _gh_request(next_path, token=token)
|
|
184
|
+
if status != 200:
|
|
185
|
+
raise SystemExit(f"GitHub API returned status {status} for {next_path}")
|
|
186
|
+
page = json.loads(body.decode("utf-8"))
|
|
187
|
+
if not isinstance(page, list):
|
|
188
|
+
raise SystemExit(
|
|
189
|
+
f"Expected JSON array from {next_path}, got {type(page).__name__}."
|
|
190
|
+
)
|
|
191
|
+
items.extend(page)
|
|
192
|
+
next_path = _parse_next_link(headers.get("Link") or headers.get("link") or "")
|
|
193
|
+
return items
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _gh_graphql_json(query: str, variables: dict[str, Any], *, token: str) -> dict[str, Any]:
|
|
197
|
+
payload = json.dumps({"query": query, "variables": variables}).encode("utf-8")
|
|
198
|
+
_status, body, _headers = _gh_request(
|
|
199
|
+
GRAPHQL_ROOT,
|
|
200
|
+
token=token,
|
|
201
|
+
accept="application/json",
|
|
202
|
+
method="POST",
|
|
203
|
+
payload=payload,
|
|
204
|
+
)
|
|
205
|
+
data = json.loads(body.decode("utf-8"))
|
|
206
|
+
errors = data.get("errors") or []
|
|
207
|
+
if errors:
|
|
208
|
+
raise SystemExit(f"GitHub GraphQL request failed: {errors}")
|
|
209
|
+
return data
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_datetime(value: str) -> datetime:
|
|
213
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def read_local_spec_files(workspace: Path, issue_number: int) -> list[tuple[str, str]]:
|
|
217
|
+
spec_dir_name = f"GH{issue_number}"
|
|
218
|
+
spec_dir = workspace / "specs" / spec_dir_name
|
|
219
|
+
results: list[tuple[str, str]] = []
|
|
220
|
+
for name in ("product.md", "tech.md"):
|
|
221
|
+
path = spec_dir / name
|
|
222
|
+
if path.exists():
|
|
223
|
+
results.append(
|
|
224
|
+
(f"specs/{spec_dir_name}/{name}", path.read_text(encoding="utf-8").strip())
|
|
225
|
+
)
|
|
226
|
+
return results
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _fetch_pull(owner: str, repo: str, pr_number: int, *, token: str) -> dict[str, Any]:
|
|
230
|
+
_status, payload = _gh_json(
|
|
231
|
+
f"/repos/{owner}/{repo}/pulls/{pr_number}",
|
|
232
|
+
token=token,
|
|
233
|
+
)
|
|
234
|
+
if not isinstance(payload, dict):
|
|
235
|
+
raise SystemExit(
|
|
236
|
+
f"Expected object payload for pull request #{pr_number}, got {type(payload).__name__}."
|
|
237
|
+
)
|
|
238
|
+
return payload
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _fetch_pull_files(
|
|
242
|
+
owner: str,
|
|
243
|
+
repo: str,
|
|
244
|
+
pr_number: int,
|
|
245
|
+
*,
|
|
246
|
+
token: str,
|
|
247
|
+
) -> list[dict[str, Any]]:
|
|
248
|
+
files = _gh_paginated_json(
|
|
249
|
+
f"/repos/{owner}/{repo}/pulls/{pr_number}/files",
|
|
250
|
+
token=token,
|
|
251
|
+
)
|
|
252
|
+
return [item for item in files if isinstance(item, dict)]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _fetch_issue(
|
|
256
|
+
owner: str,
|
|
257
|
+
repo: str,
|
|
258
|
+
issue_number: int,
|
|
259
|
+
*,
|
|
260
|
+
token: str,
|
|
261
|
+
) -> dict[str, Any] | None:
|
|
262
|
+
status, payload = _gh_json(
|
|
263
|
+
f"/repos/{owner}/{repo}/issues/{issue_number}",
|
|
264
|
+
token=token,
|
|
265
|
+
allow_http_error=True,
|
|
266
|
+
)
|
|
267
|
+
if status == 404:
|
|
268
|
+
return None
|
|
269
|
+
if not isinstance(payload, dict):
|
|
270
|
+
raise SystemExit(
|
|
271
|
+
f"Expected object payload for issue #{issue_number}, got {type(payload).__name__}."
|
|
272
|
+
)
|
|
273
|
+
return payload
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _fetch_file_contents(
|
|
277
|
+
owner: str,
|
|
278
|
+
repo: str,
|
|
279
|
+
path: str,
|
|
280
|
+
*,
|
|
281
|
+
ref: str,
|
|
282
|
+
token: str,
|
|
283
|
+
) -> str | None:
|
|
284
|
+
encoded_path = urllib.parse.quote(path, safe="/")
|
|
285
|
+
status, payload = _gh_json(
|
|
286
|
+
f"/repos/{owner}/{repo}/contents/{encoded_path}",
|
|
287
|
+
token=token,
|
|
288
|
+
params={"ref": ref},
|
|
289
|
+
allow_http_error=True,
|
|
290
|
+
)
|
|
291
|
+
if status == 404:
|
|
292
|
+
return None
|
|
293
|
+
if not isinstance(payload, dict):
|
|
294
|
+
return None
|
|
295
|
+
content = str(payload.get("content") or "").strip()
|
|
296
|
+
encoding = str(payload.get("encoding") or "").strip().lower()
|
|
297
|
+
if not content or encoding != "base64":
|
|
298
|
+
return None
|
|
299
|
+
return base64.b64decode(content.encode("utf-8")).decode("utf-8").strip()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _normalize_github_linked_issue(node: Any, *, source: str) -> dict[str, Any] | None:
|
|
303
|
+
if not isinstance(node, dict):
|
|
304
|
+
return None
|
|
305
|
+
number = node.get("number")
|
|
306
|
+
if not isinstance(number, int):
|
|
307
|
+
return None
|
|
308
|
+
repository = node.get("repository") or {}
|
|
309
|
+
owner = ((repository.get("owner") or {}).get("login") or "").strip()
|
|
310
|
+
repo = str(repository.get("name") or "").strip()
|
|
311
|
+
if not owner or not repo:
|
|
312
|
+
return None
|
|
313
|
+
return {
|
|
314
|
+
"owner": owner,
|
|
315
|
+
"repo": repo,
|
|
316
|
+
"number": number,
|
|
317
|
+
"source": source,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _graphql_pull_request_data(
|
|
322
|
+
owner: str,
|
|
323
|
+
repo: str,
|
|
324
|
+
pr_number: int,
|
|
325
|
+
query: str,
|
|
326
|
+
*,
|
|
327
|
+
token: str,
|
|
328
|
+
after: str | None,
|
|
329
|
+
) -> dict[str, Any]:
|
|
330
|
+
data = _gh_graphql_json(
|
|
331
|
+
query,
|
|
332
|
+
{
|
|
333
|
+
"owner": owner,
|
|
334
|
+
"name": repo,
|
|
335
|
+
"number": int(pr_number),
|
|
336
|
+
"after": after,
|
|
337
|
+
},
|
|
338
|
+
token=token,
|
|
339
|
+
)
|
|
340
|
+
return (((data.get("data") or {}).get("repository") or {}).get("pullRequest") or {})
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _fetch_closing_issue_references(
|
|
344
|
+
owner: str,
|
|
345
|
+
repo: str,
|
|
346
|
+
pr_number: int,
|
|
347
|
+
*,
|
|
348
|
+
token: str,
|
|
349
|
+
) -> list[dict[str, Any]]:
|
|
350
|
+
linked: dict[tuple[str, str, int, str], dict[str, Any]] = {}
|
|
351
|
+
cursor: str | None = None
|
|
352
|
+
while True:
|
|
353
|
+
pr_data = _graphql_pull_request_data(
|
|
354
|
+
owner,
|
|
355
|
+
repo,
|
|
356
|
+
pr_number,
|
|
357
|
+
_CLOSING_ISSUES_QUERY,
|
|
358
|
+
token=token,
|
|
359
|
+
after=cursor,
|
|
360
|
+
)
|
|
361
|
+
closing_refs = pr_data.get("closingIssuesReferences") or {}
|
|
362
|
+
for node in closing_refs.get("nodes") or []:
|
|
363
|
+
issue_ref = _normalize_github_linked_issue(
|
|
364
|
+
node,
|
|
365
|
+
source="closingIssuesReferences",
|
|
366
|
+
)
|
|
367
|
+
if issue_ref is None:
|
|
368
|
+
continue
|
|
369
|
+
key = (
|
|
370
|
+
issue_ref["owner"].lower(),
|
|
371
|
+
issue_ref["repo"].lower(),
|
|
372
|
+
int(issue_ref["number"]),
|
|
373
|
+
str(issue_ref["source"]),
|
|
374
|
+
)
|
|
375
|
+
linked[key] = issue_ref
|
|
376
|
+
page_info = closing_refs.get("pageInfo") or {}
|
|
377
|
+
if not page_info.get("hasNextPage"):
|
|
378
|
+
break
|
|
379
|
+
cursor = page_info.get("endCursor")
|
|
380
|
+
if not cursor:
|
|
381
|
+
break
|
|
382
|
+
return sorted(
|
|
383
|
+
linked.values(),
|
|
384
|
+
key=lambda item: (
|
|
385
|
+
str(item["owner"]).lower(),
|
|
386
|
+
str(item["repo"]).lower(),
|
|
387
|
+
int(item["number"]),
|
|
388
|
+
str(item["source"]),
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _fetch_manual_linked_issue_references(
|
|
394
|
+
owner: str,
|
|
395
|
+
repo: str,
|
|
396
|
+
pr_number: int,
|
|
397
|
+
*,
|
|
398
|
+
token: str,
|
|
399
|
+
) -> list[dict[str, Any]]:
|
|
400
|
+
connected: dict[tuple[str, str, int], dict[str, Any]] = {}
|
|
401
|
+
cursor: str | None = None
|
|
402
|
+
while True:
|
|
403
|
+
pr_data = _graphql_pull_request_data(
|
|
404
|
+
owner,
|
|
405
|
+
repo,
|
|
406
|
+
pr_number,
|
|
407
|
+
_MANUAL_LINKED_ISSUES_QUERY,
|
|
408
|
+
token=token,
|
|
409
|
+
after=cursor,
|
|
410
|
+
)
|
|
411
|
+
timeline_items = pr_data.get("timelineItems") or {}
|
|
412
|
+
for node in timeline_items.get("nodes") or []:
|
|
413
|
+
if not isinstance(node, dict):
|
|
414
|
+
continue
|
|
415
|
+
issue_ref = _normalize_github_linked_issue(
|
|
416
|
+
node.get("subject"),
|
|
417
|
+
source="manualLink",
|
|
418
|
+
)
|
|
419
|
+
if issue_ref is None:
|
|
420
|
+
continue
|
|
421
|
+
key = (
|
|
422
|
+
issue_ref["owner"].lower(),
|
|
423
|
+
issue_ref["repo"].lower(),
|
|
424
|
+
int(issue_ref["number"]),
|
|
425
|
+
)
|
|
426
|
+
typename = str(node.get("__typename") or "")
|
|
427
|
+
if typename == "ConnectedEvent":
|
|
428
|
+
connected[key] = issue_ref
|
|
429
|
+
elif typename == "DisconnectedEvent":
|
|
430
|
+
connected.pop(key, None)
|
|
431
|
+
page_info = timeline_items.get("pageInfo") or {}
|
|
432
|
+
if not page_info.get("hasNextPage"):
|
|
433
|
+
break
|
|
434
|
+
cursor = page_info.get("endCursor")
|
|
435
|
+
if not cursor:
|
|
436
|
+
break
|
|
437
|
+
return sorted(
|
|
438
|
+
connected.values(),
|
|
439
|
+
key=lambda item: (
|
|
440
|
+
str(item["owner"]).lower(),
|
|
441
|
+
str(item["repo"]).lower(),
|
|
442
|
+
int(item["number"]),
|
|
443
|
+
str(item["source"]),
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _dedupe_ints(values: list[int]) -> list[int]:
|
|
449
|
+
return list(dict.fromkeys(int(value) for value in values))
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _same_repo_issue_numbers(
|
|
453
|
+
owner: str,
|
|
454
|
+
repo: str,
|
|
455
|
+
issue_refs: list[dict[str, Any]],
|
|
456
|
+
) -> list[int]:
|
|
457
|
+
normalized_owner = owner.lower()
|
|
458
|
+
normalized_repo = repo.lower()
|
|
459
|
+
return _dedupe_ints(
|
|
460
|
+
[
|
|
461
|
+
int(issue_ref["number"])
|
|
462
|
+
for issue_ref in issue_refs
|
|
463
|
+
if str(issue_ref.get("owner") or "").lower() == normalized_owner
|
|
464
|
+
and str(issue_ref.get("repo") or "").lower() == normalized_repo
|
|
465
|
+
]
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _deterministic_issue_candidates(pr: dict[str, Any], changed_files: list[str]) -> list[int]:
|
|
470
|
+
head_ref = str(((pr.get("head") or {}).get("ref")) or "")
|
|
471
|
+
branch_issue_matches = [
|
|
472
|
+
int(match.group(1))
|
|
473
|
+
for match in re.finditer(
|
|
474
|
+
r"(?:^|/)(?:spec|implement)-issue-(\d+)(?:$|[/-])",
|
|
475
|
+
head_ref,
|
|
476
|
+
)
|
|
477
|
+
]
|
|
478
|
+
spec_file_issue_numbers = [
|
|
479
|
+
int(match.group(1))
|
|
480
|
+
for filename in changed_files
|
|
481
|
+
for match in [re.match(r"^specs/GH(\d+)/(?:product|tech)\.md$", filename)]
|
|
482
|
+
if match
|
|
483
|
+
]
|
|
484
|
+
return _dedupe_ints(branch_issue_matches + spec_file_issue_numbers)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _resolve_deterministic_issue_numbers(
|
|
488
|
+
owner: str,
|
|
489
|
+
repo: str,
|
|
490
|
+
pr: dict[str, Any],
|
|
491
|
+
changed_files: list[str],
|
|
492
|
+
*,
|
|
493
|
+
token: str,
|
|
494
|
+
) -> list[int]:
|
|
495
|
+
resolved: list[int] = []
|
|
496
|
+
for candidate in _deterministic_issue_candidates(pr, changed_files):
|
|
497
|
+
issue = _fetch_issue(owner, repo, candidate, token=token)
|
|
498
|
+
if issue is None:
|
|
499
|
+
continue
|
|
500
|
+
if not issue.get("pull_request"):
|
|
501
|
+
resolved.append(candidate)
|
|
502
|
+
return resolved
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def resolve_issue_number_for_pr(
|
|
506
|
+
owner: str,
|
|
507
|
+
repo: str,
|
|
508
|
+
pr_number: int,
|
|
509
|
+
pr: dict[str, Any],
|
|
510
|
+
changed_files: list[str],
|
|
511
|
+
*,
|
|
512
|
+
token: str,
|
|
513
|
+
) -> int | None:
|
|
514
|
+
deterministic_issue_numbers = _resolve_deterministic_issue_numbers(
|
|
515
|
+
owner,
|
|
516
|
+
repo,
|
|
517
|
+
pr,
|
|
518
|
+
changed_files,
|
|
519
|
+
token=token,
|
|
520
|
+
)
|
|
521
|
+
github_linked_issues = _fetch_closing_issue_references(
|
|
522
|
+
owner,
|
|
523
|
+
repo,
|
|
524
|
+
pr_number,
|
|
525
|
+
token=token,
|
|
526
|
+
)
|
|
527
|
+
github_linked_issues.extend(
|
|
528
|
+
_fetch_manual_linked_issue_references(
|
|
529
|
+
owner,
|
|
530
|
+
repo,
|
|
531
|
+
pr_number,
|
|
532
|
+
token=token,
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
same_repo_linked_numbers = _same_repo_issue_numbers(owner, repo, github_linked_issues)
|
|
536
|
+
|
|
537
|
+
primary_issue_number: int | None = None
|
|
538
|
+
if len(deterministic_issue_numbers) == 1:
|
|
539
|
+
primary_issue_number = deterministic_issue_numbers[0]
|
|
540
|
+
elif len(deterministic_issue_numbers) == 0 and len(same_repo_linked_numbers) == 1:
|
|
541
|
+
primary_issue_number = same_repo_linked_numbers[0]
|
|
542
|
+
return primary_issue_number
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def find_matching_spec_prs(
|
|
546
|
+
owner: str,
|
|
547
|
+
repo: str,
|
|
548
|
+
issue_number: int,
|
|
549
|
+
*,
|
|
550
|
+
token: str,
|
|
551
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
552
|
+
expected_spec_branch = f"oz-agent/spec-issue-{issue_number}"
|
|
553
|
+
matching = _gh_paginated_json(
|
|
554
|
+
f"/repos/{owner}/{repo}/pulls",
|
|
555
|
+
token=token,
|
|
556
|
+
params={"state": "all", "head": f"{owner}:{expected_spec_branch}"},
|
|
557
|
+
)
|
|
558
|
+
approved: list[dict[str, Any]] = []
|
|
559
|
+
unapproved: list[dict[str, Any]] = []
|
|
560
|
+
for pr in matching:
|
|
561
|
+
if not isinstance(pr, dict):
|
|
562
|
+
continue
|
|
563
|
+
pr_number = int(pr.get("number") or 0)
|
|
564
|
+
if pr_number <= 0:
|
|
565
|
+
continue
|
|
566
|
+
issue_payload = _fetch_issue(owner, repo, pr_number, token=token) or {}
|
|
567
|
+
labels = [
|
|
568
|
+
str((label or {}).get("name") or "")
|
|
569
|
+
for label in issue_payload.get("labels", []) or []
|
|
570
|
+
if isinstance(label, dict)
|
|
571
|
+
]
|
|
572
|
+
files = _fetch_pull_files(owner, repo, pr_number, token=token)
|
|
573
|
+
spec_files = [
|
|
574
|
+
str(file.get("filename") or "")
|
|
575
|
+
for file in files
|
|
576
|
+
if str(file.get("filename") or "").startswith("specs/")
|
|
577
|
+
]
|
|
578
|
+
head = pr.get("head") or {}
|
|
579
|
+
head_repo = head.get("repo") or {}
|
|
580
|
+
entry = {
|
|
581
|
+
"number": pr_number,
|
|
582
|
+
"url": str(pr.get("html_url") or ""),
|
|
583
|
+
"updated_at": str(pr.get("updated_at") or ""),
|
|
584
|
+
"head_ref_name": str(head.get("ref") or ""),
|
|
585
|
+
"head_repo_full_name": str(head_repo.get("full_name") or ""),
|
|
586
|
+
"spec_files": spec_files,
|
|
587
|
+
}
|
|
588
|
+
if "plan-approved" in labels:
|
|
589
|
+
approved.append(entry)
|
|
590
|
+
else:
|
|
591
|
+
unapproved.append(entry)
|
|
592
|
+
approved.sort(key=lambda item: parse_datetime(item["updated_at"]), reverse=True)
|
|
593
|
+
unapproved.sort(key=lambda item: parse_datetime(item["updated_at"]), reverse=True)
|
|
594
|
+
return approved, unapproved
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def resolve_spec_context_for_issue(
|
|
598
|
+
owner: str,
|
|
599
|
+
repo: str,
|
|
600
|
+
issue_number: int,
|
|
601
|
+
*,
|
|
602
|
+
workspace: Path,
|
|
603
|
+
token: str,
|
|
604
|
+
) -> dict[str, Any]:
|
|
605
|
+
approved, unapproved = find_matching_spec_prs(owner, repo, issue_number, token=token)
|
|
606
|
+
selected = approved[0] if approved else None
|
|
607
|
+
local_specs = read_local_spec_files(workspace, issue_number)
|
|
608
|
+
if selected and selected["head_repo_full_name"] != f"{owner}/{repo}":
|
|
609
|
+
raise RuntimeError(
|
|
610
|
+
f"Linked approved spec PR #{selected['number']} uses branch "
|
|
611
|
+
f"{selected['head_repo_full_name']}:{selected['head_ref_name']}, which this workflow cannot push to."
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
spec_context_source = "approved-pr" if selected else "directory" if local_specs else ""
|
|
615
|
+
spec_entries: list[dict[str, str]] = []
|
|
616
|
+
if selected:
|
|
617
|
+
for path in selected["spec_files"]:
|
|
618
|
+
content = _fetch_file_contents(
|
|
619
|
+
owner,
|
|
620
|
+
repo,
|
|
621
|
+
path,
|
|
622
|
+
ref=selected["head_ref_name"],
|
|
623
|
+
token=token,
|
|
624
|
+
)
|
|
625
|
+
if content:
|
|
626
|
+
spec_entries.append({"path": path, "content": content})
|
|
627
|
+
elif local_specs:
|
|
628
|
+
for path, content in local_specs:
|
|
629
|
+
spec_entries.append({"path": path, "content": content})
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
"selected_spec_pr": selected,
|
|
633
|
+
"approved_spec_prs": approved,
|
|
634
|
+
"unapproved_spec_prs": unapproved,
|
|
635
|
+
"spec_context_source": spec_context_source,
|
|
636
|
+
"spec_entries": spec_entries,
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def resolve_spec_context_for_pr(
|
|
641
|
+
owner: str,
|
|
642
|
+
repo: str,
|
|
643
|
+
pr_number: int,
|
|
644
|
+
*,
|
|
645
|
+
workspace: Path,
|
|
646
|
+
token: str,
|
|
647
|
+
) -> dict[str, Any]:
|
|
648
|
+
pr = _fetch_pull(owner, repo, pr_number, token=token)
|
|
649
|
+
files = _fetch_pull_files(owner, repo, pr_number, token=token)
|
|
650
|
+
changed_files = [str(file.get("filename") or "") for file in files]
|
|
651
|
+
issue_number = resolve_issue_number_for_pr(
|
|
652
|
+
owner,
|
|
653
|
+
repo,
|
|
654
|
+
pr_number,
|
|
655
|
+
pr,
|
|
656
|
+
changed_files,
|
|
657
|
+
token=token,
|
|
658
|
+
)
|
|
659
|
+
if not issue_number:
|
|
660
|
+
return {
|
|
661
|
+
"issue_number": None,
|
|
662
|
+
"spec_context_source": "",
|
|
663
|
+
"selected_spec_pr": None,
|
|
664
|
+
"spec_entries": [],
|
|
665
|
+
"changed_files": changed_files,
|
|
666
|
+
}
|
|
667
|
+
spec_context = resolve_spec_context_for_issue(
|
|
668
|
+
owner,
|
|
669
|
+
repo,
|
|
670
|
+
issue_number,
|
|
671
|
+
workspace=workspace,
|
|
672
|
+
token=token,
|
|
673
|
+
)
|
|
674
|
+
spec_context["issue_number"] = issue_number
|
|
675
|
+
spec_context["changed_files"] = changed_files
|
|
676
|
+
return spec_context
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _format_spec_context(spec_context: dict[str, object]) -> str:
|
|
680
|
+
sections: list[str] = []
|
|
681
|
+
selected_spec_pr = spec_context.get("selected_spec_pr")
|
|
682
|
+
source = str(spec_context.get("spec_context_source") or "")
|
|
683
|
+
if (
|
|
684
|
+
source == "approved-pr"
|
|
685
|
+
and isinstance(selected_spec_pr, dict)
|
|
686
|
+
and selected_spec_pr.get("number")
|
|
687
|
+
and selected_spec_pr.get("url")
|
|
688
|
+
):
|
|
689
|
+
sections.append(
|
|
690
|
+
f"Linked approved spec PR: [#{selected_spec_pr['number']}]({selected_spec_pr['url']})"
|
|
691
|
+
)
|
|
692
|
+
elif source == "directory":
|
|
693
|
+
sections.append("Repository spec context was found in `specs/`.")
|
|
694
|
+
for entry in spec_context.get("spec_entries", []):
|
|
695
|
+
if not isinstance(entry, dict):
|
|
696
|
+
continue
|
|
697
|
+
path = str(entry.get("path") or "").strip()
|
|
698
|
+
content = str(entry.get("content") or "").strip()
|
|
699
|
+
if not path or not content:
|
|
700
|
+
continue
|
|
701
|
+
sections.append(f"## {path}\n\n{content}")
|
|
702
|
+
return "\n\n".join(sections).strip() or NO_SPEC_CONTEXT_MESSAGE
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def main() -> None:
|
|
706
|
+
args = _parse_args()
|
|
707
|
+
if "/" not in args.repo:
|
|
708
|
+
raise SystemExit(
|
|
709
|
+
f"Invalid repository slug: {args.repo!r}. Expected OWNER/REPO."
|
|
710
|
+
)
|
|
711
|
+
owner, repo = args.repo.split("/", 1)
|
|
712
|
+
spec_context = resolve_spec_context_for_pr(
|
|
713
|
+
owner,
|
|
714
|
+
repo,
|
|
715
|
+
args.pr,
|
|
716
|
+
workspace=REPO_ROOT,
|
|
717
|
+
token=_resolve_token(),
|
|
718
|
+
)
|
|
719
|
+
print(_format_spec_context(spec_context))
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
if __name__ == "__main__":
|
|
723
|
+
main()
|