linear-toon-mcp 0.6.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +59 -14
- data/lib/linear_toon_mcp/resolvers/base.rb +217 -0
- data/lib/linear_toon_mcp/resolvers/cycle.rb +11 -0
- data/lib/linear_toon_mcp/resolvers/initiative.rb +10 -0
- data/lib/linear_toon_mcp/resolvers/issue_label.rb +19 -0
- data/lib/linear_toon_mcp/resolvers/project.rb +10 -0
- data/lib/linear_toon_mcp/resolvers/project_milestone.rb +11 -0
- data/lib/linear_toon_mcp/resolvers/project_status.rb +12 -0
- data/lib/linear_toon_mcp/resolvers/team.rb +10 -0
- data/lib/linear_toon_mcp/resolvers/user.rb +24 -0
- data/lib/linear_toon_mcp/resolvers/workflow_state.rb +12 -0
- data/lib/linear_toon_mcp/resolvers.rb +19 -174
- data/lib/linear_toon_mcp/tools/add_project_to_initiative.rb +48 -0
- data/lib/linear_toon_mcp/tools/archive_project.rb +41 -0
- data/lib/linear_toon_mcp/tools/base.rb +76 -0
- data/lib/linear_toon_mcp/tools/create.rb +83 -0
- data/lib/linear_toon_mcp/tools/create_issue_label.rb +48 -0
- data/lib/linear_toon_mcp/tools/delete.rb +79 -0
- data/lib/linear_toon_mcp/tools/delete_comment.rb +34 -0
- data/lib/linear_toon_mcp/tools/delete_initiative.rb +77 -0
- data/lib/linear_toon_mcp/tools/delete_status_update.rb +57 -0
- data/lib/linear_toon_mcp/tools/get.rb +69 -0
- data/lib/linear_toon_mcp/tools/get_initiative.rb +71 -0
- data/lib/linear_toon_mcp/tools/get_issue.rb +1 -18
- data/lib/linear_toon_mcp/tools/get_issue_status.rb +47 -0
- data/lib/linear_toon_mcp/tools/get_project.rb +20 -34
- data/lib/linear_toon_mcp/tools/get_status_update.rb +81 -0
- data/lib/linear_toon_mcp/tools/get_team.rb +45 -0
- data/lib/linear_toon_mcp/tools/get_user.rb +44 -0
- data/lib/linear_toon_mcp/tools/list.rb +58 -0
- data/lib/linear_toon_mcp/tools/list_comments.rb +48 -34
- data/lib/linear_toon_mcp/tools/list_cycles.rb +5 -17
- data/lib/linear_toon_mcp/tools/list_initiatives.rb +124 -0
- data/lib/linear_toon_mcp/tools/list_issue_labels.rb +6 -23
- data/lib/linear_toon_mcp/tools/list_issue_statuses.rb +7 -17
- data/lib/linear_toon_mcp/tools/list_issues.rb +65 -93
- data/lib/linear_toon_mcp/tools/list_projects.rb +6 -23
- data/lib/linear_toon_mcp/tools/list_status_updates.rb +105 -0
- data/lib/linear_toon_mcp/tools/list_teams.rb +1 -17
- data/lib/linear_toon_mcp/tools/list_users.rb +7 -24
- data/lib/linear_toon_mcp/tools/remove_project_from_initiative.rb +62 -0
- data/lib/linear_toon_mcp/tools/save_comment.rb +121 -0
- data/lib/linear_toon_mcp/tools/save_initiative.rb +114 -0
- data/lib/linear_toon_mcp/tools/save_issue.rb +292 -0
- data/lib/linear_toon_mcp/tools/save_project.rb +173 -0
- data/lib/linear_toon_mcp/tools/save_status_update.rb +158 -0
- data/lib/linear_toon_mcp/tools.rb +45 -0
- data/lib/linear_toon_mcp/version.rb +1 -1
- data/lib/linear_toon_mcp.rb +29 -18
- metadata +36 -4
- data/lib/linear_toon_mcp/tools/create_comment.rb +0 -69
- data/lib/linear_toon_mcp/tools/create_issue.rb +0 -173
- data/lib/linear_toon_mcp/tools/update_issue.rb +0 -239
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4548c28ca6deb74cc3ea72d889aad5f931ed4d63265f9d742f8ad54b1af651b9
|
|
4
|
+
data.tar.gz: 8089aab524d5febf2207243b5de33524dc2a264fdc8624a009d1ed3fbf4ce9a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 64044b9580eef34aab49067d721ee0435b38658a6bd99ba5594260457b1424beb4f9f3236de429986a6f099c2c60debfa9a72635538ac095f91f5440b343adf0
|
|
7
|
+
data.tar.gz: 2f679acce91253b8632c913c3be49bb0329aa8a2eb84538205a24ef248fcf7e0448b9ff0b8263087cbc39a993c23cf383f3868868ca94d7691b3c695051ebfbc
|
data/README.md
CHANGED
|
@@ -42,21 +42,66 @@ claude mcp add linear-toon -e LINEAR_API_KEY=lin_api_xxxxx -- linear-toon-mcp
|
|
|
42
42
|
|
|
43
43
|
## Tools
|
|
44
44
|
|
|
45
|
+
Most parameters accept either a UUID or a human identifier (issue identifier like `LIN-123`, team key like `VIB`, project/initiative/label name, user email or `"me"`); the server resolves it. `save_*` tools dispatch by `id` presence — provide an `id` to update, omit it to create. All responses are TOON-encoded.
|
|
46
|
+
|
|
47
|
+
### Issues
|
|
48
|
+
|
|
49
|
+
| Tool | Description |
|
|
50
|
+
|------|-------------|
|
|
51
|
+
| `get_issue` | Retrieve an issue by ID or identifier (e.g., `LIN-123`). Returns the issue plus its parent and direct children. |
|
|
52
|
+
| `list_issues` | List issues with filters (team, assignee, state, label, priority, project, cycle) and cursor pagination. |
|
|
53
|
+
| `save_issue` | Create or update an issue. Accepts names for team, assignee, state, labels, project, cycle, and milestone. Relation params (`blocks`, `relatedTo`, `duplicateOf`) and `parentId` accept UUIDs or identifiers. On create, relations are appended; on update, they replace existing. Null clears nullable fields on update. |
|
|
54
|
+
|
|
55
|
+
### Comments
|
|
56
|
+
|
|
57
|
+
| Tool | Description |
|
|
58
|
+
|------|-------------|
|
|
59
|
+
| `list_comments` | List comments on an issue, project, initiative, or project status update (exactly one parent). Cursor-paginated. |
|
|
60
|
+
| `save_comment` | Create or update a comment. On create, exactly one of `issue` / `project` / `initiative` / `projectUpdate` identifies the parent. Supports threaded replies via `parentId`. |
|
|
61
|
+
| `delete_comment` | Delete a comment by ID. |
|
|
62
|
+
|
|
63
|
+
### Projects
|
|
64
|
+
|
|
65
|
+
| Tool | Description |
|
|
66
|
+
|------|-------------|
|
|
67
|
+
| `list_projects` | List projects, optionally scoped to a team. Returns id, name, and status. |
|
|
68
|
+
| `get_project` | Retrieve a project by name, ID, or slug. Optional includes for members, milestones, and resources. |
|
|
69
|
+
| `save_project` | Create or update a project. On create, `name` and `teams` (one or more) are required. Resolves names to IDs for teams, lead, members, labels, status, and initiative (initiative links on creation only — use `add_project_to_initiative` afterwards). |
|
|
70
|
+
| `archive_project` | Archive a project (recoverable soft delete). Linear has no hard-delete for projects. |
|
|
71
|
+
|
|
72
|
+
### Initiatives
|
|
73
|
+
|
|
74
|
+
| Tool | Description |
|
|
75
|
+
|------|-------------|
|
|
76
|
+
| `list_initiatives` | List initiatives with filters (status, owner, parent initiative, date ranges) and cursor pagination. Optional `includeProjects` adds linked projects. |
|
|
77
|
+
| `get_initiative` | Retrieve an initiative by name or ID. Returns linked projects. Optional `includeSubInitiatives`. |
|
|
78
|
+
| `save_initiative` | Create or update an initiative. Resolves `owner` and `parentInitiative` names to IDs. Exposes both `description` (short summary, ~255 chars) and `content` (long Markdown). |
|
|
79
|
+
| `delete_initiative` | Delete an initiative. `archive: true` soft-deletes via `initiativeArchive`. Hard delete is refused while projects are still linked — unlink first or archive. |
|
|
80
|
+
| `add_project_to_initiative` | Link a project to an initiative. |
|
|
81
|
+
| `remove_project_from_initiative` | Unlink a project from an initiative; finds and deletes the underlying join record. |
|
|
82
|
+
|
|
83
|
+
### Status updates
|
|
84
|
+
|
|
85
|
+
| Tool | Description |
|
|
86
|
+
|------|-------------|
|
|
87
|
+
| `list_status_updates` | List status updates posted to a project or initiative (exactly one parent). Cursor-paginated. |
|
|
88
|
+
| `get_status_update` | Retrieve a status update by ID — transparently fetches whichever parent type owns it. |
|
|
89
|
+
| `save_status_update` | Create or update a status update on a project or initiative. `health` enum: `onTrack` / `atRisk` / `offTrack`. Body is Markdown. |
|
|
90
|
+
| `delete_status_update` | Archive a status update by ID. Linear has no hard-delete for status updates; this maps to `*UpdateArchive`. |
|
|
91
|
+
|
|
92
|
+
### Workspace
|
|
93
|
+
|
|
45
94
|
| Tool | Description |
|
|
46
95
|
|------|-------------|
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `create_issue` | Create a new Linear issue. Accepts human-friendly names for team, assignee, state, labels, project, cycle, and milestone (resolved to IDs automatically; label names resolve against the target team or workspace-wide labels). Relation params (`blocks`, `relatedTo`, `duplicateOf`) and `parentId` accept either issue UUIDs or human identifiers (e.g., `LIN-123`). Supports issue relations and link attachments. |
|
|
57
|
-
| `update_issue` | Update an existing Linear issue by ID. Supports partial updates, null to remove fields, and relation replacement. Relation params (`blocks`, `relatedTo`, `duplicateOf`) and `parentId` accept either issue UUIDs or human identifiers (e.g., `LIN-123`). Label names resolve against the issue's team or workspace-wide labels. |
|
|
58
|
-
| `create_comment` | Create a comment on a Linear issue. Supports Markdown content and threaded replies via parentId. |
|
|
59
|
-
| `list_comments` | List comments for a specific Linear issue in chronological order. Returns each comment's id, body, author, and timestamps. |
|
|
96
|
+
| `list_teams` | List all teams. Returns id, name, and key. |
|
|
97
|
+
| `get_team` | Retrieve a team by id, key (e.g., `VIB`), or name. |
|
|
98
|
+
| `list_users` | List users, optionally scoped to a team. |
|
|
99
|
+
| `get_user` | Retrieve a user by id, name, email, or `"me"`. |
|
|
100
|
+
| `list_cycles` | List cycles for a team. Returns id, name, number, startsAt, endsAt. |
|
|
101
|
+
| `list_issue_statuses` | List workflow states for a team. Returns id, type (`backlog`, `unstarted`, `started`, `completed`, `canceled`, `triage`, `duplicate`), and name. |
|
|
102
|
+
| `get_issue_status` | Retrieve a workflow state by name or UUID, team-scoped. |
|
|
103
|
+
| `list_issue_labels` | List issue labels, optionally scoped to a team. |
|
|
104
|
+
| `create_issue_label` | Create an issue label. Omit `team` for a workspace-wide label. |
|
|
60
105
|
|
|
61
106
|
## Development
|
|
62
107
|
|
|
@@ -70,7 +115,7 @@ bundle exec standardrb # lint
|
|
|
70
115
|
|
|
71
116
|
## Versioning
|
|
72
117
|
|
|
73
|
-
|
|
118
|
+
[Semantic versioning](https://semver.org/). Breaking tool removals or rename go in a major bump, new tools or new optional parameters go in a minor, fixes and internal refactors go in a patch. The single source of truth is `lib/linear_toon_mcp/version.rb`.
|
|
74
119
|
|
|
75
120
|
## Releasing
|
|
76
121
|
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinearToonMcp
|
|
4
|
+
module Resolvers
|
|
5
|
+
# Base class for entity resolvers. Subclasses declare their lookup
|
|
6
|
+
# attributes and any required parent scope. UUIDs always pass through
|
|
7
|
+
# unchanged regardless of the declared attributes.
|
|
8
|
+
#
|
|
9
|
+
# Defaults derive from the class name:
|
|
10
|
+
#
|
|
11
|
+
# WorkflowState.connection_name # => "workflowStates"
|
|
12
|
+
# WorkflowState.filter_type_name # => "WorkflowStateFilter"
|
|
13
|
+
# WorkflowState.entity_label # => "State"
|
|
14
|
+
#
|
|
15
|
+
# Override any default via {.connection}, {.filter_type}, or {.label}.
|
|
16
|
+
class Base
|
|
17
|
+
# Lookup attribute catalog: value predicate paired with GraphQL filter builder.
|
|
18
|
+
ATTRIBUTES = {
|
|
19
|
+
name: {
|
|
20
|
+
matches: ->(_v) { true },
|
|
21
|
+
filter: ->(v) { {name: {eqIgnoreCase: v}} }
|
|
22
|
+
},
|
|
23
|
+
email: {
|
|
24
|
+
matches: ->(v) { v.include?("@") },
|
|
25
|
+
filter: ->(v) { {email: {eq: v}} }
|
|
26
|
+
},
|
|
27
|
+
number: {
|
|
28
|
+
matches: ->(v) { v.match?(NUMERIC_RE) },
|
|
29
|
+
filter: ->(v) { {number: {eq: v.to_i}} }
|
|
30
|
+
},
|
|
31
|
+
slug: {
|
|
32
|
+
matches: ->(_v) { true },
|
|
33
|
+
filter: ->(v) { {slugId: {eqIgnoreCase: v}} }
|
|
34
|
+
},
|
|
35
|
+
key: {
|
|
36
|
+
matches: ->(v) { v.match?(/\A[A-Z]+\z/) },
|
|
37
|
+
filter: ->(v) { {key: {eq: v}} }
|
|
38
|
+
},
|
|
39
|
+
type: {
|
|
40
|
+
matches: ->(v) { v.match?(/\A[a-z]+\z/) },
|
|
41
|
+
filter: ->(v) { {type: {eq: v}} }
|
|
42
|
+
}
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# Declares lookup attributes for this resolver, in priority order.
|
|
47
|
+
#
|
|
48
|
+
# class Team < Base
|
|
49
|
+
# lookup_by :key, :name
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# @param attrs [Array<Symbol>] attribute names from {ATTRIBUTES}
|
|
53
|
+
def lookup_by(*attrs)
|
|
54
|
+
@attributes = attrs.freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Declares a parent-scoping kwarg expected by {.call}. The kwarg name
|
|
58
|
+
# implies the GraphQL filter key — +:team_id+ produces
|
|
59
|
+
# +{team: {id: {eq: value}}}+.
|
|
60
|
+
#
|
|
61
|
+
# class Cycle < Base
|
|
62
|
+
# scoped_by :team_id
|
|
63
|
+
# lookup_by :name
|
|
64
|
+
# end
|
|
65
|
+
#
|
|
66
|
+
# @param key [Symbol] kwarg name passed to {.call}
|
|
67
|
+
# @param optional [Boolean] omit the scope filter when scope arg is nil
|
|
68
|
+
# @param workspace_fallback [Boolean] when set, the scope filter becomes
|
|
69
|
+
# an +or:+ matching either the scoped parent or workspace-level
|
|
70
|
+
# records (parent +null+)
|
|
71
|
+
def scoped_by(key, optional: false, workspace_fallback: false)
|
|
72
|
+
@scope_config = {key: key, optional: optional, workspace_fallback: workspace_fallback}.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Overrides the derived GraphQL connection name.
|
|
76
|
+
def connection(name)
|
|
77
|
+
@connection = name.to_s
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Overrides the derived GraphQL filter type name.
|
|
81
|
+
def filter_type(name)
|
|
82
|
+
@filter_type = name.to_s
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Overrides the derived not-found label.
|
|
86
|
+
def label(name)
|
|
87
|
+
@label = name.to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the attributes declared via {.lookup_by}.
|
|
91
|
+
def attributes
|
|
92
|
+
@attributes || []
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
attr_reader :scope_config
|
|
96
|
+
|
|
97
|
+
# Returns the GraphQL connection name.
|
|
98
|
+
#
|
|
99
|
+
# WorkflowState.connection_name # => "workflowStates"
|
|
100
|
+
def connection_name
|
|
101
|
+
@connection ||= "#{entity_name[0].downcase}#{entity_name[1..]}s"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns the GraphQL filter input type name.
|
|
105
|
+
#
|
|
106
|
+
# WorkflowState.filter_type_name # => "WorkflowStateFilter"
|
|
107
|
+
def filter_type_name
|
|
108
|
+
@filter_type ||= "#{entity_name}Filter"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns the not-found label — the trailing CamelCase word of
|
|
112
|
+
# {.entity_name}.
|
|
113
|
+
#
|
|
114
|
+
# WorkflowState.entity_label # => "State"
|
|
115
|
+
def entity_label
|
|
116
|
+
@label ||= entity_name.scan(/[A-Z][a-z]+/).last || entity_name
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Returns the entity name.
|
|
120
|
+
#
|
|
121
|
+
# WorkflowState.entity_name # => "WorkflowState"
|
|
122
|
+
def entity_name
|
|
123
|
+
@entity_name ||= name.split("::").last
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Returns the memoized GraphQL query.
|
|
127
|
+
def query
|
|
128
|
+
@query ||= <<~GRAPHQL
|
|
129
|
+
query($filter: #{filter_type_name}) {
|
|
130
|
+
#{connection_name}(filter: $filter, first: 1) { nodes { id } }
|
|
131
|
+
}
|
|
132
|
+
GRAPHQL
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Resolves +value+ to a UUID using {LinearToonMcp.client}.
|
|
136
|
+
#
|
|
137
|
+
# Team.call(value: "Engineering")
|
|
138
|
+
# WorkflowState.call(value: "Done", team_id: tid)
|
|
139
|
+
#
|
|
140
|
+
# @param value [String]
|
|
141
|
+
# @param scope [Hash] parent-scope kwargs (e.g. +team_id:+)
|
|
142
|
+
# @return [String] resolved UUID
|
|
143
|
+
# @raise [Error] when no attribute resolves the value
|
|
144
|
+
def call(value:, **scope)
|
|
145
|
+
new(**scope).resolve(value)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Resolves each value via {.call}, forwarding scope.
|
|
149
|
+
#
|
|
150
|
+
# IssueLabel.call_many(values: ["bug", "p1"], team_id: tid)
|
|
151
|
+
#
|
|
152
|
+
# @return [Array<String>]
|
|
153
|
+
def call_many(values:, **scope)
|
|
154
|
+
values.map { |v| call(value: v, **scope) }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def initialize(**scope)
|
|
159
|
+
@scope = scope
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Resolves +value+ to a UUID. UUIDs pass through unchanged; otherwise
|
|
163
|
+
# each {.lookup_by} attribute is tried in declared order and the first
|
|
164
|
+
# GraphQL lookup that returns a node wins.
|
|
165
|
+
#
|
|
166
|
+
# @raise [Error] when nothing resolves +value+
|
|
167
|
+
def resolve(value)
|
|
168
|
+
return value if value.match?(UUID_RE)
|
|
169
|
+
|
|
170
|
+
self.class.attributes.each do |attr|
|
|
171
|
+
definition = ATTRIBUTES.fetch(attr) { raise Error, "Unknown attribute: #{attr.inspect}" }
|
|
172
|
+
next unless definition[:matches].call(value)
|
|
173
|
+
|
|
174
|
+
id = lookup(definition[:filter].call(value).merge(scope_filter))
|
|
175
|
+
return id if id
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
raise Error, not_found_message(value)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
attr_reader :scope
|
|
184
|
+
|
|
185
|
+
def client
|
|
186
|
+
LinearToonMcp.client
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def scope_filter
|
|
190
|
+
cfg = self.class.scope_config
|
|
191
|
+
return {} unless cfg
|
|
192
|
+
|
|
193
|
+
scope_id = scope[cfg[:key]]
|
|
194
|
+
if scope_id.nil?
|
|
195
|
+
return {} if cfg[:optional]
|
|
196
|
+
raise Error, "Missing required scope: #{cfg[:key]}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
parent_field = cfg[:key].to_s.sub(/_id\z/, "").to_sym
|
|
200
|
+
if cfg[:workspace_fallback]
|
|
201
|
+
{or: [{parent_field => {null: true}}, {parent_field => {id: {eq: scope_id}}}]}
|
|
202
|
+
else
|
|
203
|
+
{parent_field => {id: {eq: scope_id}}}
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def lookup(filter)
|
|
208
|
+
data = client.query(self.class.query, variables: {filter:})
|
|
209
|
+
data.dig(self.class.connection_name, "nodes", 0, "id")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def not_found_message(value)
|
|
213
|
+
"#{self.class.entity_label} not found: #{value}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinearToonMcp
|
|
4
|
+
module Resolvers
|
|
5
|
+
# Resolves a Linear issue label by name. Optionally scoped to a team;
|
|
6
|
+
# when scoped, matches either the team's labels or workspace-wide labels
|
|
7
|
+
# (team +null+).
|
|
8
|
+
class IssueLabel < Base
|
|
9
|
+
scoped_by :team_id, optional: true, workspace_fallback: true
|
|
10
|
+
lookup_by :name
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def not_found_message(value)
|
|
15
|
+
scope[:team_id] ? "Label not found on target team or workspace: #{value}" : super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinearToonMcp
|
|
4
|
+
module Resolvers
|
|
5
|
+
# Resolves a Linear project status by name (workspace-scoped).
|
|
6
|
+
class ProjectStatus < Base
|
|
7
|
+
connection :projectStatuses
|
|
8
|
+
filter_type :ProjectStatusFilter
|
|
9
|
+
lookup_by :name
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinearToonMcp
|
|
4
|
+
module Resolvers
|
|
5
|
+
# Resolves a Linear user by email, name, or the literal +"me"+.
|
|
6
|
+
class User < Base
|
|
7
|
+
VIEWER_QUERY = "query { viewer { id } }"
|
|
8
|
+
|
|
9
|
+
lookup_by :email, :name
|
|
10
|
+
|
|
11
|
+
def resolve(value)
|
|
12
|
+
return resolve_viewer if value == "me"
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def resolve_viewer
|
|
19
|
+
data = client.query(VIEWER_QUERY)
|
|
20
|
+
data.dig("viewer", "id") || raise(Error, "Could not resolve current user")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,182 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module LinearToonMcp
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# Resolvers translate human-friendly identifiers — UUIDs, names, emails,
|
|
5
|
+
# slugs, numbers, the literal "me" — into Linear API UUIDs. They read
|
|
6
|
+
# {LinearToonMcp.client} for API calls.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# Resolvers::Team.call(value: "Engineering")
|
|
10
|
+
# Resolvers::WorkflowState.call(value: "In Progress", team_id: tid)
|
|
11
|
+
# Resolvers::IssueLabel.call_many(values: ["bug", "p1"], team_id: tid)
|
|
6
12
|
module Resolvers
|
|
7
13
|
UUID_RE = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
8
14
|
NUMERIC_RE = /\A\d+\z/
|
|
9
|
-
|
|
10
|
-
TEAM_QUERY = <<~GRAPHQL
|
|
11
|
-
query($filter: TeamFilter) {
|
|
12
|
-
teams(filter: $filter, first: 1) { nodes { id } }
|
|
13
|
-
}
|
|
14
|
-
GRAPHQL
|
|
15
|
-
|
|
16
|
-
VIEWER_QUERY = "query { viewer { id } }"
|
|
17
|
-
|
|
18
|
-
USER_QUERY = <<~GRAPHQL
|
|
19
|
-
query($filter: UserFilter) {
|
|
20
|
-
users(filter: $filter, first: 1) { nodes { id } }
|
|
21
|
-
}
|
|
22
|
-
GRAPHQL
|
|
23
|
-
|
|
24
|
-
STATE_QUERY = <<~GRAPHQL
|
|
25
|
-
query($filter: WorkflowStateFilter) {
|
|
26
|
-
workflowStates(filter: $filter, first: 1) { nodes { id } }
|
|
27
|
-
}
|
|
28
|
-
GRAPHQL
|
|
29
|
-
|
|
30
|
-
LABEL_QUERY = <<~GRAPHQL
|
|
31
|
-
query($filter: IssueLabelFilter) {
|
|
32
|
-
issueLabels(filter: $filter, first: 1) { nodes { id } }
|
|
33
|
-
}
|
|
34
|
-
GRAPHQL
|
|
35
|
-
|
|
36
|
-
PROJECT_QUERY = <<~GRAPHQL
|
|
37
|
-
query($filter: ProjectFilter) {
|
|
38
|
-
projects(filter: $filter, first: 1) { nodes { id } }
|
|
39
|
-
}
|
|
40
|
-
GRAPHQL
|
|
41
|
-
|
|
42
|
-
CYCLE_QUERY = <<~GRAPHQL
|
|
43
|
-
query($filter: CycleFilter) {
|
|
44
|
-
cycles(filter: $filter, first: 1) { nodes { id } }
|
|
45
|
-
}
|
|
46
|
-
GRAPHQL
|
|
47
|
-
|
|
48
|
-
MILESTONE_QUERY = <<~GRAPHQL
|
|
49
|
-
query($filter: ProjectMilestoneFilter) {
|
|
50
|
-
projectMilestones(filter: $filter, first: 1) { nodes { id } }
|
|
51
|
-
}
|
|
52
|
-
GRAPHQL
|
|
53
|
-
|
|
54
|
-
module_function
|
|
55
|
-
|
|
56
|
-
# @param client [Client]
|
|
57
|
-
# @param value [String] team UUID or name
|
|
58
|
-
# @return [String] team UUID
|
|
59
|
-
# @raise [Error] when team not found
|
|
60
|
-
def resolve_team(client, value)
|
|
61
|
-
return value if value.match?(UUID_RE)
|
|
62
|
-
data = client.query(TEAM_QUERY, variables: {filter: {name: {eqIgnoreCase: value}}})
|
|
63
|
-
data.dig("teams", "nodes", 0, "id") or raise Error, "Team not found: #{value}"
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# @param client [Client]
|
|
67
|
-
# @param value [String] user UUID, "me", email, or name
|
|
68
|
-
# @return [String] user UUID
|
|
69
|
-
# @raise [Error] when user not found
|
|
70
|
-
def resolve_user(client, value)
|
|
71
|
-
return value if value.match?(UUID_RE)
|
|
72
|
-
|
|
73
|
-
if value == "me"
|
|
74
|
-
data = client.query(VIEWER_QUERY)
|
|
75
|
-
return data.dig("viewer", "id") || raise(Error, "Could not resolve current user")
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
filter = value.include?("@") ? {email: {eq: value}} : {name: {eqIgnoreCase: value}}
|
|
79
|
-
data = client.query(USER_QUERY, variables: {filter:})
|
|
80
|
-
data.dig("users", "nodes", 0, "id") or raise Error, "User not found: #{value}"
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# @param client [Client]
|
|
84
|
-
# @param team_id [String] team UUID (for scoping)
|
|
85
|
-
# @param value [String] state UUID or name
|
|
86
|
-
# @return [String] state UUID
|
|
87
|
-
# @raise [Error] when state not found
|
|
88
|
-
def resolve_state(client, team_id, value)
|
|
89
|
-
return value if value.match?(UUID_RE)
|
|
90
|
-
filter = {name: {eqIgnoreCase: value}, team: {id: {eq: team_id}}}
|
|
91
|
-
data = client.query(STATE_QUERY, variables: {filter:})
|
|
92
|
-
data.dig("workflowStates", "nodes", 0, "id") or raise Error, "State not found: #{value}"
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Resolve a single label name or UUID to its UUID.
|
|
96
|
-
# When +team_id+ is supplied, restricts the lookup to labels scoped to that
|
|
97
|
-
# team or to workspace-wide labels (where the label's team relation is null),
|
|
98
|
-
# so a name like "Bug" matches the right team-scoped label and not a same-named
|
|
99
|
-
# label on another team.
|
|
100
|
-
# @param client [Client]
|
|
101
|
-
# @param value [String] label UUID or name
|
|
102
|
-
# @param team_id [String, nil] optional team UUID to scope the lookup
|
|
103
|
-
# @return [String] label UUID
|
|
104
|
-
# @raise [Error] when label not found
|
|
105
|
-
def resolve_label(client, value, team_id: nil)
|
|
106
|
-
return value if value.match?(UUID_RE)
|
|
107
|
-
|
|
108
|
-
filter = {name: {eqIgnoreCase: value}}
|
|
109
|
-
if team_id
|
|
110
|
-
filter[:or] = [
|
|
111
|
-
{team: {null: true}},
|
|
112
|
-
{team: {id: {eq: team_id}}}
|
|
113
|
-
]
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
data = client.query(LABEL_QUERY, variables: {filter:})
|
|
117
|
-
id = data.dig("issueLabels", "nodes", 0, "id")
|
|
118
|
-
return id if id
|
|
119
|
-
|
|
120
|
-
raise Error, label_not_found_message(value, team_id)
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# @param client [Client]
|
|
124
|
-
# @param values [Array<String>] label UUIDs or names
|
|
125
|
-
# @param team_id [String, nil] optional team UUID to scope the lookup
|
|
126
|
-
# @return [Array<String>] label UUIDs
|
|
127
|
-
def resolve_labels(client, values, team_id: nil)
|
|
128
|
-
values.map { |v| resolve_label(client, v, team_id:) }
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def label_not_found_message(value, team_id)
|
|
132
|
-
return "Label not found: #{value}" unless team_id
|
|
133
|
-
"Label not found on target team or workspace: #{value}"
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# @param client [Client]
|
|
137
|
-
# @param value [String] project UUID, name, or slug
|
|
138
|
-
# @return [String] project UUID
|
|
139
|
-
# @raise [Error] when project not found
|
|
140
|
-
def resolve_project(client, value)
|
|
141
|
-
return value if value.match?(UUID_RE)
|
|
142
|
-
|
|
143
|
-
# Try name first, then slug
|
|
144
|
-
data = client.query(PROJECT_QUERY, variables: {filter: {name: {eqIgnoreCase: value}}})
|
|
145
|
-
id = data.dig("projects", "nodes", 0, "id")
|
|
146
|
-
return id if id
|
|
147
|
-
|
|
148
|
-
data = client.query(PROJECT_QUERY, variables: {filter: {slugId: {eqIgnoreCase: value}}})
|
|
149
|
-
data.dig("projects", "nodes", 0, "id") or raise Error, "Project not found: #{value}"
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# @param client [Client]
|
|
153
|
-
# @param team_id [String] team UUID (for scoping)
|
|
154
|
-
# @param value [String] cycle UUID, number, or name
|
|
155
|
-
# @return [String] cycle UUID
|
|
156
|
-
# @raise [Error] when cycle not found
|
|
157
|
-
def resolve_cycle(client, team_id, value)
|
|
158
|
-
return value if value.match?(UUID_RE)
|
|
159
|
-
|
|
160
|
-
filter = if value.match?(NUMERIC_RE)
|
|
161
|
-
{number: {eq: value.to_i}, team: {id: {eq: team_id}}}
|
|
162
|
-
else
|
|
163
|
-
{name: {eqIgnoreCase: value}, team: {id: {eq: team_id}}}
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
data = client.query(CYCLE_QUERY, variables: {filter:})
|
|
167
|
-
data.dig("cycles", "nodes", 0, "id") or raise Error, "Cycle not found: #{value}"
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
# @param client [Client]
|
|
171
|
-
# @param project_id [String] project UUID (for scoping)
|
|
172
|
-
# @param value [String] milestone UUID or name
|
|
173
|
-
# @return [String] milestone UUID
|
|
174
|
-
# @raise [Error] when milestone not found
|
|
175
|
-
def resolve_milestone(client, project_id, value)
|
|
176
|
-
return value if value.match?(UUID_RE)
|
|
177
|
-
filter = {name: {eqIgnoreCase: value}, project: {id: {eq: project_id}}}
|
|
178
|
-
data = client.query(MILESTONE_QUERY, variables: {filter:})
|
|
179
|
-
data.dig("projectMilestones", "nodes", 0, "id") or raise Error, "Milestone not found: #{value}"
|
|
180
|
-
end
|
|
181
15
|
end
|
|
182
16
|
end
|
|
17
|
+
|
|
18
|
+
require_relative "resolvers/base"
|
|
19
|
+
require_relative "resolvers/team"
|
|
20
|
+
require_relative "resolvers/user"
|
|
21
|
+
require_relative "resolvers/workflow_state"
|
|
22
|
+
require_relative "resolvers/issue_label"
|
|
23
|
+
require_relative "resolvers/project"
|
|
24
|
+
require_relative "resolvers/cycle"
|
|
25
|
+
require_relative "resolvers/project_milestone"
|
|
26
|
+
require_relative "resolvers/initiative"
|
|
27
|
+
require_relative "resolvers/project_status"
|