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.
- ucp_gen-0.1.0/.gitignore +6 -0
- ucp_gen-0.1.0/PKG-INFO +70 -0
- ucp_gen-0.1.0/README.md +54 -0
- ucp_gen-0.1.0/pyproject.toml +33 -0
- ucp_gen-0.1.0/src/ucp_gen/__init__.py +6 -0
- ucp_gen-0.1.0/src/ucp_gen/build.py +288 -0
- ucp_gen-0.1.0/src/ucp_gen/cli.py +76 -0
- ucp_gen-0.1.0/src/ucp_gen/github.py +96 -0
- ucp_gen-0.1.0/tests/__init__.py +0 -0
- ucp_gen-0.1.0/tests/fixtures.py +72 -0
- ucp_gen-0.1.0/tests/test_build.py +93 -0
- ucp_gen-0.1.0/tests/test_cli.py +38 -0
ucp_gen-0.1.0/.gitignore
ADDED
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).
|
ucp_gen-0.1.0/README.md
ADDED
|
@@ -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,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"])
|