lots a goodies #1

merged
opened by zzstoatzz.io targeting main from refactor/types-and-urls
  • refactor types.py into dir
  • grokability for LLMs
  • better typing in general
+137
NEXT_STEPS.md
···
+
# next steps
+
+
## critical fixes
+
+
### 1. label validation must fail loudly
+
+
**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.
+
+
**current behavior:**
+
```python
+
create_repo_issue(repo="owner/repo", labels=["demo", "nonexistent"])
+
# -> creates issue with NO labels, returns success
+
```
+
+
**what should happen:**
+
```python
+
create_repo_issue(repo="owner/repo", labels=["demo", "nonexistent"])
+
# -> raises ValueError:
+
# "invalid labels: ['demo', 'nonexistent']
+
# available labels for this repo: ['wontfix', 'duplicate', 'good-first-issue', ...]"
+
```
+
+
**fix locations:**
+
- `src/tangled_mcp/_tangled/_issues.py:_apply_labels()` - validate before applying
+
- add `validate_labels()` helper that checks against repo's subscribed labels
+
- fail fast with actionable error message listing available labels
+
+
### 2. list_repo_issues should include label information
+
+
**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.
+
+
**impact:** users can't see what labels an issue has without manually querying label ops or checking the UI.
+
+
**fix:**
+
- add `labels: list[str]` field to `IssueInfo` model
+
- in `list_repo_issues`, fetch label ops and correlate with issues
+
- return label names (not URIs) for better UX
+
+
### 3. fix pydantic field warning
+
+
**warning:**
+
```
+
UnsupportedFieldAttributeWarning: The 'default' attribute with value None was provided
+
to the `Field()` function, which has no effect in the context it was used.
+
```
+
+
**likely cause:** somewhere we're using `Field(default=None)` in an `Annotated` type or union context where it doesn't make sense.
+
+
**fix:** audit all `Field()` uses and remove invalid `default=None` declarations.
+
+
## enhancements
+
+
### 4. better error messages for repo resolution failures
+
+
when a repo doesn't exist or handle can't be resolved, give users clear next steps:
+
- is the repo name spelled correctly?
+
- does the repo exist on tangled.org?
+
- do you have access to it?
+
+
### 5. add label listing tool
+
+
users need to know what labels are available for a repo before they can use them.
+
+
**new tool:**
+
```python
+
list_repo_labels(repo: str) -> list[str]
+
# returns: ["wontfix", "duplicate", "good-first-issue", ...]
+
```
+
+
### 6. pagination cursor handling
+
+
currently returning raw cursor strings. consider:
+
- documenting cursor format
+
- providing helper for "has more pages" checking
+
- clear examples in docstrings
+
+
## completed improvements (this session)
+
+
### ✅ types architecture refactored
+
- moved from single `types.py` to `types/` directory
+
- separated concerns: `_common.py`, `_branches.py`, `_issues.py`
+
- public API in `__init__.py`
+
- parsing logic moved into types via `.from_api_response()` class methods
+
+
### ✅ proper validation with annotated types
+
- `RepoIdentifier = Annotated[str, AfterValidator(normalize_repo_identifier)]`
+
- strips `@` prefix automatically
+
- validates format before processing
+
+
### ✅ clickable URLs instead of AT Protocol internals
+
- issue operations return `https://tangled.org/@owner/repo/issues/N`
+
- removed useless `uri` and `cid` from user-facing responses
+
- URL generation encapsulated in types via `@computed_field`
+
+
### ✅ proper typing everywhere
+
- no more `dict[str, Any]` return types
+
- pydantic models for all results
+
- type safety throughout
+
+
### ✅ minimal test coverage
+
- 17 tests covering public contracts
+
- no implementation details tested
+
- validates key behaviors: URL generation, validation, parsing
+
+
### ✅ demo scripts
+
- full lifecycle demo
+
- URL format handling demo
+
- branch listing demo
+
- label manipulation demo (revealed silent failure issue)
+
+
### ✅ documentation improvements
+
- MCP client installation instructions in collapsible details
+
- clear usage examples for multiple clients
+
+
## technical debt
+
+
### remove unused types
+
- `RepoInfo`, `PullInfo`, `CreateRepoResult`, `GenericResult` - not used anywhere
+
- clean up or remove from public API
+
+
### consolidate URL generation logic
+
- `_tangled_issue_url()` helper was created to DRY the URL generation
+
- good pattern, consider extending to other URL types if needed
+
+
### consider lazy evaluation for expensive validations
+
- repo resolution happens on every tool call
+
- could cache repo metadata (knot, did) for duration of connection
+
- tradeoff: freshness vs performance
+
+
## priorities
+
+
1. **critical:** fix label validation (fails silently)
+
2. **high:** add labels to list_repo_issues output
+
3. **medium:** add list_repo_labels tool
+
4. **medium:** fix pydantic warning
+
5. **low:** better error messages
+
6. **low:** clean up unused types
-74
src/tangled_mcp/types.py
···
-
"""type definitions for tangled MCP server"""
-
-
from typing import Any
-
-
from pydantic import BaseModel, Field
-
-
-
class RepoInfo(BaseModel):
-
"""repository information"""
-
-
name: str
-
knot: str
-
description: str | None = None
-
created_at: str = Field(alias="createdAt")
-
-
-
class IssueInfo(BaseModel):
-
"""issue information"""
-
-
repo: str
-
title: str
-
body: str | None = None
-
created_at: str = Field(alias="createdAt")
-
-
-
class PullInfo(BaseModel):
-
"""pull request information"""
-
-
title: str
-
body: str | None = None
-
patch: str
-
target_repo: str
-
target_branch: str
-
source_branch: str | None = None
-
source_sha: str | None = None
-
created_at: str = Field(alias="createdAt")
-
-
-
class BranchInfo(BaseModel):
-
"""branch information"""
-
-
name: str
-
sha: str
-
-
-
class CreateIssueResult(BaseModel):
-
"""result of creating an issue"""
-
-
uri: str
-
success: bool = True
-
message: str = "issue created successfully"
-
-
-
class CreateRepoResult(BaseModel):
-
"""result of creating a repository"""
-
-
uri: str
-
success: bool = True
-
message: str = "repository created successfully"
-
-
-
class ListBranchesResult(BaseModel):
-
"""result of listing branches"""
-
-
branches: list[BranchInfo]
-
cursor: str | None = None
-
-
-
class GenericResult(BaseModel):
-
"""generic operation result"""
-
-
success: bool
-
message: str
-
data: dict[str, Any] | None = None
+22
src/tangled_mcp/types/__init__.py
···
+
"""public types API for tangled MCP server"""
+
+
from tangled_mcp.types._branches import BranchInfo, ListBranchesResult
+
from tangled_mcp.types._common import RepoIdentifier
+
from tangled_mcp.types._issues import (
+
CreateIssueResult,
+
DeleteIssueResult,
+
IssueInfo,
+
ListIssuesResult,
+
UpdateIssueResult,
+
)
+
+
__all__ = [
+
"BranchInfo",
+
"CreateIssueResult",
+
"DeleteIssueResult",
+
"IssueInfo",
+
"ListBranchesResult",
+
"ListIssuesResult",
+
"RepoIdentifier",
+
"UpdateIssueResult",
+
]
+49
src/tangled_mcp/types/_branches.py
···
+
"""branch-related types"""
+
+
from typing import Any
+
+
from pydantic import BaseModel
+
+
+
class BranchInfo(BaseModel):
+
"""branch information"""
+
+
name: str
+
sha: str
+
+
+
class ListBranchesResult(BaseModel):
+
"""result of listing branches"""
+
+
branches: list[BranchInfo]
+
cursor: str | None = None
+
+
@classmethod
+
def from_api_response(cls, response: dict[str, Any]) -> "ListBranchesResult":
+
"""construct from raw API response
+
+
Args:
+
response: raw response from tangled API with structure:
+
{
+
"branches": [
+
{"reference": {"name": "main", "hash": "abc123"}},
+
...
+
],
+
"cursor": "optional_cursor"
+
}
+
+
Returns:
+
ListBranchesResult with parsed branches
+
"""
+
branches = []
+
if "branches" in response:
+
for branch_data in response["branches"]:
+
ref = branch_data.get("reference", {})
+
branches.append(
+
BranchInfo(
+
name=ref.get("name", ""),
+
sha=ref.get("hash", ""),
+
)
+
)
+
+
return cls(branches=branches, cursor=response.get("cursor"))
+18
src/tangled_mcp/types/_common.py
···
+
"""shared types and validators"""
+
+
from typing import Annotated
+
+
from pydantic import AfterValidator
+
+
+
def normalize_repo_identifier(v: str) -> str:
+
"""normalize repo identifier to owner/repo format without @ prefix"""
+
if "/" not in v:
+
raise ValueError(f"invalid repo format: '{v}'. expected 'owner/repo'")
+
owner, repo_name = v.split("/", 1)
+
# strip @ from owner if present
+
owner = owner.lstrip("@")
+
return f"{owner}/{repo_name}"
+
+
+
RepoIdentifier = Annotated[str, AfterValidator(normalize_repo_identifier)]
+89
tests/test_types.py
···
+
"""tests for public types API"""
+
+
import pytest
+
from pydantic import ValidationError
+
+
from tangled_mcp.types import (
+
CreateIssueResult,
+
ListBranchesResult,
+
UpdateIssueResult,
+
)
+
+
+
class TestRepoIdentifierValidation:
+
"""test RepoIdentifier validation behavior"""
+
+
def test_strips_at_prefix(self):
+
"""@ prefix is stripped during validation"""
+
result = CreateIssueResult(repo="@owner/repo", issue_id=1)
+
assert result.repo == "owner/repo"
+
+
def test_accepts_without_at_prefix(self):
+
"""repo identifier without @ works"""
+
result = CreateIssueResult(repo="owner/repo", issue_id=1)
+
assert result.repo == "owner/repo"
+
+
def test_rejects_invalid_format(self):
+
"""repo identifier without slash is rejected"""
+
with pytest.raises(ValidationError, match="invalid repo format"):
+
CreateIssueResult(repo="invalid", issue_id=1)
+
+
+
class TestIssueResultURLs:
+
"""test issue result URL generation"""
+
+
def test_create_issue_url(self):
+
"""create result generates correct tangled.org URL"""
+
result = CreateIssueResult(repo="owner/repo", issue_id=42)
+
assert result.url == "https://tangled.org/@owner/repo/issues/42"
+
+
def test_update_issue_url(self):
+
"""update result generates correct tangled.org URL"""
+
result = UpdateIssueResult(repo="owner/repo", issue_id=42)
+
assert result.url == "https://tangled.org/@owner/repo/issues/42"
+
+
def test_url_handles_at_prefix_input(self):
+
"""URL is correct even when input has @ prefix"""
+
result = CreateIssueResult(repo="@owner/repo", issue_id=42)
+
assert result.url == "https://tangled.org/@owner/repo/issues/42"
+
+
+
class TestListBranchesFromAPIResponse:
+
"""test ListBranchesResult.from_api_response constructor"""
+
+
def test_parses_branch_data(self):
+
"""parses branches from API response structure"""
+
response = {
+
"branches": [
+
{"reference": {"name": "main", "hash": "abc123"}},
+
{"reference": {"name": "dev", "hash": "def456"}},
+
],
+
"cursor": "next_page",
+
}
+
+
result = ListBranchesResult.from_api_response(response)
+
+
assert len(result.branches) == 2
+
assert result.branches[0].name == "main"
+
assert result.branches[0].sha == "abc123"
+
assert result.branches[1].name == "dev"
+
assert result.branches[1].sha == "def456"
+
assert result.cursor == "next_page"
+
+
def test_handles_missing_cursor(self):
+
"""cursor is optional in API response"""
+
response = {"branches": [{"reference": {"name": "main", "hash": "abc123"}}]}
+
+
result = ListBranchesResult.from_api_response(response)
+
+
assert len(result.branches) == 1
+
assert result.cursor is None
+
+
def test_handles_empty_branches(self):
+
"""handles empty branches list"""
+
response = {"branches": []}
+
+
result = ListBranchesResult.from_api_response(response)
+
+
assert result.branches == []
+
assert result.cursor is None
+2
src/tangled_mcp/_tangled/__init__.py
···
create_issue,
delete_issue,
list_repo_issues,
+
list_repo_labels,
update_issue,
)
···
"update_issue",
"delete_issue",
"list_repo_issues",
+
"list_repo_labels",
"resolve_repo_identifier",
]
+121
src/tangled_mcp/_tangled/_issues.py
···
# filter issues by repo
issues = []
+
issue_uris = []
for record in response.records:
if (
repo := getattr(record.value, "repo", None)
) is not None and repo == repo_at_uri:
+
issue_uris.append(record.uri)
issues.append(
{
"uri": record.uri,
···
"title": getattr(record.value, "title", ""),
"body": getattr(record.value, "body", None),
"createdAt": getattr(record.value, "createdAt", ""),
+
"labels": [], # will be populated below
}
)
+
# fetch label ops and correlate with issues
+
if issue_uris:
+
label_ops = client.com.atproto.repo.list_records(
+
models.ComAtprotoRepoListRecords.Params(
+
repo=client.me.did,
+
collection="sh.tangled.label.op",
+
limit=100,
+
)
+
)
+
+
# build map of issue_uri -> current label URIs
+
issue_labels_map: dict[str, set[str]] = {uri: set() for uri in issue_uris}
+
for op_record in label_ops.records:
+
if hasattr(op_record.value, "subject") and op_record.value.subject in issue_labels_map:
+
subject_uri = op_record.value.subject
+
if hasattr(op_record.value, "add"):
+
for operand in op_record.value.add:
+
if hasattr(operand, "key"):
+
issue_labels_map[subject_uri].add(operand.key)
+
if hasattr(op_record.value, "delete"):
+
for operand in op_record.value.delete:
+
if hasattr(operand, "key"):
+
issue_labels_map[subject_uri].discard(operand.key)
+
+
# extract label names from URIs and add to issues
+
for issue in issues:
+
label_uris = issue_labels_map.get(issue["uri"], set())
+
issue["labels"] = [uri.split("/")[-1] for uri in label_uris]
+
return {"issues": issues, "cursor": response.cursor}
+
def list_repo_labels(repo_id: str) -> list[str]:
+
"""list available labels for a repository
+
+
Args:
+
repo_id: repository identifier in "did/repo" format
+
+
Returns:
+
list of available label names for the repo
+
"""
+
client = _get_authenticated_client()
+
+
if not client.me:
+
raise RuntimeError("client not authenticated")
+
+
# parse repo_id to get owner_did and repo_name
+
if "/" not in repo_id:
+
raise ValueError(f"invalid repo_id format: {repo_id}")
+
+
owner_did, repo_name = repo_id.split("/", 1)
+
+
# get the repo's subscribed label definitions
+
records = client.com.atproto.repo.list_records(
+
models.ComAtprotoRepoListRecords.Params(
+
repo=owner_did,
+
collection="sh.tangled.repo",
+
limit=100,
+
)
+
)
+
+
repo_labels: list[str] = []
+
for record in records.records:
+
if (
+
name := getattr(record.value, "name", None)
+
) is not None and name == repo_name:
+
if (subscribed_labels := getattr(record.value, "labels", None)) is not None:
+
# extract label names from URIs
+
repo_labels = [uri.split("/")[-1] for uri in subscribed_labels]
+
break
+
+
if not repo_labels and not any(
+
(name := getattr(r.value, "name", None)) and name == repo_name
+
for r in records.records
+
):
+
raise ValueError(f"repo not found: {repo_id}")
+
+
return repo_labels
+
+
def _get_current_labels(client, issue_uri: str) -> set[str]:
"""get current labels applied to an issue by examining all label ops"""
label_ops = client.com.atproto.repo.list_records(
···
return current_labels
+
def _validate_labels(labels: list[str], repo_labels: list[str]) -> None:
+
"""validate that all requested labels exist in the repo's subscribed labels
+
+
Args:
+
labels: list of label names or URIs to validate
+
repo_labels: list of label definition URIs the repo subscribes to
+
+
Raises:
+
ValueError: if any labels are invalid, listing available labels
+
"""
+
# extract available label names from repo's subscribed label URIs
+
available_labels = [uri.split("/")[-1] for uri in repo_labels]
+
+
# check each requested label
+
invalid_labels = []
+
for label in labels:
+
if label.startswith("at://"):
+
# if it's a full URI, check if it's in repo_labels
+
if label not in repo_labels:
+
invalid_labels.append(label)
+
else:
+
# if it's a name, check if it matches any available label
+
if not any(
+
label.lower() == available.lower() for available in available_labels
+
):
+
invalid_labels.append(label)
+
+
# fail loudly if any labels are invalid
+
if invalid_labels:
+
raise ValueError(
+
f"invalid labels: {invalid_labels}\n"
+
f"available labels for this repo: {sorted(available_labels)}"
+
)
+
+
def _apply_labels(
client,
issue_uri: str,
···
labels: list of label names or URIs to apply
repo_labels: list of label definition URIs the repo subscribes to
current_labels: set of currently applied label URIs
+
+
Raises:
+
ValueError: if any labels are invalid (via _validate_labels)
"""
+
# validate labels before attempting to apply
+
_validate_labels(labels, repo_labels)
+
# resolve label names to URIs
new_label_uris = set()
for label in labels:
+1 -1
src/tangled_mcp/settings.py
···
# optional: specify PDS URL if auto-discovery doesn't work
# leave empty for auto-discovery from handle
-
tangled_pds_url: str | None = Field(default=None)
+
tangled_pds_url: str | None = None
# tangled service constants
+2 -1
tests/test_server.py
···
async with Client(tangled_mcp) as client:
tools = await client.list_tools()
-
assert len(tools) == 5
+
assert len(tools) == 6
tool_names = {tool.name for tool in tools}
assert "list_repo_branches" in tool_names
···
assert "update_repo_issue" in tool_names
assert "delete_repo_issue" in tool_names
assert "list_repo_issues" in tool_names
+
assert "list_repo_labels" in tool_names
async def test_list_repo_branches_tool_schema(self):
"""test list_repo_branches tool has correct schema"""