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