1use 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#[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 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 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 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 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 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 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
242async 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}