Jump to…
owzkxxuxzulumerged#3

attribute changes to their author, and index SSH pushes

13 files+295−36
Collapse all
Comparingv1 against its parent
MCargo.lock+1−0
@@ −1098,6 +1098,7 @@
10981098 "dotenvy",
10991099 "rand 0.8.7",
11001100 "russh",
1101+ "serde_json",
11011102 "sqlx",
11021103 "tokio",
11031104 "tracing",
Mcrates/df-ssh/Cargo.toml+1−0
@@ −17,6 +17,7 @@
1717dotenvy.workspace = true
1818rand.workspace = true
1919russh.workspace = true
20+serde_json.workspace = true
2021sqlx.workspace = true
2122tokio.workspace = true
2223tracing.workspace = true
Mcrates/df-auth/src/provisioning.rs+41−0
@@ −89,6 +89,47 @@
8989 Ok(u)
9090}
9191
92+/// Fill in profile fields the account is missing, from a fresh login's claims.
93+///
94+/// Only ever writes over a `NULL`. An account whose email is already recorded
95+/// keeps it, so this cannot silently move an identity from under commits that
96+/// are attributed to it.
97+///
98+/// This exists because the claims are **not reliably present on every login**:
99+/// Hydra's skip-consent path (taken once consent is remembered) can return an
100+/// ID token carrying only `sub`, so an account created during such a login is
101+/// created with no email at all — and email is the only thing that links a
102+/// pushed commit back to an account. Backfilling on any later login that does
103+/// carry the claim is what repairs that without the user doing anything.
104+pub async fn backfill_profile(db: &PgPool, user_id: Uuid, identity: &Identity) -> Result<()> {
105+ let email = identity.email.as_deref().map(str::trim).filter(|e| !e.is_empty());
106+ let name = identity.name.as_deref().map(str::trim).filter(|n| !n.is_empty());
107+
108+ if email.is_none() && name.is_none() {
109+ return Ok(());
110+ }
111+
112+ let updated = sqlx::query(
113+ "UPDATE users
114+ SET email = COALESCE(email, $2),
115+ display_name = COALESCE(display_name, $3)
116+ WHERE id = $1
117+ AND (($2 IS NOT NULL AND email IS NULL)
118+ OR ($3 IS NOT NULL AND display_name IS NULL))",
119+ )
120+ .bind(user_id)
121+ .bind(email)
122+ .bind(name)
123+ .execute(db)
124+ .await
125+ .context("backfilling user profile")?;
126+
127+ if updated.rows_affected() > 0 {
128+ tracing::info!(user = %user_id, "filled in profile fields from a fresh login");
129+ }
130+ Ok(())
131+}
132+
92133/// Whether an open invitation exists for this email.
93134pub async fn has_invitation(db: &PgPool, email: &str) -> Result<bool> {
94135 let found: Option<(Uuid,)> =
Mcrates/df-ssh/src/main.rs+1−0
@@ −259,6 +259,7 @@
259259 channel,
260260 request.service,
261261 &dir,
262+ &self.db,
262263 &self.database_url,
263264 &self.hook_binary,
264265 resolved.repo_id,
Mcrates/df-ssh/src/repo.rs+40−0
@@ −113,6 +113,7 @@
113113 channel: ChannelId,
114114 service: Service,
115115 dir: &Path,
116+ db: &PgPool,
116117 database_url: &str,
117118 hook_binary: &str,
118119 repo_id: Uuid,
@@ −192,6 +193,7 @@
192193 // Reap the child and close the channel once it exits. This runs detached so
193194 // `exec_request` can return and the handler can keep delivering client data
194195 // to the stdin we hand back.
196+ let db = db.clone();
195197 tokio::spawn(async move {
196198 let status = match child.wait().await {
197199 Ok(s) => s,
@@ −204,6 +206,28 @@
204206 let _ = out_task.await;
205207 let _ = err_task.await;
206208
209+ // A push that git accepted has to be indexed, exactly as the HTTP
210+ // transport does it — without this the objects land but the site never
211+ // learns about them, so the push is invisible until somebody runs
212+ // `dogfood-admin reindex` by hand.
213+ //
214+ // Only on success: a rejected push (a protected bookmark, say) wrote
215+ // nothing to index. Failures here are logged and dropped rather than
216+ // surfaced, because the objects are already durable and failing the
217+ // client would make it retry a push that succeeded.
218+ if service.is_write() && status.success() {
219+ if let Err(e) = enqueue_index(&db, repo_id, Some(user_id)).await {
220+ tracing::error!(repo = %repo_id, "enqueuing IndexPush failed: {e:#}");
221+ }
222+ if let Err(e) = sqlx::query("UPDATE repos SET pushed_at = now() WHERE id = $1")
223+ .bind(repo_id)
224+ .execute(&db)
225+ .await
226+ {
227+ tracing::warn!(repo = %repo_id, "recording push time failed: {e}");
228+ }
229+ }
230+
207231 let code = status.code().unwrap_or(1) as u32;
208232 let _ = handle.exit_status_request(channel, code).await;
209233 let _ = handle.eof(channel).await;
@@ −213,6 +237,22 @@
213237 Ok(stdin)
214238}
215239
240+/// Queue an indexing job for a repository.
241+///
242+/// Deliberately the same payload shape the HTTP transport enqueues — one job
243+/// kind, one worker, whichever way the push arrived.
244+async fn enqueue_index(db: &PgPool, repo_id: Uuid, pushed_by: Option<Uuid>) -> Result<()> {
245+ sqlx::query("INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)")
246+ .bind(df_db::ids::new_id())
247+ .bind(serde_json::json!({
248+ "repo_id": repo_id,
249+ "pushed_by": pushed_by,
250+ }))
251+ .execute(db)
252+ .await?;
253+ Ok(())
254+}
255+
216256#[cfg(test)]
217257mod tests {
218258 use super::*;
Mcrates/df-worker/src/index_push.rs+76−6
@@ −92,6 +92,9 @@
9292
9393 let mut change_rows: HashMap<String, Uuid> = HashMap::new();
9494 let mut revisions_added = 0usize;
95+ // One lookup per distinct author email rather than per commit: a thousand
96+ // -commit walk is usually a handful of people.
97+ let mut author_cache: HashMap<String, Option<Uuid>> = HashMap::new();
9598
9699 for c in &commit_list {
97100 // jj commits carry their change id. Plain-git commits do not, and get a
@@ −113,6 +116,8 @@
113116 continue;
114117 }
115118
119+ let author_user_id = resolve_author(db, &mut author_cache, &c.author_email).await;
120+
116121 let change_uuid = upsert_change(
117122 db,
118123 repo_id,
@@ −121,6 +126,7 @@
121126 &default_bookmark,
122127 merged.contains(&change_id),
123128 synthetic,
129+ author_user_id,
124130 )
125131 .await?;
126132
@@ −144,6 +150,9 @@
144150 emit_event(
145151 db,
146152 repo_id,
153+ // Genuinely nobody's action: the indexer
154+ // re-anchored these, not a person.
155+ None,
147156 "comments.rebased",
148157 change_uuid,
149158 serde_json::json!({
@@ −165,9 +174,15 @@
165174 }
166175 }
167176
177+ // A push is the *pusher's* action, so it is attributed to them
178+ // rather than to the commit's author — those differ whenever
179+ // somebody lands work written by someone else. The author is
180+ // the fallback only when the transport did not tell us who
181+ // pushed (an admin reindex, for one).
168182 emit_event(
169183 db,
170184 repo_id,
185+ pushed_by.or(author_user_id),
171186 "change.pushed",
172187 change_uuid,
173188 serde_json::json!({ "rev": c.rev }),
@@ −178,6 +193,7 @@
178193 emit_event(
179194 db,
180195 repo_id,
196+ pushed_by.or(author_user_id),
181197 "change.conflicted",
182198 change_uuid,
183199 serde_json::json!({ "rev": c.rev }),
@@ −255,6 +271,48 @@
255271 )
256272}
257273
274+/// Resolve the Dogfood account that wrote a commit, by its author email.
275+///
276+/// Email is the only link a pushed commit carries back to an account — the
277+/// commit knows nothing about Dogfood — so this is the same rule every forge
278+/// uses. `users.email` is `citext`, so the comparison is case-insensitive in
279+/// the database rather than here.
280+///
281+/// `None` is an ordinary outcome, not a failure: commits pushed by somebody
282+/// with no account, or written under an email the account has not recorded,
283+/// keep the name the commit gave them and simply do not link anywhere. Never
284+/// falls back to the *pusher* — attributing Alice's commit to Bob because Bob
285+/// pushed it would be worse than not linking at all.
286+async fn resolve_author(
287+ db: &PgPool,
288+ cache: &mut HashMap<String, Option<Uuid>>,
289+ author_email: &str,
290+) -> Option<Uuid> {
291+ let email = author_email.trim();
292+ if email.is_empty() {
293+ return None;
294+ }
295+
296+ if let Some(hit) = cache.get(email) {
297+ return *hit;
298+ }
299+
300+ let found: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE email = $1")
301+ .bind(email)
302+ .fetch_optional(db)
303+ .await
304+ .unwrap_or_else(|e| {
305+ // A lookup failure must not fail the whole index job; the change is
306+ // still worth recording, just unattributed.
307+ tracing::warn!("resolving a commit author failed: {e}");
308+ None
309+ });
310+
311+ let id = found.map(|(id,)| id);
312+ cache.insert(email.to_string(), id);
313+ id
314+}
315+
258316/// Insert or update the `changes` row, returning its id.
259317async fn upsert_change(
260318 db: &PgPool,
@@ −264,6 +322,7 @@
264322 default_bookmark: &str,
265323 on_target: bool,
266324 synthetic: bool,
325+ author_user_id: Option<Uuid>,
267326) -> Result<Uuid> {
268327 let existing: Option<(Uuid, ChangeStateSql)> = sqlx::query_as(
269328 "SELECT id, state FROM changes WHERE repo_id = $1 AND change_id = $2",
@@ −282,9 +341,14 @@
282341 let next = indexer::next_state(state.into(), on_target);
283342
284343 sqlx::query(
344+ // `COALESCE` on the author so a reindex *fills in* an attribution
345+ // that could not be resolved before — the user has since recorded
346+ // the email — without ever overwriting one that is already set,
347+ // which would undo the web UI's explicit author on a created change.
285348 "UPDATE changes
286349 SET title = $2, description = $3, conflicted = $4,
287350 state = $5::change_state,
351+ author_user_id = COALESCE(author_user_id, $6),
288352 merged_at = CASE WHEN $5 = 'merged' AND merged_at IS NULL
289353 THEN now() ELSE merged_at END,
290354 updated_at = now()
@@ −295,6 +359,7 @@
295359 .bind(description)
296360 .bind(c.conflicted)
297361 .bind(state_str(next))
362+ .bind(author_user_id)
298363 .execute(db)
299364 .await?;
300365
@@ −322,8 +387,9 @@
322387 // is what keeps the job idempotent.
323388 let inserted: Option<(Uuid,)> = sqlx::query_as(
324389 "INSERT INTO changes (id, repo_id, change_id, number, title, description,
325 state, conflicted, target_bookmark, synthetic, merged_at)
326 VALUES ($1, $2, $3, $4, $5, $6, $7::change_state, $8, $9, $10,
390+ state, conflicted, target_bookmark, synthetic,
391+ author_user_id, merged_at)
392+ VALUES ($1, $2, $3, $4, $5, $6, $7::change_state, $8, $9, $10, $11,
327393 CASE WHEN $7 = 'merged' THEN now() ELSE NULL END)
328394 ON CONFLICT (repo_id, change_id) DO NOTHING
329395 RETURNING id",
@@ −338,6 +404,7 @@
338404 .bind(c.conflicted)
339405 .bind(default_bookmark)
340406 .bind(synthetic)
407+ .bind(author_user_id)
341408 .fetch_optional(&mut *tx)
342409 .await?;
343410
@@ −347,11 +414,12 @@
347414 Some((id,)) => {
348415 // Timeline event for a newly seen change.
349416 let _ = sqlx::query(
350 "INSERT INTO events (id, repo_id, kind, subject_type, subject_id, payload)
351 VALUES ($1, $2, 'change.opened', 'change', $3, $4)",
417+ "INSERT INTO events (id, repo_id, actor_id, kind, subject_type, subject_id, payload)
418+ VALUES ($1, $2, $3, 'change.opened', 'change', $4, $5)",
352419 )
353420 .bind(new_id())
354421 .bind(repo_id)
422+ .bind(author_user_id)
355423 .bind(id)
356424 .bind(serde_json::json!({ "change_id": change_id }))
357425 .execute(db)
@@ −457,16 +525,18 @@
457525async fn emit_event(
458526 db: &PgPool,
459527 repo_id: Uuid,
528+ actor: Option<Uuid>,
460529 kind: &str,
461530 change_uuid: Uuid,
462531 payload: serde_json::Value,
463532) {
464533 if let Err(e) = sqlx::query(
465 "INSERT INTO events (id, repo_id, kind, subject_type, subject_id, payload)
466 VALUES ($1, $2, $3, 'change', $4, $5)",
534+ "INSERT INTO events (id, repo_id, actor_id, kind, subject_type, subject_id, payload)
535+ VALUES ($1, $2, $3, $4, 'change', $5, $6)",
467536 )
468537 .bind(new_id())
469538 .bind(repo_id)
539+ .bind(actor)
470540 .bind(kind)
471541 .bind(change_uuid)
472542 .bind(payload)
Mcrates/df-web/src/routes/auth.rs+7−0
@@ −109,6 +109,13 @@
109109
110110 // Returning user: sign straight in.
111111 if let Some(user) = provisioning::find_by_subject(&state.db, &identity.subject).await? {
112+ // Claims are not guaranteed on every login, so an account can exist
113+ // with no email — which is the one thing that links pushed commits to
114+ // it. Any login that does carry the claim repairs that. Never fatal:
115+ // failing to backfill must not cost the user their sign-in.
116+ if let Err(e) = provisioning::backfill_profile(&state.db, user.id, &identity).await {
117+ tracing::warn!(user = %user.id, "backfilling profile failed: {e:#}");
118+ }
112119 let jar =
113120 establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref())
114121 .await?;
Mcrates/df-web/src/routes/change.rs+19−1
@@ −83,9 +83,12 @@
8383 revset_vals: Vec<String>,
8484) -> AppResult<Vec<v::ChangeRow>> {
8585 let mut sql = String::from(
86+ // `hr.author_name` is the fallback when no account matched the commit's
87+ // email — the person is still known, just not linkable.
8688 "SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text,
8789 c.conflicted, c.updated_at,
8890 u.handle::text AS author,
91+ hr.author_name,
8992 (SELECT count(*) FROM revisions rr WHERE rr.change_id_fk = c.id) AS revcount,
9093 COALESCE((
9194 SELECT array_agg(pc.change_id)
@@ −95,6 +98,7 @@
9598 ), '{}') AS children
9699 FROM changes c
97100 LEFT JOIN users u ON u.id = c.author_user_id
101+ LEFT JOIN revisions hr ON hr.id = c.head_revision_id
98102 WHERE c.repo_id = $1
99103 AND ($2 = 'all' OR c.state::text = $2)",
100104 );
@@ −116,6 +120,7 @@
116120 bool,
117121 chrono::DateTime<chrono::Utc>,
118122 Option<String>,
123+ Option<String>,
119124 i64,
120125 Vec<String>,
121126 ),
@@ −132,7 +137,19 @@
132137 Ok(rows
133138 .into_iter()
134139 .map(
135 |(number, change_id, synthetic, title, st, conflicted, updated_at, author, revcount, children)| {
140+ |(
141+ number,
142+ change_id,
143+ synthetic,
144+ title,
145+ st,
146+ conflicted,
147+ updated_at,
148+ author,
149+ author_name,
150+ revcount,
151+ children,
152+ )| {
136153 v::ChangeRow {
137154 number,
138155 change_id,
@@ −142,6 +159,7 @@
142159 conflicted,
143160 updated_at,
144161 author,
162+ author_name,
145163 revision_count: revcount,
146164 children,
147165 }
Mcrates/df-web/src/routes/home.rs+48−16
@@ −156,9 +156,20 @@
156156/// Public repositories only, and no drafts: this renders for anonymous
157157/// visitors, so anything it can reach is world-readable by definition.
158158async fn public_feed(state: &AppState) -> AppResult<Vec<FeedItem>> {
159 let rows: Vec<(String, String, i64, String, bool, String, Option<String>, DateTime<Utc>)> =
160 sqlx::query_as(
161 r#"
159+ let rows: Vec<(
160+ String,
161+ String,
162+ i64,
163+ String,
164+ bool,
165+ String,
166+ Option<String>,
167+ Option<String>,
168+ DateTime<Utc>,
169+ )> = sqlx::query_as(
170+ // `hr.author_name` is the fallback when no account matched the commit's
171+ // email — the person is still known, just not linkable.
172+ r#"
162173 SELECT COALESCE(ou.handle, og.handle) AS owner,
163174 r.name::text,
164175 c.number,
@@ −166,34 +177,39 @@
166177 c.synthetic,
167178 c.title,
168179 au.handle AS author,
180+ hr.author_name,
169181 c.updated_at
170182 FROM changes c
171183 JOIN repos r ON r.id = c.repo_id
172184 LEFT JOIN users ou ON ou.id = r.owner_user_id
173185 LEFT JOIN orgs og ON og.id = r.owner_org_id
174186 LEFT JOIN users au ON au.id = c.author_user_id
187+ LEFT JOIN revisions hr ON hr.id = c.head_revision_id
175188 WHERE r.archived = false
176189 AND r.visibility = 'public'
177190 AND c.state <> 'draft'
178191 ORDER BY c.updated_at DESC
179192 LIMIT 12
180193 "#,
181 )
182 .fetch_all(&state.db)
183 .await?;
194+ )
195+ .fetch_all(&state.db)
196+ .await?;
184197
185198 Ok(rows
186199 .into_iter()
187200 .map(
188 |(owner, repo, number, change_id, synthetic, title, author, when)| FeedItem {
189 owner,
190 repo,
191 number,
192 change_id,
193 synthetic,
194 title,
195 author,
196 when,
201+ |(owner, repo, number, change_id, synthetic, title, author, author_name, when)| {
202+ FeedItem {
203+ owner,
204+ repo,
205+ number,
206+ change_id,
207+ synthetic,
208+ title,
209+ author,
210+ author_name,
211+ when,
212+ }
197213 },
198214 )
199215 .collect())
@@ −209,13 +225,26 @@
209225 String,
210226 bool,
211227 Option<String>,
228+ Option<String>,
212229 DateTime<Utc>,
213230);
214231
215232fn to_dash_changes(rows: Vec<ChangeRow>) -> Vec<DashChange> {
216233 rows.into_iter()
217234 .map(
218 |(owner, repo, number, change_id, synthetic, title, state, conflicted, author, updated_at)| {
235+ |(
236+ owner,
237+ repo,
238+ number,
239+ change_id,
240+ synthetic,
241+ title,
242+ state,
243+ conflicted,
244+ author,
245+ author_name,
246+ updated_at,
247+ )| {
219248 DashChange {
220249 owner,
221250 repo,
@@ −226,6 +255,7 @@
226255 state,
227256 conflicted,
228257 author,
258+ author_name,
229259 updated_at,
230260 }
231261 },
@@ −243,12 +273,14 @@
243273 c.state::text,
244274 c.conflicted,
245275 au.handle AS author,
276+ hr.author_name,
246277 c.updated_at
247278 FROM changes c
248279 JOIN repos r ON r.id = c.repo_id
249280 LEFT JOIN users ou ON ou.id = r.owner_user_id
250281 LEFT JOIN orgs og ON og.id = r.owner_org_id
251282 LEFT JOIN users au ON au.id = c.author_user_id
283+ LEFT JOIN revisions hr ON hr.id = c.head_revision_id
252284"#;
253285
254286/// Open changes waiting on this viewer.
Mcrates/df-web/src/routes/review.rs+12−1
@@ −38,6 +38,7 @@
3838 /// (seq, rev), oldest first.
3939 revisions: Vec<(i32, String)>,
4040 author: Option<String>,
41+ author_name: Option<String>,
4142 can_manage: bool,
4243}
4344
@@ −90,13 +91,22 @@
9091 .fetch_optional(&state.db)
9192 .await?;
9293
94+ // What the commit itself says, for when no account matched its email.
95+ let author_name: Option<String> = sqlx::query_scalar(
96+ "SELECT r.author_name FROM revisions r
97+ JOIN changes c ON c.head_revision_id = r.id WHERE c.id = $1",
98+ )
99+ .bind(change.id)
100+ .fetch_optional(&state.db)
101+ .await?;
102+
93103 // The author of a change manages it even without the maintain role — it is
94104 // their work, and requiring a maintainer to retitle your own change would be
95105 // absurd. Everything else still needs the role.
96106 let is_author = matches!((user, &author), (Some(u), Some(a)) if &u.handle == a);
97107 let can_manage = ctx.access.can_manage_changes() || is_author;
98108
99 Ok(Ok(Loaded { ctx, change, revisions, author, can_manage }))
109+ Ok(Ok(Loaded { ctx, change, revisions, author, author_name, can_manage }))
100110}
101111
102112impl Loaded {
@@ −110,6 +120,7 @@
110120 conflicted: self.change.conflicted,
111121 target_bookmark: &self.change.target_bookmark,
112122 author: self.author.as_deref(),
123+ author_name: self.author_name.as_deref(),
113124 revision_count: self.revisions.len(),
114125 can_manage: self.can_manage,
115126 can_comment,
Mcrates/df-web/src/views/change.rs+8−1
@@ −18,6 +18,8 @@
1818 pub conflicted: bool,
1919 pub updated_at: DateTime<Utc>,
2020 pub author: Option<String>,
21+ /// The name the commit itself carries, used when no account matched.
22+ pub author_name: Option<String>,
2123 pub revision_count: i64,
2224 /// Changes stacked directly on top of this one.
2325 pub children: Vec<String>,
@@ −110,7 +112,12 @@
110112 div .row style="margin-top:6px;gap:10px" {
111113 (change_chip(&r.change_id, r.synthetic))
112114 span .faint { "#" (r.number) }
113 @if let Some(a) = &r.author { span .faint { (a) } }
115+ @match (r.author.as_deref(), r.author_name.as_deref()) {
116+ (Some(h), _) => a .faint href=(format!("/{h}")) { (h) },
117+ (None, Some(n)) => span .faint
118+ title="this commit is not linked to a Dogfood account" { (n) },
119+ (None, None) => {}
120+ }
114121 // Revision count is the visible payoff of stable
115122 // identity: one review, many rewrites.
116123 @if r.revision_count > 1 {
Mcrates/df-web/src/views/pages.rs+33−10
@@ −20,9 +20,32 @@
2020 /// `None` for a change pushed by somebody with no Dogfood account — the
2121 /// indexer records those, and dropping the row would misrepresent activity.
2222 pub author: Option<String>,
23+ /// The name the commit itself carries, used when no account matched.
24+ pub author_name: Option<String>,
2325 pub when: DateTime<Utc>,
2426}
2527
28+/// Render whoever is responsible for something.
29+///
30+/// Three cases, in descending order of what we actually know:
31+/// a matched account links to its profile; an unmatched commit shows the name
32+/// git recorded, which is a real person even though we cannot link them; and
33+/// only a commit with no author name at all falls through to "someone".
34+///
35+/// The middle case is the common one on a fresh instance — an account links to
36+/// commits by email, and until the account records an email nothing matches —
37+/// so showing the git name rather than "someone" is the difference between a
38+/// readable history and an anonymous one.
39+pub fn actor(handle: Option<&str>, name: Option<&str>) -> Markup {
40+ html! {
41+ @match (handle, name.map(str::trim).filter(|n| !n.is_empty())) {
42+ (Some(h), _) => a .feed-actor href=(format!("/{h}")) { (h) },
43+ (None, Some(n)) => span .feed-actor title="this commit is not linked to a Dogfood account" { (n) },
44+ (None, None) => span .feed-actor.faint { "someone" },
45+ }
46+ }
47+}
48+
2649/// The pitch, stated as a diff. Deliberately not configurable: it is copy.
2750const COMPARISONS: &[(&str, &str)] = &[
2851 ("detached HEAD, lost work", "every state is recoverable"),
@@ −170,15 +193,11 @@
170193
171194 html! {
172195 li .feed-row {
173 (avatar(item.author.as_deref().unwrap_or("?")))
196+ (avatar(item.author.as_deref().or(item.author_name.as_deref()).unwrap_or("?")))
174197 div .feed-main {
175198 a .feed-title href=(href) { (item.title) }
176199 div .feed-meta {
177 @if let Some(a) = &item.author {
178 span .feed-actor { (a) }
179 } @else {
180 span .feed-actor.faint { "someone" }
181 }
200+ (actor(item.author.as_deref(), item.author_name.as_deref()))
182201 span .faint { "in" }
183202 a .mono href=(format!("/{}/{}", item.owner, item.repo)) {
184203 (item.owner) "/" (item.repo)
@@ −225,6 +244,8 @@
225244 pub state: String,
226245 pub conflicted: bool,
227246 pub author: Option<String>,
247+ /// The name the commit itself carries, used when no account matched.
248+ pub author_name: Option<String>,
228249 pub updated_at: DateTime<Utc>,
229250}
230251
@@ −332,12 +353,11 @@
332353
333354 html! {
334355 li .feed-row {
356+ (avatar(c.author.as_deref().or(c.author_name.as_deref()).unwrap_or("?")))
335357 div .feed-main {
336358 a .feed-title href=(href) { (c.title) }
337359 div .feed-meta {
338 @if let Some(a) = &c.author {
339 span .feed-actor { (a) }
340 }
360+ (actor(c.author.as_deref(), c.author_name.as_deref()))
341361 span .faint { "in" }
342362 a .mono href=(format!("/{}/{}", c.owner, c.repo)) {
343363 (c.owner) "/" (c.repo)
@@ −361,7 +381,10 @@
361381
362382 html! {
363383 li .activity-row {
364 span .activity-actor { (a.actor.as_deref().unwrap_or("someone")) }
384+ @match a.actor.as_deref() {
385+ Some(h) => a .activity-actor href=(format!("/{h}")) { (h) },
386+ None => span .activity-actor.faint { "someone" },
387+ }
365388 span .dim { " " (activity_verb(&a.kind)) " " }
366389 @if let (Some(n), Some(t)) = (a.change_number, &a.change_title) {
367390 a href=(format!("{repo_href}/changes/{n}")) { (t) }
Mcrates/df-web/src/views/review.rs+8−1
@@ −30,6 +30,8 @@
3030 pub conflicted: bool,
3131 pub target_bookmark: &'a str,
3232 pub author: Option<&'a str>,
33+ /// The name the commit itself carries, used when no account matched.
34+ pub author_name: Option<&'a str>,
3335 pub revision_count: usize,
3436 /// Whether the viewer may edit the change (author or maintainer).
3537 pub can_manage: bool,
@@ −50,7 +52,12 @@
5052 div .row style="margin-top:8px;gap:10px" {
5153 (change_chip(c.change_id, c.synthetic))
5254 span .faint { "#" (c.number) }
53 @if let Some(a) = c.author { span .faint { "by " (a) } }
55+ @match (c.author, c.author_name) {
56+ (Some(h), _) => span .faint { "by " a href=(format!("/{h}")) { (h) } },
57+ (None, Some(n)) => span .faint
58+ title="this commit is not linked to a Dogfood account" { "by " (n) },
59+ (None, None) => {}
60+ }
5461 span .faint { "into" }
5562 span .chip { (c.target_bookmark) }
5663 @if c.revision_count > 1 {