diff --git a/README.md b/README.md index 6feb3e9..baf1edf 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,58 @@ for child in detail.children: print("sub-issue:", child.identifier, child.title) ``` +## Sharing issues + +Share a private-team issue with a specific user, or revoke that access: + +```python +from linear_python_client import ( + FindUserRequest, + IssueShareRequest, + IssueUnshareRequest, + IssueRequest, +) + +# Resolve the user UUID first +user = client.find_user(FindUserRequest(email="collab@partner.com")).user +if user is None: + raise ValueError("no Linear user for that email") + +# Share +client.share_issue(IssueShareRequest(id="SEC-123", user_id=user.id)) + +# Verify: re-pull the issue detail and inspect sharedAccess +detail = client.issue_details(IssueRequest(id="SEC-123")).issue +print(detail.shared_access.is_shared) # True +print(detail.shared_access.shared_with_count) # 1 + +# Revoke +client.unshare_issue(IssueUnshareRequest(id="SEC-123", user_id=user.id)) +``` + +**Preconditions** (enforced by the API; failures raise `LinearGraphQLError`): + +1. The acting principal must have native access to the **entire sub-issue tree**, + not just the top-level issue. +2. The team must have **issue sharing enabled** and the principal must satisfy its + `securitySettings.issueSharing` role (`member` or `owner`). +3. Sub-issues that **inherit sharing from their parent** cannot be shared/unshared + directly — set `inherits_shared_access=False` via `update_issue` first. + +### Filtering by shared access + +`IssueFilter` already supports `sharedWith` (a user filter) and `hasSharedUsers` +(a relation-exists comparator). Because list methods pass `filter` through as a +raw dict, this already works without any additional client code: + +```python +# Issues shared with a specific user +client.issues(IssuesRequest(filter={"sharedWith": {"id": {"eq": ""}}})) + +# Issues that have any shared users +client.issues(IssuesRequest(filter={"hasSharedUsers": {"eq": True}})) +``` + ## Passing names instead of UUIDs `create_issue` and `update_issue` automatically resolve non-UUID strings to UUIDs, so @@ -258,6 +310,8 @@ Each method maps a `*Request` to a `*Response`: | `archive_issue(...)` | `IssueArchiveRequest` | `ArchiveIssueResponse` | | `add_label(...)` | `IssueAddLabelRequest` | `AddLabelResponse` | | `remove_label(...)` | `IssueRemoveLabelRequest` | `RemoveLabelResponse` | +| `share_issue(...)` | `IssueShareRequest` | `ShareIssueResponse` | +| `unshare_issue(...)` | `IssueUnshareRequest` | `UnshareIssueResponse` | | `set_issue_state(...)` | `IssueSetStateRequest` | `UpdateIssueResponse` | | `project(...)` | `ProjectRequest` | `ProjectResponse` | | `projects(...)` | `ProjectsRequest` | `ProjectsResponse` | diff --git a/docs/api/models.md b/docs/api/models.md index b675809..1ebc359 100644 --- a/docs/api/models.md +++ b/docs/api/models.md @@ -15,6 +15,7 @@ requested are populated. - Team - Issue - IssueDetail + - IssueSharedAccess - Project - Comment - WorkflowState diff --git a/docs/api/requests.md b/docs/api/requests.md index 01b828e..f4a30ec 100644 --- a/docs/api/requests.md +++ b/docs/api/requests.md @@ -26,6 +26,8 @@ method takes exactly one of these. - IssueArchiveRequest - IssueAddLabelRequest - IssueRemoveLabelRequest + - IssueShareRequest + - IssueUnshareRequest - IssueSetStateRequest - FindWorkflowStateRequest - FindTeamRequest diff --git a/docs/api/responses.md b/docs/api/responses.md index b541163..3e00316 100644 --- a/docs/api/responses.md +++ b/docs/api/responses.md @@ -30,4 +30,6 @@ The typed result models returned by each - ArchiveIssueResponse - AddLabelResponse - RemoveLabelResponse + - ShareIssueResponse + - UnshareIssueResponse - CreateCommentResponse diff --git a/docs/usage.md b/docs/usage.md index dfcc5e3..e70103b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -199,6 +199,60 @@ client.remove_label(IssueRemoveLabelRequest(id=issue_id, label_id=label_id)) Look up label UUIDs with [`issue_labels`](#workflow-states-labels). +## Sharing issues + +Share a private-team issue with an external user, or revoke that access: + +```python +from linear_python_client import ( + FindUserRequest, + IssueShareRequest, + IssueUnshareRequest, + IssueRequest, +) + +# Resolve the user UUID first +user = client.find_user(FindUserRequest(email="collab@partner.com")).user +if user is None: + raise ValueError("no Linear user for that email") + +# Share +client.share_issue(IssueShareRequest(id="SEC-123", user_id=user.id)) + +# Verify: re-pull the issue detail and inspect sharedAccess +detail = client.issue_details(IssueRequest(id="SEC-123")).issue +print(detail.shared_access.is_shared) # True +print(detail.shared_access.shared_with_count) # 1 + +# Revoke +client.unshare_issue(IssueUnshareRequest(id="SEC-123", user_id=user.id)) +``` + +**Preconditions** (enforced by the API; failures raise +[`LinearGraphQLError`][linear_python_client.LinearGraphQLError]): + +1. The acting principal must have native access to the **entire sub-issue tree**. +2. The team must have **issue sharing enabled** and the principal must satisfy its + `securitySettings.issueSharing` role (`member` or `owner`). +3. Sub-issues that **inherit sharing from their parent** cannot be shared/unshared + directly — set `inherits_shared_access=False` via `update_issue` first. + +`shared_access` is only populated by `issue_details()`, not by the lightweight +`issue()` call. + +### Filtering by shared access + +`IssueFilter` supports `sharedWith` (a user filter) and `hasSharedUsers` +(a relation-exists comparator). Pass them as raw filter dicts: + +```python +# Issues shared with a specific user +client.issues(IssuesRequest(filter={"sharedWith": {"id": {"eq": ""}}})) + +# Issues that have any shared users +client.issues(IssuesRequest(filter={"hasSharedUsers": {"eq": True}})) +``` + ## Status (workflow state) Move an issue to a status with `set_issue_state`. Statuses are workflow states diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index feb0e9b..96aa636 100644 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -12,7 +12,9 @@ LINEAR_API_KEY=lin_api_... uv run python scripts/smoke_test.py Optional environment variables: - LINEAR_TEAM_ID UUID of the team to create the test issue in (default: first team). + LINEAR_TEAM_ID UUID of the team to create the test issue in (default: first team). + LINEAR_TEST_SHARE_USER_ID UUID of a second workspace user to run the share/unshare leg. + If unset, the sharing checks are skipped and the default run passes. Exit code is non-zero if any check fails. """ @@ -41,7 +43,9 @@ IssueRemoveLabelRequest, IssueRequest, IssueSetStateRequest, + IssueShareRequest, IssuesRequest, + IssueUnshareRequest, IssueUpdateRequest, LinearClient, ProjectRequest, @@ -52,6 +56,7 @@ UsersRequest, WorkflowStatesRequest, ) +from linear_python_client.errors import LinearGraphQLError from linear_python_client.graphql import queries MARKER = "[linear-python-client smoke test]" @@ -175,9 +180,11 @@ def main() -> int: team_labels = ( r.run( "issue_labels(team-scoped)", - lambda: client.issue_labels( - IssueLabelsRequest(filter={"team": {"id": {"eq": team_id}}}, first=50) - ).nodes, + lambda: ( + client.issue_labels( + IssueLabelsRequest(filter={"team": {"id": {"eq": team_id}}}, first=50) + ).nodes + ), ) or [] ) @@ -212,7 +219,10 @@ def main() -> int: "find_user(by name)", lambda: client.find_user(FindUserRequest(name=viewer.name)).user, ) - r.check("find_user(name) resolves to a user", bool(found_user_by_name and found_user_by_name.id)) + r.check( + "find_user(name) resolves to a user", + bool(found_user_by_name and found_user_by_name.id), + ) # find_user by email (new) if viewer.email: @@ -238,9 +248,11 @@ def main() -> int: label_for_resolve = team_labels[0] resolved_label = r.run( "find_label(by name)", - lambda: client.find_label( - FindLabelRequest(name=label_for_resolve.name, team_id=team_id) - ).label, + lambda: ( + client.find_label( + FindLabelRequest(name=label_for_resolve.name, team_id=team_id) + ).label + ), ) r.check( "find_label(name) resolves to same id", @@ -423,9 +435,11 @@ def pull(): ) resolved = r.run( "find_workflow_state(by name)", - lambda: client.find_workflow_state( - FindWorkflowStateRequest(team_id=team_id, name=target.name) - ).state, + lambda: ( + client.find_workflow_state( + FindWorkflowStateRequest(team_id=team_id, name=target.name) + ).state + ), ) r.check( "find_workflow_state resolves to same id", @@ -440,9 +454,7 @@ def pull(): label = team_labels[0] r.run( "add_label()", - lambda: client.add_label( - IssueAddLabelRequest(id=issue_id, label_id=label.id) - ), + lambda: client.add_label(IssueAddLabelRequest(id=issue_id, label_id=label.id)), ) pulled = pull() r.check( @@ -469,14 +481,10 @@ def pull(): body = f"{MARKER} comment {int(time.time())}" created_comment = r.run( "create_comment()", - lambda: client.create_comment( - CommentCreateRequest(issue_id=issue_id, body=body) - ), + lambda: client.create_comment(CommentCreateRequest(issue_id=issue_id, body=body)), ) comment_id = ( - created_comment.comment.id - if created_comment and created_comment.comment - else None + created_comment.comment.id if created_comment and created_comment.comment else None ) listed = r.run( "comments(issue_id=...)", @@ -506,6 +514,70 @@ def pull(): any(c.body == body for c in detail.comments), ) r.check("details: state present", bool(detail.state)) + # -- issue sharing (conditional on LINEAR_TEST_SHARE_USER_ID) ---- + section("share_issue / unshare_issue") + share_user_id = os.environ.get("LINEAR_TEST_SHARE_USER_ID") + if not share_user_id: + r.skip( + "share_issue/unshare_issue", + "LINEAR_TEST_SHARE_USER_ID not set — skipping share leg", + ) + else: + try: + r.run( + "share_issue()", + lambda: client.share_issue( + IssueShareRequest(id=issue_id, user_id=share_user_id) + ), + ) + detail_after_share = r.run( + "issue_details() after share", + lambda: client.issue_details(IssueRequest(id=issue_id)).issue, + ) + if detail_after_share and detail_after_share.shared_access: + sa = detail_after_share.shared_access + r.check( + "shared_access.is_shared is True", + sa.is_shared is True, + ) + r.check( + "shared_with_count == 1", + sa.shared_with_count == 1, + str(sa.shared_with_count), + ) + r.check( + "share user appears in shared_with_users", + any(u.id == share_user_id for u in sa.shared_with_users), + ) + else: + r.check("shared_access populated after share", False) + + r.run( + "unshare_issue()", + lambda: client.unshare_issue( + IssueUnshareRequest(id=issue_id, user_id=share_user_id) + ), + ) + detail_after_unshare = r.run( + "issue_details() after unshare", + lambda: client.issue_details(IssueRequest(id=issue_id)).issue, + ) + if detail_after_unshare and detail_after_unshare.shared_access: + r.check( + "is_shared is False after unshare", + detail_after_unshare.shared_access.is_shared is False, + ) + else: + r.check("shared_access populated after unshare", False) + except LinearGraphQLError as exc: + if "sharing" in str(exc).lower() or "not enabled" in str(exc).lower(): + r.skip( + "share_issue/unshare_issue", + f"issue sharing not enabled for this workspace/team: {exc}", + ) + else: + raise + finally: # -- archive (cleanup) + verify -------------------------------- section("archive_issue (cleanup)") diff --git a/src/linear_python_client/__init__.py b/src/linear_python_client/__init__.py index 4bf8fee..51a0d4e 100644 --- a/src/linear_python_client/__init__.py +++ b/src/linear_python_client/__init__.py @@ -19,6 +19,7 @@ IssueDetail, IssueLabel, IssueRelation, + IssueSharedAccess, LinearModel, PageInfo, Project, @@ -42,7 +43,9 @@ IssueRemoveLabelRequest, IssueRequest, IssueSetStateRequest, + IssueShareRequest, IssuesRequest, + IssueUnshareRequest, IssueUpdateRequest, PaginatedRequest, ProjectRequest, @@ -69,8 +72,10 @@ ProjectResponse, ProjectsResponse, RemoveLabelResponse, + ShareIssueResponse, TeamResponse, TeamsResponse, + UnshareIssueResponse, UpdateIssueResponse, UserResponse, UsersResponse, @@ -100,6 +105,7 @@ "IssueDetail", "IssueLabel", "IssueRelation", + "IssueSharedAccess", "PageInfo", "Project", "Team", @@ -119,6 +125,8 @@ "IssueAddLabelRequest", "IssueRemoveLabelRequest", "IssueSetStateRequest", + "IssueShareRequest", + "IssueUnshareRequest", "FindWorkflowStateRequest", "FindTeamRequest", "FindUserRequest", @@ -146,6 +154,8 @@ "ArchiveIssueResponse", "AddLabelResponse", "RemoveLabelResponse", + "ShareIssueResponse", + "UnshareIssueResponse", "ProjectResponse", "ProjectsResponse", "CommentResponse", diff --git a/src/linear_python_client/client.py b/src/linear_python_client/client.py index ef22496..403b8e6 100644 --- a/src/linear_python_client/client.py +++ b/src/linear_python_client/client.py @@ -39,7 +39,9 @@ IssueRemoveLabelRequest, IssueRequest, IssueSetStateRequest, + IssueShareRequest, IssuesRequest, + IssueUnshareRequest, IssueUpdateRequest, PaginatedRequest, ProjectRequest, @@ -66,8 +68,10 @@ ProjectResponse, ProjectsResponse, RemoveLabelResponse, + ShareIssueResponse, TeamResponse, TeamsResponse, + UnshareIssueResponse, UpdateIssueResponse, UserResponse, UsersResponse, @@ -628,6 +632,61 @@ def remove_label(self, request: IssueRemoveLabelRequest) -> RemoveLabelResponse: ) return RemoveLabelResponse.model_validate(data.get("issueRemoveLabel") or {}) + def share_issue(self, request: IssueShareRequest) -> ShareIssueResponse: + """Share an issue with a specific user. + + Resolve the user UUID up front with + [`find_user`][linear_python_client.client.LinearClient.find_user] — + this method takes a UUID and passes it straight through. + + Args: + request: An [`IssueShareRequest`][linear_python_client.IssueShareRequest]. + + Returns: + A [`ShareIssueResponse`][linear_python_client.ShareIssueResponse] + exposing `success` and the updated `issue`. + + Raises: + LinearGraphQLError: If sharing is rejected by the API. Common causes: + + 1. The principal lacks **native access to the issue's entire + sub-issue tree**. + 2. The principal lacks **permission to share in that team** — + ``issueSharingEnabled`` must be on and the principal must + satisfy the team's ``securitySettings.issueSharing`` role + (``member`` or ``owner``). + 3. The issue **inherits sharing from a parent** — unsharing is + only possible at the root. Set ``inherits_shared_access=False`` + via ``update_issue`` first. + """ + data = self.execute(queries.ISSUE_SHARE, {"id": request.id, "userId": request.user_id}) + return ShareIssueResponse.model_validate(data.get("issueShare") or {}) + + def unshare_issue(self, request: IssueUnshareRequest) -> UnshareIssueResponse: + """Remove a user's shared access to an issue. + + Args: + request: An [`IssueUnshareRequest`][linear_python_client.IssueUnshareRequest]. + + Returns: + An [`UnshareIssueResponse`][linear_python_client.UnshareIssueResponse] + exposing `success` and the updated `issue`. + + Raises: + LinearGraphQLError: If unsharing is rejected by the API. Common causes: + + 1. The principal lacks **native access to the issue's entire + sub-issue tree**. + 2. The principal lacks **permission to share in that team** — + ``issueSharingEnabled`` must be on and the principal must + satisfy the team's ``securitySettings.issueSharing`` role + (``member`` or ``owner``). + 3. The issue **inherits sharing from a parent** — see + ``share_issue`` for the workaround. + """ + data = self.execute(queries.ISSUE_UNSHARE, {"id": request.id, "userId": request.user_id}) + return UnshareIssueResponse.model_validate(data.get("issueUnshare") or {}) + def set_issue_state(self, request: IssueSetStateRequest) -> UpdateIssueResponse: """Move an issue to a workflow state (status). diff --git a/src/linear_python_client/graphql/queries.py b/src/linear_python_client/graphql/queries.py index a4d8230..1ad4063 100644 --- a/src/linear_python_client/graphql/queries.py +++ b/src/linear_python_client/graphql/queries.py @@ -138,6 +138,14 @@ children { nodes { ...IssueSummaryFields } } subscribers { nodes { ...UserFields } } relations { nodes { type relatedIssue { ...IssueSummaryFields } } } + sharedAccess { + isShared + viewerHasOnlySharedAccess + sharedWithCount + sharedWithUsers { id name email } + disallowedIssueFields + } + inheritsSharedAccess } """ @@ -403,3 +411,35 @@ def _compose(*parts: str) -> str: } """, ) + +ISSUE_SHARE = _compose( + ISSUE_FIELDS, + USER_FIELDS, + TEAM_FIELDS, + STATE_FIELDS, + LABEL_FIELDS, + """ +mutation IssueShare($id: String!, $userId: String!) { + issueShare(id: $id, userId: $userId) { + success + issue { ...IssueFields } + } +} +""", +) + +ISSUE_UNSHARE = _compose( + ISSUE_FIELDS, + USER_FIELDS, + TEAM_FIELDS, + STATE_FIELDS, + LABEL_FIELDS, + """ +mutation IssueUnshare($id: String!, $userId: String!) { + issueUnshare(id: $id, userId: $userId) { + success + issue { ...IssueFields } + } +} +""", +) diff --git a/src/linear_python_client/models/__init__.py b/src/linear_python_client/models/__init__.py index 45e862e..654795c 100644 --- a/src/linear_python_client/models/__init__.py +++ b/src/linear_python_client/models/__init__.py @@ -14,6 +14,7 @@ IssueDetail, IssueLabel, IssueRelation, + IssueSharedAccess, LinearModel, PageInfo, Project, @@ -37,7 +38,9 @@ IssueRemoveLabelRequest, IssueRequest, IssueSetStateRequest, + IssueShareRequest, IssuesRequest, + IssueUnshareRequest, IssueUpdateRequest, PaginatedRequest, ProjectRequest, @@ -64,8 +67,10 @@ ProjectResponse, ProjectsResponse, RemoveLabelResponse, + ShareIssueResponse, TeamResponse, TeamsResponse, + UnshareIssueResponse, UpdateIssueResponse, UserResponse, UsersResponse, @@ -84,6 +89,7 @@ "IssueDetail", "IssueLabel", "IssueRelation", + "IssueSharedAccess", "PageInfo", "Project", "Team", @@ -103,6 +109,8 @@ "IssueAddLabelRequest", "IssueRemoveLabelRequest", "IssueSetStateRequest", + "IssueShareRequest", + "IssueUnshareRequest", "FindWorkflowStateRequest", "FindTeamRequest", "FindUserRequest", @@ -130,6 +138,8 @@ "ArchiveIssueResponse", "AddLabelResponse", "RemoveLabelResponse", + "ShareIssueResponse", + "UnshareIssueResponse", "ProjectResponse", "ProjectsResponse", "CommentResponse", diff --git a/src/linear_python_client/models/entities.py b/src/linear_python_client/models/entities.py index 731a99c..e689fd1 100644 --- a/src/linear_python_client/models/entities.py +++ b/src/linear_python_client/models/entities.py @@ -134,6 +134,16 @@ def _unwrap_label_nodes(cls, value: object) -> object: return _unwrap_nodes(value) +class IssueSharedAccess(LinearModel): + """The sharing state of an issue, exposed by :attr:`IssueDetail.shared_access`.""" + + is_shared: bool = False + viewer_has_only_shared_access: bool = False + shared_with_count: int = 0 + shared_with_users: list[User] = Field(default_factory=list) + disallowed_issue_fields: list[str] = Field(default_factory=list) + + class Attachment(LinearModel): """A link or file attached to an issue.""" @@ -177,6 +187,8 @@ class IssueDetail(Issue): children: list[Issue] = Field(default_factory=list) subscribers: list[User] = Field(default_factory=list) relations: list[IssueRelation] = Field(default_factory=list) + shared_access: IssueSharedAccess | None = None + inherits_shared_access: bool | None = None @field_validator( "comments", "attachments", "children", "subscribers", "relations", mode="before" diff --git a/src/linear_python_client/models/requests.py b/src/linear_python_client/models/requests.py index 4c98b2d..324dc2d 100644 --- a/src/linear_python_client/models/requests.py +++ b/src/linear_python_client/models/requests.py @@ -214,6 +214,58 @@ class IssueRemoveLabelRequest(LinearModel): label_id: str +class IssueShareRequest(LinearModel): + """Share an issue with a specific user. + + Attributes: + id: UUID or human identifier (e.g. ``"SEC-123"``) of the issue to share. + user_id: UUID of the user to grant shared access to. + + Preconditions (enforced by the API, surfaces as + :exc:`~linear_python_client.LinearGraphQLError`): + + 1. The acting principal must have **native access to the issue's entire + sub-issue tree**, not just the top-level issue. + 2. The acting principal needs **permission to share issues in that issue's + team** — the team's ``issueSharingEnabled`` must be on and the principal + must satisfy the team's ``securitySettings.issueSharing`` role + (``member`` or ``owner``). + 3. **Issues that inherit sharing from a parent cannot be shared directly.** + If ``IssueDetail.inherits_shared_access`` is ``True``, the mutation will + error. Set ``inherits_shared_access=False`` via + :meth:`~linear_python_client.client.LinearClient.update_issue` first. + """ + + id: str + user_id: str + + +class IssueUnshareRequest(LinearModel): + """Remove a user's shared access to an issue. + + Attributes: + id: UUID or human identifier (e.g. ``"SEC-123"``) of the issue. + user_id: UUID of the user whose shared access to revoke. + + Preconditions (enforced by the API, surfaces as + :exc:`~linear_python_client.LinearGraphQLError`): + + 1. The acting principal must have **native access to the issue's entire + sub-issue tree**, not just the top-level issue. + 2. The acting principal needs **permission to share issues in that issue's + team** — the team's ``issueSharingEnabled`` must be on and the principal + must satisfy the team's ``securitySettings.issueSharing`` role + (``member`` or ``owner``). + 3. **Issues that inherit sharing from a parent cannot be unshared directly.** + If ``IssueDetail.inherits_shared_access`` is ``True``, the mutation will + error. Set ``inherits_shared_access=False`` via + :meth:`~linear_python_client.client.LinearClient.update_issue` first. + """ + + id: str + user_id: str + + class IssueSetStateRequest(LinearModel): """Move an issue to a workflow state (status). diff --git a/src/linear_python_client/models/responses.py b/src/linear_python_client/models/responses.py index c6ab109..9e3e6c8 100644 --- a/src/linear_python_client/models/responses.py +++ b/src/linear_python_client/models/responses.py @@ -175,3 +175,17 @@ class RemoveLabelResponse(LinearModel): success: bool = False issue: Issue | None = None + + +class ShareIssueResponse(LinearModel): + """Response for [`share_issue`][linear_python_client.client.LinearClient.share_issue].""" + + success: bool = False + issue: Issue | None = None + + +class UnshareIssueResponse(LinearModel): + """Response for [`unshare_issue`][linear_python_client.client.LinearClient.unshare_issue].""" + + success: bool = False + issue: Issue | None = None diff --git a/tests/test_id_resolution.py b/tests/test_id_resolution.py index 154ecad..ac98073 100644 --- a/tests/test_id_resolution.py +++ b/tests/test_id_resolution.py @@ -70,7 +70,9 @@ def label_miss() -> httpx.Response: def state_hit(id: str = _STATE_UUID) -> httpx.Response: - return gql({"workflowStates": {"nodes": [{"id": id, "name": "In Progress", "type": "started"}]}}) + return gql( + {"workflowStates": {"nodes": [{"id": id, "name": "In Progress", "type": "started"}]}} + ) def state_miss() -> httpx.Response: @@ -223,9 +225,7 @@ def test_project_uuid_skips_lookup(client: LinearClient) -> None: def test_project_not_found_raises_value_error(client: LinearClient) -> None: respx.post(DEFAULT_ENDPOINT).mock(side_effect=[team_hit(), project_miss()]) with pytest.raises(ValueError, match="Project not found"): - client.create_issue( - IssueCreateRequest(team_id="Engineering", title="x", project_id="Nope") - ) + client.create_issue(IssueCreateRequest(team_id="Engineering", title="x", project_id="Nope")) # --------------------------------------------------------------------------- @@ -287,9 +287,7 @@ def test_state_name_resolved_to_uuid(client: LinearClient) -> None: @respx.mock def test_state_uuid_skips_lookup(client: LinearClient) -> None: route = respx.post(DEFAULT_ENDPOINT).mock(side_effect=[team_hit(), create_ok()]) - client.create_issue( - IssueCreateRequest(team_id="Engineering", title="x", state_id=_STATE_UUID) - ) + client.create_issue(IssueCreateRequest(team_id="Engineering", title="x", state_id=_STATE_UUID)) assert route.call_count == 2 @@ -297,9 +295,7 @@ def test_state_uuid_skips_lookup(client: LinearClient) -> None: def test_state_not_found_raises_value_error(client: LinearClient) -> None: respx.post(DEFAULT_ENDPOINT).mock(side_effect=[team_hit(), state_miss()]) with pytest.raises(ValueError, match="Workflow state"): - client.create_issue( - IssueCreateRequest(team_id="Engineering", title="x", state_id="Nope") - ) + client.create_issue(IssueCreateRequest(team_id="Engineering", title="x", state_id="Nope")) # --------------------------------------------------------------------------- diff --git a/tests/test_issue_extras.py b/tests/test_issue_extras.py index cd4f0db..7cfafc9 100644 --- a/tests/test_issue_extras.py +++ b/tests/test_issue_extras.py @@ -1,4 +1,4 @@ -"""Coverage of labels, status updates, and full issue details.""" +"""Coverage of labels, status updates, full issue details, and issue sharing.""" from __future__ import annotations @@ -17,12 +17,18 @@ IssueRemoveLabelRequest, IssueRequest, IssueSetStateRequest, + IssueSharedAccess, + IssueShareRequest, + IssueUnshareRequest, LinearClient, RemoveLabelResponse, + ShareIssueResponse, + UnshareIssueResponse, UpdateIssueResponse, WorkflowStateResponse, ) from linear_python_client.client import DEFAULT_ENDPOINT +from linear_python_client.errors import LinearGraphQLError def gql_response(data: dict) -> httpx.Response: @@ -118,9 +124,7 @@ def test_find_workflow_state_match(client: LinearClient) -> None: @respx.mock def test_find_workflow_state_no_match(client: LinearClient) -> None: - respx.post(DEFAULT_ENDPOINT).mock( - return_value=gql_response({"workflowStates": {"nodes": []}}) - ) + respx.post(DEFAULT_ENDPOINT).mock(return_value=gql_response({"workflowStates": {"nodes": []}})) resp = client.find_workflow_state(FindWorkflowStateRequest(team_id="t1", name="Nope")) assert resp.state is None @@ -191,3 +195,120 @@ def test_issue_detail_collections_default_empty() -> None: assert issue.parent is None assert issue.project is None assert issue.cycle is None + + +# -- issue sharing ---------------------------------------------------------- + + +@respx.mock +def test_share_issue_success(client: LinearClient) -> None: + route = respx.post(DEFAULT_ENDPOINT).mock( + return_value=gql_response( + {"issueShare": {"success": True, "issue": {"id": "i1", "identifier": "SEC-1"}}} + ) + ) + resp = client.share_issue(IssueShareRequest(id="i1", user_id="u1")) + assert isinstance(resp, ShareIssueResponse) + assert resp.success is True + assert resp.issue.identifier == "SEC-1" + body = last_body(route) + assert "issueShare" in body["query"] + assert body["variables"] == {"id": "i1", "userId": "u1"} + + +@respx.mock +def test_unshare_issue_success(client: LinearClient) -> None: + route = respx.post(DEFAULT_ENDPOINT).mock( + return_value=gql_response( + {"issueUnshare": {"success": True, "issue": {"id": "i1", "identifier": "SEC-1"}}} + ) + ) + resp = client.unshare_issue(IssueUnshareRequest(id="i1", user_id="u1")) + assert isinstance(resp, UnshareIssueResponse) + assert resp.success is True + assert resp.issue.identifier == "SEC-1" + body = last_body(route) + assert "issueUnshare" in body["query"] + assert body["variables"] == {"id": "i1", "userId": "u1"} + + +@respx.mock +def test_share_issue_graphql_error_propagates(client: LinearClient) -> None: + respx.post(DEFAULT_ENDPOINT).mock( + return_value=httpx.Response( + 200, + json={ + "errors": [ + { + "message": "Issue sharing is not enabled for this team", + "extensions": {"code": "BAD_USER_INPUT"}, + } + ] + }, + ) + ) + with pytest.raises(LinearGraphQLError): + client.share_issue(IssueShareRequest(id="i1", user_id="u1")) + + +@respx.mock +def test_issue_details_parses_shared_access(client: LinearClient) -> None: + respx.post(DEFAULT_ENDPOINT).mock( + return_value=gql_response( + { + "issue": { + "id": "i1", + "identifier": "SEC-1", + "title": "Shared issue", + "inheritsSharedAccess": False, + "sharedAccess": { + "isShared": True, + "viewerHasOnlySharedAccess": False, + "sharedWithCount": 1, + "sharedWithUsers": [ + {"id": "u1", "name": "Alice", "email": "alice@example.com"} + ], + "disallowedIssueFields": ["teamId"], + }, + } + } + ) + ) + resp = client.issue_details(IssueRequest(id="SEC-1")) + issue = resp.issue + assert isinstance(issue, IssueDetail) + assert issue.inherits_shared_access is False + assert isinstance(issue.shared_access, IssueSharedAccess) + assert issue.shared_access.is_shared is True + assert issue.shared_access.shared_with_count == 1 + assert issue.shared_access.shared_with_users[0].name == "Alice" + assert "teamId" in issue.shared_access.disallowed_issue_fields + + +@respx.mock +def test_issue_details_not_shared(client: LinearClient) -> None: + respx.post(DEFAULT_ENDPOINT).mock( + return_value=gql_response( + { + "issue": { + "id": "i1", + "identifier": "SEC-2", + "title": "Private issue", + "inheritsSharedAccess": False, + "sharedAccess": { + "isShared": False, + "viewerHasOnlySharedAccess": False, + "sharedWithCount": 0, + "sharedWithUsers": [], + "disallowedIssueFields": [], + }, + } + } + ) + ) + resp = client.issue_details(IssueRequest(id="SEC-2")) + issue = resp.issue + assert issue.shared_access is not None + assert issue.shared_access.is_shared is False + assert issue.shared_access.shared_with_count == 0 + assert issue.shared_access.shared_with_users == []