Who did the work: via AGENT, rotated identities link themselves, a Flow badge 8933faff2043
A change a delegate signed for its author says so wherever the author
is shown: 'via NAME' with an agent badge in history rows, a via row
under the author on the change page (an arrow, the agent's identity,
the delegation it acted under and its expiry, its key), a count under
the author in Contributors, and a via object in the API's change
summaries. The author stays the human; a revoked or withdrawn
delegation turns the badge red.
A push that brings changes authored by a successor of a linked
identity, where every step kept the key pair (a renewal, a delegation
added or removed), links the successor to the same account and audits
it as identity.rotated: delegating to an agent no longer leaves the
author's changes unattributed until a key is re-uploaded. A rotation
to fresh keys still needs the challenge.
/{owner}/{name}/flow/badge.svg: the latest run on the default bookmark
as a small SVG for a README (passing, failing, running, no runs), for
whoever may read the repository.
Pins oxygenflow main, which pins oxygen main with author.signer, so
the test runs an agent commit end to end: the rotated identity links
itself, the change page has the via row, history rows and the API
carry it, Contributors count it under the author, and the badge
answers. Spec §5.3, §12, §13.2.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 12:52 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
8933faff204360bab79c77fe01773fae1c7121c4b7dd71c54e4ba0effd8a8e97- Depends on
- 145306044896 19bb31ff1f5c 380996db84e5 42fa95540468 6ad368c5958e 736f6813543d 760a7dd30d6f 78001759604c 83d5052bf76c 92574625ee9a 93be9ce4653e b43a9ab0498f b64e2537d702 cc19697f8871 d23464c0ac60 de505df5cf8d f28389bd6aab
- Depended on by
- 42c77f6515c2 c26267b3aba6 217814c239f0 7c06f5b28ade c8684d44d549 d9b3b84605a4 c9aacc0e7dc1 92cea629fdf6 36e5a9c15a84 17a3a84b00d1 554ccacfc123 c21ef37dd4a0 206c1fc1b06a d0fbea2eb46e a6c241e8eba0
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 31 atoms, 0 tree operations, 14 files
Browse the tree at this change
modified crates/hub-api/src/lib.rs +15 −0
| … | 79 unchanged lines |
||
| 80 | 80 | pub deps: Vec<String>, |
|
| 81 | 81 | pub head: bool, |
|
| 82 | 82 | pub signature: Signature, |
|
| 83 | + | /// Signed for the author by a delegate (an agent), when it was. |
|
| 84 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 85 | + | pub via: Option<Via>, |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | /// A delegate that signed a change for its author (Oxygen spec §14.2). |
|
| 89 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 90 | + | pub struct Via { |
|
| 91 | + | pub name: String, |
|
| 92 | + | pub key: String, |
|
| 93 | + | /// The delegation's scope, `changes` for an agent. |
|
| 94 | + | pub scope: String, |
|
| 95 | + | pub expires_ms: Option<u64>, |
|
| 96 | + | /// The delegation has since been revoked or withdrawn. |
|
| 97 | + | pub revoked: bool, |
|
| 83 | 98 | } |
|
| 84 | 99 | ||
| 85 | 100 | /// Who wrote a change: the linked account when the signing key is linked, |
|
| … | 962 unchanged lines |
modified crates/hub/src/api/mod.rs +7 −0
| … | 355 unchanged lines |
||
| 356 | 356 | when_ms: c.when_ms, |
|
| 357 | 357 | deps: c.deps.clone(), |
|
| 358 | 358 | head: c.head, |
|
| 359 | + | via: c.via.as_ref().map(|v| Via { |
|
| 360 | + | name: v.name.clone(), |
|
| 361 | + | key: v.key.clone(), |
|
| 362 | + | scope: v.scope.clone(), |
|
| 363 | + | expires_ms: v.expires_ms, |
|
| 364 | + | revoked: v.revoked, |
|
| 365 | + | }), |
|
| 359 | 366 | signature: match &c.signature { |
|
| 360 | 367 | view::SigView::Unsigned => Signature::Unsigned, |
|
| 361 | 368 | view::SigView::Valid { key, sealed, revoked, .. } => Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked }, |
|
| … | 1761 unchanged lines |
modified crates/hub/src/protocol.rs +39 −0
| … | 208 unchanged lines |
||
| 209 | 209 | .unwrap_or_default() |
|
| 210 | 210 | }; |
|
| 211 | 211 | let messages: Vec<(String, String)> = if bookmarks.contains(&row.default_bookmark) { imported.clone() } else { vec![] }; |
|
| 212 | + | // Authors whose identity is a successor (a rotation, a renewal, a |
|
| 213 | + | // delegation) of an earlier one: each step kept the key pair, so |
|
| 214 | + | // if the chain reaches a linked key it is the same person, and the |
|
| 215 | + | // successor is linked too, without re-uploading it (hub spec §5.3). |
|
| 216 | + | let successors: Vec<(String, String, String, Vec<u8>, String)> = self |
|
| 217 | + | .repos |
|
| 218 | + | .open(&parsed) |
|
| 219 | + | .map(|r| { |
|
| 220 | + | let mut seen = std::collections::HashSet::new(); |
|
| 221 | + | let mut out = Vec::new(); |
|
| 222 | + | for h in &report.imported { |
|
| 223 | + | let Ok(meta) = r.store().get_change_meta(h) else { continue }; |
|
| 224 | + | let mut key = meta.author; |
|
| 225 | + | while !key.is_zero() && seen.insert(key) { |
|
| 226 | + | let Ok(id) = r.store().get_identity(&key) else { break }; |
|
| 227 | + | let Some(prev) = id.previous else { break }; |
|
| 228 | + | let Ok(before) = r.store().get_identity(&prev) else { break }; |
|
| 229 | + | if before.ed25519_pk != id.ed25519_pk || before.mldsa65_pk != id.mldsa65_pk { |
|
| 230 | + | break; |
|
| 231 | + | } |
|
| 232 | + | out.push((key.to_hex(), id.name.clone(), id.email.clone(), id.encode(), prev.to_hex())); |
|
| 233 | + | key = prev; |
|
| 234 | + | } |
|
| 235 | + | } |
|
| 236 | + | out |
|
| 237 | + | }) |
|
| 238 | + | .unwrap_or_default(); |
|
| 212 | 239 | let on = if bookmarks.contains(&row.default_bookmark) { |
|
| 213 | 240 | row.default_bookmark.clone() |
|
| 214 | 241 | } else { |
|
| … | 12 unchanged lines |
||
| 227 | 254 | let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str())); |
|
| 228 | 255 | if let Err(e) = db::repos::record_push(&pool, row.id, who, &results).await { |
|
| 229 | 256 | tracing::warn!("recording push to {}: {e}", parsed); |
|
| 257 | + | } |
|
| 258 | + | // Oldest first: a link made for one step lets the next step link. |
|
| 259 | + | for (key, name, email, object, prev) in successors.iter().rev() { |
|
| 260 | + | let Ok(None) = db::accounts::identity_by_key(&pool, key).await else { continue }; |
|
| 261 | + | let Ok(Some(earlier)) = db::accounts::identity_by_key(&pool, prev).await else { continue }; |
|
| 262 | + | match db::accounts::link_identity(&pool, earlier.account_id, key, name, email, object).await { |
|
| 263 | + | Ok(()) => { |
|
| 264 | + | tracing::info!("linked identity {} to account {} as a successor of {}", &key[..12], earlier.account_id, &prev[..12]); |
|
| 265 | + | db::accounts::audit(&pool, None, "identity.rotated", key, &format!("successor of {}", &prev[..12])).await.ok(); |
|
| 266 | + | } |
|
| 267 | + | Err(e) => tracing::warn!("linking successor identity {}: {e}", &key[..12]), |
|
| 268 | + | } |
|
| 230 | 269 | } |
|
| 231 | 270 | let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade()); |
|
| 232 | 271 | if let Some(app) = app { |
|
| … | 49 unchanged lines |
modified crates/hub/src/render/graph.rs +1 −0
| … | 96 unchanged lines |
||
| 97 | 97 | head: false, |
|
| 98 | 98 | resolution: false, |
|
| 99 | 99 | signature: SigView::Unsigned, |
|
| 100 | + | via: None, |
|
| 100 | 101 | labels: vec![], |
|
| 101 | 102 | } |
|
| 102 | 103 | } |
|
| … | 17 unchanged lines |
modified crates/hub/src/repos/view.rs +101 −4
| … | 211 unchanged lines |
||
| 212 | 212 | } |
|
| 213 | 213 | } |
|
| 214 | 214 | ||
| 215 | + | /// Who signed for the author when it was not the author: a delegate, such |
|
| 216 | + | /// as an agent, named by its identity, with the delegation that let it |
|
| 217 | + | /// (Oxygen's agent workflows doc §6). |
|
| 218 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 219 | + | pub struct ViaView { |
|
| 220 | + | pub name: String, |
|
| 221 | + | pub email: String, |
|
| 222 | + | pub key: String, |
|
| 223 | + | pub short: String, |
|
| 224 | + | /// The delegation's scope as written, `changes` for an agent. |
|
| 225 | + | pub scope: String, |
|
| 226 | + | pub expires_ms: Option<u64>, |
|
| 227 | + | /// The signing key was revoked, or the delegation is no longer in the |
|
| 228 | + | /// author's identity: the change deserves a second look. |
|
| 229 | + | pub revoked: bool, |
|
| 230 | + | } |
|
| 231 | + | ||
| 232 | + | impl ViaView { |
|
| 233 | + | /// The delegation, as the change page's `via` row says it. |
|
| 234 | + | pub fn delegation(&self) -> String { |
|
| 235 | + | if self.revoked { |
|
| 236 | + | return "delegation revoked".into(); |
|
| 237 | + | } |
|
| 238 | + | match self.expires_ms { |
|
| 239 | + | Some(e) => format!("delegate for {}, until {}", self.scope, when_utc(e)), |
|
| 240 | + | None => format!("delegate for {}", self.scope), |
|
| 241 | + | } |
|
| 242 | + | } |
|
| 243 | + | ||
| 244 | + | /// What hovering the badge says. |
|
| 245 | + | pub fn title(&self) -> String { |
|
| 246 | + | let until = match self.expires_ms { |
|
| 247 | + | Some(e) => format!(", until {}", when_utc(e)), |
|
| 248 | + | None => String::new(), |
|
| 249 | + | }; |
|
| 250 | + | if self.revoked { |
|
| 251 | + | format!("signed by {} as a delegate of the author; that delegation has since been revoked", self.name) |
|
| 252 | + | } else { |
|
| 253 | + | format!("signed by {} as a delegate of the author ({}{until}), key {}", self.name, self.scope, self.short) |
|
| 254 | + | } |
|
| 255 | + | } |
|
| 256 | + | } |
|
| 257 | + | ||
| 258 | + | /// `2026-10-04 20:12Z` for a wall time in milliseconds. |
|
| 259 | + | fn when_utc(ms: u64) -> String { |
|
| 260 | + | time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000) |
|
| 261 | + | .ok() |
|
| 262 | + | .and_then(|t| t.format(&time::macros::format_description!("[year]-[month]-[day] [hour]:[minute]Z")).ok()) |
|
| 263 | + | .unwrap_or_else(|| ms.to_string()) |
|
| 264 | + | } |
|
| 265 | + | ||
| 266 | + | /// The delegate that signed `hash` for the author, when one did. |
|
| 267 | + | pub fn via(repo: &Repo, author: &KeyId, sig: &SigView) -> Option<ViaView> { |
|
| 268 | + | let SigView::Valid { key, name, revoked, .. } = sig else { return None }; |
|
| 269 | + | if author.is_zero() || *key == author.to_hex() { |
|
| 270 | + | return None; |
|
| 271 | + | } |
|
| 272 | + | let signer = KeyId::from_hex(key)?; |
|
| 273 | + | let entry = repo.store().get_identity(author).ok().and_then(|a| a.delegate(&signer).copied()); |
|
| 274 | + | let email = repo.store().get_identity(&signer).map(|i| i.email).unwrap_or_default(); |
|
| 275 | + | Some(ViaView { |
|
| 276 | + | name: name.clone(), |
|
| 277 | + | email, |
|
| 278 | + | key: key.clone(), |
|
| 279 | + | short: signer.short(12), |
|
| 280 | + | scope: entry.map(|d| d.scope.to_string()).unwrap_or_else(|| "changes".into()), |
|
| 281 | + | expires_ms: entry.and_then(|d| d.expires), |
|
| 282 | + | revoked: *revoked || entry.is_none(), |
|
| 283 | + | }) |
|
| 284 | + | } |
|
| 285 | + | ||
| 215 | 286 | /// One change as history rows and change pages show it. |
|
| 216 | 287 | #[derive(Clone, Debug)] |
|
| 217 | 288 | pub struct ChangeView { |
|
| … | 7 unchanged lines |
||
| 225 | 296 | pub head: bool, |
|
| 226 | 297 | pub resolution: bool, |
|
| 227 | 298 | pub signature: SigView, |
|
| 299 | + | /// Signed for the author by a delegate (an agent), when it was. |
|
| 300 | + | pub via: Option<ViaView>, |
|
| 228 | 301 | /// Bookmarks and tags whose state has this change as a head. |
|
| 229 | 302 | pub labels: Vec<Label>, |
|
| 230 | 303 | } |
|
| … | 45 unchanged lines |
||
| 276 | 349 | labels: &HashMap<ChangeHash, Vec<Label>>, |
|
| 277 | 350 | ) -> ChangeView { |
|
| 278 | 351 | let (title, body) = split_message(&meta.message); |
|
| 352 | + | let signature = signature(repo, hash); |
|
| 353 | + | let via = via(repo, &meta.author, &signature); |
|
| 279 | 354 | ChangeView { |
|
| 280 | 355 | hash: hash.to_hex(), |
|
| 281 | 356 | short: hash.short(12), |
|
| … | 4 unchanged lines |
||
| 286 | 361 | deps: meta.deps.iter().map(|d| d.to_hex()).collect(), |
|
| 287 | 362 | head, |
|
| 288 | 363 | resolution: meta.kind == oxy_core::objects::ChangeKind::Resolution, |
|
| 289 | - | signature: signature(repo, hash), |
|
| 364 | + | signature, |
|
| 365 | + | via, |
|
| 290 | 366 | labels: labels.get(hash).cloned().unwrap_or_default(), |
|
| 291 | 367 | } |
|
| 292 | 368 | } |
|
| … | 547 unchanged lines |
||
| 840 | 916 | pub changes: usize, |
|
| 841 | 917 | /// The hub account the key is linked to, filled by the web layer. |
|
| 842 | 918 | pub account: Option<String>, |
|
| 919 | + | /// Of `changes`, how many a delegate signed for this author, by the |
|
| 920 | + | /// delegate's name (an agent's work stays the author's, and is shown). |
|
| 921 | + | pub via: Vec<(String, usize)>, |
|
| 843 | 922 | } |
|
| 844 | 923 | ||
| 845 | 924 | /// One language's share of a state's code, by bytes. |
|
| … | 5 unchanged lines |
||
| 851 | 930 | pub colour: String, |
|
| 852 | 931 | } |
|
| 853 | 932 | ||
| 854 | - | /// Authors of the changes of a state, most changes first. |
|
| 933 | + | /// Authors of the changes of a state, most changes first. A change a |
|
| 934 | + | /// delegate signed counts for its author, and the delegate is named under |
|
| 935 | + | /// them: the signing key is read, not verified (the change rows do that). |
|
| 855 | 936 | pub fn contributors(repo: &Repo, state: &oxy_core::state::State) -> Vec<Contributor> { |
|
| 856 | 937 | let mut by: HashMap<String, Contributor> = HashMap::new(); |
|
| 857 | - | for (_, meta) in state.changes() { |
|
| 938 | + | let mut names: HashMap<KeyId, String> = HashMap::new(); |
|
| 939 | + | for (hash, meta) in state.changes() { |
|
| 858 | 940 | let a = author(repo, &meta.author, &meta.message); |
|
| 859 | 941 | let id = if a.key.is_empty() { format!("git:{}", a.email.to_lowercase()) } else { a.key.clone() }; |
|
| 860 | 942 | // Unsigned changes with no Git author carry no name at all. |
|
| 861 | 943 | let name = |
|
| 862 | 944 | if a.key.is_empty() && a.email.is_empty() && a.name == "unknown" { "Unattributed changes".to_string() } else { a.name.clone() }; |
|
| 863 | - | by.entry(id).or_insert_with(|| Contributor { name, key: a.key.clone(), changes: 0, account: None }).changes += 1; |
|
| 945 | + | let entry = by.entry(id).or_insert_with(|| Contributor { name, key: a.key.clone(), changes: 0, account: None, via: Vec::new() }); |
|
| 946 | + | entry.changes += 1; |
|
| 947 | + | if !meta.author.is_zero() { |
|
| 948 | + | let signer = repo.store().signatures(hash).ok().and_then(|s| s.first().map(|s| s.key)).filter(|k| *k != meta.author); |
|
| 949 | + | if let Some(k) = signer { |
|
| 950 | + | let who = |
|
| 951 | + | names.entry(k).or_insert_with(|| repo.store().get_identity(&k).map(|i| i.name).unwrap_or_else(|_| k.short(12))).clone(); |
|
| 952 | + | match entry.via.iter_mut().find(|(n, _)| *n == who) { |
|
| 953 | + | Some((_, n)) => *n += 1, |
|
| 954 | + | None => entry.via.push((who, 1)), |
|
| 955 | + | } |
|
| 956 | + | } |
|
| 957 | + | } |
|
| 864 | 958 | } |
|
| 865 | 959 | let mut out: Vec<Contributor> = by.into_values().collect(); |
|
| 960 | + | for c in &mut out { |
|
| 961 | + | c.via.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); |
|
| 962 | + | } |
|
| 866 | 963 | out.sort_by(|a, b| b.changes.cmp(&a.changes).then(a.name.cmp(&b.name))); |
|
| 867 | 964 | out |
|
| 868 | 965 | } |
|
| … | 529 unchanged lines |
modified crates/hub/src/web/flow.rs +35 −0
| … | 684 unchanged lines |
||
| 685 | 685 | .map_err(|m| Error::bad(shell.clone(), m))?; |
|
| 686 | 686 | Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", rel.tag)).into_response()) |
|
| 687 | 687 | } |
|
| 688 | + | ||
| 689 | + | /// `/{owner}/{name}/flow/badge.svg`: the latest run on the default |
|
| 690 | + | /// bookmark as a small SVG for a README, for whoever may read the |
|
| 691 | + | /// repository. `passed` green, `failed` or `error` red, a run still going |
|
| 692 | + | /// blue, none grey. |
|
| 693 | + | pub async fn badge( |
|
| 694 | + | State(app): State<Shared>, |
|
| 695 | + | viewer: Viewer, |
|
| 696 | + | Path((owner, name)): Path<(String, String)>, |
|
| 697 | + | ) -> Result<axum::response::Response, Error> { |
|
| 698 | + | use axum::response::IntoResponse; |
|
| 699 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/flow"), &viewer); |
|
| 700 | + | let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 701 | + | let latest = fdb::latest_on(&app.db, row.id, &row.default_bookmark).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 702 | + | let (text, colour) = match latest.as_ref().map(|r| r.status.as_str()) { |
|
| 703 | + | Some("passed") => ("passing", "#2da44e"), |
|
| 704 | + | Some("failed") | Some("error") => ("failing", "#cf222e"), |
|
| 705 | + | Some("cancelled") => ("cancelled", "#6e7781"), |
|
| 706 | + | Some(_) => ("running", "#0969da"), |
|
| 707 | + | None => ("no runs", "#6e7781"), |
|
| 708 | + | }; |
|
| 709 | + | let label = "OxygenFlow"; |
|
| 710 | + | let (lw, tw) = (label.len() as u32 * 7 + 10, text.len() as u32 * 7 + 10); |
|
| 711 | + | let svg = format!( |
|
| 712 | + | r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img" aria-label="{label}: {text}"><title>{label}: {text}</title><rect width="{lw}" height="20" rx="3" fill="#555"/><rect x="{lw}" width="{tw}" height="20" rx="3" fill="{colour}"/><rect x="{lw}" width="4" height="20" fill="{colour}"/><g fill="#fff" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle"><text x="{lx}" y="14">{label}</text><text x="{tx}" y="14">{text}</text></g></svg>"##, |
|
| 713 | + | w = lw + tw, |
|
| 714 | + | lx = lw / 2, |
|
| 715 | + | tx = lw + tw / 2, |
|
| 716 | + | ); |
|
| 717 | + | Ok(( |
|
| 718 | + | [(axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), (axum::http::header::CACHE_CONTROL, "no-cache, max-age=60")], |
|
| 719 | + | svg, |
|
| 720 | + | ) |
|
| 721 | + | .into_response()) |
|
| 722 | + | } |
modified crates/hub/src/web/mod.rs +1 −0
| … | 169 unchanged lines |
||
| 170 | 170 | .route("/{owner}/{name}/resolve-live/{bookmark}/{*path}", get(resolve::live_events).post(resolve::live_choose)) |
|
| 171 | 171 | .route("/{owner}/{name}/newfile/{r}", get(edit::new_form)) |
|
| 172 | 172 | .route("/{owner}/{name}/flow", get(flow::runs)) |
|
| 173 | + | .route("/{owner}/{name}/flow/badge.svg", get(flow::badge)) |
|
| 173 | 174 | .route("/{owner}/{name}/flow/{id}", get(flow::run)) |
|
| 174 | 175 | .route("/{owner}/{name}/flow/{id}/live", get(flow::live)) |
|
| 175 | 176 | .route("/{owner}/{name}/flow/start", post(flow::start)) |
|
| … | 448 unchanged lines |
modified crates/hub/src/web/proposals.rs +7 −0
| … | 1258 unchanged lines |
||
| 1259 | 1259 | when_ms: c.when_ms, |
|
| 1260 | 1260 | deps: c.deps.clone(), |
|
| 1261 | 1261 | head: c.head, |
|
| 1262 | + | via: c.via.as_ref().map(|v| hub_api::Via { |
|
| 1263 | + | name: v.name.clone(), |
|
| 1264 | + | key: v.key.clone(), |
|
| 1265 | + | scope: v.scope.clone(), |
|
| 1266 | + | expires_ms: v.expires_ms, |
|
| 1267 | + | revoked: v.revoked, |
|
| 1268 | + | }), |
|
| 1262 | 1269 | signature: match &c.signature { |
|
| 1263 | 1270 | view::SigView::Unsigned => hub_api::Signature::Unsigned, |
|
| 1264 | 1271 | view::SigView::Valid { key, sealed, revoked, .. } => { |
|
| … | 175 unchanged lines |
modified crates/hub/tests/edits.rs +94 −0
| … | 161 unchanged lines |
||
| 162 | 162 | let key2 = client.config.author_key.unwrap(); |
|
| 163 | 163 | let id2 = client.identity(&key2).unwrap(); |
|
| 164 | 164 | assert!(id2.delegate(&app.hub_key.key).is_some()); |
|
| 165 | + | // The successor was pushed with a change it authored? Not yet: link it |
|
| 166 | + | // by hand here, as before; the auto-link is checked further down. |
|
| 165 | 167 | db::accounts::link_identity(&app.db, andy_id, &key2.to_hex(), &id2.name, &id2.email, &id2.encode()).await.unwrap(); |
|
| 166 | 168 | ||
| 167 | 169 | // An edit lands on main, authored by Andy, signed by the hub as his delegate. |
|
| … | 308 unchanged lines |
||
| 476 | 478 | client.log().unwrap().into_iter().filter(|e| !e.is_working_copy).map(|e| client.authenticate_change(&e.hash).unwrap()).collect(); |
|
| 477 | 479 | assert!(v.iter().all(|s| matches!(s, oxy_core::repo::SignatureStatus::Valid { .. })), "{v:?}"); |
|
| 478 | 480 | } |
|
| 481 | + | ||
| 482 | + | /// A rotation reaches the hub with the next push: an author whose identity |
|
| 483 | + | /// is a successor of a linked one, with the same key pair, is linked |
|
| 484 | + | /// without another challenge. An agent's change shows who signed for the |
|
| 485 | + | /// author, on the change page, in history rows and over the API. |
|
| 486 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 487 | + | async fn a_rotated_identity_links_itself_and_an_agent_s_change_says_via() { |
|
| 488 | + | hermetic(); |
|
| 489 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 490 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 491 | + | config.auth.push_tokens = vec!["site".into()]; |
|
| 492 | + | config.registration.mode = "open".into(); |
|
| 493 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 494 | + | let port = listener.local_addr().unwrap().port(); |
|
| 495 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 496 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 497 | + | let router = oxygenhub::router(app.clone()); |
|
| 498 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 499 | + | let base = format!("http://127.0.0.1:{port}"); |
|
| 500 | + | let app = &app; |
|
| 501 | + | let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await; |
|
| 502 | + | assert!(r.cookie.is_some()); |
|
| 503 | + | let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; |
|
| 504 | + | let repo = RepoName::parse("andy/demo").unwrap(); |
|
| 505 | + | app.repos.create(&repo, None).unwrap(); |
|
| 506 | + | db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 507 | + | let work = tempfile::tempdir().unwrap(); |
|
| 508 | + | let home = tempfile::tempdir().unwrap(); |
|
| 509 | + | let mut client = Repo::init(work.path()).unwrap().with_home(home.path()); |
|
| 510 | + | client.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap(); |
|
| 511 | + | client.config.set("core.sign", "change").unwrap(); |
|
| 512 | + | client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap(); |
|
| 513 | + | client.config.set("remote.origin.token", "site").unwrap(); |
|
| 514 | + | client.save_config().unwrap(); |
|
| 515 | + | write(work.path(), "a.txt", b"one\n"); |
|
| 516 | + | client.snapshot("t").unwrap(); |
|
| 517 | + | client.commit("first", &[]).unwrap(); |
|
| 518 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 519 | + | let key1 = client.config.author_key.unwrap(); |
|
| 520 | + | let id1 = client.identity(&key1).unwrap(); |
|
| 521 | + | db::accounts::link_identity(&app.db, andy_id, &key1.to_hex(), &id1.name, &id1.email, &id1.encode()).await.unwrap(); |
|
| 522 | + | ||
| 523 | + | // An agent with its own key; Andy delegates (a rotation) and commits. |
|
| 524 | + | let agent_home = tempfile::tempdir().unwrap(); |
|
| 525 | + | let mut agent_repo = Repo::init(&work.path().join("agent-scratch")).unwrap().with_home(agent_home.path()); |
|
| 526 | + | let (agent_key, agent_id) = agent_repo.create_identity("Antigravity", "[email protected]", IdentityScope::User).unwrap(); |
|
| 527 | + | client.delegate_add(agent_id, oxy_core::identity::Scope::CHANGES, None).unwrap(); |
|
| 528 | + | let key2 = client.config.author_key.unwrap(); |
|
| 529 | + | assert_ne!(key1, key2); |
|
| 530 | + | write(work.path(), "a.txt", b"one\ntwo\n"); |
|
| 531 | + | client.snapshot("t").unwrap(); |
|
| 532 | + | let by_andy = client.commit("second, by Andy", &[]).unwrap(); |
|
| 533 | + | // The agent commits in Andy's checkout: Andy as author, its own signature. |
|
| 534 | + | let mut as_agent = Repo::open(work.path()).unwrap().with_home(agent_home.path()); |
|
| 535 | + | as_agent.set_user_config("author.signer", &agent_key.to_hex()).unwrap(); |
|
| 536 | + | as_agent.set_user_config("core.sign", "change").unwrap(); |
|
| 537 | + | write(work.path(), "b.txt", b"by the agent\n"); |
|
| 538 | + | as_agent.snapshot("t").unwrap(); |
|
| 539 | + | let by_agent = as_agent.commit("third, by the agent", &[]).unwrap(); |
|
| 540 | + | // Andy pushes both. |
|
| 541 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 542 | + | tokio::time::sleep(std::time::Duration::from_millis(300)).await; |
|
| 543 | + | ||
| 544 | + | // The successor is linked to Andy's account by the push. |
|
| 545 | + | let linked = db::accounts::identity_by_key(&app.db, &key2.to_hex()).await.unwrap(); |
|
| 546 | + | assert_eq!(linked.map(|l| l.account_id), Some(andy_id), "the rotated identity links itself"); |
|
| 547 | + | assert!(db::accounts::identity_by_key(&app.db, &agent_key.to_hex()).await.unwrap().is_none(), "an agent's key is nobody's account"); |
|
| 548 | + | ||
| 549 | + | // The change page: Andy as author, a via row naming the agent. |
|
| 550 | + | let page = send(app, "GET", &format!("/andy/demo/change/{}", by_agent.to_hex()), None, None).await.body; |
|
| 551 | + | assert!(page.contains("<dt>via</dt>"), "{page}"); |
|
| 552 | + | assert!(page.contains("Antigravity") && page.contains("delegate for changes"), "{page}"); |
|
| 553 | + | assert!(page.contains("href=\"/andy\""), "the author is Andy's account: {page}"); |
|
| 554 | + | let andy_page = send(app, "GET", &format!("/andy/demo/change/{}", by_andy.to_hex()), None, None).await.body; |
|
| 555 | + | assert!(!andy_page.contains("<dt>via</dt>"), "{andy_page}"); |
|
| 556 | + | // History rows and the API say so too. |
|
| 557 | + | let rows = send(app, "GET", "/andy/demo/changes/main", None, None).await.body; |
|
| 558 | + | assert!(rows.contains("via Antigravity"), "{rows}"); |
|
| 559 | + | let hist = send(app, "GET", "/api/v1/repos/andy/demo/history/main", None, None).await.body; |
|
| 560 | + | let v: serde_json::Value = serde_json::from_str(&hist).unwrap(); |
|
| 561 | + | let agent_change = v["changes"].as_array().unwrap().iter().find(|c| c["hash"] == by_agent.to_hex()).unwrap(); |
|
| 562 | + | assert_eq!(agent_change["via"]["name"], "Antigravity"); |
|
| 563 | + | assert_eq!(agent_change["via"]["scope"], "changes"); |
|
| 564 | + | assert_eq!(agent_change["author"]["account"], "andy"); |
|
| 565 | + | // Contributors: the agent's change counts for Andy, named under him. |
|
| 566 | + | let overview = send(app, "GET", "/andy/demo", None, None).await.body; |
|
| 567 | + | assert!(overview.contains("via Antigravity 1"), "{overview}"); |
|
| 568 | + | // The badge: no runs yet. |
|
| 569 | + | let badge = send(app, "GET", "/andy/demo/flow/badge.svg", None, None).await; |
|
| 570 | + | assert_eq!(badge.status, StatusCode::OK); |
|
| 571 | + | assert!(badge.body.contains("<svg") && badge.body.contains("no runs"), "{}", badge.body); |
|
| 572 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +4 −2
| … | 179 unchanged lines |
||
| 180 | 180 | ||
| 181 | 181 | An Oxygen identity is a signed object holding an Ed25519 + ML-DSA-65 key pair's public halves (spec §14.2). An account links an identity by uploading the identity object (`oxy identity export`) and proving possession: the hub issues a 32-byte challenge and the client signs it with `oxy identity sign-challenge` (§18); the hub verifies both signatures. A linked key is what makes a pushed change *this account's* change: author attribution in every page comes from the change's signing key, not from who held the token that pushed it. A change signed by an unlinked key shows its identity's name (or KeyId when the identity is unknown) with a "key not linked" tag rather than a user name. |
|
| 182 | 182 | ||
| 183 | - | An identity can be linked to at most one account. Rotation (`oxy identity rotate`) produces a new identity whose object names the old key; the hub accepts the rotated identity for the same account without a new challenge when the rotation is signed by the old key. |
|
| 183 | + | An identity can be linked to at most one account. Rotation (`oxy identity rotate`, `renew`, `delegate add|remove`) produces a successor identity whose object names the old key. When a push brings changes authored by a successor of a linked identity, and every step of the chain kept the key pair, the hub links the successor to the same account itself and records `identity.rotated` in the audit log: delegating to an agent never breaks attribution and needs no re-upload. A rotation to a fresh key pair (`oxy identity rotate`) still needs the new identity linked with a challenge. |
|
| 184 | 184 | ||
| 185 | 185 | ### 5.4 Authentication for the wire protocol |
|
| 186 | 186 | ||
| … | 254 unchanged lines |
||
| 441 | 441 | ||
| 442 | 442 | ### 12.1 API |
|
| 443 | 443 | ||
| 444 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 444 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 445 | 445 | ||
| 446 | 446 | ### 12.2 Webhooks |
|
| 447 | 447 | ||
| … | 57 unchanged lines |
||
| 505 | 505 | - **Checks.** Each job is also the check `flow/<job>` on the run's state (§13.1), so proposals, required checks and the editor's status bar see it. |
|
| 506 | 506 | - **Pages.** A **Flow** tab lists runs (status, bookmark, state, pusher, when, duration). A run page draws the job graph as SVG, with columns by dependency depth and status by colour and glyph, and each job's log below (folded, the failed ones open); it refreshes while the run is live. Readers of the repository can see both. |
|
| 507 | 507 | ||
| 508 | + | `/{owner}/{name}/flow/badge.svg` is a small SVG of the latest run on the default bookmark (`passing`, `failing`, `running`, `no runs`) for a README, for whoever may read the repository. It is a convenience, with the weakness of every badge; the stamps below are the real thing. |
|
| 509 | + | ||
| 508 | 510 | **State stamps** replace CI badges, which are mutable images claiming that a branch passes, name no state and cannot be checked. A stamp belongs to a state: |
|
| 509 | 511 | ||
| 510 | 512 | - `/{owner}/{name}/flow/stamp/{ref}` is an SVG. For a state address (heads joined with `+`) or a change, it shows the newest run of exactly that state: one glyph per job in dependency order, the run's status, the state's short hash and the run's date. It shows "no run" when there is none. The same data is machine-readable in the SVG's `<metadata>`: heads, run number, status, and each job's status and receipt hash. For a bookmark or tag, it redirects to the stamp of the ref's current state, so even the image names what was checked. A finished run's stamp may be cached for an hour; any other stamp for a minute. |
|
| … | 173 unchanged lines |
modified templates/change.html +2 −1
| … | 8 unchanged lines |
||
| 9 | 9 | <div class="head"><h1>{{ c.view.title }} <small class="px">{{ c.view.short }}</small>{% if c.view.resolution %} <span class="tag" title="Resolves conflicts; depends on every change that wrote a side">resolution</span>{% endif %}</h1></div> |
|
| 10 | 10 | {{ body_html|safe }} |
|
| 11 | 11 | <dl class="facts"> |
|
| 12 | - | <dt>Author</dt><dd>{% if let Some(a) = c.view.author.account %}<a href="/{{ a }}">{{ c.view.author.name }}</a> <span class="tag">@{{ a }}</span>{% else %}{{ c.view.author.name }}{% endif %}{% if !c.view.author.email.is_empty() %} <span class="mute"><{{ c.view.author.email }}></span>{% endif %}{% if !c.view.author.key.is_empty() && c.view.author.account.is_none() %} <span class="tag" title="The key that signed this change is not linked to any account.">key not linked</span>{% if shell.user.is_some() %} <a class="small" href="/settings/keys">link your key</a>{% endif %}{% endif %}</dd> |
|
| 12 | + | <dt>Author</dt><dd>{% if let Some(a) = c.view.author.account %}<a href="/{{ a }}">{{ c.view.author.name }}</a> <span class="tag">@{{ a }}</span>{% else %}{{ c.view.author.name }}{% endif %}{% if !c.view.author.email.is_empty() %} <span class="mute"><{{ c.view.author.email }}></span>{% endif %}{% if !c.view.author.key.is_empty() && c.view.author.account.is_none() %} <span class="tag" title="The key that signed this change is not linked to any account.">key not linked</span>{% if shell.user.is_some() %} <a class="small" href="/settings/keys">link your key</a>{% endif %}{% endif %}</dd>{% if let Some(v) = c.view.via %} |
|
| 13 | + | <dt>via</dt><dd><span class="mute" aria-hidden="true">↳</span> <span class="tag{% if v.revoked %} bad{% endif %}" title="{{ v.title() }}">agent</span> {{ v.name }}{% if !v.email.is_empty() %} <span class="mute"><{{ v.email }}></span>{% endif %} <span class="mute small">· {{ v.delegation() }} · key {{ v.short }}</span></dd>{% endif %} |
|
| 13 | 14 | <dt>When</dt><dd><time title="{{ c.view.when_ms|when }}">{{ c.view.when_ms|ago }}</time> · {{ c.view.when_ms|when }}</dd> |
|
| 14 | 15 | <dt>Signature</dt><dd>{% call m::sig(c.view.signature) %} {{ c.view.signature.title() }}</dd> |
|
| 15 | 16 | <dt>Hash</dt><dd><code class="full">{{ c.view.hash }}</code> <button class="small" data-copy="{{ c.view.hash }}">copy</button></dd> |
|
| … | 12 unchanged lines |
modified templates/overview.html +1 −1
| … | 47 unchanged lines |
||
| 48 | 48 | {% if !o.contributors.is_empty() %} |
|
| 49 | 49 | <h3>Contributors <span class="count-chip">{{ o.contributors.len() }}</span></h3> |
|
| 50 | 50 | <ul class="plain contributors">{% for c in o.contributors.iter().take(10) %} |
|
| 51 | - | <li>{% if let Some(u) = c.account %}<a href="/{{ u }}"><img class="avatar sm" src="/avatar/{{ u }}" alt="" width="20" height="20"> {{ u }}</a> <span class="mute small">{{ c.name }}</span>{% else %}<span class="avatar-none" aria-hidden="true">○</span> {{ c.name }}{% endif %} <span class="mute small" title="changes">· {{ c.changes }}</span></li> |
|
| 51 | + | <li>{% if let Some(u) = c.account %}<a href="/{{ u }}"><img class="avatar sm" src="/avatar/{{ u }}" alt="" width="20" height="20"> {{ u }}</a> <span class="mute small">{{ c.name }}</span>{% else %}<span class="avatar-none" aria-hidden="true">○</span> {{ c.name }}{% endif %} <span class="mute small" title="changes">· {{ c.changes }}</span>{% if !c.via.is_empty() %}<br><span class="mute small">via {% for v in c.via %}{{ v.0 }} {{ v.1 }}{% if !loop.last %}, {% endif %}{% endfor %}</span>{% endif %}</li> |
|
| 52 | 52 | {% endfor %}</ul> |
|
| 53 | 53 | {% if o.contributors.len() > 10 %}<p class="small mute">and {{ o.contributors.len() - 10 }} more</p>{% endif %} |
|
| 54 | 54 | {% endif %} |
|
| … | 20 unchanged lines |
modified templates/repo_tabs.html +1 −1
| … | 28 unchanged lines |
||
| 29 | 29 | {% call labels(c) %} |
|
| 30 | 30 | {% if c.resolution %}<span class="tag">resolution</span>{% endif %} |
|
| 31 | 31 | {% if c.head %}<span class="tag head">head</span>{% endif %} |
|
| 32 | - | <div class="meta"><span class="who">{% if let Some(a) = c.author.account %}<a href="/{{ a }}">{{ c.author.name }}</a>{% else %}{{ c.author.name }}{% endif %}</span> · <time title="{{ c.when_ms|when }}">{{ c.when_ms|ago }}</time></div> |
|
| 32 | + | <div class="meta"><span class="who">{% if let Some(a) = c.author.account %}<a href="/{{ a }}">{{ c.author.name }}</a>{% else %}{{ c.author.name }}{% endif %}{% if let Some(v) = c.via %} <span class="mute">via {{ v.name }}</span> <span class="tag{% if v.revoked %} bad{% endif %}" title="{{ v.title() }}">agent</span>{% endif %}</span> · <time title="{{ c.when_ms|when }}">{{ c.when_ms|ago }}</time></div> |
|
| 33 | 33 | </div> |
|
| 34 | 34 | <div class="chg-side"> |
|
| 35 | 35 | {% call sig(c.signature) %} |
|
| … | 39 unchanged lines |