From aacbf9b2437ef973e9cb64d1fa55cbace1e03937 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sat, 11 Oct 2025 02:12:15 -0500 Subject: [PATCH] refactor types and improve issue operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## types architecture - refactor types.py → types/ directory structure - _common.py: RepoIdentifier validation with Annotated types - _branches.py: branch types + ListBranchesResult.from_api_response() - _issues.py: issue types + ListIssuesResult.from_api_response() - __init__.py: public API - move parsing logic into types via class method constructors - parsing logic out of tool functions (DRY, separation of concerns) ## issue operations improvements - return clickable URLs instead of AT Protocol URIs/CIDs - CreateIssueResult/UpdateIssueResult: {url, issue_id} - DeleteIssueResult: {issue_id} - URL generation via @computed_field in types - RepoIdentifier validator strips @ prefix, normalizes format - all operations return proper Pydantic models (no dict[str, Any]) ## better auth error messages - _get_authenticated_client() now provides actionable errors - tells users to verify TANGLED_HANDLE and TANGLED_PASSWORD ## documentation - wrap MCP client installation in
for cleaner README - add NEXT_STEPS.md documenting critical issues found: - silent label validation failures (labels must fail loudly) - missing label data in list_repo_issues - pydantic field warning ## testing - add test_types.py with 9 tests covering public API - validates: RepoIdentifier normalization, URL generation, API parsing - all 17 tests passing no breaking changes to public API, all existing functionality preserved --- NEXT_STEPS.md | 137 +++++++++++++++++++++++++++++ README.md | 13 +-- src/tangled_mcp/server.py | 68 ++++++-------- src/tangled_mcp/types.py | 74 ---------------- src/tangled_mcp/types/__init__.py | 22 +++++ src/tangled_mcp/types/_branches.py | 49 +++++++++++ src/tangled_mcp/types/_common.py | 18 ++++ src/tangled_mcp/types/_issues.py | 90 +++++++++++++++++++ tests/test_types.py | 89 +++++++++++++++++++ 9 files changed, 442 insertions(+), 118 deletions(-) create mode 100644 NEXT_STEPS.md delete mode 100644 src/tangled_mcp/types.py create mode 100644 src/tangled_mcp/types/__init__.py create mode 100644 src/tangled_mcp/types/_branches.py create mode 100644 src/tangled_mcp/types/_common.py create mode 100644 src/tangled_mcp/types/_issues.py create mode 100644 tests/test_types.py diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md new file mode 100644 index 0000000..1b90896 --- /dev/null +++ b/NEXT_STEPS.md @@ -0,0 +1,137 @@ +# 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 diff --git a/README.md b/README.md index b562838..0088f9b 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,10 @@ TANGLED_PDS_URL= ## usage -### using with MCP clients +
+MCP client installation instructions -#### claude code +### claude code ```bash # basic setup @@ -43,7 +44,7 @@ claude mcp add tangled \ -- uvx tangled-mcp ``` -#### cursor +### cursor add to your cursor settings (`~/.cursor/mcp.json` or `.cursor/mcp.json`): @@ -62,7 +63,7 @@ add to your cursor settings (`~/.cursor/mcp.json` or `.cursor/mcp.json`): } ``` -#### codex cli +### codex cli ```bash codex mcp add tangled \ @@ -71,13 +72,15 @@ codex mcp add tangled \ -- uvx tangled-mcp ``` -#### other clients +### other clients for clients that support MCP server configuration, use: - **command**: `uvx` - **args**: `["tangled-mcp"]` - **environment variables**: `TANGLED_HANDLE`, `TANGLED_PASSWORD`, and optionally `TANGLED_PDS_URL` +
+ ### development usage ```bash diff --git a/src/tangled_mcp/server.py b/src/tangled_mcp/server.py index c9e8557..fad8030 100644 --- a/src/tangled_mcp/server.py +++ b/src/tangled_mcp/server.py @@ -1,12 +1,18 @@ """tangled MCP server - provides tools and resources for tangled git platform""" -from typing import Annotated, Any +from typing import Annotated from fastmcp import FastMCP from pydantic import Field from tangled_mcp import _tangled -from tangled_mcp.types import BranchInfo, ListBranchesResult +from tangled_mcp.types import ( + CreateIssueResult, + DeleteIssueResult, + ListBranchesResult, + ListIssuesResult, + UpdateIssueResult, +) tangled_mcp = FastMCP("tangled MCP server") @@ -63,19 +69,7 @@ def list_repo_branches( knot, repo_id = _tangled.resolve_repo_identifier(repo) response = _tangled.list_branches(knot, repo_id, limit, cursor) - # parse response into BranchInfo objects - 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 ListBranchesResult(branches=branches, cursor=response.get("cursor")) + return ListBranchesResult.from_api_response(response) @tangled_mcp.tool @@ -95,7 +89,7 @@ def create_repo_issue( "to apply to the issue" ), ] = None, -) -> dict[str, str | int]: +) -> CreateIssueResult: """create an issue on a repository Args: @@ -105,17 +99,14 @@ def create_repo_issue( labels: optional list of label names to apply Returns: - dict with uri, cid, and issueId of created issue + CreateIssueResult with url (clickable link) and issue_id """ # resolve owner/repo to (knot, did/repo) knot, repo_id = _tangled.resolve_repo_identifier(repo) # create_issue doesn't need knot (uses atproto putRecord, not XRPC) response = _tangled.create_issue(repo_id, title, body, labels) - return { - "uri": response["uri"], - "cid": response["cid"], - "issueId": response["issueId"], - } + + return CreateIssueResult(repo=repo, issue_id=response["issueId"]) @tangled_mcp.tool @@ -136,7 +127,7 @@ def update_repo_issue( "use empty list [] to remove all labels" ), ] = None, -) -> dict[str, str]: +) -> UpdateIssueResult: """update an existing issue on a repository Args: @@ -147,13 +138,14 @@ def update_repo_issue( labels: optional list of label names to SET (replaces existing) Returns: - dict with uri and cid of updated issue + UpdateIssueResult with url (clickable link) and issue_id """ # resolve owner/repo to (knot, did/repo) knot, repo_id = _tangled.resolve_repo_identifier(repo) # update_issue doesn't need knot (uses atproto putRecord, not XRPC) - response = _tangled.update_issue(repo_id, issue_id, title, body, labels) - return {"uri": response["uri"], "cid": response["cid"]} + _tangled.update_issue(repo_id, issue_id, title, body, labels) + + return UpdateIssueResult(repo=repo, issue_id=issue_id) @tangled_mcp.tool @@ -167,7 +159,7 @@ def delete_repo_issue( issue_id: Annotated[ int, Field(description="issue number to delete (e.g., 1, 2, 3...)") ], -) -> dict[str, str]: +) -> DeleteIssueResult: """delete an issue from a repository Args: @@ -175,13 +167,14 @@ def delete_repo_issue( issue_id: issue number to delete Returns: - dict with uri of deleted issue + DeleteIssueResult with issue_id of deleted issue """ # resolve owner/repo to (knot, did/repo) - knot, repo_id = _tangled.resolve_repo_identifier(repo) + _, repo_id = _tangled.resolve_repo_identifier(repo) # delete_issue doesn't need knot (uses atproto deleteRecord, not XRPC) - response = _tangled.delete_issue(repo_id, issue_id) - return {"uri": response["uri"]} + _tangled.delete_issue(repo_id, issue_id) + + return DeleteIssueResult(issue_id=issue_id) @tangled_mcp.tool @@ -194,9 +187,9 @@ def list_repo_issues( ], limit: Annotated[ int, Field(ge=1, le=100, description="maximum number of issues to return") - ] = 50, + ] = 20, cursor: Annotated[str | None, Field(description="pagination cursor")] = None, -) -> dict[str, Any]: +) -> ListIssuesResult: """list issues for a repository Args: @@ -205,14 +198,11 @@ def list_repo_issues( cursor: optional pagination cursor Returns: - dict with list of issues and optional cursor + ListIssuesResult with list of issues and optional cursor """ # resolve owner/repo to (knot, did/repo) - knot, repo_id = _tangled.resolve_repo_identifier(repo) + _, repo_id = _tangled.resolve_repo_identifier(repo) # list_repo_issues doesn't need knot (queries atproto records, not XRPC) response = _tangled.list_repo_issues(repo_id, limit, cursor) - return { - "issues": response["issues"], - "cursor": response.get("cursor"), - } + return ListIssuesResult.from_api_response(response) diff --git a/src/tangled_mcp/types.py b/src/tangled_mcp/types.py deleted file mode 100644 index 74a72fa..0000000 --- a/src/tangled_mcp/types.py +++ /dev/null @@ -1,74 +0,0 @@ -"""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 diff --git a/src/tangled_mcp/types/__init__.py b/src/tangled_mcp/types/__init__.py new file mode 100644 index 0000000..57f9503 --- /dev/null +++ b/src/tangled_mcp/types/__init__.py @@ -0,0 +1,22 @@ +"""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", +] diff --git a/src/tangled_mcp/types/_branches.py b/src/tangled_mcp/types/_branches.py new file mode 100644 index 0000000..a20854e --- /dev/null +++ b/src/tangled_mcp/types/_branches.py @@ -0,0 +1,49 @@ +"""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")) diff --git a/src/tangled_mcp/types/_common.py b/src/tangled_mcp/types/_common.py new file mode 100644 index 0000000..7cd1dbb --- /dev/null +++ b/src/tangled_mcp/types/_common.py @@ -0,0 +1,18 @@ +"""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)] diff --git a/src/tangled_mcp/types/_issues.py b/src/tangled_mcp/types/_issues.py new file mode 100644 index 0000000..1d63bfc --- /dev/null +++ b/src/tangled_mcp/types/_issues.py @@ -0,0 +1,90 @@ +"""issue-related types""" + +from typing import Any + +from pydantic import BaseModel, Field, computed_field + +from tangled_mcp.types._common import RepoIdentifier + + +def _tangled_issue_url(repo: RepoIdentifier, issue_id: int) -> str: + """construct clickable tangled.org URL""" + owner, repo_name = repo.split("/", 1) + return f"https://tangled.org/@{owner}/{repo_name}/issues/{issue_id}" + + +class IssueInfo(BaseModel): + """issue information""" + + uri: str + cid: str + issue_id: int = Field(alias="issueId") + title: str + body: str | None = None + created_at: str = Field(alias="createdAt") + + +class CreateIssueResult(BaseModel): + """result of creating an issue""" + + repo: RepoIdentifier + issue_id: int + + @computed_field + @property + def url(self) -> str: + """construct clickable tangled.org URL""" + return _tangled_issue_url(self.repo, self.issue_id) + + +class UpdateIssueResult(BaseModel): + """result of updating an issue""" + + repo: RepoIdentifier + issue_id: int + + @computed_field + @property + def url(self) -> str: + """construct clickable tangled.org URL""" + return _tangled_issue_url(self.repo, self.issue_id) + + +class DeleteIssueResult(BaseModel): + """result of deleting an issue""" + + issue_id: int + + +class ListIssuesResult(BaseModel): + """result of listing issues""" + + issues: list[IssueInfo] + cursor: str | None = None + + @classmethod + def from_api_response(cls, response: dict[str, Any]) -> "ListIssuesResult": + """construct from raw API response + + Args: + response: raw response from tangled API with structure: + { + "issues": [ + { + "uri": "at://...", + "cid": "bafyrei...", + "issueId": 1, + "title": "...", + "body": "...", + "createdAt": "..." + }, + ... + ], + "cursor": "optional_cursor" + } + + Returns: + ListIssuesResult with parsed issues + """ + issues = [IssueInfo(**issue_data) for issue_data in response.get("issues", [])] + return cls(issues=issues, cursor=response.get("cursor")) diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..c629cc8 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,89 @@ +"""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.43.0 From bd0cbb16123b8dd5623e852a105e0cf3dbc74c38 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sat, 11 Oct 2025 02:26:07 -0500 Subject: [PATCH] fix critical label issues: validation, listing, and visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## critical fixes ### 1. fix pydantic field warning - remove invalid `Field(default=None)` from settings.py - change to `tangled_pds_url: str | None = None` ### 2. add loud label validation - new `_validate_labels()` helper checks labels against repo's subscribed definitions - raises `ValueError` with available labels list when invalid labels provided - prevents silent failures when creating/updating issues ### 3. include labels in list_repo_issues - add `labels: list[str]` field to `IssueInfo` model - fetch and correlate label ops with issues - return label names (not URIs) for better UX ### 4. add list_repo_labels tool - new tool to query available labels for a repository - extracts label names from repo's subscribed label definitions - helps users discover which labels they can use ## changes - src/tangled_mcp/settings.py: fix pydantic warning - src/tangled_mcp/_tangled/_issues.py: add validation, label fetching, new tool - src/tangled_mcp/types/_issues.py: add labels field to IssueInfo - src/tangled_mcp/server.py: expose list_repo_labels tool - src/tangled_mcp/_tangled/__init__.py: export list_repo_labels - tests/test_server.py: update tool count (5 -> 6) - README.md: document new tool all 17 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 1 + src/tangled_mcp/_tangled/__init__.py | 2 + src/tangled_mcp/_tangled/_issues.py | 121 +++++++++++++++++++++++++++ src/tangled_mcp/server.py | 23 +++++ src/tangled_mcp/settings.py | 2 +- src/tangled_mcp/types/_issues.py | 1 + tests/test_server.py | 3 +- 7 files changed, 151 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0088f9b..a3c141a 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ all tools accept repositories in `owner/repo` format (e.g., `zzstoatzz/tangled-m - `update_repo_issue(repo, issue_id, title, body, labels)` - update an issue's title, body, and/or labels - `delete_repo_issue(repo, issue_id)` - delete an issue - `list_repo_issues(repo, limit, cursor)` - list issues for a repository +- `list_repo_labels(repo)` - list available labels for a repository ## development diff --git a/src/tangled_mcp/_tangled/__init__.py b/src/tangled_mcp/_tangled/__init__.py index b7974d6..16b5e75 100644 --- a/src/tangled_mcp/_tangled/__init__.py +++ b/src/tangled_mcp/_tangled/__init__.py @@ -10,6 +10,7 @@ from tangled_mcp._tangled._issues import ( create_issue, delete_issue, list_repo_issues, + list_repo_labels, update_issue, ) @@ -21,5 +22,6 @@ __all__ = [ "update_issue", "delete_issue", "list_repo_issues", + "list_repo_labels", "resolve_repo_identifier", ] diff --git a/src/tangled_mcp/_tangled/_issues.py b/src/tangled_mcp/_tangled/_issues.py index 15e21ba..1556a92 100644 --- a/src/tangled_mcp/_tangled/_issues.py +++ b/src/tangled_mcp/_tangled/_issues.py @@ -377,10 +377,12 @@ def list_repo_issues( # 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, @@ -389,12 +391,90 @@ def list_repo_issues( "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( @@ -421,6 +501,41 @@ def _get_current_labels(client, issue_uri: str) -> set[str]: 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, @@ -436,7 +551,13 @@ def _apply_labels( 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: diff --git a/src/tangled_mcp/server.py b/src/tangled_mcp/server.py index fad8030..48ba739 100644 --- a/src/tangled_mcp/server.py +++ b/src/tangled_mcp/server.py @@ -206,3 +206,26 @@ def list_repo_issues( response = _tangled.list_repo_issues(repo_id, limit, cursor) return ListIssuesResult.from_api_response(response) + + +@tangled_mcp.tool +def list_repo_labels( + repo: Annotated[ + str, + Field( + description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')" + ), + ], +) -> list[str]: + """list available labels for a repository + + Args: + repo: repository identifier in 'owner/repo' format + + Returns: + list of available label names for the repository + """ + # resolve owner/repo to (knot, did/repo) + _, repo_id = _tangled.resolve_repo_identifier(repo) + # list_repo_labels doesn't need knot (queries atproto records, not XRPC) + return _tangled.list_repo_labels(repo_id) diff --git a/src/tangled_mcp/settings.py b/src/tangled_mcp/settings.py index 3671e99..53e2543 100644 --- a/src/tangled_mcp/settings.py +++ b/src/tangled_mcp/settings.py @@ -10,7 +10,7 @@ class Settings(BaseSettings): # 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 diff --git a/src/tangled_mcp/types/_issues.py b/src/tangled_mcp/types/_issues.py index 1d63bfc..af21cfd 100644 --- a/src/tangled_mcp/types/_issues.py +++ b/src/tangled_mcp/types/_issues.py @@ -22,6 +22,7 @@ class IssueInfo(BaseModel): title: str body: str | None = None created_at: str = Field(alias="createdAt") + labels: list[str] = [] class CreateIssueResult(BaseModel): diff --git a/tests/test_server.py b/tests/test_server.py index 9a56ddb..44d5a9a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -22,7 +22,7 @@ class TestServerStructure: 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 @@ -30,6 +30,7 @@ class TestServerStructure: 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""" -- 2.43.0