ucp-gen 0.1.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ package-lock.json
3
+ .venv/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .DS_Store
ucp_gen-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: ucp-gen
3
+ Version: 0.1.0
4
+ Summary: Generate Universal Context Packages (UCP) from real systems. First source: GitHub issues.
5
+ Project-URL: Specification, https://github.com/ucpcore/ucp
6
+ Project-URL: Homepage, https://ucpcore.org
7
+ Author: UCP contributors
8
+ License-Expression: Apache-2.0
9
+ Keywords: context,github,llm,mcp,ucp
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: pyucp>=0.1.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # ucp-gen
18
+
19
+ Generate [Universal Context Packages](https://github.com/ucpcore/ucp) from
20
+ real systems — no LLM in the loop. The first supported source is GitHub
21
+ issues: one command turns an issue, its comments, timeline and linked pull
22
+ requests into a validated, provenance-backed `.ucp.json`.
23
+
24
+ ```bash
25
+ pip install ucp-gen
26
+ export GITHUB_TOKEN=... # optional, raises the API rate limit
27
+
28
+ # JSON package to a file
29
+ ucp-gen github vercel/next.js#12345 -o task.ucp.json
30
+
31
+ # canonical LLM rendering, capped at 1500 tokens
32
+ ucp-gen github owner/repo#42 --markdown --token-budget 1500
33
+
34
+ # include a "what changed since" diff (adds the ucp-temporal profile)
35
+ ucp-gen github owner/repo#42 --since 2026-06-01T00:00:00Z
36
+ ```
37
+
38
+ ## What the mapping does
39
+
40
+ | GitHub | UCP |
41
+ |---|---|
42
+ | issue title / state / assignee | `entity` |
43
+ | first meaningful paragraph of the body | `summary` |
44
+ | state, assignees, milestone, labels, PR states, comment gists | `must_know` claims with salience |
45
+ | merged linked PRs | `decisions` (accepted) |
46
+ | comments with decision markers ("we decided", "let's go with") | `decisions` (proposed) |
47
+ | issue timeline | `history`, and `context_diff` when `--since` is given |
48
+ | linked PRs | `related_objects` |
49
+ | every issue / comment / PR | `sources` entry with URL + content hash |
50
+
51
+ Every claim cites its sources; every source carries a `sha256` content hash.
52
+ The output always validates against the UCP schema before it is written —
53
+ the generator will fail rather than emit an invalid package.
54
+
55
+ Feed the result to any UCP consumer, e.g. the reference MCP server:
56
+
57
+ ```bash
58
+ pip install ucp-mcp
59
+ ucp-gen github owner/repo#42 -o ./contexts/task.ucp.json
60
+ ucp-mcp --dir ./contexts # Cursor / Claude Code now sees the context
61
+ ```
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ pip install -e ".[dev]"
67
+ pytest
68
+ ```
69
+
70
+ Part of the UCP reference toolchain (Apache 2.0).
@@ -0,0 +1,54 @@
1
+ # ucp-gen
2
+
3
+ Generate [Universal Context Packages](https://github.com/ucpcore/ucp) from
4
+ real systems — no LLM in the loop. The first supported source is GitHub
5
+ issues: one command turns an issue, its comments, timeline and linked pull
6
+ requests into a validated, provenance-backed `.ucp.json`.
7
+
8
+ ```bash
9
+ pip install ucp-gen
10
+ export GITHUB_TOKEN=... # optional, raises the API rate limit
11
+
12
+ # JSON package to a file
13
+ ucp-gen github vercel/next.js#12345 -o task.ucp.json
14
+
15
+ # canonical LLM rendering, capped at 1500 tokens
16
+ ucp-gen github owner/repo#42 --markdown --token-budget 1500
17
+
18
+ # include a "what changed since" diff (adds the ucp-temporal profile)
19
+ ucp-gen github owner/repo#42 --since 2026-06-01T00:00:00Z
20
+ ```
21
+
22
+ ## What the mapping does
23
+
24
+ | GitHub | UCP |
25
+ |---|---|
26
+ | issue title / state / assignee | `entity` |
27
+ | first meaningful paragraph of the body | `summary` |
28
+ | state, assignees, milestone, labels, PR states, comment gists | `must_know` claims with salience |
29
+ | merged linked PRs | `decisions` (accepted) |
30
+ | comments with decision markers ("we decided", "let's go with") | `decisions` (proposed) |
31
+ | issue timeline | `history`, and `context_diff` when `--since` is given |
32
+ | linked PRs | `related_objects` |
33
+ | every issue / comment / PR | `sources` entry with URL + content hash |
34
+
35
+ Every claim cites its sources; every source carries a `sha256` content hash.
36
+ The output always validates against the UCP schema before it is written —
37
+ the generator will fail rather than emit an invalid package.
38
+
39
+ Feed the result to any UCP consumer, e.g. the reference MCP server:
40
+
41
+ ```bash
42
+ pip install ucp-mcp
43
+ ucp-gen github owner/repo#42 -o ./contexts/task.ucp.json
44
+ ucp-mcp --dir ./contexts # Cursor / Claude Code now sees the context
45
+ ```
46
+
47
+ ## Development
48
+
49
+ ```bash
50
+ pip install -e ".[dev]"
51
+ pytest
52
+ ```
53
+
54
+ Part of the UCP reference toolchain (Apache 2.0).
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ucp-gen"
7
+ version = "0.1.0"
8
+ description = "Generate Universal Context Packages (UCP) from real systems. First source: GitHub issues."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "UCP contributors" }]
13
+ keywords = ["ucp", "llm", "context", "github", "mcp"]
14
+ dependencies = [
15
+ "pyucp>=0.1.0",
16
+ "httpx>=0.27",
17
+ ]
18
+
19
+ [project.urls]
20
+ Specification = "https://github.com/ucpcore/ucp"
21
+ Homepage = "https://ucpcore.org"
22
+
23
+ [project.scripts]
24
+ ucp-gen = "ucp_gen.cli:main"
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=8"]
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/ucp_gen"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
@@ -0,0 +1,6 @@
1
+ """ucp-gen — generate Universal Context Packages from real systems."""
2
+ from .build import build_package
3
+
4
+ __version__ = "0.1.0"
5
+
6
+ __all__ = ["build_package", "__version__"]
@@ -0,0 +1,288 @@
1
+ """Map a GitHub issue bundle to a valid Universal Context Package.
2
+
3
+ Pure functions only: no network, no clock reads (``now`` is injectable),
4
+ so the whole mapping is testable on fixtures. No LLM in the loop — the
5
+ structure itself carries the meaning; summarization can be layered on top.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import re
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Optional
14
+
15
+ SPEC_VERSION = "0.1.0"
16
+ GENERATOR = {"name": "ucp-gen", "version": "0.1.0", "url": "https://github.com/ucpcore/ucp"}
17
+
18
+ # Comments matching these are surfaced as proposed decisions (cheap heuristic;
19
+ # merged PRs are the reliable "accepted" signal).
20
+ _DECISION_RE = re.compile(
21
+ r"\b(we (?:decided|agreed|will go with)|decision:|let's go with|going with)\b", re.IGNORECASE
22
+ )
23
+
24
+ _MAX_HISTORY = 20
25
+ _MAX_COMMENT_CLAIMS = 10
26
+ _EXCERPT_LEN = 280
27
+
28
+
29
+ def _hash(text: str) -> str:
30
+ return "sha256:" + hashlib.sha256((text or "").encode("utf-8")).hexdigest()
31
+
32
+
33
+ def _actor(user: Optional[dict]) -> Optional[dict]:
34
+ if not user:
35
+ return None
36
+ return {"id": f"github:{user['login']}", "display_name": user["login"]}
37
+
38
+
39
+ def _excerpt(text: Optional[str]) -> Optional[str]:
40
+ if not text:
41
+ return None
42
+ flat = " ".join(text.split())
43
+ return flat[:_EXCERPT_LEN] + ("…" if len(flat) > _EXCERPT_LEN else "")
44
+
45
+
46
+ def _first_paragraph(text: Optional[str], fallback: str) -> str:
47
+ if not text:
48
+ return fallback
49
+ for block in re.split(r"\n\s*\n", text.strip()):
50
+ cleaned = " ".join(block.split())
51
+ # Skip pure-markup blocks (images, HTML comments, headers-only).
52
+ if cleaned and not cleaned.startswith(("<!--", "![", "|")):
53
+ return cleaned[:600]
54
+ return fallback
55
+
56
+
57
+ def _iso(value: Optional[str]) -> Optional[str]:
58
+ return value or None
59
+
60
+
61
+ def build_package(
62
+ bundle: dict[str, Any],
63
+ since: Optional[str] = None,
64
+ now: Optional[datetime] = None,
65
+ ) -> dict[str, Any]:
66
+ """Build a UCP dict (spec 0.1.0, profile ucp-core) from a GitHub bundle."""
67
+ owner, repo = bundle["owner"], bundle["repo"]
68
+ issue = bundle["issue"]
69
+ comments = bundle.get("comments", [])
70
+ timeline = bundle.get("timeline", [])
71
+ pulls = bundle.get("linked_pulls", [])
72
+ number = issue["number"]
73
+ full = f"{owner}/{repo}"
74
+ generated_at = (now or datetime.now(timezone.utc)).isoformat().replace("+00:00", "Z")
75
+
76
+ sources: dict[str, dict] = {}
77
+ sources["issue"] = {
78
+ "system": "github",
79
+ "type": "ticket",
80
+ "title": f"{full}#{number}: {issue['title']}",
81
+ "url": issue["html_url"],
82
+ "author": _actor(issue.get("user")),
83
+ "created_at": _iso(issue.get("created_at")),
84
+ "updated_at": _iso(issue.get("updated_at")),
85
+ "content_hash": _hash(issue.get("body") or ""),
86
+ "retrieved_at": generated_at,
87
+ "excerpt": _excerpt(issue.get("body")),
88
+ }
89
+ for comment in comments:
90
+ sources[f"comment-{comment['id']}"] = {
91
+ "system": "github",
92
+ "type": "comment",
93
+ "title": f"Comment by {comment['user']['login']}",
94
+ "url": comment["html_url"],
95
+ "author": _actor(comment.get("user")),
96
+ "created_at": _iso(comment.get("created_at")),
97
+ "content_hash": _hash(comment.get("body") or ""),
98
+ "retrieved_at": generated_at,
99
+ "excerpt": _excerpt(comment.get("body")),
100
+ }
101
+ for pull in pulls:
102
+ sources[f"pr-{pull['number']}"] = {
103
+ "system": "github",
104
+ "type": "pull_request",
105
+ "title": f"PR #{pull['number']}: {pull['title']}",
106
+ "url": pull["html_url"],
107
+ "author": _actor(pull.get("user")),
108
+ "created_at": _iso(pull.get("created_at")),
109
+ "updated_at": _iso(pull.get("updated_at")),
110
+ "content_hash": _hash(pull.get("body") or ""),
111
+ "retrieved_at": generated_at,
112
+ "excerpt": _excerpt(pull.get("body")),
113
+ }
114
+
115
+ # --- must_know: state of the world, highest salience first ---------------
116
+ must_know: list[dict] = []
117
+
118
+ def claim(cid: str, text: str, salience: float, srcs: list[str], **extra: Any) -> None:
119
+ must_know.append({"id": cid, "text": text, "salience": salience, "sources": srcs, **extra})
120
+
121
+ state = issue.get("state", "open")
122
+ state_text = f"Issue is {state}"
123
+ if state == "closed" and issue.get("state_reason"):
124
+ state_text += f" ({issue['state_reason']})"
125
+ claim("state", state_text, 0.95, ["issue"], kind="status",
126
+ asserted_at=_iso(issue.get("updated_at")))
127
+
128
+ assignees = [a["login"] for a in issue.get("assignees") or []]
129
+ if assignees:
130
+ claim("assignees", "Assigned to " + ", ".join(assignees), 0.85, ["issue"], kind="fact")
131
+ else:
132
+ claim("assignees", "Nobody is assigned", 0.6, ["issue"], kind="fact")
133
+
134
+ if issue.get("milestone"):
135
+ milestone = issue["milestone"]
136
+ text = f"Milestone: {milestone['title']}"
137
+ if milestone.get("due_on"):
138
+ text += f" (due {milestone['due_on'][:10]})"
139
+ claim("milestone", text, 0.8, ["issue"], kind="constraint")
140
+
141
+ labels = [label["name"] for label in issue.get("labels") or []]
142
+ if labels:
143
+ claim("labels", "Labels: " + ", ".join(labels), 0.65, ["issue"], kind="fact")
144
+
145
+ for pull in pulls:
146
+ merged = bool(pull.get("merged_at"))
147
+ pr_state = "merged" if merged else pull.get("state", "open")
148
+ claim(
149
+ f"pr-{pull['number']}-state",
150
+ f"PR #{pull['number']} \u201c{pull['title']}\u201d is {pr_state}",
151
+ 0.9 if merged else 0.8,
152
+ [f"pr-{pull['number']}"],
153
+ kind="status",
154
+ asserted_at=_iso(pull.get("merged_at") or pull.get("updated_at")),
155
+ )
156
+
157
+ # Recent comments become low-salience claims: newer ⇒ more relevant.
158
+ recent = sorted(comments, key=lambda c: c.get("created_at") or "", reverse=True)
159
+ for rank, comment in enumerate(recent[:_MAX_COMMENT_CLAIMS]):
160
+ body = _excerpt(comment.get("body"))
161
+ if not body:
162
+ continue
163
+ claim(
164
+ f"comment-{comment['id']}-gist",
165
+ f"{comment['user']['login']}: {body}",
166
+ max(0.2, 0.55 - 0.05 * rank),
167
+ [f"comment-{comment['id']}"],
168
+ kind="fact",
169
+ asserted_at=_iso(comment.get("created_at")),
170
+ )
171
+
172
+ # --- decisions ------------------------------------------------------------
173
+ decisions: list[dict] = []
174
+ for pull in pulls:
175
+ if pull.get("merged_at"):
176
+ decisions.append({
177
+ "id": f"decision-pr-{pull['number']}",
178
+ "decision": f"Implemented via PR #{pull['number']}: {pull['title']}",
179
+ "status": "accepted",
180
+ "sources": [f"pr-{pull['number']}"],
181
+ "decided_by": _actor(pull.get("merged_by") or pull.get("user")),
182
+ "decided_at": _iso(pull.get("merged_at")),
183
+ })
184
+ for comment in comments:
185
+ body = comment.get("body") or ""
186
+ if _DECISION_RE.search(body):
187
+ decisions.append({
188
+ "id": f"decision-comment-{comment['id']}",
189
+ "decision": _excerpt(body) or "",
190
+ "status": "proposed",
191
+ "sources": [f"comment-{comment['id']}"],
192
+ "decided_by": _actor(comment.get("user")),
193
+ "decided_at": _iso(comment.get("created_at")),
194
+ })
195
+
196
+ # --- history + context_diff from the timeline ------------------------------
197
+ history: list[dict] = [{
198
+ "occurred_at": issue["created_at"],
199
+ "summary": f"Issue opened by {issue['user']['login']}",
200
+ "actor": _actor(issue.get("user")),
201
+ "sources": ["issue"],
202
+ }]
203
+ diff_changes: list[dict] = []
204
+
205
+ def timeline_summary(event: dict) -> Optional[tuple[str, str]]:
206
+ kind = event.get("event")
207
+ actor_login = (event.get("actor") or {}).get("login", "someone")
208
+ if kind == "labeled":
209
+ return f"{actor_login} added label \u201c{event['label']['name']}\u201d", "updated"
210
+ if kind == "unlabeled":
211
+ return f"{actor_login} removed label \u201c{event['label']['name']}\u201d", "updated"
212
+ if kind == "assigned":
213
+ return f"{actor_login} assigned {(event.get('assignee') or {}).get('login', '?')}", "updated"
214
+ if kind == "closed":
215
+ return f"{actor_login} closed the issue", "status_changed"
216
+ if kind == "reopened":
217
+ return f"{actor_login} reopened the issue", "status_changed"
218
+ if kind == "milestoned":
219
+ return f"{actor_login} set milestone \u201c{(event.get('milestone') or {}).get('title', '?')}\u201d", "updated"
220
+ if kind == "cross-referenced":
221
+ source_issue = (event.get("source") or {}).get("issue") or {}
222
+ ref_kind = "PR" if source_issue.get("pull_request") else "issue"
223
+ return f"Referenced by {ref_kind} #{source_issue.get('number', '?')}", "added"
224
+ if kind == "commented":
225
+ return f"{actor_login} commented", "added"
226
+ return None
227
+
228
+ for event in timeline:
229
+ occurred = event.get("created_at") or event.get("submitted_at")
230
+ described = timeline_summary(event)
231
+ if not occurred or not described:
232
+ continue
233
+ summary_text, change_type = described
234
+ history.append({
235
+ "occurred_at": occurred,
236
+ "summary": summary_text,
237
+ "actor": _actor(event.get("actor")),
238
+ "sources": ["issue"],
239
+ })
240
+ if since and occurred > since:
241
+ diff_changes.append({
242
+ "type": change_type,
243
+ "summary": summary_text,
244
+ "occurred_at": occurred,
245
+ "actor": _actor(event.get("actor")),
246
+ "sources": ["issue"],
247
+ })
248
+
249
+ history.sort(key=lambda e: e["occurred_at"])
250
+ history = history[-_MAX_HISTORY:]
251
+
252
+ # --- related objects --------------------------------------------------------
253
+ related = [{
254
+ "ref": {"system": "github", "type": "pull_request",
255
+ "id": f"{full}#{pull['number']}", "url": pull["html_url"]},
256
+ "title": pull["title"],
257
+ "relation": "implements" if pull.get("merged_at") else "references",
258
+ "salience": 0.9 if pull.get("merged_at") else 0.7,
259
+ } for pull in pulls]
260
+
261
+ profiles = ["ucp-core"]
262
+ package: dict[str, Any] = {
263
+ "ucp_version": SPEC_VERSION,
264
+ "id": f"urn:uuid:{uuid.uuid4()}",
265
+ "generated_at": generated_at,
266
+ "generator": GENERATOR,
267
+ "profiles": profiles,
268
+ "entity": {
269
+ "ref": {"system": "github", "type": "issue",
270
+ "id": f"{full}#{number}", "url": issue["html_url"]},
271
+ "title": issue["title"],
272
+ "status": state,
273
+ **({"assignee": _actor(issue["assignee"])} if issue.get("assignee") else {}),
274
+ },
275
+ "summary": {
276
+ "text": _first_paragraph(issue.get("body"), issue["title"]),
277
+ "sources": ["issue"],
278
+ },
279
+ "must_know": must_know,
280
+ "decisions": decisions,
281
+ "history": history,
282
+ "related_objects": related,
283
+ "sources": sources,
284
+ }
285
+ if since:
286
+ profiles.append("ucp-temporal")
287
+ package["context_diff"] = {"since": since, "changes": diff_changes}
288
+ return package
@@ -0,0 +1,76 @@
1
+ """CLI: turn a GitHub issue into a validated UCP package.
2
+
3
+ ucp-gen github vercel/next.js#12345 -o task.ucp.json
4
+ ucp-gen github owner/repo#42 --since 2026-06-01T00:00:00Z --markdown
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import re
11
+ import sys
12
+
13
+ import ucp
14
+
15
+ from .build import build_package
16
+ from .github import GitHubError, fetch_issue_bundle
17
+
18
+ _REF_RE = re.compile(r"^(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)#(?P<number>\d+)$")
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ parser = argparse.ArgumentParser(prog="ucp-gen", description=__doc__)
23
+ sub = parser.add_subparsers(dest="command", required=True)
24
+
25
+ gh = sub.add_parser("github", help="generate a UCP from a GitHub issue")
26
+ gh.add_argument("ref", help="issue reference, e.g. owner/repo#123")
27
+ gh.add_argument("-o", "--output", help="write the package to this file (default: stdout)")
28
+ gh.add_argument("--since", help="ISO timestamp: include a context_diff since this moment")
29
+ gh.add_argument("--token", help="GitHub token (default: $GITHUB_TOKEN / $GH_TOKEN)")
30
+ gh.add_argument("--markdown", action="store_true",
31
+ help="print the canonical LLM rendering instead of JSON")
32
+ gh.add_argument("--token-budget", type=int, default=None,
33
+ help="token budget for --markdown rendering")
34
+
35
+ args = parser.parse_args(argv)
36
+
37
+ match = _REF_RE.match(args.ref)
38
+ if not match:
39
+ parser.error(f"expected owner/repo#number, got: {args.ref}")
40
+
41
+ try:
42
+ bundle = fetch_issue_bundle(
43
+ match["owner"], match["repo"], int(match["number"]), token=args.token
44
+ )
45
+ except GitHubError as exc:
46
+ print(f"ucp-gen: {exc}", file=sys.stderr)
47
+ return 1
48
+
49
+ data = build_package(bundle, since=args.since)
50
+
51
+ # The generator must never emit an invalid package.
52
+ ucp.validate(data)
53
+ pkg = ucp.Package.model_validate(data)
54
+ dangling = pkg.verify_references()
55
+ if dangling:
56
+ print(f"ucp-gen: internal error, dangling sources: {dangling}", file=sys.stderr)
57
+ return 2
58
+
59
+ if args.markdown:
60
+ out = pkg.render(token_budget=args.token_budget)
61
+ else:
62
+ out = json.dumps(data, indent=2, ensure_ascii=False)
63
+
64
+ if args.output:
65
+ with open(args.output, "w", encoding="utf-8") as fh:
66
+ fh.write(out + "\n")
67
+ est = ucp.estimate_tokens(pkg.render())
68
+ print(f"ucp-gen: wrote {args.output} "
69
+ f"({len(data['sources'])} sources, ~{est} tokens rendered)", file=sys.stderr)
70
+ else:
71
+ print(out)
72
+ return 0
73
+
74
+
75
+ if __name__ == "__main__":
76
+ raise SystemExit(main())
@@ -0,0 +1,96 @@
1
+ """GitHub REST client: fetches everything needed to build a UCP for an issue.
2
+
3
+ Network access is confined to this module; ``build.py`` is pure and works
4
+ on the plain-dict bundle returned by :func:`fetch_issue_bundle`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from typing import Any, Optional
10
+
11
+ import httpx
12
+
13
+ API = "https://api.github.com"
14
+
15
+
16
+ class GitHubError(RuntimeError):
17
+ pass
18
+
19
+
20
+ def _client(token: Optional[str] = None) -> httpx.Client:
21
+ token = token or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
22
+ headers = {
23
+ "Accept": "application/vnd.github+json",
24
+ "X-GitHub-Api-Version": "2022-11-28",
25
+ "User-Agent": "ucp-gen",
26
+ }
27
+ if token:
28
+ headers["Authorization"] = f"Bearer {token}"
29
+ return httpx.Client(base_url=API, headers=headers, timeout=30.0, follow_redirects=True)
30
+
31
+
32
+ def _get_json(client: httpx.Client, url: str, **params: Any) -> Any:
33
+ resp = client.get(url, params=params or None)
34
+ if resp.status_code == 404:
35
+ raise GitHubError(f"not found: {url}")
36
+ if resp.status_code == 403 and resp.headers.get("x-ratelimit-remaining") == "0":
37
+ raise GitHubError("GitHub rate limit exceeded; set GITHUB_TOKEN to raise the limit")
38
+ resp.raise_for_status()
39
+ return resp.json()
40
+
41
+
42
+ def _paginate(client: httpx.Client, url: str, limit: int = 200) -> list[dict]:
43
+ items: list[dict] = []
44
+ page = 1
45
+ while len(items) < limit:
46
+ batch = _get_json(client, url, per_page=100, page=page)
47
+ if not batch:
48
+ break
49
+ items.extend(batch)
50
+ if len(batch) < 100:
51
+ break
52
+ page += 1
53
+ return items[:limit]
54
+
55
+
56
+ def fetch_issue_bundle(
57
+ owner: str, repo: str, number: int, token: Optional[str] = None
58
+ ) -> dict[str, Any]:
59
+ """Fetch an issue with comments, timeline, and linked pull requests.
60
+
61
+ Returns a JSON-serializable bundle:
62
+ ``{"owner", "repo", "issue", "comments", "timeline", "linked_pulls"}``.
63
+ """
64
+ with _client(token) as client:
65
+ base = f"/repos/{owner}/{repo}/issues/{number}"
66
+ issue = _get_json(client, base)
67
+ comments = _paginate(client, f"{base}/comments")
68
+ timeline = _paginate(client, f"{base}/timeline")
69
+
70
+ # Cross-referenced PRs show up in the timeline; fetch their real state
71
+ # (merged / open / closed) because the timeline entry alone lacks it.
72
+ pull_numbers: list[int] = []
73
+ for event in timeline:
74
+ source_issue = (event.get("source") or {}).get("issue") or {}
75
+ if event.get("event") == "cross-referenced" and source_issue.get("pull_request"):
76
+ same_repo = (source_issue.get("repository") or {}).get("full_name")
77
+ if same_repo == f"{owner}/{repo}":
78
+ pull_numbers.append(source_issue["number"])
79
+
80
+ linked_pulls = []
81
+ for pull_number in dict.fromkeys(pull_numbers):
82
+ try:
83
+ linked_pulls.append(
84
+ _get_json(client, f"/repos/{owner}/{repo}/pulls/{pull_number}")
85
+ )
86
+ except GitHubError:
87
+ continue
88
+
89
+ return {
90
+ "owner": owner,
91
+ "repo": repo,
92
+ "issue": issue,
93
+ "comments": comments,
94
+ "timeline": timeline,
95
+ "linked_pulls": linked_pulls,
96
+ }
File without changes
@@ -0,0 +1,72 @@
1
+ """A realistic GitHub issue bundle (trimmed to the fields ucp-gen reads)."""
2
+
3
+
4
+ def user(login: str) -> dict:
5
+ return {"login": login}
6
+
7
+
8
+ BUNDLE = {
9
+ "owner": "acme",
10
+ "repo": "rocket",
11
+ "issue": {
12
+ "number": 42,
13
+ "title": "Payment webhook drops events under load",
14
+ "state": "closed",
15
+ "state_reason": "completed",
16
+ "html_url": "https://github.com/acme/rocket/issues/42",
17
+ "user": user("alice"),
18
+ "created_at": "2026-06-01T09:00:00Z",
19
+ "updated_at": "2026-06-20T18:00:00Z",
20
+ "body": (
21
+ "When more than ~50 webhook events arrive per second, some are\n"
22
+ "silently dropped.\n\n"
23
+ "Steps to reproduce:\n1. Fire 100 events\n2. Count processed"
24
+ ),
25
+ "assignee": user("bob"),
26
+ "assignees": [user("bob")],
27
+ "labels": [{"name": "bug"}, {"name": "priority:high"}],
28
+ "milestone": {"title": "v2.1", "due_on": "2026-07-01T00:00:00Z"},
29
+ },
30
+ "comments": [
31
+ {
32
+ "id": 100,
33
+ "user": user("bob"),
34
+ "created_at": "2026-06-02T10:00:00Z",
35
+ "html_url": "https://github.com/acme/rocket/issues/42#issuecomment-100",
36
+ "body": "Reproduced. The consumer ack's before processing finishes.",
37
+ },
38
+ {
39
+ "id": 101,
40
+ "user": user("carol"),
41
+ "created_at": "2026-06-05T12:00:00Z",
42
+ "html_url": "https://github.com/acme/rocket/issues/42#issuecomment-101",
43
+ "body": "We decided to switch to at-least-once delivery with an idempotency key.",
44
+ },
45
+ ],
46
+ "timeline": [
47
+ {"event": "labeled", "created_at": "2026-06-01T09:05:00Z",
48
+ "actor": user("alice"), "label": {"name": "bug"}},
49
+ {"event": "assigned", "created_at": "2026-06-01T09:10:00Z",
50
+ "actor": user("alice"), "assignee": user("bob")},
51
+ {"event": "commented", "created_at": "2026-06-02T10:00:00Z", "actor": user("bob")},
52
+ {"event": "cross-referenced", "created_at": "2026-06-10T08:00:00Z",
53
+ "actor": user("bob"),
54
+ "source": {"issue": {"number": 55, "pull_request": {},
55
+ "repository": {"full_name": "acme/rocket"}}}},
56
+ {"event": "closed", "created_at": "2026-06-20T18:00:00Z", "actor": user("bob")},
57
+ ],
58
+ "linked_pulls": [
59
+ {
60
+ "number": 55,
61
+ "title": "Make webhook consumer idempotent",
62
+ "state": "closed",
63
+ "merged_at": "2026-06-20T17:55:00Z",
64
+ "merged_by": user("carol"),
65
+ "html_url": "https://github.com/acme/rocket/pull/55",
66
+ "user": user("bob"),
67
+ "created_at": "2026-06-10T08:00:00Z",
68
+ "updated_at": "2026-06-20T17:55:00Z",
69
+ "body": "Adds idempotency keys so redelivered events are no-ops.",
70
+ }
71
+ ],
72
+ }
@@ -0,0 +1,93 @@
1
+ import copy
2
+ from datetime import datetime, timezone
3
+
4
+ import ucp
5
+
6
+ from ucp_gen import build_package
7
+
8
+ from .fixtures import BUNDLE
9
+
10
+ NOW = datetime(2026, 7, 5, 12, 0, 0, tzinfo=timezone.utc)
11
+
12
+
13
+ def build(**kwargs):
14
+ return build_package(copy.deepcopy(BUNDLE), now=NOW, **kwargs)
15
+
16
+
17
+ def test_output_is_schema_valid_and_reference_clean():
18
+ data = build()
19
+ ucp.validate(data) # raises on violation
20
+ pkg = ucp.Package.model_validate(data)
21
+ assert pkg.verify_references() == []
22
+
23
+
24
+ def test_entity_reflects_the_issue():
25
+ pkg = ucp.Package.model_validate(build())
26
+ assert pkg.entity.ref.id == "acme/rocket#42"
27
+ assert pkg.entity.status == "closed"
28
+ assert pkg.entity.assignee.id == "github:bob"
29
+
30
+
31
+ def test_summary_takes_first_meaningful_paragraph():
32
+ pkg = ucp.Package.model_validate(build())
33
+ assert pkg.summary.text.startswith("When more than ~50 webhook events")
34
+ assert pkg.summary.sources == ["issue"]
35
+
36
+
37
+ def test_state_claim_has_top_salience():
38
+ pkg = ucp.Package.model_validate(build())
39
+ top = max(pkg.must_know, key=lambda c: c.salience or 0)
40
+ assert top.id == "state"
41
+ assert "closed" in top.text and "completed" in top.text
42
+
43
+
44
+ def test_merged_pr_becomes_accepted_decision():
45
+ pkg = ucp.Package.model_validate(build())
46
+ accepted = [d for d in pkg.decisions if d.status == "accepted"]
47
+ assert len(accepted) == 1
48
+ assert "PR #55" in accepted[0].decision
49
+ assert accepted[0].decided_by.id == "github:carol"
50
+
51
+
52
+ def test_decision_marker_in_comment_becomes_proposed_decision():
53
+ pkg = ucp.Package.model_validate(build())
54
+ proposed = [d for d in pkg.decisions if d.status == "proposed"]
55
+ assert len(proposed) == 1
56
+ assert "at-least-once delivery" in proposed[0].decision
57
+ assert proposed[0].sources == ["comment-101"]
58
+
59
+
60
+ def test_every_source_carries_content_hash():
61
+ data = build()
62
+ assert len(data["sources"]) == 4 # issue + 2 comments + 1 PR
63
+ for source in data["sources"].values():
64
+ assert source["content_hash"].startswith("sha256:")
65
+
66
+
67
+ def test_history_is_chronological():
68
+ pkg = ucp.Package.model_validate(build())
69
+ stamps = [event.occurred_at for event in pkg.history]
70
+ assert stamps == sorted(stamps)
71
+ assert "Issue opened by alice" in pkg.history[0].summary
72
+
73
+
74
+ def test_since_produces_context_diff_and_temporal_profile():
75
+ data = build(since="2026-06-15T00:00:00Z")
76
+ ucp.validate(data)
77
+ pkg = ucp.Package.model_validate(data)
78
+ assert "ucp-temporal" in pkg.profiles
79
+ summaries = [change.summary for change in pkg.context_diff.changes]
80
+ assert summaries == ["bob closed the issue"] # the only event after `since`
81
+
82
+
83
+ def test_no_since_means_no_diff():
84
+ data = build()
85
+ assert "context_diff" not in data
86
+ assert data["profiles"] == ["ucp-core"]
87
+
88
+
89
+ def test_rendering_fits_budget_and_keeps_core():
90
+ pkg = ucp.Package.model_validate(build())
91
+ tight = pkg.render(token_budget=500)
92
+ assert ucp.estimate_tokens(tight) <= 500
93
+ assert "Payment webhook drops events" in tight
@@ -0,0 +1,38 @@
1
+ import copy
2
+ import json
3
+
4
+ import pytest
5
+
6
+ import ucp_gen.cli as cli
7
+
8
+ from .fixtures import BUNDLE
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def offline_github(monkeypatch):
13
+ def fake_fetch(owner, repo, number, token=None):
14
+ assert (owner, repo, number) == ("acme", "rocket", 42)
15
+ return copy.deepcopy(BUNDLE)
16
+
17
+ monkeypatch.setattr(cli, "fetch_issue_bundle", fake_fetch)
18
+
19
+
20
+ def test_cli_writes_valid_json(tmp_path, capsys):
21
+ out = tmp_path / "task.ucp.json"
22
+ assert cli.main(["github", "acme/rocket#42", "-o", str(out)]) == 0
23
+ data = json.loads(out.read_text())
24
+ assert data["ucp_version"] == "0.1.0"
25
+ assert "sources" in data
26
+ assert "wrote" in capsys.readouterr().err
27
+
28
+
29
+ def test_cli_markdown_mode(capsys):
30
+ assert cli.main(["github", "acme/rocket#42", "--markdown", "--token-budget", "600"]) == 0
31
+ output = capsys.readouterr().out
32
+ assert "Payment webhook drops events" in output
33
+ assert "{" not in output.splitlines()[0]
34
+
35
+
36
+ def test_cli_rejects_bad_ref():
37
+ with pytest.raises(SystemExit):
38
+ cli.main(["github", "not-a-ref"])