11193927
Show live issue claims in the web UI
a73x 2026-09-06 08:09
Commit message
src/server/http/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -20,6 +20,9 @@ use std::sync::Arc; | |||
| 20 | pub struct AppState { | 20 | pub struct AppState { |
| 21 | pub repos_dir: PathBuf, | 21 | pub repos_dir: PathBuf, |
| 22 | pub site_title: String, | 22 | pub site_title: String, |
| 23 | /// The collaboration database, read (never written) by these handlers to | ||
| 24 | /// show who has claimed what. | ||
| 25 | pub collab_db: PathBuf, | ||
| 23 | } | 26 | } |
| 24 | 27 | ||
| 25 | pub fn router(state: AppState) -> Router { | 28 | pub fn router(state: AppState) -> Router { |
src/server/http/repo/issues.rs
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,29 @@ use axum::response::{IntoResponse, Response}; | |||
| 5 | 5 | ||
| 6 | use super::{collab_counts, internal_error, not_found, open_repo, AppState, CommentView}; | 6 | use super::{collab_counts, internal_error, not_found, open_repo, AppState, CommentView}; |
| 7 | use crate::http::timestamp::Timestamp; | 7 | use crate::http::timestamp::Timestamp; |
| 8 | use crate::leases; | ||
| 9 | |||
| 10 | /// Every live claim in a repository, as `issue id -> (holder, expiry)`. | ||
| 11 | /// | ||
| 12 | /// A missing or unreadable lease database is treated as "no claims": the | ||
| 13 | /// claim is a hint on a page, and a server that has never served a | ||
| 14 | /// `collab-lease` verb has no database at all. Failing the page over an | ||
| 15 | /// absent hint would be the wrong trade. | ||
| 16 | fn claims_for( | ||
| 17 | state: &AppState, | ||
| 18 | entry: &crate::repos::RepoEntry, | ||
| 19 | ) -> std::collections::HashMap<String, (String, Option<i64>)> { | ||
| 20 | let key = leases::repo_key(&state.repos_dir, &entry.path); | ||
| 21 | let now = chrono::Utc::now().timestamp(); | ||
| 22 | leases::open(&state.collab_db) | ||
| 23 | .and_then(|conn| leases::list(&conn, &key, now)) | ||
| 24 | .map(|rows| { | ||
| 25 | rows.into_iter() | ||
| 26 | .map(|l| (l.issue_id, (l.holder, l.expires_at))) | ||
| 27 | .collect() | ||
| 28 | }) | ||
| 29 | .unwrap_or_default() | ||
| 30 | } | ||
| 8 | 31 | ||
| 9 | /// Which issues to include in the list view. Defaults to `Open` so the list | 32 | /// Which issues to include in the list view. Defaults to `Open` so the list |
| 10 | /// agrees with the nav badge, which only ever counts open issues. Closed and | 33 | /// agrees with the nav badge, which only ever counts open issues. Closed and |
| @@ -48,6 +71,9 @@ pub struct IssueListItem { | |||
| 48 | pub author: String, | 71 | pub author: String, |
| 49 | pub labels: String, | 72 | pub labels: String, |
| 50 | pub updated: Timestamp, | 73 | pub updated: Timestamp, |
| 74 | /// Who holds a live claim on this issue, or empty when nobody does — the | ||
| 75 | /// same string-or-empty convention `labels` uses. | ||
| 76 | pub claimed_by: String, | ||
| 51 | } | 77 | } |
| 52 | 78 | ||
| 53 | #[derive(Debug)] | 79 | #[derive(Debug)] |
| @@ -60,6 +86,10 @@ pub struct IssueDetailView { | |||
| 60 | pub assignees: String, | 86 | pub assignees: String, |
| 61 | pub close_reason: Option<String>, | 87 | pub close_reason: Option<String>, |
| 62 | pub comments: Vec<CommentView>, | 88 | pub comments: Vec<CommentView>, |
| 89 | /// Who holds a live claim, or empty when nobody does. | ||
| 90 | pub claimed_by: String, | ||
| 91 | /// When the claim lapses, or empty for an open-ended one (an assignment). | ||
| 92 | pub claim_expires: String, | ||
| 63 | } | 93 | } |
| 64 | 94 | ||
| 65 | #[derive(askama::Template, askama_web::WebTemplate)] | 95 | #[derive(askama::Template, askama_web::WebTemplate)] |
| @@ -93,12 +123,13 @@ pub async fn issues( | |||
| 93 | Query(query): Query<IssuesQuery>, | 123 | Query(query): Query<IssuesQuery>, |
| 94 | State(state): State<Arc<AppState>>, | 124 | State(state): State<Arc<AppState>>, |
| 95 | ) -> Response { | 125 | ) -> Response { |
| 96 | let (_entry, repo) = match open_repo(&state, &repo_name) { | 126 | let (entry, repo) = match open_repo(&state, &repo_name) { |
| 97 | Ok(r) => r, | 127 | Ok(r) => r, |
| 98 | Err(resp) => return resp, | 128 | Err(resp) => return resp, |
| 99 | }; | 129 | }; |
| 100 | 130 | ||
| 101 | let (open_patches, open_issues) = collab_counts(&repo); | 131 | let (open_patches, open_issues) = collab_counts(&repo); |
| 132 | let claims = claims_for(&state, &entry); | ||
| 102 | 133 | ||
| 103 | let filter = IssueListFilter::from_query(query.filter.as_deref()); | 134 | let filter = IssueListFilter::from_query(query.filter.as_deref()); |
| 104 | 135 | ||
| @@ -133,6 +164,10 @@ pub async fn issues( | |||
| 133 | .map(|i| { | 164 | .map(|i| { |
| 134 | let id = i.id.clone(); | 165 | let id = i.id.clone(); |
| 135 | let short_id = abbrev.of(&id).to_string(); | 166 | let short_id = abbrev.of(&id).to_string(); |
| 167 | let claimed_by = claims | ||
| 168 | .get(&id) | ||
| 169 | .map(|(holder, _)| holder.clone()) | ||
| 170 | .unwrap_or_default(); | ||
| 136 | IssueListItem { | 171 | IssueListItem { |
| 137 | short_id, | 172 | short_id, |
| 138 | id, | 173 | id, |
| @@ -141,6 +176,7 @@ pub async fn issues( | |||
| 141 | author: i.author.name, | 176 | author: i.author.name, |
| 142 | labels: i.labels.join(", "), | 177 | labels: i.labels.join(", "), |
| 143 | updated: Timestamp::new(i.last_updated), | 178 | updated: Timestamp::new(i.last_updated), |
| 179 | claimed_by, | ||
| 144 | } | 180 | } |
| 145 | }) | 181 | }) |
| 146 | .collect(); | 182 | .collect(); |
| @@ -172,7 +208,7 @@ pub async fn issue_detail( | |||
| 172 | Path((repo_name, issue_id)): Path<(String, String)>, | 208 | Path((repo_name, issue_id)): Path<(String, String)>, |
| 173 | State(state): State<Arc<AppState>>, | 209 | State(state): State<Arc<AppState>>, |
| 174 | ) -> Response { | 210 | ) -> Response { |
| 175 | let (_entry, repo) = match open_repo(&state, &repo_name) { | 211 | let (entry, repo) = match open_repo(&state, &repo_name) { |
| 176 | Ok(r) => r, | 212 | Ok(r) => r, |
| 177 | Err(resp) => return resp, | 213 | Err(resp) => return resp, |
| 178 | }; | 214 | }; |
| @@ -189,6 +225,20 @@ pub async fn issue_detail( | |||
| 189 | Err(_) => return internal_error(&state, "Failed to load issue state."), | 225 | Err(_) => return internal_error(&state, "Failed to load issue state."), |
| 190 | }; | 226 | }; |
| 191 | 227 | ||
| 228 | // The one issue's claim, asked for directly rather than filtered out of | ||
| 229 | // the whole repository's list. | ||
| 230 | let claim = leases::open(&state.collab_db) | ||
| 231 | .and_then(|conn| { | ||
| 232 | leases::current( | ||
| 233 | &conn, | ||
| 234 | &leases::repo_key(&state.repos_dir, &entry.path), | ||
| 235 | &full_id, | ||
| 236 | chrono::Utc::now().timestamp(), | ||
| 237 | ) | ||
| 238 | }) | ||
| 239 | .ok() | ||
| 240 | .flatten(); | ||
| 241 | |||
| 192 | let issue = IssueDetailView { | 242 | let issue = IssueDetailView { |
| 193 | title: is.title, | 243 | title: is.title, |
| 194 | body: is.body, | 244 | body: is.body, |
| @@ -197,6 +247,13 @@ pub async fn issue_detail( | |||
| 197 | labels: is.labels.join(", "), | 247 | labels: is.labels.join(", "), |
| 198 | assignees: is.assignees.join(", "), | 248 | assignees: is.assignees.join(", "), |
| 199 | close_reason: is.close_reason, | 249 | close_reason: is.close_reason, |
| 250 | claimed_by: claim.as_ref().map(|l| l.holder.clone()).unwrap_or_default(), | ||
| 251 | claim_expires: claim | ||
| 252 | .as_ref() | ||
| 253 | .and_then(|l| l.expires_at) | ||
| 254 | .and_then(|s| chrono::DateTime::from_timestamp(s, 0)) | ||
| 255 | .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) | ||
| 256 | .unwrap_or_default(), | ||
| 200 | comments: is | 257 | comments: is |
| 201 | .comments | 258 | .comments |
| 202 | .into_iter() | 259 | .into_iter() |
src/server/http/templates/issue_detail.html
| Old | New | ||
|---|---|---|---|
| @@ -8,6 +8,9 @@ | |||
| 8 | <span class="status-{{ issue.status }}">{{ issue.status }}</span> | 8 | <span class="status-{{ issue.status }}">{{ issue.status }}</span> |
| 9 | by <strong>{{ issue.author }}</strong> | 9 | by <strong>{{ issue.author }}</strong> |
| 10 | </p> | 10 | </p> |
| 11 | {% if !issue.claimed_by.is_empty() %} | ||
| 12 | <p style="color: #666;">Claimed by: <strong>{{ issue.claimed_by }}</strong>{% if !issue.claim_expires.is_empty() %} <span class="mono">(expires {{ issue.claim_expires }})</span>{% endif %}</p> | ||
| 13 | {% endif %} | ||
| 11 | {% if !issue.labels.is_empty() %} | 14 | {% if !issue.labels.is_empty() %} |
| 12 | <p style="color: #666;">Labels: {{ issue.labels }}</p> | 15 | <p style="color: #666;">Labels: {{ issue.labels }}</p> |
| 13 | {% endif %} | 16 | {% endif %} |
src/server/http/templates/issues.html
| Old | New | ||
|---|---|---|---|
| @@ -27,6 +27,7 @@ | |||
| 27 | <th>Status</th> | 27 | <th>Status</th> |
| 28 | <th>Title</th> | 28 | <th>Title</th> |
| 29 | <th>Author</th> | 29 | <th>Author</th> |
| 30 | <th>Claimed by</th> | ||
| 30 | <th>Labels</th> | 31 | <th>Labels</th> |
| 31 | <th>Updated</th> | 32 | <th>Updated</th> |
| 32 | </tr> | 33 | </tr> |
| @@ -38,6 +39,7 @@ | |||
| 38 | <td><span class="status-{{ i.status }}">{{ i.status }}</span></td> | 39 | <td><span class="status-{{ i.status }}">{{ i.status }}</span></td> |
| 39 | <td><a href="/{{ repo_name }}/issues/{{ i.id }}">{{ i.title }}</a></td> | 40 | <td><a href="/{{ repo_name }}/issues/{{ i.id }}">{{ i.title }}</a></td> |
| 40 | <td style="color: #666;">{{ i.author }}</td> | 41 | <td style="color: #666;">{{ i.author }}</td> |
| 42 | <td style="color: #666;">{{ i.claimed_by }}</td> | ||
| 41 | <td style="color: #666;">{{ i.labels }}</td> | 43 | <td style="color: #666;">{{ i.labels }}</td> |
| 42 | <td class="mono timestamp" style="color: #666;" title="{{ i.updated.full }}">{{ i.updated.short }}</td> | 44 | <td class="mono timestamp" style="color: #666;" title="{{ i.updated.full }}">{{ i.updated.short }}</td> |
| 43 | </tr> | 45 | </tr> |
src/server/leases.rs
| Old | New | ||
|---|---|---|---|
| @@ -92,6 +92,23 @@ pub enum Release { | |||
| 92 | NotHolder { holder: String }, | 92 | NotHolder { holder: String }, |
| 93 | } | 93 | } |
| 94 | 94 | ||
| 95 | /// The `repo` column value for a repository on disk: its path relative to | ||
| 96 | /// `repos_dir` (so `…/repos/myrepo.git` becomes `myrepo.git`). | ||
| 97 | /// | ||
| 98 | /// One function because two callers need the identical string and they reach | ||
| 99 | /// it from different directions — the SSH verb from a resolved exec path, the | ||
| 100 | /// web UI from a `RepoEntry`. Deriving it twice is how they would drift, and | ||
| 101 | /// the symptom would be a claim that exists over SSH but is invisible on the | ||
| 102 | /// page. Relative rather than absolute so the rows survive `repos_dir` | ||
| 103 | /// moving. | ||
| 104 | pub fn repo_key(repos_dir: &Path, repo_path: &Path) -> String { | ||
| 105 | repo_path | ||
| 106 | .strip_prefix(repos_dir) | ||
| 107 | .unwrap_or(repo_path) | ||
| 108 | .to_string_lossy() | ||
| 109 | .to_string() | ||
| 110 | } | ||
| 111 | |||
| 95 | /// Open (creating if needed) the lease database and ensure the schema. | 112 | /// Open (creating if needed) the lease database and ensure the schema. |
| 96 | pub fn open(path: &Path) -> rusqlite::Result<Connection> { | 113 | pub fn open(path: &Path) -> rusqlite::Result<Connection> { |
| 97 | let conn = Connection::open(path)?; | 114 | let conn = Connection::open(path)?; |
| @@ -542,6 +559,24 @@ mod tests { | |||
| 542 | } | 559 | } |
| 543 | 560 | ||
| 544 | #[test] | 561 | #[test] |
| 562 | fn repo_key_is_relative_to_repos_dir() { | ||
| 563 | assert_eq!( | ||
| 564 | repo_key(Path::new("/srv/git"), Path::new("/srv/git/myrepo.git")), | ||
| 565 | "myrepo.git" | ||
| 566 | ); | ||
| 567 | assert_eq!( | ||
| 568 | repo_key(Path::new("/srv/git"), Path::new("/srv/git/org/sub.git")), | ||
| 569 | "org/sub.git" | ||
| 570 | ); | ||
| 571 | // A path outside repos_dir cannot be made relative; keep it whole | ||
| 572 | // rather than inventing a key that could collide. | ||
| 573 | assert_eq!( | ||
| 574 | repo_key(Path::new("/srv/git"), Path::new("/elsewhere/x.git")), | ||
| 575 | "/elsewhere/x.git" | ||
| 576 | ); | ||
| 577 | } | ||
| 578 | |||
| 579 | #[test] | ||
| 545 | fn open_creates_file_and_schema() { | 580 | fn open_creates_file_and_schema() { |
| 546 | let dir = tempfile::tempdir().unwrap(); | 581 | let dir = tempfile::tempdir().unwrap(); |
| 547 | let path = dir.path().join("collab.db"); | 582 | let path = dir.path().join("collab.db"); |
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -230,6 +230,7 @@ async fn main() { | |||
| 230 | let app_state = http::AppState { | 230 | let app_state = http::AppState { |
| 231 | repos_dir: config.repos_dir.clone(), | 231 | repos_dir: config.repos_dir.clone(), |
| 232 | site_title: config.site_title.clone(), | 232 | site_title: config.site_title.clone(), |
| 233 | collab_db: config.collab_db_path(), | ||
| 233 | }; | 234 | }; |
| 234 | let router = http::router(app_state); | 235 | let router = http::router(app_state); |
| 235 | 236 | ||
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -437,14 +437,9 @@ impl SshHandler { | |||
| 437 | _ => principal.to_string(), | 437 | _ => principal.to_string(), |
| 438 | }; | 438 | }; |
| 439 | 439 | ||
| 440 | // The repo key leases are stored under. `entry_for_path` already | 440 | // The repo key leases are stored under — the same derivation the web |
| 441 | // proved this path is a repo under repos_dir; the relative form keeps | 441 | // UI uses, so a claim made here is the claim shown there. |
| 442 | // rows portable if repos_dir moves. | 442 | let repo_key = crate::leases::repo_key(&self.config.repos_dir, resolved_path); |
| 443 | let repo_key = resolved_path | ||
| 444 | .strip_prefix(&self.config.repos_dir) | ||
| 445 | .unwrap_or(resolved_path) | ||
| 446 | .to_string_lossy() | ||
| 447 | .to_string(); | ||
| 448 | 443 | ||
| 449 | // Acquiring points at a specific issue, so the issue has to exist and | 444 | // Acquiring points at a specific issue, so the issue has to exist and |
| 450 | // be open: leasing work nobody can do is a bug we should refuse, not | 445 | // be open: leasing work nobody can do is a bug we should refuse, not |
tests/web_rendering_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -412,3 +412,96 @@ fn rendering_pages_moves_no_refs_and_appends_no_events() { | |||
| 412 | "rendering pages changed the repository's refs" | 412 | "rendering pages changed the repository's refs" |
| 413 | ); | 413 | ); |
| 414 | } | 414 | } |
| 415 | |||
| 416 | /// A live claim belongs on the page: a claim nobody can see is a claim people | ||
| 417 | /// work around. Both the list and the detail view show the holder, and an | ||
| 418 | /// expired one shows nobody — `refs/collab/*` says nothing about either, so | ||
| 419 | /// this is the only place the lease store and the UI meet. | ||
| 420 | #[test] | ||
| 421 | fn a_live_claim_shows_on_the_issue_pages_and_an_expired_one_does_not() { | ||
| 422 | let harness = ServerHarness::new("render-claims"); | ||
| 423 | harness.push_head(); | ||
| 424 | let (_ref_name, full_id) = common::open_issue( | ||
| 425 | &harness.work_repo_git2(), | ||
| 426 | &common::alice(), | ||
| 427 | "An issue to claim", | ||
| 428 | ); | ||
| 429 | harness.push_collab_refs(); | ||
| 430 | let name = harness.repo_name().to_string(); | ||
| 431 | |||
| 432 | let unclaimed_list = harness.get_ok(&format!("/{name}/issues")); | ||
| 433 | assert!( | ||
| 434 | unclaimed_list.body.contains("Claimed by"), | ||
| 435 | "the column should exist even with no claims" | ||
| 436 | ); | ||
| 437 | |||
| 438 | let acquired = harness.ssh_exec(&format!( | ||
| 439 | "collab-lease acquire '{name}.git' '{full_id}' --ttl 600" | ||
| 440 | )); | ||
| 441 | assert!(acquired.status.success()); | ||
| 442 | let holder = | ||
| 443 | serde_json::from_str::<serde_json::Value>(String::from_utf8_lossy(&acquired.stdout).trim()) | ||
| 444 | .unwrap()["holder"] | ||
| 445 | .as_str() | ||
| 446 | .unwrap() | ||
| 447 | .to_string(); | ||
| 448 | |||
| 449 | let list = harness.get_ok(&format!("/{name}/issues")); | ||
| 450 | assert!( | ||
| 451 | list.body.contains(&holder), | ||
| 452 | "the list should name the holder {holder}: {}", | ||
| 453 | list.body | ||
| 454 | ); | ||
| 455 | |||
| 456 | let detail = harness.get_ok(&format!("/{name}/issues/{full_id}")); | ||
| 457 | assert!( | ||
| 458 | detail.body.contains("Claimed by:") && detail.body.contains(&holder), | ||
| 459 | "the detail page should name the holder: {}", | ||
| 460 | detail.body | ||
| 461 | ); | ||
| 462 | assert!( | ||
| 463 | detail.body.contains("expires"), | ||
| 464 | "a ttl claim should say when it lapses: {}", | ||
| 465 | detail.body | ||
| 466 | ); | ||
| 467 | |||
| 468 | // A 1-second lease, waited out: the row survives (it carries the fencing | ||
| 469 | // token) but the page must stop showing a claim. | ||
| 470 | harness.ssh_exec(&format!("collab-lease release '{name}.git' '{full_id}'")); | ||
| 471 | harness.ssh_exec(&format!( | ||
| 472 | "collab-lease acquire '{name}.git' '{full_id}' --ttl 1" | ||
| 473 | )); | ||
| 474 | std::thread::sleep(std::time::Duration::from_millis(1200)); | ||
| 475 | |||
| 476 | let expired = harness.get_ok(&format!("/{name}/issues/{full_id}")); | ||
| 477 | assert!( | ||
| 478 | !expired.body.contains("Claimed by:"), | ||
| 479 | "an expired claim must not be shown: {}", | ||
| 480 | expired.body | ||
| 481 | ); | ||
| 482 | } | ||
| 483 | |||
| 484 | /// An open-ended claim (no `--ttl`) is an assignment, so it must not claim to | ||
| 485 | /// expire. | ||
| 486 | #[test] | ||
| 487 | fn an_open_ended_claim_shows_no_expiry() { | ||
| 488 | let harness = ServerHarness::new("render-assigned"); | ||
| 489 | harness.push_head(); | ||
| 490 | let (_ref_name, full_id) = common::open_issue( | ||
| 491 | &harness.work_repo_git2(), | ||
| 492 | &common::alice(), | ||
| 493 | "An assigned issue", | ||
| 494 | ); | ||
| 495 | harness.push_collab_refs(); | ||
| 496 | let name = harness.repo_name().to_string(); | ||
| 497 | |||
| 498 | harness.ssh_exec(&format!("collab-lease acquire '{name}.git' '{full_id}'")); | ||
| 499 | |||
| 500 | let detail = harness.get_ok(&format!("/{name}/issues/{full_id}")); | ||
| 501 | assert!(detail.body.contains("Claimed by:")); | ||
| 502 | assert!( | ||
| 503 | !detail.body.contains("expires"), | ||
| 504 | "an open-ended claim must not print an expiry: {}", | ||
| 505 | detail.body | ||
| 506 | ); | ||
| 507 | } | ||