owzkxxuxzulumerged#3
attribute changes to their author, and index SSH pushes
MCargo.lock+1−0
| @@ −1098,6 +1098,7 @@ | |||
| 1098 | 1098 | "dotenvy", | |
| 1099 | 1099 | "rand 0.8.7", | |
| 1100 | 1100 | "russh", | |
| 1101 | + | "serde_json", | |
| 1101 | 1102 | "sqlx", | |
| 1102 | 1103 | "tokio", | |
| 1103 | 1104 | "tracing", | |
Mcrates/df-ssh/Cargo.toml+1−0
| @@ −17,6 +17,7 @@ | |||
| 17 | 17 | dotenvy.workspace = true | |
| 18 | 18 | rand.workspace = true | |
| 19 | 19 | russh.workspace = true | |
| 20 | + | serde_json.workspace = true | |
| 20 | 21 | sqlx.workspace = true | |
| 21 | 22 | tokio.workspace = true | |
| 22 | 23 | tracing.workspace = true | |
Mcrates/df-auth/src/provisioning.rs+41−0
| @@ −89,6 +89,47 @@ | |||
| 89 | 89 | Ok(u) | |
| 90 | 90 | } | |
| 91 | 91 | ||
| 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 | + | ||
| 92 | 133 | /// Whether an open invitation exists for this email. | |
| 93 | 134 | pub async fn has_invitation(db: &PgPool, email: &str) -> Result<bool> { | |
| 94 | 135 | let found: Option<(Uuid,)> = | |
Mcrates/df-ssh/src/main.rs+1−0
| @@ −259,6 +259,7 @@ | |||
| 259 | 259 | channel, | |
| 260 | 260 | request.service, | |
| 261 | 261 | &dir, | |
| 262 | + | &self.db, | |
| 262 | 263 | &self.database_url, | |
| 263 | 264 | &self.hook_binary, | |
| 264 | 265 | resolved.repo_id, | |
Mcrates/df-ssh/src/repo.rs+40−0
| @@ −113,6 +113,7 @@ | |||
| 113 | 113 | channel: ChannelId, | |
| 114 | 114 | service: Service, | |
| 115 | 115 | dir: &Path, | |
| 116 | + | db: &PgPool, | |
| 116 | 117 | database_url: &str, | |
| 117 | 118 | hook_binary: &str, | |
| 118 | 119 | repo_id: Uuid, | |
| @@ −192,6 +193,7 @@ | |||
| 192 | 193 | // Reap the child and close the channel once it exits. This runs detached so | |
| 193 | 194 | // `exec_request` can return and the handler can keep delivering client data | |
| 194 | 195 | // to the stdin we hand back. | |
| 196 | + | let db = db.clone(); | |
| 195 | 197 | tokio::spawn(async move { | |
| 196 | 198 | let status = match child.wait().await { | |
| 197 | 199 | Ok(s) => s, | |
| @@ −204,6 +206,28 @@ | |||
| 204 | 206 | let _ = out_task.await; | |
| 205 | 207 | let _ = err_task.await; | |
| 206 | 208 | ||
| 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 | + | ||
| 207 | 231 | let code = status.code().unwrap_or(1) as u32; | |
| 208 | 232 | let _ = handle.exit_status_request(channel, code).await; | |
| 209 | 233 | let _ = handle.eof(channel).await; | |
| @@ −213,6 +237,22 @@ | |||
| 213 | 237 | Ok(stdin) | |
| 214 | 238 | } | |
| 215 | 239 | ||
| 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 | + | ||
| 216 | 256 | #[cfg(test)] | |
| 217 | 257 | mod tests { | |
| 218 | 258 | use super::*; | |
Mcrates/df-worker/src/index_push.rs+76−6
| @@ −92,6 +92,9 @@ | |||
| 92 | 92 | ||
| 93 | 93 | let mut change_rows: HashMap<String, Uuid> = HashMap::new(); | |
| 94 | 94 | 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(); | |
| 95 | 98 | ||
| 96 | 99 | for c in &commit_list { | |
| 97 | 100 | // jj commits carry their change id. Plain-git commits do not, and get a | |
| @@ −113,6 +116,8 @@ | |||
| 113 | 116 | continue; | |
| 114 | 117 | } | |
| 115 | 118 | ||
| 119 | + | let author_user_id = resolve_author(db, &mut author_cache, &c.author_email).await; | |
| 120 | + | ||
| 116 | 121 | let change_uuid = upsert_change( | |
| 117 | 122 | db, | |
| 118 | 123 | repo_id, | |
| @@ −121,6 +126,7 @@ | |||
| 121 | 126 | &default_bookmark, | |
| 122 | 127 | merged.contains(&change_id), | |
| 123 | 128 | synthetic, | |
| 129 | + | author_user_id, | |
| 124 | 130 | ) | |
| 125 | 131 | .await?; | |
| 126 | 132 | ||
| @@ −144,6 +150,9 @@ | |||
| 144 | 150 | emit_event( | |
| 145 | 151 | db, | |
| 146 | 152 | repo_id, | |
| 153 | + | // Genuinely nobody's action: the indexer | |
| 154 | + | // re-anchored these, not a person. | |
| 155 | + | None, | |
| 147 | 156 | "comments.rebased", | |
| 148 | 157 | change_uuid, | |
| 149 | 158 | serde_json::json!({ | |
| @@ −165,9 +174,15 @@ | |||
| 165 | 174 | } | |
| 166 | 175 | } | |
| 167 | 176 | ||
| 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). | |
| 168 | 182 | emit_event( | |
| 169 | 183 | db, | |
| 170 | 184 | repo_id, | |
| 185 | + | pushed_by.or(author_user_id), | |
| 171 | 186 | "change.pushed", | |
| 172 | 187 | change_uuid, | |
| 173 | 188 | serde_json::json!({ "rev": c.rev }), | |
| @@ −178,6 +193,7 @@ | |||
| 178 | 193 | emit_event( | |
| 179 | 194 | db, | |
| 180 | 195 | repo_id, | |
| 196 | + | pushed_by.or(author_user_id), | |
| 181 | 197 | "change.conflicted", | |
| 182 | 198 | change_uuid, | |
| 183 | 199 | serde_json::json!({ "rev": c.rev }), | |
| @@ −255,6 +271,48 @@ | |||
| 255 | 271 | ) | |
| 256 | 272 | } | |
| 257 | 273 | ||
| 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 | + | ||
| 258 | 316 | /// Insert or update the `changes` row, returning its id. | |
| 259 | 317 | async fn upsert_change( | |
| 260 | 318 | db: &PgPool, | |
| @@ −264,6 +322,7 @@ | |||
| 264 | 322 | default_bookmark: &str, | |
| 265 | 323 | on_target: bool, | |
| 266 | 324 | synthetic: bool, | |
| 325 | + | author_user_id: Option<Uuid>, | |
| 267 | 326 | ) -> Result<Uuid> { | |
| 268 | 327 | let existing: Option<(Uuid, ChangeStateSql)> = sqlx::query_as( | |
| 269 | 328 | "SELECT id, state FROM changes WHERE repo_id = $1 AND change_id = $2", | |
| @@ −282,9 +341,14 @@ | |||
| 282 | 341 | let next = indexer::next_state(state.into(), on_target); | |
| 283 | 342 | ||
| 284 | 343 | 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. | |
| 285 | 348 | "UPDATE changes | |
| 286 | 349 | SET title = $2, description = $3, conflicted = $4, | |
| 287 | 350 | state = $5::change_state, | |
| 351 | + | author_user_id = COALESCE(author_user_id, $6), | |
| 288 | 352 | merged_at = CASE WHEN $5 = 'merged' AND merged_at IS NULL | |
| 289 | 353 | THEN now() ELSE merged_at END, | |
| 290 | 354 | updated_at = now() | |
| @@ −295,6 +359,7 @@ | |||
| 295 | 359 | .bind(description) | |
| 296 | 360 | .bind(c.conflicted) | |
| 297 | 361 | .bind(state_str(next)) | |
| 362 | + | .bind(author_user_id) | |
| 298 | 363 | .execute(db) | |
| 299 | 364 | .await?; | |
| 300 | 365 | ||
| @@ −322,8 +387,9 @@ | |||
| 322 | 387 | // is what keeps the job idempotent. | |
| 323 | 388 | let inserted: Option<(Uuid,)> = sqlx::query_as( | |
| 324 | 389 | "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, | |
| 327 | 393 | CASE WHEN $7 = 'merged' THEN now() ELSE NULL END) | |
| 328 | 394 | ON CONFLICT (repo_id, change_id) DO NOTHING | |
| 329 | 395 | RETURNING id", | |
| @@ −338,6 +404,7 @@ | |||
| 338 | 404 | .bind(c.conflicted) | |
| 339 | 405 | .bind(default_bookmark) | |
| 340 | 406 | .bind(synthetic) | |
| 407 | + | .bind(author_user_id) | |
| 341 | 408 | .fetch_optional(&mut *tx) | |
| 342 | 409 | .await?; | |
| 343 | 410 | ||
| @@ −347,11 +414,12 @@ | |||
| 347 | 414 | Some((id,)) => { | |
| 348 | 415 | // Timeline event for a newly seen change. | |
| 349 | 416 | 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)", | |
| 352 | 419 | ) | |
| 353 | 420 | .bind(new_id()) | |
| 354 | 421 | .bind(repo_id) | |
| 422 | + | .bind(author_user_id) | |
| 355 | 423 | .bind(id) | |
| 356 | 424 | .bind(serde_json::json!({ "change_id": change_id })) | |
| 357 | 425 | .execute(db) | |
| @@ −457,16 +525,18 @@ | |||
| 457 | 525 | async fn emit_event( | |
| 458 | 526 | db: &PgPool, | |
| 459 | 527 | repo_id: Uuid, | |
| 528 | + | actor: Option<Uuid>, | |
| 460 | 529 | kind: &str, | |
| 461 | 530 | change_uuid: Uuid, | |
| 462 | 531 | payload: serde_json::Value, | |
| 463 | 532 | ) { | |
| 464 | 533 | 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)", | |
| 467 | 536 | ) | |
| 468 | 537 | .bind(new_id()) | |
| 469 | 538 | .bind(repo_id) | |
| 539 | + | .bind(actor) | |
| 470 | 540 | .bind(kind) | |
| 471 | 541 | .bind(change_uuid) | |
| 472 | 542 | .bind(payload) | |
Mcrates/df-web/src/routes/auth.rs+7−0
| @@ −109,6 +109,13 @@ | |||
| 109 | 109 | ||
| 110 | 110 | // Returning user: sign straight in. | |
| 111 | 111 | 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 | + | } | |
| 112 | 119 | let jar = | |
| 113 | 120 | establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref()) | |
| 114 | 121 | .await?; | |
Mcrates/df-web/src/routes/change.rs+19−1
| @@ −83,9 +83,12 @@ | |||
| 83 | 83 | revset_vals: Vec<String>, | |
| 84 | 84 | ) -> AppResult<Vec<v::ChangeRow>> { | |
| 85 | 85 | 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. | |
| 86 | 88 | "SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text, | |
| 87 | 89 | c.conflicted, c.updated_at, | |
| 88 | 90 | u.handle::text AS author, | |
| 91 | + | hr.author_name, | |
| 89 | 92 | (SELECT count(*) FROM revisions rr WHERE rr.change_id_fk = c.id) AS revcount, | |
| 90 | 93 | COALESCE(( | |
| 91 | 94 | SELECT array_agg(pc.change_id) | |
| @@ −95,6 +98,7 @@ | |||
| 95 | 98 | ), '{}') AS children | |
| 96 | 99 | FROM changes c | |
| 97 | 100 | LEFT JOIN users u ON u.id = c.author_user_id | |
| 101 | + | LEFT JOIN revisions hr ON hr.id = c.head_revision_id | |
| 98 | 102 | WHERE c.repo_id = $1 | |
| 99 | 103 | AND ($2 = 'all' OR c.state::text = $2)", | |
| 100 | 104 | ); | |
| @@ −116,6 +120,7 @@ | |||
| 116 | 120 | bool, | |
| 117 | 121 | chrono::DateTime<chrono::Utc>, | |
| 118 | 122 | Option<String>, | |
| 123 | + | Option<String>, | |
| 119 | 124 | i64, | |
| 120 | 125 | Vec<String>, | |
| 121 | 126 | ), | |
| @@ −132,7 +137,19 @@ | |||
| 132 | 137 | Ok(rows | |
| 133 | 138 | .into_iter() | |
| 134 | 139 | .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 | + | )| { | |
| 136 | 153 | v::ChangeRow { | |
| 137 | 154 | number, | |
| 138 | 155 | change_id, | |
| @@ −142,6 +159,7 @@ | |||
| 142 | 159 | conflicted, | |
| 143 | 160 | updated_at, | |
| 144 | 161 | author, | |
| 162 | + | author_name, | |
| 145 | 163 | revision_count: revcount, | |
| 146 | 164 | children, | |
| 147 | 165 | } | |
Mcrates/df-web/src/routes/home.rs+48−16
| @@ −156,9 +156,20 @@ | |||
| 156 | 156 | /// Public repositories only, and no drafts: this renders for anonymous | |
| 157 | 157 | /// visitors, so anything it can reach is world-readable by definition. | |
| 158 | 158 | async 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#" | |
| 162 | 173 | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 163 | 174 | r.name::text, | |
| 164 | 175 | c.number, | |
| @@ −166,34 +177,39 @@ | |||
| 166 | 177 | c.synthetic, | |
| 167 | 178 | c.title, | |
| 168 | 179 | au.handle AS author, | |
| 180 | + | hr.author_name, | |
| 169 | 181 | c.updated_at | |
| 170 | 182 | FROM changes c | |
| 171 | 183 | JOIN repos r ON r.id = c.repo_id | |
| 172 | 184 | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 173 | 185 | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 174 | 186 | LEFT JOIN users au ON au.id = c.author_user_id | |
| 187 | + | LEFT JOIN revisions hr ON hr.id = c.head_revision_id | |
| 175 | 188 | WHERE r.archived = false | |
| 176 | 189 | AND r.visibility = 'public' | |
| 177 | 190 | AND c.state <> 'draft' | |
| 178 | 191 | ORDER BY c.updated_at DESC | |
| 179 | 192 | LIMIT 12 | |
| 180 | 193 | "#, | |
| 181 | − | ) | |
| 182 | − | .fetch_all(&state.db) | |
| 183 | − | .await?; | |
| 194 | + | ) | |
| 195 | + | .fetch_all(&state.db) | |
| 196 | + | .await?; | |
| 184 | 197 | ||
| 185 | 198 | Ok(rows | |
| 186 | 199 | .into_iter() | |
| 187 | 200 | .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 | + | } | |
| 197 | 213 | }, | |
| 198 | 214 | ) | |
| 199 | 215 | .collect()) | |
| @@ −209,13 +225,26 @@ | |||
| 209 | 225 | String, | |
| 210 | 226 | bool, | |
| 211 | 227 | Option<String>, | |
| 228 | + | Option<String>, | |
| 212 | 229 | DateTime<Utc>, | |
| 213 | 230 | ); | |
| 214 | 231 | ||
| 215 | 232 | fn to_dash_changes(rows: Vec<ChangeRow>) -> Vec<DashChange> { | |
| 216 | 233 | rows.into_iter() | |
| 217 | 234 | .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 | + | )| { | |
| 219 | 248 | DashChange { | |
| 220 | 249 | owner, | |
| 221 | 250 | repo, | |
| @@ −226,6 +255,7 @@ | |||
| 226 | 255 | state, | |
| 227 | 256 | conflicted, | |
| 228 | 257 | author, | |
| 258 | + | author_name, | |
| 229 | 259 | updated_at, | |
| 230 | 260 | } | |
| 231 | 261 | }, | |
| @@ −243,12 +273,14 @@ | |||
| 243 | 273 | c.state::text, | |
| 244 | 274 | c.conflicted, | |
| 245 | 275 | au.handle AS author, | |
| 276 | + | hr.author_name, | |
| 246 | 277 | c.updated_at | |
| 247 | 278 | FROM changes c | |
| 248 | 279 | JOIN repos r ON r.id = c.repo_id | |
| 249 | 280 | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 250 | 281 | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 251 | 282 | LEFT JOIN users au ON au.id = c.author_user_id | |
| 283 | + | LEFT JOIN revisions hr ON hr.id = c.head_revision_id | |
| 252 | 284 | "#; | |
| 253 | 285 | ||
| 254 | 286 | /// Open changes waiting on this viewer. | |
Mcrates/df-web/src/routes/review.rs+12−1
| @@ −38,6 +38,7 @@ | |||
| 38 | 38 | /// (seq, rev), oldest first. | |
| 39 | 39 | revisions: Vec<(i32, String)>, | |
| 40 | 40 | author: Option<String>, | |
| 41 | + | author_name: Option<String>, | |
| 41 | 42 | can_manage: bool, | |
| 42 | 43 | } | |
| 43 | 44 | ||
| @@ −90,13 +91,22 @@ | |||
| 90 | 91 | .fetch_optional(&state.db) | |
| 91 | 92 | .await?; | |
| 92 | 93 | ||
| 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 | + | ||
| 93 | 103 | // The author of a change manages it even without the maintain role — it is | |
| 94 | 104 | // their work, and requiring a maintainer to retitle your own change would be | |
| 95 | 105 | // absurd. Everything else still needs the role. | |
| 96 | 106 | let is_author = matches!((user, &author), (Some(u), Some(a)) if &u.handle == a); | |
| 97 | 107 | let can_manage = ctx.access.can_manage_changes() || is_author; | |
| 98 | 108 | ||
| 99 | − | Ok(Ok(Loaded { ctx, change, revisions, author, can_manage })) | |
| 109 | + | Ok(Ok(Loaded { ctx, change, revisions, author, author_name, can_manage })) | |
| 100 | 110 | } | |
| 101 | 111 | ||
| 102 | 112 | impl Loaded { | |
| @@ −110,6 +120,7 @@ | |||
| 110 | 120 | conflicted: self.change.conflicted, | |
| 111 | 121 | target_bookmark: &self.change.target_bookmark, | |
| 112 | 122 | author: self.author.as_deref(), | |
| 123 | + | author_name: self.author_name.as_deref(), | |
| 113 | 124 | revision_count: self.revisions.len(), | |
| 114 | 125 | can_manage: self.can_manage, | |
| 115 | 126 | can_comment, | |
Mcrates/df-web/src/views/change.rs+8−1
| @@ −18,6 +18,8 @@ | |||
| 18 | 18 | pub conflicted: bool, | |
| 19 | 19 | pub updated_at: DateTime<Utc>, | |
| 20 | 20 | pub author: Option<String>, | |
| 21 | + | /// The name the commit itself carries, used when no account matched. | |
| 22 | + | pub author_name: Option<String>, | |
| 21 | 23 | pub revision_count: i64, | |
| 22 | 24 | /// Changes stacked directly on top of this one. | |
| 23 | 25 | pub children: Vec<String>, | |
| @@ −110,7 +112,12 @@ | |||
| 110 | 112 | div .row style="margin-top:6px;gap:10px" { | |
| 111 | 113 | (change_chip(&r.change_id, r.synthetic)) | |
| 112 | 114 | 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 | + | } | |
| 114 | 121 | // Revision count is the visible payoff of stable | |
| 115 | 122 | // identity: one review, many rewrites. | |
| 116 | 123 | @if r.revision_count > 1 { | |
Mcrates/df-web/src/views/pages.rs+33−10
| @@ −20,9 +20,32 @@ | |||
| 20 | 20 | /// `None` for a change pushed by somebody with no Dogfood account — the | |
| 21 | 21 | /// indexer records those, and dropping the row would misrepresent activity. | |
| 22 | 22 | pub author: Option<String>, | |
| 23 | + | /// The name the commit itself carries, used when no account matched. | |
| 24 | + | pub author_name: Option<String>, | |
| 23 | 25 | pub when: DateTime<Utc>, | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 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 | + | ||
| 26 | 49 | /// The pitch, stated as a diff. Deliberately not configurable: it is copy. | |
| 27 | 50 | const COMPARISONS: &[(&str, &str)] = &[ | |
| 28 | 51 | ("detached HEAD, lost work", "every state is recoverable"), | |
| @@ −170,15 +193,11 @@ | |||
| 170 | 193 | ||
| 171 | 194 | html! { | |
| 172 | 195 | 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("?"))) | |
| 174 | 197 | div .feed-main { | |
| 175 | 198 | a .feed-title href=(href) { (item.title) } | |
| 176 | 199 | 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())) | |
| 182 | 201 | span .faint { "in" } | |
| 183 | 202 | a .mono href=(format!("/{}/{}", item.owner, item.repo)) { | |
| 184 | 203 | (item.owner) "/" (item.repo) | |
| @@ −225,6 +244,8 @@ | |||
| 225 | 244 | pub state: String, | |
| 226 | 245 | pub conflicted: bool, | |
| 227 | 246 | pub author: Option<String>, | |
| 247 | + | /// The name the commit itself carries, used when no account matched. | |
| 248 | + | pub author_name: Option<String>, | |
| 228 | 249 | pub updated_at: DateTime<Utc>, | |
| 229 | 250 | } | |
| 230 | 251 | ||
| @@ −332,12 +353,11 @@ | |||
| 332 | 353 | ||
| 333 | 354 | html! { | |
| 334 | 355 | li .feed-row { | |
| 356 | + | (avatar(c.author.as_deref().or(c.author_name.as_deref()).unwrap_or("?"))) | |
| 335 | 357 | div .feed-main { | |
| 336 | 358 | a .feed-title href=(href) { (c.title) } | |
| 337 | 359 | 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())) | |
| 341 | 361 | span .faint { "in" } | |
| 342 | 362 | a .mono href=(format!("/{}/{}", c.owner, c.repo)) { | |
| 343 | 363 | (c.owner) "/" (c.repo) | |
| @@ −361,7 +381,10 @@ | |||
| 361 | 381 | ||
| 362 | 382 | html! { | |
| 363 | 383 | 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 | + | } | |
| 365 | 388 | span .dim { " " (activity_verb(&a.kind)) " " } | |
| 366 | 389 | @if let (Some(n), Some(t)) = (a.change_number, &a.change_title) { | |
| 367 | 390 | a href=(format!("{repo_href}/changes/{n}")) { (t) } | |
Mcrates/df-web/src/views/review.rs+8−1
| @@ −30,6 +30,8 @@ | |||
| 30 | 30 | pub conflicted: bool, | |
| 31 | 31 | pub target_bookmark: &'a str, | |
| 32 | 32 | pub author: Option<&'a str>, | |
| 33 | + | /// The name the commit itself carries, used when no account matched. | |
| 34 | + | pub author_name: Option<&'a str>, | |
| 33 | 35 | pub revision_count: usize, | |
| 34 | 36 | /// Whether the viewer may edit the change (author or maintainer). | |
| 35 | 37 | pub can_manage: bool, | |
| @@ −50,7 +52,12 @@ | |||
| 50 | 52 | div .row style="margin-top:8px;gap:10px" { | |
| 51 | 53 | (change_chip(c.change_id, c.synthetic)) | |
| 52 | 54 | 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 | + | } | |
| 54 | 61 | span .faint { "into" } | |
| 55 | 62 | span .chip { (c.target_bookmark) } | |
| 56 | 63 | @if c.revision_count > 1 { | |