1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use anyhow::{anyhow, Context, Result};
12use chrono::Utc;
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15use uuid::Uuid;
16
17use khive_runtime::{secret_gate, KhiveRuntime, NamespaceToken, VerbRegistry};
18use khive_storage::types::{SqlStatement, SqlValue};
19
20use crate::hook;
21use crate::refs;
22
23#[derive(Debug, Clone, Copy)]
27pub struct IngestInclude {
28 pub commits: bool,
29 pub issues: bool,
30 pub pull_requests: bool,
31}
32
33impl Default for IngestInclude {
34 fn default() -> Self {
35 Self {
36 commits: true,
37 issues: true,
38 pull_requests: true,
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct IngestOptions {
46 pub repo: PathBuf,
48 pub project: String,
50 pub max_items: Option<u64>,
54 pub include: IngestInclude,
56}
57
58impl IngestOptions {
59 pub fn unbounded(repo: PathBuf, project: String) -> Self {
62 Self {
63 repo,
64 project,
65 max_items: None,
66 include: IngestInclude::default(),
67 }
68 }
69}
70
71struct Budget {
74 remaining: Option<u64>,
75}
76
77impl Budget {
78 fn try_consume(&mut self) -> bool {
79 match &mut self.remaining {
80 None => true,
81 Some(0) => false,
82 Some(n) => {
83 *n -= 1;
84 true
85 }
86 }
87 }
88
89 fn exhausted(&self) -> bool {
90 matches!(self.remaining, Some(0))
91 }
92}
93
94struct NewRecordForRef {
98 id: Uuid,
99 text: String,
100}
101
102#[derive(Debug, Default, Serialize)]
104pub struct IngestReport {
105 pub commits_ingested: u64,
106 pub commits_skipped_existing: u64,
107 pub issues_ingested: u64,
108 pub issues_skipped_existing: u64,
109 pub prs_ingested: u64,
110 pub prs_skipped_existing: u64,
111 pub gh_available: bool,
114 pub warnings: Vec<String>,
115 pub done: bool,
120 pub project_id: Option<String>,
123 pub project_created: bool,
127 pub reference_edges_created: u64,
131 pub reference_edges_unresolved: u64,
135 pub parent_edges_created: u64,
138 pub commit_embeddings_truncated: u64,
143}
144
145pub async fn run_ingest(
154 runtime: &KhiveRuntime,
155 token: &NamespaceToken,
156 registry: &VerbRegistry,
157 opts: IngestOptions,
158) -> Result<IngestReport> {
159 run_ingest_with_commit_recovery(runtime, token, registry, opts, |_repo, _err| Ok(None)).await
160}
161
162pub(crate) async fn run_ingest_with_commit_recovery(
167 runtime: &KhiveRuntime,
168 token: &NamespaceToken,
169 registry: &VerbRegistry,
170 opts: IngestOptions,
171 mut recover: impl FnMut(&Path, &GitLogError) -> Result<Option<RecoveredRepo>> + Send,
172) -> Result<IngestReport> {
173 let mut report = IngestReport {
174 done: true,
175 ..IngestReport::default()
176 };
177
178 let project_id = resolve_id(runtime, token, &opts.project)
179 .await?
180 .ok_or_else(|| anyhow!("--project {:?} did not resolve to an entity", opts.project))?;
181 report.project_id = Some(project_id.to_string());
182
183 let mut merge_sha_to_pr: HashMap<String, Uuid> = HashMap::new();
184 let mut number_to_pr: HashMap<u64, Uuid> = HashMap::new();
185 let mut budget = Budget {
186 remaining: opts.max_items,
187 };
188 let mut new_records: Vec<NewRecordForRef> = Vec::new();
189
190 if opts.include.issues || opts.include.pull_requests {
196 if gh_available(&opts.repo) {
197 report.gh_available = true;
198 if opts.include.pull_requests && !budget.exhausted() {
199 match ingest_prs(
200 runtime,
201 token,
202 registry,
203 &opts.repo,
204 project_id,
205 &mut report,
206 &mut merge_sha_to_pr,
207 &mut number_to_pr,
208 &mut budget,
209 &mut new_records,
210 )
211 .await
212 {
213 Ok(()) => {}
214 Err(e) => report
215 .warnings
216 .push(format!("gh pr list failed, skipping pull requests: {e}")),
217 }
218 }
219 if opts.include.issues && !budget.exhausted() {
220 if let Err(e) = ingest_issues(
221 runtime,
222 token,
223 registry,
224 &opts.repo,
225 project_id,
226 &mut report,
227 &mut budget,
228 &mut new_records,
229 )
230 .await
231 {
232 report
233 .warnings
234 .push(format!("gh issue list failed, skipping issues: {e}"));
235 }
236 }
237 } else {
238 report.gh_available = false;
239 report.warnings.push(
240 "gh CLI not found on PATH; skipped issues and pull requests — commits still ingest"
241 .to_string(),
242 );
243 }
244 }
245
246 if opts.include.commits && !budget.exhausted() {
247 ingest_commits(
248 runtime,
249 token,
250 registry,
251 &opts.repo,
252 project_id,
253 &merge_sha_to_pr,
254 &number_to_pr,
255 &mut report,
256 &mut budget,
257 &mut new_records,
258 &mut recover,
259 )
260 .await?;
261 }
262
263 if budget.exhausted() {
264 report.done = false;
265 }
266
267 link_references(
268 runtime,
269 token,
270 registry,
271 project_id,
272 &new_records,
273 &mut report,
274 )
275 .await;
276
277 Ok(report)
278}
279
280async fn resolve_id(
283 runtime: &KhiveRuntime,
284 _token: &NamespaceToken,
285 raw: &str,
286) -> Result<Option<Uuid>> {
287 if let Ok(u) = Uuid::parse_str(raw) {
288 return Ok(Some(u));
289 }
290 runtime
291 .resolve_prefix_unfiltered(raw)
292 .await
293 .map_err(|e| anyhow!("{e}"))
294}
295
296pub async fn resolve_project_id(runtime: &KhiveRuntime, raw: &str) -> Result<Option<Uuid>> {
301 if let Ok(u) = Uuid::parse_str(raw) {
302 return Ok(Some(u));
303 }
304 runtime
305 .resolve_prefix_unfiltered(raw)
306 .await
307 .map_err(|e| anyhow!("{e}"))
308}
309
310async fn find_issue_or_pr_by_number(
313 runtime: &KhiveRuntime,
314 token: &NamespaceToken,
315 project_id: Uuid,
316 number: u64,
317) -> Result<Option<Uuid>> {
318 if let Some(id) = find_by_number(runtime, token, "issue", project_id, number).await? {
319 return Ok(Some(id));
320 }
321 find_by_number(runtime, token, "pull_request", project_id, number).await
322}
323
324async fn link_references(
329 runtime: &KhiveRuntime,
330 token: &NamespaceToken,
331 registry: &VerbRegistry,
332 project_id: Uuid,
333 new_records: &[NewRecordForRef],
334 report: &mut IngestReport,
335) {
336 for record in new_records {
337 let mentions = refs::dedupe_prefer_closes(refs::extract_references(&record.text));
338 for mention in mentions {
339 let target = match find_issue_or_pr_by_number(
340 runtime,
341 token,
342 project_id,
343 mention.number,
344 )
345 .await
346 {
347 Ok(Some(id)) => id,
348 Ok(None) => {
349 report.reference_edges_unresolved += 1;
350 continue;
351 }
352 Err(e) => {
353 report
354 .warnings
355 .push(format!("resolving reference #{}: {e}", mention.number));
356 continue;
357 }
358 };
359 if target == record.id {
360 continue;
363 }
364 match registry
365 .dispatch(
366 "link",
367 json!({
368 "source_id": record.id.to_string(),
369 "target_id": target.to_string(),
370 "relation": "annotates",
371 "metadata": { "ref_kind": mention.kind.as_str() },
372 }),
373 )
374 .await
375 {
376 Ok(_) => report.reference_edges_created += 1,
377 Err(e) => report.warnings.push(format!(
378 "linking reference #{} from {}: {e}",
379 mention.number, record.id
380 )),
381 }
382 }
383 }
384}
385
386fn gh_available(repo: &Path) -> bool {
388 Command::new("gh")
389 .arg("--version")
390 .current_dir(repo)
391 .output()
392 .map(|o| o.status.success())
393 .unwrap_or(false)
394}
395
396async fn find_commit_by_sha(
399 runtime: &KhiveRuntime,
400 token: &NamespaceToken,
401 sha: &str,
402) -> Result<Option<Uuid>> {
403 let sql = runtime.sql();
404 let mut r = sql.reader().await.map_err(|e| anyhow!("{e}"))?;
405 let row = r
406 .query_row(SqlStatement {
407 sql: "SELECT id FROM notes WHERE kind='commit' AND namespace=?1 \
408 AND deleted_at IS NULL AND json_extract(properties,'$.sha')=?2 LIMIT 1"
409 .into(),
410 params: vec![
411 SqlValue::Text(token.namespace().as_str().to_string()),
412 SqlValue::Text(sha.to_string()),
413 ],
414 label: Some("git_ingest_find_commit_by_sha".into()),
415 })
416 .await
417 .map_err(|e| anyhow!("{e}"))?;
418 Ok(row.and_then(|r| row_uuid(&r)))
419}
420
421async fn find_by_number(
425 runtime: &KhiveRuntime,
426 token: &NamespaceToken,
427 kind: &str,
428 project_id: Uuid,
429 number: u64,
430) -> Result<Option<Uuid>> {
431 let sql = runtime.sql();
432 let mut r = sql.reader().await.map_err(|e| anyhow!("{e}"))?;
433 let row = r
434 .query_row(SqlStatement {
435 sql: "SELECT id FROM notes WHERE kind=?1 AND namespace=?2 \
436 AND deleted_at IS NULL AND json_extract(properties,'$.number')=?3 \
437 AND json_extract(properties,'$.project_id')=?4 LIMIT 1"
438 .into(),
439 params: vec![
440 SqlValue::Text(kind.to_string()),
441 SqlValue::Text(token.namespace().as_str().to_string()),
442 SqlValue::Integer(number as i64),
443 SqlValue::Text(project_id.to_string()),
444 ],
445 label: Some("git_ingest_find_by_number".into()),
446 })
447 .await
448 .map_err(|e| anyhow!("{e}"))?;
449 Ok(row.and_then(|r| row_uuid(&r)))
450}
451
452fn row_uuid(row: &khive_storage::types::SqlRow) -> Option<Uuid> {
453 match row.get("id") {
454 Some(SqlValue::Uuid(u)) => Some(*u),
455 Some(SqlValue::Text(s)) => Uuid::parse_str(s).ok(),
456 _ => None,
457 }
458}
459
460fn escape_like(input: &str) -> String {
463 let mut out = String::with_capacity(input.len());
464 for c in input.chars() {
465 if matches!(c, '\\' | '%' | '_') {
466 out.push('\\');
467 }
468 out.push(c);
469 }
470 out
471}
472
473async fn find_document_for_path(
479 runtime: &KhiveRuntime,
480 token: &NamespaceToken,
481 path: &str,
482) -> Result<Option<Uuid>> {
483 let file_name = Path::new(path)
484 .file_name()
485 .and_then(|f| f.to_str())
486 .unwrap_or(path);
487 let sql = runtime.sql();
488 let namespace = token.namespace().as_str().to_string();
489 let like_pattern = format!("%{}", escape_like(path));
490
491 let mut r = sql.reader().await.map_err(|e| anyhow!("{e}"))?;
492 let row = r
493 .query_row(SqlStatement {
494 sql: "SELECT id FROM entities WHERE kind='document' AND namespace=?1 \
495 AND deleted_at IS NULL \
496 AND (json_extract(properties,'$.source_uri')=?2 OR name=?3 \
497 OR json_extract(properties,'$.source_uri') LIKE ?4 ESCAPE '\\') \
498 ORDER BY CASE WHEN json_extract(properties,'$.source_uri')=?2 OR name=?3 \
499 THEN 0 ELSE 1 END, id \
500 LIMIT 1"
501 .into(),
502 params: vec![
503 SqlValue::Text(namespace),
504 SqlValue::Text(path.to_string()),
505 SqlValue::Text(file_name.to_string()),
506 SqlValue::Text(like_pattern),
507 ],
508 label: Some("git_ingest_find_document_for_path".into()),
509 })
510 .await
511 .map_err(|e| anyhow!("{e}"))?;
512 Ok(row.and_then(|r| row_uuid(&r)))
513}
514
515async fn read_cursor(
517 runtime: &KhiveRuntime,
518 project_id: Uuid,
519 kind: &str,
520) -> Result<Option<String>> {
521 let sql = runtime.sql();
522 let mut r = sql.reader().await.map_err(|e| anyhow!("{e}"))?;
523 let row = r
524 .query_row(SqlStatement {
525 sql: "SELECT cursor_value FROM git_mirror_cursor WHERE project_id=?1 AND kind=?2"
526 .into(),
527 params: vec![
528 SqlValue::Text(project_id.to_string()),
529 SqlValue::Text(kind.to_string()),
530 ],
531 label: Some("git_ingest_read_cursor".into()),
532 })
533 .await
534 .map_err(|e| anyhow!("{e}"))?;
535 Ok(row.and_then(|r| match r.get("cursor_value") {
536 Some(SqlValue::Text(s)) => Some(s.clone()),
537 _ => None,
538 }))
539}
540
541async fn write_cursor(
545 runtime: &KhiveRuntime,
546 project_id: Uuid,
547 kind: &str,
548 value: &str,
549) -> Result<()> {
550 let sql = runtime.sql();
551 let mut w = sql.writer().await.map_err(|e| anyhow!("{e}"))?;
552 w.execute(SqlStatement {
553 sql: "INSERT INTO git_mirror_cursor(project_id, kind, cursor_value, updated_at) \
554 VALUES(?1, ?2, ?3, ?4) \
555 ON CONFLICT(project_id, kind) DO UPDATE SET \
556 cursor_value=excluded.cursor_value, \
557 updated_at=excluded.updated_at"
558 .into(),
559 params: vec![
560 SqlValue::Text(project_id.to_string()),
561 SqlValue::Text(kind.to_string()),
562 SqlValue::Text(value.to_string()),
563 SqlValue::Integer(Utc::now().timestamp_micros()),
564 ],
565 label: Some("git_ingest_write_cursor".into()),
566 })
567 .await
568 .map_err(|e| anyhow!("{e}"))?;
569 Ok(())
570}
571
572const RECORD_SEP: char = '\u{1e}';
575const FIELD_SEP: char = '\u{1f}';
576
577struct RawCommit {
578 sha: String,
579 short_sha: String,
580 author: String,
581 author_email: String,
582 committed_at: String,
583 parents: Vec<String>,
584 subject: String,
585 body: String,
586}
587
588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
591pub(crate) enum GitLogPhase {
592 Metadata,
593 TouchedFiles,
594}
595
596#[derive(Debug)]
599pub(crate) struct GitLogError {
600 phase: GitLogPhase,
601 stderr: String,
602}
603
604impl std::fmt::Display for GitLogError {
605 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
606 let cmd = match self.phase {
607 GitLogPhase::Metadata => "git log",
608 GitLogPhase::TouchedFiles => "git log --name-only",
609 };
610 write!(f, "{cmd} failed: {}", self.stderr)
611 }
612}
613
614impl std::error::Error for GitLogError {}
615
616impl GitLogError {
617 pub(crate) fn is_missing_promisor_object(&self) -> bool {
622 let lower = self.stderr.to_ascii_lowercase();
623 lower.contains("promisor")
624 && (lower.contains("not in the object database") || lower.contains("missing object"))
625 }
626}
627
628fn walk_commits(repo: &Path, since_sha: Option<&str>) -> Result<Vec<RawCommit>> {
631 let format = format!("%H{FIELD_SEP}%h{FIELD_SEP}%an{FIELD_SEP}%ae{FIELD_SEP}%cI{FIELD_SEP}%P{FIELD_SEP}%s{FIELD_SEP}%b{RECORD_SEP}");
636 let mut args = vec![
637 "log".to_string(),
638 "--reverse".to_string(),
639 format!("--pretty=format:{format}"),
640 ];
641 if let Some(sha) = since_sha {
642 args.push(format!("{sha}..HEAD"));
643 }
644 let output = Command::new("git")
645 .arg("-C")
646 .arg(repo)
647 .args(&args)
648 .output()
649 .context("spawning git log")?;
650 if !output.status.success() {
651 return Err(anyhow::Error::new(GitLogError {
652 phase: GitLogPhase::Metadata,
653 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
654 }));
655 }
656 let text = String::from_utf8_lossy(&output.stdout);
657 let mut commits = Vec::new();
658 for record in text.split(RECORD_SEP) {
659 let record = record.trim_matches('\n');
660 if record.is_empty() {
661 continue;
662 }
663 let fields: Vec<&str> = record.splitn(8, FIELD_SEP).collect();
664 if fields.len() < 8 {
665 continue;
666 }
667 let sha = fields[0].to_string();
668 let short_sha = fields[1].to_string();
669 let author = fields[2].to_string();
670 let author_email = fields[3].to_string();
671 let committed_at = fields[4].to_string();
672 let parents = fields[5]
673 .split_whitespace()
674 .map(str::to_string)
675 .collect::<Vec<_>>();
676 let subject = fields[6].to_string();
677 let body = fields[7].trim_end_matches('\n').to_string();
678 commits.push(RawCommit {
679 sha,
680 short_sha,
681 author,
682 author_email,
683 committed_at,
684 parents,
685 subject,
686 body,
687 });
688 }
689 Ok(commits)
690}
691
692fn touched_files(repo: &Path) -> Result<HashMap<String, Vec<String>>> {
695 let output = Command::new("git")
696 .arg("-C")
697 .arg(repo)
698 .arg("log")
699 .arg("--name-only")
700 .arg(format!("--pretty=format:{RECORD_SEP}%H"))
701 .output()
702 .context("spawning git log --name-only")?;
703 if !output.status.success() {
704 return Err(anyhow::Error::new(GitLogError {
705 phase: GitLogPhase::TouchedFiles,
706 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
707 }));
708 }
709 let text = String::from_utf8_lossy(&output.stdout);
710 let mut map: HashMap<String, Vec<String>> = HashMap::new();
711 for block in text.split(RECORD_SEP) {
712 let mut lines = block.lines().filter(|l| !l.trim().is_empty());
713 let Some(sha) = lines.next() else { continue };
714 let files: Vec<String> = lines.map(str::to_string).collect();
715 map.insert(sha.trim().to_string(), files);
716 }
717 Ok(map)
718}
719
720struct CommitSnapshot {
723 commits: Vec<RawCommit>,
724 files_by_sha: HashMap<String, Vec<String>>,
725}
726
727fn load_commit_snapshot(repo: &Path, since_sha: Option<&str>) -> Result<CommitSnapshot> {
730 let commits = walk_commits(repo, since_sha)?;
731 if commits.is_empty() {
732 return Ok(CommitSnapshot {
733 commits,
734 files_by_sha: HashMap::new(),
735 });
736 }
737 let files_by_sha = touched_files(repo)?;
738 Ok(CommitSnapshot {
739 commits,
740 files_by_sha,
741 })
742}
743
744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
747pub(crate) enum CacheRepairStrategy {
748 Refetch,
749 Reclone,
750}
751
752pub(crate) struct RecoveredRepo {
755 pub(crate) repo: PathBuf,
756 pub(crate) strategy: CacheRepairStrategy,
757}
758
759fn cache_repair_warning(strategy: CacheRepairStrategy) -> String {
760 match strategy {
761 CacheRepairStrategy::Refetch => {
762 "repaired corrupt remote git cache by refetching missing promisor objects".to_string()
763 }
764 CacheRepairStrategy::Reclone => {
765 "repaired corrupt remote git cache by replacing the owned clone".to_string()
766 }
767 }
768}
769
770fn recover_commit_snapshot(
775 repo: &Path,
776 since_sha: Option<&str>,
777 mut recover: impl FnMut(&Path, &GitLogError) -> Result<Option<RecoveredRepo>>,
778) -> Result<(CommitSnapshot, Option<String>)> {
779 let mut repo_path = repo.to_path_buf();
780 let mut recovery_warning: Option<String> = None;
781 loop {
782 match load_commit_snapshot(&repo_path, since_sha) {
783 Ok(snapshot) => return Ok((snapshot, recovery_warning)),
784 Err(e) => {
785 let classified = e
786 .downcast_ref::<GitLogError>()
787 .filter(|g| g.is_missing_promisor_object());
788 let Some(git_log_err) = classified else {
789 return Err(e);
790 };
791 match recover(&repo_path, git_log_err)? {
792 Some(recovered) => {
793 repo_path = recovered.repo;
794 recovery_warning = Some(cache_repair_warning(recovered.strategy));
795 }
796 None => return Err(e),
797 }
798 }
799 }
800 }
801}
802
803fn squash_merge_pr_number(subject: &str) -> Option<u64> {
805 let trimmed = subject.trim_end();
806 let close = trimmed.strip_suffix(')')?;
807 let open = close.rfind("(#")?;
808 close[open + 2..].parse::<u64>().ok()
809}
810
811const NAME_MAX_CHARS: usize = 120;
815
816const MAX_COMMIT_EMBED_BYTES: usize = 32_768;
822
823fn truncated_embedding_head(content: &str) -> Option<&str> {
827 if content.len() <= MAX_COMMIT_EMBED_BYTES {
828 return None;
829 }
830 let mut end = MAX_COMMIT_EMBED_BYTES;
831 while !content.is_char_boundary(end) {
832 end -= 1;
833 }
834 Some(&content[..end])
835}
836
837struct MaskedCommitFields {
842 sha: String,
843 short_sha: String,
844 author: String,
845 author_email: String,
846 committed_at: String,
847 parents: Vec<String>,
848 subject: String,
849 body: String,
850}
851
852impl MaskedCommitFields {
853 fn new(commit: &RawCommit) -> Self {
854 let RawCommit {
855 sha,
856 short_sha,
857 author,
858 author_email,
859 committed_at,
860 parents,
861 subject,
862 body,
863 } = commit;
864 Self {
865 sha: sha.clone(),
866 short_sha: short_sha.clone(),
867 author: secret_gate::mask_secrets(author).into_owned(),
868 author_email: secret_gate::mask_secrets(author_email).into_owned(),
869 committed_at: committed_at.clone(),
870 parents: parents.clone(),
871 subject: secret_gate::mask_secrets(subject).into_owned(),
872 body: secret_gate::mask_secrets(body).into_owned(),
873 }
874 }
875}
876
877#[allow(clippy::too_many_arguments)]
878async fn ingest_commits(
879 runtime: &KhiveRuntime,
880 token: &NamespaceToken,
881 registry: &VerbRegistry,
882 repo: &Path,
883 project_id: Uuid,
884 merge_sha_to_pr: &HashMap<String, Uuid>,
885 number_to_pr: &HashMap<u64, Uuid>,
886 report: &mut IngestReport,
887 budget: &mut Budget,
888 new_records: &mut Vec<NewRecordForRef>,
889 recover: &mut (dyn FnMut(&Path, &GitLogError) -> Result<Option<RecoveredRepo>> + Send),
890) -> Result<()> {
891 let since = read_cursor(runtime, project_id, "commits").await?;
892 let (snapshot, recovery_warning) = recover_commit_snapshot(repo, since.as_deref(), recover)?;
893 let CommitSnapshot {
894 commits,
895 files_by_sha,
896 } = snapshot;
897 if commits.is_empty() {
898 if let Some(warning) = recovery_warning {
899 report.warnings.push(warning);
900 }
901 return Ok(());
902 }
903
904 let mut last_sha: Option<String> = since;
914 let mut cursor_stalled = false;
915 let mut local_sha_to_id: HashMap<String, Uuid> = HashMap::new();
920 for c in &commits {
921 if let Some(existing) = find_commit_by_sha(runtime, token, &c.sha).await? {
922 local_sha_to_id.insert(c.sha.clone(), existing);
923 report.commits_skipped_existing += 1;
924 if !cursor_stalled {
925 last_sha = Some(c.sha.clone());
926 }
927 continue;
928 }
929
930 if budget.exhausted() {
931 break;
932 }
933
934 let masked = MaskedCommitFields::new(c);
935 let content = if masked.body.trim().is_empty() {
936 masked.subject.clone()
937 } else {
938 format!("{}\n\n{}", masked.subject, masked.body)
939 };
940
941 let mut annotates = vec![project_id.to_string()];
942
943 if let Some(paths) = files_by_sha.get(&c.sha) {
944 for p in paths {
945 if !p.starts_with("docs/adr/") {
946 continue;
947 }
948 if let Some(doc_id) = find_document_for_path(runtime, token, p).await? {
949 annotates.push(doc_id.to_string());
950 }
951 }
952 }
953
954 let pr_id = match merge_sha_to_pr.get(&c.sha).copied() {
960 Some(id) => Some(id),
961 None => match squash_merge_pr_number(&c.subject) {
962 Some(n) => match number_to_pr.get(&n).copied() {
963 Some(id) => Some(id),
964 None => find_by_number(runtime, token, "pull_request", project_id, n).await?,
965 },
966 None => None,
967 },
968 };
969 if let Some(pr_id) = pr_id {
970 annotates.push(pr_id.to_string());
971 }
972
973 let properties = json!({
974 "sha": masked.sha,
975 "short_sha": masked.short_sha,
976 "author": masked.author,
977 "author_email": masked.author_email,
978 "committed_at": masked.committed_at,
979 "parents": masked.parents,
980 });
981
982 let name = refs::truncate_chars(
983 &format!("{} {}", masked.short_sha, masked.subject),
984 NAME_MAX_CHARS,
985 );
986 let embedding_head = truncated_embedding_head(&content);
987
988 let mut create_request = json!({
989 "kind": "commit",
990 "name": name,
991 "content": content,
992 "properties": properties,
993 "annotates": annotates,
994 });
995 if let Some(head) = embedding_head {
996 create_request["embedding_content"] = json!(head);
997 }
998
999 budget.try_consume();
1000 match registry.dispatch("create", create_request).await {
1001 Ok(v) => {
1002 report.commits_ingested += 1;
1003 if embedding_head.is_some() {
1004 report.commit_embeddings_truncated += 1;
1005 }
1006 if !cursor_stalled {
1007 last_sha = Some(c.sha.clone());
1008 }
1009 if let Some(id) = v
1010 .get("id")
1011 .and_then(|v| v.as_str())
1012 .and_then(|s| Uuid::parse_str(s).ok())
1013 {
1014 local_sha_to_id.insert(c.sha.clone(), id);
1015 new_records.push(NewRecordForRef {
1016 id,
1017 text: content.clone(),
1018 });
1019 for parent_sha in &c.parents {
1024 let parent_id = match local_sha_to_id.get(parent_sha).copied() {
1025 Some(pid) => Some(pid),
1026 None => find_commit_by_sha(runtime, token, parent_sha).await?,
1027 };
1028 let Some(parent_id) = parent_id else {
1029 continue;
1030 };
1031 if parent_id == id {
1032 continue;
1033 }
1034 match registry
1035 .dispatch(
1036 "link",
1037 json!({
1038 "source_id": parent_id.to_string(),
1039 "target_id": id.to_string(),
1040 "relation": "precedes",
1041 }),
1042 )
1043 .await
1044 {
1045 Ok(_) => report.parent_edges_created += 1,
1046 Err(e) => report.warnings.push(format!(
1047 "linking parent {parent_sha} -> {} precedes: {e}",
1048 c.sha
1049 )),
1050 }
1051 }
1052 }
1053 }
1054 Err(e) => {
1055 report
1056 .warnings
1057 .push(format!("create commit {}: {e}", c.sha));
1058 cursor_stalled = true;
1059 }
1060 }
1061 }
1062
1063 if let Some(sha) = last_sha {
1064 write_cursor(runtime, project_id, "commits", &sha).await?;
1065 }
1066 if let Some(warning) = recovery_warning {
1067 report.warnings.push(warning);
1068 }
1069 Ok(())
1070}
1071
1072#[derive(Debug, Deserialize)]
1075struct GhAuthor {
1076 login: Option<String>,
1077}
1078
1079#[derive(Debug, Deserialize)]
1080struct GhLabel {
1081 name: String,
1082}
1083
1084#[derive(Debug, Deserialize)]
1085struct GhIssue {
1086 number: u64,
1087 title: String,
1088 author: Option<GhAuthor>,
1089 #[serde(rename = "createdAt")]
1090 created_at: Option<String>,
1091 #[serde(rename = "closedAt")]
1092 closed_at: Option<String>,
1093 #[serde(rename = "updatedAt")]
1094 updated_at: Option<String>,
1095 labels: Option<Vec<GhLabel>>,
1096 #[serde(rename = "stateReason")]
1097 state_reason: Option<String>,
1098 body: Option<String>,
1099}
1100
1101#[derive(Debug, Deserialize)]
1102struct GhMergeCommit {
1103 oid: Option<String>,
1104}
1105
1106#[derive(Debug, Deserialize)]
1107struct GhPr {
1108 number: u64,
1109 title: String,
1110 author: Option<GhAuthor>,
1111 #[serde(rename = "createdAt")]
1112 created_at: Option<String>,
1113 #[serde(rename = "mergedAt")]
1114 merged_at: Option<String>,
1115 #[serde(rename = "closedAt")]
1116 closed_at: Option<String>,
1117 #[serde(rename = "updatedAt")]
1118 updated_at: Option<String>,
1119 #[serde(rename = "baseRefName")]
1120 base_ref_name: Option<String>,
1121 #[serde(rename = "headRefName")]
1122 head_ref_name: Option<String>,
1123 #[serde(rename = "mergeCommit")]
1124 merge_commit: Option<GhMergeCommit>,
1125 body: Option<String>,
1126}
1127
1128struct MaskedIssueFields {
1132 number: u64,
1133 title: String,
1134 body: String,
1135 author_login: Option<String>,
1136 labels: Vec<String>,
1137 created_at: Option<String>,
1138 closed_at: Option<String>,
1139 updated_at: Option<String>,
1140 state_reason: StateReasonField,
1141}
1142
1143#[derive(Debug, Clone, PartialEq, Eq)]
1147enum StateReasonField {
1148 Absent,
1149 Valid(String),
1150 Rejected,
1151}
1152
1153impl MaskedIssueFields {
1154 fn new(issue: GhIssue, warnings: &mut Vec<String>) -> Self {
1155 let GhIssue {
1156 number,
1157 title,
1158 author,
1159 created_at,
1160 closed_at,
1161 updated_at,
1162 labels,
1163 state_reason,
1164 body,
1165 } = issue;
1166
1167 Self {
1168 number,
1169 title: secret_gate::mask_secrets(&title).into_owned(),
1170 body: secret_gate::mask_secrets(&body.unwrap_or_default()).into_owned(),
1171 author_login: author
1172 .and_then(|a| a.login)
1173 .map(|login| secret_gate::mask_secrets(&login).into_owned()),
1174 labels: labels
1175 .unwrap_or_default()
1176 .into_iter()
1177 .map(|l| secret_gate::mask_secrets(&l.name).into_owned())
1178 .collect(),
1179 created_at: canonical_issue_timestamp("createdAt", number, created_at, warnings),
1180 closed_at: canonical_issue_timestamp("closedAt", number, closed_at, warnings),
1181 updated_at: canonical_issue_timestamp("updatedAt", number, updated_at, warnings),
1182 state_reason: canonical_issue_state_reason(state_reason),
1183 }
1184 }
1185}
1186
1187fn canonical_issue_state_reason(raw: Option<String>) -> StateReasonField {
1191 let Some(raw) = raw.filter(|r| !r.is_empty()) else {
1192 return StateReasonField::Absent;
1193 };
1194 let lowered = raw.to_ascii_lowercase();
1195 if hook::ISSUE_STATE_REASONS.contains(&lowered.as_str()) {
1196 StateReasonField::Valid(lowered)
1197 } else {
1198 StateReasonField::Rejected
1199 }
1200}
1201
1202fn canonical_issue_timestamp(
1207 field: &'static str,
1208 number: u64,
1209 raw: Option<String>,
1210 warnings: &mut Vec<String>,
1211) -> Option<String> {
1212 let raw = raw?;
1213 match chrono::DateTime::parse_from_rfc3339(&raw) {
1214 Ok(dt) => Some(
1215 dt.with_timezone(&Utc)
1216 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1217 ),
1218 Err(_) => {
1219 warnings.push(format!(
1220 "issue #{number}: {field} is not a valid RFC3339 timestamp, field dropped"
1221 ));
1222 None
1223 }
1224 }
1225}
1226
1227fn gh_json(repo: &Path, args: &[&str]) -> Result<String> {
1228 let output = Command::new("gh")
1230 .current_dir(repo)
1231 .args(args)
1232 .output()
1233 .context("spawning gh")?;
1234 if !output.status.success() {
1235 return Err(anyhow!(
1236 "gh {:?} failed: {}",
1237 args,
1238 String::from_utf8_lossy(&output.stderr)
1239 ));
1240 }
1241 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1242}
1243
1244const PAGE_LIMIT: usize = 1000;
1249
1250#[derive(Debug, Clone, PartialEq, Eq)]
1254enum PageOutcome {
1255 WindowComplete,
1257 StopBudgetExhausted,
1259 StopFloorStalled,
1261 Continue(String),
1263}
1264
1265fn decide_page_outcome(
1266 page_len: usize,
1267 current_floor: Option<&str>,
1268 last_updated_at: Option<&str>,
1269 budget_exhausted: bool,
1270) -> PageOutcome {
1271 if page_len < PAGE_LIMIT {
1272 return PageOutcome::WindowComplete;
1273 }
1274 if budget_exhausted {
1275 return PageOutcome::StopBudgetExhausted;
1276 }
1277 match last_updated_at {
1278 Some(next) if Some(next) != current_floor => PageOutcome::Continue(next.to_string()),
1279 _ => PageOutcome::StopFloorStalled,
1280 }
1281}
1282
1283#[cfg(test)]
1285fn page_outcome_proves_window_complete(outcome: PageOutcome) -> bool {
1286 matches!(outcome, PageOutcome::WindowComplete)
1287}
1288
1289fn search_query(floor: Option<&str>) -> String {
1290 match floor {
1291 Some(f) => format!("sort:updated-asc updated:>={f}"),
1292 None => "sort:updated-asc".to_string(),
1293 }
1294}
1295
1296const PR_FIELDS: &str = "number,title,author,createdAt,mergedAt,closedAt,updatedAt,baseRefName,headRefName,mergeCommit,body";
1297const ISSUE_FIELDS: &str =
1298 "number,title,author,createdAt,closedAt,updatedAt,labels,stateReason,body";
1299
1300fn fetch_pr_page(repo: &Path, floor: Option<&str>) -> Result<Vec<GhPr>> {
1301 let search = search_query(floor);
1302 let raw = gh_json(
1303 repo,
1304 &[
1305 "pr",
1306 "list",
1307 "--state",
1308 "all",
1309 "--search",
1310 search.as_str(),
1311 "--limit",
1312 "1000",
1313 "--json",
1314 PR_FIELDS,
1315 ],
1316 )?;
1317 serde_json::from_str(&raw).context("parsing gh pr list --json")
1318}
1319
1320fn fetch_issue_page(repo: &Path, floor: Option<&str>) -> Result<Vec<GhIssue>> {
1321 let search = search_query(floor);
1322 let raw = gh_json(
1323 repo,
1324 &[
1325 "issue",
1326 "list",
1327 "--state",
1328 "all",
1329 "--search",
1330 search.as_str(),
1331 "--limit",
1332 "1000",
1333 "--json",
1334 ISSUE_FIELDS,
1335 ],
1336 )?;
1337 serde_json::from_str(&raw).context("parsing gh issue list --json")
1338}
1339
1340struct MaskedPrFields {
1345 number: u64,
1346 title: String,
1347 body: String,
1348 author_login: Option<String>,
1349 created_at: Option<String>,
1350 merged_at: Option<String>,
1351 closed_at: Option<String>,
1352 updated_at: Option<String>,
1353 base_ref_name: Option<String>,
1354 head_ref_name: Option<String>,
1355 merge_commit_oid: Option<String>,
1356}
1357
1358impl MaskedPrFields {
1359 fn new(pr: GhPr) -> Self {
1360 let GhPr {
1361 number,
1362 title,
1363 author,
1364 created_at,
1365 merged_at,
1366 closed_at,
1367 updated_at,
1368 base_ref_name,
1369 head_ref_name,
1370 merge_commit,
1371 body,
1372 } = pr;
1373 Self {
1374 number,
1375 title: secret_gate::mask_secrets(&title).into_owned(),
1376 body: secret_gate::mask_secrets(&body.unwrap_or_default()).into_owned(),
1377 author_login: author
1378 .and_then(|a| a.login)
1379 .map(|login| secret_gate::mask_secrets(&login).into_owned()),
1380 created_at,
1381 merged_at,
1382 closed_at,
1383 updated_at,
1384 base_ref_name: base_ref_name.map(|r| secret_gate::mask_secrets(&r).into_owned()),
1385 head_ref_name: head_ref_name.map(|r| secret_gate::mask_secrets(&r).into_owned()),
1386 merge_commit_oid: merge_commit.and_then(|m| m.oid),
1387 }
1388 }
1389}
1390
1391#[allow(clippy::too_many_arguments)]
1392async fn ingest_prs(
1393 runtime: &KhiveRuntime,
1394 token: &NamespaceToken,
1395 registry: &VerbRegistry,
1396 repo: &Path,
1397 project_id: Uuid,
1398 report: &mut IngestReport,
1399 merge_sha_to_pr: &mut HashMap<String, Uuid>,
1400 number_to_pr: &mut HashMap<u64, Uuid>,
1401 budget: &mut Budget,
1402 new_records: &mut Vec<NewRecordForRef>,
1403) -> Result<()> {
1404 let since = read_cursor(runtime, project_id, "prs").await?;
1405
1406 let mut max_updated: Option<String> = since.clone();
1412 let mut cursor_stalled = false;
1413 let mut floor = since.clone();
1414 let mut window_complete = true;
1415
1416 'paging: loop {
1417 let mut page = fetch_pr_page(repo, floor.as_deref())?;
1418 let page_len = page.len();
1419 page.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
1428 let last_updated_at = page.last().and_then(|pr| pr.updated_at.clone());
1429
1430 for pr in page {
1431 let is_new = since
1432 .as_deref()
1433 .zip(pr.updated_at.as_deref())
1434 .map(|(cursor, updated)| updated >= cursor)
1435 .unwrap_or(true);
1436
1437 if let Some(existing) =
1438 find_by_number(runtime, token, "pull_request", project_id, pr.number).await?
1439 {
1440 number_to_pr.insert(pr.number, existing);
1441 if let Some(oid) = pr.merge_commit.as_ref().and_then(|m| m.oid.clone()) {
1442 merge_sha_to_pr.insert(oid, existing);
1443 }
1444 report.prs_skipped_existing += 1;
1445 if !cursor_stalled {
1446 if let Some(u) = &pr.updated_at {
1447 if max_updated
1448 .as_deref()
1449 .map(|m| u.as_str() > m)
1450 .unwrap_or(true)
1451 {
1452 max_updated = Some(u.clone());
1453 }
1454 }
1455 }
1456 continue;
1457 }
1458 if !is_new {
1459 continue;
1460 }
1461 if budget.exhausted() {
1462 break;
1463 }
1464
1465 let masked = MaskedPrFields::new(pr);
1466 let content = masked.body;
1467 let properties = json!({
1468 "number": masked.number,
1469 "title": masked.title,
1470 "author": masked.author_login,
1471 "created_at": masked.created_at,
1472 "merged_at": masked.merged_at,
1473 "closed_at": masked.closed_at,
1474 "base_ref": masked.base_ref_name,
1475 "head_ref": masked.head_ref_name,
1476 "project_id": project_id.to_string(),
1477 });
1478 let name = refs::truncate_chars(
1479 &format!("#{} {}", masked.number, masked.title),
1480 NAME_MAX_CHARS,
1481 );
1482
1483 budget.try_consume();
1484 let result = match registry
1485 .dispatch(
1486 "create",
1487 json!({
1488 "kind": "pull_request",
1489 "name": name,
1490 "content": content,
1491 "properties": properties,
1492 "annotates": [project_id.to_string()],
1493 }),
1494 )
1495 .await
1496 {
1497 Ok(v) => v,
1498 Err(e) => {
1499 report
1500 .warnings
1501 .push(format!("create pull_request #{}: {e}", masked.number));
1502 cursor_stalled = true;
1503 continue;
1504 }
1505 };
1506
1507 if let Some(id) = result
1508 .get("id")
1509 .and_then(|v| v.as_str())
1510 .and_then(|s| Uuid::parse_str(s).ok())
1511 {
1512 number_to_pr.insert(masked.number, id);
1513 if let Some(oid) = masked.merge_commit_oid {
1514 merge_sha_to_pr.insert(oid, id);
1515 }
1516 new_records.push(NewRecordForRef {
1517 id,
1518 text: content.clone(),
1519 });
1520 }
1521 report.prs_ingested += 1;
1522 if !cursor_stalled {
1523 if let Some(u) = &masked.updated_at {
1524 if max_updated
1525 .as_deref()
1526 .map(|m| u.as_str() > m)
1527 .unwrap_or(true)
1528 {
1529 max_updated = Some(u.clone());
1530 }
1531 }
1532 }
1533 }
1534
1535 match decide_page_outcome(
1536 page_len,
1537 floor.as_deref(),
1538 last_updated_at.as_deref(),
1539 budget.exhausted(),
1540 ) {
1541 PageOutcome::WindowComplete => break 'paging,
1542 PageOutcome::StopBudgetExhausted | PageOutcome::StopFloorStalled => {
1543 window_complete = false;
1544 break 'paging;
1545 }
1546 PageOutcome::Continue(next_floor) => floor = Some(next_floor),
1547 }
1548 }
1549
1550 if !window_complete {
1551 report.done = false;
1556 }
1557
1558 if let Some(cursor) = max_updated {
1559 write_cursor(runtime, project_id, "prs", &cursor).await?;
1560 }
1561 Ok(())
1562}
1563
1564#[allow(clippy::too_many_arguments)]
1565async fn ingest_issues(
1566 runtime: &KhiveRuntime,
1567 token: &NamespaceToken,
1568 registry: &VerbRegistry,
1569 repo: &Path,
1570 project_id: Uuid,
1571 report: &mut IngestReport,
1572 budget: &mut Budget,
1573 new_records: &mut Vec<NewRecordForRef>,
1574) -> Result<()> {
1575 let since = read_cursor(runtime, project_id, "issues").await?;
1576
1577 let mut max_updated: Option<String> = since.clone();
1583 let mut cursor_stalled = false;
1584 let mut floor = since.clone();
1585 let mut window_complete = true;
1586
1587 'paging: loop {
1588 let page = fetch_issue_page(repo, floor.as_deref())?;
1589 let page_len = page.len();
1590 let mut masked_page: Vec<MaskedIssueFields> = page
1599 .into_iter()
1600 .map(|issue| MaskedIssueFields::new(issue, &mut report.warnings))
1601 .collect();
1602 masked_page.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
1607 let last_updated_at = masked_page.last().and_then(|i| i.updated_at.clone());
1608
1609 for masked in masked_page {
1610 let is_new = since
1611 .as_deref()
1612 .zip(masked.updated_at.as_deref())
1613 .map(|(cursor, updated)| updated >= cursor)
1614 .unwrap_or(true);
1615
1616 if find_by_number(runtime, token, "issue", project_id, masked.number)
1617 .await?
1618 .is_some()
1619 {
1620 report.issues_skipped_existing += 1;
1621 if !cursor_stalled {
1622 if let Some(u) = &masked.updated_at {
1623 if max_updated
1624 .as_deref()
1625 .map(|m| u.as_str() > m)
1626 .unwrap_or(true)
1627 {
1628 max_updated = Some(u.clone());
1629 }
1630 }
1631 }
1632 continue;
1633 }
1634 if !is_new {
1635 continue;
1636 }
1637 if budget.exhausted() {
1638 break;
1639 }
1640
1641 let number = masked.number;
1642 let updated_at = masked.updated_at.clone();
1643
1644 if masked.state_reason == StateReasonField::Rejected {
1653 report.warnings.push(format!(
1654 "issue #{number}: stateReason is not one of the governed values, record skipped"
1655 ));
1656 cursor_stalled = true;
1657 continue;
1658 }
1659
1660 let content = masked.body;
1661 let safe_title = masked.title;
1662 let mut properties = json!({
1663 "number": number,
1664 "title": safe_title,
1665 "author": masked.author_login,
1666 "created_at": masked.created_at,
1667 "closed_at": masked.closed_at,
1668 "labels": masked.labels,
1669 "project_id": project_id.to_string(),
1670 });
1671 if let StateReasonField::Valid(reason) = masked.state_reason {
1672 properties["state_reason"] = json!(reason);
1673 }
1674 let name = refs::truncate_chars(&format!("#{number} {safe_title}"), NAME_MAX_CHARS);
1675
1676 budget.try_consume();
1677 let result = match registry
1678 .dispatch(
1679 "create",
1680 json!({
1681 "kind": "issue",
1682 "name": name,
1683 "content": content,
1684 "properties": properties,
1685 "annotates": [project_id.to_string()],
1686 }),
1687 )
1688 .await
1689 {
1690 Ok(v) => v,
1691 Err(e) => {
1692 report.warnings.push(format!("create issue #{number}: {e}"));
1693 cursor_stalled = true;
1694 continue;
1695 }
1696 };
1697 if let Some(id) = result
1698 .get("id")
1699 .and_then(|v| v.as_str())
1700 .and_then(|s| Uuid::parse_str(s).ok())
1701 {
1702 new_records.push(NewRecordForRef {
1703 id,
1704 text: content.clone(),
1705 });
1706 }
1707
1708 report.issues_ingested += 1;
1709 if !cursor_stalled {
1710 if let Some(u) = &updated_at {
1711 if max_updated
1712 .as_deref()
1713 .map(|m| u.as_str() > m)
1714 .unwrap_or(true)
1715 {
1716 max_updated = Some(u.clone());
1717 }
1718 }
1719 }
1720 }
1721
1722 match decide_page_outcome(
1723 page_len,
1724 floor.as_deref(),
1725 last_updated_at.as_deref(),
1726 budget.exhausted(),
1727 ) {
1728 PageOutcome::WindowComplete => break 'paging,
1729 PageOutcome::StopBudgetExhausted | PageOutcome::StopFloorStalled => {
1730 window_complete = false;
1731 break 'paging;
1732 }
1733 PageOutcome::Continue(next_floor) => floor = Some(next_floor),
1734 }
1735 }
1736
1737 if !window_complete {
1738 report.done = false;
1739 }
1740
1741 if let Some(cursor) = max_updated {
1742 write_cursor(runtime, project_id, "issues", &cursor).await?;
1743 }
1744 Ok(())
1745}
1746
1747#[cfg(test)]
1748mod paging_tests {
1749 use super::*;
1750
1751 #[test]
1752 fn search_query_omits_updated_qualifier_with_no_floor() {
1753 assert_eq!(search_query(None), "sort:updated-asc");
1754 }
1755
1756 #[test]
1757 fn search_query_includes_inclusive_updated_floor() {
1758 assert_eq!(
1759 search_query(Some("2024-01-01T00:00:00Z")),
1760 "sort:updated-asc updated:>=2024-01-01T00:00:00Z"
1761 );
1762 }
1763
1764 #[test]
1765 fn short_page_proves_window_complete_regardless_of_budget() {
1766 let outcome = decide_page_outcome(42, None, Some("2024-01-01T00:00:00Z"), false);
1767 assert_eq!(outcome, PageOutcome::WindowComplete);
1768 assert!(page_outcome_proves_window_complete(outcome));
1769
1770 let outcome = decide_page_outcome(0, None, None, true);
1774 assert_eq!(outcome, PageOutcome::WindowComplete);
1775 }
1776
1777 #[test]
1784 fn full_page_with_stalled_floor_is_not_window_complete_even_with_budget_left() {
1785 let outcome = decide_page_outcome(PAGE_LIMIT, Some("X"), Some("X"), false);
1786 assert_eq!(outcome, PageOutcome::StopFloorStalled);
1787 assert!(!page_outcome_proves_window_complete(outcome));
1788 }
1789
1790 #[test]
1791 fn full_page_with_advancing_floor_and_budget_left_continues() {
1792 let outcome = decide_page_outcome(PAGE_LIMIT, Some("A"), Some("B"), false);
1793 assert_eq!(outcome, PageOutcome::Continue("B".to_string()));
1794 assert!(!page_outcome_proves_window_complete(outcome));
1795 }
1796
1797 #[test]
1798 fn full_page_with_exhausted_budget_stops_without_proving_completeness() {
1799 let outcome = decide_page_outcome(PAGE_LIMIT, Some("A"), Some("B"), true);
1800 assert_eq!(outcome, PageOutcome::StopBudgetExhausted);
1801 assert!(!page_outcome_proves_window_complete(outcome));
1802 }
1803
1804 #[test]
1805 fn full_page_with_no_updated_at_stalls_rather_than_looping_forever() {
1806 let outcome = decide_page_outcome(PAGE_LIMIT, Some("A"), None, false);
1807 assert_eq!(outcome, PageOutcome::StopFloorStalled);
1808 }
1809}
1810
1811#[cfg(test)]
1815mod recovery_classifier_tests {
1816 use super::*;
1817
1818 fn err(phase: GitLogPhase, stderr: &str) -> GitLogError {
1819 GitLogError {
1820 phase,
1821 stderr: stderr.to_string(),
1822 }
1823 }
1824
1825 const REAL_WORLD_MESSAGE: &str = "fatal: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef is in \
1826 the commit graph file, but not in the object database\nfatal: unable to parse commit: \
1827 deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\nfatal: could not fetch from promisor remote";
1828
1829 #[test]
1830 fn classifies_real_world_missing_promisor_object_message_on_either_phase() {
1831 assert!(err(GitLogPhase::TouchedFiles, REAL_WORLD_MESSAGE).is_missing_promisor_object());
1832 assert!(err(GitLogPhase::Metadata, REAL_WORLD_MESSAGE).is_missing_promisor_object());
1833 }
1834
1835 #[test]
1836 fn classifies_missing_object_wording_case_insensitively() {
1837 assert!(err(
1838 GitLogPhase::TouchedFiles,
1839 "FATAL: MISSING OBJECT abc123; PROMISOR remote unavailable"
1840 )
1841 .is_missing_promisor_object());
1842 }
1843
1844 #[test]
1845 fn does_not_classify_bad_object_without_promisor() {
1846 assert!(!err(GitLogPhase::Metadata, "fatal: bad object HEAD").is_missing_promisor_object());
1847 }
1848
1849 #[test]
1850 fn does_not_classify_auth_or_network_failures() {
1851 assert!(!err(
1852 GitLogPhase::Metadata,
1853 "fatal: Authentication failed for 'https://example.com/org/repo.git/'"
1854 )
1855 .is_missing_promisor_object());
1856 assert!(!err(
1857 GitLogPhase::TouchedFiles,
1858 "fatal: unable to access 'https://example.com/org/repo.git/': Could not resolve host"
1859 )
1860 .is_missing_promisor_object());
1861 }
1862
1863 #[test]
1864 fn does_not_classify_promisor_mention_without_missing_object_wording() {
1865 assert!(!err(
1869 GitLogPhase::Metadata,
1870 "fatal: promisor remote configured but unreachable"
1871 )
1872 .is_missing_promisor_object());
1873 }
1874
1875 #[test]
1878 fn recover_commit_snapshot_returns_no_warning_when_healthy() {
1879 let _env = crate::cache::ENV_MUTEX.blocking_lock();
1880 let dir = tempfile::tempdir().expect("tempdir");
1881 init_repo_with_commit(dir.path());
1882 let mut recover_calls = 0;
1883 let (snapshot, warning) = recover_commit_snapshot(dir.path(), None, |_repo, _err| {
1884 recover_calls += 1;
1885 Ok(None)
1886 })
1887 .expect("healthy repo loads");
1888 assert_eq!(snapshot.commits.len(), 1);
1889 assert_eq!(warning, None);
1890 assert_eq!(recover_calls, 0);
1891 }
1892
1893 #[test]
1896 fn recover_commit_snapshot_never_calls_recover_for_unclassified_failures() {
1897 let _env = crate::cache::ENV_MUTEX.blocking_lock();
1898 let dir = tempfile::tempdir().expect("tempdir");
1899 let mut recover_calls = 0;
1902 let result = recover_commit_snapshot(dir.path(), None, |_repo, _err| {
1903 recover_calls += 1;
1904 Ok(Some(RecoveredRepo {
1905 repo: dir.path().to_path_buf(),
1906 strategy: CacheRepairStrategy::Refetch,
1907 }))
1908 });
1909 assert!(result.is_err(), "a non-repo path must fail to load");
1910 assert_eq!(
1911 recover_calls, 0,
1912 "an unclassified failure must never invoke recover"
1913 );
1914 }
1915
1916 fn init_repo_with_commit(repo: &Path) {
1917 let run = |args: &[&str]| {
1918 let out = std::process::Command::new("git")
1919 .arg("-C")
1920 .arg(repo)
1921 .args(args)
1922 .output()
1923 .expect("spawn git");
1924 assert!(
1925 out.status.success(),
1926 "git {args:?} failed: {}",
1927 String::from_utf8_lossy(&out.stderr)
1928 );
1929 };
1930 run(&["init", "-q", "-b", "main"]);
1931 run(&["config", "user.email", "test@example.com"]);
1932 run(&["config", "user.name", "Test User"]);
1933 std::fs::write(repo.join("a.txt"), b"hello").unwrap();
1934 run(&["add", "a.txt"]);
1935 run(&["commit", "-q", "-m", "initial"]);
1936 }
1937}
1938
1939#[cfg(test)]
1940mod truncation_tests {
1941 use super::*;
1942
1943 #[test]
1944 fn under_cap_content_is_not_truncated() {
1945 let content = "a".repeat(MAX_COMMIT_EMBED_BYTES - 1);
1946 assert_eq!(truncated_embedding_head(&content), None);
1947 }
1948
1949 #[test]
1950 fn exactly_at_cap_content_is_not_truncated() {
1951 let content = "a".repeat(MAX_COMMIT_EMBED_BYTES);
1952 assert_eq!(truncated_embedding_head(&content), None);
1953 }
1954
1955 #[test]
1956 fn over_cap_content_is_truncated_to_exactly_the_cap() {
1957 let content = "a".repeat(MAX_COMMIT_EMBED_BYTES + 1);
1958 let head = truncated_embedding_head(&content).expect("over cap must truncate");
1959 assert_eq!(head.len(), MAX_COMMIT_EMBED_BYTES);
1960 assert!(content.starts_with(head));
1961 }
1962
1963 #[test]
1965 fn multibyte_scalar_straddling_cap_rolls_back_to_char_boundary() {
1966 let mut content = "a".repeat(MAX_COMMIT_EMBED_BYTES - 1);
1969 content.push('€'); content.push_str("tail-sentinel");
1971
1972 let head = truncated_embedding_head(&content).expect("over cap must truncate");
1973 assert!(head.len() <= MAX_COMMIT_EMBED_BYTES);
1974 assert!(content.is_char_boundary(head.len()));
1975 assert!(std::str::from_utf8(head.as_bytes()).is_ok());
1976 assert!(content.starts_with(head));
1977 assert!(
1978 !head.contains("tail-sentinel"),
1979 "head must not include text past the cap"
1980 );
1981 }
1982}
1983
1984#[cfg(test)]
1987mod compact_prefix_resolver_tests {
1988 use super::*;
1989 use khive_runtime::Namespace;
1990
1991 #[tokio::test]
1992 async fn resolve_project_id_rejects_like_wildcard_input() {
1993 let rt = KhiveRuntime::memory().unwrap();
1994 let token = rt.authorize(Namespace::local()).unwrap();
1995 let project = rt
1996 .create_entity(
1997 &token,
1998 "project",
1999 None,
2000 "WildcardIngestTest",
2001 None,
2002 None,
2003 vec![],
2004 )
2005 .await
2006 .unwrap();
2007 let compact = project.id.simple().to_string();
2008 let wildcard_input = format!("{}%", &compact[..8]);
2009
2010 let resolved = resolve_project_id(&rt, &wildcard_input).await.unwrap();
2011 assert_eq!(
2012 resolved, None,
2013 "a %-bearing project argument must not resolve via a wildcard LIKE scan"
2014 );
2015 }
2016
2017 #[tokio::test]
2018 async fn resolve_id_resolves_compact_prefix_over_8_chars() {
2019 let rt = KhiveRuntime::memory().unwrap();
2020 let token = rt.authorize(Namespace::local()).unwrap();
2021 let project = rt
2022 .create_entity(
2023 &token,
2024 "project",
2025 None,
2026 "CompactIngestTest",
2027 None,
2028 None,
2029 vec![],
2030 )
2031 .await
2032 .unwrap();
2033 let compact = project.id.simple().to_string();
2034
2035 let resolved = resolve_id(&rt, &token, &compact[..16]).await.unwrap();
2036 assert_eq!(resolved, Some(project.id));
2037 }
2038}
2039
2040#[cfg(test)]
2043mod find_document_for_path_tests {
2044 use super::*;
2045 use khive_runtime::Namespace;
2046
2047 async fn create_document(rt: &KhiveRuntime, token: &NamespaceToken, source_uri: &str) -> Uuid {
2048 rt.create_entity(
2049 token,
2050 "document",
2051 None,
2052 source_uri,
2053 None,
2054 Some(json!({ "source_uri": source_uri })),
2055 vec![],
2056 )
2057 .await
2058 .unwrap()
2059 .id
2060 }
2061
2062 #[tokio::test]
2063 async fn path_with_like_wildcards_resolves_only_itself() {
2064 let rt = KhiveRuntime::memory().unwrap();
2065 let token = rt.authorize(Namespace::local()).unwrap();
2066
2067 let path = "src/100%_done.rs";
2075 let decoy_source_uri = "prefix/src/100Qdone.rs";
2076 create_document(&rt, &token, decoy_source_uri).await;
2077
2078 let resolved = find_document_for_path(&rt, &token, path).await.unwrap();
2079 assert_eq!(
2080 resolved, None,
2081 "a % or _ in the path must be matched literally, not as a LIKE wildcard"
2082 );
2083 }
2084
2085 #[tokio::test]
2086 async fn exact_match_wins_over_wildcard_broadened_candidate() {
2087 let rt = KhiveRuntime::memory().unwrap();
2088 let token = rt.authorize(Namespace::local()).unwrap();
2089
2090 let path = "crates/khive-pack-git/src/ingest.rs";
2091 let broadened_suffix_path = "other/crates/khive-pack-git/src/ingest.rs";
2092 create_document(&rt, &token, broadened_suffix_path).await;
2095 let exact_id = create_document(&rt, &token, path).await;
2096
2097 let resolved = find_document_for_path(&rt, &token, path).await.unwrap();
2098 assert_eq!(
2099 resolved,
2100 Some(exact_id),
2101 "an exact source_uri match must always win over a suffix-LIKE candidate"
2102 );
2103 }
2104
2105 #[tokio::test]
2108 async fn single_query_snapshot_prefers_exact_over_broadened() {
2109 let rt = KhiveRuntime::memory().unwrap();
2110 let token = rt.authorize(Namespace::local()).unwrap();
2111
2112 let path = "crates/khive-pack-git/src/toctou.rs";
2113 let broadened_suffix_path = "other/crates/khive-pack-git/src/toctou.rs";
2114 let exact_id = create_document(&rt, &token, path).await;
2115 create_document(&rt, &token, broadened_suffix_path).await;
2116
2117 let resolved = find_document_for_path(&rt, &token, path).await.unwrap();
2118 assert_eq!(
2119 resolved,
2120 Some(exact_id),
2121 "a single query covering both exact and broadened candidates \
2122 must still rank the exact match first, regardless of insertion order"
2123 );
2124 }
2125}