1pub mod http;
2pub mod models;
3pub mod tools;
4
5#[doc(hidden)]
7pub mod test_support;
8
9use anyhow::{Context, Result};
10use cynic::{MutationBuilder, QueryBuilder};
11use http::LinearClient;
12use linear_queries::scalars::DateTimeOrDuration;
13use linear_queries::*;
14use regex::Regex;
15
16pub use tools::build_registry;
18
19fn parse_identifier(input: &str) -> Option<(String, i32)> {
22 let upper = input.to_uppercase();
23 let re = Regex::new(r"([A-Z]{2,10})-(\d{1,10})").unwrap();
24 if let Some(caps) = re.captures(&upper) {
25 let key = caps.get(1)?.as_str().to_string();
26 let num_str = caps.get(2)?.as_str();
27 let number: i32 = num_str.parse().ok()?;
28 return Some((key, number));
29 }
30 None
31}
32
33#[derive(Clone)]
34pub struct LinearTools {
35 api_key: Option<String>,
36}
37
38impl LinearTools {
39 pub fn new() -> Self {
40 Self {
41 api_key: std::env::var("LINEAR_API_KEY").ok(),
42 }
43 }
44
45 fn resolve_issue_id(&self, input: &str) -> IssueIdentifier {
46 if let Some((key, number)) = parse_identifier(input) {
48 return IssueIdentifier::Identifier(format!("{}-{}", key, number));
49 }
50 IssueIdentifier::Id(input.to_string())
52 }
53
54 async fn resolve_to_issue_id(&self, client: &LinearClient, input: &str) -> Result<String> {
57 match self.resolve_issue_id(input) {
58 IssueIdentifier::Id(id) => Ok(id),
59 IssueIdentifier::Identifier(ident) => {
60 let (team_key, number) = parse_identifier(&ident)
61 .ok_or_else(|| anyhow::anyhow!("not found: Issue {} not found", ident))?;
62 let filter = IssueFilter {
63 team: Some(TeamFilter {
64 key: Some(StringComparator {
65 eq: Some(team_key),
66 ..Default::default()
67 }),
68 ..Default::default()
69 }),
70 number: Some(NumberComparator {
71 eq: Some(number as f64),
72 ..Default::default()
73 }),
74 ..Default::default()
75 };
76 let op = IssuesQuery::build(IssuesArguments {
77 first: Some(1),
78 after: None,
79 filter: Some(filter),
80 });
81 let resp = client.run(op).await?;
82 let data = http::extract_data(resp)?;
83 let issue = data
84 .issues
85 .nodes
86 .into_iter()
87 .next()
88 .ok_or_else(|| anyhow::anyhow!("not found: Issue {} not found", ident))?;
89 Ok(issue.id.inner().to_string())
90 }
91 }
92 }
93}
94
95impl Default for LinearTools {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101enum IssueIdentifier {
102 Id(String),
103 Identifier(String),
104}
105
106impl LinearTools {
111 #[allow(clippy::too_many_arguments)]
113 pub async fn search_issues(
114 &self,
115 query: Option<String>,
116 include_comments: Option<bool>,
117 priority: Option<i32>,
118 state_id: Option<String>,
119 assignee_id: Option<String>,
120 team_id: Option<String>,
121 project_id: Option<String>,
122 created_after: Option<String>,
123 created_before: Option<String>,
124 updated_after: Option<String>,
125 updated_before: Option<String>,
126 first: Option<i32>,
127 after: Option<String>,
128 ) -> Result<models::SearchResult> {
129 let client = LinearClient::new(self.api_key.clone())
130 .context("internal: failed to create Linear client")?;
131
132 let mut filter = IssueFilter::default();
134 let mut has_filter = false;
135
136 if let Some(p) = priority {
137 filter.priority = Some(NullableNumberComparator {
138 eq: Some(p as f64),
139 ..Default::default()
140 });
141 has_filter = true;
142 }
143 if let Some(id) = state_id {
144 filter.state = Some(WorkflowStateFilter {
145 id: Some(IdComparator {
146 eq: Some(cynic::Id::new(id)),
147 }),
148 });
149 has_filter = true;
150 }
151 if let Some(id) = assignee_id {
152 filter.assignee = Some(NullableUserFilter {
153 id: Some(IdComparator {
154 eq: Some(cynic::Id::new(id)),
155 }),
156 });
157 has_filter = true;
158 }
159 if let Some(id) = team_id {
160 filter.team = Some(TeamFilter {
161 id: Some(IdComparator {
162 eq: Some(cynic::Id::new(id)),
163 }),
164 ..Default::default()
165 });
166 has_filter = true;
167 }
168 if let Some(id) = project_id {
169 filter.project = Some(NullableProjectFilter {
170 id: Some(IdComparator {
171 eq: Some(cynic::Id::new(id)),
172 }),
173 });
174 has_filter = true;
175 }
176 if created_after.is_some() || created_before.is_some() {
177 filter.created_at = Some(DateComparator {
178 gte: created_after.map(DateTimeOrDuration),
179 lte: created_before.map(DateTimeOrDuration),
180 ..Default::default()
181 });
182 has_filter = true;
183 }
184 if updated_after.is_some() || updated_before.is_some() {
185 filter.updated_at = Some(DateComparator {
186 gte: updated_after.map(DateTimeOrDuration),
187 lte: updated_before.map(DateTimeOrDuration),
188 ..Default::default()
189 });
190 has_filter = true;
191 }
192
193 let filter_opt = if has_filter { Some(filter) } else { None };
194 let page_size = Some(first.unwrap_or(50).clamp(1, 100));
195 let q_trimmed = query.as_ref().map(|s| s.trim()).unwrap_or("");
196
197 if !q_trimmed.is_empty() {
198 let op = SearchIssuesQuery::build(SearchIssuesArguments {
200 term: q_trimmed.to_string(),
201 include_comments: Some(include_comments.unwrap_or(true)),
202 first: page_size,
203 after,
204 filter: filter_opt,
205 });
206 let resp = client.run(op).await?;
207 let data = http::extract_data(resp)?;
208
209 let issues = data
210 .search_issues
211 .nodes
212 .into_iter()
213 .map(|i| models::IssueSummary {
214 id: i.id.inner().to_string(),
215 identifier: i.identifier,
216 title: i.title,
217 state: Some(i.state.name),
218 assignee: i.assignee.map(|u| {
219 if u.display_name.is_empty() {
220 u.name
221 } else {
222 u.display_name
223 }
224 }),
225 priority: Some(i.priority as i32),
226 url: Some(i.url),
227 team_key: Some(i.team.key),
228 updated_at: i.updated_at.0,
229 })
230 .collect();
231
232 Ok(models::SearchResult {
233 issues,
234 has_next_page: data.search_issues.page_info.has_next_page,
235 end_cursor: data.search_issues.page_info.end_cursor,
236 })
237 } else {
238 let op = IssuesQuery::build(IssuesArguments {
240 first: page_size,
241 after,
242 filter: filter_opt,
243 });
244
245 let resp = client.run(op).await?;
246 let data = http::extract_data(resp)?;
247
248 let issues = data
249 .issues
250 .nodes
251 .into_iter()
252 .map(|i| models::IssueSummary {
253 id: i.id.inner().to_string(),
254 identifier: i.identifier,
255 title: i.title,
256 state: i.state.map(|s| s.name),
257 assignee: i.assignee.map(|u| {
258 if u.display_name.is_empty() {
259 u.name
260 } else {
261 u.display_name
262 }
263 }),
264 priority: Some(i.priority as i32),
265 url: Some(i.url),
266 team_key: Some(i.team.key),
267 updated_at: i.updated_at.0,
268 })
269 .collect();
270
271 Ok(models::SearchResult {
272 issues,
273 has_next_page: data.issues.page_info.has_next_page,
274 end_cursor: data.issues.page_info.end_cursor,
275 })
276 }
277 }
278
279 pub async fn read_issue(&self, issue: String) -> Result<models::IssueDetails> {
281 let client = LinearClient::new(self.api_key.clone())
282 .context("internal: failed to create Linear client")?;
283 let resolved = self.resolve_issue_id(&issue);
284
285 let issue_data = match resolved {
286 IssueIdentifier::Id(id) => {
287 let op = IssueByIdQuery::build(IssueByIdArguments { id });
288 let resp = client.run(op).await?;
289 let data = http::extract_data(resp)?;
290 data.issue
291 .ok_or_else(|| anyhow::anyhow!("not found: Issue not found"))?
292 }
293 IssueIdentifier::Identifier(ident) => {
294 let (team_key, number) = parse_identifier(&ident)
296 .ok_or_else(|| anyhow::anyhow!("not found: Issue {} not found", ident))?;
297 let filter = IssueFilter {
298 team: Some(TeamFilter {
299 key: Some(StringComparator {
300 eq: Some(team_key),
301 ..Default::default()
302 }),
303 ..Default::default()
304 }),
305 number: Some(NumberComparator {
306 eq: Some(number as f64),
307 ..Default::default()
308 }),
309 ..Default::default()
310 };
311 let op = IssuesQuery::build(IssuesArguments {
312 first: Some(1),
313 after: None,
314 filter: Some(filter),
315 });
316 let resp = client.run(op).await?;
317 let data = http::extract_data(resp)?;
318 data.issues
319 .nodes
320 .into_iter()
321 .next()
322 .ok_or_else(|| anyhow::anyhow!("not found: Issue {} not found", ident))?
323 }
324 };
325
326 let summary = models::IssueSummary {
327 id: issue_data.id.inner().to_string(),
328 identifier: issue_data.identifier.clone(),
329 title: issue_data.title.clone(),
330 state: issue_data.state.map(|s| s.name),
331 assignee: issue_data.assignee.map(|u| {
332 if u.display_name.is_empty() {
333 u.name
334 } else {
335 u.display_name
336 }
337 }),
338 priority: Some(issue_data.priority as i32),
339 url: Some(issue_data.url.clone()),
340 team_key: Some(issue_data.team.key),
341 updated_at: issue_data.updated_at.0.clone(),
342 };
343
344 Ok(models::IssueDetails {
345 issue: summary,
346 description: issue_data.description,
347 project: issue_data.project.map(|p| p.name),
348 created_at: issue_data.created_at.0,
349 })
350 }
351
352 #[allow(clippy::too_many_arguments)]
354 pub async fn create_issue(
355 &self,
356 team_id: String,
357 title: String,
358 description: Option<String>,
359 priority: Option<i32>,
360 assignee_id: Option<String>,
361 project_id: Option<String>,
362 state_id: Option<String>,
363 parent_id: Option<String>,
364 label_ids: Vec<String>,
365 ) -> Result<models::CreateIssueResult> {
366 let client = LinearClient::new(self.api_key.clone())
367 .context("internal: failed to create Linear client")?;
368
369 let label_ids_opt = if label_ids.is_empty() {
371 None
372 } else {
373 Some(label_ids)
374 };
375
376 let input = IssueCreateInput {
377 team_id,
378 title: Some(title),
379 description,
380 priority,
381 assignee_id,
382 project_id,
383 state_id,
384 parent_id,
385 label_ids: label_ids_opt,
386 };
387
388 let op = IssueCreateMutation::build(IssueCreateArguments { input });
389 let resp = client.run(op).await?;
390 let data = http::extract_data(resp)?;
391
392 let payload = data.issue_create;
393 let issue = payload.issue.map(|i| models::IssueSummary {
394 id: i.id.inner().to_string(),
395 identifier: i.identifier,
396 title: i.title,
397 state: i.state.map(|s| s.name),
398 assignee: i.assignee.map(|u| {
399 if u.display_name.is_empty() {
400 u.name
401 } else {
402 u.display_name
403 }
404 }),
405 priority: Some(i.priority as i32),
406 url: Some(i.url),
407 team_key: Some(i.team.key),
408 updated_at: i.updated_at.0,
409 });
410
411 Ok(models::CreateIssueResult {
412 success: payload.success,
413 issue,
414 })
415 }
416
417 pub async fn add_comment(
419 &self,
420 issue: String,
421 body: String,
422 parent_id: Option<String>,
423 ) -> Result<models::CommentResult> {
424 let client = LinearClient::new(self.api_key.clone())
425 .context("internal: failed to create Linear client")?;
426 let issue_id = self.resolve_to_issue_id(&client, &issue).await?;
427
428 let input = CommentCreateInput {
429 issue_id,
430 body: Some(body),
431 parent_id,
432 };
433
434 let op = CommentCreateMutation::build(CommentCreateArguments { input });
435 let resp = client.run(op).await?;
436 let data = http::extract_data(resp)?;
437
438 let payload = data.comment_create;
439 let (comment_id, body, created_at) = match payload.comment {
440 Some(c) => (
441 Some(c.id.inner().to_string()),
442 Some(c.body),
443 Some(c.created_at.0),
444 ),
445 None => (None, None, None),
446 };
447
448 Ok(models::CommentResult {
449 success: payload.success,
450 comment_id,
451 body,
452 created_at,
453 })
454 }
455}
456
457#[cfg(test)]
460mod tests {
461 use super::parse_identifier;
462
463 #[test]
464 fn parse_plain_uppercase() {
465 assert_eq!(parse_identifier("ENG-245"), Some(("ENG".into(), 245)));
466 }
467
468 #[test]
469 fn parse_lowercase_normalizes() {
470 assert_eq!(parse_identifier("eng-245"), Some(("ENG".into(), 245)));
471 }
472
473 #[test]
474 fn parse_from_url() {
475 assert_eq!(
476 parse_identifier("https://linear.app/foo/issue/eng-245/slug"),
477 Some(("ENG".into(), 245))
478 );
479 }
480
481 #[test]
482 fn parse_invalid_returns_none() {
483 assert_eq!(parse_identifier("invalid"), None);
484 assert_eq!(parse_identifier("ENG-"), None);
485 assert_eq!(parse_identifier("ENG"), None);
486 assert_eq!(parse_identifier("123-456"), None);
487 }
488}