1use crate::config::Config;
8use crate::db::Database;
9use crate::linear::LinearClient;
10use crate::search;
11use std::path::Path;
12use std::sync::Mutex;
13use tokio::sync::OnceCell;
14
15#[derive(Debug, thiserror::Error, uniffi::Error)]
18pub enum RectilinearError {
19 #[error("Database error: {message}")]
20 Database { message: String },
21 #[error("API error: {message}")]
22 Api { message: String },
23 #[error("Config error: {message}")]
24 Config { message: String },
25 #[error("Not found: {key}")]
26 NotFound { key: String },
27}
28
29impl From<anyhow::Error> for RectilinearError {
30 fn from(err: anyhow::Error) -> Self {
31 RectilinearError::Database {
32 message: err.to_string(),
33 }
34 }
35}
36
37#[derive(uniffi::Record)]
40pub struct RtIssue {
41 pub id: String,
42 pub identifier: String,
43 pub team_key: String,
44 pub title: String,
45 pub description: Option<String>,
46 pub state_name: String,
47 pub state_type: String,
48 pub priority: i32,
49 pub assignee_name: Option<String>,
50 pub project_name: Option<String>,
51 pub project_id: Option<String>,
52 pub project_milestone_id: Option<String>,
53 pub project_milestone_name: Option<String>,
54 pub cycle_id: Option<String>,
55 pub cycle_name: Option<String>,
56 pub labels: Vec<String>,
57 pub created_at: String,
58 pub updated_at: String,
59 pub url: String,
60 pub branch_name: Option<String>,
61 pub archived_at: Option<String>,
63}
64
65impl From<crate::db::Issue> for RtIssue {
66 fn from(issue: crate::db::Issue) -> Self {
67 let labels: Vec<String> = serde_json::from_str(&issue.labels_json).unwrap_or_default();
68 Self {
69 id: issue.id,
70 identifier: issue.identifier,
71 team_key: issue.team_key,
72 title: issue.title,
73 description: issue.description,
74 state_name: issue.state_name,
75 state_type: issue.state_type,
76 priority: issue.priority,
77 assignee_name: issue.assignee_name,
78 project_name: issue.project_name,
79 project_id: issue.project_id,
80 project_milestone_id: issue.project_milestone_id,
81 project_milestone_name: issue.project_milestone_name,
82 cycle_id: issue.cycle_id,
83 cycle_name: issue.cycle_name,
84 labels,
85 created_at: issue.created_at,
86 updated_at: issue.updated_at,
87 url: issue.url,
88 branch_name: issue.branch_name,
89 archived_at: issue.archived_at,
90 }
91 }
92}
93
94#[derive(uniffi::Record)]
95pub struct RtSearchResult {
96 pub issue_id: String,
97 pub identifier: String,
98 pub title: String,
99 pub state_name: String,
100 pub priority: i32,
101 pub score: f64,
102 pub similarity: Option<f32>,
103}
104
105impl From<search::SearchResult> for RtSearchResult {
106 fn from(sr: search::SearchResult) -> Self {
107 Self {
108 issue_id: sr.issue_id,
109 identifier: sr.identifier,
110 title: sr.title,
111 state_name: sr.state_name,
112 priority: sr.priority,
113 score: sr.score,
114 similarity: sr.similarity,
115 }
116 }
117}
118
119#[derive(uniffi::Record)]
120pub struct RtRelation {
121 pub relation_type: String,
122 pub issue_identifier: String,
123 pub issue_title: String,
124 pub issue_state: String,
125 pub issue_url: String,
126}
127
128impl From<crate::db::EnrichedRelation> for RtRelation {
129 fn from(rel: crate::db::EnrichedRelation) -> Self {
130 Self {
131 relation_type: rel.relation_type,
132 issue_identifier: rel.issue_identifier,
133 issue_title: rel.issue_title,
134 issue_state: rel.issue_state,
135 issue_url: rel.issue_url,
136 }
137 }
138}
139
140#[derive(uniffi::Record)]
141pub struct RtBlocker {
142 pub identifier: String,
143 pub title: String,
144 pub state_name: String,
145 pub is_terminal: bool,
146}
147
148#[derive(uniffi::Record)]
149pub struct RtIssueEnriched {
150 pub id: String,
151 pub identifier: String,
152 pub team_key: String,
153 pub title: String,
154 pub description: Option<String>,
155 pub state_name: String,
156 pub state_type: String,
157 pub priority: i32,
158 pub assignee_name: Option<String>,
159 pub project_name: Option<String>,
160 pub project_id: Option<String>,
161 pub project_milestone_id: Option<String>,
162 pub project_milestone_name: Option<String>,
163 pub cycle_id: Option<String>,
164 pub cycle_name: Option<String>,
165 pub labels: Vec<String>,
166 pub created_at: String,
167 pub updated_at: String,
168 pub url: String,
169 pub branch_name: Option<String>,
170 pub blocked_by: Vec<RtBlocker>,
171}
172
173#[derive(uniffi::Record)]
174pub struct RtTeam {
175 pub id: String,
176 pub key: String,
177 pub name: String,
178}
179
180#[derive(uniffi::Enum)]
181pub enum RtSearchMode {
182 Fts,
183 Vector,
184 Hybrid,
185}
186
187#[derive(uniffi::Record)]
188pub struct RtFieldCompleteness {
189 pub total: u64,
190 pub with_description: u64,
191 pub with_priority: u64,
192 pub with_labels: u64,
193 pub with_project: u64,
194}
195
196#[derive(uniffi::Record)]
197pub struct RtIssueSummary {
198 pub id: String,
199 pub identifier: String,
200 pub team_key: String,
201 pub title: String,
202 pub state_name: String,
203 pub state_type: String,
204 pub priority: i32,
205 pub project_name: Option<String>,
206 pub labels: Vec<String>,
207 pub updated_at: String,
208 pub url: String,
209 pub has_description: bool,
210 pub has_embedding: bool,
211}
212
213impl From<crate::db::IssueSummary> for RtIssueSummary {
214 fn from(s: crate::db::IssueSummary) -> Self {
215 Self {
216 id: s.id,
217 identifier: s.identifier,
218 team_key: s.team_key,
219 title: s.title,
220 state_name: s.state_name,
221 state_type: s.state_type,
222 priority: s.priority,
223 project_name: s.project_name,
224 labels: s.labels,
225 updated_at: s.updated_at,
226 url: s.url,
227 has_description: s.has_description,
228 has_embedding: s.has_embedding,
229 }
230 }
231}
232
233#[derive(uniffi::Record)]
234pub struct RtTeamSummary {
235 pub key: String,
236 pub issue_count: u64,
237 pub embedded_count: u64,
238 pub last_synced_at: Option<String>,
239}
240
241#[derive(uniffi::Record)]
242pub struct RtCreateIssueResult {
243 pub id: String,
244 pub identifier: String,
245}
246
247#[derive(uniffi::Record)]
248pub struct RtCreateIssueInput {
249 pub team_key: String,
250 pub title: String,
251 pub description: Option<String>,
252 pub priority: Option<i32>,
253 pub label_ids: Vec<String>,
254 pub parent_id: Option<String>,
255 pub project_id: Option<String>,
256 pub project_milestone_id: Option<String>,
257}
258
259#[derive(uniffi::Record)]
260pub struct RtProjectTeam {
261 pub id: String,
262 pub key: String,
263 pub name: String,
264}
265
266#[derive(uniffi::Record)]
267pub struct RtProjectMember {
268 pub id: String,
269 pub name: String,
270}
271
272#[derive(uniffi::Record)]
273pub struct RtProjectLabel {
274 pub id: String,
275 pub name: String,
276 pub color: String,
277 pub description: Option<String>,
278}
279
280#[derive(uniffi::Record)]
281pub struct RtProject {
282 pub id: String,
283 pub workspace_id: String,
284 pub slug_id: String,
285 pub name: String,
286 pub description: String,
287 pub content: Option<String>,
288 pub icon: Option<String>,
289 pub color: String,
290 pub status_id: String,
291 pub status_name: String,
292 pub status_type: String,
293 pub status_color: String,
294 pub priority: i32,
295 pub start_date: Option<String>,
296 pub target_date: Option<String>,
297 pub lead_id: Option<String>,
298 pub lead_name: Option<String>,
299 pub created_at: String,
300 pub updated_at: String,
301 pub archived_at: Option<String>,
302 pub url: String,
303 pub progress: f64,
304 pub teams: Vec<RtProjectTeam>,
305 pub members: Vec<RtProjectMember>,
306 pub labels: Vec<RtProjectLabel>,
307}
308
309impl From<crate::db::Project> for RtProject {
310 fn from(project: crate::db::Project) -> Self {
311 Self {
312 id: project.id,
313 workspace_id: project.workspace_id,
314 slug_id: project.slug_id,
315 name: project.name,
316 description: project.description,
317 content: project.content,
318 icon: project.icon,
319 color: project.color,
320 status_id: project.status_id,
321 status_name: project.status_name,
322 status_type: project.status_type,
323 status_color: project.status_color,
324 priority: project.priority,
325 start_date: project.start_date,
326 target_date: project.target_date,
327 lead_id: project.lead_id,
328 lead_name: project.lead_name,
329 created_at: project.created_at,
330 updated_at: project.updated_at,
331 archived_at: project.archived_at,
332 url: project.url,
333 progress: project.progress,
334 teams: project
335 .teams
336 .into_iter()
337 .map(|team| RtProjectTeam {
338 id: team.id,
339 key: team.key,
340 name: team.name,
341 })
342 .collect(),
343 members: project
344 .members
345 .into_iter()
346 .map(|member| RtProjectMember {
347 id: member.id,
348 name: member.name,
349 })
350 .collect(),
351 labels: project
352 .labels
353 .into_iter()
354 .map(|label| RtProjectLabel {
355 id: label.id,
356 name: label.name,
357 color: label.color,
358 description: label.description,
359 })
360 .collect(),
361 }
362 }
363}
364
365#[derive(uniffi::Record)]
366pub struct RtProjectMilestone {
367 pub id: String,
368 pub workspace_id: String,
369 pub project_id: String,
370 pub project_name: String,
371 pub name: String,
372 pub description: Option<String>,
373 pub target_date: Option<String>,
374 pub status: String,
375 pub progress: f64,
376 pub sort_order: f64,
377 pub created_at: String,
378 pub updated_at: String,
379 pub archived_at: Option<String>,
380}
381
382impl From<crate::db::ProjectMilestone> for RtProjectMilestone {
383 fn from(milestone: crate::db::ProjectMilestone) -> Self {
384 Self {
385 id: milestone.id,
386 workspace_id: milestone.workspace_id,
387 project_id: milestone.project_id,
388 project_name: milestone.project_name,
389 name: milestone.name,
390 description: milestone.description,
391 target_date: milestone.target_date,
392 status: milestone.status,
393 progress: milestone.progress,
394 sort_order: milestone.sort_order,
395 created_at: milestone.created_at,
396 updated_at: milestone.updated_at,
397 archived_at: milestone.archived_at,
398 }
399 }
400}
401
402#[derive(uniffi::Record)]
403pub struct RtProjectBundle {
404 pub project: RtProject,
405 pub milestones: Vec<RtProjectMilestone>,
406 pub issues: Vec<RtIssue>,
407}
408
409impl From<crate::db::ProjectBundle> for RtProjectBundle {
410 fn from(bundle: crate::db::ProjectBundle) -> Self {
411 Self {
412 project: bundle.project.into(),
413 milestones: bundle.milestones.into_iter().map(Into::into).collect(),
414 issues: bundle.issues.into_iter().map(Into::into).collect(),
415 }
416 }
417}
418
419#[derive(uniffi::Record)]
420pub struct RtProjectMilestoneBundle {
421 pub project: RtProject,
422 pub milestone: RtProjectMilestone,
423 pub issues: Vec<RtIssue>,
424}
425
426impl From<crate::db::ProjectMilestoneBundle> for RtProjectMilestoneBundle {
427 fn from(bundle: crate::db::ProjectMilestoneBundle) -> Self {
428 Self {
429 project: bundle.project.into(),
430 milestone: bundle.milestone.into(),
431 issues: bundle.issues.into_iter().map(Into::into).collect(),
432 }
433 }
434}
435
436#[derive(uniffi::Record)]
437pub struct RtProjectSyncResult {
438 pub projects: u64,
439 pub milestones: u64,
440}
441
442#[derive(uniffi::Record)]
443pub struct RtCreateProjectInput {
444 pub name: String,
445 pub team_ids: Vec<String>,
446 pub description: Option<String>,
447 pub content: Option<String>,
448 pub icon: Option<String>,
449 pub color: Option<String>,
450 pub status_id: Option<String>,
451 pub priority: Option<i32>,
452 pub lead_id: Option<String>,
453 pub start_date: Option<String>,
454 pub target_date: Option<String>,
455 pub member_ids: Option<Vec<String>>,
456 pub label_ids: Option<Vec<String>>,
457}
458
459#[derive(uniffi::Record)]
460pub struct RtUpdateProjectInput {
461 pub name: Option<String>,
462 pub team_ids: Option<Vec<String>>,
463 pub description: Option<String>,
464 pub content: Option<String>,
465 pub icon: Option<String>,
466 pub color: Option<String>,
467 pub status_id: Option<String>,
468 pub priority: Option<i32>,
469 pub lead_id: Option<String>,
470 pub start_date: Option<String>,
471 pub target_date: Option<String>,
472 pub member_ids: Option<Vec<String>>,
473 pub label_ids: Option<Vec<String>>,
474}
475
476#[derive(uniffi::Record)]
477pub struct RtCreateProjectMilestoneInput {
478 pub project_id: String,
479 pub name: String,
480 pub description: Option<String>,
481 pub target_date: Option<String>,
482 pub sort_order: Option<f64>,
483}
484
485#[derive(uniffi::Record)]
486pub struct RtUpdateProjectMilestoneInput {
487 pub project_id: Option<String>,
488 pub name: Option<String>,
489 pub description: Option<String>,
490 pub target_date: Option<String>,
491 pub sort_order: Option<f64>,
492}
493
494#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
495pub enum RtSyncPhase {
496 FetchingIssues,
497 GeneratingEmbeddings,
498 IndexingIssues,
499 IndexComplete,
500 HydratingIssueDetails,
501 HydratingLabels,
502 HydratingRelations,
503 HydratingComments,
504 WaitingForRateLimitRetry,
505}
506
507#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
508pub struct RtSyncProgress {
509 pub phase: RtSyncPhase,
510 pub completed: u64,
511 pub total: Option<u64>,
512 pub issue_id: Option<String>,
513}
514
515#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
516pub enum RtHydrationPolicy {
517 OpenOnly,
518 OpenAndRecent,
519 All,
520}
521
522impl From<RtHydrationPolicy> for crate::db::HydrationPolicy {
523 fn from(value: RtHydrationPolicy) -> Self {
524 match value {
525 RtHydrationPolicy::OpenOnly => Self::OpenOnly,
526 RtHydrationPolicy::OpenAndRecent => Self::OpenAndRecent,
527 RtHydrationPolicy::All => Self::All,
528 }
529 }
530}
531
532#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
533pub enum RtHydrationMode {
534 IfNeeded,
535 ForceRefresh,
536}
537
538impl From<RtHydrationMode> for crate::db::HydrationMode {
539 fn from(value: RtHydrationMode) -> Self {
540 match value {
541 RtHydrationMode::IfNeeded => Self::IfNeeded,
542 RtHydrationMode::ForceRefresh => Self::ForceRefresh,
543 }
544 }
545}
546
547#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
548pub enum RtHydrationResource {
549 Details,
550 Labels,
551 Relations,
552 Comments,
553}
554
555impl From<crate::db::HydrationResource> for RtHydrationResource {
556 fn from(value: crate::db::HydrationResource) -> Self {
557 match value {
558 crate::db::HydrationResource::Details => Self::Details,
559 crate::db::HydrationResource::Labels => Self::Labels,
560 crate::db::HydrationResource::Relations => Self::Relations,
561 crate::db::HydrationResource::Comments => Self::Comments,
562 }
563 }
564}
565
566#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
567pub enum RtHydrationStatus {
568 Pending,
569 Running,
570 Hydrated,
571 Partial,
572 Retryable,
573 PermissionDenied,
574 Unavailable,
575}
576
577impl From<crate::db::HydrationStatus> for RtHydrationStatus {
578 fn from(value: crate::db::HydrationStatus) -> Self {
579 match value {
580 crate::db::HydrationStatus::Pending => Self::Pending,
581 crate::db::HydrationStatus::Running => Self::Running,
582 crate::db::HydrationStatus::Hydrated => Self::Hydrated,
583 crate::db::HydrationStatus::Partial => Self::Partial,
584 crate::db::HydrationStatus::Retryable => Self::Retryable,
585 crate::db::HydrationStatus::PermissionDenied => Self::PermissionDenied,
586 crate::db::HydrationStatus::Unavailable => Self::Unavailable,
587 }
588 }
589}
590
591#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
592pub struct RtHydrationResourceState {
593 pub resource: RtHydrationResource,
594 pub status: RtHydrationStatus,
595 pub source_updated_at: String,
596 pub last_attempted_at: Option<String>,
597 pub hydrated_at: Option<String>,
598 pub attempt_count: u32,
599 pub next_retry_at: Option<String>,
600 pub last_error: Option<String>,
601}
602
603impl From<crate::db::HydrationResourceState> for RtHydrationResourceState {
604 fn from(value: crate::db::HydrationResourceState) -> Self {
605 Self {
606 resource: value.resource.into(),
607 status: value.status.into(),
608 source_updated_at: value.source_updated_at,
609 last_attempted_at: value.last_attempted_at,
610 hydrated_at: value.hydrated_at,
611 attempt_count: value.attempt_count,
612 next_retry_at: value.next_retry_at,
613 last_error: value.last_error,
614 }
615 }
616}
617
618#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
619pub struct RtIssueIndexSyncResult {
620 pub indexed: u64,
621 pub inserted: u64,
622 pub updated: u64,
623 pub unchanged: u64,
624 pub queued_for_hydration: u64,
625 pub committed_checkpoint: String,
626}
627
628impl From<crate::linear::IssueIndexSyncResult> for RtIssueIndexSyncResult {
629 fn from(value: crate::linear::IssueIndexSyncResult) -> Self {
630 Self {
631 indexed: value.indexed as u64,
632 inserted: value.inserted as u64,
633 updated: value.updated as u64,
634 unchanged: value.unchanged as u64,
635 queued_for_hydration: value.queued_for_hydration as u64,
636 committed_checkpoint: value.committed_checkpoint,
637 }
638 }
639}
640
641#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
642pub struct RtIssueHydrationResult {
643 pub issue_id: String,
644 pub status: RtHydrationStatus,
645 pub hydrated_resources: u64,
646 pub retryable_failures: u64,
647 pub permanent_failures: u64,
648 pub rate_limited: bool,
649 pub resources: Vec<RtHydrationResourceState>,
650}
651
652impl From<crate::linear::IssueHydrationResult> for RtIssueHydrationResult {
653 fn from(value: crate::linear::IssueHydrationResult) -> Self {
654 Self {
655 issue_id: value.issue_id,
656 status: value.status.into(),
657 hydrated_resources: value.hydrated_resources as u64,
658 retryable_failures: value.retryable_failures as u64,
659 permanent_failures: value.permanent_failures as u64,
660 rate_limited: value.rate_limited,
661 resources: value.resources.into_iter().map(Into::into).collect(),
662 }
663 }
664}
665
666#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
667pub struct RtHydrationBatchResult {
668 pub requested: u64,
669 pub hydrated: u64,
670 pub partial: u64,
671 pub deferred: u64,
672 pub retryable_failures: u64,
673 pub permanent_failures: u64,
674 pub required_failures: u64,
675 pub comment_failures: u64,
676 pub rate_limited: bool,
677}
678
679impl From<crate::linear::HydrationBatchResult> for RtHydrationBatchResult {
680 fn from(value: crate::linear::HydrationBatchResult) -> Self {
681 Self {
682 requested: value.requested as u64,
683 hydrated: value.hydrated as u64,
684 partial: value.partial as u64,
685 deferred: value.deferred as u64,
686 retryable_failures: value.retryable_failures as u64,
687 permanent_failures: value.permanent_failures as u64,
688 required_failures: value.required_failures as u64,
689 comment_failures: value.comment_failures as u64,
690 rate_limited: value.rate_limited,
691 }
692 }
693}
694
695fn rt_progress_phase(value: crate::linear::SyncProgressPhase) -> RtSyncPhase {
696 match value {
697 crate::linear::SyncProgressPhase::IndexingIssues => RtSyncPhase::IndexingIssues,
698 crate::linear::SyncProgressPhase::IndexComplete => RtSyncPhase::IndexComplete,
699 crate::linear::SyncProgressPhase::HydratingIssueDetails => {
700 RtSyncPhase::HydratingIssueDetails
701 }
702 crate::linear::SyncProgressPhase::HydratingLabels => RtSyncPhase::HydratingLabels,
703 crate::linear::SyncProgressPhase::HydratingRelations => RtSyncPhase::HydratingRelations,
704 crate::linear::SyncProgressPhase::HydratingComments => RtSyncPhase::HydratingComments,
705 crate::linear::SyncProgressPhase::WaitingForRateLimitRetry => {
706 RtSyncPhase::WaitingForRateLimitRetry
707 }
708 }
709}
710
711impl From<crate::db::TeamSummary> for RtTeamSummary {
712 fn from(t: crate::db::TeamSummary) -> Self {
713 Self {
714 key: t.key,
715 issue_count: t.issue_count as u64,
716 embedded_count: t.embedded_count as u64,
717 last_synced_at: t.last_synced_at,
718 }
719 }
720}
721
722impl From<RtSearchMode> for search::SearchMode {
723 fn from(mode: RtSearchMode) -> Self {
724 match mode {
725 RtSearchMode::Fts => search::SearchMode::Fts,
726 RtSearchMode::Vector => search::SearchMode::Vector,
727 RtSearchMode::Hybrid => search::SearchMode::Hybrid,
728 }
729 }
730}
731
732#[derive(uniffi::Object)]
735pub struct RectilinearEngine {
736 db: Database,
737 gemini_api_key: Option<String>,
738 sync_progress: Mutex<Option<RtSyncProgress>>,
739 http_client: OnceCell<reqwest::Client>,
742}
743
744struct SyncProgressReset<'a>(&'a Mutex<Option<RtSyncProgress>>);
747
748impl Drop for SyncProgressReset<'_> {
749 fn drop(&mut self) {
750 *self.0.lock().unwrap() = None;
751 }
752}
753
754impl RectilinearEngine {
755 async fn client(&self) -> &reqwest::Client {
759 self.http_client
760 .get_or_init(|| async { reqwest::Client::new() })
761 .await
762 }
763}
764
765#[uniffi::export(async_runtime = "tokio")]
766impl RectilinearEngine {
767 #[uniffi::constructor]
770 pub fn new(db_path: String, gemini_api_key: Option<String>) -> Result<Self, RectilinearError> {
771 let path = Path::new(&db_path);
772 if let Some(parent) = path.parent() {
773 std::fs::create_dir_all(parent).map_err(|e| RectilinearError::Config {
774 message: format!("Failed to create database directory: {e}"),
775 })?;
776 }
777
778 let db = Database::open(path)?;
779
780 Ok(Self {
781 db,
782 gemini_api_key,
783 sync_progress: Mutex::new(None),
784 http_client: OnceCell::new(),
785 })
786 }
787
788 pub fn linear_api_key_for_workspace(
790 &self,
791 workspace_id: &str,
792 ) -> Result<String, RectilinearError> {
793 let config = Config::load().map_err(|e| RectilinearError::Config {
794 message: e.to_string(),
795 })?;
796 config
797 .workspace_api_key(workspace_id)
798 .map_err(|e| RectilinearError::Config {
799 message: e.to_string(),
800 })
801 }
802
803 pub fn list_workspaces(&self) -> Result<Vec<String>, RectilinearError> {
805 let config = Config::load().map_err(|e| RectilinearError::Config {
806 message: e.to_string(),
807 })?;
808 Ok(config.workspace_names())
809 }
810
811 pub fn get_active_workspace(&self) -> Result<String, RectilinearError> {
813 let config = Config::load().map_err(|e| RectilinearError::Config {
814 message: e.to_string(),
815 })?;
816 config
817 .resolve_active_workspace()
818 .map_err(|e| RectilinearError::Config {
819 message: e.to_string(),
820 })
821 }
822
823 pub fn get_issue(&self, id_or_identifier: String) -> Result<Option<RtIssue>, RectilinearError> {
827 Ok(self.db.get_issue(&id_or_identifier)?.map(RtIssue::from))
828 }
829
830 pub fn list_projects(
832 &self,
833 workspace_id: String,
834 include_archived: bool,
835 ) -> Result<Vec<RtProject>, RectilinearError> {
836 Ok(self
837 .db
838 .list_projects(&workspace_id, include_archived)?
839 .into_iter()
840 .map(Into::into)
841 .collect())
842 }
843
844 pub fn get_project(
846 &self,
847 id_or_name: String,
848 workspace_id: String,
849 ) -> Result<Option<RtProject>, RectilinearError> {
850 Ok(self
851 .db
852 .get_project(&workspace_id, &id_or_name)?
853 .map(Into::into))
854 }
855
856 pub fn list_project_milestones(
858 &self,
859 project_id: String,
860 ) -> Result<Vec<RtProjectMilestone>, RectilinearError> {
861 Ok(self
862 .db
863 .list_project_milestones(&project_id)?
864 .into_iter()
865 .map(Into::into)
866 .collect())
867 }
868
869 pub fn get_project_bundle(
871 &self,
872 id_or_name: String,
873 workspace_id: String,
874 ) -> Result<Option<RtProjectBundle>, RectilinearError> {
875 Ok(self
876 .db
877 .get_project_bundle(&workspace_id, &id_or_name)?
878 .map(Into::into))
879 }
880
881 pub fn get_project_milestone_bundle(
883 &self,
884 id_or_name: String,
885 project_id: Option<String>,
886 workspace_id: String,
887 ) -> Result<Option<RtProjectMilestoneBundle>, RectilinearError> {
888 Ok(self
889 .db
890 .get_project_milestone_bundle(&workspace_id, &id_or_name, project_id.as_deref())?
891 .map(Into::into))
892 }
893
894 pub fn get_triage_queue(
896 &self,
897 team: Option<String>,
898 include_completed: bool,
899 workspace_id: String,
900 ) -> Result<Vec<RtIssue>, RectilinearError> {
901 let issues =
902 self.db
903 .get_unprioritized_issues(team.as_deref(), include_completed, &workspace_id)?;
904 Ok(issues.into_iter().map(RtIssue::from).collect())
905 }
906
907 pub fn search_fts(
909 &self,
910 query: String,
911 limit: u32,
912 workspace_id: String,
913 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
914 let results = self.db.fts_search(&query, limit as usize, &workspace_id)?;
915 Ok(results
916 .into_iter()
917 .map(|fts| RtSearchResult {
918 issue_id: fts.issue_id,
919 identifier: fts.identifier,
920 title: fts.title,
921 state_name: fts.state_name,
922 priority: fts.priority,
923 score: fts.bm25_score,
924 similarity: None,
925 })
926 .collect())
927 }
928
929 pub fn count_issues(
931 &self,
932 team: Option<String>,
933 workspace_id: String,
934 ) -> Result<u64, RectilinearError> {
935 Ok(self.db.count_issues(team.as_deref(), &workspace_id)? as u64)
936 }
937
938 pub fn count_embedded_issues(
940 &self,
941 team: Option<String>,
942 workspace_id: String,
943 ) -> Result<u64, RectilinearError> {
944 Ok(self
945 .db
946 .count_embedded_issues(team.as_deref(), &workspace_id)? as u64)
947 }
948
949 pub fn get_sync_progress(&self) -> Option<RtSyncProgress> {
951 self.sync_progress.lock().unwrap().clone()
952 }
953
954 pub fn get_field_completeness(
956 &self,
957 team: Option<String>,
958 workspace_id: String,
959 ) -> Result<RtFieldCompleteness, RectilinearError> {
960 let (total, desc, pri, labels, proj) = self
961 .db
962 .get_field_completeness(team.as_deref(), &workspace_id)?;
963 Ok(RtFieldCompleteness {
964 total: total as u64,
965 with_description: desc as u64,
966 with_priority: pri as u64,
967 with_labels: labels as u64,
968 with_project: proj as u64,
969 })
970 }
971
972 pub fn list_all_issues(
974 &self,
975 team: Option<String>,
976 filter: Option<String>,
977 limit: u32,
978 offset: u32,
979 workspace_id: String,
980 ) -> Result<Vec<RtIssueSummary>, RectilinearError> {
981 let issues = self.db.list_all_issues(
982 team.as_deref(),
983 filter.as_deref(),
984 limit as usize,
985 offset as usize,
986 &workspace_id,
987 )?;
988 Ok(issues.into_iter().map(RtIssueSummary::from).collect())
989 }
990
991 pub fn list_synced_teams(
993 &self,
994 workspace_id: String,
995 ) -> Result<Vec<RtTeamSummary>, RectilinearError> {
996 Ok(self
997 .db
998 .list_synced_teams(&workspace_id)?
999 .into_iter()
1000 .map(RtTeamSummary::from)
1001 .collect())
1002 }
1003
1004 pub fn get_relations(&self, issue_id: String) -> Result<Vec<RtRelation>, RectilinearError> {
1006 Ok(self
1007 .db
1008 .get_relations_enriched(&issue_id)?
1009 .into_iter()
1010 .map(RtRelation::from)
1011 .collect())
1012 }
1013
1014 pub fn get_active_issues(
1016 &self,
1017 team: String,
1018 state_types: Vec<String>,
1019 workspace_id: String,
1020 ) -> Result<Vec<RtIssueEnriched>, RectilinearError> {
1021 let issues = self
1022 .db
1023 .get_issues_by_state_types(&team, &state_types, &workspace_id)?;
1024 let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
1025 let blockers = self.db.get_blockers_for_issues(&issue_ids)?;
1026
1027 let mut blocker_map: std::collections::HashMap<String, Vec<RtBlocker>> =
1029 std::collections::HashMap::new();
1030 for b in blockers {
1031 let is_terminal = matches!(b.state_type.as_str(), "completed" | "canceled");
1032 blocker_map.entry(b.issue_id).or_default().push(RtBlocker {
1033 identifier: b.identifier,
1034 title: b.title,
1035 state_name: b.state_name,
1036 is_terminal,
1037 });
1038 }
1039
1040 Ok(issues
1041 .into_iter()
1042 .map(|issue| {
1043 let labels: Vec<String> =
1044 serde_json::from_str(&issue.labels_json).unwrap_or_default();
1045 let blocked_by = blocker_map.remove(&issue.id).unwrap_or_default();
1046 RtIssueEnriched {
1047 id: issue.id,
1048 identifier: issue.identifier,
1049 team_key: issue.team_key,
1050 title: issue.title,
1051 description: issue.description,
1052 state_name: issue.state_name,
1053 state_type: issue.state_type,
1054 priority: issue.priority,
1055 assignee_name: issue.assignee_name,
1056 project_name: issue.project_name,
1057 project_id: issue.project_id,
1058 project_milestone_id: issue.project_milestone_id,
1059 project_milestone_name: issue.project_milestone_name,
1060 cycle_id: issue.cycle_id,
1061 cycle_name: issue.cycle_name,
1062 labels,
1063 created_at: issue.created_at,
1064 updated_at: issue.updated_at,
1065 url: issue.url,
1066 branch_name: issue.branch_name,
1067 blocked_by,
1068 }
1069 })
1070 .collect())
1071 }
1072
1073 pub async fn list_teams(&self, workspace_id: String) -> Result<Vec<RtTeam>, RectilinearError> {
1077 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1078 let client = LinearClient::with_http_client(self.client().await.clone(), &api_key);
1079 let teams = client
1080 .list_teams()
1081 .await
1082 .map_err(|e| RectilinearError::Api {
1083 message: e.to_string(),
1084 })?;
1085 Ok(teams
1086 .into_iter()
1087 .map(|t| RtTeam {
1088 id: t.id,
1089 key: t.key,
1090 name: t.name,
1091 })
1092 .collect())
1093 }
1094
1095 pub async fn test_gemini_api_key(&self) -> Result<(), RectilinearError> {
1097 let api_key = self
1098 .gemini_api_key
1099 .as_deref()
1100 .ok_or_else(|| RectilinearError::Config {
1101 message: "Gemini API key not configured".into(),
1102 })?;
1103
1104 crate::embedding::Embedder::new_api_with_http_client(self.client().await.clone(), api_key)
1105 .map_err(|e| RectilinearError::Config {
1106 message: e.to_string(),
1107 })?
1108 .test_api_key()
1109 .await
1110 .map_err(|e| RectilinearError::Api {
1111 message: e.to_string(),
1112 })
1113 }
1114
1115 pub async fn sync_projects(
1117 &self,
1118 workspace_id: String,
1119 ) -> Result<RtProjectSyncResult, RectilinearError> {
1120 let client = self.linear_client(&workspace_id).await?;
1121 let (projects, milestones) = client
1122 .sync_projects(&self.db, &workspace_id)
1123 .await
1124 .map_err(api_error)?;
1125 Ok(RtProjectSyncResult {
1126 projects: projects as u64,
1127 milestones: milestones as u64,
1128 })
1129 }
1130
1131 pub async fn sync_team_projects(
1133 &self,
1134 team_key: String,
1135 workspace_id: String,
1136 ) -> Result<RtProjectSyncResult, RectilinearError> {
1137 let client = self.linear_client(&workspace_id).await?;
1138 let result = client
1139 .sync_team_projects(&self.db, &team_key, &workspace_id)
1140 .await
1141 .map_err(api_error)?;
1142 Ok(RtProjectSyncResult {
1143 projects: result.projects as u64,
1144 milestones: result.milestones as u64,
1145 })
1146 }
1147
1148 pub async fn import_project(
1150 &self,
1151 id_or_name: String,
1152 workspace_id: String,
1153 ) -> Result<RtProjectBundle, RectilinearError> {
1154 let client = self.linear_client(&workspace_id).await?;
1155 client
1156 .import_project(&self.db, &workspace_id, &id_or_name)
1157 .await
1158 .map(Into::into)
1159 .map_err(api_error)
1160 }
1161
1162 pub async fn import_project_milestone(
1164 &self,
1165 id_or_name: String,
1166 project_id: Option<String>,
1167 workspace_id: String,
1168 ) -> Result<RtProjectMilestoneBundle, RectilinearError> {
1169 let client = self.linear_client(&workspace_id).await?;
1170 client
1171 .import_project_milestone(&self.db, &workspace_id, project_id.as_deref(), &id_or_name)
1172 .await
1173 .map(Into::into)
1174 .map_err(api_error)
1175 }
1176
1177 pub async fn create_project(
1179 &self,
1180 input: RtCreateProjectInput,
1181 workspace_id: String,
1182 ) -> Result<RtProject, RectilinearError> {
1183 let client = self.linear_client(&workspace_id).await?;
1184 let input = crate::linear::CreateProjectInput {
1185 name: input.name,
1186 team_ids: input.team_ids,
1187 description: input.description,
1188 content: input.content,
1189 icon: input.icon,
1190 color: input.color,
1191 status_id: input.status_id,
1192 priority: input.priority,
1193 lead_id: input.lead_id,
1194 start_date: input.start_date,
1195 target_date: input.target_date,
1196 member_ids: input.member_ids,
1197 label_ids: input.label_ids,
1198 };
1199 let id = client.create_project(&input).await.map_err(api_error)?;
1200 let project = client
1201 .fetch_project(&id, &workspace_id)
1202 .await
1203 .map_err(api_error)?;
1204 self.db.upsert_project(&project)?;
1205 Ok(project.into())
1206 }
1207
1208 pub async fn update_project(
1210 &self,
1211 id_or_name: String,
1212 input: RtUpdateProjectInput,
1213 workspace_id: String,
1214 ) -> Result<RtProject, RectilinearError> {
1215 let client = self.linear_client(&workspace_id).await?;
1216 let id = client
1217 .find_project_by_name(&id_or_name)
1218 .await
1219 .map_err(api_error)?;
1220 let input = crate::linear::UpdateProjectInput {
1221 name: input.name,
1222 team_ids: input.team_ids,
1223 description: input.description,
1224 content: input.content,
1225 icon: input.icon,
1226 color: input.color,
1227 status_id: input.status_id,
1228 priority: input.priority,
1229 lead_id: input.lead_id,
1230 start_date: input.start_date,
1231 target_date: input.target_date,
1232 member_ids: input.member_ids,
1233 label_ids: input.label_ids,
1234 };
1235 client
1236 .update_project(&id, &input)
1237 .await
1238 .map_err(api_error)?;
1239 let project = client
1240 .fetch_project(&id, &workspace_id)
1241 .await
1242 .map_err(api_error)?;
1243 self.db.upsert_project(&project)?;
1244 Ok(project.into())
1245 }
1246
1247 pub async fn delete_project(
1249 &self,
1250 id_or_name: String,
1251 workspace_id: String,
1252 ) -> Result<(), RectilinearError> {
1253 let client = self.linear_client(&workspace_id).await?;
1254 let id = client
1255 .find_project_by_name(&id_or_name)
1256 .await
1257 .map_err(api_error)?;
1258 client.delete_project(&id).await.map_err(api_error)?;
1259 self.db.delete_project_local(&id)?;
1260 Ok(())
1261 }
1262
1263 pub async fn create_project_milestone(
1265 &self,
1266 input: RtCreateProjectMilestoneInput,
1267 workspace_id: String,
1268 ) -> Result<RtProjectMilestone, RectilinearError> {
1269 let client = self.linear_client(&workspace_id).await?;
1270 let input = crate::linear::CreateProjectMilestoneInput {
1271 project_id: input.project_id,
1272 name: input.name,
1273 description: input.description,
1274 target_date: input.target_date,
1275 sort_order: input.sort_order,
1276 };
1277 let id = client
1278 .create_project_milestone(&input)
1279 .await
1280 .map_err(api_error)?;
1281 self.cache_project_milestone(&client, &id, &workspace_id)
1282 .await
1283 }
1284
1285 pub async fn update_project_milestone(
1287 &self,
1288 id_or_name: String,
1289 owning_project_id: Option<String>,
1290 input: RtUpdateProjectMilestoneInput,
1291 workspace_id: String,
1292 ) -> Result<RtProjectMilestone, RectilinearError> {
1293 let client = self.linear_client(&workspace_id).await?;
1294 let id = client
1295 .find_project_milestone(owning_project_id.as_deref(), &id_or_name)
1296 .await
1297 .map_err(api_error)?;
1298 let input = crate::linear::UpdateProjectMilestoneInput {
1299 project_id: input.project_id,
1300 name: input.name,
1301 description: input.description,
1302 target_date: input.target_date,
1303 sort_order: input.sort_order,
1304 };
1305 client
1306 .update_project_milestone(&id, &input)
1307 .await
1308 .map_err(api_error)?;
1309 self.cache_project_milestone(&client, &id, &workspace_id)
1310 .await
1311 }
1312
1313 pub async fn delete_project_milestone(
1315 &self,
1316 id_or_name: String,
1317 owning_project_id: Option<String>,
1318 workspace_id: String,
1319 ) -> Result<(), RectilinearError> {
1320 let client = self.linear_client(&workspace_id).await?;
1321 let id = client
1322 .find_project_milestone(owning_project_id.as_deref(), &id_or_name)
1323 .await
1324 .map_err(api_error)?;
1325 client
1326 .delete_project_milestone(&id)
1327 .await
1328 .map_err(api_error)?;
1329 self.db.delete_project_milestone_local(&id)?;
1330 Ok(())
1331 }
1332
1333 pub async fn set_issue_project_context(
1335 &self,
1336 issue_id: String,
1337 project_id: Option<String>,
1338 project_milestone_id: Option<String>,
1339 workspace_id: String,
1340 ) -> Result<RtIssue, RectilinearError> {
1341 let client = self.linear_client(&workspace_id).await?;
1342 let project_id = match (project_id, project_milestone_id.as_deref()) {
1343 (Some(project_id), _) => Some(project_id),
1344 (None, Some(milestone_id)) => Some(
1345 client
1346 .fetch_project_milestone(milestone_id, &workspace_id)
1347 .await
1348 .map_err(api_error)?
1349 .project_id,
1350 ),
1351 (None, None) => None,
1352 };
1353 let project_value = project_id.unwrap_or_default();
1354 let milestone_value = project_milestone_id.unwrap_or_default();
1355 client
1356 .update_issue(
1357 &issue_id,
1358 crate::linear::UpdateIssueInput {
1359 project_id: Some(&project_value),
1360 project_milestone_id: Some(&milestone_value),
1361 ..Default::default()
1362 },
1363 )
1364 .await
1365 .map_err(api_error)?;
1366 let (mut issue, relations, label_ids) = client
1367 .fetch_single_issue(&issue_id)
1368 .await
1369 .map_err(api_error)?;
1370 issue.workspace_id = workspace_id;
1371 self.db.upsert_issue(&issue)?;
1372 self.db.upsert_relations(&issue.id, &relations)?;
1373 self.db.replace_issue_labels(&issue.id, &label_ids)?;
1374 Ok(issue.into())
1375 }
1376
1377 pub async fn sync_team(
1379 &self,
1380 team_key: String,
1381 full: bool,
1382 workspace_id: String,
1383 ) -> Result<u64, RectilinearError> {
1384 self.set_sync_progress(Some(RtSyncProgress {
1385 phase: RtSyncPhase::FetchingIssues,
1386 completed: 0,
1387 total: None,
1388 issue_id: None,
1389 }));
1390 let _progress_reset = SyncProgressReset(&self.sync_progress);
1391
1392 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1393 let client = LinearClient::with_http_client(self.client().await.clone(), &api_key);
1394 let progress_state = &self.sync_progress;
1395 let progress = move |count: usize| {
1396 *progress_state.lock().unwrap() = Some(RtSyncProgress {
1397 phase: RtSyncPhase::FetchingIssues,
1398 completed: count as u64,
1399 total: None,
1400 issue_id: None,
1401 });
1402 };
1403 let result = client
1404 .sync_team(
1405 &self.db,
1406 &team_key,
1407 &workspace_id,
1408 full,
1409 false,
1410 Some(&progress),
1411 )
1412 .await
1413 .map_err(|e| RectilinearError::Api {
1414 message: e.to_string(),
1415 });
1416 self.set_sync_progress(None);
1417 result.map(|count| count as u64)
1418 }
1419
1420 pub async fn sync_team_index(
1423 &self,
1424 team_key: String,
1425 full: bool,
1426 workspace_id: String,
1427 ) -> Result<RtIssueIndexSyncResult, RectilinearError> {
1428 let client = self.linear_client(&workspace_id).await?;
1429 self.set_sync_progress(Some(RtSyncProgress {
1430 phase: RtSyncPhase::IndexingIssues,
1431 completed: 0,
1432 total: None,
1433 issue_id: None,
1434 }));
1435 let _progress_reset = SyncProgressReset(&self.sync_progress);
1436 let progress_state = &self.sync_progress;
1437 let progress = move |update: crate::linear::SyncProgressUpdate| {
1438 *progress_state.lock().unwrap() = Some(RtSyncProgress {
1439 phase: rt_progress_phase(update.phase),
1440 completed: update.completed as u64,
1441 total: update.total.map(|value| value as u64),
1442 issue_id: update.issue_id,
1443 });
1444 };
1445 let result = client
1446 .sync_team_index(&self.db, &team_key, &workspace_id, full, Some(&progress))
1447 .await
1448 .map(Into::into)
1449 .map_err(api_error);
1450 self.set_sync_progress(None);
1451 result
1452 }
1453
1454 pub async fn hydrate_issue(
1456 &self,
1457 issue_id: String,
1458 workspace_id: String,
1459 ) -> Result<RtIssueHydrationResult, RectilinearError> {
1460 let client = self.linear_client(&workspace_id).await?;
1461 let _progress_reset = SyncProgressReset(&self.sync_progress);
1462 let progress_state = &self.sync_progress;
1463 let progress = move |update: crate::linear::SyncProgressUpdate| {
1464 *progress_state.lock().unwrap() = Some(RtSyncProgress {
1465 phase: rt_progress_phase(update.phase),
1466 completed: update.completed as u64,
1467 total: update.total.map(|value| value as u64),
1468 issue_id: update.issue_id,
1469 });
1470 };
1471 let result = client
1472 .hydrate_issue(&self.db, &issue_id, &workspace_id, Some(&progress))
1473 .await
1474 .map(Into::into)
1475 .map_err(api_error);
1476 self.set_sync_progress(None);
1477 result
1478 }
1479
1480 pub async fn hydrate_issue_with_mode(
1482 &self,
1483 issue_id: String,
1484 workspace_id: String,
1485 mode: RtHydrationMode,
1486 ) -> Result<RtIssueHydrationResult, RectilinearError> {
1487 let client = self.linear_client(&workspace_id).await?;
1488 let _progress_reset = SyncProgressReset(&self.sync_progress);
1489 let progress_state = &self.sync_progress;
1490 let progress = move |update: crate::linear::SyncProgressUpdate| {
1491 *progress_state.lock().unwrap() = Some(RtSyncProgress {
1492 phase: rt_progress_phase(update.phase),
1493 completed: update.completed as u64,
1494 total: update.total.map(|value| value as u64),
1495 issue_id: update.issue_id,
1496 });
1497 };
1498 let result = client
1499 .hydrate_issue_with_mode(
1500 &self.db,
1501 &issue_id,
1502 &workspace_id,
1503 mode.into(),
1504 Some(&progress),
1505 )
1506 .await
1507 .map(Into::into)
1508 .map_err(api_error);
1509 self.set_sync_progress(None);
1510 result
1511 }
1512
1513 pub async fn hydrate_pending_issues(
1515 &self,
1516 team_key: String,
1517 workspace_id: String,
1518 limit: u32,
1519 policy: RtHydrationPolicy,
1520 ) -> Result<RtHydrationBatchResult, RectilinearError> {
1521 let client = self.linear_client(&workspace_id).await?;
1522 let _progress_reset = SyncProgressReset(&self.sync_progress);
1523 let progress_state = &self.sync_progress;
1524 let progress = move |update: crate::linear::SyncProgressUpdate| {
1525 *progress_state.lock().unwrap() = Some(RtSyncProgress {
1526 phase: rt_progress_phase(update.phase),
1527 completed: update.completed as u64,
1528 total: update.total.map(|value| value as u64),
1529 issue_id: update.issue_id,
1530 });
1531 };
1532 let result = client
1533 .hydrate_pending_issues(
1534 &self.db,
1535 &team_key,
1536 &workspace_id,
1537 limit as usize,
1538 policy.into(),
1539 Some(&progress),
1540 )
1541 .await
1542 .map(Into::into)
1543 .map_err(api_error);
1544 self.set_sync_progress(None);
1545 result
1546 }
1547
1548 pub fn get_issue_hydration_state(
1550 &self,
1551 issue_id: String,
1552 workspace_id: String,
1553 ) -> Result<RtIssueHydrationResult, RectilinearError> {
1554 let issue = self
1555 .db
1556 .get_issue(&issue_id)?
1557 .ok_or_else(|| RectilinearError::NotFound {
1558 key: issue_id.clone(),
1559 })?;
1560 let state = self
1561 .db
1562 .get_issue_hydration_state(&workspace_id, &issue.id)?;
1563 Ok(RtIssueHydrationResult {
1564 issue_id: issue.id,
1565 status: state.status.into(),
1566 hydrated_resources: state
1567 .resources
1568 .iter()
1569 .filter(|resource| resource.status == crate::db::HydrationStatus::Hydrated)
1570 .count() as u64,
1571 retryable_failures: state
1572 .resources
1573 .iter()
1574 .filter(|resource| resource.status == crate::db::HydrationStatus::Retryable)
1575 .count() as u64,
1576 permanent_failures: state
1577 .resources
1578 .iter()
1579 .filter(|resource| {
1580 matches!(
1581 resource.status,
1582 crate::db::HydrationStatus::PermissionDenied
1583 | crate::db::HydrationStatus::Unavailable
1584 )
1585 })
1586 .count() as u64,
1587 rate_limited: false,
1588 resources: state.resources.into_iter().map(Into::into).collect(),
1589 })
1590 }
1591
1592 pub async fn search_hybrid(
1594 &self,
1595 query: String,
1596 team: Option<String>,
1597 limit: u32,
1598 workspace_id: String,
1599 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
1600 let config = Config::load().unwrap_or_default();
1601 let embedder = self.make_embedder(&config).await?;
1602
1603 let results = search::search(
1604 &self.db,
1605 search::SearchParams {
1606 query: &query,
1607 mode: search::SearchMode::Hybrid,
1608 team_key: team.as_deref(),
1609 state_filter: None,
1610 label_ids: None,
1611 limit: limit as usize,
1612 embedder: embedder.as_ref(),
1613 rrf_k: config.search.rrf_k,
1614 workspace_id: &workspace_id,
1615 },
1616 )
1617 .await?;
1618
1619 Ok(results.into_iter().map(RtSearchResult::from).collect())
1620 }
1621
1622 pub async fn find_duplicates(
1624 &self,
1625 text: String,
1626 team: Option<String>,
1627 threshold: f32,
1628 workspace_id: String,
1629 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
1630 let config = Config::load().unwrap_or_default();
1631 let embedder =
1632 self.make_embedder(&config)
1633 .await?
1634 .ok_or_else(|| RectilinearError::Config {
1635 message:
1636 "Embedder not available — set GEMINI_API_KEY or enable local embeddings"
1637 .into(),
1638 })?;
1639
1640 let results = search::find_duplicates(
1641 &self.db,
1642 &text,
1643 team.as_deref(),
1644 threshold,
1645 10,
1646 &embedder,
1647 config.search.rrf_k,
1648 &workspace_id,
1649 )
1650 .await?;
1651
1652 Ok(results.into_iter().map(RtSearchResult::from).collect())
1653 }
1654
1655 pub async fn save_issue(
1657 &self,
1658 issue_id: String,
1659 title: Option<String>,
1660 description: Option<String>,
1661 priority: Option<i32>,
1662 state: Option<String>,
1663 labels: Option<Vec<String>>,
1664 workspace_id: String,
1665 ) -> Result<(), RectilinearError> {
1666 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1667 let client = LinearClient::with_http_client(self.client().await.clone(), &api_key);
1668
1669 let state_id = if let Some(ref state_name) = state {
1670 if let Some(issue) = self.db.get_issue(&issue_id)? {
1672 Some(
1673 client
1674 .get_state_id(&issue.team_key, state_name)
1675 .await
1676 .map_err(|e| RectilinearError::Api {
1677 message: e.to_string(),
1678 })?,
1679 )
1680 } else {
1681 None
1682 }
1683 } else {
1684 None
1685 };
1686
1687 let label_ids =
1688 if let Some(ref label_names) = labels {
1689 Some(client.get_label_ids(label_names).await.map_err(|e| {
1690 RectilinearError::Api {
1691 message: e.to_string(),
1692 }
1693 })?)
1694 } else {
1695 None
1696 };
1697
1698 client
1699 .update_issue(
1700 &issue_id,
1701 crate::linear::UpdateIssueInput {
1702 title: title.as_deref(),
1703 description: description.as_deref(),
1704 priority,
1705 state_id: state_id.as_deref(),
1706 label_ids: label_ids.as_deref(),
1707 ..Default::default()
1708 },
1709 )
1710 .await
1711 .map_err(|e| RectilinearError::Api {
1712 message: e.to_string(),
1713 })?;
1714
1715 if let Ok((issue, relations, label_ids)) = client.fetch_single_issue(&issue_id).await {
1717 let _ = self.db.upsert_issue(&issue);
1718 let _ = self.db.upsert_relations(&issue.id, &relations);
1719 let _ = self.db.replace_issue_labels(&issue.id, &label_ids);
1720 }
1721
1722 Ok(())
1723 }
1724
1725 pub async fn create_issue(
1727 &self,
1728 input: RtCreateIssueInput,
1729 workspace_id: String,
1730 ) -> Result<RtCreateIssueResult, RectilinearError> {
1731 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1732 let client = LinearClient::with_http_client(self.client().await.clone(), &api_key);
1733
1734 let team_id =
1735 client
1736 .get_team_id(&input.team_key)
1737 .await
1738 .map_err(|e| RectilinearError::Api {
1739 message: e.to_string(),
1740 })?;
1741 let project_id = match (
1742 input.project_id.as_deref(),
1743 input.project_milestone_id.as_deref(),
1744 ) {
1745 (Some(project_id), _) => Some(project_id.to_string()),
1746 (None, Some(milestone_id)) => Some(
1747 client
1748 .fetch_project_milestone(milestone_id, &workspace_id)
1749 .await
1750 .map_err(api_error)?
1751 .project_id,
1752 ),
1753 (None, None) => None,
1754 };
1755
1756 let (id, identifier) = client
1757 .create_issue(crate::linear::CreateIssueInput {
1758 team_id: &team_id,
1759 title: &input.title,
1760 description: input.description.as_deref(),
1761 priority: input.priority,
1762 label_ids: &input.label_ids,
1763 assignee_id: None,
1764 parent_id: input.parent_id.as_deref(),
1765 project_id: project_id.as_deref(),
1766 project_milestone_id: input.project_milestone_id.as_deref(),
1767 })
1768 .await
1769 .map_err(|e| RectilinearError::Api {
1770 message: e.to_string(),
1771 })?;
1772
1773 Ok(RtCreateIssueResult { id, identifier })
1774 }
1775
1776 pub async fn add_comment(
1778 &self,
1779 issue_id: String,
1780 body: String,
1781 workspace_id: String,
1782 ) -> Result<(), RectilinearError> {
1783 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1784 let client = LinearClient::with_http_client(self.client().await.clone(), &api_key);
1785 client
1786 .add_comment(&issue_id, &body)
1787 .await
1788 .map_err(|e| RectilinearError::Api {
1789 message: e.to_string(),
1790 })
1791 }
1792
1793 pub async fn refresh_issue(
1796 &self,
1797 id_or_identifier: String,
1798 workspace_id: String,
1799 ) -> Result<Option<RtIssue>, RectilinearError> {
1800 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1801 let client = LinearClient::with_http_client(self.client().await.clone(), &api_key);
1802
1803 let result = if id_or_identifier.contains('-')
1804 && id_or_identifier
1805 .chars()
1806 .last()
1807 .is_some_and(|c| c.is_ascii_digit())
1808 {
1809 client
1810 .fetch_issue_by_identifier(&id_or_identifier)
1811 .await
1812 .map_err(|e| RectilinearError::Api {
1813 message: e.to_string(),
1814 })?
1815 } else {
1816 Some(
1817 client
1818 .fetch_single_issue(&id_or_identifier)
1819 .await
1820 .map_err(|e| RectilinearError::Api {
1821 message: e.to_string(),
1822 })?,
1823 )
1824 };
1825
1826 if let Some((issue, relations, label_ids)) = result {
1827 self.db.upsert_issue(&issue)?;
1828 self.db.upsert_relations(&issue.id, &relations)?;
1829 self.db.replace_issue_labels(&issue.id, &label_ids)?;
1830 Ok(Some(RtIssue::from(issue)))
1831 } else {
1832 Ok(None)
1833 }
1834 }
1835
1836 pub async fn embed_issues(
1839 &self,
1840 team: Option<String>,
1841 limit: u32,
1842 workspace_id: String,
1843 ) -> Result<u64, RectilinearError> {
1844 let config = Config::load().unwrap_or_default();
1845 let embedder =
1846 self.make_embedder(&config)
1847 .await?
1848 .ok_or_else(|| {
1849 RectilinearError::Config {
1850 message:
1851 "No embedding backend available — set GEMINI_API_KEY or enable local embeddings"
1852 .into(),
1853 }
1854 })?;
1855
1856 let model_name = embedder.backend_name().to_string();
1857 let issues = self.db.get_issues_needing_embedding_for_model(
1858 team.as_deref(),
1859 false,
1860 &workspace_id,
1861 Some(&model_name),
1862 )?;
1863
1864 let to_process = if limit > 0 {
1865 &issues[..std::cmp::min(issues.len(), limit as usize)]
1866 } else {
1867 &issues
1868 };
1869 let total = to_process.len() as u64;
1870
1871 self.set_sync_progress(Some(RtSyncProgress {
1872 phase: RtSyncPhase::GeneratingEmbeddings,
1873 completed: 0,
1874 total: Some(total),
1875 issue_id: None,
1876 }));
1877
1878 const BATCH_SIZE: usize = 100;
1882
1883 struct IssueChunks {
1885 issue_id: String,
1886 source_content_hash: String,
1887 chunks: Vec<String>,
1888 }
1889 let mut pending: Vec<IssueChunks> = Vec::new();
1890 for issue in to_process {
1891 let chunks = crate::embedding::chunk_text(
1892 &issue.title,
1893 issue.description.as_deref().unwrap_or(""),
1894 512,
1895 64,
1896 );
1897 pending.push(IssueChunks {
1898 issue_id: issue.id.clone(),
1899 source_content_hash: crate::embedding::issue_content_hash(
1900 &issue.title,
1901 issue.description.as_deref(),
1902 ),
1903 chunks,
1904 });
1905 }
1906
1907 let result: Result<u64, RectilinearError> = async {
1908 let mut flat_chunks: Vec<(usize, usize, String)> = Vec::new();
1911 for (issue_idx, ic) in pending.iter().enumerate() {
1912 for (chunk_idx, text) in ic.chunks.iter().enumerate() {
1913 flat_chunks.push((issue_idx, chunk_idx, text.clone()));
1914 }
1915 }
1916
1917 let mut embeddings_flat: Vec<Vec<f32>> = Vec::with_capacity(flat_chunks.len());
1919 for batch in flat_chunks.chunks(BATCH_SIZE) {
1920 let texts: Vec<String> = batch.iter().map(|(_, _, t)| t.clone()).collect();
1921 let batch_embeddings =
1922 embedder
1923 .embed_batch(&texts)
1924 .await
1925 .map_err(|e| RectilinearError::Api {
1926 message: e.to_string(),
1927 })?;
1928 embeddings_flat.extend(batch_embeddings);
1929 }
1930
1931 let mut emb_offset = 0usize;
1933 let mut count = 0u64;
1934 for ic in &pending {
1935 let n = ic.chunks.len();
1936 let issue_embeddings = &embeddings_flat[emb_offset..emb_offset + n];
1937
1938 let chunk_data: Vec<(usize, String, Vec<u8>)> = ic
1939 .chunks
1940 .iter()
1941 .zip(issue_embeddings.iter())
1942 .enumerate()
1943 .map(|(idx, (text, emb))| {
1944 (idx, text.clone(), crate::embedding::embedding_to_bytes(emb))
1945 })
1946 .collect();
1947
1948 self.db.upsert_chunks_with_model_and_hash(
1949 &ic.issue_id,
1950 &chunk_data,
1951 &model_name,
1952 &ic.source_content_hash,
1953 )?;
1954 emb_offset += n;
1955 count += 1;
1956 self.set_sync_progress(Some(RtSyncProgress {
1957 phase: RtSyncPhase::GeneratingEmbeddings,
1958 completed: count,
1959 total: Some(total),
1960 issue_id: None,
1961 }));
1962 }
1963
1964 Ok(count)
1965 }
1966 .await;
1967
1968 self.set_sync_progress(None);
1969 result
1970 }
1971}
1972
1973impl RectilinearEngine {
1976 async fn linear_client(&self, workspace_id: &str) -> Result<LinearClient, RectilinearError> {
1977 let api_key = self.linear_api_key_for_workspace(workspace_id)?;
1978 Ok(LinearClient::with_http_client(
1979 self.client().await.clone(),
1980 &api_key,
1981 ))
1982 }
1983
1984 async fn cache_project_milestone(
1985 &self,
1986 client: &LinearClient,
1987 milestone_id: &str,
1988 workspace_id: &str,
1989 ) -> Result<RtProjectMilestone, RectilinearError> {
1990 let milestone = client
1991 .fetch_project_milestone(milestone_id, workspace_id)
1992 .await
1993 .map_err(api_error)?;
1994 let project = client
1995 .fetch_project(&milestone.project_id, workspace_id)
1996 .await
1997 .map_err(api_error)?;
1998 self.db.upsert_project(&project)?;
1999 self.db.upsert_project_milestone(&milestone)?;
2000 Ok(milestone.into())
2001 }
2002
2003 fn set_sync_progress(&self, progress: Option<RtSyncProgress>) {
2004 *self.sync_progress.lock().unwrap() = progress;
2005 }
2006
2007 async fn make_embedder(
2008 &self,
2009 config: &Config,
2010 ) -> Result<Option<crate::embedding::Embedder>, RectilinearError> {
2011 let key = self
2012 .gemini_api_key
2013 .as_deref()
2014 .or(config.embedding.gemini_api_key.as_deref());
2015
2016 if let Some(api_key) = key {
2017 Ok(Some(
2018 crate::embedding::Embedder::new_api_with_http_client(
2019 self.client().await.clone(),
2020 api_key,
2021 )
2022 .map_err(|e| RectilinearError::Config {
2023 message: e.to_string(),
2024 })?,
2025 ))
2026 } else {
2027 #[cfg(feature = "local-embeddings")]
2028 {
2029 let models_dir = Config::models_dir().map_err(|e| RectilinearError::Config {
2030 message: e.to_string(),
2031 })?;
2032 Ok(Some(
2033 crate::embedding::Embedder::new_local(&models_dir).map_err(|e| {
2034 RectilinearError::Config {
2035 message: e.to_string(),
2036 }
2037 })?,
2038 ))
2039 }
2040 #[cfg(not(feature = "local-embeddings"))]
2041 {
2042 Ok(None)
2043 }
2044 }
2045 }
2046}
2047
2048fn api_error(error: anyhow::Error) -> RectilinearError {
2049 RectilinearError::Api {
2050 message: error.to_string(),
2051 }
2052}