1use std::{collections::BTreeSet, path::Path, process::Command};
2
3use rusqlite::{Connection, OptionalExtension, params};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::index::now_ms;
8
9#[derive(Debug, Clone, Serialize)]
10pub struct GitHubStatus {
11 pub refs: u64,
12 pub issues: u64,
13 pub comments: u64,
14 pub pulls: u64,
15 pub reviews: u64,
16 pub review_comments: u64,
17 pub last_sync_ms: Option<i64>,
18 pub capability: String,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct GitHubSyncReport {
23 pub offline: bool,
24 pub discovered_refs: usize,
25 pub skipped_refs: usize,
26 pub failed_refs: usize,
27 pub synced_items: usize,
28 pub errors: Vec<GitHubSyncError>,
29 pub status: GitHubStatus,
30}
31
32#[derive(Debug, Clone, Serialize)]
33pub struct GitHubSyncError {
34 pub owner: String,
35 pub repo: String,
36 pub number: i64,
37 pub status: String,
38 pub error: String,
39}
40
41#[derive(Debug, Clone)]
42pub struct GitHubSyncProgress {
43 pub current: usize,
44 pub total: usize,
45 pub owner: String,
46 pub repo: String,
47 pub number: i64,
48 pub action: GitHubSyncAction,
49 pub message: Option<String>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum GitHubSyncAction {
54 Syncing,
55 Skipped,
56 Synced,
57 Failed,
58 RebuildingFts,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct GitHubRef {
63 pub owner: String,
64 pub repo: String,
65 pub number: i64,
66 pub ref_kind: String,
67 pub source_kind: String,
68 pub source_path: Option<String>,
69 pub source_commit: Option<String>,
70 pub source_text: String,
71}
72
73#[derive(Debug, Clone, Serialize)]
74pub struct GitHubEvidence {
75 pub owner: String,
76 pub repo: String,
77 pub number: i64,
78 pub item_kind: String,
79 pub item_id: String,
80 pub url: String,
81 pub title: String,
82 pub snippet: String,
83 pub classification: String,
84 pub evidence_kind: &'static str,
85 pub score: f64,
86}
87
88#[derive(Debug, Clone, Serialize)]
89pub struct Papertrail {
90 pub current_source: Option<CurrentSourceEvidence>,
91 pub github_evidence: Vec<GitHubEvidence>,
92 #[serde(skip_serializing_if = "Vec::is_empty")]
93 pub fallback_github_evidence: Vec<GitHubEvidence>,
94}
95
96#[derive(Debug, Clone, Serialize)]
97pub struct CurrentSourceEvidence {
98 pub chunk_id: Option<i64>,
99 pub path: String,
100 pub start_line: Option<i64>,
101 pub end_line: Option<i64>,
102 pub symbol: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct GitHubIssue {
107 pub owner: String,
108 pub repo: String,
109 pub number: i64,
110 pub html_url: String,
111 pub state: String,
112 pub title: String,
113 pub body: String,
114 pub author: Option<String>,
115 pub created_at: Option<String>,
116 pub updated_at: Option<String>,
117 pub is_pull_request: bool,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct GitHubComment {
122 pub id: i64,
123 pub owner: String,
124 pub repo: String,
125 pub number: i64,
126 pub html_url: String,
127 pub body: String,
128 pub author: Option<String>,
129 pub created_at: Option<String>,
130 pub updated_at: Option<String>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct GitHubPullRequest {
135 pub owner: String,
136 pub repo: String,
137 pub number: i64,
138 pub html_url: String,
139 pub state: String,
140 pub title: String,
141 pub body: String,
142 pub author: Option<String>,
143 pub created_at: Option<String>,
144 pub updated_at: Option<String>,
145 pub merged_at: Option<String>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct GitHubReview {
150 pub id: i64,
151 pub owner: String,
152 pub repo: String,
153 pub number: i64,
154 pub html_url: Option<String>,
155 pub state: String,
156 pub body: String,
157 pub author: Option<String>,
158 pub submitted_at: Option<String>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct GitHubReviewComment {
163 pub id: i64,
164 pub owner: String,
165 pub repo: String,
166 pub number: i64,
167 pub path: Option<String>,
168 pub html_url: String,
169 pub body: String,
170 pub author: Option<String>,
171 pub created_at: Option<String>,
172 pub updated_at: Option<String>,
173}
174
175pub trait GitHubClient {
176 fn issue(&self, owner: &str, repo: &str, number: i64) -> anyhow::Result<GitHubIssue>;
177 fn issue_comments(
178 &self,
179 owner: &str,
180 repo: &str,
181 number: i64,
182 ) -> anyhow::Result<Vec<GitHubComment>>;
183 fn pull(
184 &self,
185 owner: &str,
186 repo: &str,
187 number: i64,
188 ) -> anyhow::Result<Option<GitHubPullRequest>>;
189 fn pull_reviews(
190 &self,
191 owner: &str,
192 repo: &str,
193 number: i64,
194 ) -> anyhow::Result<Vec<GitHubReview>>;
195 fn pull_review_comments(
196 &self,
197 owner: &str,
198 repo: &str,
199 number: i64,
200 ) -> anyhow::Result<Vec<GitHubReviewComment>>;
201}
202
203pub struct GhCliGitHubClient;
204
205impl GitHubClient for GhCliGitHubClient {
206 fn issue(&self, owner: &str, repo: &str, number: i64) -> anyhow::Result<GitHubIssue> {
207 let value = gh_api_json(&format!("repos/{owner}/{repo}/issues/{number}"))?;
208 Ok(issue_from_value(owner, repo, &value))
209 }
210
211 fn issue_comments(
212 &self,
213 owner: &str,
214 repo: &str,
215 number: i64,
216 ) -> anyhow::Result<Vec<GitHubComment>> {
217 let values = gh_api_paginated(&format!("repos/{owner}/{repo}/issues/{number}/comments"))?;
218 Ok(values.iter().map(|value| comment_from_value(owner, repo, number, value)).collect())
219 }
220
221 fn pull(
222 &self,
223 owner: &str,
224 repo: &str,
225 number: i64,
226 ) -> anyhow::Result<Option<GitHubPullRequest>> {
227 match gh_api_json(&format!("repos/{owner}/{repo}/pulls/{number}")) {
228 Ok(value) => Ok(Some(pull_from_value(owner, repo, number, &value))),
229 Err(_) => Ok(None),
230 }
231 }
232
233 fn pull_reviews(
234 &self,
235 owner: &str,
236 repo: &str,
237 number: i64,
238 ) -> anyhow::Result<Vec<GitHubReview>> {
239 let values = gh_api_paginated(&format!("repos/{owner}/{repo}/pulls/{number}/reviews"))?;
240 Ok(values.iter().map(|value| review_from_value(owner, repo, number, value)).collect())
241 }
242
243 fn pull_review_comments(
244 &self,
245 owner: &str,
246 repo: &str,
247 number: i64,
248 ) -> anyhow::Result<Vec<GitHubReviewComment>> {
249 let values = gh_api_paginated(&format!("repos/{owner}/{repo}/pulls/{number}/comments"))?;
250 Ok(values
251 .iter()
252 .map(|value| review_comment_from_value(owner, repo, number, value))
253 .collect())
254 }
255}
256
257pub fn sync_from_refs<C: GitHubClient>(
258 conn: &Connection,
259 root: &Path,
260 client: Option<&C>,
261 offline: bool,
262) -> anyhow::Result<GitHubSyncReport> {
263 sync_from_refs_with_progress(conn, root, client, offline, |_| {})
264}
265
266pub fn sync_from_refs_with_progress<C: GitHubClient>(
267 conn: &Connection,
268 root: &Path,
269 client: Option<&C>,
270 offline: bool,
271 mut progress: impl FnMut(GitHubSyncProgress),
272) -> anyhow::Result<GitHubSyncReport> {
273 let refs = discover_and_store_refs(conn, root)?;
274 let sync = if offline {
275 SyncRefsReport::default()
276 } else {
277 let client = client.ok_or_else(|| anyhow::anyhow!("github sync requires a client"))?;
278 sync_refs(conn, client, refs.iter(), &mut progress)?
279 };
280 set_meta(conn, "github_last_sync_ms", &now_ms().to_string())?;
281 Ok(GitHubSyncReport {
282 offline,
283 discovered_refs: refs.len(),
284 skipped_refs: sync.skipped_refs,
285 failed_refs: sync.failed_refs,
286 synced_items: sync.synced_items,
287 errors: sync.errors,
288 status: status(conn)?,
289 })
290}
291
292pub fn sync_issue<C: GitHubClient>(
293 conn: &Connection,
294 issue_ref: &str,
295 client: Option<&C>,
296 offline: bool,
297) -> anyhow::Result<GitHubSyncReport> {
298 let parsed = parse_issue_ref(issue_ref, default_repo().as_deref())
299 .ok_or_else(|| anyhow::anyhow!("invalid GitHub issue reference `{issue_ref}`"))?;
300 store_ref(
301 conn,
302 &GitHubRef {
303 owner: parsed.owner,
304 repo: parsed.repo,
305 number: parsed.number,
306 ref_kind: "unknown".to_string(),
307 source_kind: "manual".to_string(),
308 source_path: None,
309 source_commit: None,
310 source_text: issue_ref.to_string(),
311 },
312 )?;
313 let refs = refs(conn)?;
314 let sync = if offline {
315 SyncRefsReport::default()
316 } else {
317 let client = client.ok_or_else(|| anyhow::anyhow!("github sync requires a client"))?;
318 sync_refs(conn, client, refs.iter().filter(|r| r.number == parsed.number), &mut |_| {})?
319 };
320 set_meta(conn, "github_last_sync_ms", &now_ms().to_string())?;
321 Ok(GitHubSyncReport {
322 offline,
323 discovered_refs: refs.len(),
324 skipped_refs: sync.skipped_refs,
325 failed_refs: sync.failed_refs,
326 synced_items: sync.synced_items,
327 errors: sync.errors,
328 status: status(conn)?,
329 })
330}
331
332pub fn status(conn: &Connection) -> anyhow::Result<GitHubStatus> {
333 Ok(GitHubStatus {
334 refs: count_table(conn, "github_refs")?,
335 issues: count_table(conn, "github_issues")?,
336 comments: count_table(conn, "github_comments")?,
337 pulls: count_table(conn, "github_pull_requests")?,
338 reviews: count_table(conn, "github_reviews")?,
339 review_comments: count_table(conn, "github_review_comments")?,
340 last_sync_ms: meta(conn, "github_last_sync_ms")?.and_then(|value| value.parse().ok()),
341 capability: if gh_available() {
342 "gh_cli_available".to_string()
343 } else {
344 "gh_cli_missing".to_string()
345 },
346 })
347}
348
349pub fn issue_search(
350 conn: &Connection,
351 query: &str,
352 limit: u32,
353) -> anyhow::Result<Vec<GitHubEvidence>> {
354 search_fts(conn, query, Some("issue"), limit)
355}
356
357pub fn rationale_search(
358 conn: &Connection,
359 query: &str,
360 limit: u32,
361) -> anyhow::Result<Vec<GitHubEvidence>> {
362 let mut evidence = Vec::new();
363 let default_repo = default_repo();
364 for reference in parse_refs(query, default_repo.as_deref()) {
365 evidence.extend(evidence_for_issue(
366 conn,
367 &reference.owner,
368 &reference.repo,
369 reference.number,
370 limit,
371 )?);
372 }
373 evidence.extend(search_fts(conn, query, None, limit)?);
374 dedupe_evidence(&mut evidence);
375 evidence.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
376 Ok(evidence)
377}
378
379pub fn refs_for_path(conn: &Connection, path: &str, limit: u32) -> anyhow::Result<Vec<GitHubRef>> {
380 let mut stmt = conn.prepare(
381 "
382 SELECT owner, repo, number, ref_kind, source_kind, source_path, source_commit, source_text
383 FROM github_refs
384 WHERE source_path = ?1
385 ORDER BY id DESC
386 LIMIT ?2
387 ",
388 )?;
389 let rows = stmt.query_map(params![path, i64::from(limit)], ref_row)?;
390 collect_rows(rows)
391}
392
393pub fn papertrail_for_chunk(
394 conn: &Connection,
395 chunk: &crate::query::ReadChunk,
396 limit: u32,
397) -> anyhow::Result<Papertrail> {
398 let mut evidence = evidence_for_path(conn, &chunk.path, limit)?;
399 if evidence.is_empty() {
400 evidence = rationale_search(conn, &chunk.path, limit)?;
401 }
402 Ok(Papertrail {
403 current_source: Some(CurrentSourceEvidence {
404 chunk_id: Some(chunk.chunk_id),
405 path: chunk.path.clone(),
406 start_line: Some(chunk.start_line),
407 end_line: Some(chunk.end_line),
408 symbol: chunk.symbol_path.clone(),
409 }),
410 github_evidence: evidence,
411 fallback_github_evidence: Vec::new(),
412 })
413}
414
415pub fn papertrail_for_symbol(
416 conn: &Connection,
417 symbol: &crate::query::symbol::SymbolHit,
418 limit: u32,
419) -> anyhow::Result<Papertrail> {
420 let mut evidence = evidence_for_path(conn, &symbol.path, limit)?;
421 evidence.extend(rationale_search(conn, &symbol.qualified_name, limit)?);
422 dedupe_evidence(&mut evidence);
423 evidence.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
424 let (start_line, end_line, chunk_id) = current_symbol_span(conn, symbol)?;
425 Ok(Papertrail {
426 current_source: Some(CurrentSourceEvidence {
427 chunk_id,
428 path: symbol.path.clone(),
429 start_line,
430 end_line,
431 symbol: Some(symbol.qualified_name.clone()),
432 }),
433 github_evidence: evidence,
434 fallback_github_evidence: Vec::new(),
435 })
436}
437
438pub fn papertrail_for_commit(
439 conn: &Connection,
440 commit_hash: &str,
441 limit: u32,
442) -> anyhow::Result<Papertrail> {
443 let mut evidence = evidence_for_commit_refs(conn, commit_hash, limit)?;
444 let mut fallback_evidence = Vec::new();
445 if evidence.is_empty() {
446 let mut stmt = conn.prepare(
447 "SELECT path FROM git_file_changes WHERE commit_hash LIKE ?1 ORDER BY path LIMIT ?2",
448 )?;
449 let commit_like = format!("{commit_hash}%");
450 let rows =
451 stmt.query_map(params![commit_like, i64::from(limit)], |row| row.get::<_, String>(0))?;
452 for row in rows {
453 fallback_evidence.extend(evidence_for_path(conn, &row?, limit)?);
454 }
455 fallback_evidence.extend(rationale_search(conn, commit_hash, limit)?);
456 mark_fallback_evidence(&mut fallback_evidence);
457 }
458 dedupe_evidence(&mut evidence);
459 dedupe_evidence(&mut fallback_evidence);
460 evidence.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
461 fallback_evidence.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
462 Ok(Papertrail {
463 current_source: None,
464 github_evidence: evidence,
465 fallback_github_evidence: fallback_evidence,
466 })
467}
468
469fn mark_fallback_evidence(evidence: &mut [GitHubEvidence]) {
470 for item in evidence {
471 item.evidence_kind = match item.evidence_kind {
472 "literal_github_ref" => "fallback_literal_github_ref",
473 "historical_github" => "fallback_historical_github",
474 _ => "fallback_github_evidence",
475 };
476 item.score = item.score.min(0.25);
477 }
478}
479
480pub fn discover_and_store_refs(conn: &Connection, root: &Path) -> anyhow::Result<Vec<GitHubRef>> {
481 let default_repo = default_repo();
482 let mut refs = Vec::new();
483 discover_commit_refs(conn, default_repo.as_deref(), &mut refs)?;
484 discover_file_refs(conn, root, default_repo.as_deref(), &mut refs)?;
485 let branch = git_output(root, &["branch", "--show-current"]).unwrap_or_default();
486 for parsed in parse_refs(&branch, default_repo.as_deref()) {
487 refs.push(GitHubRef {
488 owner: parsed.owner,
489 repo: parsed.repo,
490 number: parsed.number,
491 ref_kind: parsed.kind,
492 source_kind: "branch".to_string(),
493 source_path: None,
494 source_commit: None,
495 source_text: branch.clone(),
496 });
497 }
498 let mut unique = BTreeSet::new();
499 refs.retain(|r| {
500 unique.insert((
501 r.owner.clone(),
502 r.repo.clone(),
503 r.number,
504 r.source_kind.clone(),
505 r.source_path.clone(),
506 r.source_commit.clone(),
507 r.source_text.clone(),
508 ))
509 });
510 for reference in &refs {
511 store_ref(conn, reference)?;
512 }
513 Ok(refs)
514}
515
516#[derive(Default)]
517struct SyncRefsReport {
518 synced_items: usize,
519 skipped_refs: usize,
520 failed_refs: usize,
521 errors: Vec<GitHubSyncError>,
522}
523
524fn sync_refs<'a, C: GitHubClient>(
525 conn: &Connection,
526 client: &C,
527 refs: impl Iterator<Item = &'a GitHubRef>,
528 progress: &mut impl FnMut(GitHubSyncProgress),
529) -> anyhow::Result<SyncRefsReport> {
530 let refs = refs.collect::<Vec<_>>();
531 let total = refs
532 .iter()
533 .map(|reference| (reference.owner.clone(), reference.repo.clone(), reference.number))
534 .collect::<BTreeSet<_>>()
535 .len();
536 let mut report = SyncRefsReport::default();
537 let mut seen = BTreeSet::new();
538 for reference in refs {
539 if !seen.insert((reference.owner.clone(), reference.repo.clone(), reference.number)) {
540 continue;
541 }
542 let current = seen.len();
543 if github_ref_synced(conn, reference)? {
544 report.skipped_refs += 1;
545 progress(sync_progress(reference, current, total, GitHubSyncAction::Skipped, None));
546 continue;
547 }
548 progress(sync_progress(reference, current, total, GitHubSyncAction::Syncing, None));
549 match sync_one_ref(conn, client, reference) {
550 Ok(items) => {
551 report.synced_items += items;
552 mark_ref_sync(conn, reference, "synced", None)?;
553 progress(sync_progress(reference, current, total, GitHubSyncAction::Synced, None));
554 },
555 Err(err) => {
556 let message = err.to_string();
557 let status = if is_not_found_error(&message) { "not_found" } else { "failed" };
558 mark_ref_sync(conn, reference, status, Some(&message))?;
559 report.failed_refs += 1;
560 report.errors.push(GitHubSyncError {
561 owner: reference.owner.clone(),
562 repo: reference.repo.clone(),
563 number: reference.number,
564 status: status.to_string(),
565 error: message.clone(),
566 });
567 progress(sync_progress(
568 reference,
569 current,
570 total,
571 GitHubSyncAction::Failed,
572 Some(message),
573 ));
574 },
575 }
576 }
577 progress(GitHubSyncProgress {
578 current: total,
579 total,
580 owner: String::new(),
581 repo: String::new(),
582 number: 0,
583 action: GitHubSyncAction::RebuildingFts,
584 message: None,
585 });
586 rebuild_fts(conn)?;
587 Ok(report)
588}
589
590fn sync_one_ref<C: GitHubClient>(
591 conn: &Connection,
592 client: &C,
593 reference: &GitHubRef,
594) -> anyhow::Result<usize> {
595 let mut synced = 0;
596 let issue = client.issue(&reference.owner, &reference.repo, reference.number)?;
597 store_issue(conn, &issue)?;
598 synced += 1;
599 for comment in client.issue_comments(&reference.owner, &reference.repo, reference.number)? {
600 store_comment(conn, &comment)?;
601 synced += 1;
602 }
603 if let Some(pull) = client.pull(&reference.owner, &reference.repo, reference.number)? {
604 store_pull(conn, &pull)?;
605 synced += 1;
606 for review in client.pull_reviews(&reference.owner, &reference.repo, reference.number)? {
607 store_review(conn, &review)?;
608 synced += 1;
609 }
610 for comment in
611 client.pull_review_comments(&reference.owner, &reference.repo, reference.number)?
612 {
613 store_review_comment(conn, &comment)?;
614 synced += 1;
615 }
616 }
617 Ok(synced)
618}
619
620fn sync_progress(
621 reference: &GitHubRef,
622 current: usize,
623 total: usize,
624 action: GitHubSyncAction,
625 message: Option<String>,
626) -> GitHubSyncProgress {
627 GitHubSyncProgress {
628 current,
629 total,
630 owner: reference.owner.clone(),
631 repo: reference.repo.clone(),
632 number: reference.number,
633 action,
634 message,
635 }
636}
637
638fn github_ref_synced(conn: &Connection, reference: &GitHubRef) -> anyhow::Result<bool> {
639 let status = conn
640 .query_row(
641 "
642 SELECT status
643 FROM github_ref_sync
644 WHERE owner = ?1 AND repo = ?2 AND number = ?3
645 ",
646 params![reference.owner, reference.repo, reference.number],
647 |row| row.get::<_, String>(0),
648 )
649 .optional()?;
650 if matches!(status.as_deref(), Some("synced" | "not_found")) {
651 return Ok(true);
652 }
653 let cached_issue = conn.query_row(
654 "
655 SELECT EXISTS(
656 SELECT 1 FROM github_issues
657 WHERE owner = ?1 AND repo = ?2 AND number = ?3
658 )
659 ",
660 params![reference.owner, reference.repo, reference.number],
661 |row| row.get::<_, bool>(0),
662 )?;
663 Ok(cached_issue)
664}
665
666fn mark_ref_sync(
667 conn: &Connection,
668 reference: &GitHubRef,
669 status: &str,
670 error: Option<&str>,
671) -> anyhow::Result<()> {
672 conn.execute(
673 "
674 INSERT INTO github_ref_sync(owner, repo, number, status, synced_at_ms, last_error)
675 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
676 ON CONFLICT(owner, repo, number) DO UPDATE SET
677 status = excluded.status,
678 synced_at_ms = excluded.synced_at_ms,
679 last_error = excluded.last_error
680 ",
681 params![reference.owner, reference.repo, reference.number, status, now_ms(), error],
682 )?;
683 Ok(())
684}
685
686fn is_not_found_error(message: &str) -> bool {
687 message.contains("HTTP 404") || message.to_ascii_lowercase().contains("not found")
688}
689
690fn discover_commit_refs(
691 conn: &Connection,
692 default_repo: Option<&str>,
693 out: &mut Vec<GitHubRef>,
694) -> anyhow::Result<()> {
695 let mut stmt = conn.prepare("SELECT hash, subject, body FROM git_commits")?;
696 let rows = stmt.query_map([], |row| {
697 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
698 })?;
699 for row in rows {
700 let (hash, subject, body) = row?;
701 for text in [subject, body] {
702 for parsed in parse_refs(&text, default_repo) {
703 out.push(GitHubRef {
704 owner: parsed.owner,
705 repo: parsed.repo,
706 number: parsed.number,
707 ref_kind: parsed.kind,
708 source_kind: "commit".to_string(),
709 source_path: None,
710 source_commit: Some(hash.clone()),
711 source_text: text.clone(),
712 });
713 }
714 }
715 }
716 Ok(())
717}
718
719fn discover_file_refs(
720 conn: &Connection,
721 root: &Path,
722 default_repo: Option<&str>,
723 out: &mut Vec<GitHubRef>,
724) -> anyhow::Result<()> {
725 let mut stmt = conn.prepare("SELECT path FROM files ORDER BY path")?;
726 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
727 for row in rows {
728 let path = row?;
729 let Ok(text) = std::fs::read_to_string(root.join(&path)) else {
730 continue;
731 };
732 for line in text.lines() {
733 for parsed in parse_refs(line, default_repo) {
734 out.push(GitHubRef {
735 owner: parsed.owner,
736 repo: parsed.repo,
737 number: parsed.number,
738 ref_kind: parsed.kind,
739 source_kind: "file".to_string(),
740 source_path: Some(path.clone()),
741 source_commit: None,
742 source_text: line.trim().to_string(),
743 });
744 }
745 }
746 }
747 Ok(())
748}
749
750fn store_ref(conn: &Connection, reference: &GitHubRef) -> anyhow::Result<()> {
751 conn.execute(
752 "
753 INSERT OR IGNORE INTO github_refs(
754 owner, repo, number, ref_kind, source_kind, source_path, source_commit, source_text, discovered_at_ms
755 )
756 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
757 ",
758 params![
759 reference.owner,
760 reference.repo,
761 reference.number,
762 reference.ref_kind,
763 reference.source_kind,
764 reference.source_path,
765 reference.source_commit,
766 reference.source_text,
767 now_ms(),
768 ],
769 )?;
770 Ok(())
771}
772
773fn store_issue(conn: &Connection, issue: &GitHubIssue) -> anyhow::Result<()> {
774 conn.execute(
775 "
776 INSERT INTO github_issues(owner, repo, number, html_url, state, title, body, author, created_at, updated_at, is_pull_request, synced_at_ms)
777 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
778 ON CONFLICT(owner, repo, number) DO UPDATE SET
779 html_url = excluded.html_url, state = excluded.state, title = excluded.title,
780 body = excluded.body, author = excluded.author, created_at = excluded.created_at,
781 updated_at = excluded.updated_at, is_pull_request = excluded.is_pull_request,
782 synced_at_ms = excluded.synced_at_ms
783 ",
784 params![
785 issue.owner,
786 issue.repo,
787 issue.number,
788 issue.html_url,
789 issue.state,
790 issue.title,
791 issue.body,
792 issue.author,
793 issue.created_at,
794 issue.updated_at,
795 issue.is_pull_request,
796 now_ms(),
797 ],
798 )?;
799 Ok(())
800}
801
802fn store_comment(conn: &Connection, comment: &GitHubComment) -> anyhow::Result<()> {
803 conn.execute(
804 "
805 INSERT OR REPLACE INTO github_comments(id, owner, repo, number, html_url, body, author, created_at, updated_at, synced_at_ms)
806 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
807 ",
808 params![
809 comment.id,
810 comment.owner,
811 comment.repo,
812 comment.number,
813 comment.html_url,
814 comment.body,
815 comment.author,
816 comment.created_at,
817 comment.updated_at,
818 now_ms(),
819 ],
820 )?;
821 Ok(())
822}
823
824fn store_pull(conn: &Connection, pull: &GitHubPullRequest) -> anyhow::Result<()> {
825 conn.execute(
826 "
827 INSERT INTO github_pull_requests(owner, repo, number, html_url, state, title, body, author, created_at, updated_at, merged_at, synced_at_ms)
828 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
829 ON CONFLICT(owner, repo, number) DO UPDATE SET
830 html_url = excluded.html_url, state = excluded.state, title = excluded.title,
831 body = excluded.body, author = excluded.author, created_at = excluded.created_at,
832 updated_at = excluded.updated_at, merged_at = excluded.merged_at,
833 synced_at_ms = excluded.synced_at_ms
834 ",
835 params![
836 pull.owner,
837 pull.repo,
838 pull.number,
839 pull.html_url,
840 pull.state,
841 pull.title,
842 pull.body,
843 pull.author,
844 pull.created_at,
845 pull.updated_at,
846 pull.merged_at,
847 now_ms(),
848 ],
849 )?;
850 Ok(())
851}
852
853fn store_review(conn: &Connection, review: &GitHubReview) -> anyhow::Result<()> {
854 conn.execute(
855 "
856 INSERT OR REPLACE INTO github_reviews(id, owner, repo, number, html_url, state, body, author, submitted_at, synced_at_ms)
857 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
858 ",
859 params![
860 review.id,
861 review.owner,
862 review.repo,
863 review.number,
864 review.html_url,
865 review.state,
866 review.body,
867 review.author,
868 review.submitted_at,
869 now_ms(),
870 ],
871 )?;
872 Ok(())
873}
874
875fn store_review_comment(conn: &Connection, comment: &GitHubReviewComment) -> anyhow::Result<()> {
876 conn.execute(
877 "
878 INSERT OR REPLACE INTO github_review_comments(id, owner, repo, number, path, html_url, body, author, created_at, updated_at, synced_at_ms)
879 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
880 ",
881 params![
882 comment.id,
883 comment.owner,
884 comment.repo,
885 comment.number,
886 comment.path,
887 comment.html_url,
888 comment.body,
889 comment.author,
890 comment.created_at,
891 comment.updated_at,
892 now_ms(),
893 ],
894 )?;
895 Ok(())
896}
897
898pub fn rebuild_fts(conn: &Connection) -> anyhow::Result<()> {
899 conn.execute("DELETE FROM github_fts", [])?;
900 insert_issue_fts(conn)?;
901 insert_comment_fts(conn)?;
902 insert_pull_fts(conn)?;
903 insert_review_fts(conn)?;
904 insert_review_comment_fts(conn)?;
905 Ok(())
906}
907
908fn insert_issue_fts(conn: &Connection) -> anyhow::Result<()> {
909 let mut stmt =
910 conn.prepare("SELECT id, owner, repo, number, html_url, title, body FROM github_issues")?;
911 let rows = stmt.query_map([], |row| {
912 Ok((
913 row.get::<_, i64>(0)?,
914 row.get::<_, String>(1)?,
915 row.get::<_, String>(2)?,
916 row.get::<_, i64>(3)?,
917 row.get::<_, String>(4)?,
918 row.get::<_, String>(5)?,
919 row.get::<_, String>(6)?,
920 ))
921 })?;
922 for row in rows {
923 let (id, owner, repo, number, url, title, body) = row?;
924 insert_fts(
925 conn,
926 FtsRow {
927 owner: &owner,
928 repo: &repo,
929 number,
930 kind: "issue",
931 item_id: &id.to_string(),
932 url: &url,
933 title: &title,
934 body: &body,
935 },
936 )?;
937 }
938 Ok(())
939}
940
941fn insert_comment_fts(conn: &Connection) -> anyhow::Result<()> {
942 let mut stmt =
943 conn.prepare("SELECT id, owner, repo, number, html_url, body FROM github_comments")?;
944 let rows = stmt.query_map([], |row| {
945 Ok((
946 row.get::<_, i64>(0)?,
947 row.get::<_, String>(1)?,
948 row.get::<_, String>(2)?,
949 row.get::<_, i64>(3)?,
950 row.get::<_, String>(4)?,
951 row.get::<_, String>(5)?,
952 ))
953 })?;
954 for row in rows {
955 let (id, owner, repo, number, url, body) = row?;
956 insert_fts(
957 conn,
958 FtsRow {
959 owner: &owner,
960 repo: &repo,
961 number,
962 kind: "comment",
963 item_id: &id.to_string(),
964 url: &url,
965 title: "",
966 body: &body,
967 },
968 )?;
969 }
970 Ok(())
971}
972
973fn insert_pull_fts(conn: &Connection) -> anyhow::Result<()> {
974 let mut stmt = conn.prepare(
975 "SELECT id, owner, repo, number, html_url, title, body FROM github_pull_requests",
976 )?;
977 let rows = stmt.query_map([], |row| {
978 Ok((
979 row.get::<_, i64>(0)?,
980 row.get::<_, String>(1)?,
981 row.get::<_, String>(2)?,
982 row.get::<_, i64>(3)?,
983 row.get::<_, String>(4)?,
984 row.get::<_, String>(5)?,
985 row.get::<_, String>(6)?,
986 ))
987 })?;
988 for row in rows {
989 let (id, owner, repo, number, url, title, body) = row?;
990 insert_fts(
991 conn,
992 FtsRow {
993 owner: &owner,
994 repo: &repo,
995 number,
996 kind: "pull",
997 item_id: &id.to_string(),
998 url: &url,
999 title: &title,
1000 body: &body,
1001 },
1002 )?;
1003 }
1004 Ok(())
1005}
1006
1007fn insert_review_fts(conn: &Connection) -> anyhow::Result<()> {
1008 let mut stmt = conn.prepare(
1009 "SELECT id, owner, repo, number, COALESCE(html_url, ''), body FROM github_reviews",
1010 )?;
1011 let rows = stmt.query_map([], |row| {
1012 Ok((
1013 row.get::<_, i64>(0)?,
1014 row.get::<_, String>(1)?,
1015 row.get::<_, String>(2)?,
1016 row.get::<_, i64>(3)?,
1017 row.get::<_, String>(4)?,
1018 row.get::<_, String>(5)?,
1019 ))
1020 })?;
1021 for row in rows {
1022 let (id, owner, repo, number, url, body) = row?;
1023 insert_fts(
1024 conn,
1025 FtsRow {
1026 owner: &owner,
1027 repo: &repo,
1028 number,
1029 kind: "review",
1030 item_id: &id.to_string(),
1031 url: &url,
1032 title: "",
1033 body: &body,
1034 },
1035 )?;
1036 }
1037 Ok(())
1038}
1039
1040fn insert_review_comment_fts(conn: &Connection) -> anyhow::Result<()> {
1041 let mut stmt = conn.prepare(
1042 "SELECT id, owner, repo, number, html_url, COALESCE(path, ''), body FROM github_review_comments",
1043 )?;
1044 let rows = stmt.query_map([], |row| {
1045 Ok((
1046 row.get::<_, i64>(0)?,
1047 row.get::<_, String>(1)?,
1048 row.get::<_, String>(2)?,
1049 row.get::<_, i64>(3)?,
1050 row.get::<_, String>(4)?,
1051 row.get::<_, String>(5)?,
1052 row.get::<_, String>(6)?,
1053 ))
1054 })?;
1055 for row in rows {
1056 let (id, owner, repo, number, url, path, body) = row?;
1057 insert_fts(
1058 conn,
1059 FtsRow {
1060 owner: &owner,
1061 repo: &repo,
1062 number,
1063 kind: "review_comment",
1064 item_id: &id.to_string(),
1065 url: &url,
1066 title: &path,
1067 body: &body,
1068 },
1069 )?;
1070 }
1071 Ok(())
1072}
1073
1074struct FtsRow<'a> {
1075 owner: &'a str,
1076 repo: &'a str,
1077 number: i64,
1078 kind: &'a str,
1079 item_id: &'a str,
1080 url: &'a str,
1081 title: &'a str,
1082 body: &'a str,
1083}
1084
1085fn insert_fts(conn: &Connection, row: FtsRow<'_>) -> anyhow::Result<()> {
1086 conn.execute(
1087 "INSERT INTO github_fts(owner, repo, number, item_kind, item_id, url, title, body, classification)
1088 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1089 params![
1090 row.owner,
1091 row.repo,
1092 row.number,
1093 row.kind,
1094 row.item_id,
1095 row.url,
1096 row.title,
1097 row.body,
1098 classify_text(&format!("{}\n{}", row.title, row.body))
1099 ],
1100 )?;
1101 Ok(())
1102}
1103
1104fn evidence_for_path(
1105 conn: &Connection,
1106 path: &str,
1107 limit: u32,
1108) -> anyhow::Result<Vec<GitHubEvidence>> {
1109 let refs = refs_for_path(conn, path, limit)?;
1110 let mut evidence = Vec::new();
1111 for reference in refs {
1112 evidence.extend(evidence_for_issue(
1113 conn,
1114 &reference.owner,
1115 &reference.repo,
1116 reference.number,
1117 limit,
1118 )?);
1119 }
1120 evidence.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
1121 Ok(evidence)
1122}
1123
1124fn current_symbol_span(
1125 conn: &Connection,
1126 symbol: &crate::query::symbol::SymbolHit,
1127) -> anyhow::Result<(Option<i64>, Option<i64>, Option<i64>)> {
1128 let span = conn
1129 .query_row(
1130 "
1131 SELECT chunks.id, chunks.start_line, chunks.end_line
1132 FROM chunks
1133 JOIN files ON files.id = chunks.file_id
1134 WHERE files.path = ?1
1135 AND (chunks.symbol_path = ?2 OR chunks.symbol_path = ?3)
1136 ORDER BY
1137 CASE WHEN chunks.symbol_path = ?2 THEN 0 ELSE 1 END,
1138 chunks.start_line
1139 LIMIT 1
1140 ",
1141 params![symbol.path, symbol.qualified_name, symbol.symbol_path],
1142 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?)),
1143 )
1144 .optional()?;
1145 Ok(match span {
1146 Some((chunk_id, start_line, end_line)) => {
1147 (Some(start_line), Some(end_line), Some(chunk_id))
1148 },
1149 None => (None, None, None),
1150 })
1151}
1152
1153fn evidence_for_issue(
1154 conn: &Connection,
1155 owner: &str,
1156 repo: &str,
1157 number: i64,
1158 limit: u32,
1159) -> anyhow::Result<Vec<GitHubEvidence>> {
1160 let mut stmt = conn.prepare(
1161 "
1162 SELECT owner, repo, number, item_kind, item_id, url, title, body, classification, 0.0
1163 FROM github_fts
1164 WHERE owner = ?1 AND repo = ?2 AND number = ?3
1165 LIMIT ?4
1166 ",
1167 )?;
1168 let rows = stmt.query_map(params![owner, repo, number, i64::from(limit)], evidence_row)?;
1169 let mut evidence = collect_rows(rows)?;
1170 for item in &mut evidence {
1171 item.evidence_kind = "literal_github_ref";
1172 item.score = 1.0;
1173 }
1174 Ok(evidence)
1175}
1176
1177fn evidence_for_commit_refs(
1178 conn: &Connection,
1179 commit_hash: &str,
1180 limit: u32,
1181) -> anyhow::Result<Vec<GitHubEvidence>> {
1182 let mut stmt = conn.prepare(
1183 "
1184 SELECT owner, repo, number
1185 FROM github_refs
1186 WHERE source_kind = 'commit'
1187 AND source_commit LIKE ?1
1188 ORDER BY ref_kind = 'closing' DESC, id DESC
1189 LIMIT ?2
1190 ",
1191 )?;
1192 let commit_like = format!("{commit_hash}%");
1193 let refs = stmt.query_map(params![commit_like, i64::from(limit)], |row| {
1194 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))
1195 })?;
1196 let mut evidence = Vec::new();
1197 for reference in refs {
1198 let (owner, repo, number) = reference?;
1199 evidence.extend(evidence_for_issue(conn, &owner, &repo, number, limit)?);
1200 }
1201 dedupe_evidence(&mut evidence);
1202 evidence.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
1203 Ok(evidence)
1204}
1205
1206fn search_fts(
1207 conn: &Connection,
1208 query: &str,
1209 kind: Option<&str>,
1210 limit: u32,
1211) -> anyhow::Result<Vec<GitHubEvidence>> {
1212 let fts_query = fts_query(query);
1213 let kind_clause = kind.map(|_| "AND item_kind = ?3").unwrap_or("");
1214 let sql = format!(
1215 "
1216 SELECT owner, repo, number, item_kind, item_id, url, title, body, classification,
1217 bm25(github_fts) AS score
1218 FROM github_fts
1219 WHERE github_fts MATCH ?1
1220 {kind_clause}
1221 ORDER BY score
1222 LIMIT ?2
1223 "
1224 );
1225 let mut stmt = conn.prepare(&sql)?;
1226 let rows = if let Some(kind) = kind {
1227 stmt.query_map(params![fts_query, i64::from(limit), kind], evidence_row)?
1228 } else {
1229 stmt.query_map(params![fts_query, i64::from(limit)], evidence_row)?
1230 };
1231 let mut hits = collect_rows(rows)?;
1232 for (rank, hit) in hits.iter_mut().enumerate() {
1233 hit.score = positive_rank_score(rank);
1234 }
1235 Ok(hits)
1236}
1237
1238fn positive_rank_score(rank: usize) -> f64 {
1239 1.0 / ((rank + 1) as f64).sqrt()
1240}
1241
1242fn dedupe_evidence(evidence: &mut Vec<GitHubEvidence>) {
1243 let mut seen = BTreeSet::new();
1244 evidence.retain(|item| {
1245 seen.insert((
1246 item.owner.clone(),
1247 item.repo.clone(),
1248 item.number,
1249 item.item_kind.clone(),
1250 item.item_id.clone(),
1251 ))
1252 });
1253}
1254
1255fn evidence_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<GitHubEvidence> {
1256 let title: String = row.get(6)?;
1257 let body: String = row.get(7)?;
1258 Ok(GitHubEvidence {
1259 owner: row.get(0)?,
1260 repo: row.get(1)?,
1261 number: row.get(2)?,
1262 item_kind: row.get(3)?,
1263 item_id: row.get(4)?,
1264 url: row.get(5)?,
1265 title,
1266 snippet: snippet(&body),
1267 classification: row.get(8)?,
1268 evidence_kind: "historical_github",
1269 score: row.get(9)?,
1270 })
1271}
1272
1273fn ref_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<GitHubRef> {
1274 Ok(GitHubRef {
1275 owner: row.get(0)?,
1276 repo: row.get(1)?,
1277 number: row.get(2)?,
1278 ref_kind: row.get(3)?,
1279 source_kind: row.get(4)?,
1280 source_path: row.get(5)?,
1281 source_commit: row.get(6)?,
1282 source_text: row.get(7)?,
1283 })
1284}
1285
1286fn refs(conn: &Connection) -> anyhow::Result<Vec<GitHubRef>> {
1287 let mut stmt = conn.prepare(
1288 "SELECT owner, repo, number, ref_kind, source_kind, source_path, source_commit, source_text FROM github_refs",
1289 )?;
1290 let rows = stmt.query_map([], ref_row)?;
1291 collect_rows(rows)
1292}
1293
1294#[derive(Debug, Clone)]
1295struct ParsedRef {
1296 owner: String,
1297 repo: String,
1298 number: i64,
1299 kind: String,
1300}
1301
1302fn parse_refs(text: &str, default_repo: Option<&str>) -> Vec<ParsedRef> {
1303 let mut refs = Vec::new();
1304 let tokens = text
1305 .split(|c: char| c.is_whitespace() || [',', ';', ')', ']', '}'].contains(&c))
1306 .map(|token| token.trim_matches(|c: char| ['(', '[', '{', '.', ':'].contains(&c)))
1307 .filter(|token| !token.is_empty())
1308 .collect::<Vec<_>>();
1309 let mut previous = "";
1310 for token in tokens {
1311 let kind = ref_kind(previous);
1312 if let Some(parsed) = parse_issue_ref(token, default_repo) {
1313 refs.push(ParsedRef { kind, ..parsed });
1314 }
1315 previous = token;
1316 }
1317 refs
1318}
1319
1320fn parse_issue_ref(token: &str, default_repo: Option<&str>) -> Option<ParsedRef> {
1321 if let Some(rest) = token.strip_prefix("https://github.com/") {
1322 let parts = rest.split('/').collect::<Vec<_>>();
1323 if parts.len() >= 4 && (parts[2] == "issues" || parts[2] == "pull") {
1324 return Some(ParsedRef {
1325 owner: parts[0].to_string(),
1326 repo: parts[1].to_string(),
1327 number: parts[3].parse().ok()?,
1328 kind: "url".to_string(),
1329 });
1330 }
1331 }
1332 if let Some((repo_ref, number)) = token.split_once('#') {
1333 let parts = repo_ref.split('/').collect::<Vec<_>>();
1334 if parts.len() == 2 {
1335 return Some(ParsedRef {
1336 owner: parts[0].to_string(),
1337 repo: parts[1].to_string(),
1338 number: number.parse().ok()?,
1339 kind: "cross_repo".to_string(),
1340 });
1341 }
1342 }
1343 if let Some(number) = token.strip_prefix("GH-") {
1344 let (owner, repo) = split_repo(default_repo?)?;
1345 return Some(ParsedRef {
1346 owner: owner.to_string(),
1347 repo: repo.to_string(),
1348 number: number.parse().ok()?,
1349 kind: "gh_dash".to_string(),
1350 });
1351 }
1352 if let Some(number) = token.strip_prefix('#') {
1353 let (owner, repo) = split_repo(default_repo?)?;
1354 return Some(ParsedRef {
1355 owner: owner.to_string(),
1356 repo: repo.to_string(),
1357 number: number.parse().ok()?,
1358 kind: "local_number".to_string(),
1359 });
1360 }
1361 None
1362}
1363
1364fn ref_kind(previous: &str) -> String {
1365 let previous = previous.to_ascii_lowercase();
1366 if ["fixes", "fixed", "closes", "closed", "resolves", "resolved"].contains(&previous.as_str()) {
1367 "closing".to_string()
1368 } else if ["refs", "ref", "see", "related"].contains(&previous.as_str()) {
1369 "reference".to_string()
1370 } else {
1371 "unknown".to_string()
1372 }
1373}
1374
1375fn classify_text(text: &str) -> String {
1376 let text = text.to_ascii_lowercase();
1377 if text.contains("decided") || text.contains("decision") || text.contains("we will") {
1378 "decision"
1379 } else if text.contains("rejected") || text.contains("alternative") || text.contains("instead")
1380 {
1381 "rejected_alternative"
1382 } else if text.contains("must") || text.contains("constraint") || text.contains("required") {
1383 "constraint"
1384 } else if text.contains("risk") || text.contains("concern") || text.contains("blocked") {
1385 "risk"
1386 } else if text.contains("obsolete") || text.contains("deprecated") || text.contains("no longer")
1387 {
1388 "obsolete"
1389 } else {
1390 "context"
1391 }
1392 .to_string()
1393}
1394
1395fn issue_from_value(owner: &str, repo: &str, value: &Value) -> GitHubIssue {
1396 GitHubIssue {
1397 owner: owner.to_string(),
1398 repo: repo.to_string(),
1399 number: value["number"].as_i64().unwrap_or_default(),
1400 html_url: string_value(value, "html_url"),
1401 state: string_value(value, "state"),
1402 title: string_value(value, "title"),
1403 body: string_value(value, "body"),
1404 author: value.pointer("/user/login").and_then(Value::as_str).map(str::to_string),
1405 created_at: value["created_at"].as_str().map(str::to_string),
1406 updated_at: value["updated_at"].as_str().map(str::to_string),
1407 is_pull_request: value.get("pull_request").is_some(),
1408 }
1409}
1410
1411fn comment_from_value(owner: &str, repo: &str, number: i64, value: &Value) -> GitHubComment {
1412 GitHubComment {
1413 id: value["id"].as_i64().unwrap_or_default(),
1414 owner: owner.to_string(),
1415 repo: repo.to_string(),
1416 number,
1417 html_url: string_value(value, "html_url"),
1418 body: string_value(value, "body"),
1419 author: value.pointer("/user/login").and_then(Value::as_str).map(str::to_string),
1420 created_at: value["created_at"].as_str().map(str::to_string),
1421 updated_at: value["updated_at"].as_str().map(str::to_string),
1422 }
1423}
1424
1425fn pull_from_value(owner: &str, repo: &str, number: i64, value: &Value) -> GitHubPullRequest {
1426 GitHubPullRequest {
1427 owner: owner.to_string(),
1428 repo: repo.to_string(),
1429 number,
1430 html_url: string_value(value, "html_url"),
1431 state: string_value(value, "state"),
1432 title: string_value(value, "title"),
1433 body: string_value(value, "body"),
1434 author: value.pointer("/user/login").and_then(Value::as_str).map(str::to_string),
1435 created_at: value["created_at"].as_str().map(str::to_string),
1436 updated_at: value["updated_at"].as_str().map(str::to_string),
1437 merged_at: value["merged_at"].as_str().map(str::to_string),
1438 }
1439}
1440
1441fn review_from_value(owner: &str, repo: &str, number: i64, value: &Value) -> GitHubReview {
1442 GitHubReview {
1443 id: value["id"].as_i64().unwrap_or_default(),
1444 owner: owner.to_string(),
1445 repo: repo.to_string(),
1446 number,
1447 html_url: value["html_url"].as_str().map(str::to_string),
1448 state: string_value(value, "state"),
1449 body: string_value(value, "body"),
1450 author: value.pointer("/user/login").and_then(Value::as_str).map(str::to_string),
1451 submitted_at: value["submitted_at"].as_str().map(str::to_string),
1452 }
1453}
1454
1455fn review_comment_from_value(
1456 owner: &str,
1457 repo: &str,
1458 number: i64,
1459 value: &Value,
1460) -> GitHubReviewComment {
1461 GitHubReviewComment {
1462 id: value["id"].as_i64().unwrap_or_default(),
1463 owner: owner.to_string(),
1464 repo: repo.to_string(),
1465 number,
1466 path: value["path"].as_str().map(str::to_string),
1467 html_url: string_value(value, "html_url"),
1468 body: string_value(value, "body"),
1469 author: value.pointer("/user/login").and_then(Value::as_str).map(str::to_string),
1470 created_at: value["created_at"].as_str().map(str::to_string),
1471 updated_at: value["updated_at"].as_str().map(str::to_string),
1472 }
1473}
1474
1475fn gh_api_json(path: &str) -> anyhow::Result<Value> {
1476 let output = Command::new("gh").args(["api", path]).output()?;
1477 if !output.status.success() {
1478 anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr).trim());
1479 }
1480 Ok(serde_json::from_slice(&output.stdout)?)
1481}
1482
1483fn gh_api_paginated(path: &str) -> anyhow::Result<Vec<Value>> {
1484 let output = Command::new("gh").args(["api", "--paginate", "--slurp", path]).output()?;
1485 if !output.status.success() {
1486 anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr).trim());
1487 }
1488 let value: Value = serde_json::from_slice(&output.stdout)?;
1489 let mut out = Vec::new();
1490 if let Some(pages) = value.as_array() {
1491 for page in pages {
1492 if let Some(items) = page.as_array() {
1493 out.extend(items.iter().cloned());
1494 }
1495 }
1496 }
1497 Ok(out)
1498}
1499
1500fn default_repo() -> Option<String> {
1501 let output = Command::new("gh")
1502 .args(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
1503 .output()
1504 .ok()?;
1505 output
1506 .status
1507 .success()
1508 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
1509 .filter(|value| !value.is_empty())
1510}
1511
1512fn gh_available() -> bool {
1513 Command::new("gh").arg("--version").output().is_ok_and(|output| output.status.success())
1514}
1515
1516fn git_output(root: &Path, args: &[&str]) -> Option<String> {
1517 let output = Command::new("git").args(args).current_dir(root).output().ok()?;
1518 output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
1519}
1520
1521fn string_value(value: &Value, key: &str) -> String {
1522 value[key].as_str().unwrap_or_default().to_string()
1523}
1524
1525fn split_repo(value: &str) -> Option<(&str, &str)> {
1526 value.split_once('/')
1527}
1528
1529fn snippet(text: &str) -> String {
1530 text.lines().take(3).collect::<Vec<_>>().join("\n")
1531}
1532
1533fn fts_query(query: &str) -> String {
1534 let terms = query
1535 .split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
1536 .filter(|term| !term.is_empty())
1537 .map(|term| format!("\"{}\"", term.replace('"', "\"\"")))
1538 .collect::<Vec<_>>();
1539 if terms.is_empty() { "\"\"".to_string() } else { terms.join(" OR ") }
1540}
1541
1542fn collect_rows<T>(
1543 rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>>,
1544) -> anyhow::Result<Vec<T>> {
1545 let mut out = Vec::new();
1546 for row in rows {
1547 out.push(row?);
1548 }
1549 Ok(out)
1550}
1551
1552fn count_table(conn: &Connection, table: &str) -> anyhow::Result<u64> {
1553 let count =
1554 conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row.get::<_, i64>(0))?;
1555 Ok(u64::try_from(count).unwrap_or(0))
1556}
1557
1558fn meta(conn: &Connection, key: &str) -> anyhow::Result<Option<String>> {
1559 Ok(conn
1560 .query_row("SELECT value FROM index_meta WHERE key = ?1", [key], |row| row.get(0))
1561 .optional()?)
1562}
1563
1564fn set_meta(conn: &Connection, key: &str, value: &str) -> anyhow::Result<()> {
1565 conn.execute(
1566 "INSERT INTO index_meta(key, value) VALUES (?1, ?2)
1567 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
1568 params![key, value],
1569 )?;
1570 Ok(())
1571}