Skip to main content

rag_rat_core/index/
github.rs

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}