anvilsign in

collin/anvil · c4d49193

feat: CI artifacts, issue tracker, push mirroring, push-to-create, repo-page polish

Collin Richards · 2026-06-10 09:17 UTC · c4d49193453402efb7865868251425cfc374ae4f · parent 3447f00b · browse files

modifiedCargo.lock+4 −0
⋯ 125 unchanged lines
126126 "anvil-core",
127127 "anvil-git",
128128 "bollard",
129+ "flate2",
129130 "futures-util",
130131 "reqwest",
131132 "serde_json",
132133 "tar",
134+ "tempfile",
133135 "tokio",
134136 "tracing",
135137 ]
⋯ 20 unchanged lines
156158 "argon2 0.5.3",
157159 "gix",
158160 "hmac 0.12.1",
161+ "rusqlite",
159162 "serde",
160163 "serde_yaml",
161164 "sha2 0.10.9",
⋯ 45 unchanged lines
207210 "maud",
208211 "pulldown-cmark",
209212 "serde",
213+ "serde_json",
210214 "similar",
211215 "syntect",
212216 "time",
⋯ 5055 unchanged lines
modifiedCargo.toml+4 −0
⋯ 34 unchanged lines
3535 gix-pack = { version = "0.71", features = ["sha1"] }
3636 maud = { version = "0.27", features = ["axum"] }
3737 pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
38+flate2 = "1"
3839 rand = "0.10"
3940 # HTTP client for the CD deploy webhook. No TLS feature on purpose: the deploy
4041 # receiver is host-local plaintext HTTP, and enabling rustls would drag in
4142 # aws-lc-rs and break the musl cross-compile (see the russh note below).
4243 reqwest = { version = "0.12", default-features = false, features = ["json"] }
44+# Used directly only for idempotent schema shims on existing databases; the
45+# version tracks what toasty-driver-sqlite already pulls in.
46+rusqlite = "0.39"
4347 serde = { version = "1", features = ["derive"] }
4448 serde_json = "1"
4549 serde_yaml = "0.9"
⋯ 25 unchanged lines
modifiedDockerfile+3 −1
⋯ 7 unchanged lines
88
99 FROM debian:bookworm-slim
1010
11+# git is needed at runtime only for push mirroring (`git push --mirror`);
12+# everything else speaks gix in-process.
1113 RUN apt-get update \
12- && apt-get install -y --no-install-recommends ca-certificates \
14+ && apt-get install -y --no-install-recommends ca-certificates git \
1315 && rm -rf /var/lib/apt/lists/* \
1416 && useradd --system --user-group --home-dir /data anvil \
1517 && mkdir -p /data /etc/anvil \
⋯ 12 unchanged lines
modifiedanvil.example.toml+7 −0
⋯ 38 unchanged lines
3939 # User inside the job container, e.g. "1000:1000". Empty keeps the image default.
4040 run_as = ""
4141
42+# Artifact caps (MiB, 0 = unlimited): one artifact / one run's total / the
43+# rolling per-repo budget. Over the repo budget, the oldest commits' artifacts
44+# are deleted after each run (branch tips pinned). See docs/ci-artifacts.md.
45+artifact_max_mb = 256
46+artifact_run_max_mb = 512
47+artifact_quota_mb = 4096
48+
4249 # Continuous deployment: on a green run of deploy_branch in the ONE repo named
4350 # by deploy_repo, POST to deploy_webhook with the X-Anvil-Deploy-Secret header.
4451 # Empty deploy_repo/deploy_webhook disables deploys entirely.
⋯ 4 unchanged lines
modifiedcrates/anvil-ci/Cargo.toml+4 −0
⋯ 10 unchanged lines
1111 anvil-core.workspace = true
1212 anvil-git.workspace = true
1313 bollard.workspace = true
14+flate2.workspace = true
1415 futures-util.workspace = true
1516 reqwest.workspace = true
1617 serde_json.workspace = true
1718 tar.workspace = true
1819 tokio.workspace = true
1920 tracing.workspace = true
21+
22+[dev-dependencies]
23+tempfile = "3"
modifiedcrates/anvil-ci/src/lib.rs+534 −38
⋯ 13 unchanged lines
1414 //! pids/memory/cpu caps plus a wall-clock timeout and an optional image
1515 //! allowlist ([`anvil_core::config::CiConfig`]).
1616
17-use anvil_core::ci::{
18- self,
19- Pipeline,
17+use std::{
18+ collections::BTreeMap,
19+ io::Read,
20+ path::Path,
2021 };
21-use anvil_core::config::CiConfig;
22+
2223 use anvil_core::{
2324 App,
25+ ci::{
26+ self,
27+ ArtifactSpec,
28+ Pipeline,
29+ },
30+ config::CiConfig,
2431 repos,
2532 storage,
2633 users,
⋯ 2 unchanged lines
2936 self,
3037 TreeFile,
3138 };
32-use bollard::Docker;
33-use bollard::container::{
34- Config,
35- CreateContainerOptions,
36- LogsOptions,
37- RemoveContainerOptions,
38- StartContainerOptions,
39- UploadToContainerOptions,
40- WaitContainerOptions,
39+use bollard::{
40+ Docker,
41+ container::{
42+ Config,
43+ CreateContainerOptions,
44+ DownloadFromContainerOptions,
45+ LogsOptions,
46+ RemoveContainerOptions,
47+ StartContainerOptions,
48+ UploadToContainerOptions,
49+ WaitContainerOptions,
50+ },
51+ image::CreateImageOptions,
52+ models::HostConfig,
4153 };
42-use bollard::image::CreateImageOptions;
43-use bollard::models::HostConfig;
4454 use futures_util::StreamExt;
4555 use tokio::sync::mpsc::UnboundedReceiver;
4656
4757 const WORKDIR: &str = "/workspace";
4858
59+/// In-container directory where meta-extractor outputs land, one file per
60+/// `<artifact>/<key>`. Downloaded as a tar after the run; file-per-value
61+/// sidesteps quoting/JSON-escaping in shell entirely.
62+const META_DIR: &str = "/tmp/anvil-meta";
63+
64+/// Cap on the meta-extractor tar (the values are short strings).
65+const META_TAR_CAP: u64 = 1024 * 1024;
66+
67+/// Per-value cap on extractor output, in bytes (after trimming).
68+const META_VALUE_CAP: usize = 1024;
69+
4970 /// Run the CI worker loop: recover interrupted runs, drain the queue, then
5071 /// process run ids as they arrive on `rx`. Runs one job at a time.
5172 pub async fn run_worker(app: App, mut rx: UnboundedReceiver<i64>) {
⋯ 59 unchanged lines
111132 owner.username, repo.name, run.ref_name, pipeline.image
112133 );
113134
114- let status = match execute(&pipeline, tar, &mut log, &app.config.ci).await {
115- Ok(0) => ci::status::SUCCESS,
116- Ok(code) => {
117- log.push_str(&format!("\n[exited with status {code}]\n"));
118- ci::status::FAILURE
135+ // Artifacts are collected into a scratch directory next to their final
136+ // home (same filesystem, so the swap below is a rename), then moved into
137+ // place only after the run finishes.
138+ let artifacts_root = app.config.artifacts_dir();
139+ let scratch = artifacts_root
140+ .join(run.repo_id.to_string())
141+ .join(format!(".collecting-{run_id}"));
142+
143+ let (status, collected) =
144+ match execute(&pipeline, tar, &mut log, &app.config.ci, &scratch).await {
145+ Ok((0, collected)) => (ci::status::SUCCESS, collected),
146+ Ok((code, collected)) => {
147+ log.push_str(&format!("\n[exited with status {code}]\n"));
148+ (ci::status::FAILURE, collected)
149+ }
150+ Err(e) => {
151+ log.push_str(&format!("\n[runner error] {e}\n"));
152+ (ci::status::ERROR, Vec::new())
153+ }
154+ };
155+
156+ // Swap the collected set into place, replacing any earlier run's
157+ // artifacts for this commit, then record the rows.
158+ if !collected.is_empty() {
159+ let final_dir = storage::artifact_commit_dir(&artifacts_root, run.repo_id, &run.commit);
160+ let swap = async {
161+ ci::delete_artifacts_for_commit(&app.db, run.repo_id, &run.commit)
162+ .await
163+ .map_err(|e| e.to_string())?;
164+ if final_dir.exists() {
165+ std::fs::remove_dir_all(&final_dir).map_err(|e| e.to_string())?;
166+ }
167+ std::fs::rename(&scratch, &final_dir).map_err(|e| e.to_string())?;
168+ for c in &collected {
169+ ci::add_artifact(
170+ &app.db,
171+ run_id,
172+ run.repo_id,
173+ &run.commit,
174+ &c.name,
175+ c.size,
176+ c.is_dir,
177+ c.browse,
178+ &c.meta,
179+ )
180+ .await
181+ .map_err(|e| e.to_string())?;
182+ }
183+ Ok::<_, String>(())
184+ };
185+ match swap.await {
186+ Ok(()) => {
187+ log.push_str(&format!("\n[collected {} artifact(s)]\n", collected.len()));
188+ gc_artifacts(app, run.repo_id, &repo_path, &run.commit, &mut log).await;
189+ }
190+ Err(e) => log.push_str(&format!("\n[storing artifacts failed: {e}]\n")),
119191 }
120- Err(e) => {
121- log.push_str(&format!("\n[runner error] {e}\n"));
122- ci::status::ERROR
123- }
124- };
192+ }
193+ let _ = std::fs::remove_dir_all(&scratch); // no-op when renamed away
125194
126195 ci::append_log(&app.db, run_id, &log).await.ok();
127196 ci::finish(&app.db, run_id, status).await.ok();
⋯ 13 unchanged lines
141210 Ok(())
142211 }
143212
213+/// Enforce `[ci] artifact_quota_mb` for one repository: while over budget,
214+/// delete the oldest commit's artifacts (rows + directory). Branch-tip
215+/// commits and the just-stored commit are pinned. Deterministic — runs after
216+/// every artifact-producing run, no background sweeper. Best-effort: failures
217+/// are logged, never failing the run.
218+async fn gc_artifacts(
219+ app: &App,
220+ repo_id: i64,
221+ repo_path: &Path,
222+ keep_commit: &str,
223+ log: &mut String,
224+) {
225+ let quota = match app.config.ci.artifact_quota_mb {
226+ 0 => return,
227+ mb => mb_cap(mb),
228+ };
229+ let rows = match ci::artifacts_for_repo(&app.db, repo_id).await {
230+ Ok(rows) => rows,
231+ Err(e) => {
232+ log.push_str(&format!("\n[artifact gc: listing failed: {e}]\n"));
233+ return;
234+ }
235+ };
236+
237+ // Per-commit totals and ages.
238+ let mut commits: BTreeMap<String, (i64, i64)> = BTreeMap::new(); // commit → (oldest created_at, bytes)
239+ let mut total: u64 = 0;
240+ for row in &rows {
241+ let entry = commits
242+ .entry(row.commit.clone())
243+ .or_insert((row.created_at, 0));
244+ entry.0 = entry.0.min(row.created_at);
245+ entry.1 += row.size;
246+ total = total.saturating_add(row.size.max(0) as u64);
247+ }
248+ if total <= quota {
249+ return;
250+ }
251+
252+ let pinned = browse::branch_tips(repo_path).unwrap_or_default();
253+ let mut victims: Vec<(i64, String, i64)> = commits
254+ .into_iter()
255+ .filter(|(commit, _)| commit != keep_commit && !pinned.contains(commit))
256+ .map(|(commit, (oldest, bytes))| (oldest, commit, bytes))
257+ .collect();
258+ victims.sort();
259+
260+ for (_, commit, bytes) in victims {
261+ if total <= quota {
262+ break;
263+ }
264+ let dir = storage::artifact_commit_dir(&app.config.artifacts_dir(), repo_id, &commit);
265+ if let Err(e) = std::fs::remove_dir_all(&dir) {
266+ log.push_str(&format!("\n[artifact gc: removing {commit}: {e}]\n"));
267+ continue; // keep the rows; retried next run
268+ }
269+ if let Err(e) = ci::delete_artifacts_for_commit(&app.db, repo_id, &commit).await {
270+ log.push_str(&format!("\n[artifact gc: forgetting {commit}: {e}]\n"));
271+ continue;
272+ }
273+ total = total.saturating_sub(bytes.max(0) as u64);
274+ log.push_str(&format!(
275+ "\n[artifact gc: dropped {} for old commit {}]\n",
276+ fmt_mb(bytes),
277+ &commit[..commit.len().min(12)]
278+ ));
279+ }
280+}
281+
282+/// Bytes as a short MiB string for log lines.
283+fn fmt_mb(bytes: i64) -> String {
284+ format!("{:.1} MiB", bytes.max(0) as f64 / (1024.0 * 1024.0))
285+}
286+
144287 /// POST the configured deploy webhook. Best-effort: logs success/failure but
145288 /// never fails the run (CI already passed).
146289 async fn deploy(app: &App, owner: &str, name: &str, run: &anvil_core::CiRun) {
⋯ 23 unchanged lines
170313 }
171314 }
172315
316+/// One artifact collected from the job container, already written under the
317+/// scratch directory; `process` swaps it into the commit's directory.
318+struct Collected {
319+ name: String,
320+ size: i64,
321+ is_dir: bool,
322+ browse: bool,
323+ /// JSON object of extractor key → output.
324+ meta: String,
325+}
326+
173327 /// Execute the pipeline in a sandboxed container, streaming output into `log`.
174-/// Returns the container's exit code.
328+/// Returns the container's exit code and any artifacts collected into
329+/// `scratch` (empty on timeout — the container is already gone).
175330 ///
176331 /// The job container never sees the Docker socket and gets no mounts of any
177-/// kind (the checkout is *uploaded*, not bind-mounted). All capabilities are
178-/// dropped and `no-new-privileges` is set unconditionally; pids/memory/cpu
179-/// caps, the wall-clock timeout, network access, the container user, and the
180-/// image allowlist come from `cfg`.
332+/// kind (the checkout is *uploaded*, not bind-mounted; artifacts are
333+/// *downloaded* out the same way). All capabilities are dropped and
334+/// `no-new-privileges` is set unconditionally; pids/memory/cpu caps, the
335+/// wall-clock timeout, network access, the container user, and the image
336+/// allowlist come from `cfg`.
181337 async fn execute(
182338 pipeline: &Pipeline,
183339 tar: Vec<u8>,
184340 log: &mut String,
185341 cfg: &CiConfig,
186-) -> Result<i64, String> {
342+ scratch: &Path,
343+) -> Result<(i64, Vec<Collected>), String> {
187344 if !cfg.image_allowed(&pipeline.image) {
188345 return Err(format!(
189346 "image {} is not permitted by ci.allowed_images",
⋯ 21 unchanged lines
211368 item.map_err(|e| format!("pull {}: {e}", pipeline.image))?;
212369 }
213370
214- // Build a single `set -e` script from the steps.
215- let mut script = String::from("set -e\n");
371+ // Build a single `set -e` script from the steps. The steps run in a
372+ // subshell so the meta-extractor trailer still runs (and the original
373+ // exit code is preserved) when a step fails — failure artifacts like test
374+ // reports are the ones that matter most.
375+ let mut script = String::from("(\nset -e\n");
216376 for step in &pipeline.steps {
217377 script.push_str("printf '\\n=== %s ===\\n' ");
218378 script.push_str(&single_quote(step.label()));
⋯ 1 unchanged line
220380 script.push_str(&step.run);
221381 script.push('\n');
222382 }
383+ script.push_str(")\nanvil_rc=$?\n");
384+ for a in &pipeline.artifacts {
385+ if a.meta.is_empty() {
386+ continue;
387+ }
388+ // Names and keys are parse-time validated to [A-Za-z0-9._-]+, so they
389+ // interpolate into the script safely.
390+ script.push_str(&format!("mkdir -p {META_DIR}/{}\n", a.name));
391+ for (key, cmd) in &a.meta {
392+ script.push_str(&format!(
393+ "{{\n{cmd}\n}} > {META_DIR}/{}/{key} 2>/dev/null || :\n",
394+ a.name
395+ ));
396+ }
397+ }
398+ script.push_str("exit $anvil_rc\n");
223399
224400 // The sandbox. Limits of 0 mean "unlimited" and omit the corresponding cap.
225401 let host_config = HostConfig {
⋯ 80 unchanged lines
306482 .unwrap_or_else(|_| Err(format!("job exceeded ci.timeout_secs ({secs}s); killed"))),
307483 };
308484
485+ // Artifacts come out of the (now stopped) container before it is removed.
486+ let collected = match &result {
487+ Ok(_) if !pipeline.artifacts.is_empty() => {
488+ collect_artifacts(&docker, &id, pipeline, cfg, scratch, log).await
489+ }
490+ _ => Vec::new(),
491+ };
492+
309493 let _ = docker
310494 .remove_container(
311495 &id,
⋯ 4 unchanged lines
316500 )
317501 .await;
318502
319- result
503+ result.map(|code| (code, collected))
320504 }
321505
322-/// Build an uncompressed tar of the checkout, rooted at `workspace/` so it
323-/// extracts to `/workspace` when uploaded to the container root.
324-fn build_tar(files: &[TreeFile]) -> Vec<u8> {
325- let mut builder = tar::Builder::new(Vec::new());
506+/// Collect the pipeline's declared artifacts from the stopped container into
507+/// `scratch`. Failures are per-artifact: each is logged and skipped, never
508+/// failing the run.
509+async fn collect_artifacts(
510+ docker: &Docker,
511+ id: &str,
512+ pipeline: &Pipeline,
513+ cfg: &CiConfig,
514+ scratch: &Path,
515+ log: &mut String,
516+) -> Vec<Collected> {
517+ if let Err(e) = std::fs::create_dir_all(scratch) {
518+ log.push_str(&format!(
519+ "\n[artifacts: creating scratch dir failed: {e}]\n"
520+ ));
521+ return Vec::new();
522+ }
523+
524+ // Extractor outputs first: artifact name → key → value.
525+ let mut metas: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
526+ if pipeline.artifacts.iter().any(|a| !a.meta.is_empty()) {
527+ match download_tar(docker, id, META_DIR, META_TAR_CAP).await {
528+ Ok(Some(bytes)) => metas = parse_meta_tar(&bytes),
529+ Ok(None) => log.push_str("\n[artifacts: extractor output exceeded its cap]\n"),
530+ Err(e) => log.push_str(&format!("\n[artifacts: reading extractor output: {e}]\n")),
531+ }
532+ }
533+
534+ let per_artifact_cap = mb_cap(cfg.artifact_max_mb);
535+ let mut run_budget = mb_cap(cfg.artifact_run_max_mb);
536+ let mut collected = Vec::new();
537+ for spec in &pipeline.artifacts {
538+ let cap = per_artifact_cap.min(run_budget);
539+ let note = |log: &mut String, what: &str| {
540+ log.push_str(&format!("\n[artifact {}: {what}]\n", spec.name));
541+ };
542+ let bytes = match download_tar(docker, id, &format!("{WORKDIR}/{}", spec.path), cap).await {
543+ Ok(Some(bytes)) => bytes,
544+ Ok(None) => {
545+ note(log, "exceeds the size cap; skipped");
546+ continue;
547+ }
548+ Err(e) => {
549+ note(log, &format!("download failed ({e}); skipped"));
550+ continue;
551+ }
552+ };
553+ match store_artifact(spec, &bytes, scratch) {
554+ Ok((size, is_dir)) => {
555+ run_budget = run_budget.saturating_sub(size as u64);
556+ let meta = metas.get(&spec.name).cloned().unwrap_or_default();
557+ collected.push(Collected {
558+ name: spec.name.clone(),
559+ size,
560+ is_dir,
561+ browse: spec.browse,
562+ meta: serde_json::to_string(&meta).unwrap_or_else(|_| "{}".into()),
563+ });
564+ }
565+ Err(e) => note(log, &format!("storing failed ({e}); skipped")),
566+ }
567+ }
568+ collected
569+}
570+
571+/// `0` (unlimited) → `u64::MAX`, otherwise MiB → bytes.
572+fn mb_cap(mb: i64) -> u64 {
573+ if mb <= 0 {
574+ u64::MAX
575+ } else {
576+ mb as u64 * 1024 * 1024
577+ }
578+}
579+
580+/// Download `path` from the container as a tar, buffering at most `cap` bytes
581+/// (`Ok(None)` when exceeded).
582+async fn download_tar(
583+ docker: &Docker,
584+ id: &str,
585+ path: &str,
586+ cap: u64,
587+) -> Result<Option<Vec<u8>>, String> {
588+ let mut stream = docker.download_from_container(
589+ id,
590+ Some(DownloadFromContainerOptions {
591+ path: path.to_string(),
592+ }),
593+ );
594+ let mut buf = Vec::new();
595+ while let Some(chunk) = stream.next().await {
596+ let chunk = chunk.map_err(|e| e.to_string())?;
597+ if (buf.len() + chunk.len()) as u64 > cap {
598+ return Ok(None);
599+ }
600+ buf.extend_from_slice(&chunk);
601+ }
602+ Ok(Some(buf))
603+}
604+
605+/// Parse the extractor-output tar (`anvil-meta/<artifact>/<key>` files) into
606+/// artifact → key → trimmed value.
607+fn parse_meta_tar(bytes: &[u8]) -> BTreeMap<String, BTreeMap<String, String>> {
608+ let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
609+ let mut archive = tar::Archive::new(bytes);
610+ let Ok(entries) = archive.entries() else {
611+ return out;
612+ };
613+ for entry in entries.flatten() {
614+ if !entry.header().entry_type().is_file() {
615+ continue;
616+ }
617+ let Ok(path) = entry.path() else { continue };
618+ // anvil-meta/<artifact>/<key>
619+ let parts: Vec<String> = path
620+ .components()
621+ .skip(1)
622+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
623+ .collect();
624+ let [artifact, key] = parts.as_slice() else {
625+ continue;
626+ };
627+ let (artifact, key) = (artifact.clone(), key.clone());
628+ let mut value = String::new();
629+ let _ = entry.take(META_VALUE_CAP as u64).read_to_string(&mut value);
630+ let value = value.trim().to_string();
631+ if !value.is_empty() {
632+ out.entry(artifact).or_default().insert(key, value);
633+ }
634+ }
635+ out
636+}
637+
638+/// Write one downloaded artifact tar into `scratch`, returning (size, is_dir).
639+///
640+/// - a file is stored as-is at `scratch/<name>`
641+/// - a directory with `browse` is extracted under `scratch/<name>/`
642+/// - any other directory is stored compressed at `scratch/<name>.tar.gz`
643+fn store_artifact(
644+ spec: &ArtifactSpec,
645+ tar_bytes: &[u8],
646+ scratch: &Path,
647+) -> Result<(i64, bool), String> {
648+ // The docker archive endpoint roots entries at the requested item's
649+ // basename; its first entry tells file from directory.
650+ let mut archive = tar::Archive::new(tar_bytes);
651+ let mut entries = archive.entries().map_err(|e| e.to_string())?;
652+ let first = entries
653+ .next()
654+ .ok_or("empty archive")?
655+ .map_err(|e| e.to_string())?;
656+ let is_dir = first.header().entry_type().is_dir();
657+
658+ if !is_dir {
659+ let mut entry = first;
660+ let mut content = Vec::new();
661+ entry.read_to_end(&mut content).map_err(|e| e.to_string())?;
662+ std::fs::write(scratch.join(&spec.name), &content).map_err(|e| e.to_string())?;
663+ return Ok((content.len() as i64, false));
664+ }
665+
666+ if !spec.browse {
667+ let file = std::fs::File::create(scratch.join(format!("{}.tar.gz", spec.name)))
668+ .map_err(|e| e.to_string())?;
669+ let mut enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
670+ std::io::Write::write_all(&mut enc, tar_bytes).map_err(|e| e.to_string())?;
671+ let file = enc.finish().map_err(|e| e.to_string())?;
672+ let size = file.metadata().map_err(|e| e.to_string())?.len();
673+ return Ok((size as i64, true));
674+ }
675+
676+ // Browsable: extract regular files under scratch/<name>/, stripping the
677+ // basename prefix. Entry paths come from docker's tar of a real
678+ // filesystem, but stay defensive: relative components only, no links.
679+ let root = scratch.join(&spec.name);
680+ let mut total = 0i64;
681+ let mut archive = tar::Archive::new(tar_bytes);
682+ for entry in archive.entries().map_err(|e| e.to_string())?.flatten() {
683+ if !entry.header().entry_type().is_file() {
684+ continue;
685+ }
686+ let Ok(path) = entry.path() else { continue };
687+ let mut rel = std::path::PathBuf::new();
688+ let mut ok = true;
689+ for c in path.components().skip(1) {
690+ match c {
691+ std::path::Component::Normal(p) => rel.push(p),
692+ _ => {
693+ ok = false;
694+ break;
695+ }
696+ }
697+ }
698+ if !ok || rel.as_os_str().is_empty() {
699+ continue;
700+ }
701+ let dest = root.join(&rel);
702+ if let Some(parent) = dest.parent() {
703+ std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
704+ }
705+ let mut entry = entry;
706+ let mut content = Vec::new();
707+ entry.read_to_end(&mut content).map_err(|e| e.to_string())?;
708+ std::fs::write(&dest, &content).map_err(|e| e.to_string())?;
709+ total += content.len() as i64;
710+ }
711+ let _ = std::fs::create_dir_all(&root); // empty dir artifact still exists
712+ Ok((total, true))
713+}
714+
715+/// Build an uncompressed tar of the checkout, rooted at `workspace/` so it
716+/// extracts to `/workspace` when uploaded to the container root.
717+fn build_tar(files: &[TreeFile]) -> Vec<u8> {
718+ let mut builder = tar::Builder::new(Vec::new());
326719 for f in files {
327720 let mut header = tar::Header::new_gnu();
328721 header.set_size(f.content.len() as u64);
⋯ 12 unchanged lines
341734 fn single_quote(s: &str) -> String {
342735 format!("'{}'", s.replace('\'', "'\\''"))
343736 }
737+
738+#[cfg(test)]
739+mod tests {
740+ use super::*;
741+
742+ /// Build a tar the way docker's archive endpoint does: entries rooted at
743+ /// the requested item's basename.
744+ fn tar_of(entries: &[(&str, Option<&str>)]) -> Vec<u8> {
745+ let mut b = tar::Builder::new(Vec::new());
746+ for (path, content) in entries {
747+ let mut h = tar::Header::new_gnu();
748+ match content {
749+ Some(c) => {
750+ h.set_size(c.len() as u64);
751+ h.set_mode(0o644);
752+ h.set_entry_type(tar::EntryType::Regular);
753+ b.append_data(&mut h, path, c.as_bytes()).unwrap();
754+ }
755+ None => {
756+ h.set_size(0);
757+ h.set_mode(0o755);
758+ h.set_entry_type(tar::EntryType::Directory);
759+ b.append_data(&mut h, path, std::io::empty()).unwrap();
760+ }
761+ }
762+ }
763+ b.into_inner().unwrap()
764+ }
765+
766+ fn spec(name: &str, path: &str, browse: bool) -> ArtifactSpec {
767+ ArtifactSpec {
768+ name: name.into(),
769+ path: path.into(),
770+ browse,
771+ meta: Default::default(),
772+ }
773+ }
774+
775+ #[test]
776+ fn stores_a_file_artifact_as_is() {
777+ let dir = tempfile::tempdir().unwrap();
778+ let tar = tar_of(&[("anvild", Some("ELF..."))]);
779+ let (size, is_dir) = store_artifact(
780+ &spec("bin", "target/release/anvild", false),
781+ &tar,
782+ dir.path(),
783+ )
784+ .unwrap();
785+ assert!(!is_dir);
786+ assert_eq!(size, 6);
787+ assert_eq!(std::fs::read(dir.path().join("bin")).unwrap(), b"ELF...");
788+ }
789+
790+ #[test]
791+ fn stores_a_directory_artifact_as_tar_gz() {
792+ let dir = tempfile::tempdir().unwrap();
793+ let tar = tar_of(&[("doc", None), ("doc/index.html", Some("<html>"))]);
794+ let (size, is_dir) =
795+ store_artifact(&spec("doc", "target/doc", false), &tar, dir.path()).unwrap();
796+ assert!(is_dir);
797+ let stored = dir.path().join("doc.tar.gz");
798+ assert_eq!(size, stored.metadata().unwrap().len() as i64);
799+ // Round-trips through gzip back to the original tar bytes.
800+ let mut gz = flate2::read::GzDecoder::new(std::fs::File::open(&stored).unwrap());
801+ let mut bytes = Vec::new();
802+ gz.read_to_end(&mut bytes).unwrap();
803+ assert_eq!(bytes, tar);
804+ }
805+
806+ #[test]
807+ fn extracts_a_browsable_directory_artifact() {
808+ let dir = tempfile::tempdir().unwrap();
809+ let tar = tar_of(&[
810+ ("doc", None),
811+ ("doc/index.html", Some("<html>")),
812+ ("doc/sub", None),
813+ ("doc/sub/page.html", Some("<p>")),
814+ ]);
815+ let (size, is_dir) =
816+ store_artifact(&spec("doc", "target/doc", true), &tar, dir.path()).unwrap();
817+ assert!(is_dir);
818+ assert_eq!(size, 6 + 3);
819+ let root = dir.path().join("doc");
820+ assert_eq!(std::fs::read(root.join("index.html")).unwrap(), b"<html>");
821+ assert_eq!(std::fs::read(root.join("sub/page.html")).unwrap(), b"<p>");
822+ }
823+
824+ #[test]
825+ fn parses_meta_tar_with_trimmed_capped_values() {
826+ let tar = tar_of(&[
827+ ("anvil-meta", None),
828+ ("anvil-meta/bin", None),
829+ ("anvil-meta/bin/version", Some("anvild 0.0.0\n")),
830+ ("anvil-meta/bin/empty", Some(" \n")),
831+ ("anvil-meta/doc", None),
832+ ("anvil-meta/doc/pages", Some("42")),
833+ ]);
834+ let metas = parse_meta_tar(&tar);
835+ assert_eq!(metas["bin"]["version"], "anvild 0.0.0");
836+ assert_eq!(metas["doc"]["pages"], "42");
837+ assert!(!metas["bin"].contains_key("empty"), "blank values dropped");
838+ }
839+}
modifiedcrates/anvil-core/Cargo.toml+1 −0
⋯ 9 unchanged lines
1010 [dependencies]
1111 gix.workspace = true
1212 toasty.workspace = true
13+rusqlite.workspace = true
1314 argon2.workspace = true
1415 hmac.workspace = true
1516 sha2.workspace = true
⋯ 11 unchanged lines
modifiedcrates/anvil-core/src/ci.rs+258 −6
⋯ 2 unchanged lines
33
44 use serde::Deserialize;
55
6-use crate::error::{
7- Error,
8- Result,
6+use crate::{
7+ error::{
8+ Error,
9+ Result,
10+ },
11+ models::{
12+ CiArtifact,
13+ CiRun,
14+ },
915 };
10-use crate::models::CiRun;
1116
1217 /// Run status values stored in [`CiRun::status`].
1318 pub mod status {
⋯ 7 unchanged lines
2126 /// Path of the pipeline definition within a repository.
2227 pub const PIPELINE_PATH: &str = ".anvil/ci.yml";
2328
24-/// A parsed pipeline: a base image and ordered straight-line steps.
29+/// A parsed pipeline: a base image, ordered straight-line steps, and the
30+/// artifacts to collect afterwards.
2531 #[derive(Clone, Debug, Deserialize)]
2632 pub struct Pipeline {
2733 /// Docker image the steps run in, e.g. `rust:1.95-bookworm`.
2834 pub image: String,
2935 #[serde(default)]
3036 pub steps: Vec<Step>,
37+ #[serde(default)]
38+ pub artifacts: Vec<ArtifactSpec>,
39+}
40+
41+/// A declared artifact: a path in the workspace to collect after the steps
42+/// run, plus optional metadata extractors (see `docs/ci-artifacts.md`).
43+#[derive(Clone, Debug, Deserialize)]
44+pub struct ArtifactSpec {
45+ /// Display/URL name; unique within the pipeline, `[A-Za-z0-9._-]+`.
46+ pub name: String,
47+ /// Path relative to the workspace root. A file downloads as-is; a
48+ /// directory downloads as a tarball — unless `browse` is set.
49+ pub path: String,
50+ /// Serve this (directory) artifact as a browsable static site instead of
51+ /// a download, e.g. rustdoc output.
52+ #[serde(default)]
53+ pub browse: bool,
54+ /// Metadata extractors: key → shell command, run *inside the job
55+ /// container* after the steps. Each command's stdout (trimmed, capped)
56+ /// becomes the value shown next to the artifact.
57+ #[serde(default)]
58+ pub meta: std::collections::BTreeMap<String, String>,
3159 }
3260
3361 /// One pipeline step: a shell command, with an optional display name.
⋯ 17 unchanged lines
5179
5280 /// Parse a `.anvil/ci.yml` pipeline definition.
5381 pub fn parse_pipeline(yaml: &str) -> Result<Pipeline> {
54- let pipeline: Pipeline = serde_yaml::from_str(yaml)
82+ let mut pipeline: Pipeline = serde_yaml::from_str(yaml)
5583 .map_err(|e| Error::Invalid(format!("invalid {PIPELINE_PATH}: {e}")))?;
5684 if pipeline.image.trim().is_empty() {
5785 return Err(Error::Invalid(format!(
5886 "{PIPELINE_PATH}: `image` is required"
5987 )));
6088 }
89+ let mut seen = std::collections::BTreeSet::new();
90+ for a in &mut pipeline.artifacts {
91+ let invalid =
92+ |what: &str| Error::Invalid(format!("{PIPELINE_PATH}: artifact `{}`: {what}", a.name));
93+ if a.name.is_empty()
94+ || !a
95+ .name
96+ .chars()
97+ .all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c))
98+ {
99+ return Err(invalid("name must be non-empty [A-Za-z0-9._-]+"));
100+ }
101+ if !seen.insert(a.name.clone()) {
102+ return Err(invalid("duplicate name"));
103+ }
104+ // Normalize away a trailing slash so a directory path and the same
105+ // path without the slash behave identically downstream.
106+ a.path = a.path.trim_end_matches('/').to_string();
107+ if a.path.is_empty()
108+ || a.path.starts_with('/')
109+ || a.path.split('/').any(|seg| seg == ".." || seg.is_empty())
110+ {
111+ return Err(invalid(
112+ "path must be relative to the workspace, without `..`",
113+ ));
114+ }
115+ // Meta keys become file names in the extractor handoff, so they get
116+ // the same charset as artifact names.
117+ for key in a.meta.keys() {
118+ if key.is_empty()
119+ || !key
120+ .chars()
121+ .all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c))
122+ {
123+ return Err(invalid(&format!(
124+ "meta key `{key}` must be [A-Za-z0-9._-]+"
125+ )));
126+ }
127+ }
128+ }
61129 Ok(pipeline)
62130 }
63131
⋯ 116 unchanged lines
180248 Ok(runs.into_iter().map(|r| r.id).collect())
181249 }
182250
251+/// Record a collected artifact. `meta` is a JSON object string (`{}` if none).
252+#[allow(clippy::too_many_arguments)]
253+pub async fn add_artifact(
254+ db: &toasty::Db,
255+ run_id: i64,
256+ repo_id: i64,
257+ commit: &str,
258+ name: &str,
259+ size: i64,
260+ is_dir: bool,
261+ browse: bool,
262+ meta: &str,
263+) -> Result<CiArtifact> {
264+ let mut conn = db.clone();
265+ let artifact = toasty::create!(CiArtifact {
266+ run_id: run_id,
267+ repo_id: repo_id,
268+ commit: commit,
269+ name: name,
270+ size: size,
271+ is_dir: is_dir,
272+ browse: browse,
273+ meta: meta,
274+ created_at: crate::now(),
275+ })
276+ .exec(&mut conn)
277+ .await?;
278+ Ok(artifact)
279+}
280+
281+/// A run's artifacts, in declaration (insertion) order.
282+pub async fn artifacts_for_run(db: &toasty::Db, run_id: i64) -> Result<Vec<CiArtifact>> {
283+ let mut conn = db.clone();
284+ let artifacts = CiArtifact::filter(CiArtifact::fields().run_id().eq(run_id))
285+ .order_by(CiArtifact::fields().id().asc())
286+ .exec(&mut conn)
287+ .await?;
288+ Ok(artifacts)
289+}
290+
291+/// The newest artifact named `name` for `commit` (across that commit's runs).
292+pub async fn latest_artifact(
293+ db: &toasty::Db,
294+ repo_id: i64,
295+ commit: &str,
296+ name: &str,
297+) -> Result<Option<CiArtifact>> {
298+ let mut conn = db.clone();
299+ let artifact = CiArtifact::filter(CiArtifact::fields().repo_id().eq(repo_id))
300+ .filter(CiArtifact::fields().commit().eq(commit))
301+ .filter(CiArtifact::fields().name().eq(name))
302+ .order_by(CiArtifact::fields().id().desc())
303+ .first()
304+ .exec(&mut conn)
305+ .await?;
306+ Ok(artifact)
307+}
308+
309+/// All artifact rows for a repository (for GC accounting). Small at our scale.
310+pub async fn artifacts_for_repo(db: &toasty::Db, repo_id: i64) -> Result<Vec<CiArtifact>> {
311+ let mut conn = db.clone();
312+ let artifacts = CiArtifact::filter(CiArtifact::fields().repo_id().eq(repo_id))
313+ .exec(&mut conn)
314+ .await?;
315+ Ok(artifacts)
316+}
317+
318+/// Delete the artifact rows for one commit (the on-disk directory is the
319+/// caller's to remove). Used when re-running a commit and by GC.
320+pub async fn delete_artifacts_for_commit(
321+ db: &toasty::Db,
322+ repo_id: i64,
323+ commit: &str,
324+) -> Result<()> {
325+ let mut conn = db.clone();
326+ let rows = CiArtifact::filter(CiArtifact::fields().repo_id().eq(repo_id))
327+ .filter(CiArtifact::fields().commit().eq(commit))
328+ .exec(&mut conn)
329+ .await?;
330+ for row in rows {
331+ let mut conn = db.clone();
332+ row.delete().exec(&mut conn).await?;
333+ }
334+ Ok(())
335+}
336+
183337 #[cfg(test)]
184338 mod tests {
185339 use super::*;
⋯ 22 unchanged lines
208362 assert!(parse_pipeline("steps: []\n").is_err());
209363 }
210364
365+ #[test]
366+ fn parses_and_validates_artifacts() {
367+ let p = parse_pipeline(
368+ r#"image: rust:1.95
369+artifacts:
370+ - name: anvild
371+ path: target/release/anvild
372+ meta:
373+ version: ./target/release/anvild --version
374+ - name: doc
375+ path: target/doc/
376+ browse: true
377+"#,
378+ )
379+ .unwrap();
380+ assert_eq!(p.artifacts.len(), 2);
381+ assert_eq!(p.artifacts[0].name, "anvild");
382+ assert_eq!(
383+ p.artifacts[0].meta["version"],
384+ "./target/release/anvild --version"
385+ );
386+ assert!(!p.artifacts[0].browse);
387+ assert_eq!(p.artifacts[1].path, "target/doc", "trailing slash trimmed");
388+ assert!(p.artifacts[1].browse);
389+
390+ let must_fail = |yaml: &str, why: &str| {
391+ assert!(
392+ parse_pipeline(&format!("image: i\n{yaml}")).is_err(),
393+ "{why}"
394+ );
395+ };
396+ must_fail(
397+ "artifacts: [{name: 'a b', path: x}]",
398+ "space in name rejected",
399+ );
400+ must_fail(
401+ "artifacts: [{name: 'a/b', path: x}]",
402+ "slash in name rejected",
403+ );
404+ must_fail("artifacts: [{name: '', path: x}]", "empty name rejected");
405+ must_fail(
406+ "artifacts: [{name: a, path: x}, {name: a, path: y}]",
407+ "duplicate name rejected",
408+ );
409+ must_fail(
410+ "artifacts: [{name: a, path: /etc}]",
411+ "absolute path rejected",
412+ );
413+ must_fail(
414+ "artifacts: [{name: a, path: '../up'}]",
415+ "traversal rejected",
416+ );
417+ must_fail(
418+ "artifacts: [{name: a, path: 'x//y'}]",
419+ "empty segment rejected",
420+ );
421+ must_fail("artifacts: [{name: a, path: ''}]", "empty path rejected");
422+ }
423+
211424 // Exercises the run-lifecycle queries against a real SQLite database, to
212425 // confirm the ORM-level `order_by`/`limit`/filter actually work (Toasty is
213426 // pre-1.0, so we don't take that on faith).
⋯ 25 unchanged lines
239452 assert_eq!(queued.len(), 4);
240453 assert!(queued.windows(2).all(|w| w[0] < w[1]), "ascending");
241454 }
455+
456+ #[tokio::test]
457+ async fn artifact_rows_round_trip() {
458+ let dir = tempfile::tempdir().unwrap();
459+ let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
460+
461+ add_artifact(&db, 1, 7, "abc", "bin", 100, false, false, "{}")
462+ .await
463+ .unwrap();
464+ add_artifact(&db, 1, 7, "abc", "doc", 5000, true, true, r#"{"v":"1"}"#)
465+ .await
466+ .unwrap();
467+ // A newer run of the same commit supersedes for `latest_artifact`.
468+ add_artifact(&db, 2, 7, "abc", "bin", 200, false, false, "{}")
469+ .await
470+ .unwrap();
471+
472+ let run1 = artifacts_for_run(&db, 1).await.unwrap();
473+ assert_eq!(run1.len(), 2);
474+ assert_eq!(run1[0].name, "bin");
475+ assert!(run1[1].browse);
476+
477+ let latest = latest_artifact(&db, 7, "abc", "bin")
478+ .await
479+ .unwrap()
480+ .unwrap();
481+ assert_eq!(latest.run_id, 2);
482+ assert_eq!(latest.size, 200);
483+ assert!(
484+ latest_artifact(&db, 8, "abc", "bin")
485+ .await
486+ .unwrap()
487+ .is_none(),
488+ "scoped to repo"
489+ );
490+
491+ delete_artifacts_for_commit(&db, 7, "abc").await.unwrap();
492+ assert!(artifacts_for_repo(&db, 7).await.unwrap().is_empty());
493+ }
242494 }
modifiedcrates/anvil-core/src/config.rs+20 −0
⋯ 77 unchanged lines
7878 /// to the image). Empty keeps the image's default user. Note many base
7979 /// images assume root for e.g. `apt-get`.
8080 pub run_as: String,
81+ /// Size cap for a single artifact, in MiB; larger artifacts are skipped
82+ /// (with a log note), never failing the run. `0` means unlimited.
83+ /// Defaults to 256.
84+ pub artifact_max_mb: i64,
85+ /// Combined size cap for one run's artifacts, in MiB. Artifacts that would
86+ /// push the run over it are skipped. `0` means unlimited. Defaults to 512.
87+ pub artifact_run_max_mb: i64,
88+ /// Combined artifact budget per repository, in MiB. After each run, oldest
89+ /// commits' artifacts are deleted until the repo fits (branch-head commits
90+ /// are pinned). `0` means unlimited. Defaults to 4096.
91+ pub artifact_quota_mb: i64,
8192 }
8293
8394 #[derive(Clone, Debug, Deserialize, Serialize)]
⋯ 54 unchanged lines
138149 timeout_secs: 1800,
139150 network: true,
140151 run_as: String::new(),
152+ artifact_max_mb: 256,
153+ artifact_run_max_mb: 512,
154+ artifact_quota_mb: 4096,
141155 }
142156 }
143157 }
⋯ 73 unchanged lines
217231 self.data_dir.join("repositories")
218232 }
219233
234+ /// Root directory under which CI artifacts are stored
235+ /// (`artifacts/{repo_id}/{commit}/…` — see `docs/ci-artifacts.md`).
236+ pub fn artifacts_dir(&self) -> PathBuf {
237+ self.data_dir.join("artifacts")
238+ }
239+
220240 /// Whether session cookies should carry the `Secure` attribute (HTTPS-only).
221241 /// Derived from the public base URL's scheme, so local plaintext dev still
222242 /// works while production behind TLS gets `Secure` automatically.
⋯ 57 unchanged lines
modifiedcrates/anvil-core/src/db.rs+232 −8
⋯ 1 unchanged line
22
33 use std::path::Path;
44
5-use crate::error::Result;
6-use crate::models::{
7- CiRun,
8- Repository,
9- Session,
10- SshKey,
11- User,
5+use crate::{
6+ error::Result,
7+ models::{
8+ CiArtifact,
9+ CiRun,
10+ Issue,
11+ IssueComment,
12+ Repository,
13+ Session,
14+ SshKey,
15+ User,
16+ },
1217 };
1318
1419 /// Open (creating if necessary) the SQLite database at `path`, creating the
⋯ 11 unchanged lines
2631 let url = format!("sqlite:{}", path.display());
2732
2833 let db = toasty::Db::builder()
29- .models(toasty::models!(User, Repository, SshKey, Session, CiRun))
34+ .models(toasty::models!(
35+ User,
36+ Repository,
37+ SshKey,
38+ Session,
39+ CiRun,
40+ CiArtifact,
41+ Issue,
42+ IssueComment
43+ ))
3044 .connect(&url)
3145 .await?;
3246
3347 if fresh {
3448 db.push_schema().await?;
49+ } else {
50+ migrate_existing(path)?;
3551 }
3652 Ok(db)
3753 }
54+
55+/// Tables added after a deployment's database was first created, as idempotent
56+/// DDL applied to existing databases (Toasty's `push_schema` only runs on a
57+/// fresh file; see above). Each statement must match what `push_schema` would
58+/// generate for the model — `schema_shim_matches_push_schema` asserts that.
59+const SCHEMA_SHIMS: &[&str] = &[
60+ CI_ARTIFACTS_DDL,
61+ r#"CREATE INDEX IF NOT EXISTS "index_ci_artifacts_by_repo_id" ON "ci_artifacts" ("repo_id")"#,
62+ r#"CREATE INDEX IF NOT EXISTS "index_ci_artifacts_by_run_id" ON "ci_artifacts" ("run_id")"#,
63+ ISSUES_DDL,
64+ r#"CREATE INDEX IF NOT EXISTS "index_issues_by_repo_id" ON "issues" ("repo_id")"#,
65+ ISSUE_COMMENTS_DDL,
66+ r#"CREATE INDEX IF NOT EXISTS "index_issue_comments_by_issue_id" ON "issue_comments" ("issue_id")"#,
67+];
68+
69+const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" (
70+"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
71+"run_id" BIGINT NOT NULL,
72+"repo_id" BIGINT NOT NULL,
73+"commit" TEXT NOT NULL,
74+"name" TEXT NOT NULL,
75+"size" BIGINT NOT NULL,
76+"is_dir" BOOLEAN NOT NULL,
77+"browse" BOOLEAN NOT NULL,
78+"meta" TEXT NOT NULL,
79+"created_at" BIGINT NOT NULL )"#;
80+
81+const ISSUES_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "issues" (
82+"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
83+"repo_id" BIGINT NOT NULL,
84+"number" BIGINT NOT NULL,
85+"title" TEXT NOT NULL,
86+"body" TEXT NOT NULL,
87+"author_id" BIGINT NOT NULL,
88+"state" TEXT NOT NULL,
89+"created_at" BIGINT NOT NULL,
90+"updated_at" BIGINT NOT NULL )"#;
91+
92+const ISSUE_COMMENTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "issue_comments" (
93+"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
94+"issue_id" BIGINT NOT NULL,
95+"author_id" BIGINT NOT NULL,
96+"body" TEXT NOT NULL,
97+"created_at" BIGINT NOT NULL )"#;
98+
99+/// Columns added to existing tables after deployment, applied as
100+/// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS`
101+/// for columns, so presence is checked via `pragma_table_info`). The model
102+/// must declare the field *last* so fresh and migrated column orders agree.
103+/// The `DEFAULT` backfills existing rows; Toasty's fresh DDL omits it, which
104+/// is fine — inserts always provide every field.
105+const COLUMN_SHIMS: &[(&str, &str, &str)] = &[(
106+ "repositories",
107+ "mirror_url",
108+ r#"ALTER TABLE "repositories" ADD COLUMN "mirror_url" TEXT NOT NULL DEFAULT ''"#,
109+)];
110+
111+/// Apply [`SCHEMA_SHIMS`] and [`COLUMN_SHIMS`] to an existing database. Uses
112+/// rusqlite directly (already in-tree via Toasty's SQLite driver); every
113+/// statement is a no-op once applied.
114+fn migrate_existing(path: &Path) -> Result<()> {
115+ let migrate_err = |e: &dyn std::fmt::Display| crate::Error::Config(format!("schema shim: {e}"));
116+ let conn = rusqlite::Connection::open(path)
117+ .map_err(|e| crate::Error::Config(format!("opening {} to migrate: {e}", path.display())))?;
118+ for ddl in SCHEMA_SHIMS {
119+ conn.execute_batch(ddl).map_err(|e| migrate_err(&e))?;
120+ }
121+ for (table, column, ddl) in COLUMN_SHIMS {
122+ let present: i64 = conn
123+ .query_row(
124+ "SELECT count(*) FROM pragma_table_info(?1) WHERE name = ?2",
125+ (table, column),
126+ |r| r.get(0),
127+ )
128+ .map_err(|e| migrate_err(&e))?;
129+ if present == 0 {
130+ conn.execute_batch(ddl).map_err(|e| migrate_err(&e))?;
131+ }
132+ }
133+ Ok(())
134+}
135+
136+#[cfg(test)]
137+mod tests {
138+ use super::*;
139+
140+ /// Tables created by shims (i.e. added after the first deployment).
141+ const SHIMMED_TABLES: &[&str] = &["ci_artifacts", "issues", "issue_comments"];
142+
143+ /// Every schema object (table + indexes) for `table`, normalized.
144+ fn schema_objects(path: &Path, table: &str) -> Vec<String> {
145+ let conn = rusqlite::Connection::open(path).unwrap();
146+ let mut stmt = conn
147+ .prepare(
148+ "SELECT sql FROM sqlite_master WHERE tbl_name=?1 \
149+ AND sql IS NOT NULL ORDER BY name",
150+ )
151+ .unwrap();
152+ stmt.query_map([table], |r| r.get::<_, String>(0))
153+ .unwrap()
154+ .map(|s| {
155+ s.unwrap()
156+ .replace(" IF NOT EXISTS", "")
157+ .split_whitespace()
158+ .collect::<Vec<_>>()
159+ .join(" ")
160+ })
161+ .collect()
162+ }
163+
164+ /// The hand-written shim DDL must produce the same tables and indexes as
165+ /// a fresh `push_schema`, or fresh and migrated databases would diverge.
166+ #[tokio::test]
167+ async fn schema_shim_matches_push_schema() {
168+ let dir = tempfile::tempdir().unwrap();
169+
170+ let fresh = dir.path().join("fresh.db");
171+ connect(&fresh).await.unwrap();
172+
173+ // A database from before the shimmed tables existed.
174+ let migrated = dir.path().join("migrated.db");
175+ connect(&migrated).await.unwrap();
176+ let conn = rusqlite::Connection::open(&migrated).unwrap();
177+ for table in SHIMMED_TABLES {
178+ conn.execute_batch(&format!("DROP TABLE {table}")).unwrap();
179+ }
180+ drop(conn);
181+ connect(&migrated).await.unwrap();
182+
183+ for table in SHIMMED_TABLES {
184+ assert_eq!(
185+ schema_objects(&fresh, table),
186+ schema_objects(&migrated, table),
187+ "schema diverges for {table}"
188+ );
189+ assert!(
190+ !schema_objects(&fresh, table).is_empty(),
191+ "no schema objects for {table}"
192+ );
193+ }
194+ }
195+
196+ /// Column name/type/notnull triples for a table.
197+ fn columns(path: &Path, table: &str) -> Vec<(String, String, bool)> {
198+ let conn = rusqlite::Connection::open(path).unwrap();
199+ let mut stmt = conn
200+ .prepare("SELECT name, type, \"notnull\" FROM pragma_table_info(?1)")
201+ .unwrap();
202+ stmt.query_map([table], |r| {
203+ Ok((r.get(0)?, r.get(1)?, r.get::<_, i64>(2)? != 0))
204+ })
205+ .unwrap()
206+ .map(|r| r.unwrap())
207+ .collect()
208+ }
209+
210+ /// A column shim must converge an old table to the fresh schema's columns
211+ /// (same names, order, types, nullability — the DDL text itself differs
212+ /// because of the backfill `DEFAULT`).
213+ #[tokio::test]
214+ async fn column_shim_matches_push_schema() {
215+ let dir = tempfile::tempdir().unwrap();
216+
217+ let fresh = dir.path().join("fresh.db");
218+ connect(&fresh).await.unwrap();
219+
220+ let migrated = dir.path().join("migrated.db");
221+ connect(&migrated).await.unwrap();
222+ let conn = rusqlite::Connection::open(&migrated).unwrap();
223+ conn.execute_batch(r#"ALTER TABLE "repositories" DROP COLUMN "mirror_url""#)
224+ .unwrap();
225+ drop(conn);
226+ connect(&migrated).await.unwrap();
227+ connect(&migrated).await.unwrap(); // idempotent
228+
229+ assert_eq!(
230+ columns(&fresh, "repositories"),
231+ columns(&migrated, "repositories")
232+ );
233+ }
234+
235+ /// Reconnecting to an existing database that predates a table must create
236+ /// it (and reconnecting again must be a no-op).
237+ #[tokio::test]
238+ async fn migrate_existing_adds_missing_tables() {
239+ let dir = tempfile::tempdir().unwrap();
240+ let path = dir.path().join("old.db");
241+ connect(&path).await.unwrap();
242+
243+ // Simulate a database from before the table existed.
244+ let conn = rusqlite::Connection::open(&path).unwrap();
245+ conn.execute_batch("DROP TABLE ci_artifacts").unwrap();
246+ drop(conn);
247+
248+ connect(&path).await.unwrap();
249+ connect(&path).await.unwrap(); // idempotent
250+
251+ let conn = rusqlite::Connection::open(&path).unwrap();
252+ let n: i64 = conn
253+ .query_row(
254+ "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ci_artifacts'",
255+ [],
256+ |r| r.get(0),
257+ )
258+ .unwrap();
259+ assert_eq!(n, 1);
260+ }
261+}
addedcrates/anvil-core/src/issues.rs+181 −0
1+//! Per-repository issues: creation, listing, comments, and open/close state.
2+//!
3+//! Issues are numbered per repository (`#1`, `#2`, …) in creation order.
4+//! Numbering is assigned as max+1 at creation, which cannot race with a
5+//! single server process (our deployment shape).
6+
7+use crate::{
8+ error::{
9+ Error,
10+ Result,
11+ },
12+ models::{
13+ Issue,
14+ IssueComment,
15+ },
16+};
17+
18+/// Issue state values stored in [`Issue::state`].
19+pub mod state {
20+ pub const OPEN: &str = "open";
21+ pub const CLOSED: &str = "closed";
22+}
23+
24+/// Create an issue, assigning the repo's next number.
25+pub async fn create(
26+ db: &toasty::Db,
27+ repo_id: i64,
28+ author_id: i64,
29+ title: &str,
30+ body: &str,
31+) -> Result<Issue> {
32+ let title = title.trim();
33+ if title.is_empty() {
34+ return Err(Error::Invalid("issue title must not be empty".into()));
35+ }
36+
37+ let mut conn = db.clone();
38+ let last = Issue::filter(Issue::fields().repo_id().eq(repo_id))
39+ .order_by(Issue::fields().number().desc())
40+ .first()
41+ .exec(&mut conn)
42+ .await?;
43+ let number = last.map(|i| i.number).unwrap_or(0) + 1;
44+
45+ let now = crate::now();
46+ let mut conn = db.clone();
47+ let issue = toasty::create!(Issue {
48+ repo_id: repo_id,
49+ number: number,
50+ title: title,
51+ body: body.trim(),
52+ author_id: author_id,
53+ state: state::OPEN,
54+ created_at: now,
55+ updated_at: now,
56+ })
57+ .exec(&mut conn)
58+ .await?;
59+ Ok(issue)
60+}
61+
62+/// Find one issue by its per-repo number.
63+pub async fn find(db: &toasty::Db, repo_id: i64, number: i64) -> Result<Option<Issue>> {
64+ let mut conn = db.clone();
65+ let issue = Issue::filter(Issue::fields().repo_id().eq(repo_id))
66+ .filter(Issue::fields().number().eq(number))
67+ .first()
68+ .exec(&mut conn)
69+ .await?;
70+ Ok(issue)
71+}
72+
73+/// List a repository's issues in one state, most recently active first.
74+pub async fn list(db: &toasty::Db, repo_id: i64, state: &str) -> Result<Vec<Issue>> {
75+ let mut conn = db.clone();
76+ let issues = Issue::filter(Issue::fields().repo_id().eq(repo_id))
77+ .filter(Issue::fields().state().eq(state))
78+ .order_by(Issue::fields().updated_at().desc())
79+ .exec(&mut conn)
80+ .await?;
81+ Ok(issues)
82+}
83+
84+/// Open/closed counts for the list page tabs.
85+pub async fn counts(db: &toasty::Db, repo_id: i64) -> Result<(usize, usize)> {
86+ // Two filtered fetches; issue counts stay small at our scale.
87+ let open = list(db, repo_id, state::OPEN).await?.len();
88+ let closed = list(db, repo_id, state::CLOSED).await?.len();
89+ Ok((open, closed))
90+}
91+
92+/// Set an issue's state (`open`/`closed`) and bump its activity time.
93+pub async fn set_state(db: &toasty::Db, issue: &mut Issue, new_state: &str) -> Result<()> {
94+ let mut conn = db.clone();
95+ issue
96+ .update()
97+ .state(new_state)
98+ .updated_at(crate::now())
99+ .exec(&mut conn)
100+ .await?;
101+ Ok(())
102+}
103+
104+/// Add a comment and bump the issue's activity time.
105+pub async fn comment(
106+ db: &toasty::Db,
107+ issue: &mut Issue,
108+ author_id: i64,
109+ body: &str,
110+) -> Result<IssueComment> {
111+ let body = body.trim();
112+ if body.is_empty() {
113+ return Err(Error::Invalid("comment must not be empty".into()));
114+ }
115+ let mut conn = db.clone();
116+ let comment = toasty::create!(IssueComment {
117+ issue_id: issue.id,
118+ author_id: author_id,
119+ body: body,
120+ created_at: crate::now(),
121+ })
122+ .exec(&mut conn)
123+ .await?;
124+ let mut conn = db.clone();
125+ issue
126+ .update()
127+ .updated_at(crate::now())
128+ .exec(&mut conn)
129+ .await?;
130+ Ok(comment)
131+}
132+
133+/// An issue's comments, oldest first.
134+pub async fn comments(db: &toasty::Db, issue_id: i64) -> Result<Vec<IssueComment>> {
135+ let mut conn = db.clone();
136+ let comments = IssueComment::filter(IssueComment::fields().issue_id().eq(issue_id))
137+ .order_by(IssueComment::fields().id().asc())
138+ .exec(&mut conn)
139+ .await?;
140+ Ok(comments)
141+}
142+
143+#[cfg(test)]
144+mod tests {
145+ use super::*;
146+
147+ #[tokio::test]
148+ async fn numbering_listing_and_comments() {
149+ let dir = tempfile::tempdir().unwrap();
150+ let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
151+
152+ let a = create(&db, 1, 10, "first", "body").await.unwrap();
153+ let b = create(&db, 1, 10, "second", "").await.unwrap();
154+ let other = create(&db, 2, 10, "other repo", "").await.unwrap();
155+ assert_eq!((a.number, b.number), (1, 2));
156+ assert_eq!(other.number, 1, "numbering is per-repo");
157+
158+ assert!(create(&db, 1, 10, " ", "x").await.is_err(), "blank title");
159+
160+ // Activity ordering: commenting on #1 moves it to the front.
161+ let mut a = find(&db, 1, 1).await.unwrap().unwrap();
162+ std::thread::sleep(std::time::Duration::from_millis(1100));
163+ comment(&db, &mut a, 11, "hello").await.unwrap();
164+ let open = list(&db, 1, state::OPEN).await.unwrap();
165+ assert_eq!(open.len(), 2);
166+ assert_eq!(open[0].number, 1, "recently-commented issue first");
167+
168+ let mut b = find(&db, 1, 2).await.unwrap().unwrap();
169+ set_state(&db, &mut b, state::CLOSED).await.unwrap();
170+ assert_eq!(counts(&db, 1).await.unwrap(), (1, 1));
171+ assert_eq!(list(&db, 1, state::CLOSED).await.unwrap()[0].number, 2);
172+
173+ let cs = comments(&db, a.id).await.unwrap();
174+ assert_eq!(cs.len(), 1);
175+ assert_eq!(cs[0].body, "hello");
176+ assert!(
177+ comment(&db, &mut a, 11, " ").await.is_err(),
178+ "blank comment"
179+ );
180+ }
181+}
modifiedcrates/anvil-core/src/lib.rs+4 −0
⋯ 9 unchanged lines
1010 pub mod config;
1111 pub mod db;
1212 pub mod error;
13+pub mod issues;
1314 pub mod models;
1415 pub mod repos;
1516 pub mod sessions;
⋯ 7 unchanged lines
2324 Result,
2425 };
2526 pub use models::{
27+ CiArtifact,
2628 CiRun,
29+ Issue,
30+ IssueComment,
2731 Repository,
2832 Session,
2933 SshKey,
⋯ 96 unchanged lines
modifiedcrates/anvil-core/src/models.rs+71 −0
⋯ 34 unchanged lines
3535 /// Short name of the default branch, e.g. `main`.
3636 pub default_branch: String,
3737 pub created_at: i64,
38+ /// Push-mirror remote: after every successful push, refs are mirrored to
39+ /// this git URL (`git push --mirror`). Empty disables mirroring. New
40+ /// columns go last so `ALTER TABLE ADD COLUMN` on existing databases
41+ /// agrees with the fresh-schema column order.
42+ pub mirror_url: String,
3843 }
3944
4045 /// A CI run for a pushed commit.
⋯ 20 unchanged lines
6166 pub finished_at: i64,
6267 }
6368
69+/// One artifact produced by a CI run, stored on disk under
70+/// `data_dir/artifacts/{repo_id}/{commit}/` (see `docs/ci-artifacts.md`).
71+///
72+/// `commit` is denormalized from the run so per-commit lookups (the
73+/// latest-on-branch alias) don't join through runs.
74+#[derive(Clone, Debug, toasty::Model)]
75+pub struct CiArtifact {
76+ #[key]
77+ #[auto]
78+ pub id: i64,
79+ #[index]
80+ pub run_id: i64,
81+ #[index]
82+ pub repo_id: i64,
83+ /// Full commit SHA the producing run was for.
84+ pub commit: String,
85+ /// Declared artifact name (unique within a pipeline, not globally).
86+ pub name: String,
87+ /// Total size in bytes (summed over files for directory artifacts).
88+ pub size: i64,
89+ /// Directory artifact (stored as a tarball, or extracted when `browse`).
90+ pub is_dir: bool,
91+ /// Served as a browsable static site rather than a download.
92+ pub browse: bool,
93+ /// JSON object of metadata-extractor key → output.
94+ pub meta: String,
95+ pub created_at: i64,
96+}
97+
98+/// An issue on a repository. `number` is the user-facing per-repo sequence
99+/// (`#1`, `#2`, …); `id` stays the global key. `state` is `open` or `closed`.
100+///
101+/// Numbering is assigned as max+1 at creation; with a single server process
102+/// (our deployment shape) that cannot race.
103+#[derive(Clone, Debug, toasty::Model)]
104+pub struct Issue {
105+ #[key]
106+ #[auto]
107+ pub id: i64,
108+ #[index]
109+ pub repo_id: i64,
110+ pub number: i64,
111+ pub title: String,
112+ /// Markdown body (may be empty).
113+ pub body: String,
114+ pub author_id: i64,
115+ pub state: String,
116+ pub created_at: i64,
117+ /// Bumped on comments and state changes, for "recently active" ordering.
118+ pub updated_at: i64,
119+}
120+
121+/// A comment on an [`Issue`].
122+#[derive(Clone, Debug, toasty::Model)]
123+pub struct IssueComment {
124+ #[key]
125+ #[auto]
126+ pub id: i64,
127+ #[index]
128+ pub issue_id: i64,
129+ pub author_id: i64,
130+ /// Markdown body.
131+ pub body: String,
132+ pub created_at: i64,
133+}
134+
64135 /// A web login session, keyed by an opaque random token stored in a cookie.
65136 #[derive(Debug, toasty::Model)]
66137 pub struct Session {
⋯ 25 unchanged lines
modifiedcrates/anvil-core/src/repos.rs+98 −9
⋯ 1 unchanged line
22
33 use std::path::Path;
44
5-use crate::error::{
6- Error,
7- Result,
8-};
9-use crate::models::{
10- Repository,
11- User,
5+use crate::{
6+ error::{
7+ Error,
8+ Result,
9+ },
10+ models::{
11+ Repository,
12+ User,
13+ },
14+ storage,
1215 };
13-use crate::storage;
1416
1517 /// A repository joined with its owner's username, for listing pages.
1618 pub struct RepoWithOwner {
⋯ 32 unchanged lines
4951 is_private: is_private,
5052 default_branch: "main",
5153 created_at: crate::now(),
54+ mirror_url: "",
5255 })
5356 .exec(&mut conn)
5457 .await?;
⋯ 11 unchanged lines
6669 Ok(repo)
6770 }
6871
72+/// Push-to-create: create `owner_username/name` on first push, when the
73+/// authenticated pusher is allowed to — the pusher *is* the owner, or is an
74+/// admin. Created repositories are private (flip in settings afterwards).
75+/// `Ok(None)` means the policy said no; callers fall back to not-found so
76+/// nothing about the namespace leaks.
77+pub async fn create_on_push(
78+ db: &toasty::Db,
79+ repositories_dir: &Path,
80+ owner_username: &str,
81+ name: &str,
82+ pusher: &User,
83+) -> Result<Option<Repository>> {
84+ let Some(owner) = crate::users::find_by_username(db, owner_username).await? else {
85+ return Ok(None);
86+ };
87+ if pusher.id != owner.id && !pusher.is_admin {
88+ return Ok(None);
89+ }
90+ let repo = create(db, repositories_dir, &owner, name, "", true).await?;
91+ tracing::info!(
92+ "push-to-create: {}/{name} (by {})",
93+ owner.username,
94+ pusher.username
95+ );
96+ Ok(Some(repo))
97+}
98+
6999 /// Find a repository by its id.
70100 pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<Repository>> {
71101 let mut db = db.clone();
⋯ 4 unchanged lines
76106 Ok(repo)
77107 }
78108
79-/// Update a repository's description and visibility.
109+/// Update a repository's description, visibility, and push-mirror URL.
80110 pub async fn update_settings(
81111 db: &toasty::Db,
82112 repo_id: i64,
83113 description: &str,
84114 is_private: bool,
115+ mirror_url: &str,
85116 ) -> Result<()> {
86117 let mut conn = db.clone();
87118 let Some(mut repo) = Repository::filter(Repository::fields().id().eq(repo_id))
⋯ 7 unchanged lines
95126 repo.update()
96127 .description(description)
97128 .is_private(is_private)
129+ .mirror_url(mirror_url.trim())
98130 .exec(&mut conn)
99131 .await?;
100132 Ok(())
⋯ 53 unchanged lines
154186 }
155187 Ok(())
156188 }
189+
190+#[cfg(test)]
191+mod tests {
192+ use super::*;
193+
194+ /// Push-to-create only fires for the namespace owner or an admin, and the
195+ /// repos it creates are private.
196+ #[tokio::test]
197+ async fn create_on_push_policy() {
198+ let dir = tempfile::tempdir().unwrap();
199+ let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
200+ let repos_dir = dir.path().join("repos");
201+
202+ let alice = crate::users::create(&db, "alice", "", "pw-alice-1", false)
203+ .await
204+ .unwrap();
205+ let bob = crate::users::create(&db, "bob", "", "pw-bob-1", false)
206+ .await
207+ .unwrap();
208+ let root = crate::users::create(&db, "root", "", "pw-root-1", true)
209+ .await
210+ .unwrap();
211+
212+ // A stranger can't create into someone else's namespace…
213+ let denied = create_on_push(&db, &repos_dir, "alice", "proj", &bob)
214+ .await
215+ .unwrap();
216+ assert!(denied.is_none());
217+ // …nor into a namespace with no such user.
218+ let nobody = create_on_push(&db, &repos_dir, "ghost", "proj", &bob)
219+ .await
220+ .unwrap();
221+ assert!(nobody.is_none());
222+
223+ // The owner can, and gets a private repo on disk.
224+ let repo = create_on_push(&db, &repos_dir, "alice", "proj", &alice)
225+ .await
226+ .unwrap()
227+ .expect("owner may push-create");
228+ assert!(repo.is_private);
229+ assert!(storage::repo_path(&repos_dir, "alice", "proj").exists());
230+
231+ // An admin can create into any namespace.
232+ let by_admin = create_on_push(&db, &repos_dir, "bob", "tool", &root)
233+ .await
234+ .unwrap();
235+ assert!(by_admin.is_some());
236+
237+ // Re-creating an existing repo is an error (callers never reach this:
238+ // they only call after a failed lookup).
239+ assert!(
240+ create_on_push(&db, &repos_dir, "alice", "proj", &alice)
241+ .await
242+ .is_err()
243+ );
244+ }
245+}
modifiedcrates/anvil-core/src/storage.rs+14 −6
⋯ 19 unchanged lines
2020 repositories_dir.join(owner).join(format!("{name}.git"))
2121 }
2222
23+/// Directory holding one commit's CI artifacts
24+/// (`<artifacts_dir>/<repo_id>/<commit>` — see `docs/ci-artifacts.md`).
25+pub fn artifact_commit_dir(artifacts_dir: &Path, repo_id: i64, commit: &str) -> PathBuf {
26+ artifacts_dir.join(repo_id.to_string()).join(commit)
27+}
28+
2329 /// Create a new bare repository on disk, returning the opened handle.
2430 ///
2531 /// `HEAD` is pointed at `refs/heads/<default_branch>` so the on-disk repository
⋯ 26 unchanged lines
5258
5359 /// Point `HEAD` at `refs/heads/<branch>` as a symbolic reference.
5460 fn set_head_branch(repo: &gix::Repository, branch: &str) -> Result<()> {
55- use gix::refs::Target;
56- use gix::refs::transaction::{
57- Change,
58- LogChange,
59- PreviousValue,
60- RefEdit,
61+ use gix::refs::{
62+ Target,
63+ transaction::{
64+ Change,
65+ LogChange,
66+ PreviousValue,
67+ RefEdit,
68+ },
6169 };
6270
6371 let target_name: gix::refs::FullName = format!("refs/heads/{branch}")
⋯ 24 unchanged lines
modifiedcrates/anvil-git/src/browse.rs+35 −5
⋯ 2 unchanged lines
33 //! These helpers back the web UI. Each opens the bare repo by path; that is
44 //! cheap enough at our scale and keeps the API stateless.
55
6-use std::collections::{
7- BTreeMap,
8- BTreeSet,
6+use std::{
7+ collections::{
8+ BTreeMap,
9+ BTreeSet,
10+ },
11+ fmt::Display,
12+ path::Path,
913 };
10-use std::fmt::Display;
11-use std::path::Path;
1214
1315 use crate::error::{
1416 Error,
⋯ 219 unchanged lines
234236 Ok(out)
235237 }
236238
239+/// The commit ids each local branch currently points at (deduplicated).
240+/// Used to pin branch-tip artifacts during GC.
241+pub fn branch_tips(repo_path: &Path) -> Result<BTreeSet<String>> {
242+ let repo = gix::open(repo_path).map_err(read)?;
243+ let refs = repo.references().map_err(read)?;
244+ let mut tips = BTreeSet::new();
245+ for r in refs.local_branches().map_err(read)?.flatten() {
246+ if let Some(id) = r.try_id() {
247+ tips.insert(id.to_string());
248+ }
249+ }
250+ Ok(tips)
251+}
252+
253+/// Resolve `rev` (branch, tag, or commit-ish) to its full commit id.
254+pub fn resolve_commit(repo_path: &Path, rev: &str) -> Result<String> {
255+ let repo = gix::open(repo_path).map_err(read)?;
256+ let id = repo
257+ .rev_parse_single(rev)
258+ .map_err(read)?
259+ .object()
260+ .map_err(read)?
261+ .peel_to_commit()
262+ .map_err(read)?
263+ .id();
264+ Ok(id.to_string())
265+}
266+
237267 /// Walk commit history starting at `rev`, newest first, up to `limit` commits.
238268 pub fn commit_log(repo_path: &Path, rev: &str, limit: usize) -> Result<Vec<CommitInfo>> {
239269 let repo = gix::open(repo_path).map_err(read)?;
⋯ 293 unchanged lines
modifiedcrates/anvil-git/src/lib.rs+1 −0
⋯ 14 unchanged lines
1515
1616 pub mod browse;
1717 pub mod error;
18+pub mod mirror;
1819 pub mod smart_http;
1920 pub mod ssh;
2021 pub mod trigger;
⋯ 9 unchanged lines
addedcrates/anvil-git/src/mirror.rs+152 −0
1+//! Push mirroring: after a successful push to an anvil repo, forward all refs
2+//! to a configured remote (e.g. a GitHub repo) with `git push --mirror`.
3+//!
4+//! This shells out to the `git` CLI — gitoxide can't push yet. The runtime
5+//! image installs git for exactly this. Mirroring is best-effort and runs in
6+//! the background: a failure is logged (with credentials stripped) and never
7+//! affects the push that triggered it.
8+//!
9+//! For GitHub over HTTPS, use a token URL:
10+//! `https://x-access-token:<token>@github.com/you/repo.git`. The URL is
11+//! stored as-is in the database — treat it like a secret.
12+
13+use std::path::PathBuf;
14+
15+/// Spawn a background `git push --mirror <url>` for `repo_path`. Returns
16+/// immediately; the result is only logged.
17+pub fn spawn_push(repo_path: PathBuf, url: String) {
18+ tokio::spawn(async move {
19+ let shown = redact(&url);
20+ match push(&repo_path, &url).await {
21+ Ok(()) => tracing::info!("mirror: pushed {} to {shown}", repo_path.display()),
22+ Err(e) => tracing::error!(
23+ "mirror: push of {} to {shown} failed: {e}",
24+ repo_path.display()
25+ ),
26+ }
27+ });
28+}
29+
30+async fn push(repo_path: &std::path::Path, url: &str) -> Result<(), String> {
31+ let output = tokio::process::Command::new("git")
32+ .arg("-C")
33+ .arg(repo_path)
34+ .args(["push", "--mirror", url])
35+ // Never block on a credential prompt; fail instead.
36+ .env("GIT_TERMINAL_PROMPT", "0")
37+ .output()
38+ .await
39+ .map_err(|e| format!("running git: {e} (is git installed?)"))?;
40+ if output.status.success() {
41+ Ok(())
42+ } else {
43+ Err(redact(&String::from_utf8_lossy(&output.stderr))
44+ .lines()
45+ .collect::<Vec<_>>()
46+ .join(" / "))
47+ }
48+}
49+
50+/// Strip the userinfo (`user:token@`) out of anything URL-shaped so secrets
51+/// never reach the log.
52+fn redact(text: &str) -> String {
53+ let mut out = String::with_capacity(text.len());
54+ for (i, part) in text.split("://").enumerate() {
55+ if i == 0 {
56+ out.push_str(part);
57+ continue;
58+ }
59+ out.push_str("://");
60+ match part.split_once('@') {
61+ // Heuristic: an '@' before the next '/' is userinfo.
62+ Some((userinfo, rest)) if !userinfo.contains('/') => {
63+ out.push_str("***@");
64+ out.push_str(rest);
65+ }
66+ _ => out.push_str(part),
67+ }
68+ }
69+ out
70+}
71+
72+#[cfg(test)]
73+mod tests {
74+ use super::*;
75+
76+ #[test]
77+ fn redacts_userinfo_only() {
78+ assert_eq!(
79+ redact("https://x-access-token:ghp_abc@github.com/a/b.git"),
80+ "https://***@github.com/a/b.git"
81+ );
82+ assert_eq!(
83+ redact("error: https://github.com/a/b.git denied"),
84+ "error: https://github.com/a/b.git denied"
85+ );
86+ assert_eq!(redact("no urls here"), "no urls here");
87+ }
88+
89+ /// End-to-end against a local bare "remote": a mirror push transfers
90+ /// branches and removes deleted ones.
91+ #[tokio::test]
92+ async fn mirror_push_to_local_remote() {
93+ let tmp = tempfile::tempdir().unwrap();
94+ let src = tmp.path().join("src.git");
95+ let dst = tmp.path().join("dst.git");
96+ let work = tmp.path().join("w");
97+
98+ let git = |args: &[&str], dir: &std::path::Path| {
99+ let out = std::process::Command::new("git")
100+ .args(args)
101+ .current_dir(dir)
102+ .env("GIT_AUTHOR_NAME", "t")
103+ .env("GIT_AUTHOR_EMAIL", "t@example.com")
104+ .env("GIT_COMMITTER_NAME", "t")
105+ .env("GIT_COMMITTER_EMAIL", "t@example.com")
106+ .output()
107+ .expect("run git");
108+ assert!(out.status.success(), "git {args:?}: {out:?}");
109+ };
110+
111+ git(&["init", "-q", "--bare", src.to_str().unwrap()], tmp.path());
112+ git(&["init", "-q", "--bare", dst.to_str().unwrap()], tmp.path());
113+ git(
114+ &["init", "-q", "-b", "main", work.to_str().unwrap()],
115+ tmp.path(),
116+ );
117+ std::fs::write(work.join("f"), "x").unwrap();
118+ git(&["add", "."], &work);
119+ git(&["commit", "-qm", "c1"], &work);
120+ git(
121+ &["push", "-q", src.to_str().unwrap(), "main", "main:extra"],
122+ &work,
123+ );
124+
125+ push(&src, dst.to_str().unwrap()).await.unwrap();
126+ let heads = std::process::Command::new("git")
127+ .args([
128+ "-C",
129+ dst.to_str().unwrap(),
130+ "branch",
131+ "--format=%(refname:short)",
132+ ])
133+ .output()
134+ .unwrap();
135+ let heads = String::from_utf8_lossy(&heads.stdout);
136+ assert!(heads.contains("main") && heads.contains("extra"));
137+
138+ // Deleting a branch upstream propagates on the next mirror push.
139+ git(&["push", "-q", src.to_str().unwrap(), ":extra"], &work);
140+ push(&src, dst.to_str().unwrap()).await.unwrap();
141+ let heads = std::process::Command::new("git")
142+ .args([
143+ "-C",
144+ dst.to_str().unwrap(),
145+ "branch",
146+ "--format=%(refname:short)",
147+ ])
148+ .output()
149+ .unwrap();
150+ assert!(!String::from_utf8_lossy(&heads.stdout).contains("extra"));
151+ }
152+}
modifiedcrates/anvil-ssh/src/lib.rs+54 −40
⋯ 5 unchanged lines
66 //!
77 //! The SSH bind address is configurable (`[ssh] listen` in the config).
88
9-use std::net::SocketAddr;
10-use std::path::{
11- Path,
12- PathBuf,
9+use std::{
10+ net::SocketAddr,
11+ path::{
12+ Path,
13+ PathBuf,
14+ },
15+ sync::Arc,
16+ time::Duration,
1317 };
14-use std::sync::Arc;
15-use std::time::Duration;
1618
1719 use anvil_core::{
1820 App,
⋯ 4 unchanged lines
2325 users,
2426 };
2527 use anvil_git::ssh as git_ssh;
26-use russh::keys::ssh_key::{
27- HashAlg,
28- LineEnding,
29- PublicKey,
30-};
31-use russh::keys::{
32- Algorithm,
33- PrivateKey,
34-};
35-use russh::server::{
36- self,
37- Auth,
38- Handler,
39- Msg,
40- Server as _,
41- Session,
42-};
4328 use russh::{
4429 Channel,
4530 ChannelId,
31+ keys::{
32+ Algorithm,
33+ PrivateKey,
34+ ssh_key::{
35+ HashAlg,
36+ LineEnding,
37+ PublicKey,
38+ },
39+ },
40+ server::{
41+ self,
42+ Auth,
43+ Handler,
44+ Msg,
45+ Server as _,
46+ Session,
47+ },
4648 };
4749 use tokio::net::TcpListener;
4850
⋯ 132 unchanged lines
181183 return fail(session, channel_id, "unsupported command");
182184 };
183185 let need_write = service == anvil_git::Service::ReceivePack;
184- let (path, repo_id) =
185- match authorize_repo(&self.app, self.authed_user, &rel, need_write).await {
186- Ok(resolved) => resolved,
187- Err(message) => return fail(session, channel_id, message),
188- };
186+ let (path, repo) = match authorize_repo(&self.app, self.authed_user, &rel, need_write).await
187+ {
188+ Ok(resolved) => resolved,
189+ Err(message) => return fail(session, channel_id, message),
190+ };
189191 let Some(channel) = self.channel.take() else {
190192 return fail(session, channel_id, "no session channel");
191193 };
⋯ 28 unchanged lines
220222 && let Some(before) = before
221223 {
222224 for run_id in
223- anvil_git::trigger::enqueue_ci_for_push(&app.db, repo_id, &path, &before).await
225+ anvil_git::trigger::enqueue_ci_for_push(&app.db, repo.id, &path, &before).await
224226 {
225227 app.notify_ci(run_id);
226228 }
229+ if !repo.mirror_url.is_empty() {
230+ anvil_git::mirror::spawn_push(path.clone(), repo.mirror_url.clone());
231+ }
227232 }
228233
229234 let _ = handle.exit_status_request(channel_id, code).await;
⋯ 20 unchanged lines
250255 authed_user: Option<i64>,
251256 rel: &str,
252257 need_write: bool,
253-) -> Result<(PathBuf, i64), &'static str> {
258+) -> Result<(PathBuf, anvil_core::Repository), &'static str> {
254259 let (owner, repo) = rel
255260 .trim_start_matches('/')
256261 .split_once('/')
⋯ 4 unchanged lines
261266 return Err("invalid repository path");
262267 }
263268
269+ let viewer = match authed_user {
270+ Some(id) => users::find_by_id(&app.db, id).await.ok().flatten(),
271+ None => None,
272+ };
273+
264274 let owner_user = users::find_by_username(&app.db, owner)
265275 .await
266276 .ok()
267277 .flatten()
268278 .ok_or("repository not found")?;
269- let repo = repos::find(&app.db, owner_user.id, name)
270- .await
271- .ok()
272- .flatten()
273- .ok_or("repository not found")?;
274-
275- let viewer = match authed_user {
276- Some(id) => users::find_by_id(&app.db, id).await.ok().flatten(),
277- None => None,
279+ let repo = match repos::find(&app.db, owner_user.id, name).await {
280+ Ok(Some(repo)) => repo,
281+ // Push-to-create: a push to a missing repo creates it when the
282+ // authenticated pusher owns the namespace (or is an admin).
283+ Ok(None) if need_write && viewer.is_some() => {
284+ let pusher = viewer.as_ref().expect("checked is_some");
285+ repos::create_on_push(&app.db, &app.config.repositories_dir(), owner, name, pusher)
286+ .await
287+ .ok()
288+ .flatten()
289+ .ok_or("repository not found")?
290+ }
291+ _ => return Err("repository not found"),
278292 };
279293 let allowed = if need_write {
280294 access::can_write(&repo, viewer.as_ref())
⋯ 8 unchanged lines
289303 if !path.exists() {
290304 return Err("repository not found");
291305 }
292- Ok((path, repo.id))
306+ Ok((path, repo))
293307 }
modifiedcrates/anvil-web/Cargo.toml+1 −0
⋯ 16 unchanged lines
1717 tower-http.workspace = true
1818 tracing.workspace = true
1919 serde.workspace = true
20+serde_json.workspace = true
2021 base64.workspace = true
2122 lru.workspace = true
2223 maud.workspace = true
⋯ 4 unchanged lines
addedcrates/anvil-web/src/artifacts.rs+241 −0
1+//! Serving CI artifacts (see `docs/ci-artifacts.md`).
2+//!
3+//! Three routes:
4+//! - `/{owner}/{repo}/ci/{run}/artifacts/{name}` — run-scoped download.
5+//! - `/{owner}/{repo}/artifacts/{rev}/{name}` — alias resolving `rev` (branch,
6+//! tag, or commit) to that commit's newest artifact; redirects to the
7+//! canonical location. With CI running on every push tip, a branch name
8+//! here is "latest on branch".
9+//! - `/{owner}/{repo}/artifacts/{rev}/{name}/{*path}` — browsable artifacts
10+//! (`browse: true`): files served like a pages site, with `index.html`
11+//! resolution. Same forge-origin caveat as pages (`docs/untrusted-mode.md`
12+//! §2).
13+//!
14+//! Access follows repository visibility, like CI logs and pages.
15+
16+use std::path::{
17+ Component,
18+ PathBuf,
19+};
20+
21+use anvil_core::{
22+ App,
23+ CiArtifact,
24+ ci,
25+ storage,
26+};
27+use anvil_git::browse;
28+use axum::{
29+ Router,
30+ extract::{
31+ Path,
32+ State,
33+ },
34+ http::header,
35+ response::{
36+ IntoResponse,
37+ Redirect,
38+ Response,
39+ },
40+ routing::get,
41+};
42+
43+use crate::{
44+ auth::CurrentUser,
45+ pages::file_response,
46+ ui::{
47+ not_found,
48+ resolve_repo,
49+ server_error,
50+ },
51+};
52+
53+/// Mount the artifact routes.
54+pub fn routes(router: Router<App>) -> Router<App> {
55+ router
56+ .route(
57+ "/{owner}/{repo}/ci/{run}/artifacts/{name}",
58+ get(download_for_run),
59+ )
60+ .route("/{owner}/{repo}/artifacts/{rev}/{name}", get(alias))
61+ .route(
62+ "/{owner}/{repo}/artifacts/{rev}/{name}/{*path}",
63+ get(browse_serve),
64+ )
65+}
66+
67+/// The on-disk location of a stored artifact (file or tarball form).
68+fn stored_path(app: &App, a: &CiArtifact) -> PathBuf {
69+ let dir = storage::artifact_commit_dir(&app.config.artifacts_dir(), a.repo_id, &a.commit);
70+ if a.is_dir && !a.browse {
71+ dir.join(format!("{}.tar.gz", a.name))
72+ } else {
73+ dir.join(&a.name)
74+ }
75+}
76+
77+/// `GET /{owner}/{repo}/ci/{run}/artifacts/{name}` — download one artifact of
78+/// one run. Browsable artifacts redirect to their site root instead.
79+async fn download_for_run(
80+ State(app): State<App>,
81+ CurrentUser(user): CurrentUser,
82+ Path((owner, repo, run_id, name)): Path<(String, String, i64, String)>,
83+) -> Response {
84+ let (_, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await {
85+ Ok(v) => v,
86+ Err(resp) => return resp,
87+ };
88+ let artifacts = match ci::artifacts_for_run(&app.db, run_id).await {
89+ Ok(a) => a,
90+ Err(e) => return server_error(e),
91+ };
92+ let Some(artifact) = artifacts
93+ .into_iter()
94+ .find(|a| a.name == name && a.repo_id == meta.id)
95+ else {
96+ return not_found("no such artifact");
97+ };
98+ if artifact.browse {
99+ return Redirect::to(&format!(
100+ "/{owner}/{repo}/artifacts/{}/{}/index.html",
101+ artifact.commit, artifact.name
102+ ))
103+ .into_response();
104+ }
105+ serve_download(&app, &artifact)
106+}
107+
108+/// `GET /{owner}/{repo}/artifacts/{rev}/{name}` — resolve `rev` and serve the
109+/// newest artifact for that commit (downloads directly; browsable artifacts
110+/// redirect to their site root under the same rev, keeping the URL stable).
111+async fn alias(
112+ State(app): State<App>,
113+ CurrentUser(user): CurrentUser,
114+ Path((owner, repo, rev, name)): Path<(String, String, String, String)>,
115+) -> Response {
116+ let (repo_path, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await {
117+ Ok(v) => v,
118+ Err(resp) => return resp,
119+ };
120+ let artifact = match lookup(&app, &repo_path, meta.id, &rev, &name).await {
121+ Ok(a) => a,
122+ Err(resp) => return resp,
123+ };
124+ if artifact.browse {
125+ return Redirect::to(&format!(
126+ "/{owner}/{repo}/artifacts/{}/{}/index.html",
127+ crate::ui::enc_ref(&rev),
128+ artifact.name
129+ ))
130+ .into_response();
131+ }
132+ serve_download(&app, &artifact)
133+}
134+
135+/// `GET /{owner}/{repo}/artifacts/{rev}/{name}/{*path}` — serve a file from a
136+/// browsable artifact's extracted tree. Directory paths resolve to their
137+/// `index.html`, redirecting to the trailing-slash form first so the site's
138+/// relative links work.
139+async fn browse_serve(
140+ State(app): State<App>,
141+ CurrentUser(user): CurrentUser,
142+ Path((owner, repo, rev, name, path)): Path<(String, String, String, String, String)>,
143+) -> Response {
144+ let (repo_path, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await {
145+ Ok(v) => v,
146+ Err(resp) => return resp,
147+ };
148+ let artifact = match lookup(&app, &repo_path, meta.id, &rev, &name).await {
149+ Ok(a) => a,
150+ Err(resp) => return resp,
151+ };
152+ if !artifact.browse {
153+ return not_found("not a browsable artifact");
154+ }
155+
156+ let root = stored_path(&app, &artifact);
157+ let trimmed = path.trim_end_matches('/');
158+ let Some(rel) = sanitize(trimmed) else {
159+ return not_found("no such file");
160+ };
161+
162+ let file = root.join(&rel);
163+ if file.is_file() {
164+ return match std::fs::read(&file) {
165+ Ok(bytes) => file_response(trimmed, bytes),
166+ Err(e) => server_error(e),
167+ };
168+ }
169+ // A directory (or the artifact root, when `path` was only slashes):
170+ // resolve its index.html behind a trailing-slash redirect.
171+ let index = file.join("index.html");
172+ if index.is_file() {
173+ if !path.ends_with('/') {
174+ return Redirect::to(&format!(
175+ "/{owner}/{repo}/artifacts/{}/{name}/{trimmed}/",
176+ crate::ui::enc_ref(&rev)
177+ ))
178+ .into_response();
179+ }
180+ return match std::fs::read(&index) {
181+ Ok(bytes) => file_response("index.html", bytes),
182+ Err(e) => server_error(e),
183+ };
184+ }
185+ not_found("no such file")
186+}
187+
188+/// Resolve `rev` to a commit and find that commit's newest artifact `name`.
189+async fn lookup(
190+ app: &App,
191+ repo_path: &std::path::Path,
192+ repo_id: i64,
193+ rev: &str,
194+ name: &str,
195+) -> Result<CiArtifact, Response> {
196+ let commit = browse::resolve_commit(repo_path, rev)
197+ .map_err(|_| not_found("no such branch, tag, or commit"))?;
198+ ci::latest_artifact(&app.db, repo_id, &commit, name)
199+ .await
200+ .map_err(server_error)?
201+ .ok_or_else(|| not_found("no artifact with that name for this commit"))
202+}
203+
204+/// Attachment response for a stored file/tarball artifact.
205+fn serve_download(app: &App, artifact: &CiArtifact) -> Response {
206+ let path = stored_path(app, artifact);
207+ let bytes = match std::fs::read(&path) {
208+ Ok(b) => b,
209+ Err(_) => return not_found("artifact data missing on disk"),
210+ };
211+ let filename = path
212+ .file_name()
213+ .map(|n| n.to_string_lossy().into_owned())
214+ .unwrap_or_else(|| artifact.name.clone());
215+ (
216+ [
217+ (header::CONTENT_TYPE, "application/octet-stream".to_string()),
218+ (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
219+ (
220+ header::CONTENT_DISPOSITION,
221+ format!("attachment; filename=\"{filename}\""),
222+ ),
223+ ],
224+ bytes,
225+ )
226+ .into_response()
227+}
228+
229+/// Normalize a request path to a safe relative path (no `..`, no absolutes,
230+/// no empty/`.` segments). `None` rejects the request.
231+fn sanitize(path: &str) -> Option<PathBuf> {
232+ let mut out = PathBuf::new();
233+ for c in std::path::Path::new(path).components() {
234+ match c {
235+ Component::Normal(p) => out.push(p),
236+ Component::CurDir => {}
237+ _ => return None,
238+ }
239+ }
240+ Some(out)
241+}
modifiedcrates/anvil-web/src/git_http.rs+73 −16
⋯ 7 unchanged lines
88 //! Access control: public repos may be cloned anonymously; private repos and all
99 //! pushes require HTTP Basic auth, enforced via [`anvil_core::access`].
1010
11-use std::collections::HashMap;
12-use std::path::PathBuf;
11+use std::{
12+ collections::HashMap,
13+ path::PathBuf,
14+};
1315
1416 use anvil_core::{
1517 App,
⋯ 66 unchanged lines
8284 Ok((path, meta))
8385 }
8486
87+/// Resolve a repo for a *push*, creating it on the fly when it doesn't exist
88+/// and the authenticated pusher owns the namespace (or is an admin) — see
89+/// [`repos::create_on_push`]. An unauthenticated request for a missing repo
90+/// gets the Basic challenge, so `git push` to a new name prompts for
91+/// credentials instead of failing with 404.
92+async fn load_repo_for_push(
93+ app: &App,
94+ headers: &HeaderMap,
95+ owner: &str,
96+ repo: &str,
97+) -> Result<(PathBuf, Repository), Response> {
98+ match load_repo(app, owner, repo).await {
99+ Err(resp) if resp.status() == StatusCode::NOT_FOUND => {
100+ let name = repo.strip_suffix(".git").unwrap_or(repo);
101+ let Some(user) = basic_user(app, headers).await else {
102+ return Err(auth_challenge());
103+ };
104+ match repos::create_on_push(&app.db, &app.config.repositories_dir(), owner, name, &user)
105+ .await
106+ {
107+ Ok(Some(meta)) => {
108+ let path =
109+ anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
110+ Ok((path, meta))
111+ }
112+ Ok(None) => Err((StatusCode::NOT_FOUND, "repository not found").into_response()),
113+ Err(anvil_core::Error::Invalid(m)) => {
114+ Err((StatusCode::BAD_REQUEST, m).into_response())
115+ }
116+ Err(e) => Err(internal(e)),
117+ }
118+ }
119+ other => other,
120+ }
121+}
122+
123+/// The authenticated Basic user, if any.
124+async fn basic_user(app: &App, headers: &HeaderMap) -> Option<anvil_core::User> {
125+ let authorization = headers
126+ .get(header::AUTHORIZATION)
127+ .and_then(|v| v.to_str().ok());
128+ crate::auth::basic_auth_user(app, authorization).await
129+}
130+
131+/// `401` with a `WWW-Authenticate` challenge so the git client prompts.
132+fn auth_challenge() -> Response {
133+ Response::builder()
134+ .status(StatusCode::UNAUTHORIZED)
135+ .header(header::WWW_AUTHENTICATE, "Basic realm=\"anvil\"")
136+ .body(Body::from("authentication required"))
137+ .unwrap()
138+}
139+
85140 /// Enforce access for a git request: anonymous reads are allowed for public
86141 /// repos; private reads and all writes require valid Basic credentials. On
87142 /// failure, returns a `401` with a `WWW-Authenticate` challenge so the git
⋯ 4 unchanged lines
92147 repo: &Repository,
93148 need_write: bool,
94149 ) -> Result<(), Response> {
95- let authorization = headers
96- .get(header::AUTHORIZATION)
97- .and_then(|v| v.to_str().ok());
98- let user = crate::auth::basic_auth_user(app, authorization).await;
150+ let user = basic_user(app, headers).await;
99151 let allowed = if need_write {
100152 access::can_write(repo, user.as_ref())
101153 } else {
⋯ 2 unchanged lines
104156 if allowed {
105157 Ok(())
106158 } else {
107- Err(Response::builder()
108- .status(StatusCode::UNAUTHORIZED)
109- .header(header::WWW_AUTHENTICATE, "Basic realm=\"anvil\"")
110- .body(Body::from("authentication required"))
111- .unwrap())
159+ Err(auth_challenge())
112160 }
113161 }
114162
⋯ 27 unchanged lines
142190 Query(query): Query<HashMap<String, String>>,
143191 headers: HeaderMap,
144192 ) -> Response {
145- let (path, meta) = match load_repo(&app, &owner, &repo).await {
146- Ok(v) => v,
147- Err(resp) => return resp,
148- };
149193 let Some(service) = query.get("service").and_then(|s| Service::from_query(s)) else {
150194 return (StatusCode::BAD_REQUEST, "missing or unsupported service").into_response();
151195 };
152196 let need_write = service == Service::ReceivePack;
197+ // A push may target a repo that doesn't exist yet (push-to-create).
198+ let loaded = if need_write {
199+ load_repo_for_push(&app, &headers, &owner, &repo).await
200+ } else {
201+ load_repo(&app, &owner, &repo).await
202+ };
203+ let (path, meta) = match loaded {
204+ Ok(v) => v,
205+ Err(resp) => return resp,
206+ };
153207 if let Err(resp) = authorize(&app, &headers, &meta, need_write).await {
154208 return resp;
155209 }
⋯ 49 unchanged lines
205259 headers: HeaderMap,
206260 body: Bytes,
207261 ) -> Response {
208- let (path, meta) = match load_repo(&app, &owner, &repo).await {
262+ let (path, meta) = match load_repo_for_push(&app, &headers, &owner, &repo).await {
209263 Ok(v) => v,
210264 Err(resp) => return resp,
211265 };
⋯ 11 unchanged lines
223277 {
224278 app.notify_ci(run_id);
225279 }
280+ if !meta.mirror_url.is_empty() {
281+ anvil_git::mirror::spawn_push(path.clone(), meta.mirror_url.clone());
282+ }
226283 rpc_response(Service::ReceivePack.result_content_type(), Body::from(b))
227284 }
228285 Err(e) => internal(e),
⋯ 2 unchanged lines
addedcrates/anvil-web/src/issues.rs+422 −0
1+//! Per-repository issue tracker UI: list, new-issue form, detail page with
2+//! comments, and open/close.
3+//!
4+//! Access mirrors the rest of the forge: anyone who can read the repo can
5+//! read its issues; any logged-in reader can open issues and comment;
6+//! closing/reopening is for the issue author or anyone with write access.
7+//! Bodies are markdown, rendered with the same pipeline as file views.
8+
9+use std::collections::HashMap;
10+
11+use anvil_core::{
12+ App,
13+ Issue,
14+ User,
15+ access,
16+ issues,
17+ users,
18+};
19+use axum::{
20+ Form,
21+ Router,
22+ extract::{
23+ Path,
24+ Query,
25+ State,
26+ },
27+ response::{
28+ IntoResponse,
29+ Redirect,
30+ Response,
31+ },
32+ routing::get,
33+};
34+use maud::{
35+ Markup,
36+ html,
37+};
38+use serde::Deserialize;
39+
40+use crate::{
41+ auth::{
42+ Csrf,
43+ CurrentUser,
44+ verify_csrf,
45+ },
46+ ui::{
47+ csrf_input,
48+ fmt_relative,
49+ fmt_time,
50+ layout,
51+ not_found,
52+ render_markdown,
53+ resolve_repo,
54+ server_error,
55+ },
56+};
57+
58+/// Mount the issue routes.
59+pub fn routes(router: Router<App>) -> Router<App> {
60+ router
61+ .route("/{owner}/{repo}/issues", get(list))
62+ .route("/{owner}/{repo}/issues/new", get(new_form).post(new_submit))
63+ .route("/{owner}/{repo}/issues/{number}", get(detail))
64+ .route(
65+ "/{owner}/{repo}/issues/{number}/comment",
66+ axum::routing::post(comment_submit),
67+ )
68+ .route(
69+ "/{owner}/{repo}/issues/{number}/state",
70+ axum::routing::post(state_submit),
71+ )
72+}
73+
74+#[derive(Deserialize)]
75+struct ListQuery {
76+ #[serde(default)]
77+ state: String,
78+}
79+
80+/// `GET /{owner}/{repo}/issues` — open issues, with a closed tab.
81+async fn list(
82+ State(app): State<App>,
83+ CurrentUser(user): CurrentUser,
84+ Path((owner, repo)): Path<(String, String)>,
85+ Query(q): Query<ListQuery>,
86+) -> Result<Markup, Response> {
87+ let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
88+ let state = if q.state == issues::state::CLOSED {
89+ issues::state::CLOSED
90+ } else {
91+ issues::state::OPEN
92+ };
93+ let items = issues::list(&app.db, meta.id, state)
94+ .await
95+ .map_err(server_error)?;
96+ let (open, closed) = issues::counts(&app.db, meta.id)
97+ .await
98+ .map_err(server_error)?;
99+ let names = usernames(&app, items.iter().map(|i| i.author_id)).await;
100+
101+ Ok(layout(
102+ &format!("{owner}/{repo}: issues"),
103+ user.as_ref(),
104+ html! {
105+ div style="display:flex;align-items:center;gap:8px" {
106+ h1 style="margin-right:auto" {
107+ a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · issues"
108+ }
109+ @if user.is_some() {
110+ a.btn href=(format!("/{owner}/{repo}/issues/new")) { "New issue" }
111+ }
112+ }
113+ p {
114+ span.pill-group {
115+ a.pill.active[state == issues::state::OPEN]
116+ href=(format!("/{owner}/{repo}/issues")) { (open) " open" }
117+ a.pill.active[state == issues::state::CLOSED]
118+ href=(format!("/{owner}/{repo}/issues?state=closed")) { (closed) " closed" }
119+ }
120+ }
121+ @if items.is_empty() {
122+ p.muted {
123+ @if state == issues::state::OPEN { "No open issues." }
124+ @else { "No closed issues." }
125+ }
126+ } @else {
127+ div.box {
128+ @for issue in &items {
129+ div.row {
130+ a.entry href=(format!("/{owner}/{repo}/issues/{}", issue.number)) {
131+ (state_dot(&issue.state))
132+ (issue.title)
133+ }
134+ span.muted style="white-space:nowrap" {
135+ "#" (issue.number)
136+ " · " (names.get(&issue.author_id).map(String::as_str).unwrap_or("?"))
137+ " · " span title=(fmt_time(issue.updated_at)) { (fmt_relative(issue.updated_at)) }
138+ }
139+ }
140+ }
141+ }
142+ }
143+ },
144+ ))
145+}
146+
147+/// `GET /{owner}/{repo}/issues/new` — the new-issue form (login required).
148+async fn new_form(
149+ State(app): State<App>,
150+ CurrentUser(user): CurrentUser,
151+ csrf: Csrf,
152+ Path((owner, repo)): Path<(String, String)>,
153+) -> Result<Markup, Response> {
154+ resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
155+ let Some(user) = user else {
156+ return Err(Redirect::to("/-/login").into_response());
157+ };
158+ Ok(new_issue_page(&owner, &repo, Some(&user), None, &csrf.0))
159+}
160+
161+#[derive(Deserialize)]
162+struct NewIssueForm {
163+ #[serde(default)]
164+ title: String,
165+ #[serde(default)]
166+ body: String,
167+ #[serde(default)]
168+ csrf: String,
169+}
170+
171+/// `POST /{owner}/{repo}/issues/new`
172+async fn new_submit(
173+ State(app): State<App>,
174+ CurrentUser(user): CurrentUser,
175+ csrf: Csrf,
176+ Path((owner, repo)): Path<(String, String)>,
177+ Form(form): Form<NewIssueForm>,
178+) -> Response {
179+ let (_, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await {
180+ Ok(v) => v,
181+ Err(resp) => return resp,
182+ };
183+ let Some(user) = user else {
184+ return Redirect::to("/-/login").into_response();
185+ };
186+ if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
187+ return resp;
188+ }
189+ match issues::create(&app.db, meta.id, user.id, &form.title, &form.body).await {
190+ Ok(issue) => {
191+ Redirect::to(&format!("/{owner}/{repo}/issues/{}", issue.number)).into_response()
192+ }
193+ Err(anvil_core::Error::Invalid(m)) => {
194+ new_issue_page(&owner, &repo, Some(&user), Some(&m), &csrf.0).into_response()
195+ }
196+ Err(e) => server_error(e),
197+ }
198+}
199+
200+fn new_issue_page(
201+ owner: &str,
202+ repo: &str,
203+ user: Option<&User>,
204+ error: Option<&str>,
205+ csrf: &str,
206+) -> Markup {
207+ layout(
208+ &format!("{owner}/{repo}: new issue"),
209+ user,
210+ html! {
211+ h1 {
212+ a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) }
213+ " · " a href=(format!("/{owner}/{repo}/issues")) { "issues" }
214+ " · new"
215+ }
216+ @if let Some(error) = error { p style="color:#cf222e" { (error) } }
217+ form.stack method="post" action=(format!("/{owner}/{repo}/issues/new")) {
218+ (csrf_input(csrf))
219+ p { label { "Title" br; input type="text" name="title" required; } }
220+ p { label { "Description (markdown)" br; textarea name="body" rows="8" {} } }
221+ p { button.btn type="submit" { "Open issue" } }
222+ }
223+ },
224+ )
225+}
226+
227+/// `GET /{owner}/{repo}/issues/{number}` — one issue with its comments.
228+async fn detail(
229+ State(app): State<App>,
230+ CurrentUser(user): CurrentUser,
231+ csrf: Csrf,
232+ Path((owner, repo, number)): Path<(String, String, i64)>,
233+) -> Result<Markup, Response> {
234+ let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
235+ let issue = issues::find(&app.db, meta.id, number)
236+ .await
237+ .map_err(server_error)?
238+ .ok_or_else(|| not_found("no such issue"))?;
239+ let comments = issues::comments(&app.db, issue.id)
240+ .await
241+ .map_err(server_error)?;
242+ let names = usernames(
243+ &app,
244+ std::iter::once(issue.author_id).chain(comments.iter().map(|c| c.author_id)),
245+ )
246+ .await;
247+ let name = |id: &i64| names.get(id).map(String::as_str).unwrap_or("?").to_string();
248+ let may_toggle = user
249+ .as_ref()
250+ .is_some_and(|u| u.id == issue.author_id || access::can_write(&meta, Some(u)));
251+ let open = issue.state == issues::state::OPEN;
252+
253+ Ok(layout(
254+ &format!("{owner}/{repo}: {} (#{})", issue.title, issue.number),
255+ user.as_ref(),
256+ html! {
257+ h1 {
258+ a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) }
259+ " · " a href=(format!("/{owner}/{repo}/issues")) { "issues" }
260+ }
261+ h2 style="font-size:18px;margin:12px 0 4px" { (issue.title) " " span.muted { "#" (issue.number) } }
262+ p {
263+ (state_badge(&issue.state))
264+ " " span.muted {
265+ (name(&issue.author_id)) " opened "
266+ span title=(fmt_time(issue.created_at)) { (fmt_relative(issue.created_at)) }
267+ " · " (comments.len()) " comment" @if comments.len() != 1 { "s" }
268+ }
269+ }
270+ div.box.issue-post {
271+ div.issue-head {
272+ (name(&issue.author_id))
273+ " · " span title=(fmt_time(issue.created_at)) { (fmt_relative(issue.created_at)) }
274+ }
275+ div.md-body {
276+ @if issue.body.is_empty() { p.muted { "No description." } }
277+ @else { (render_markdown(&issue.body)) }
278+ }
279+ }
280+ @for c in &comments {
281+ div.box.issue-post {
282+ div.issue-head {
283+ (name(&c.author_id))
284+ " · " span title=(fmt_time(c.created_at)) { (fmt_relative(c.created_at)) }
285+ }
286+ div.md-body { (render_markdown(&c.body)) }
287+ }
288+ }
289+ @if user.is_some() {
290+ form.stack method="post" action=(format!("/{owner}/{repo}/issues/{}/comment", issue.number)) {
291+ (csrf_input(&csrf.0))
292+ p { label { "Comment (markdown)" br; textarea name="body" rows="4" required {} } }
293+ p {
294+ button.btn type="submit" { "Comment" }
295+ @if may_toggle {
296+ " "
297+ button.btn.btn-secondary type="submit"
298+ formaction=(format!("/{owner}/{repo}/issues/{}/state", issue.number))
299+ formnovalidate
300+ name="state" value=(if open { "closed" } else { "open" }) {
301+ @if open { "Close issue" } @else { "Reopen issue" }
302+ }
303+ }
304+ }
305+ }
306+ } @else {
307+ p.muted { a href="/-/login" { "Log in" } " to comment." }
308+ }
309+ },
310+ ))
311+}
312+
313+#[derive(Deserialize)]
314+struct CommentForm {
315+ #[serde(default)]
316+ body: String,
317+ #[serde(default)]
318+ csrf: String,
319+}
320+
321+/// `POST /{owner}/{repo}/issues/{number}/comment`
322+async fn comment_submit(
323+ State(app): State<App>,
324+ CurrentUser(user): CurrentUser,
325+ csrf: Csrf,
326+ Path((owner, repo, number)): Path<(String, String, i64)>,
327+ Form(form): Form<CommentForm>,
328+) -> Response {
329+ let (meta, mut issue, user) = match load_for_update(&app, user, &owner, &repo, number).await {
330+ Ok(v) => v,
331+ Err(resp) => return resp,
332+ };
333+ let _ = meta;
334+ if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
335+ return resp;
336+ }
337+ match issues::comment(&app.db, &mut issue, user.id, &form.body).await {
338+ Ok(_) | Err(anvil_core::Error::Invalid(_)) => {
339+ Redirect::to(&format!("/{owner}/{repo}/issues/{number}")).into_response()
340+ }
341+ Err(e) => server_error(e),
342+ }
343+}
344+
345+#[derive(Deserialize)]
346+struct StateForm {
347+ #[serde(default)]
348+ state: String,
349+ #[serde(default)]
350+ csrf: String,
351+}
352+
353+/// `POST /{owner}/{repo}/issues/{number}/state` — close or reopen.
354+async fn state_submit(
355+ State(app): State<App>,
356+ CurrentUser(user): CurrentUser,
357+ csrf: Csrf,
358+ Path((owner, repo, number)): Path<(String, String, i64)>,
359+ Form(form): Form<StateForm>,
360+) -> Response {
361+ let (meta, mut issue, user) = match load_for_update(&app, user, &owner, &repo, number).await {
362+ Ok(v) => v,
363+ Err(resp) => return resp,
364+ };
365+ if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
366+ return resp;
367+ }
368+ let may_toggle = user.id == issue.author_id || access::can_write(&meta, Some(&user));
369+ if !may_toggle {
370+ return not_found("no such issue");
371+ }
372+ let new_state = if form.state == issues::state::CLOSED {
373+ issues::state::CLOSED
374+ } else {
375+ issues::state::OPEN
376+ };
377+ match issues::set_state(&app.db, &mut issue, new_state).await {
378+ Ok(()) => Redirect::to(&format!("/{owner}/{repo}/issues/{number}")).into_response(),
379+ Err(e) => server_error(e),
380+ }
381+}
382+
383+/// Shared resolve for the mutating endpoints: readable repo, existing issue,
384+/// logged-in user.
385+async fn load_for_update(
386+ app: &App,
387+ user: Option<User>,
388+ owner: &str,
389+ repo: &str,
390+ number: i64,
391+) -> Result<(anvil_core::Repository, Issue, User), Response> {
392+ let (_, meta) = resolve_repo(app, user.as_ref(), owner, repo).await?;
393+ let issue = issues::find(&app.db, meta.id, number)
394+ .await
395+ .map_err(server_error)?
396+ .ok_or_else(|| not_found("no such issue"))?;
397+ let user = user.ok_or_else(|| Redirect::to("/-/login").into_response())?;
398+ Ok((meta, issue, user))
399+}
400+
401+/// author_id → username for the ids in `ids`.
402+async fn usernames(app: &App, ids: impl Iterator<Item = i64>) -> HashMap<i64, String> {
403+ let mut out = HashMap::new();
404+ for id in ids {
405+ if let std::collections::hash_map::Entry::Vacant(entry) = out.entry(id)
406+ && let Ok(Some(u)) = users::find_by_id(&app.db, id).await
407+ {
408+ entry.insert(u.username);
409+ }
410+ }
411+ out
412+}
413+
414+/// Small open/closed indicator for list rows.
415+fn state_dot(state: &str) -> Markup {
416+ html! { span class=(format!("issue-dot {state}")) {} }
417+}
418+
419+/// Open/closed pill for the detail page.
420+fn state_badge(state: &str) -> Markup {
421+ html! { span class=(format!("st issue-{state}")) { (state) } }
422+}
modifiedcrates/anvil-web/src/lib.rs+4 −0
⋯ 19 unchanged lines
2020 },
2121 };
2222
23+pub mod artifacts;
2324 pub mod auth;
2425 pub mod git_http;
26+pub mod issues;
2527 pub mod pages;
2628 pub mod ui;
2729
⋯ 5 unchanged lines
3335 .route("/-/logout", post(auth::logout));
3436 router = ui::routes(router); // web UI, including `/`
3537 router = pages::routes(router); // static sites from `pages` branches
38+ router = artifacts::routes(router); // CI artifact downloads + sites
39+ router = issues::routes(router); // per-repo issue tracker
3640 router = git_http::routes(router); // smart-HTTP git endpoints
3741 router
3842 // Derives the per-request CSRF token so the layout can attach it to
⋯ 22 unchanged lines
modifiedcrates/anvil-web/src/pages.rs+11 −8
⋯ 32 unchanged lines
3333 html,
3434 };
3535
36-use crate::auth::CurrentUser;
37-use crate::ui::{
38- layout,
39- not_found,
40- resolve_repo,
41- server_error,
36+use crate::{
37+ auth::CurrentUser,
38+ ui::{
39+ entry_icon,
40+ layout,
41+ not_found,
42+ resolve_repo,
43+ server_error,
44+ },
4245 };
4346
4447 /// The branch pages are served from.
⋯ 33 unchanged lines
7881 @for e in &entries {
7982 div.row {
8083 a.entry href=(format!("/{owner}/{repo}/pages/{}{}", e.name, if e.is_dir { "/" } else { "" })) {
81- span.icon { (if e.is_dir { "[ ]" } else { "·" }) }
84+ (entry_icon(e.is_dir))
8285 (e.name) @if e.is_dir { "/" }
8386 }
8487 }
⋯ 41 unchanged lines
126129
127130 /// Raw file response with a guessed content type. `nosniff` keeps browsers
128131 /// from second-guessing it.
129-fn file_response(path: &str, bytes: Vec<u8>) -> Response {
132+pub(crate) fn file_response(path: &str, bytes: Vec<u8>) -> Response {
130133 (
131134 [
132135 (header::CONTENT_TYPE, content_type(path)),
⋯ 52 unchanged lines
modifiedcrates/anvil-web/src/ui.rs+213 −40
⋯ 1 unchanged line
22 //! blob viewing. Pages are plain SSR and work without JavaScript; htmx-based
33 //! progressive enhancement is a follow-up.
44
5-use std::collections::{
6- BTreeMap,
7- HashMap,
8-};
9-use std::path::PathBuf;
10-use std::sync::{
11- Arc,
12- Mutex,
13- OnceLock,
5+use std::{
6+ collections::{
7+ BTreeMap,
8+ HashMap,
9+ },
10+ path::PathBuf,
11+ sync::{
12+ Arc,
13+ Mutex,
14+ OnceLock,
15+ },
1416 };
1517
1618 use anvil_core::{
⋯ 45 unchanged lines
6264 ChangeTag,
6365 TextDiff,
6466 };
65-use syntect::easy::HighlightLines;
66-use syntect::highlighting::{
67- Theme,
68- ThemeSet,
69-};
70-use syntect::html::{
71- IncludeBackground,
72- styled_line_to_highlighted_html,
67+use syntect::{
68+ easy::HighlightLines,
69+ highlighting::{
70+ Theme,
71+ ThemeSet,
72+ },
73+ html::{
74+ IncludeBackground,
75+ styled_line_to_highlighted_html,
76+ },
77+ parsing::SyntaxSet,
7378 };
74-use syntect::parsing::SyntaxSet;
7579 use time::OffsetDateTime;
7680
7781 use crate::auth::{
⋯ 72 unchanged lines
150154 form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); }
151155 form.stack input[type=text], form.stack textarea { width:100%; max-width:480px; padding:6px 8px; border:1px solid var(--border); border-radius:6px; font:inherit; }
152156 form.stack .check { display:flex; gap:8px; align-items:flex-start; max-width:480px; }
157+.issue-dot { width:10px; height:10px; border-radius:50%; flex:none; }
158+.issue-dot.open { background:#1a7f37; }
159+.issue-dot.closed { background:#8250df; }
160+.st.issue-open { background:#dafbe1; color:#1a7f37; }
161+.st.issue-closed { background:#fbefff; color:#8250df; }
162+.issue-post { margin:12px 0; }
163+.issue-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; color:var(--muted); }
164+.btn.btn-secondary { background:var(--bg); color:var(--fg); border-color:var(--border); }
165+.readme { margin-top:16px; }
166+.readme-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; font-weight:600; }
153167 .latest-commit { display:flex; gap:10px; align-items:baseline; background:var(--code-bg); border:1px solid var(--border); border-radius:6px 6px 0 0; border-bottom:0; padding:8px 16px; }
154168 .latest-commit + .box { border-radius:0 0 6px 6px; }
155169 .commit-list { list-style:none; padding:0; margin:0; }
⋯ 148 unchanged lines
304318 }
305319 } }
306320 main { div.container { (body) } }
307- footer { div.container { "anvil — a minimal git forge" } }
321+ footer { div.container { "anvil — a git forge" } }
308322 script src="/-/static/htmx.min.js" {}
309323 script { (PreEscaped(CLONE_JS)) }
310324 }
⋯ 300 unchanged lines
611625 description: String,
612626 private: Option<String>,
613627 #[serde(default)]
628+ mirror_url: String,
629+ #[serde(default)]
614630 csrf: String,
615631 }
616632
⋯ 124 unchanged lines
741757 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
742758 return resp;
743759 }
744- if let Err(e) =
745- repos::update_settings(&app.db, meta.id, &form.description, form.private.is_some()).await
760+ if let Err(e) = repos::update_settings(
761+ &app.db,
762+ meta.id,
763+ &form.description,
764+ form.private.is_some(),
765+ &form.mirror_url,
766+ )
767+ .await
746768 {
747769 return server_error(e);
748770 }
⋯ 18 unchanged lines
767789 (csrf_input(csrf))
768790 p { label { "Description" br; input type="text" name="description" value=(meta.description); } }
769791 p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } }
792+ p {
793+ label {
794+ "Mirror push URL" br;
795+ input type="text" name="mirror_url" value=(meta.mirror_url)
796+ placeholder="https://x-access-token:<token>@github.com/you/repo.git";
797+ }
798+ br;
799+ span.muted style="font-size:12px" {
800+ "After every push here, all refs are mirrored to this remote ("
801+ code { "git push --mirror" }
802+ "). Stored as-is — use a scoped token. Empty disables it."
803+ }
804+ }
770805 p { button.btn type="submit" { "Save changes" } }
771806 }
772807 },
⋯ 7 unchanged lines
780815 .ssh
781816 .enabled
782817 .then(|| app.config.ssh_clone_url(owner, name));
818+ // SSH first and preselected when available — it's the protocol that can
819+ // push without a credential prompt.
820+ let default_cmd = format!("git clone {}", ssh.as_deref().unwrap_or(&http));
783821 html! {
784822 div.clone data-http=(format!("git clone {http}")) data-ssh=[ssh.as_ref().map(|s| format!("git clone {s}"))] {
785823 div.clone-head {
786824 span.muted { "Clone" }
787825 div.clone-tabs {
788- button.clone-tab.active type="button" data-proto="http" { "HTTP" }
789826 @if ssh.is_some() {
790- button.clone-tab type="button" data-proto="ssh" { "SSH" }
827+ button.clone-tab.active type="button" data-proto="ssh" { "SSH" }
828+ button.clone-tab type="button" data-proto="http" { "HTTP" }
829+ } @else {
830+ button.clone-tab.active type="button" data-proto="http" { "HTTP" }
791831 }
792832 }
793833 }
794834 div.clone-cmd {
795- code { "git clone " (http) }
835+ code { (default_cmd) }
796836 button.copy-btn type="button" title="Copy to clipboard" aria-label="Copy" {
797837 (PreEscaped(CLIPBOARD_SVG))
798838 }
⋯ 20 unchanged lines
819859 @if meta.is_private { " " span.pill { "private" } }
820860 }
821861 span.repo-nav {
862+ a href=(format!("/{owner}/{repo}/issues")) { "Issues" }
863+ " · "
822864 a href=(format!("/{owner}/{repo}/ci")) { "CI" }
823865 " · "
824866 a href=(format!("/{owner}/{repo}/pages")) { "Pages" }
⋯ 36 unchanged lines
861903 let entry_commits =
862904 browse::latest_entry_commits(&path, &rev, "", ENTRY_LOG_WALK).unwrap_or_default();
863905
906+ // A root README renders below the tree, GitHub-style. Best-effort: a
907+ // missing or unreadable file just omits the section.
908+ let readme = entries
909+ .iter()
910+ .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("readme.md"))
911+ .and_then(|e| {
912+ let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?;
913+ Some((
914+ render_markdown(&String::from_utf8_lossy(&bytes)),
915+ e.name.clone(),
916+ ))
917+ });
918+
864919 Ok(layout(
865920 &format!("{owner}/{repo}"),
866921 user.as_ref(),
⋯ 8 unchanged lines
875930 div.latest-commit {
876931 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
877932 a href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.summary) }
878- span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) }
933+ span.muted style="margin-left:auto" {
934+ (c.author) " · "
935+ span title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
936+ }
879937 }
880938 }
881939 (tree_table(&owner, &repo, &rev, "", &entries, &entry_commits))
940+ @if let Some((rendered, name)) = &readme {
941+ div.box.readme {
942+ div.readme-head {
943+ a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
944+ }
945+ div.md-body { (rendered) }
946+ }
947+ }
882948 },
883949 ))
884950 }
⋯ 115 unchanged lines
10001066 /// Repo content is untrusted, so this is a stored-XSS surface: raw HTML in the
10011067 /// source is emitted as escaped literal text, and `javascript:`/`data:`-style
10021068 /// link and image destinations are dropped.
1003-fn render_markdown(text: &str) -> Markup {
1069+pub(crate) fn render_markdown(text: &str) -> Markup {
10041070 use pulldown_cmark::{
10051071 Event,
10061072 Options,
⋯ 47 unchanged lines
10541120 /// beyond this many commits just lose the annotation.
10551121 const ENTRY_LOG_WALK: usize = 400;
10561122
1123+/// Folder or file icon for an entry row (tree listings, pages, artifacts).
1124+pub(crate) fn entry_icon(is_dir: bool) -> Markup {
1125+ html! {
1126+ @if is_dir {
1127+ span.icon.dir { (PreEscaped(FOLDER_SVG)) }
1128+ } @else {
1129+ span.icon { (PreEscaped(FILE_SVG)) }
1130+ }
1131+ }
1132+}
1133+
1134+/// Human-readable byte size (`482 B`, `1.2 KiB`, `34.0 MiB`).
1135+pub(crate) fn fmt_size(bytes: i64) -> String {
1136+ let b = bytes.max(0) as f64;
1137+ match b {
1138+ b if b < 1024.0 => format!("{bytes} B"),
1139+ b if b < 1024.0 * 1024.0 => format!("{:.1} KiB", b / 1024.0),
1140+ b if b < 1024.0 * 1024.0 * 1024.0 => format!("{:.1} MiB", b / (1024.0 * 1024.0)),
1141+ b => format!("{:.1} GiB", b / (1024.0 * 1024.0 * 1024.0)),
1142+ }
1143+}
1144+
10571145 /// Percent-encode a ref name for use as one path segment in a URL. Axum
10581146 /// matches routes before decoding, so an encoded `/` keeps a branch like
10591147 /// `feat/x` inside the single `{rev}` segment.
1060-fn enc_ref(name: &str) -> String {
1148+pub(crate) fn enc_ref(name: &str) -> String {
10611149 name.replace('%', "%25")
10621150 .replace('/', "%2F")
10631151 .replace('?', "%3F")
⋯ 54 unchanged lines
11181206 @let kind = if e.is_dir { "tree" } else { "blob" };
11191207 div.row {
11201208 a.entry href=(format!("/{owner}/{repo}/{kind}/{}/{child}", enc_ref(rev))) {
1121- @if e.is_dir {
1122- span.icon.dir { (PreEscaped(FOLDER_SVG)) }
1123- } @else {
1124- span.icon { (PreEscaped(FILE_SVG)) }
1125- }
1209+ (entry_icon(e.is_dir))
11261210 (e.name) @if e.is_dir { "/" }
11271211 }
11281212 @if let Some(c) = latest.get(&e.name) {
11291213 a.fc-msg href=(format!("/{owner}/{repo}/commit/{}", c.id)) title=(c.summary) { (c.summary) }
1130- span.fc-time { (fmt_date(c.time)) }
1214+ span.fc-time title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
11311215 }
11321216 }
11331217 }
⋯ 70 unchanged lines
12041288 a href=(format!("/{owner}/{repo}/ci")) { (status_badge(st)) }
12051289 }
12061290 span { (c.summary) }
1207- span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) }
1291+ span.muted style="margin-left:auto" {
1292+ (c.author) " · "
1293+ span title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
1294+ }
12081295 }
12091296 }
12101297 }
⋯ 84 unchanged lines
12951382 .map_err(server_error)?
12961383 .filter(|r| r.repo_id == meta.id)
12971384 .ok_or_else(|| not_found("no such CI run"))?;
1385+ let artifacts = ci::artifacts_for_run(&app.db, run.id)
1386+ .await
1387+ .map_err(server_error)?;
12981388 Ok(layout(
12991389 &format!("{owner}/{repo}: CI #{}", run.id),
13001390 user.as_ref(),
⋯ 15 unchanged lines
13161406 @if run.finished_at > 0 { " · finished " (fmt_time(run.finished_at)) }
13171407 @if let Some(d) = run_duration(&run) { " · took " (d) }
13181408 }
1409+ @if !artifacts.is_empty() {
1410+ h2 { "Artifacts" }
1411+ div.box {
1412+ @for a in &artifacts {
1413+ div.row {
1414+ a.entry href=(format!("/{owner}/{repo}/ci/{}/artifacts/{}", run.id, a.name)) {
1415+ (entry_icon(a.is_dir))
1416+ (a.name)
1417+ @if a.browse { " " span.pill { "site" } }
1418+ @else if a.is_dir { ".tar.gz" }
1419+ }
1420+ span.muted {
1421+ (artifact_meta_chips(&a.meta))
1422+ (fmt_size(a.size))
1423+ }
1424+ }
1425+ }
1426+ }
1427+ }
13191428 @if run.log.is_empty() {
13201429 p.muted { "No output yet." }
13211430 } @else {
⋯ 3 unchanged lines
13251434 ))
13261435 }
13271436
1437+/// Render an artifact's extractor metadata (a JSON object of key → value) as
1438+/// inline `key: value` chips before the size.
1439+fn artifact_meta_chips(meta: &str) -> Markup {
1440+ let map: BTreeMap<String, String> = serde_json::from_str(meta).unwrap_or_default();
1441+ html! {
1442+ @for (k, v) in &map {
1443+ span.pill title=(k) { (k) ": " (v) }
1444+ " "
1445+ }
1446+ }
1447+}
1448+
13281449 /// A coloured status pill for a CI run status string.
13291450 fn status_badge(status: &str) -> Markup {
13301451 html! { span class=(format!("st {status}")) { (status) } }
⋯ 228 unchanged lines
15591680 }
15601681
15611682 /// Format a Unix timestamp as `YYYY-MM-DD HH:MM UTC`.
1562-fn fmt_time(secs: i64) -> String {
1683+pub(crate) fn fmt_time(secs: i64) -> String {
15631684 match OffsetDateTime::from_unix_timestamp(secs) {
15641685 Ok(t) => format!(
15651686 "{:04}-{:02}-{:02} {:02}:{:02} UTC",
⋯ 7 unchanged lines
15731694 }
15741695 }
15751696
1576-/// Format a Unix timestamp as a bare `YYYY-MM-DD` (for compact tree rows).
1577-fn fmt_date(secs: i64) -> String {
1578- match OffsetDateTime::from_unix_timestamp(secs) {
1579- Ok(t) => format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day()),
1580- Err(_) => secs.to_string(),
1697+/// Format a Unix timestamp relative to now (`2 hours ago`, `last month`).
1698+pub(crate) fn fmt_relative(secs: i64) -> String {
1699+ relative_to(secs, OffsetDateTime::now_utc().unix_timestamp())
1700+}
1701+
1702+fn relative_to(secs: i64, now: i64) -> String {
1703+ fn ago(n: i64, one: &str, unit: &str) -> String {
1704+ if n == 1 {
1705+ one.to_string()
1706+ } else {
1707+ format!("{n} {unit}s ago")
1708+ }
1709+ }
1710+ let delta = now - secs;
1711+ if delta < 60 {
1712+ return "just now".to_string();
1713+ }
1714+ let minutes = delta / 60;
1715+ if minutes < 60 {
1716+ return ago(minutes, "1 minute ago", "minute");
15811717 }
1718+ let hours = delta / 3600;
1719+ if hours < 24 {
1720+ return ago(hours, "1 hour ago", "hour");
1721+ }
1722+ let days = delta / 86_400;
1723+ if days < 7 {
1724+ return ago(days, "yesterday", "day");
1725+ }
1726+ let weeks = days / 7;
1727+ if weeks < 5 {
1728+ return ago(weeks, "last week", "week");
1729+ }
1730+ let months = days / 30;
1731+ if months < 12 {
1732+ return ago(months, "last month", "month");
1733+ }
1734+ ago(days / 365, "last year", "year")
15821735 }
15831736
15841737 /// Heuristic: treat content with a NUL in the first 8 KiB as binary.
⋯ 33 unchanged lines
16181771 "normal links survive: {out}"
16191772 );
16201773 }
1774+
1775+ #[test]
1776+ fn relative_time_buckets() {
1777+ const NOW: i64 = 1_000_000_000;
1778+ let at = |delta: i64| relative_to(NOW - delta, NOW);
1779+ assert_eq!(at(0), "just now");
1780+ assert_eq!(at(59), "just now");
1781+ assert_eq!(at(60), "1 minute ago");
1782+ assert_eq!(at(45 * 60), "45 minutes ago");
1783+ assert_eq!(at(3600), "1 hour ago");
1784+ assert_eq!(at(23 * 3600), "23 hours ago");
1785+ assert_eq!(at(86_400), "yesterday");
1786+ assert_eq!(at(3 * 86_400), "3 days ago");
1787+ assert_eq!(at(8 * 86_400), "last week");
1788+ assert_eq!(at(20 * 86_400), "2 weeks ago");
1789+ assert_eq!(at(40 * 86_400), "last month");
1790+ assert_eq!(at(200 * 86_400), "6 months ago");
1791+ assert_eq!(at(400 * 86_400), "last year");
1792+ assert_eq!(at(900 * 86_400), "2 years ago");
1793+ }
16211794 }
modifieddocs/ci-artifacts.md+10 −7
11 # CI artifacts — design
22
3-Status: **design sketch, not implemented.** Companion to the CI runner in
3+Status: **implemented** (2026-06-10). Companion to the CI runner in
44 `crates/anvil-ci` and the threat model in `docs/untrusted-mode.md`.
5+Deviations from the original sketch are noted inline.
56
67 ## Goals
78
⋯ 63 unchanged lines
7172 (1 KiB); the resulting key→value map is stored as JSON on the artifact row.
7273 A failed extractor stores nothing for that key and appends a note to the log.
7374
74-Implementation note: the extractor output travels in a well-known file the
75-broker downloads (e.g. `/workspace/.anvil-meta.json`, written by a generated
76-trailer in the script), so it rides the same archive mechanism as artifacts
77-and needs no log parsing.
75+Implementation note: each extractor's stdout lands in its own file under
76+`/tmp/anvil-meta/<artifact>/<key>` (written by a generated script trailer that
77+runs even when a step fails — the steps execute in a subshell whose exit code
78+is preserved). The broker downloads that directory via the same archive
79+mechanism as artifacts; file-per-value avoids shell JSON-escaping entirely.
7880
7981 ## Storage
8082
⋯ 54 unchanged lines
135137 branch head, which are pinned. No background sweeper, no clocks to test; the
136138 invariant holds whenever an artifact lands.
137139
138-Orphan cleanup (repo deleted → remove `artifacts/{repo_id}`) hooks into repo
139-deletion alongside the existing git-dir removal.
140+Orphan cleanup (repo deleted → remove `artifacts/{repo_id}`) will hook into
141+repo deletion **when that feature exists** — anvil currently has no way to
142+delete a repository at all, so there is nothing to hook yet.
140143
141144 ## Out of scope (deliberately)
142145
⋯ 16 unchanged lines