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)]
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 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 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 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 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 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 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
234async 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}