MCP server for tangled

add gh CLI parity design for pull requests

- document all gh pr commands and map to tangled MCP tools
- 8 tools: create, update, list, get, close, reopen, merge, ready
- preserve gh parameter names and semantics where possible
- key differences: user-provided patches, logical-only merges
- draft state as custom status value
- label reuse from issues (subject-based ops)
- default limit 30 (matching gh)

also update CLAUDE.md:
- use jq for JSON parsing (not python pipes)
- never use sleep (poll with tools instead)

+561
+3
CLAUDE.md
··· 22 22 - justfile: `setup`, `test`, `check`, `push` 23 23 - versioning: uv-dynamic-versioning (git tags) 24 24 - type checking: ty + ruff (I, UP) 25 + - **use `jq` for JSON parsing** (not python pipes) 26 + - example: `curl -s https://pypi.org/pypi/tangled-mcp/json | jq -r '.info.version'` 27 + - **never use `sleep`** - poll/check with actual tools instead 25 28 26 29 ## architecture notes 27 30 - repos stored as atproto records in collection `sh.tangled.repo` (NOT `sh.tangled.repo.repo`)
+558
docs/design-pulls.md
··· 1 + # pull requests: design exploration 2 + 3 + ## design principle: gh CLI parity 4 + 5 + **goal**: tangled-mcp pull request tools should be a subset of `gh pr` commands with matching semantics 6 + 7 + - users familiar with `gh` should feel at home 8 + - parameters should match where possible 9 + - we implement what tangled's atproto schema supports 10 + - we don't try to exceed gh's surface area 11 + 12 + ## gh pr commands (reference) 13 + 14 + ### general commands (gh) 15 + - `gh pr create` - create a PR with title, body, labels, draft state 16 + - `gh pr list` - list PRs with filters (state, labels, author, base, head) 17 + - `gh pr view` - show details of a single PR 18 + - `gh pr close` - close a PR 19 + - `gh pr reopen` - reopen a closed PR 20 + - `gh pr edit` - edit title, body, labels, base branch 21 + - `gh pr merge` - merge a PR 22 + 23 + ### what we can support (tangled MCP) 24 + 25 + | gh command | tangled tool | notes | 26 + |------------|--------------|-------| 27 + | `gh pr create` | `create_repo_pull` | ✅ title, body, base, head, labels, draft | 28 + | `gh pr list` | `list_repo_pulls` | ✅ state, labels, limit filtering | 29 + | `gh pr view` | `get_repo_pull` | ✅ full details of one PR | 30 + | `gh pr close` | `close_repo_pull` | ✅ via status update | 31 + | `gh pr reopen` | `reopen_repo_pull` | ✅ via status update | 32 + | `gh pr edit` | `update_repo_pull` | ✅ title, body, labels | 33 + | `gh pr merge` | `merge_repo_pull` | ✅ via status update (logical, not git merge) | 34 + | `gh pr comment` | ❌ not v1 | need `sh.tangled.repo.pull.comment` support | 35 + | `gh pr diff` | ❌ not v1 | could show `patch` field | 36 + | `gh pr checks` | ❌ not supported | no CI concept in tangled | 37 + | `gh pr review` | ❌ not v1 | need review records | 38 + 39 + ## current state 40 + 41 + ### what we have (issues) 42 + - **collection**: `sh.tangled.repo.issue` (stored on user's PDS) 43 + - **extra fields**: `issueId` (sequential), `owner` (creator DID) 44 + - **labels**: separate `sh.tangled.label.op` records, applied/removed via ops 45 + - **operations**: create, update, delete, list, list_labels 46 + - **no state tracking**: we don't use `sh.tangled.repo.issue.status` yet 47 + - **no comments**: we don't use `sh.tangled.repo.issue.comment` yet 48 + 49 + ### what we have (branches) 50 + - **knot XRPC**: `sh.tangled.repo.listBranches` query via knot 51 + - **read-only**: no branch creation/deletion yet 52 + - **operations**: list only 53 + 54 + ## pull request schema (from lexicons) 55 + 56 + ### core record: `sh.tangled.repo.pull` 57 + ```typescript 58 + { 59 + target: { 60 + repo: string (at-uri), // where it's merging to 61 + branch: string 62 + }, 63 + source: { 64 + branch: string, // where it's coming from 65 + sha: string (40 chars), // commit hash 66 + repo?: string (at-uri) // optional: for cross-repo pulls 67 + }, 68 + title: string, 69 + body?: string, 70 + patch: string, // git diff format 71 + createdAt: datetime 72 + } 73 + ``` 74 + 75 + ### state tracking: `sh.tangled.repo.pull.status` 76 + ```typescript 77 + { 78 + pull: string (at-uri), // reference to pull record 79 + status: "open" | "closed" | "merged" 80 + } 81 + ``` 82 + 83 + ### comments: `sh.tangled.repo.pull.comment` 84 + ```typescript 85 + { 86 + pull: string (at-uri), 87 + body: string, 88 + createdAt: datetime 89 + } 90 + ``` 91 + 92 + ## design questions 93 + 94 + ### 1. sequential IDs (pullId) 95 + **question**: should pulls have `pullId` like issues have `issueId`? 96 + 97 + **considerations**: 98 + - human-friendly references: "PR #42" vs AT-URI 99 + - need to maintain counter per-repo (same pattern as issues) 100 + - easier for users to reference in comments/descriptions 101 + - tangled.org URLs probably expect this: `tangled.org/@owner/repo/pulls/42` 102 + 103 + **recommendation**: yes, add `pullId` field following issue pattern 104 + 105 + ### 2. resources vs tools 106 + **current**: 1 resource (`tangled://status`), 6 tools 107 + 108 + **question**: should we expose repos/issues/pulls as MCP resources? 109 + 110 + **resources are for**: 111 + - read-only data 112 + - things that change over time 113 + - content the LLM should "know about" contextually 114 + - example: `tangled://repo/{owner}/{repo}/issues` → feed LLM current issues 115 + 116 + **tools are for**: 117 + - actions (create, update, delete) 118 + - queries with parameters 119 + - returning specific structured data 120 + 121 + **potential resources**: 122 + - `tangled://repo/{owner}/{repo}` → repo metadata, default branch, description 123 + - `tangled://repo/{owner}/{repo}/issues` → current open issues 124 + - `tangled://repo/{owner}/{repo}/pulls` → current open pulls 125 + - `tangled://repo/{owner}/{repo}/branches` → all branches 126 + 127 + **recommendation**: 128 + - keep tools for actions (create/update/delete) 129 + - add resources for "current state" views 130 + - resources update when queried (not live/streaming) 131 + 132 + ### 3. patch generation 133 + **question**: where does the `patch` field come from? 134 + 135 + **options**: 136 + a) **client generates**: user provides git diff output 137 + - pros: simple, no server-side git ops 138 + - cons: user burden, error-prone 139 + 140 + b) **knot XRPC**: new endpoint `sh.tangled.repo.generatePatch` 141 + - pros: server generates correct diff 142 + - cons: requires knot changes 143 + 144 + c) **hybrid**: accept both user-provided patch OR branch refs 145 + - if patch provided: use it 146 + - if only branches: call knot to generate 147 + - pros: flexible 148 + - cons: more complex 149 + 150 + **recommendation**: start with (a), add (b) later as knot capability 151 + 152 + ### 4. cross-repo pulls (forks) 153 + **question**: how to handle `source.repo` different from `target.repo`? 154 + 155 + **use case**: fork workflow 156 + - user forks `owner-a/repo` to `owner-b/repo` 157 + - makes changes on fork 158 + - opens pull from `owner-b/repo:feature` → `owner-a/repo:main` 159 + 160 + **challenges**: 161 + - need to resolve both repos (source and target) 162 + - patch generation across repos 163 + - permissions: who can create pulls? 164 + 165 + **recommendation**: 166 + - v1: same-repo pulls only (`source.repo` optional, defaults to target) 167 + - v2: add cross-repo support once we understand patterns 168 + 169 + ### 5. state management 170 + **question**: do we track state separately or in-record? 171 + 172 + **issues**: currently don't use `sh.tangled.repo.issue.status` 173 + **pulls**: lexicon has `sh.tangled.repo.pull.status` 174 + 175 + **pattern from labels**: 176 + - labels are separate ops 177 + - ops can be applied/reverted 178 + - current state = sum of all ops 179 + 180 + **state is simpler**: 181 + - open → closed → merged (mostly linear) 182 + - probably doesn't need full ops history 183 + - could just update a field on pull record OR use status records 184 + 185 + **recommendation**: 186 + - use `sh.tangled.repo.pull.status` records (follow lexicon) 187 + - easier to track state changes over time 188 + - consistent with label pattern 189 + - can query "all status changes for pull X" 190 + 191 + **draft state**: gh treats draft as a separate boolean, but tangled's lexicon shows: 192 + - `sh.tangled.repo.pull.status.open` 193 + - `sh.tangled.repo.pull.status.closed` 194 + - `sh.tangled.repo.pull.status.merged` 195 + 196 + **solution**: we could: 197 + - (a) add custom `draft` field to pull record (not in lexicon, might break) 198 + - (b) treat draft as metadata in status record 199 + - (c) add `sh.tangled.repo.pull.status.draft` as custom state 200 + 201 + **recommendation**: (c) - add draft as a custom state value 202 + - fits existing pattern 203 + - `draft` → `open` transition when ready 204 + - backwards compatible (lexicon uses `knownValues` not enum) 205 + 206 + ### 6. interconnections 207 + **entities that reference each other**: 208 + - issues mention pulls: "closes #42" 209 + - pulls mention issues: "fixes #123" 210 + - both have labels, comments 211 + - pulls reference commits/branches 212 + 213 + **question**: how to expose these relationships? 214 + 215 + **options**: 216 + a) **inline**: include referenced entities in responses 217 + - `list_repo_pulls` returns issues it closes 218 + - bloats responses 219 + 220 + b) **separate queries**: tools to fetch relationships 221 + - `get_pull_related_issues(pull_id)` 222 + - `get_issue_related_pulls(issue_id)` 223 + - more API calls but cleaner 224 + 225 + c) **resources**: expose as graphs 226 + - `tangled://repo/{owner}/{repo}/graph` → all entities + edges 227 + - LLM can traverse 228 + - ambitious 229 + 230 + **recommendation**: start with (b), consider (c) as resources mature 231 + 232 + ### 7. comments 233 + **question**: support issue/pull comments now or later? 234 + 235 + **considerations**: 236 + - both have `comment` collections 237 + - valuable for context (PR review discussions) 238 + - adds complexity (list, create, update, delete comments) 239 + 240 + **recommendation**: 241 + - v1: skip comments, focus on core pull CRUD 242 + - v2: add comments once pull basics work 243 + - keeps initial scope manageable 244 + 245 + ## tool signatures (gh-style) 246 + 247 + ### create_repo_pull 248 + ```python 249 + def create_repo_pull( 250 + repo: str, # gh: -R, --repo 251 + title: str, # gh: -t, --title 252 + body: str | None = None, # gh: -b, --body 253 + base: str = "main", # gh: -B, --base (target branch) 254 + head: str, # gh: -H, --head (source branch) 255 + source_sha: str, # commit hash (required, no gh equiv - we need it for atproto) 256 + patch: str, # git diff (required, no gh equiv - atproto schema) 257 + labels: list[str] | None = None, # gh: -l, --label 258 + draft: bool = False, # gh: -d, --draft 259 + ) -> CreatePullResult: 260 + """ 261 + create a pull request 262 + 263 + similar to: gh pr create --title "..." --body "..." --base main --head feature --label bug --draft 264 + """ 265 + ``` 266 + 267 + ### update_repo_pull 268 + ```python 269 + def update_repo_pull( 270 + repo: str, # gh: -R, --repo 271 + pull_id: int, # gh: <number> 272 + title: str | None = None, # gh: -t, --title 273 + body: str | None = None, # gh: -b, --body 274 + base: str | None = None, # gh: -B, --base (change target branch) 275 + add_labels: list[str] | None = None, # gh: --add-label 276 + remove_labels: list[str] | None = None, # gh: --remove-label 277 + ) -> UpdatePullResult: 278 + """ 279 + edit a pull request 280 + 281 + similar to: gh pr edit 42 --title "..." --add-label bug --remove-label wontfix 282 + """ 283 + ``` 284 + 285 + ### list_repo_pulls 286 + ```python 287 + def list_repo_pulls( 288 + repo: str, # gh: -R, --repo 289 + state: str = "open", # gh: -s, --state {open|closed|merged|all} 290 + labels: list[str] | None = None, # gh: -l, --label 291 + base: str | None = None, # gh: -B, --base (filter by target branch) 292 + head: str | None = None, # gh: -H, --head (filter by source branch) 293 + draft: bool | None = None, # gh: -d, --draft (filter by draft state) 294 + limit: int = 30, # gh: -L, --limit (default 30) 295 + cursor: str | None = None, # pagination 296 + ) -> ListPullsResult: 297 + """ 298 + list pull requests 299 + 300 + similar to: gh pr list --state open --label bug --limit 50 301 + """ 302 + ``` 303 + 304 + ### get_repo_pull 305 + ```python 306 + def get_repo_pull( 307 + repo: str, # gh: -R, --repo 308 + pull_id: int, # gh: <number> 309 + ) -> PullInfo: 310 + """ 311 + view a pull request 312 + 313 + similar to: gh pr view 42 314 + """ 315 + ``` 316 + 317 + ### close_repo_pull 318 + ```python 319 + def close_repo_pull( 320 + repo: str, # gh: -R, --repo 321 + pull_id: int, # gh: <number> 322 + ) -> UpdatePullResult: 323 + """ 324 + close a pull request (sets status to closed) 325 + 326 + similar to: gh pr close 42 327 + """ 328 + ``` 329 + 330 + ### reopen_repo_pull 331 + ```python 332 + def reopen_repo_pull( 333 + repo: str, # gh: -R, --repo 334 + pull_id: int, # gh: <number> 335 + ) -> UpdatePullResult: 336 + """ 337 + reopen a closed pull request (sets status back to open) 338 + 339 + similar to: gh pr reopen 42 340 + """ 341 + ``` 342 + 343 + ### merge_repo_pull 344 + ```python 345 + def merge_repo_pull( 346 + repo: str, # gh: -R, --repo 347 + pull_id: int, # gh: <number> 348 + ) -> UpdatePullResult: 349 + """ 350 + mark a pull request as merged (sets status to merged) 351 + 352 + note: this is a logical merge (status change), not an actual git merge 353 + similar to: gh pr merge 42 (but without the git operation) 354 + """ 355 + ``` 356 + 357 + ## proposed roadmap 358 + 359 + ### phase 1: core pr operations (gh parity) 360 + 361 + **7 tools matching gh pr commands**: 362 + 363 + 1. **create_repo_pull** (matches `gh pr create`) 364 + - parameters: repo, title, body, base, head, source_sha, patch, labels, draft 365 + - generates pullId (like issueId) 366 + - creates `sh.tangled.repo.pull` record 367 + - creates initial `sh.tangled.repo.pull.status` record (open or draft) 368 + - applies labels if provided 369 + - returns CreatePullResult with pullId and URL 370 + 371 + 2. **update_repo_pull** (matches `gh pr edit`) 372 + - parameters: repo, pull_id, title, body, base, add_labels, remove_labels 373 + - updates pull record via putRecord + swap 374 + - handles incremental label changes (add/remove pattern) 375 + - returns UpdatePullResult with pullId and URL 376 + 377 + 3. **list_repo_pulls** (matches `gh pr list`) 378 + - parameters: repo, state, labels, base, head, draft, limit, cursor 379 + - queries `sh.tangled.repo.pull` + correlates with status 380 + - filters by state (open/closed/merged/all), labels, branches, draft 381 + - default limit 30 (matching gh) 382 + - includes labels for each pull 383 + - returns ListPullsResult with pulls and cursor 384 + 385 + 4. **get_repo_pull** (matches `gh pr view`) 386 + - parameters: repo, pull_id 387 + - fetches single pull with full details (target, source, patch, status, labels) 388 + - returns PullInfo model 389 + 390 + 5. **close_repo_pull** (matches `gh pr close`) 391 + - parameters: repo, pull_id 392 + - creates new status record with "closed" 393 + - returns UpdatePullResult 394 + 395 + 6. **reopen_repo_pull** (matches `gh pr reopen`) 396 + - parameters: repo, pull_id 397 + - creates new status record with "open" 398 + - only works if current status is "closed" 399 + - returns UpdatePullResult 400 + 401 + 7. **merge_repo_pull** (matches `gh pr merge` logically) 402 + - parameters: repo, pull_id 403 + - creates new status record with "merged" 404 + - note: logical merge only (no git operation) 405 + - returns UpdatePullResult 406 + 407 + **types** (following issue pattern): 408 + - `PullInfo` model (uri, cid, pullId, title, body, target, source, createdAt, labels, status) 409 + - `CreatePullResult` (repo, pull_id, url) 410 + - `UpdatePullResult` (repo, pull_id, url) 411 + - `ListPullsResult` (pulls, cursor) 412 + 413 + **new module**: 414 + - `src/tangled_mcp/_tangled/_pulls.py` (parallel to _issues.py) 415 + - `src/tangled_mcp/types/_pulls.py` (parallel to _issues.py) 416 + 417 + ### phase 2: labels + better state 418 + **enhancements**: 419 + - pulls support labels (reuse `_validate_labels`, `_apply_labels`) 420 + - `list_pull_status_history(repo, pull_id)` → all status changes 421 + - pull status in URL: `tangled.org/@owner/repo/pulls/42` shows status 422 + 423 + ### phase 3: resources 424 + **add resources**: 425 + - `tangled://repo/{owner}/{repo}/pulls/open` → current open PRs 426 + - `tangled://repo/{owner}/{repo}/pulls/{pull_id}` → specific pull context 427 + - helps LLM understand "what PRs exist for this repo?" 428 + 429 + ### phase 4: cross-repo + comments 430 + **ambitious**: 431 + - cross-repo pull support (forks) 432 + - comment creation/listing 433 + - patch generation via knot XRPC 434 + 435 + ## open questions 436 + 437 + ### 1. draft state implementation 438 + **question**: should we use `sh.tangled.repo.pull.status.draft` as a custom state? 439 + - **option a**: separate draft field on pull record (not in lexicon) 440 + - **option b**: draft as custom status value `sh.tangled.repo.pull.status.draft` 441 + - **recommended**: option b - fits lexicon pattern, `draft` → `open` transition 442 + 443 + ### 2. patch format (v1 scope) 444 + **question**: how do users provide the patch field? 445 + - **v1**: user provides git diff string (simple, no server dependency) 446 + - **v2**: could add knot XRPC to generate from branch refs 447 + - **gh equivalent**: `gh pr create` auto-generates from local branch 448 + 449 + ### 3. ready-for-review transition 450 + **question**: `gh pr ready` marks draft PR as ready - how to support? 451 + - **option a**: separate `ready_repo_pull(repo, pull_id)` tool 452 + - **option b**: use `update_repo_pull` with status change 453 + - **recommended**: option a - matches gh command structure 454 + 455 + ### 4. URL format confirmation 456 + **assumption**: `https://tangled.org/@owner/repo/pulls/42` 457 + - matches issue pattern 458 + - need to confirm with tangled.org routing 459 + 460 + ### 5. merge semantics 461 + **clarification**: `merge_repo_pull` is logical only (status change) 462 + - does NOT perform git merge operation 463 + - tangled may handle actual merge separately 464 + - gh does both (status + git operation) 465 + 466 + ## implementation notes 467 + 468 + ### pattern consistency with issues 469 + ```python 470 + # issues pattern (working) 471 + def create_issue(repo_id, title, body, labels): 472 + # 1. resolve repo to AT-URI 473 + # 2. find next issueId 474 + # 3. create record with tid rkey 475 + # 4. apply labels if provided 476 + # 5. return {uri, cid, issueId} 477 + 478 + # pulls pattern (proposed) 479 + def create_pull(repo_id, target_branch, source_branch, source_sha, title, body, patch, labels): 480 + # 1. resolve repo to AT-URI 481 + # 2. find next pullId 482 + # 3. create pull record with {target, source, patch, ...} 483 + # 4. create status record (open) 484 + # 5. apply labels if provided 485 + # 6. return {uri, cid, pullId} 486 + ``` 487 + 488 + ### state tracking pattern 489 + ```python 490 + def _get_current_pull_status(client, pull_uri): 491 + """get latest status for a pull by querying status records""" 492 + status_records = client.com.atproto.repo.list_records( 493 + collection="sh.tangled.repo.pull.status", 494 + repo=client.me.did, 495 + ) 496 + 497 + # find all status records for this pull 498 + pull_statuses = [ 499 + r for r in status_records.records 500 + if getattr(r.value, "pull", None) == pull_uri 501 + ] 502 + 503 + # return most recent (last created) 504 + if pull_statuses: 505 + latest = max(pull_statuses, key=lambda r: getattr(r.value, "createdAt", "")) 506 + return getattr(latest.value, "status", "open") 507 + 508 + return "open" # default 509 + ``` 510 + 511 + ### label reuse 512 + ```python 513 + # labels work the same for issues and pulls 514 + # _apply_labels takes a subject_uri (issue or pull) 515 + _apply_labels(client, pull_uri, labels, repo_labels, current_labels) 516 + 517 + # so label ops are generic: 518 + { 519 + "$type": "sh.tangled.label.op", 520 + "subject": "at://did/sh.tangled.repo.pull/abc123", # or issue URI 521 + "add": [...], 522 + "delete": [...] 523 + } 524 + ``` 525 + 526 + ## summary: gh pr → tangled MCP mapping 527 + 528 + ### v1 feature matrix (phase 1) 529 + 530 + | feature | gh command | tangled tool | parameters | notes | 531 + |---------|-----------|--------------|------------|-------| 532 + | create PR | `gh pr create` | `create_repo_pull` | title, body, base, head, source_sha, patch, labels, draft | ✅ full parity except auto-patch | 533 + | edit PR | `gh pr edit` | `update_repo_pull` | title, body, base, add_labels, remove_labels | ✅ full parity | 534 + | list PRs | `gh pr list` | `list_repo_pulls` | state, labels, base, head, draft, limit | ✅ full parity (default limit 30) | 535 + | view PR | `gh pr view` | `get_repo_pull` | pull_id | ✅ full parity | 536 + | close PR | `gh pr close` | `close_repo_pull` | pull_id | ✅ status change only | 537 + | reopen PR | `gh pr reopen` | `reopen_repo_pull` | pull_id | ✅ status change only | 538 + | merge PR | `gh pr merge` | `merge_repo_pull` | pull_id | ⚠️ logical only (no git merge) | 539 + | mark ready | `gh pr ready` | `ready_repo_pull` | pull_id | ✅ draft → open transition | 540 + 541 + ### not in v1 (future) 542 + - `gh pr comment` → need `sh.tangled.repo.pull.comment` support 543 + - `gh pr diff` → could show `patch` field 544 + - `gh pr review` → need review records 545 + - `gh pr checks` → no CI concept in tangled 546 + 547 + ### key differences from gh 548 + 1. **patch field**: users provide git diff string (gh auto-generates) 549 + 2. **merge**: logical status change only (gh performs git merge) 550 + 3. **source_sha**: required parameter (gh infers from branch) 551 + 4. **pullId**: explicit numeric ID (gh uses number or branch name) 552 + 553 + ### preserved gh patterns 554 + - parameter names match gh flags where possible 555 + - state values: `open`, `closed`, `merged`, `draft` 556 + - filtering: state, labels, base, head, draft 557 + - default limit: 30 (matching gh pr list) 558 + - incremental label updates: add/remove pattern