-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.
+1
-1
.github/workflows/publish-mcp.yml
+1
-1
.github/workflows/publish-mcp.yml
···
36
36
37
37
- name: install mcp publisher
38
38
run: |
39
-
curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.3.3/mcp-publisher_linux_amd64.tar.gz" | tar xz
39
+
curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.2.3/mcp-publisher_1.2.3_linux_amd64.tar.gz" | tar xz
40
40
sudo mv mcp-publisher /usr/local/bin/
41
41
42
42
- name: login to mcp registry (github oidc)
-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
+1
-4
README.md
+1
-4
README.md
···
102
102
- `create_repo_issue(repo, title, body, labels)` - create an issue with optional labels
103
103
- `update_repo_issue(repo, issue_id, title, body, labels)` - update an issue's title, body, and/or labels
104
104
- `delete_repo_issue(repo, issue_id)` - delete an issue
105
-
- `list_repo_issues(repo, limit)` - list issues for a repository
105
+
- `list_repo_issues(repo, limit, cursor)` - list issues for a repository
106
106
- `list_repo_labels(repo)` - list available labels for a repository
107
-
108
-
### pull requests
109
-
- `list_repo_pulls(repo, limit)` - list PRs targeting a repository (only shows PRs you created)
110
107
111
108
## development
112
109
-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
+3
-3
server.json
+3
-3
server.json
···
1
1
{
2
-
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
2
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.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.14",
6
+
"version": "0.0.9",
7
7
"packages": [
8
8
{
9
9
"registryType": "pypi",
10
10
"identifier": "tangled-mcp",
11
-
"version": "0.0.14",
11
+
"version": "0.0.9",
12
12
"transport": {
13
13
"type": "stdio"
14
14
}
-2
src/tangled_mcp/_tangled/__init__.py
-2
src/tangled_mcp/_tangled/__init__.py
···
13
13
list_repo_labels,
14
14
update_issue,
15
15
)
16
-
from tangled_mcp._tangled._pulls import list_repo_pulls
17
16
18
17
__all__ = [
19
18
"_get_authenticated_client",
···
24
23
"delete_issue",
25
24
"list_repo_issues",
26
25
"list_repo_labels",
27
-
"list_repo_pulls",
28
26
"resolve_repo_identifier",
29
27
]
+7
-25
src/tangled_mcp/_tangled/_client.py
+7
-25
src/tangled_mcp/_tangled/_client.py
···
8
8
from tangled_mcp.settings import TANGLED_DID, settings
9
9
10
10
11
-
def _extract_error_message(e: Exception) -> str:
12
-
"""extract a clean, concise error message from an exception"""
13
-
# handle atproto RequestErrorBase (BadRequestError, UnauthorizedError, etc.)
14
-
# structure: e.response.content.message
15
-
if hasattr(e, "response") and e.response:
16
-
content = getattr(e.response, "content", None)
17
-
if content and hasattr(content, "message"):
18
-
return content.message
19
-
# handle httpx errors
20
-
if hasattr(e, "response") and hasattr(e.response, "text"):
21
-
return e.response.text[:200]
22
-
# fallback to string but limit length
23
-
msg = str(e)
24
-
if len(msg) > 200:
25
-
return msg[:200] + "..."
26
-
return msg
27
-
28
-
29
11
def resolve_repo_identifier(owner_slash_repo: str) -> tuple[str, str]:
30
12
"""resolve owner/repo format to (knot, did/repo) for tangled XRPC
31
13
···
62
44
)
63
45
owner_did = response.did
64
46
except Exception as e:
65
-
# extract clean error message
66
-
msg = _extract_error_message(e)
67
-
raise ValueError(f"failed to resolve handle '{owner}': {msg}") from e
47
+
raise ValueError(f"failed to resolve handle '{owner}': {e}") from e
68
48
69
49
# query owner's repo collection to find repo and get knot
70
50
try:
···
76
56
)
77
57
)
78
58
except Exception as e:
79
-
msg = _extract_error_message(e)
80
-
raise ValueError(f"failed to list repos for '{owner}': {msg}") from e
59
+
raise ValueError(f"failed to list repos for '{owner}': {e}") from e
81
60
82
61
# find repo with matching name and extract knot
83
62
for record in records.records:
···
107
86
try:
108
87
client.login(settings.tangled_handle, settings.tangled_password)
109
88
except Exception as e:
110
-
msg = _extract_error_message(e)
111
-
raise RuntimeError(f"auth failed for '{settings.tangled_handle}': {msg}") from e
89
+
raise RuntimeError(
90
+
f"failed to authenticate with handle '{settings.tangled_handle}'. "
91
+
f"verify TANGLED_HANDLE and TANGLED_PASSWORD are correct. "
92
+
f"error: {e}"
93
+
) from e
112
94
113
95
return client
114
96
+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:
-92
src/tangled_mcp/_tangled/_pulls.py
-92
src/tangled_mcp/_tangled/_pulls.py
···
1
-
"""pull request operations for tangled"""
2
-
3
-
from typing import Any
4
-
5
-
from atproto import models
6
-
7
-
from tangled_mcp._tangled._client import _get_authenticated_client
8
-
9
-
10
-
def list_repo_pulls(repo_id: str, limit: int = 50) -> dict[str, Any]:
11
-
"""list pull requests created by the authenticated user for a repository
12
-
13
-
note: this only returns PRs that the authenticated user created.
14
-
tangled stores PRs in the creator's repo, so we can only see our own PRs.
15
-
16
-
Args:
17
-
repo_id: repository identifier in "did/repo" format
18
-
limit: maximum number of pulls to return
19
-
20
-
Returns:
21
-
dict containing pulls list
22
-
"""
23
-
client = _get_authenticated_client()
24
-
25
-
if not client.me:
26
-
raise RuntimeError("client not authenticated")
27
-
28
-
# parse repo_id to get owner_did and repo_name
29
-
if "/" not in repo_id:
30
-
raise ValueError(f"invalid repo_id format: {repo_id}")
31
-
32
-
owner_did, repo_name = repo_id.split("/", 1)
33
-
34
-
# get the repo AT-URI by querying the repo collection
35
-
records = client.com.atproto.repo.list_records(
36
-
models.ComAtprotoRepoListRecords.Params(
37
-
repo=owner_did,
38
-
collection="sh.tangled.repo",
39
-
limit=100,
40
-
)
41
-
)
42
-
43
-
repo_at_uri = None
44
-
for record in records.records:
45
-
if (name := getattr(record.value, "name", None)) is not None and name == repo_name:
46
-
repo_at_uri = record.uri
47
-
break
48
-
49
-
if not repo_at_uri:
50
-
raise ValueError(f"repo not found: {repo_id}")
51
-
52
-
# list pull records from the authenticated user's collection
53
-
response = client.com.atproto.repo.list_records(
54
-
models.ComAtprotoRepoListRecords.Params(
55
-
repo=client.me.did,
56
-
collection="sh.tangled.repo.pull",
57
-
limit=limit,
58
-
)
59
-
)
60
-
61
-
# filter pulls by target repo and convert to dict format
62
-
pulls = []
63
-
for record in response.records:
64
-
value = record.value
65
-
target = getattr(value, "target", None)
66
-
if not target:
67
-
continue
68
-
69
-
target_repo = getattr(target, "repo", None)
70
-
if target_repo != repo_at_uri:
71
-
continue
72
-
73
-
source = getattr(value, "source", {})
74
-
pulls.append(
75
-
{
76
-
"uri": record.uri,
77
-
"cid": record.cid,
78
-
"title": getattr(value, "title", ""),
79
-
"source": {
80
-
"sha": getattr(source, "sha", ""),
81
-
"branch": getattr(source, "branch", ""),
82
-
"repo": getattr(source, "repo", None),
83
-
},
84
-
"target": {
85
-
"repo": target_repo,
86
-
"branch": getattr(target, "branch", ""),
87
-
},
88
-
"createdAt": getattr(value, "createdAt", ""),
89
-
}
90
-
)
91
-
92
-
return {"pulls": pulls}
+11
-40
src/tangled_mcp/server.py
+11
-40
src/tangled_mcp/server.py
···
11
11
DeleteIssueResult,
12
12
ListBranchesResult,
13
13
ListIssuesResult,
14
-
ListPullsResult,
15
14
UpdateIssueResult,
16
15
)
17
16
···
54
53
limit: Annotated[
55
54
int, Field(ge=1, le=100, description="maximum number of branches to return")
56
55
] = 50,
56
+
cursor: Annotated[str | None, Field(description="pagination cursor")] = None,
57
57
) -> ListBranchesResult:
58
58
"""list branches for a repository
59
59
60
60
Args:
61
61
repo: repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')
62
62
limit: maximum number of branches to return (1-100)
63
+
cursor: optional pagination cursor
63
64
64
65
Returns:
65
-
list of branches
66
+
list of branches with optional cursor for pagination
66
67
"""
67
68
# resolve owner/repo to (knot, did/repo)
68
69
knot, repo_id = _tangled.resolve_repo_identifier(repo)
69
-
response = _tangled.list_branches(knot, repo_id, limit, cursor=None)
70
+
response = _tangled.list_branches(knot, repo_id, limit, cursor)
70
71
71
72
return ListBranchesResult.from_api_response(response)
72
73
···
105
106
# create_issue doesn't need knot (uses atproto putRecord, not XRPC)
106
107
response = _tangled.create_issue(repo_id, title, body, labels)
107
108
108
-
return CreateIssueResult(repo=repo, id=response["issueId"])
109
+
return CreateIssueResult(repo=repo, issue_id=response["issueId"])
109
110
110
111
111
112
@tangled_mcp.tool
···
144
145
# update_issue doesn't need knot (uses atproto putRecord, not XRPC)
145
146
_tangled.update_issue(repo_id, issue_id, title, body, labels)
146
147
147
-
return UpdateIssueResult(repo=repo, id=issue_id)
148
+
return UpdateIssueResult(repo=repo, issue_id=issue_id)
148
149
149
150
150
151
@tangled_mcp.tool
···
173
174
# delete_issue doesn't need knot (uses atproto deleteRecord, not XRPC)
174
175
_tangled.delete_issue(repo_id, issue_id)
175
176
176
-
return DeleteIssueResult(id=issue_id)
177
+
return DeleteIssueResult(issue_id=issue_id)
177
178
178
179
179
180
@tangled_mcp.tool
···
187
188
limit: Annotated[
188
189
int, Field(ge=1, le=100, description="maximum number of issues to return")
189
190
] = 20,
191
+
cursor: Annotated[str | None, Field(description="pagination cursor")] = None,
190
192
) -> ListIssuesResult:
191
193
"""list issues for a repository
192
194
193
195
Args:
194
196
repo: repository identifier in 'owner/repo' format
195
197
limit: maximum number of issues to return (1-100)
198
+
cursor: optional pagination cursor
196
199
197
200
Returns:
198
-
ListIssuesResult with list of issues
201
+
ListIssuesResult with list of issues and optional cursor
199
202
"""
200
203
# resolve owner/repo to (knot, did/repo)
201
204
_, repo_id = _tangled.resolve_repo_identifier(repo)
202
205
# list_repo_issues doesn't need knot (queries atproto records, not XRPC)
203
-
response = _tangled.list_repo_issues(repo_id, limit, cursor=None)
206
+
response = _tangled.list_repo_issues(repo_id, limit, cursor)
204
207
205
208
return ListIssuesResult.from_api_response(response)
206
209
···
226
229
_, repo_id = _tangled.resolve_repo_identifier(repo)
227
230
# list_repo_labels doesn't need knot (queries atproto records, not XRPC)
228
231
return _tangled.list_repo_labels(repo_id)
229
-
230
-
231
-
@tangled_mcp.tool
232
-
def list_repo_pulls(
233
-
repo: Annotated[
234
-
str,
235
-
Field(
236
-
description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')"
237
-
),
238
-
],
239
-
limit: Annotated[
240
-
int, Field(ge=1, le=100, description="maximum number of pulls to return")
241
-
] = 20,
242
-
) -> ListPullsResult:
243
-
"""list pull requests created by the authenticated user for a repository
244
-
245
-
note: only returns PRs that the authenticated user created (tangled stores
246
-
PRs in the creator's repo, so we can only see our own PRs).
247
-
248
-
Args:
249
-
repo: repository identifier in 'owner/repo' format
250
-
limit: maximum number of pulls to return (1-100)
251
-
252
-
Returns:
253
-
ListPullsResult with list of pull requests
254
-
"""
255
-
# resolve owner/repo to (knot, did/repo)
256
-
_, repo_id = _tangled.resolve_repo_identifier(repo)
257
-
# list_repo_pulls doesn't need knot (queries atproto records, not XRPC)
258
-
response = _tangled.list_repo_pulls(repo_id, limit)
259
-
260
-
return ListPullsResult.from_api_response(response["pulls"])
-5
src/tangled_mcp/types/__init__.py
-5
src/tangled_mcp/types/__init__.py
···
9
9
ListIssuesResult,
10
10
UpdateIssueResult,
11
11
)
12
-
from tangled_mcp.types._pulls import ListPullsResult, PullInfo, PullSource, PullTarget
13
12
14
13
__all__ = [
15
14
"BranchInfo",
···
18
17
"IssueInfo",
19
18
"ListBranchesResult",
20
19
"ListIssuesResult",
21
-
"ListPullsResult",
22
-
"PullInfo",
23
-
"PullSource",
24
-
"PullTarget",
25
20
"RepoIdentifier",
26
21
"UpdateIssueResult",
27
22
]
+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"))
+13
-16
src/tangled_mcp/types/_issues.py
+13
-16
src/tangled_mcp/types/_issues.py
···
18
18
19
19
uri: str
20
20
cid: str
21
-
id: int = Field(alias="issueId")
21
+
issue_id: int = Field(alias="issueId")
22
22
title: str
23
23
body: str | None = None
24
24
created_at: str = Field(alias="createdAt")
···
28
28
class CreateIssueResult(BaseModel):
29
29
"""result of creating an issue"""
30
30
31
-
repo: RepoIdentifier = Field(exclude=True)
32
-
id: int
31
+
repo: RepoIdentifier
32
+
issue_id: int
33
33
34
34
@computed_field
35
35
@property
36
36
def url(self) -> str:
37
37
"""construct clickable tangled.org URL"""
38
-
return _tangled_issue_url(self.repo, self.id)
38
+
return _tangled_issue_url(self.repo, self.issue_id)
39
39
40
40
41
41
class UpdateIssueResult(BaseModel):
42
42
"""result of updating an issue"""
43
43
44
-
repo: RepoIdentifier = Field(exclude=True)
45
-
id: int
44
+
repo: RepoIdentifier
45
+
issue_id: int
46
46
47
47
@computed_field
48
48
@property
49
49
def url(self) -> str:
50
50
"""construct clickable tangled.org URL"""
51
-
return _tangled_issue_url(self.repo, self.id)
51
+
return _tangled_issue_url(self.repo, self.issue_id)
52
52
53
53
54
54
class DeleteIssueResult(BaseModel):
55
55
"""result of deleting an issue"""
56
56
57
-
id: int
57
+
issue_id: int
58
58
59
59
60
60
class ListIssuesResult(BaseModel):
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
-
issues = []
89
-
for issue_data in response.get("issues", []):
90
-
# skip malformed issues (e.g., missing issueId)
91
-
if issue_data.get("issueId") is None:
92
-
continue
93
-
issues.append(IssueInfo(**issue_data))
94
-
return cls(issues=issues)
90
+
issues = [IssueInfo(**issue_data) for issue_data in response.get("issues", [])]
91
+
return cls(issues=issues, cursor=response.get("cursor"))
-70
src/tangled_mcp/types/_pulls.py
-70
src/tangled_mcp/types/_pulls.py
···
1
-
"""pull request types"""
2
-
3
-
from typing import Any
4
-
5
-
from pydantic import BaseModel, Field
6
-
7
-
8
-
class PullSource(BaseModel):
9
-
"""source branch info for a pull request"""
10
-
11
-
sha: str
12
-
branch: str
13
-
repo: str | None = None # AT-URI of source repo (for cross-repo PRs)
14
-
15
-
16
-
class PullTarget(BaseModel):
17
-
"""target branch info for a pull request"""
18
-
19
-
repo: str # AT-URI of target repo
20
-
branch: str
21
-
22
-
23
-
class PullInfo(BaseModel):
24
-
"""pull request information"""
25
-
26
-
uri: str
27
-
cid: str
28
-
title: str
29
-
source: PullSource
30
-
target: PullTarget
31
-
created_at: str = Field(alias="createdAt")
32
-
33
-
34
-
class ListPullsResult(BaseModel):
35
-
"""result of listing pull requests"""
36
-
37
-
pulls: list[PullInfo]
38
-
39
-
@classmethod
40
-
def from_api_response(cls, pulls_data: list[dict[str, Any]]) -> "ListPullsResult":
41
-
"""construct from pre-filtered pull data
42
-
43
-
Args:
44
-
pulls_data: list of pull dicts already filtered by target repo
45
-
46
-
Returns:
47
-
ListPullsResult with parsed pulls
48
-
"""
49
-
pulls = []
50
-
for pull in pulls_data:
51
-
source = pull.get("source", {})
52
-
target = pull.get("target", {})
53
-
pulls.append(
54
-
PullInfo(
55
-
uri=pull["uri"],
56
-
cid=pull["cid"],
57
-
title=pull.get("title", ""),
58
-
source=PullSource(
59
-
sha=source.get("sha", ""),
60
-
branch=source.get("branch", ""),
61
-
repo=source.get("repo"),
62
-
),
63
-
target=PullTarget(
64
-
repo=target.get("repo", ""),
65
-
branch=target.get("branch", ""),
66
-
),
67
-
createdAt=pull.get("createdAt", ""),
68
-
)
69
-
)
70
-
return cls(pulls=pulls)
-43
tests/test_resolver.py
-43
tests/test_resolver.py
···
2
2
3
3
import pytest
4
4
5
-
from tangled_mcp._tangled._client import _extract_error_message
6
-
7
-
8
-
class TestExtractErrorMessage:
9
-
"""test error message extraction from various exception types"""
10
-
11
-
def test_extracts_from_atproto_response(self):
12
-
"""extracts message from atproto RequestErrorBase structure"""
13
-
14
-
class MockContent:
15
-
message = "handle must be a valid handle"
16
-
17
-
class MockResponse:
18
-
content = MockContent()
19
-
20
-
class MockException(Exception):
21
-
response = MockResponse()
22
-
23
-
msg = _extract_error_message(MockException())
24
-
assert msg == "handle must be a valid handle"
25
-
26
-
def test_truncates_long_messages(self):
27
-
"""truncates messages longer than 200 chars"""
28
-
long_msg = "x" * 300
29
-
30
-
class MockException(Exception):
31
-
pass
32
-
33
-
e = MockException(long_msg)
34
-
msg = _extract_error_message(e)
35
-
assert len(msg) == 203 # 200 + "..."
36
-
assert msg.endswith("...")
37
-
38
-
def test_handles_none_response(self):
39
-
"""handles exception with None response"""
40
-
41
-
class MockException(Exception):
42
-
response = None
43
-
44
-
e = MockException("fallback message")
45
-
msg = _extract_error_message(e)
46
-
assert msg == "fallback message"
47
-
48
5
49
6
class TestRepoIdentifierParsing:
50
7
"""test repository identifier format validation"""
+3
-2
tests/test_server.py
+3
-2
tests/test_server.py
···
22
22
async with Client(tangled_mcp) as client:
23
23
tools = await client.list_tools()
24
24
25
-
assert len(tools) == 7
25
+
assert len(tools) == 6
26
26
27
27
tool_names = {tool.name for tool in tools}
28
28
assert "list_repo_branches" in tool_names
···
31
31
assert "delete_repo_issue" in tool_names
32
32
assert "list_repo_issues" in tool_names
33
33
assert "list_repo_labels" in tool_names
34
-
assert "list_repo_pulls" in tool_names
35
34
36
35
async def test_list_repo_branches_tool_schema(self):
37
36
"""test list_repo_branches tool has correct schema"""
···
56
55
assert properties["limit"]["minimum"] == 1
57
56
assert properties["limit"]["maximum"] == 100
58
57
assert properties["limit"]["default"] == 50
58
+
59
+
assert "cursor" in properties
59
60
60
61
# required parameters
61
62
assert tool.inputSchema["required"] == ["repo"]
+18
-14
tests/test_types.py
+18
-14
tests/test_types.py
···
15
15
16
16
def test_strips_at_prefix(self):
17
17
"""@ prefix is stripped during validation"""
18
-
result = CreateIssueResult(repo="@owner/repo", id=1)
18
+
result = CreateIssueResult(repo="@owner/repo", issue_id=1)
19
19
assert result.repo == "owner/repo"
20
20
21
21
def test_accepts_without_at_prefix(self):
22
22
"""repo identifier without @ works"""
23
-
result = CreateIssueResult(repo="owner/repo", id=1)
23
+
result = CreateIssueResult(repo="owner/repo", issue_id=1)
24
24
assert result.repo == "owner/repo"
25
25
26
26
def test_rejects_invalid_format(self):
27
27
"""repo identifier without slash is rejected"""
28
28
with pytest.raises(ValidationError, match="invalid repo format"):
29
-
CreateIssueResult(repo="invalid", id=1)
29
+
CreateIssueResult(repo="invalid", issue_id=1)
30
30
31
31
32
32
class TestIssueResultURLs:
···
34
34
35
35
def test_create_issue_url(self):
36
36
"""create result generates correct tangled.org URL"""
37
-
result = CreateIssueResult(repo="owner/repo", id=42)
37
+
result = CreateIssueResult(repo="owner/repo", issue_id=42)
38
38
assert result.url == "https://tangled.org/@owner/repo/issues/42"
39
39
40
40
def test_update_issue_url(self):
41
41
"""update result generates correct tangled.org URL"""
42
-
result = UpdateIssueResult(repo="owner/repo", id=42)
42
+
result = UpdateIssueResult(repo="owner/repo", issue_id=42)
43
43
assert result.url == "https://tangled.org/@owner/repo/issues/42"
44
44
45
45
def test_url_handles_at_prefix_input(self):
46
46
"""URL is correct even when input has @ prefix"""
47
-
result = CreateIssueResult(repo="@owner/repo", id=42)
47
+
result = CreateIssueResult(repo="@owner/repo", issue_id=42)
48
48
assert result.url == "https://tangled.org/@owner/repo/issues/42"
49
49
50
-
def test_repo_excluded_from_serialization(self):
51
-
"""repo field is excluded from JSON output"""
52
-
result = CreateIssueResult(repo="owner/repo", id=42)
53
-
data = result.model_dump()
54
-
assert "repo" not in data
55
-
assert data["id"] == 42
56
-
assert "url" in data
57
-
58
50
59
51
class TestListBranchesFromAPIResponse:
60
52
"""test ListBranchesResult.from_api_response constructor"""
···
66
58
{"reference": {"name": "main", "hash": "abc123"}},
67
59
{"reference": {"name": "dev", "hash": "def456"}},
68
60
],
61
+
"cursor": "next_page",
69
62
}
70
63
71
64
result = ListBranchesResult.from_api_response(response)
···
75
68
assert result.branches[0].sha == "abc123"
76
69
assert result.branches[1].name == "dev"
77
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
78
81
79
82
def test_handles_empty_branches(self):
80
83
"""handles empty branches list"""
···
83
86
result = ListBranchesResult.from_api_response(response)
84
87
85
88
assert result.branches == []
89
+
assert result.cursor is None