-9
.claude/commands/release.md
-9
.claude/commands/release.md
···
1
-
check git state and commit and push if appropriate, we are doing to release.
2
-
3
-
read @docs/publishing.md and use it to help me cut a new release of tangled-mcp.
4
-
5
-
use gh to view the website for the current repo, it should point to the latest published version of the MCP in the official registry, verify after release this points to the new version you just released.
6
-
7
-
also curl pypi to verify that the new version is available.
8
-
9
-
run a smoke test of the MCP as a top level executable of the library, then follow by a test of the individual tools with the FastMCP client.
-4
CLAUDE.md
-4
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
-
- remember that `tree` is your friend, better than `ls` and a dream
26
-
- **use `jq` for JSON parsing** (not python pipes)
27
-
- example: `curl -s https://pypi.org/pypi/tangled-mcp/json | jq -r '.info.version'`
28
-
- **never use `sleep`** - poll/check with actual tools instead
29
25
30
26
## architecture notes
31
27
- repos stored as atproto records in collection `sh.tangled.repo` (NOT `sh.tangled.repo.repo`)
+137
NEXT_STEPS.md
+137
NEXT_STEPS.md
···
1
+
# next steps
2
+
3
+
## critical fixes
4
+
5
+
### 1. label validation must fail loudly
6
+
7
+
**problem:** when users specify labels that don't exist in the repo's subscribed label definitions, they're silently ignored. no error, no warning, just nothing happens.
8
+
9
+
**current behavior:**
10
+
```python
11
+
create_repo_issue(repo="owner/repo", labels=["demo", "nonexistent"])
12
+
# -> creates issue with NO labels, returns success
13
+
```
14
+
15
+
**what should happen:**
16
+
```python
17
+
create_repo_issue(repo="owner/repo", labels=["demo", "nonexistent"])
18
+
# -> raises ValueError:
19
+
# "invalid labels: ['demo', 'nonexistent']
20
+
# available labels for this repo: ['wontfix', 'duplicate', 'good-first-issue', ...]"
21
+
```
22
+
23
+
**fix locations:**
24
+
- `src/tangled_mcp/_tangled/_issues.py:_apply_labels()` - validate before applying
25
+
- add `validate_labels()` helper that checks against repo's subscribed labels
26
+
- fail fast with actionable error message listing available labels
27
+
28
+
### 2. list_repo_issues should include label information
29
+
30
+
**problem:** `list_repo_issues` returns issues but doesn't include their labels. labels are stored separately in `sh.tangled.label.op` records and need to be fetched and correlated.
31
+
32
+
**impact:** users can't see what labels an issue has without manually querying label ops or checking the UI.
33
+
34
+
**fix:**
35
+
- add `labels: list[str]` field to `IssueInfo` model
36
+
- in `list_repo_issues`, fetch label ops and correlate with issues
37
+
- return label names (not URIs) for better UX
38
+
39
+
### 3. fix pydantic field warning
40
+
41
+
**warning:**
42
+
```
43
+
UnsupportedFieldAttributeWarning: The 'default' attribute with value None was provided
44
+
to the `Field()` function, which has no effect in the context it was used.
45
+
```
46
+
47
+
**likely cause:** somewhere we're using `Field(default=None)` in an `Annotated` type or union context where it doesn't make sense.
48
+
49
+
**fix:** audit all `Field()` uses and remove invalid `default=None` declarations.
50
+
51
+
## enhancements
52
+
53
+
### 4. better error messages for repo resolution failures
54
+
55
+
when a repo doesn't exist or handle can't be resolved, give users clear next steps:
56
+
- is the repo name spelled correctly?
57
+
- does the repo exist on tangled.org?
58
+
- do you have access to it?
59
+
60
+
### 5. add label listing tool
61
+
62
+
users need to know what labels are available for a repo before they can use them.
63
+
64
+
**new tool:**
65
+
```python
66
+
list_repo_labels(repo: str) -> list[str]
67
+
# returns: ["wontfix", "duplicate", "good-first-issue", ...]
68
+
```
69
+
70
+
### 6. pagination cursor handling
71
+
72
+
currently returning raw cursor strings. consider:
73
+
- documenting cursor format
74
+
- providing helper for "has more pages" checking
75
+
- clear examples in docstrings
76
+
77
+
## completed improvements (this session)
78
+
79
+
### โ
types architecture refactored
80
+
- moved from single `types.py` to `types/` directory
81
+
- separated concerns: `_common.py`, `_branches.py`, `_issues.py`
82
+
- public API in `__init__.py`
83
+
- parsing logic moved into types via `.from_api_response()` class methods
84
+
85
+
### โ
proper validation with annotated types
86
+
- `RepoIdentifier = Annotated[str, AfterValidator(normalize_repo_identifier)]`
87
+
- strips `@` prefix automatically
88
+
- validates format before processing
89
+
90
+
### โ
clickable URLs instead of AT Protocol internals
91
+
- issue operations return `https://tangled.org/@owner/repo/issues/N`
92
+
- removed useless `uri` and `cid` from user-facing responses
93
+
- URL generation encapsulated in types via `@computed_field`
94
+
95
+
### โ
proper typing everywhere
96
+
- no more `dict[str, Any]` return types
97
+
- pydantic models for all results
98
+
- type safety throughout
99
+
100
+
### โ
minimal test coverage
101
+
- 17 tests covering public contracts
102
+
- no implementation details tested
103
+
- validates key behaviors: URL generation, validation, parsing
104
+
105
+
### โ
demo scripts
106
+
- full lifecycle demo
107
+
- URL format handling demo
108
+
- branch listing demo
109
+
- label manipulation demo (revealed silent failure issue)
110
+
111
+
### โ
documentation improvements
112
+
- MCP client installation instructions in collapsible details
113
+
- clear usage examples for multiple clients
114
+
115
+
## technical debt
116
+
117
+
### remove unused types
118
+
- `RepoInfo`, `PullInfo`, `CreateRepoResult`, `GenericResult` - not used anywhere
119
+
- clean up or remove from public API
120
+
121
+
### consolidate URL generation logic
122
+
- `_tangled_issue_url()` helper was created to DRY the URL generation
123
+
- good pattern, consider extending to other URL types if needed
124
+
125
+
### consider lazy evaluation for expensive validations
126
+
- repo resolution happens on every tool call
127
+
- could cache repo metadata (knot, did) for duration of connection
128
+
- tradeoff: freshness vs performance
129
+
130
+
## priorities
131
+
132
+
1. **critical:** fix label validation (fails silently)
133
+
2. **high:** add labels to list_repo_issues output
134
+
3. **medium:** add list_repo_labels tool
135
+
4. **medium:** fix pydantic warning
136
+
5. **low:** better error messages
137
+
6. **low:** clean up unused types
-617
sandbox/design-pulls.md
-617
sandbox/design-pulls.md
···
1
-
# pull requests: design exploration
2
-
3
-
**โ ๏ธ CRITICAL: THIS DESIGN CANNOT BE IMPLEMENTED โ ๏ธ**
4
-
5
-
**Date discovered**: 2025-10-11
6
-
**Reason**: Pull requests in tangled use a fundamentally different architecture than issues.
7
-
8
-
## Why PR support via atproto records doesn't work
9
-
10
-
After implementing this design and testing, we discovered that **tangled's appview does not ingest pull records from the firehose**. Here's the architectural difference:
11
-
12
-
### Issues (โ
Works):
13
-
1. **Jetstream subscription**: `appview/state/state.go:109` subscribes to `sh.tangled.repo.issue`
14
-
2. **Firehose ingester**: `appview/ingester.go:79-80` has `ingestIssue()` function
15
-
3. **Pattern**: atproto record is source of truth โ firehose keeps database synchronized
16
-
4. **Result**: MCP tools work! Create issue via atproto โ appears on tangled.org
17
-
18
-
### Pulls (โ Broken):
19
-
1. **No jetstream subscription**: `tangled.RepoPullNSID` is **missing** from subscription list
20
-
2. **No firehose ingester**: No `ingestPull()` function exists in `appview/ingester.go`
21
-
3. **Pattern**: Database is source of truth โ atproto record is decorative
22
-
4. **Web UI flow** (`appview/pulls/pulls.go:1196`):
23
-
- Creates DB entry FIRST via `db.NewPull()`
24
-
- THEN creates atproto record as "announcement"
25
-
5. **Result**: MCP-created PRs are **orphan records** that exist on PDS but never appear on tangled.org
26
-
27
-
### Why this design exists
28
-
29
-
Looking at the code (`appview/pulls/pulls.go`), pulls have:
30
-
- **Submissions array**: Multiple rounds of patches (DB-only concept)
31
-
- **Size concerns**: Patches can be megabytes (expensive in atproto)
32
-
- **Complexity**: Appview manages pull state in its database with features not in atproto schema
33
-
- **Pragmatism**: They wanted to ship pulls quickly, made DB the source of truth
34
-
35
-
### Evidence
36
-
37
-
```bash
38
-
# Issues are subscribed in jetstream
39
-
grep -n "RepoIssueNSID" sandbox/tangled-core/appview/state/state.go
40
-
# 109: tangled.RepoIssueNSID,
41
-
42
-
# Pulls are NOT subscribed
43
-
grep -n "RepoPullNSID" sandbox/tangled-core/appview/state/state.go
44
-
# (no results)
45
-
46
-
# Issues have an ingester
47
-
grep -A 2 "RepoIssueNSID:" sandbox/tangled-core/appview/ingester.go
48
-
# case tangled.RepoIssueNSID:
49
-
# err = i.ingestIssue(ctx, e)
50
-
51
-
# Pulls do NOT have an ingester
52
-
grep "RepoPullNSID:" sandbox/tangled-core/appview/ingester.go
53
-
# (no results)
54
-
```
55
-
56
-
### Conclusion
57
-
58
-
**Pull request support cannot be added to tangled-mcp** without changes to tangled-core (adding firehose consumer). The design below is architecturally sound but incompatible with tangled's current implementation.
59
-
60
-
---
61
-
62
-
## design principle: gh CLI parity
63
-
64
-
**goal**: tangled-mcp pull request tools should be a subset of `gh pr` commands with matching semantics
65
-
66
-
- users familiar with `gh` should feel at home
67
-
- parameters should match where possible
68
-
- we implement what tangled's atproto schema supports
69
-
- we don't try to exceed gh's surface area
70
-
71
-
## gh pr commands (reference)
72
-
73
-
### general commands (gh)
74
-
- `gh pr create` - create a PR with title, body, labels, draft state
75
-
- `gh pr list` - list PRs with filters (state, labels, author, base, head)
76
-
- `gh pr view` - show details of a single PR
77
-
- `gh pr close` - close a PR
78
-
- `gh pr reopen` - reopen a closed PR
79
-
- `gh pr edit` - edit title, body, labels, base branch
80
-
- `gh pr merge` - merge a PR
81
-
82
-
### what we can support (tangled MCP)
83
-
84
-
| gh command | tangled tool | notes |
85
-
|------------|--------------|-------|
86
-
| `gh pr create` | `create_repo_pull` | โ
title, body, base, head, labels, draft |
87
-
| `gh pr list` | `list_repo_pulls` | โ
state, labels, limit filtering |
88
-
| `gh pr view` | `get_repo_pull` | โ
full details of one PR |
89
-
| `gh pr close` | `close_repo_pull` | โ
via status update |
90
-
| `gh pr reopen` | `reopen_repo_pull` | โ
via status update |
91
-
| `gh pr edit` | `update_repo_pull` | โ
title, body, labels |
92
-
| `gh pr merge` | `merge_repo_pull` | โ
via status update (logical, not git merge) |
93
-
| `gh pr comment` | โ not v1 | need `sh.tangled.repo.pull.comment` support |
94
-
| `gh pr diff` | โ not v1 | could show `patch` field |
95
-
| `gh pr checks` | โ not supported | no CI concept in tangled |
96
-
| `gh pr review` | โ not v1 | need review records |
97
-
98
-
## current state
99
-
100
-
### what we have (issues)
101
-
- **collection**: `sh.tangled.repo.issue` (stored on user's PDS)
102
-
- **extra fields**: `issueId` (sequential), `owner` (creator DID)
103
-
- **labels**: separate `sh.tangled.label.op` records, applied/removed via ops
104
-
- **operations**: create, update, delete, list, list_labels
105
-
- **no state tracking**: we don't use `sh.tangled.repo.issue.status` yet
106
-
- **no comments**: we don't use `sh.tangled.repo.issue.comment` yet
107
-
108
-
### what we have (branches)
109
-
- **knot XRPC**: `sh.tangled.repo.listBranches` query via knot
110
-
- **read-only**: no branch creation/deletion yet
111
-
- **operations**: list only
112
-
113
-
## pull request schema (from lexicons)
114
-
115
-
### core record: `sh.tangled.repo.pull`
116
-
```typescript
117
-
{
118
-
target: {
119
-
repo: string (at-uri), // where it's merging to
120
-
branch: string
121
-
},
122
-
source: {
123
-
branch: string, // where it's coming from
124
-
sha: string (40 chars), // commit hash
125
-
repo?: string (at-uri) // optional: for cross-repo pulls
126
-
},
127
-
title: string,
128
-
body?: string,
129
-
patch: string, // git diff format
130
-
createdAt: datetime
131
-
}
132
-
```
133
-
134
-
### state tracking: `sh.tangled.repo.pull.status`
135
-
```typescript
136
-
{
137
-
pull: string (at-uri), // reference to pull record
138
-
status: "open" | "closed" | "merged"
139
-
}
140
-
```
141
-
142
-
### comments: `sh.tangled.repo.pull.comment`
143
-
```typescript
144
-
{
145
-
pull: string (at-uri),
146
-
body: string,
147
-
createdAt: datetime
148
-
}
149
-
```
150
-
151
-
## design questions
152
-
153
-
### 1. sequential IDs (pullId)
154
-
**question**: should pulls have `pullId` like issues have `issueId`?
155
-
156
-
**considerations**:
157
-
- human-friendly references: "PR #42" vs AT-URI
158
-
- need to maintain counter per-repo (same pattern as issues)
159
-
- easier for users to reference in comments/descriptions
160
-
- tangled.org URLs probably expect this: `tangled.org/@owner/repo/pulls/42`
161
-
162
-
**recommendation**: yes, add `pullId` field following issue pattern
163
-
164
-
### 2. resources vs tools
165
-
**current**: 1 resource (`tangled://status`), 6 tools
166
-
167
-
**question**: should we expose repos/issues/pulls as MCP resources?
168
-
169
-
**resources are for**:
170
-
- read-only data
171
-
- things that change over time
172
-
- content the LLM should "know about" contextually
173
-
- example: `tangled://repo/{owner}/{repo}/issues` โ feed LLM current issues
174
-
175
-
**tools are for**:
176
-
- actions (create, update, delete)
177
-
- queries with parameters
178
-
- returning specific structured data
179
-
180
-
**potential resources**:
181
-
- `tangled://repo/{owner}/{repo}` โ repo metadata, default branch, description
182
-
- `tangled://repo/{owner}/{repo}/issues` โ current open issues
183
-
- `tangled://repo/{owner}/{repo}/pulls` โ current open pulls
184
-
- `tangled://repo/{owner}/{repo}/branches` โ all branches
185
-
186
-
**recommendation**:
187
-
- keep tools for actions (create/update/delete)
188
-
- add resources for "current state" views
189
-
- resources update when queried (not live/streaming)
190
-
191
-
### 3. patch generation
192
-
**question**: where does the `patch` field come from?
193
-
194
-
**options**:
195
-
a) **client generates**: user provides git diff output
196
-
- pros: simple, no server-side git ops
197
-
- cons: user burden, error-prone
198
-
199
-
b) **knot XRPC**: new endpoint `sh.tangled.repo.generatePatch`
200
-
- pros: server generates correct diff
201
-
- cons: requires knot changes
202
-
203
-
c) **hybrid**: accept both user-provided patch OR branch refs
204
-
- if patch provided: use it
205
-
- if only branches: call knot to generate
206
-
- pros: flexible
207
-
- cons: more complex
208
-
209
-
**recommendation**: start with (a), add (b) later as knot capability
210
-
211
-
### 4. cross-repo pulls (forks)
212
-
**question**: how to handle `source.repo` different from `target.repo`?
213
-
214
-
**use case**: fork workflow
215
-
- user forks `owner-a/repo` to `owner-b/repo`
216
-
- makes changes on fork
217
-
- opens pull from `owner-b/repo:feature` โ `owner-a/repo:main`
218
-
219
-
**challenges**:
220
-
- need to resolve both repos (source and target)
221
-
- patch generation across repos
222
-
- permissions: who can create pulls?
223
-
224
-
**recommendation**:
225
-
- v1: same-repo pulls only (`source.repo` optional, defaults to target)
226
-
- v2: add cross-repo support once we understand patterns
227
-
228
-
### 5. state management
229
-
**question**: do we track state separately or in-record?
230
-
231
-
**issues**: currently don't use `sh.tangled.repo.issue.status`
232
-
**pulls**: lexicon has `sh.tangled.repo.pull.status`
233
-
234
-
**pattern from labels**:
235
-
- labels are separate ops
236
-
- ops can be applied/reverted
237
-
- current state = sum of all ops
238
-
239
-
**state is simpler**:
240
-
- open โ closed โ merged (mostly linear)
241
-
- probably doesn't need full ops history
242
-
- could just update a field on pull record OR use status records
243
-
244
-
**recommendation**:
245
-
- use `sh.tangled.repo.pull.status` records (follow lexicon)
246
-
- easier to track state changes over time
247
-
- consistent with label pattern
248
-
- can query "all status changes for pull X"
249
-
250
-
**draft state**: gh treats draft as a separate boolean, but tangled's lexicon shows:
251
-
- `sh.tangled.repo.pull.status.open`
252
-
- `sh.tangled.repo.pull.status.closed`
253
-
- `sh.tangled.repo.pull.status.merged`
254
-
255
-
**solution**: we could:
256
-
- (a) add custom `draft` field to pull record (not in lexicon, might break)
257
-
- (b) treat draft as metadata in status record
258
-
- (c) add `sh.tangled.repo.pull.status.draft` as custom state
259
-
260
-
**recommendation**: (c) - add draft as a custom state value
261
-
- fits existing pattern
262
-
- `draft` โ `open` transition when ready
263
-
- backwards compatible (lexicon uses `knownValues` not enum)
264
-
265
-
### 6. interconnections
266
-
**entities that reference each other**:
267
-
- issues mention pulls: "closes #42"
268
-
- pulls mention issues: "fixes #123"
269
-
- both have labels, comments
270
-
- pulls reference commits/branches
271
-
272
-
**question**: how to expose these relationships?
273
-
274
-
**options**:
275
-
a) **inline**: include referenced entities in responses
276
-
- `list_repo_pulls` returns issues it closes
277
-
- bloats responses
278
-
279
-
b) **separate queries**: tools to fetch relationships
280
-
- `get_pull_related_issues(pull_id)`
281
-
- `get_issue_related_pulls(issue_id)`
282
-
- more API calls but cleaner
283
-
284
-
c) **resources**: expose as graphs
285
-
- `tangled://repo/{owner}/{repo}/graph` โ all entities + edges
286
-
- LLM can traverse
287
-
- ambitious
288
-
289
-
**recommendation**: start with (b), consider (c) as resources mature
290
-
291
-
### 7. comments
292
-
**question**: support issue/pull comments now or later?
293
-
294
-
**considerations**:
295
-
- both have `comment` collections
296
-
- valuable for context (PR review discussions)
297
-
- adds complexity (list, create, update, delete comments)
298
-
299
-
**recommendation**:
300
-
- v1: skip comments, focus on core pull CRUD
301
-
- v2: add comments once pull basics work
302
-
- keeps initial scope manageable
303
-
304
-
## tool signatures (gh-style)
305
-
306
-
### create_repo_pull
307
-
```python
308
-
def create_repo_pull(
309
-
repo: str, # gh: -R, --repo
310
-
title: str, # gh: -t, --title
311
-
body: str | None = None, # gh: -b, --body
312
-
base: str = "main", # gh: -B, --base (target branch)
313
-
head: str, # gh: -H, --head (source branch)
314
-
source_sha: str, # commit hash (required, no gh equiv - we need it for atproto)
315
-
patch: str, # git diff (required, no gh equiv - atproto schema)
316
-
labels: list[str] | None = None, # gh: -l, --label
317
-
draft: bool = False, # gh: -d, --draft
318
-
) -> CreatePullResult:
319
-
"""
320
-
create a pull request
321
-
322
-
similar to: gh pr create --title "..." --body "..." --base main --head feature --label bug --draft
323
-
"""
324
-
```
325
-
326
-
### update_repo_pull
327
-
```python
328
-
def update_repo_pull(
329
-
repo: str, # gh: -R, --repo
330
-
pull_id: int, # gh: <number>
331
-
title: str | None = None, # gh: -t, --title
332
-
body: str | None = None, # gh: -b, --body
333
-
base: str | None = None, # gh: -B, --base (change target branch)
334
-
add_labels: list[str] | None = None, # gh: --add-label
335
-
remove_labels: list[str] | None = None, # gh: --remove-label
336
-
) -> UpdatePullResult:
337
-
"""
338
-
edit a pull request
339
-
340
-
similar to: gh pr edit 42 --title "..." --add-label bug --remove-label wontfix
341
-
"""
342
-
```
343
-
344
-
### list_repo_pulls
345
-
```python
346
-
def list_repo_pulls(
347
-
repo: str, # gh: -R, --repo
348
-
state: str = "open", # gh: -s, --state {open|closed|merged|all}
349
-
labels: list[str] | None = None, # gh: -l, --label
350
-
base: str | None = None, # gh: -B, --base (filter by target branch)
351
-
head: str | None = None, # gh: -H, --head (filter by source branch)
352
-
draft: bool | None = None, # gh: -d, --draft (filter by draft state)
353
-
limit: int = 30, # gh: -L, --limit (default 30)
354
-
cursor: str | None = None, # pagination
355
-
) -> ListPullsResult:
356
-
"""
357
-
list pull requests
358
-
359
-
similar to: gh pr list --state open --label bug --limit 50
360
-
"""
361
-
```
362
-
363
-
### get_repo_pull
364
-
```python
365
-
def get_repo_pull(
366
-
repo: str, # gh: -R, --repo
367
-
pull_id: int, # gh: <number>
368
-
) -> PullInfo:
369
-
"""
370
-
view a pull request
371
-
372
-
similar to: gh pr view 42
373
-
"""
374
-
```
375
-
376
-
### close_repo_pull
377
-
```python
378
-
def close_repo_pull(
379
-
repo: str, # gh: -R, --repo
380
-
pull_id: int, # gh: <number>
381
-
) -> UpdatePullResult:
382
-
"""
383
-
close a pull request (sets status to closed)
384
-
385
-
similar to: gh pr close 42
386
-
"""
387
-
```
388
-
389
-
### reopen_repo_pull
390
-
```python
391
-
def reopen_repo_pull(
392
-
repo: str, # gh: -R, --repo
393
-
pull_id: int, # gh: <number>
394
-
) -> UpdatePullResult:
395
-
"""
396
-
reopen a closed pull request (sets status back to open)
397
-
398
-
similar to: gh pr reopen 42
399
-
"""
400
-
```
401
-
402
-
### merge_repo_pull
403
-
```python
404
-
def merge_repo_pull(
405
-
repo: str, # gh: -R, --repo
406
-
pull_id: int, # gh: <number>
407
-
) -> UpdatePullResult:
408
-
"""
409
-
mark a pull request as merged (sets status to merged)
410
-
411
-
note: this is a logical merge (status change), not an actual git merge
412
-
similar to: gh pr merge 42 (but without the git operation)
413
-
"""
414
-
```
415
-
416
-
## proposed roadmap
417
-
418
-
### phase 1: core pr operations (gh parity)
419
-
420
-
**7 tools matching gh pr commands**:
421
-
422
-
1. **create_repo_pull** (matches `gh pr create`)
423
-
- parameters: repo, title, body, base, head, source_sha, patch, labels, draft
424
-
- generates pullId (like issueId)
425
-
- creates `sh.tangled.repo.pull` record
426
-
- creates initial `sh.tangled.repo.pull.status` record (open or draft)
427
-
- applies labels if provided
428
-
- returns CreatePullResult with pullId and URL
429
-
430
-
2. **update_repo_pull** (matches `gh pr edit`)
431
-
- parameters: repo, pull_id, title, body, base, add_labels, remove_labels
432
-
- updates pull record via putRecord + swap
433
-
- handles incremental label changes (add/remove pattern)
434
-
- returns UpdatePullResult with pullId and URL
435
-
436
-
3. **list_repo_pulls** (matches `gh pr list`)
437
-
- parameters: repo, state, labels, base, head, draft, limit, cursor
438
-
- queries `sh.tangled.repo.pull` + correlates with status
439
-
- filters by state (open/closed/merged/all), labels, branches, draft
440
-
- default limit 30 (matching gh)
441
-
- includes labels for each pull
442
-
- returns ListPullsResult with pulls and cursor
443
-
444
-
4. **get_repo_pull** (matches `gh pr view`)
445
-
- parameters: repo, pull_id
446
-
- fetches single pull with full details (target, source, patch, status, labels)
447
-
- returns PullInfo model
448
-
449
-
5. **close_repo_pull** (matches `gh pr close`)
450
-
- parameters: repo, pull_id
451
-
- creates new status record with "closed"
452
-
- returns UpdatePullResult
453
-
454
-
6. **reopen_repo_pull** (matches `gh pr reopen`)
455
-
- parameters: repo, pull_id
456
-
- creates new status record with "open"
457
-
- only works if current status is "closed"
458
-
- returns UpdatePullResult
459
-
460
-
7. **merge_repo_pull** (matches `gh pr merge` logically)
461
-
- parameters: repo, pull_id
462
-
- creates new status record with "merged"
463
-
- note: logical merge only (no git operation)
464
-
- returns UpdatePullResult
465
-
466
-
**types** (following issue pattern):
467
-
- `PullInfo` model (uri, cid, pullId, title, body, target, source, createdAt, labels, status)
468
-
- `CreatePullResult` (repo, pull_id, url)
469
-
- `UpdatePullResult` (repo, pull_id, url)
470
-
- `ListPullsResult` (pulls, cursor)
471
-
472
-
**new module**:
473
-
- `src/tangled_mcp/_tangled/_pulls.py` (parallel to _issues.py)
474
-
- `src/tangled_mcp/types/_pulls.py` (parallel to _issues.py)
475
-
476
-
### phase 2: labels + better state
477
-
**enhancements**:
478
-
- pulls support labels (reuse `_validate_labels`, `_apply_labels`)
479
-
- `list_pull_status_history(repo, pull_id)` โ all status changes
480
-
- pull status in URL: `tangled.org/@owner/repo/pulls/42` shows status
481
-
482
-
### phase 3: resources
483
-
**add resources**:
484
-
- `tangled://repo/{owner}/{repo}/pulls/open` โ current open PRs
485
-
- `tangled://repo/{owner}/{repo}/pulls/{pull_id}` โ specific pull context
486
-
- helps LLM understand "what PRs exist for this repo?"
487
-
488
-
### phase 4: cross-repo + comments
489
-
**ambitious**:
490
-
- cross-repo pull support (forks)
491
-
- comment creation/listing
492
-
- patch generation via knot XRPC
493
-
494
-
## open questions
495
-
496
-
### 1. draft state implementation
497
-
**question**: should we use `sh.tangled.repo.pull.status.draft` as a custom state?
498
-
- **option a**: separate draft field on pull record (not in lexicon)
499
-
- **option b**: draft as custom status value `sh.tangled.repo.pull.status.draft`
500
-
- **recommended**: option b - fits lexicon pattern, `draft` โ `open` transition
501
-
502
-
### 2. patch format (v1 scope)
503
-
**question**: how do users provide the patch field?
504
-
- **v1**: user provides git diff string (simple, no server dependency)
505
-
- **v2**: could add knot XRPC to generate from branch refs
506
-
- **gh equivalent**: `gh pr create` auto-generates from local branch
507
-
508
-
### 3. ready-for-review transition
509
-
**question**: `gh pr ready` marks draft PR as ready - how to support?
510
-
- **option a**: separate `ready_repo_pull(repo, pull_id)` tool
511
-
- **option b**: use `update_repo_pull` with status change
512
-
- **recommended**: option a - matches gh command structure
513
-
514
-
### 4. URL format confirmation
515
-
**assumption**: `https://tangled.org/@owner/repo/pulls/42`
516
-
- matches issue pattern
517
-
- need to confirm with tangled.org routing
518
-
519
-
### 5. merge semantics
520
-
**clarification**: `merge_repo_pull` is logical only (status change)
521
-
- does NOT perform git merge operation
522
-
- tangled may handle actual merge separately
523
-
- gh does both (status + git operation)
524
-
525
-
## implementation notes
526
-
527
-
### pattern consistency with issues
528
-
```python
529
-
# issues pattern (working)
530
-
def create_issue(repo_id, title, body, labels):
531
-
# 1. resolve repo to AT-URI
532
-
# 2. find next issueId
533
-
# 3. create record with tid rkey
534
-
# 4. apply labels if provided
535
-
# 5. return {uri, cid, issueId}
536
-
537
-
# pulls pattern (proposed)
538
-
def create_pull(repo_id, target_branch, source_branch, source_sha, title, body, patch, labels):
539
-
# 1. resolve repo to AT-URI
540
-
# 2. find next pullId
541
-
# 3. create pull record with {target, source, patch, ...}
542
-
# 4. create status record (open)
543
-
# 5. apply labels if provided
544
-
# 6. return {uri, cid, pullId}
545
-
```
546
-
547
-
### state tracking pattern
548
-
```python
549
-
def _get_current_pull_status(client, pull_uri):
550
-
"""get latest status for a pull by querying status records"""
551
-
status_records = client.com.atproto.repo.list_records(
552
-
collection="sh.tangled.repo.pull.status",
553
-
repo=client.me.did,
554
-
)
555
-
556
-
# find all status records for this pull
557
-
pull_statuses = [
558
-
r for r in status_records.records
559
-
if getattr(r.value, "pull", None) == pull_uri
560
-
]
561
-
562
-
# return most recent (last created)
563
-
if pull_statuses:
564
-
latest = max(pull_statuses, key=lambda r: getattr(r.value, "createdAt", ""))
565
-
return getattr(latest.value, "status", "open")
566
-
567
-
return "open" # default
568
-
```
569
-
570
-
### label reuse
571
-
```python
572
-
# labels work the same for issues and pulls
573
-
# _apply_labels takes a subject_uri (issue or pull)
574
-
_apply_labels(client, pull_uri, labels, repo_labels, current_labels)
575
-
576
-
# so label ops are generic:
577
-
{
578
-
"$type": "sh.tangled.label.op",
579
-
"subject": "at://did/sh.tangled.repo.pull/abc123", # or issue URI
580
-
"add": [...],
581
-
"delete": [...]
582
-
}
583
-
```
584
-
585
-
## summary: gh pr โ tangled MCP mapping
586
-
587
-
### v1 feature matrix (phase 1)
588
-
589
-
| feature | gh command | tangled tool | parameters | notes |
590
-
|---------|-----------|--------------|------------|-------|
591
-
| create PR | `gh pr create` | `create_repo_pull` | title, body, base, head, source_sha, patch, labels, draft | โ
full parity except auto-patch |
592
-
| edit PR | `gh pr edit` | `update_repo_pull` | title, body, base, add_labels, remove_labels | โ
full parity |
593
-
| list PRs | `gh pr list` | `list_repo_pulls` | state, labels, base, head, draft, limit | โ
full parity (default limit 30) |
594
-
| view PR | `gh pr view` | `get_repo_pull` | pull_id | โ
full parity |
595
-
| close PR | `gh pr close` | `close_repo_pull` | pull_id | โ
status change only |
596
-
| reopen PR | `gh pr reopen` | `reopen_repo_pull` | pull_id | โ
status change only |
597
-
| merge PR | `gh pr merge` | `merge_repo_pull` | pull_id | โ ๏ธ logical only (no git merge) |
598
-
| mark ready | `gh pr ready` | `ready_repo_pull` | pull_id | โ
draft โ open transition |
599
-
600
-
### not in v1 (future)
601
-
- `gh pr comment` โ need `sh.tangled.repo.pull.comment` support
602
-
- `gh pr diff` โ could show `patch` field
603
-
- `gh pr review` โ need review records
604
-
- `gh pr checks` โ no CI concept in tangled
605
-
606
-
### key differences from gh
607
-
1. **patch field**: users provide git diff string (gh auto-generates)
608
-
2. **merge**: logical status change only (gh performs git merge)
609
-
3. **source_sha**: required parameter (gh infers from branch)
610
-
4. **pullId**: explicit numeric ID (gh uses number or branch name)
611
-
612
-
### preserved gh patterns
613
-
- parameter names match gh flags where possible
614
-
- state values: `open`, `closed`, `merged`, `draft`
615
-
- filtering: state, labels, base, head, draft
616
-
- default limit: 30 (matching gh pr list)
617
-
- incremental label updates: add/remove pattern
+2
-2
server.json
+2
-2
server.json
···
3
3
"name": "io.github.zzstoatzz/tangled-mcp",
4
4
"title": "Tangled MCP",
5
5
"description": "MCP server for Tangled git platform. Manage repositories, branches, and issues on tangled.org.",
6
-
"version": "0.0.10",
6
+
"version": "0.0.9",
7
7
"packages": [
8
8
{
9
9
"registryType": "pypi",
10
10
"identifier": "tangled-mcp",
11
-
"version": "0.0.10",
11
+
"version": "0.0.9",
12
12
"transport": {
13
13
"type": "stdio"
14
14
}
+1
-8
src/tangled_mcp/_tangled/_issues.py
+1
-8
src/tangled_mcp/_tangled/_issues.py
···
79
79
80
80
next_issue_id = max_issue_id + 1
81
81
82
-
# validate labels BEFORE creating the issue to prevent orphaned issues
83
-
if labels:
84
-
_validate_labels(labels, repo_labels)
85
-
86
82
# generate timestamp ID for rkey
87
83
tid = int(datetime.now(timezone.utc).timestamp() * 1000000)
88
84
rkey = str(tid)
···
412
408
# build map of issue_uri -> current label URIs
413
409
issue_labels_map: dict[str, set[str]] = {uri: set() for uri in issue_uris}
414
410
for op_record in label_ops.records:
415
-
if (
416
-
hasattr(op_record.value, "subject")
417
-
and op_record.value.subject in issue_labels_map
418
-
):
411
+
if hasattr(op_record.value, "subject") and op_record.value.subject in issue_labels_map:
419
412
subject_uri = op_record.value.subject
420
413
if hasattr(op_record.value, "add"):
421
414
for operand in op_record.value.add:
+8
-4
src/tangled_mcp/server.py
+8
-4
src/tangled_mcp/server.py
···
53
53
limit: Annotated[
54
54
int, Field(ge=1, le=100, description="maximum number of branches to return")
55
55
] = 50,
56
+
cursor: Annotated[str | None, Field(description="pagination cursor")] = None,
56
57
) -> ListBranchesResult:
57
58
"""list branches for a repository
58
59
59
60
Args:
60
61
repo: repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')
61
62
limit: maximum number of branches to return (1-100)
63
+
cursor: optional pagination cursor
62
64
63
65
Returns:
64
-
list of branches
66
+
list of branches with optional cursor for pagination
65
67
"""
66
68
# resolve owner/repo to (knot, did/repo)
67
69
knot, repo_id = _tangled.resolve_repo_identifier(repo)
68
-
response = _tangled.list_branches(knot, repo_id, limit, cursor=None)
70
+
response = _tangled.list_branches(knot, repo_id, limit, cursor)
69
71
70
72
return ListBranchesResult.from_api_response(response)
71
73
···
186
188
limit: Annotated[
187
189
int, Field(ge=1, le=100, description="maximum number of issues to return")
188
190
] = 20,
191
+
cursor: Annotated[str | None, Field(description="pagination cursor")] = None,
189
192
) -> ListIssuesResult:
190
193
"""list issues for a repository
191
194
192
195
Args:
193
196
repo: repository identifier in 'owner/repo' format
194
197
limit: maximum number of issues to return (1-100)
198
+
cursor: optional pagination cursor
195
199
196
200
Returns:
197
-
ListIssuesResult with list of issues
201
+
ListIssuesResult with list of issues and optional cursor
198
202
"""
199
203
# resolve owner/repo to (knot, did/repo)
200
204
_, repo_id = _tangled.resolve_repo_identifier(repo)
201
205
# list_repo_issues doesn't need knot (queries atproto records, not XRPC)
202
-
response = _tangled.list_repo_issues(repo_id, limit, cursor=None)
206
+
response = _tangled.list_repo_issues(repo_id, limit, cursor)
203
207
204
208
return ListIssuesResult.from_api_response(response)
205
209
+4
-2
src/tangled_mcp/types/_branches.py
+4
-2
src/tangled_mcp/types/_branches.py
···
16
16
"""result of listing branches"""
17
17
18
18
branches: list[BranchInfo]
19
+
cursor: str | None = None
19
20
20
21
@classmethod
21
22
def from_api_response(cls, response: dict[str, Any]) -> "ListBranchesResult":
···
27
28
"branches": [
28
29
{"reference": {"name": "main", "hash": "abc123"}},
29
30
...
30
-
]
31
+
],
32
+
"cursor": "optional_cursor"
31
33
}
32
34
33
35
Returns:
···
44
46
)
45
47
)
46
48
47
-
return cls(branches=branches)
49
+
return cls(branches=branches, cursor=response.get("cursor"))
+4
-2
src/tangled_mcp/types/_issues.py
+4
-2
src/tangled_mcp/types/_issues.py
···
61
61
"""result of listing issues"""
62
62
63
63
issues: list[IssueInfo]
64
+
cursor: str | None = None
64
65
65
66
@classmethod
66
67
def from_api_response(cls, response: dict[str, Any]) -> "ListIssuesResult":
···
79
80
"createdAt": "..."
80
81
},
81
82
...
82
-
]
83
+
],
84
+
"cursor": "optional_cursor"
83
85
}
84
86
85
87
Returns:
86
88
ListIssuesResult with parsed issues
87
89
"""
88
90
issues = [IssueInfo(**issue_data) for issue_data in response.get("issues", [])]
89
-
return cls(issues=issues)
91
+
return cls(issues=issues, cursor=response.get("cursor"))
+2
tests/test_server.py
+2
tests/test_server.py
+12
tests/test_types.py
+12
tests/test_types.py
···
58
58
{"reference": {"name": "main", "hash": "abc123"}},
59
59
{"reference": {"name": "dev", "hash": "def456"}},
60
60
],
61
+
"cursor": "next_page",
61
62
}
62
63
63
64
result = ListBranchesResult.from_api_response(response)
···
67
68
assert result.branches[0].sha == "abc123"
68
69
assert result.branches[1].name == "dev"
69
70
assert result.branches[1].sha == "def456"
71
+
assert result.cursor == "next_page"
72
+
73
+
def test_handles_missing_cursor(self):
74
+
"""cursor is optional in API response"""
75
+
response = {"branches": [{"reference": {"name": "main", "hash": "abc123"}}]}
76
+
77
+
result = ListBranchesResult.from_api_response(response)
78
+
79
+
assert len(result.branches) == 1
80
+
assert result.cursor is None
70
81
71
82
def test_handles_empty_branches(self):
72
83
"""handles empty branches list"""
···
75
86
result = ListBranchesResult.from_api_response(response)
76
87
77
88
assert result.branches == []
89
+
assert result.cursor is None