Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<user-uuid>"}}}))

# 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
Expand Down Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions docs/api/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ requested are populated.
- Team
- Issue
- IssueDetail
- IssueSharedAccess
- Project
- Comment
- WorkflowState
Expand Down
2 changes: 2 additions & 0 deletions docs/api/requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ method takes exactly one of these.
- IssueArchiveRequest
- IssueAddLabelRequest
- IssueRemoveLabelRequest
- IssueShareRequest
- IssueUnshareRequest
- IssueSetStateRequest
- FindWorkflowStateRequest
- FindTeamRequest
Expand Down
2 changes: 2 additions & 0 deletions docs/api/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@ The typed result models returned by each
- ArchiveIssueResponse
- AddLabelResponse
- RemoveLabelResponse
- ShareIssueResponse
- UnshareIssueResponse
- CreateCommentResponse
54 changes: 54 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<user-uuid>"}}}))

# 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
Expand Down
112 changes: 92 additions & 20 deletions scripts/smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -41,7 +43,9 @@
IssueRemoveLabelRequest,
IssueRequest,
IssueSetStateRequest,
IssueShareRequest,
IssuesRequest,
IssueUnshareRequest,
IssueUpdateRequest,
LinearClient,
ProjectRequest,
Expand All @@ -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]"
Expand Down Expand Up @@ -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 []
)
Expand Down Expand Up @@ -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:
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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(
Expand All @@ -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=...)",
Expand Down Expand Up @@ -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)")
Expand Down
10 changes: 10 additions & 0 deletions src/linear_python_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
IssueDetail,
IssueLabel,
IssueRelation,
IssueSharedAccess,
LinearModel,
PageInfo,
Project,
Expand All @@ -42,7 +43,9 @@
IssueRemoveLabelRequest,
IssueRequest,
IssueSetStateRequest,
IssueShareRequest,
IssuesRequest,
IssueUnshareRequest,
IssueUpdateRequest,
PaginatedRequest,
ProjectRequest,
Expand All @@ -69,8 +72,10 @@
ProjectResponse,
ProjectsResponse,
RemoveLabelResponse,
ShareIssueResponse,
TeamResponse,
TeamsResponse,
UnshareIssueResponse,
UpdateIssueResponse,
UserResponse,
UsersResponse,
Expand Down Expand Up @@ -100,6 +105,7 @@
"IssueDetail",
"IssueLabel",
"IssueRelation",
"IssueSharedAccess",
"PageInfo",
"Project",
"Team",
Expand All @@ -119,6 +125,8 @@
"IssueAddLabelRequest",
"IssueRemoveLabelRequest",
"IssueSetStateRequest",
"IssueShareRequest",
"IssueUnshareRequest",
"FindWorkflowStateRequest",
"FindTeamRequest",
"FindUserRequest",
Expand Down Expand Up @@ -146,6 +154,8 @@
"ArchiveIssueResponse",
"AddLabelResponse",
"RemoveLabelResponse",
"ShareIssueResponse",
"UnshareIssueResponse",
"ProjectResponse",
"ProjectsResponse",
"CommentResponse",
Expand Down
Loading
Loading