Skip to main content

khive_pack_git/
handlers.rs

1//! `git.digest` verb handler (ADR-088 Amendment 1).
2//!
3//! Resolves the `source` argument (local path or `https://` URL, cloning/
4//! fetching remote sources into the scratch cache), resolves or auto-creates
5//! the repo-anchor `project` entity, then drives the shared
6//! `ingest::run_ingest` core with a bounded, cursor-resumable pass.
7
8use std::path::Path;
9
10use anyhow::anyhow;
11use serde_json::{json, Value};
12use uuid::Uuid;
13
14use khive_runtime::{KhiveRuntime, NamespaceToken, RuntimeError, VerbRegistry};
15use khive_storage::types::{SqlStatement, SqlValue};
16
17use crate::cache::{self, CacheError};
18use crate::ingest::{
19    resolve_project_id, run_ingest, run_ingest_with_commit_recovery, CacheRepairStrategy,
20    GitLogError, IngestInclude, IngestOptions, RecoveredRepo,
21};
22use crate::source::{parse_source, repo_basename, DigestSource};
23use crate::GitPack;
24
25/// Issue #765 bounded repair policy: at most one refetch, then at most one
26/// reclone. See crates/khive-pack-git/docs/api/handlers.md#remoterecoverystage.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub(crate) enum RemoteRecoveryStage {
29    Initial,
30    Refetched,
31    Recloned,
32}
33
34pub(crate) struct RemoteCommitRecovery {
35    canonical_url: String,
36    stage: RemoteRecoveryStage,
37}
38
39impl RemoteCommitRecovery {
40    pub(crate) fn new(canonical_url: impl Into<String>) -> Self {
41        Self {
42            canonical_url: canonical_url.into(),
43            stage: RemoteRecoveryStage::Initial,
44        }
45    }
46
47    /// Advance the repair state machine by one step for a classified
48    /// `GitLogError`. See crates/khive-pack-git/docs/api/handlers.md#repair.
49    pub(crate) fn repair(
50        &mut self,
51        _repo: &Path,
52        _error: &GitLogError,
53    ) -> anyhow::Result<Option<RecoveredRepo>> {
54        match self.stage {
55            RemoteRecoveryStage::Initial => match cache::refetch_clone(&self.canonical_url) {
56                Ok(repo) => {
57                    self.stage = RemoteRecoveryStage::Refetched;
58                    Ok(Some(RecoveredRepo {
59                        repo,
60                        strategy: CacheRepairStrategy::Refetch,
61                    }))
62                }
63                // The refetch command itself failed at the git level (e.g.
64                // the remote still cannot supply the missing objects) --
65                // fall through to the one guarded reclone immediately rather
66                // than surfacing the refetch failure. An I/O, size-cap, or
67                // ownership-guard failure is terminal: it is not a signal
68                // that a fresh clone would fare any differently, and is
69                // never worth risking a second destructive operation for.
70                Err(CacheError::Git(_)) => {
71                    self.stage = RemoteRecoveryStage::Refetched;
72                    self.reclone()
73                }
74                Err(e) => Err(anyhow!("cache repair (refetch) failed: {e}")),
75            },
76            RemoteRecoveryStage::Refetched => self.reclone(),
77            RemoteRecoveryStage::Recloned => Ok(None),
78        }
79    }
80
81    fn reclone(&mut self) -> anyhow::Result<Option<RecoveredRepo>> {
82        match cache::reclone(&self.canonical_url) {
83            Ok(repo) => {
84                self.stage = RemoteRecoveryStage::Recloned;
85                Ok(Some(RecoveredRepo {
86                    repo,
87                    strategy: CacheRepairStrategy::Reclone,
88                }))
89            }
90            Err(e) => Err(anyhow!("cache repair (reclone) failed: {e}")),
91        }
92    }
93}
94
95const DEFAULT_MAX_ITEMS: i64 = 500;
96const MIN_MAX_ITEMS: i64 = 1;
97const MAX_MAX_ITEMS: i64 = 2000;
98
99impl GitPack {
100    pub(crate) async fn handle_digest(
101        &self,
102        token: &NamespaceToken,
103        registry: &VerbRegistry,
104        params: Value,
105    ) -> Result<Value, RuntimeError> {
106        let source_raw = params
107            .get("source")
108            .and_then(Value::as_str)
109            .ok_or_else(|| RuntimeError::InvalidInput("git.digest requires source".into()))?;
110        let source =
111            parse_source(source_raw).map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
112
113        // Parsed as i64 (not u64) so an out-of-range negative value clamps to
114        // MIN_MAX_ITEMS instead of failing `as_u64` and silently falling
115        // through to the default -- a caller passing `-1` gets the smallest
116        // legal budget, not an unrequested 500-item pass. A non-integer
117        // value (string, float, bool, array, object) is rejected outright
118        // rather than silently defaulted.
119        let max_items = match params.get("max_items") {
120            None | Some(Value::Null) => DEFAULT_MAX_ITEMS,
121            Some(v) => v.as_i64().ok_or_else(|| {
122                RuntimeError::InvalidInput(format!("max_items must be an integer, got {v:?}"))
123            })?,
124        }
125        .clamp(MIN_MAX_ITEMS, MAX_MAX_ITEMS) as u64;
126
127        let include = match params.get("include") {
128            None | Some(Value::Null) => IngestInclude::default(),
129            Some(v) => parse_include(v)?,
130        };
131
132        let mut warnings: Vec<String> = Vec::new();
133
134        // Resolve a local repo path -- remote sources clone/fetch into the
135        // scratch cache first (ADR-088 Amendment 1 §Remote-URL mode).
136        let (repo_path, gh_capable) = match &source {
137            DigestSource::Local(p) => (p.clone(), true),
138            DigestSource::Remote { canonical, gh_slug } => {
139                let cloned = cache::ensure_clone(canonical).map_err(|e| {
140                    RuntimeError::InvalidInput(format!(
141                        "remote clone/fetch of {canonical:?} failed: {e}"
142                    ))
143                })?;
144                if gh_slug.is_none() {
145                    warnings.push(format!(
146                        "host for {canonical:?} is not github.com; issue/pull_request \
147                         ingestion is skipped (commits-only degradation, ADR-088 Amendment 1)"
148                    ));
149                }
150                (cloned, gh_slug.is_some())
151            }
152        };
153
154        // Resolve or auto-create the repo-anchor `project` entity.
155        let (project_id, project_created) = match params.get("project").and_then(Value::as_str) {
156            Some(raw) => {
157                let id = resolve_project_id(self.runtime(), raw)
158                    .await
159                    .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?
160                    .ok_or_else(|| {
161                        RuntimeError::InvalidInput(format!(
162                            "project {raw:?} did not resolve to an entity"
163                        ))
164                    })?;
165                (id, false)
166            }
167            None => resolve_or_create_project(self.runtime(), registry, token, &source).await?,
168        };
169
170        let effective_include = IngestInclude {
171            commits: include.commits,
172            issues: include.issues && gh_capable,
173            pull_requests: include.pull_requests && gh_capable,
174        };
175
176        let opts = IngestOptions {
177            repo: repo_path,
178            project: project_id.to_string(),
179            max_items: Some(max_items),
180            include: effective_include,
181        };
182
183        // Only a remote-URL source has a disposable cache to repair (ADR-088
184        // Amendment 1) -- a local path is the caller's own working copy and
185        // is never a candidate for self-heal (issue #765).
186        let mut report = match &source {
187            DigestSource::Local(_) => run_ingest(self.runtime(), token, registry, opts).await,
188            DigestSource::Remote { canonical, .. } => {
189                let mut recovery = RemoteCommitRecovery::new(canonical.clone());
190                run_ingest_with_commit_recovery(self.runtime(), token, registry, opts, {
191                    move |repo, err| recovery.repair(repo, err)
192                })
193                .await
194            }
195        }
196        .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
197
198        report.warnings.extend(warnings);
199        report.project_id = Some(project_id.to_string());
200        report.project_created = project_created;
201
202        serde_json::to_value(&report)
203            .map_err(|e| RuntimeError::InvalidInput(format!("serializing report: {e}")))
204    }
205}
206
207fn parse_include(v: &Value) -> Result<IngestInclude, RuntimeError> {
208    let arr = v
209        .as_array()
210        .ok_or_else(|| RuntimeError::InvalidInput("include must be an array of strings".into()))?;
211    let mut include = IngestInclude {
212        commits: false,
213        issues: false,
214        pull_requests: false,
215    };
216    for entry in arr {
217        let s = entry
218            .as_str()
219            .ok_or_else(|| RuntimeError::InvalidInput("include entries must be strings".into()))?;
220        match s {
221            "commits" => include.commits = true,
222            "issues" => include.issues = true,
223            "pull_requests" => include.pull_requests = true,
224            other => {
225                return Err(RuntimeError::InvalidInput(format!(
226                    "unknown include kind {other:?}; valid: commits | issues | pull_requests"
227                )))
228            }
229        }
230    }
231    Ok(include)
232}
233
234/// Find an existing `project` entity whose `properties.repo_url` matches the
235/// source's canonical URL/path, or whose `name` matches the repo basename;
236/// create the anchor when none is found (ADR-088 Amendment 1 — auto-creation
237/// is reported via `IngestReport.project_created`, never silent).
238async fn resolve_or_create_project(
239    runtime: &KhiveRuntime,
240    registry: &VerbRegistry,
241    token: &NamespaceToken,
242    source: &DigestSource,
243) -> Result<(Uuid, bool), RuntimeError> {
244    let repo_url = match source {
245        DigestSource::Local(p) => p.to_string_lossy().to_string(),
246        DigestSource::Remote { canonical, .. } => canonical.clone(),
247    };
248    let name = repo_basename(source);
249
250    if let Some(id) = find_project_by_repo(runtime, token, &repo_url, &name)
251        .await
252        .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?
253    {
254        return Ok((id, false));
255    }
256
257    let resp = registry
258        .dispatch(
259            "create",
260            json!({
261                "kind": "project",
262                "name": name,
263                "properties": { "repo_url": repo_url },
264            }),
265        )
266        .await?;
267    let id = resp
268        .get("id")
269        .and_then(Value::as_str)
270        .and_then(|s| Uuid::parse_str(s).ok())
271        .ok_or_else(|| {
272            RuntimeError::InvalidInput("create(kind=project) did not return an id".into())
273        })?;
274    Ok((id, true))
275}
276
277async fn find_project_by_repo(
278    runtime: &KhiveRuntime,
279    token: &NamespaceToken,
280    repo_url: &str,
281    name: &str,
282) -> anyhow::Result<Option<Uuid>> {
283    let sql = runtime.sql();
284    let mut r = sql.reader().await.map_err(|e| anyhow!("{e}"))?;
285    let row = r
286        .query_row(SqlStatement {
287            sql: "SELECT id FROM entities WHERE kind='project' AND namespace=?1 \
288                  AND deleted_at IS NULL \
289                  AND (json_extract(properties,'$.repo_url')=?2 OR name=?3) \
290                  LIMIT 1"
291                .into(),
292            params: vec![
293                SqlValue::Text(token.namespace().as_str().to_string()),
294                SqlValue::Text(repo_url.to_string()),
295                SqlValue::Text(name.to_string()),
296            ],
297            label: Some("git_digest_find_project_by_repo".into()),
298        })
299        .await
300        .map_err(|e| anyhow!("{e}"))?;
301    Ok(row.and_then(|r| match r.get("id") {
302        Some(SqlValue::Uuid(u)) => Some(*u),
303        Some(SqlValue::Text(s)) => Uuid::parse_str(s).ok(),
304        _ => None,
305    }))
306}