Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +2 −0
| @@ -5611,6 +5611,7 @@ dependencies = [ | |||
| 5611 | "config", | 5611 | "config", |
| 5612 | "flate2", | 5612 | "flate2", |
| 5613 | "git", | 5613 | "git", |
| 5614 | "git2", | ||
| 5614 | "grep", | 5615 | "grep", |
| 5615 | "highlight", | 5616 | "highlight", |
| 5616 | "mail", | 5617 | "mail", |
| @@ -5621,6 +5622,7 @@ dependencies = [ | |||
| 5621 | "serde", | 5622 | "serde", |
| 5622 | "serde_json", | 5623 | "serde_json", |
| 5623 | "store", | 5624 | "store", |
| 5625 | "tempfile", | ||
| 5624 | "thiserror 2.0.19", | 5626 | "thiserror 2.0.19", |
| 5625 | "time", | 5627 | "time", |
| 5626 | "tokio", | 5628 | "tokio", |
assets/base.css +53 −0
| @@ -357,6 +357,59 @@ body.drawer-open .drawer-backdrop { | |||
| 357 | color: var(--fb-danger); | 357 | color: var(--fb-danger); |
| 358 | border-color: var(--fb-danger); | 358 | border-color: var(--fb-danger); |
| 359 | } | 359 | } |
| 360 | .badge-state-merged { | ||
| 361 | color: var(--fb-accent); | ||
| 362 | border-color: var(--fb-accent); | ||
| 363 | } | ||
| 364 | |||
| 365 | /* Pull requests: compare form, merge panel, and change list. */ | ||
| 366 | .compare-refs { | ||
| 367 | display: flex; | ||
| 368 | align-items: center; | ||
| 369 | gap: 0.75rem; | ||
| 370 | flex-wrap: wrap; | ||
| 371 | margin-bottom: 1rem; | ||
| 372 | } | ||
| 373 | .compare-refs label { | ||
| 374 | display: inline-flex; | ||
| 375 | align-items: center; | ||
| 376 | gap: 0.4rem; | ||
| 377 | } | ||
| 378 | .compare-refs select { | ||
| 379 | width: auto; | ||
| 380 | } | ||
| 381 | .compare-arrow { | ||
| 382 | font-size: 1.25rem; | ||
| 383 | color: var(--fb-muted); | ||
| 384 | } | ||
| 385 | .merge-panel { | ||
| 386 | border-left: 3px solid var(--fb-border); | ||
| 387 | } | ||
| 388 | .merge-panel.clean { | ||
| 389 | border-left-color: var(--fb-success); | ||
| 390 | } | ||
| 391 | .merge-panel.conflicts { | ||
| 392 | border-left-color: var(--fb-danger); | ||
| 393 | } | ||
| 394 | .merge-panel.merged { | ||
| 395 | border-left-color: var(--fb-accent); | ||
| 396 | } | ||
| 397 | .merge-form { | ||
| 398 | display: flex; | ||
| 399 | align-items: center; | ||
| 400 | gap: 0.5rem; | ||
| 401 | flex-wrap: wrap; | ||
| 402 | margin-top: 0.5rem; | ||
| 403 | } | ||
| 404 | .merge-form select { | ||
| 405 | width: auto; | ||
| 406 | } | ||
| 407 | .pr-changes { | ||
| 408 | margin-top: 1.5rem; | ||
| 409 | } | ||
| 410 | .pr-commits { | ||
| 411 | margin-bottom: 1rem; | ||
| 412 | } | ||
| 360 | .issue-header { | 413 | .issue-header { |
| 361 | margin-bottom: 1rem; | 414 | margin-bottom: 1rem; |
| 362 | padding-bottom: 0.75rem; | 415 | padding-bottom: 0.75rem; |
crates/git/src/repo.rs +45 −0
| @@ -307,6 +307,51 @@ impl Repo { | |||
| 307 | Ok(out) | 307 | Ok(out) |
| 308 | } | 308 | } |
| 309 | 309 | ||
| 310 | /// The best common ancestor of two commits (the merge base), or `None` if they | ||
| 311 | /// have unrelated histories. | ||
| 312 | /// | ||
| 313 | /// # Errors | ||
| 314 | /// | ||
| 315 | /// Returns [`GitError::Libgit2`] on a lookup failure. | ||
| 316 | pub fn merge_base(&self, a: &Oid, b: &Oid) -> Result<Option<Oid>, GitError> { | ||
| 317 | let a = git2::Oid::from_str(a.as_str())?; | ||
| 318 | let b = git2::Oid::from_str(b.as_str())?; | ||
| 319 | match self.inner.merge_base(a, b) { | ||
| 320 | Ok(oid) => Ok(Some(oid_of(oid))), | ||
| 321 | Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), | ||
| 322 | Err(e) => Err(e.into()), | ||
| 323 | } | ||
| 324 | } | ||
| 325 | |||
| 326 | /// The commits reachable from `head` but not `base`, newest first — the commits | ||
| 327 | /// a pull request would contribute. Capped at `limit`. | ||
| 328 | /// | ||
| 329 | /// # Errors | ||
| 330 | /// | ||
| 331 | /// Returns [`GitError::Libgit2`] on a revwalk failure. | ||
| 332 | pub fn commits_between( | ||
| 333 | &self, | ||
| 334 | base: &Oid, | ||
| 335 | head: &Oid, | ||
| 336 | limit: usize, | ||
| 337 | ) -> Result<Vec<CommitSummary>, GitError> { | ||
| 338 | let base = git2::Oid::from_str(base.as_str())?; | ||
| 339 | let head = git2::Oid::from_str(head.as_str())?; | ||
| 340 | let mut walk = self.inner.revwalk()?; | ||
| 341 | walk.set_sorting(Sort::TIME)?; | ||
| 342 | walk.push(head)?; | ||
| 343 | walk.hide(base)?; | ||
| 344 | let mut out = Vec::with_capacity(limit.min(64)); | ||
| 345 | for oid in walk { | ||
| 346 | let commit = self.inner.find_commit(oid?)?; | ||
| 347 | out.push(commit_summary(&commit)); | ||
| 348 | if out.len() >= limit { | ||
| 349 | break; | ||
| 350 | } | ||
| 351 | } | ||
| 352 | Ok(out) | ||
| 353 | } | ||
| 354 | |||
| 310 | /// Fetch a single commit in full. | 355 | /// Fetch a single commit in full. |
| 311 | /// | 356 | /// |
| 312 | /// # Errors | 357 | /// # Errors |
crates/store/src/issues.rs +140 −1
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | ||
| 5 | //! Issues and pull requests (shared `issues` table), comments, and issue labels. | 5 | //! Issues and pull requests (shared `issues` table), comments, and issue labels. |
| 6 | 6 | ||
| 7 | use model::{Comment, Issue, IssueState, Label}; | 7 | use model::{Comment, Issue, IssueState, Label, PullRequest}; |
| 8 | use sqlx::any::AnyRow; | 8 | use sqlx::any::AnyRow; |
| 9 | use sqlx::{AssertSqlSafe, Row}; | 9 | use sqlx::{AssertSqlSafe, Row}; |
| 10 | 10 | ||
| @@ -387,6 +387,145 @@ impl Store { | |||
| 387 | Ok(()) | 387 | Ok(()) |
| 388 | } | 388 | } |
| 389 | 389 | ||
| 390 | // ---- Pull requests ---- | ||
| 391 | |||
| 392 | /// Open a pull request: allocate the shared number, insert the `issues` row | ||
| 393 | /// (`is_pull = true`), and record its base/head refs, all in one transaction. | ||
| 394 | /// | ||
| 395 | /// # Errors | ||
| 396 | /// | ||
| 397 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 398 | pub async fn create_pull( | ||
| 399 | &self, | ||
| 400 | repo_id: &str, | ||
| 401 | author_id: &str, | ||
| 402 | title: &str, | ||
| 403 | body: &str, | ||
| 404 | base_ref: &str, | ||
| 405 | head_ref: &str, | ||
| 406 | ) -> Result<Issue, StoreError> { | ||
| 407 | let mut tx = self.pool.begin().await?; | ||
| 408 | let row = sqlx::query("SELECT next_iid FROM repos WHERE id = $1") | ||
| 409 | .bind(repo_id) | ||
| 410 | .fetch_one(&mut *tx) | ||
| 411 | .await?; | ||
| 412 | let number: i64 = row.try_get("next_iid")?; | ||
| 413 | sqlx::query("UPDATE repos SET next_iid = $1 WHERE id = $2") | ||
| 414 | .bind(number + 1) | ||
| 415 | .bind(repo_id) | ||
| 416 | .execute(&mut *tx) | ||
| 417 | .await?; | ||
| 418 | |||
| 419 | let now = now_ms(); | ||
| 420 | let issue = Issue { | ||
| 421 | id: new_id(), | ||
| 422 | repo_id: repo_id.to_string(), | ||
| 423 | number, | ||
| 424 | author_id: author_id.to_string(), | ||
| 425 | title: title.to_string(), | ||
| 426 | body: body.to_string(), | ||
| 427 | state: IssueState::Open, | ||
| 428 | is_pull: true, | ||
| 429 | assignee_id: None, | ||
| 430 | created_at: now, | ||
| 431 | updated_at: now, | ||
| 432 | closed_at: None, | ||
| 433 | }; | ||
| 434 | sqlx::query( | ||
| 435 | "INSERT INTO issues \ | ||
| 436 | (id, repo_id, number, author_id, title, body, state, is_pull, assignee_id, \ | ||
| 437 | created_at, updated_at, closed_at) \ | ||
| 438 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", | ||
| 439 | ) | ||
| 440 | .bind(&issue.id) | ||
| 441 | .bind(&issue.repo_id) | ||
| 442 | .bind(issue.number) | ||
| 443 | .bind(&issue.author_id) | ||
| 444 | .bind(&issue.title) | ||
| 445 | .bind(&issue.body) | ||
| 446 | .bind(issue.state.as_str()) | ||
| 447 | .bind(issue.is_pull) | ||
| 448 | .bind(&issue.assignee_id) | ||
| 449 | .bind(issue.created_at) | ||
| 450 | .bind(issue.updated_at) | ||
| 451 | .bind(issue.closed_at) | ||
| 452 | .execute(&mut *tx) | ||
| 453 | .await?; | ||
| 454 | sqlx::query("INSERT INTO pull_requests (issue_id, base_ref, head_ref) VALUES ($1, $2, $3)") | ||
| 455 | .bind(&issue.id) | ||
| 456 | .bind(base_ref) | ||
| 457 | .bind(head_ref) | ||
| 458 | .execute(&mut *tx) | ||
| 459 | .await?; | ||
| 460 | tx.commit().await?; | ||
| 461 | Ok(issue) | ||
| 462 | } | ||
| 463 | |||
| 464 | /// Fetch the pull-request row for an issue id, if it is a PR. | ||
| 465 | /// | ||
| 466 | /// # Errors | ||
| 467 | /// | ||
| 468 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 469 | pub async fn pull_by_issue(&self, issue_id: &str) -> Result<Option<PullRequest>, StoreError> { | ||
| 470 | let row = sqlx::query( | ||
| 471 | "SELECT issue_id, base_ref, head_ref, merged_at, merged_by, merge_sha, merge_strategy \ | ||
| 472 | FROM pull_requests WHERE issue_id = $1", | ||
| 473 | ) | ||
| 474 | .bind(issue_id) | ||
| 475 | .fetch_optional(&self.pool) | ||
| 476 | .await?; | ||
| 477 | row.map(|r| { | ||
| 478 | Ok(PullRequest { | ||
| 479 | issue_id: r.try_get("issue_id")?, | ||
| 480 | base_ref: r.try_get("base_ref")?, | ||
| 481 | head_ref: r.try_get("head_ref")?, | ||
| 482 | merged_at: r.try_get("merged_at")?, | ||
| 483 | merged_by: r.try_get("merged_by")?, | ||
| 484 | merge_sha: r.try_get("merge_sha")?, | ||
| 485 | merge_strategy: r.try_get("merge_strategy")?, | ||
| 486 | }) | ||
| 487 | }) | ||
| 488 | .transpose() | ||
| 489 | .map_err(|e: sqlx::Error| e.into()) | ||
| 490 | } | ||
| 491 | |||
| 492 | /// Record a merge: stamp the pull-request row and close the issue, atomically. | ||
| 493 | /// | ||
| 494 | /// # Errors | ||
| 495 | /// | ||
| 496 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 497 | pub async fn mark_pull_merged( | ||
| 498 | &self, | ||
| 499 | issue_id: &str, | ||
| 500 | merged_by: &str, | ||
| 501 | merge_sha: &str, | ||
| 502 | strategy: &str, | ||
| 503 | ) -> Result<(), StoreError> { | ||
| 504 | let now = now_ms(); | ||
| 505 | let mut tx = self.pool.begin().await?; | ||
| 506 | sqlx::query( | ||
| 507 | "UPDATE pull_requests \ | ||
| 508 | SET merged_at = $1, merged_by = $2, merge_sha = $3, merge_strategy = $4 \ | ||
| 509 | WHERE issue_id = $5", | ||
| 510 | ) | ||
| 511 | .bind(now) | ||
| 512 | .bind(merged_by) | ||
| 513 | .bind(merge_sha) | ||
| 514 | .bind(strategy) | ||
| 515 | .bind(issue_id) | ||
| 516 | .execute(&mut *tx) | ||
| 517 | .await?; | ||
| 518 | sqlx::query( | ||
| 519 | "UPDATE issues SET state = 'closed', closed_at = $1, updated_at = $1 WHERE id = $2", | ||
| 520 | ) | ||
| 521 | .bind(now) | ||
| 522 | .bind(issue_id) | ||
| 523 | .execute(&mut *tx) | ||
| 524 | .await?; | ||
| 525 | tx.commit().await?; | ||
| 526 | Ok(()) | ||
| 527 | } | ||
| 528 | |||
| 390 | /// Map an `issues` row (selected via [`ISSUE_COLUMNS`]) to an [`Issue`]. | 529 | /// Map an `issues` row (selected via [`ISSUE_COLUMNS`]) to an [`Issue`]. |
| 391 | fn map_issue(&self, row: &AnyRow) -> Result<Issue, sqlx::Error> { | 530 | fn map_issue(&self, row: &AnyRow) -> Result<Issue, sqlx::Error> { |
| 392 | Ok(Issue { | 531 | Ok(Issue { |
crates/web/Cargo.toml +2 −0
| @@ -42,6 +42,8 @@ tracing-subscriber = { workspace = true } | |||
| 42 | 42 | ||
| 43 | [dev-dependencies] | 43 | [dev-dependencies] |
| 44 | tower = { workspace = true, features = ["util"] } | 44 | tower = { workspace = true, features = ["util"] } |
| 45 | tempfile = { workspace = true } | ||
| 46 | git2 = { workspace = true } | ||
| 45 | 47 | ||
| 46 | [lints] | 48 | [lints] |
| 47 | workspace = true | 49 | workspace = true |
crates/web/src/issues.rs +30 −10
| @@ -49,6 +49,16 @@ impl Kind { | |||
| 49 | tab: Tab::Issues, | 49 | tab: Tab::Issues, |
| 50 | } | 50 | } |
| 51 | } | 51 | } |
| 52 | |||
| 53 | /// The pull-requests kind. | ||
| 54 | pub(crate) fn pulls() -> Self { | ||
| 55 | Self { | ||
| 56 | is_pull: true, | ||
| 57 | seg: "pulls", | ||
| 58 | noun: "pull request", | ||
| 59 | tab: Tab::Pulls, | ||
| 60 | } | ||
| 61 | } | ||
| 52 | } | 62 | } |
| 53 | 63 | ||
| 54 | /// `GET …/-/issues` — the issue list with open/closed tabs. | 64 | /// `GET …/-/issues` — the issue list with open/closed tabs. |
| @@ -271,7 +281,7 @@ pub(crate) async fn view( | |||
| 271 | // ---- shared render helpers ---- | 281 | // ---- shared render helpers ---- |
| 272 | 282 | ||
| 273 | /// A single comment card (also used for the issue body). | 283 | /// A single comment card (also used for the issue body). |
| 274 | fn comment_card(author: &str, body: &str, created_at: i64) -> Markup { | 284 | pub(crate) fn comment_card(author: &str, body: &str, created_at: i64) -> Markup { |
| 275 | html! { | 285 | html! { |
| 276 | div class="card comment" { | 286 | div class="card comment" { |
| 277 | div class="comment-head muted" { | 287 | div class="comment-head muted" { |
| @@ -295,7 +305,7 @@ fn state_icon(st: IssueState, is_pull: bool) -> Markup { | |||
| 295 | } | 305 | } |
| 296 | 306 | ||
| 297 | /// The open/closed state badge. | 307 | /// The open/closed state badge. |
| 298 | fn state_badge(st: IssueState, is_pull: bool) -> Markup { | 308 | pub(crate) fn state_badge(st: IssueState, is_pull: bool) -> Markup { |
| 299 | let (class, label) = match st { | 309 | let (class, label) = match st { |
| 300 | IssueState::Open => ("badge badge-state-open", "Open"), | 310 | IssueState::Open => ("badge badge-state-open", "Open"), |
| 301 | IssueState::Closed => ("badge badge-state-closed", "Closed"), | 311 | IssueState::Closed => ("badge badge-state-closed", "Closed"), |
| @@ -308,7 +318,7 @@ fn state_badge(st: IssueState, is_pull: bool) -> Markup { | |||
| 308 | } | 318 | } |
| 309 | 319 | ||
| 310 | /// The close/reopen button (posts to the state endpoint). | 320 | /// The close/reopen button (posts to the state endpoint). |
| 311 | fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup { | 321 | pub(crate) fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup { |
| 312 | let (target, label) = match issue.state { | 322 | let (target, label) = match issue.state { |
| 313 | IssueState::Open => ("closed", format!("Close {}", kind.noun)), | 323 | IssueState::Open => ("closed", format!("Close {}", kind.noun)), |
| 314 | IssueState::Closed => ("open", format!("Reopen {}", kind.noun)), | 324 | IssueState::Closed => ("open", format!("Reopen {}", kind.noun)), |
| @@ -323,7 +333,12 @@ fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup { | |||
| 323 | } | 333 | } |
| 324 | 334 | ||
| 325 | /// The assignee select form (owner + collaborators). | 335 | /// The assignee select form (owner + collaborators). |
| 326 | async fn assignee_form(issue: &Issue, ctx: &RepoCtx, state: &AppState, csrf: &str) -> Markup { | 336 | pub(crate) async fn assignee_form( |
| 337 | issue: &Issue, | ||
| 338 | ctx: &RepoCtx, | ||
| 339 | state: &AppState, | ||
| 340 | csrf: &str, | ||
| 341 | ) -> Markup { | ||
| 327 | let mut options: Vec<(String, String)> = | 342 | let mut options: Vec<(String, String)> = |
| 328 | vec![(ctx.owner.id.clone(), ctx.owner.username.clone())]; | 343 | vec![(ctx.owner.id.clone(), ctx.owner.username.clone())]; |
| 329 | if let Ok(collabs) = state.store.list_collaborators(&ctx.repo.id).await { | 344 | if let Ok(collabs) = state.store.list_collaborators(&ctx.repo.id).await { |
| @@ -346,7 +361,12 @@ async fn assignee_form(issue: &Issue, ctx: &RepoCtx, state: &AppState, csrf: &st | |||
| 346 | } | 361 | } |
| 347 | 362 | ||
| 348 | /// The label-toggle form: checkboxes for each repo label. | 363 | /// The label-toggle form: checkboxes for each repo label. |
| 349 | fn label_form(issue: &Issue, repo_labels: &[Label], applied: &[Label], csrf: &str) -> Markup { | 364 | pub(crate) fn label_form( |
| 365 | issue: &Issue, | ||
| 366 | repo_labels: &[Label], | ||
| 367 | applied: &[Label], | ||
| 368 | csrf: &str, | ||
| 369 | ) -> Markup { | ||
| 350 | let is_on = |id: &str| applied.iter().any(|l| l.id == id); | 370 | let is_on = |id: &str| applied.iter().any(|l| l.id == id); |
| 351 | html! { | 371 | html! { |
| 352 | form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form label-picker" { | 372 | form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form label-picker" { |
| @@ -364,7 +384,7 @@ fn label_form(issue: &Issue, repo_labels: &[Label], applied: &[Label], csrf: &st | |||
| 364 | } | 384 | } |
| 365 | 385 | ||
| 366 | /// Resolve a username for display, falling back to the id. | 386 | /// Resolve a username for display, falling back to the id. |
| 367 | async fn username(state: &AppState, user_id: &str) -> String { | 387 | pub(crate) async fn username(state: &AppState, user_id: &str) -> String { |
| 368 | state | 388 | state |
| 369 | .store | 389 | .store |
| 370 | .user_by_id(user_id) | 390 | .user_by_id(user_id) |
| @@ -377,7 +397,7 @@ async fn username(state: &AppState, user_id: &str) -> String { | |||
| 377 | // ---- POST handlers (fixed-prefix routes, keyed by id) ---- | 397 | // ---- POST handlers (fixed-prefix routes, keyed by id) ---- |
| 378 | 398 | ||
| 379 | /// Load an issue's repo and the viewer's access to it. | 399 | /// Load an issue's repo and the viewer's access to it. |
| 380 | async fn repo_of_issue( | 400 | pub(crate) async fn repo_of_issue( |
| 381 | state: &AppState, | 401 | state: &AppState, |
| 382 | viewer: Option<&User>, | 402 | viewer: Option<&User>, |
| 383 | issue: &Issue, | 403 | issue: &Issue, |
| @@ -392,7 +412,7 @@ async fn repo_of_issue( | |||
| 392 | } | 412 | } |
| 393 | 413 | ||
| 394 | /// Compute the viewer's access to a repo. | 414 | /// Compute the viewer's access to a repo. |
| 395 | async fn access_for( | 415 | pub(crate) async fn access_for( |
| 396 | state: &AppState, | 416 | state: &AppState, |
| 397 | viewer: Option<&User>, | 417 | viewer: Option<&User>, |
| 398 | repo: &model::Repo, | 418 | repo: &model::Repo, |
| @@ -418,7 +438,7 @@ async fn access_for( | |||
| 418 | } | 438 | } |
| 419 | 439 | ||
| 420 | /// The path to an issue/PR. | 440 | /// The path to an issue/PR. |
| 421 | async fn issue_url(state: &AppState, repo: &model::Repo, issue: &Issue) -> String { | 441 | pub(crate) async fn issue_url(state: &AppState, repo: &model::Repo, issue: &Issue) -> String { |
| 422 | let owner = username(state, &repo.owner_id).await; | 442 | let owner = username(state, &repo.owner_id).await; |
| 423 | let seg = if issue.is_pull { "pulls" } else { "issues" }; | 443 | let seg = if issue.is_pull { "pulls" } else { "issues" }; |
| 424 | format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number) | 444 | format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number) |
| @@ -679,7 +699,7 @@ pub async fn set_labels( | |||
| 679 | } | 699 | } |
| 680 | 700 | ||
| 681 | /// Redirect on success, render the error otherwise. | 701 | /// Redirect on success, render the error otherwise. |
| 682 | fn respond_redirect(result: AppResult<String>) -> Response { | 702 | pub(crate) fn respond_redirect(result: AppResult<String>) -> Response { |
| 683 | match result { | 703 | match result { |
| 684 | Ok(dest) => Redirect::to(&dest).into_response(), | 704 | Ok(dest) => Redirect::to(&dest).into_response(), |
| 685 | Err(err) => err.into_response(), | 705 | Err(err) => err.into_response(), |
crates/web/src/lib.rs +3 −0
| @@ -21,6 +21,7 @@ mod issues; | |||
| 21 | mod layout; | 21 | mod layout; |
| 22 | mod markdown; | 22 | mod markdown; |
| 23 | mod pages; | 23 | mod pages; |
| 24 | mod pulls; | ||
| 24 | mod repo; | 25 | mod repo; |
| 25 | mod search; | 26 | mod search; |
| 26 | #[path = "serve.rs"] | 27 | #[path = "serve.rs"] |
| @@ -183,6 +184,8 @@ pub fn build_router(state: AppState) -> Router { | |||
| 183 | .route("/issue/{id}/state", post(issues::set_state)) | 184 | .route("/issue/{id}/state", post(issues::set_state)) |
| 184 | .route("/issue/{id}/assignee", post(issues::set_assignee)) | 185 | .route("/issue/{id}/assignee", post(issues::set_assignee)) |
| 185 | .route("/issue/{id}/labels", post(issues::set_labels)) | 186 | .route("/issue/{id}/labels", post(issues::set_labels)) |
| 187 | .route("/pull-new/{repo_id}", post(pulls::create)) | ||
| 188 | .route("/pull/{id}/merge", post(pulls::merge)) | ||
| 186 | .route("/healthz", get(serve_assets::healthz)) | 189 | .route("/healthz", get(serve_assets::healthz)) |
| 187 | .route("/version", get(serve_assets::version)) | 190 | .route("/version", get(serve_assets::version)) |
| 188 | .route("/theme.css", get(serve_assets::theme_default)) | 191 | .route("/theme.css", get(serve_assets::theme_default)) |
crates/web/src/pulls.rs +587 −0
| @@ -0,0 +1,587 @@ | |||
| 1 | // This Source Code Form is subject to the terms of the Mozilla Public | ||
| 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | ||
| 4 | |||
| 5 | //! Pull requests: open (branch compare), view (conversation + commits + diff), | ||
| 6 | //! and merge. | ||
| 7 | //! | ||
| 8 | //! PRs share the `issues` table and much rendering with [`crate::issues`] — the | ||
| 9 | //! conversation thread, state button, assignee, and labels are the same helpers, | ||
| 10 | //! and comment/state/assignee/label mutations reuse the `/issue/{id}/…` routes. | ||
| 11 | //! What is PR-specific lives here: the compare form, the three-dot diff, and the | ||
| 12 | //! server-side merge, which shells out through [`git::merge`]. | ||
| 13 | |||
| 14 | use std::path::PathBuf; | ||
| 15 | |||
| 16 | use axum::extract::{Path, State}; | ||
| 17 | use axum::http::{HeaderMap, Uri}; | ||
| 18 | use axum::response::{IntoResponse, Redirect, Response}; | ||
| 19 | use axum_extra::extract::cookie::CookieJar; | ||
| 20 | use maud::{Markup, html}; | ||
| 21 | use model::User; | ||
| 22 | use serde::Deserialize; | ||
| 23 | |||
| 24 | use crate::AppState; | ||
| 25 | use crate::diff; | ||
| 26 | use crate::error::{AppError, AppResult}; | ||
| 27 | use crate::issues::{ | ||
| 28 | Kind, access_for, assignee_form, comment_card, issue_url, label_form, repo_of_issue, | ||
| 29 | respond_redirect, state_badge, state_button, username, | ||
| 30 | }; | ||
| 31 | use crate::layout::page; | ||
| 32 | use crate::pages::build_chrome; | ||
| 33 | use crate::repo::{RepoCtx, Tab, branch_names, label_chip, repo_header}; | ||
| 34 | use crate::session::{MaybeUser, verify_csrf}; | ||
| 35 | |||
| 36 | /// `GET …/-/pulls/new` — the branch-compare form for opening a pull request. | ||
| 37 | pub(crate) async fn new_form( | ||
| 38 | state: &AppState, | ||
| 39 | viewer: Option<User>, | ||
| 40 | jar: CookieJar, | ||
| 41 | uri: &Uri, | ||
| 42 | ctx: RepoCtx, | ||
| 43 | ) -> AppResult<Response> { | ||
| 44 | if !ctx.repo.pulls_enabled { | ||
| 45 | return Err(AppError::NotFound); | ||
| 46 | } | ||
| 47 | if viewer.is_none() { | ||
| 48 | return Ok(Redirect::to("/login").into_response()); | ||
| 49 | } | ||
| 50 | let branches = branch_names(state, &ctx.repo.id).await; | ||
| 51 | // Pre-select the base/head from the query when arriving from a branch view. | ||
| 52 | let q = compare_query(uri); | ||
| 53 | let base_sel = q.0.unwrap_or_else(|| ctx.repo.default_branch.clone()); | ||
| 54 | let head_sel = q.1.unwrap_or_default(); | ||
| 55 | |||
| 56 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | ||
| 57 | let body = html! { | ||
| 58 | (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches)) | ||
| 59 | h2 { "New pull request" } | ||
| 60 | div class="card" { | ||
| 61 | form method="post" action=(format!("/pull-new/{}", ctx.repo.id)) { | ||
| 62 | input type="hidden" name="_csrf" value=(chrome.csrf); | ||
| 63 | div class="compare-refs" { | ||
| 64 | label { "Base " | ||
| 65 | select name="base_ref" aria-label="Base branch" { | ||
| 66 | @for b in &branches { | ||
| 67 | option value=(b) selected[*b == base_sel] { (b) } | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | span class="compare-arrow" { "←" } | ||
| 72 | label { "Compare " | ||
| 73 | select name="head_ref" aria-label="Compare branch" { | ||
| 74 | @for b in &branches { | ||
| 75 | option value=(b) selected[*b == head_sel] { (b) } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | } | ||
| 79 | } | ||
| 80 | label for="title" { "Title" } | ||
| 81 | input type="text" id="title" name="title" required autofocus; | ||
| 82 | label for="body" { "Description" } | ||
| 83 | textarea id="body" name="body" rows="6" placeholder="Markdown supported" {} | ||
| 84 | button class="btn btn-primary" type="submit" { "Create pull request" } | ||
| 85 | } | ||
| 86 | } | ||
| 87 | }; | ||
| 88 | let title = format!( | ||
| 89 | "New pull request · {}/{}", | ||
| 90 | ctx.owner.username, ctx.repo.path | ||
| 91 | ); | ||
| 92 | Ok((jar, page(&chrome, &title, body)).into_response()) | ||
| 93 | } | ||
| 94 | |||
| 95 | /// `GET …/-/pulls/{n}` — a pull request: conversation, commits, and diff. | ||
| 96 | pub(crate) async fn view( | ||
| 97 | state: &AppState, | ||
| 98 | viewer: Option<User>, | ||
| 99 | jar: CookieJar, | ||
| 100 | uri: &Uri, | ||
| 101 | ctx: RepoCtx, | ||
| 102 | number: i64, | ||
| 103 | ) -> AppResult<Response> { | ||
| 104 | if !ctx.repo.pulls_enabled { | ||
| 105 | return Err(AppError::NotFound); | ||
| 106 | } | ||
| 107 | let issue = state | ||
| 108 | .store | ||
| 109 | .issue_by_number(&ctx.repo.id, number, true) | ||
| 110 | .await? | ||
| 111 | .ok_or(AppError::NotFound)?; | ||
| 112 | let pr = state | ||
| 113 | .store | ||
| 114 | .pull_by_issue(&issue.id) | ||
| 115 | .await? | ||
| 116 | .ok_or(AppError::NotFound)?; | ||
| 117 | |||
| 118 | let can_write = viewer.is_some() && ctx.access >= auth::Access::Write; | ||
| 119 | let is_author = viewer.as_ref().is_some_and(|v| v.id == issue.author_id); | ||
| 120 | let branches = branch_names(state, &ctx.repo.id).await; | ||
| 121 | let Loaded { | ||
| 122 | author, | ||
| 123 | comment_rows, | ||
| 124 | issue_labels, | ||
| 125 | repo_labels, | ||
| 126 | assignee, | ||
| 127 | commits, | ||
| 128 | files, | ||
| 129 | mergeable, | ||
| 130 | } = gather(state, &ctx, &issue, &pr, can_write).await?; | ||
| 131 | |||
| 132 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | ||
| 133 | let csrf = chrome.csrf.clone(); | ||
| 134 | let body = html! { | ||
| 135 | (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches)) | ||
| 136 | div class="issue-view" { | ||
| 137 | header class="issue-header" { | ||
| 138 | h1 { (issue.title) " " span class="muted" { "#" (issue.number) } } | ||
| 139 | div class="issue-sub" { | ||
| 140 | (pr_state_badge(&issue, &pr)) | ||
| 141 | span class="muted" { | ||
| 142 | " " (author) " wants to merge " | ||
| 143 | code { (pr.head_ref) } " into " code { (pr.base_ref) } | ||
| 144 | } | ||
| 145 | } | ||
| 146 | } | ||
| 147 | div class="issue-main" { | ||
| 148 | article class="issue-thread" { | ||
| 149 | (comment_card(&author, &issue.body, issue.created_at)) | ||
| 150 | @for (c, cauthor) in &comment_rows { | ||
| 151 | (comment_card(cauthor, &c.body, c.created_at)) | ||
| 152 | } | ||
| 153 | (merge_panel(&issue, &pr, mergeable, &csrf)) | ||
| 154 | @if can_write { | ||
| 155 | div class="card comment-form" { | ||
| 156 | form method="post" action=(format!("/issue/{}/comment", issue.id)) { | ||
| 157 | input type="hidden" name="_csrf" value=(csrf); | ||
| 158 | textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {} | ||
| 159 | div class="comment-actions" { | ||
| 160 | @if can_write || is_author { | ||
| 161 | (state_button(&issue, &csrf, Kind::pulls())) | ||
| 162 | } | ||
| 163 | button class="btn btn-primary" type="submit" { "Comment" } | ||
| 164 | } | ||
| 165 | } | ||
| 166 | } | ||
| 167 | } | ||
| 168 | } | ||
| 169 | aside class="issue-side" { | ||
| 170 | section { | ||
| 171 | h3 { "Assignee" } | ||
| 172 | @if let Some(a) = &assignee { p { (a) } } @else { p class="muted" { "No one" } } | ||
| 173 | @if can_write { (assignee_form(&issue, &ctx, state, &csrf).await) } | ||
| 174 | } | ||
| 175 | section { | ||
| 176 | h3 { "Labels" } | ||
| 177 | @if issue_labels.is_empty() { p class="muted" { "None" } } | ||
| 178 | @else { div class="label-set" { @for l in &issue_labels { (label_chip(l)) } } } | ||
| 179 | @if can_write && !repo_labels.is_empty() { | ||
| 180 | (label_form(&issue, &repo_labels, &issue_labels, &csrf)) | ||
| 181 | } | ||
| 182 | } | ||
| 183 | } | ||
| 184 | } | ||
| 185 | @if issue.state == model::IssueState::Open { | ||
| 186 | (pr_changes(&commits, &files)) | ||
| 187 | } | ||
| 188 | } | ||
| 189 | }; | ||
| 190 | let title = format!("{} · {}/{}", issue.title, ctx.owner.username, ctx.repo.path); | ||
| 191 | Ok((jar, page(&chrome, &title, body)).into_response()) | ||
| 192 | } | ||
| 193 | |||
| 194 | /// Everything the PR view needs beyond the issue/PR rows themselves. | ||
| 195 | struct Loaded { | ||
| 196 | /// The author's display name. | ||
| 197 | author: String, | ||
| 198 | /// Each comment paired with its author's name. | ||
| 199 | comment_rows: Vec<(model::Comment, String)>, | ||
| 200 | /// Labels applied to this PR. | ||
| 201 | issue_labels: Vec<model::Label>, | ||
| 202 | /// All labels defined on the repo (for the picker). | ||
| 203 | repo_labels: Vec<model::Label>, | ||
| 204 | /// The assignee's name, if any. | ||
| 205 | assignee: Option<String>, | ||
| 206 | /// The commits the PR contributes (empty once closed). | ||
| 207 | commits: Vec<git::CommitSummary>, | ||
| 208 | /// The rendered three-dot diff (empty once closed). | ||
| 209 | files: Vec<crate::diff::RenderedFile>, | ||
| 210 | /// Mergeability, computed only for an open PR the viewer can merge. | ||
| 211 | mergeable: Option<git::merge::Mergeability>, | ||
| 212 | } | ||
| 213 | |||
| 214 | /// Load the comments, labels, assignee, diff, and mergeability for the PR view. | ||
| 215 | async fn gather( | ||
| 216 | state: &AppState, | ||
| 217 | ctx: &RepoCtx, | ||
| 218 | issue: &model::Issue, | ||
| 219 | pr: &model::PullRequest, | ||
| 220 | can_write: bool, | ||
| 221 | ) -> AppResult<Loaded> { | ||
| 222 | let author = username(state, &issue.author_id).await; | ||
| 223 | let comments = state.store.list_comments(&issue.id).await?; | ||
| 224 | let mut comment_rows = Vec::with_capacity(comments.len()); | ||
| 225 | for c in comments { | ||
| 226 | let name = username(state, &c.author_id).await; | ||
| 227 | comment_rows.push((c, name)); | ||
| 228 | } | ||
| 229 | let issue_labels = state.store.issue_labels(&issue.id).await?; | ||
| 230 | let repo_labels = state.store.list_labels(&ctx.repo.id).await?; | ||
| 231 | let assignee = match &issue.assignee_id { | ||
| 232 | Some(id) => Some(username(state, id).await), | ||
| 233 | None => None, | ||
| 234 | }; | ||
| 235 | |||
| 236 | let open = issue.state == model::IssueState::Open; | ||
| 237 | // Three-dot commits/diff; best-effort so a deleted branch does not 500. | ||
| 238 | let (commits, files) = if open { | ||
| 239 | crate::repo::load_range_diff(state, &ctx.repo.id, &pr.base_ref, &pr.head_ref) | ||
| 240 | .await | ||
| 241 | .unwrap_or_default() | ||
| 242 | } else { | ||
| 243 | (Vec::new(), Vec::new()) | ||
| 244 | }; | ||
| 245 | let mergeable = if open && pr.merged_at.is_none() && can_write { | ||
| 246 | analyze(state, &ctx.repo.id, &pr.base_ref, &pr.head_ref).await | ||
| 247 | } else { | ||
| 248 | None | ||
| 249 | }; | ||
| 250 | Ok(Loaded { | ||
| 251 | author, | ||
| 252 | comment_rows, | ||
| 253 | issue_labels, | ||
| 254 | repo_labels, | ||
| 255 | assignee, | ||
| 256 | commits, | ||
| 257 | files, | ||
| 258 | mergeable, | ||
| 259 | }) | ||
| 260 | } | ||
| 261 | |||
| 262 | // ---- render helpers ---- | ||
| 263 | |||
| 264 | /// The commits + diff a pull request contributes. | ||
| 265 | fn pr_changes(commits: &[git::CommitSummary], files: &[crate::diff::RenderedFile]) -> Markup { | ||
| 266 | let (adds, dels) = files.iter().fold((0, 0), |(a, d), f| { | ||
| 267 | let (fa, fd) = f.stats(); | ||
| 268 | (a + fa, d + fd) | ||
| 269 | }); | ||
| 270 | html! { | ||
| 271 | section class="pr-changes" { | ||
| 272 | h2 { (commits.len()) " commits · " (files.len()) " files changed " | ||
| 273 | span class="muted" { | ||
| 274 | span style="color:var(--fb-diff-add-fg)" { "+" (adds) } | ||
| 275 | " " | ||
| 276 | span style="color:var(--fb-diff-del-fg)" { "−" (dels) } | ||
| 277 | } | ||
| 278 | } | ||
| 279 | @if !commits.is_empty() { | ||
| 280 | ul class="entry-list card pr-commits" { | ||
| 281 | @for c in commits { | ||
| 282 | li class="entry-row" { | ||
| 283 | div class="entry-row-body" { | ||
| 284 | span class="entry-row-title" { (c.summary) } | ||
| 285 | div class="entry-row-meta muted mono" { (c.oid.short()) } | ||
| 286 | } | ||
| 287 | } | ||
| 288 | } | ||
| 289 | } | ||
| 290 | } | ||
| 291 | @for (i, f) in files.iter().enumerate() { | ||
| 292 | details open id=(format!("file-{i}")) class="diff-file" { | ||
| 293 | summary { | ||
| 294 | @let (fa, fd) = f.stats(); | ||
| 295 | span class="mono" { (f.path()) } | ||
| 296 | " " span class="muted" { "+" (fa) " −" (fd) } | ||
| 297 | } | ||
| 298 | @if f.diff.is_binary { | ||
| 299 | p class="muted" style="padding:0.5rem" { "Binary file not shown." } | ||
| 300 | } @else if f.diff.hunks.is_empty() { | ||
| 301 | p class="muted" style="padding:0.5rem" { "No textual changes." } | ||
| 302 | } @else { | ||
| 303 | (diff::unified(f, None)) | ||
| 304 | } | ||
| 305 | } | ||
| 306 | } | ||
| 307 | } | ||
| 308 | } | ||
| 309 | } | ||
| 310 | |||
| 311 | /// The state badge for a PR, distinguishing a merged PR from a plain closed one. | ||
| 312 | fn pr_state_badge(issue: &model::Issue, pr: &model::PullRequest) -> Markup { | ||
| 313 | if pr.merged_at.is_some() { | ||
| 314 | return html! { span class="badge badge-state-merged" { "Merged" } }; | ||
| 315 | } | ||
| 316 | state_badge(issue.state, true) | ||
| 317 | } | ||
| 318 | |||
| 319 | /// The merge panel: shows mergeability and, when clean, the strategy buttons. | ||
| 320 | fn merge_panel( | ||
| 321 | issue: &model::Issue, | ||
| 322 | pr: &model::PullRequest, | ||
| 323 | mergeable: Option<git::merge::Mergeability>, | ||
| 324 | csrf: &str, | ||
| 325 | ) -> Markup { | ||
| 326 | use git::merge::Mergeability; | ||
| 327 | if let Some(sha) = &pr.merge_sha { | ||
| 328 | return html! { | ||
| 329 | div class="card merge-panel merged" { | ||
| 330 | p { "This pull request was merged" } | ||
| 331 | @if let Some(s) = &pr.merge_strategy { " " span class="muted" { "(" (s) ")" } } | ||
| 332 | p class="mono muted" { (sha) } | ||
| 333 | } | ||
| 334 | }; | ||
| 335 | } | ||
| 336 | if issue.state != model::IssueState::Open { | ||
| 337 | return html! {}; | ||
| 338 | } | ||
| 339 | match mergeable { | ||
| 340 | None => html! {}, | ||
| 341 | Some(Mergeability::AlreadyMerged) => html! { | ||
| 342 | div class="card merge-panel" { | ||
| 343 | p class="muted" { "The base branch already contains these commits — nothing to merge." } | ||
| 344 | } | ||
| 345 | }, | ||
| 346 | Some(Mergeability::Conflicts) => html! { | ||
| 347 | div class="card merge-panel conflicts" { | ||
| 348 | p { "This branch has conflicts that must be resolved before it can be merged." } | ||
| 349 | } | ||
| 350 | }, | ||
| 351 | Some(Mergeability::Clean) => html! { | ||
| 352 | div class="card merge-panel clean" { | ||
| 353 | p { "This branch has no conflicts with the base branch." } | ||
| 354 | form method="post" action=(format!("/pull/{}/merge", issue.id)) class="merge-form" { | ||
| 355 | input type="hidden" name="_csrf" value=(csrf); | ||
| 356 | label for="strategy" { "Method " } | ||
| 357 | select id="strategy" name="strategy" { | ||
| 358 | option value="merge" { "Create a merge commit" } | ||
| 359 | option value="squash" { "Squash and merge" } | ||
| 360 | option value="rebase" { "Rebase and merge" } | ||
| 361 | } | ||
| 362 | button class="btn btn-primary" type="submit" { "Merge pull request" } | ||
| 363 | } | ||
| 364 | } | ||
| 365 | }, | ||
| 366 | } | ||
| 367 | } | ||
| 368 | |||
| 369 | /// Run the (blocking) mergeability analysis on the blocking pool. | ||
| 370 | async fn analyze( | ||
| 371 | state: &AppState, | ||
| 372 | repo_id: &str, | ||
| 373 | base_ref: &str, | ||
| 374 | head_ref: &str, | ||
| 375 | ) -> Option<git::merge::Mergeability> { | ||
| 376 | let binary = state.config.git.binary.clone(); | ||
| 377 | let home = state.config.storage.data_dir.join("tmp"); | ||
| 378 | let repo_path = git::repo_path(&state.config.storage.repo_dir, repo_id).ok()?; | ||
| 379 | let base = base_ref.to_string(); | ||
| 380 | let head = head_ref.to_string(); | ||
| 381 | tokio::task::spawn_blocking(move || { | ||
| 382 | git::merge::analyze(&binary, &repo_path, &home, &base, &head).ok() | ||
| 383 | }) | ||
| 384 | .await | ||
| 385 | .ok() | ||
| 386 | .flatten() | ||
| 387 | } | ||
| 388 | |||
| 389 | // ---- POST handlers ---- | ||
| 390 | |||
| 391 | /// `?base_ref=&head_ref=` for pre-filling the compare form. | ||
| 392 | fn compare_query(uri: &Uri) -> (Option<String>, Option<String>) { | ||
| 393 | let mut base = None; | ||
| 394 | let mut head = None; | ||
| 395 | if let Some(q) = uri.query() { | ||
| 396 | for (k, v) in url::form_urlencoded::parse(q.as_bytes()) { | ||
| 397 | match k.as_ref() { | ||
| 398 | "base_ref" => base = Some(v.into_owned()), | ||
| 399 | "head_ref" => head = Some(v.into_owned()), | ||
| 400 | _ => {} | ||
| 401 | } | ||
| 402 | } | ||
| 403 | } | ||
| 404 | (base, head) | ||
| 405 | } | ||
| 406 | |||
| 407 | /// New-pull-request form fields. | ||
| 408 | #[derive(Debug, Deserialize)] | ||
| 409 | pub struct NewPullForm { | ||
| 410 | /// The base branch (merged into). | ||
| 411 | #[serde(default)] | ||
| 412 | base_ref: String, | ||
| 413 | /// The head branch (source of the changes). | ||
| 414 | #[serde(default)] | ||
| 415 | head_ref: String, | ||
| 416 | /// Title. | ||
| 417 | #[serde(default)] | ||
| 418 | title: String, | ||
| 419 | /// Markdown body. | ||
| 420 | #[serde(default)] | ||
| 421 | body: String, | ||
| 422 | /// CSRF token. | ||
| 423 | #[serde(rename = "_csrf")] | ||
| 424 | csrf: String, | ||
| 425 | } | ||
| 426 | |||
| 427 | /// `POST /pull-new/{repo_id}` — open a pull request. | ||
| 428 | pub async fn create( | ||
| 429 | State(state): State<AppState>, | ||
| 430 | MaybeUser(viewer): MaybeUser, | ||
| 431 | jar: CookieJar, | ||
| 432 | headers: HeaderMap, | ||
| 433 | Path(repo_id): Path<String>, | ||
| 434 | axum::Form(form): axum::Form<NewPullForm>, | ||
| 435 | ) -> Response { | ||
| 436 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 437 | return AppError::Forbidden.into_response(); | ||
| 438 | } | ||
| 439 | let result = async { | ||
| 440 | let user = viewer.ok_or(AppError::Forbidden)?; | ||
| 441 | let repo = state | ||
| 442 | .store | ||
| 443 | .repo_by_id(&repo_id) | ||
| 444 | .await? | ||
| 445 | .ok_or(AppError::NotFound)?; | ||
| 446 | if !repo.pulls_enabled { | ||
| 447 | return Err(AppError::NotFound); | ||
| 448 | } | ||
| 449 | // Read access is enough to open a PR. | ||
| 450 | if access_for(&state, Some(&user), &repo).await? < auth::Access::Read { | ||
| 451 | return Err(AppError::NotFound); | ||
| 452 | } | ||
| 453 | let title = form.title.trim(); | ||
| 454 | let base = form.base_ref.trim(); | ||
| 455 | let head = form.head_ref.trim(); | ||
| 456 | if title.is_empty() { | ||
| 457 | return Err(AppError::BadRequest("A title is required.".to_string())); | ||
| 458 | } | ||
| 459 | if base.is_empty() || head.is_empty() || base == head { | ||
| 460 | return Err(AppError::BadRequest( | ||
| 461 | "Choose two different branches to compare.".to_string(), | ||
| 462 | )); | ||
| 463 | } | ||
| 464 | // Both refs must exist. | ||
| 465 | let names = branch_names(&state, &repo.id).await; | ||
| 466 | if !names.iter().any(|b| b == base) || !names.iter().any(|b| b == head) { | ||
| 467 | return Err(AppError::BadRequest("Unknown branch.".to_string())); | ||
| 468 | } | ||
| 469 | let issue = state | ||
| 470 | .store | ||
| 471 | .create_pull(&repo.id, &user.id, title, form.body.trim(), base, head) | ||
| 472 | .await?; | ||
| 473 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | ||
| 474 | } | ||
| 475 | .await; | ||
| 476 | respond_redirect(result) | ||
| 477 | } | ||
| 478 | |||
| 479 | /// Merge form: the chosen strategy. | ||
| 480 | #[derive(Debug, Deserialize)] | ||
| 481 | pub struct MergeForm { | ||
| 482 | /// `merge` | `squash` | `rebase`. | ||
| 483 | #[serde(default)] | ||
| 484 | strategy: String, | ||
| 485 | /// CSRF token. | ||
| 486 | #[serde(rename = "_csrf")] | ||
| 487 | csrf: String, | ||
| 488 | } | ||
| 489 | |||
| 490 | /// `POST /pull/{id}/merge` — merge a pull request (Write access). | ||
| 491 | pub async fn merge( | ||
| 492 | State(state): State<AppState>, | ||
| 493 | MaybeUser(viewer): MaybeUser, | ||
| 494 | jar: CookieJar, | ||
| 495 | headers: HeaderMap, | ||
| 496 | Path(id): Path<String>, | ||
| 497 | axum::Form(form): axum::Form<MergeForm>, | ||
| 498 | ) -> Response { | ||
| 499 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 500 | return AppError::Forbidden.into_response(); | ||
| 501 | } | ||
| 502 | let result = async { | ||
| 503 | let user = viewer.ok_or(AppError::Forbidden)?; | ||
| 504 | let issue = state | ||
| 505 | .store | ||
| 506 | .issue_by_id(&id) | ||
| 507 | .await? | ||
| 508 | .ok_or(AppError::NotFound)?; | ||
| 509 | if !issue.is_pull { | ||
| 510 | return Err(AppError::NotFound); | ||
| 511 | } | ||
| 512 | let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; | ||
| 513 | if access < auth::Access::Write { | ||
| 514 | return Err(AppError::Forbidden); | ||
| 515 | } | ||
| 516 | let pr = state | ||
| 517 | .store | ||
| 518 | .pull_by_issue(&issue.id) | ||
| 519 | .await? | ||
| 520 | .ok_or(AppError::NotFound)?; | ||
| 521 | if pr.merged_at.is_some() || issue.state != model::IssueState::Open { | ||
| 522 | return Err(AppError::BadRequest( | ||
| 523 | "This pull request is already closed.".to_string(), | ||
| 524 | )); | ||
| 525 | } | ||
| 526 | |||
| 527 | let strategy = git::merge::MergeStrategy::from_token(&form.strategy); | ||
| 528 | let sha = run_merge(&state, &repo, &pr, &user, strategy, &issue).await?; | ||
| 529 | state | ||
| 530 | .store | ||
| 531 | .mark_pull_merged(&issue.id, &user.id, &sha, strategy.as_str()) | ||
| 532 | .await?; | ||
| 533 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | ||
| 534 | } | ||
| 535 | .await; | ||
| 536 | respond_redirect(result) | ||
| 537 | } | ||
| 538 | |||
| 539 | /// Run the blocking server-side merge on the blocking pool. | ||
| 540 | async fn run_merge( | ||
| 541 | state: &AppState, | ||
| 542 | repo: &model::Repo, | ||
| 543 | pr: &model::PullRequest, | ||
| 544 | user: &User, | ||
| 545 | strategy: git::merge::MergeStrategy, | ||
| 546 | issue: &model::Issue, | ||
| 547 | ) -> AppResult<String> { | ||
| 548 | let binary = state.config.git.binary.clone(); | ||
| 549 | let home: PathBuf = state.config.storage.data_dir.join("tmp"); | ||
| 550 | let repo_path = git::repo_path(&state.config.storage.repo_dir, &repo.id)?; | ||
| 551 | let workdir = home.join(format!("merge-{}", issue.id)); | ||
| 552 | let base = pr.base_ref.clone(); | ||
| 553 | let head = pr.head_ref.clone(); | ||
| 554 | let name = user | ||
| 555 | .display_name | ||
| 556 | .clone() | ||
| 557 | .unwrap_or_else(|| user.username.clone()); | ||
| 558 | let email = user.email.clone(); | ||
| 559 | let message = format!("Merge pull request #{} — {}", issue.number, issue.title); | ||
| 560 | |||
| 561 | let sha = tokio::task::spawn_blocking(move || { | ||
| 562 | let req = git::merge::MergeRequest { | ||
| 563 | binary: &binary, | ||
| 564 | repo_path: &repo_path, | ||
| 565 | workdir: &workdir, | ||
| 566 | base_branch: &base, | ||
| 567 | head_ref: &head, | ||
| 568 | strategy, | ||
| 569 | message: &message, | ||
| 570 | identity: git::merge::MergeIdentity { | ||
| 571 | home: &home, | ||
| 572 | name: &name, | ||
| 573 | email: &email, | ||
| 574 | }, | ||
| 575 | }; | ||
| 576 | git::merge::merge(&req) | ||
| 577 | }) | ||
| 578 | .await | ||
| 579 | .map_err(AppError::internal)?; | ||
| 580 | |||
| 581 | sha.map_err(|e| match e { | ||
| 582 | git::GitError::MergeConflict => { | ||
| 583 | AppError::BadRequest("The branch could not be merged cleanly.".to_string()) | ||
| 584 | } | ||
| 585 | other => AppError::from(other), | ||
| 586 | }) | ||
| 587 | } | ||
crates/web/src/repo.rs +62 −0
| @@ -305,6 +305,20 @@ async fn dispatch_view( | |||
| 305 | }, | 305 | }, |
| 306 | } | 306 | } |
| 307 | } | 307 | } |
| 308 | "pulls" => { | ||
| 309 | let kind = crate::issues::Kind::pulls(); | ||
| 310 | match rest { | ||
| 311 | "" => { | ||
| 312 | let closed = uri.query().is_some_and(|q| q.contains("state=closed")); | ||
| 313 | crate::issues::list(state, viewer, jar, uri, ctx, kind, closed).await | ||
| 314 | } | ||
| 315 | "new" => crate::pulls::new_form(state, viewer, jar, uri, ctx).await, | ||
| 316 | n => match n.parse::<i64>() { | ||
| 317 | Ok(num) => crate::pulls::view(state, viewer, jar, uri, ctx, num).await, | ||
| 318 | Err(_) => Err(AppError::NotFound), | ||
| 319 | }, | ||
| 320 | } | ||
| 321 | } | ||
| 308 | "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await, | 322 | "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await, |
| 309 | // Reserved `runs` slot returns 404 until built. | 323 | // Reserved `runs` slot returns 404 until built. |
| 310 | _ => Err(AppError::NotFound), | 324 | _ => Err(AppError::NotFound), |
| @@ -1538,6 +1552,54 @@ async fn load_commit( | |||
| 1538 | .await | 1552 | .await |
| 1539 | } | 1553 | } |
| 1540 | 1554 | ||
| 1555 | /// The commits and rendered diff a pull request contributes: the head-only | ||
| 1556 | /// commit list (newest first) and the three-dot diff (merge-base → head). | ||
| 1557 | pub(crate) type RangeDiff = (Vec<git::CommitSummary>, Vec<RenderedFile>); | ||
| 1558 | |||
| 1559 | /// Load a pull request's commits and diff. `base` and `head` are branch names (or | ||
| 1560 | /// revs); the diff is three-dot (against their merge base) so unrelated base | ||
| 1561 | /// commits do not appear as changes. | ||
| 1562 | pub(crate) async fn load_range_diff( | ||
| 1563 | state: &AppState, | ||
| 1564 | repo_id: &str, | ||
| 1565 | base: &str, | ||
| 1566 | head: &str, | ||
| 1567 | ) -> AppResult<RangeDiff> { | ||
| 1568 | let context_lines = state.config.ui.diff_context_lines; | ||
| 1569 | let base = base.to_string(); | ||
| 1570 | let head = head.to_string(); | ||
| 1571 | git_read(state, repo_id, move |repo| { | ||
| 1572 | let base_oid = repo.resolve_ref(&base)?.oid; | ||
| 1573 | let head_oid = repo.resolve_ref(&head)?.oid; | ||
| 1574 | // Three-dot: diff from the merge base, so only the head's own work shows. | ||
| 1575 | let merge_base = repo.merge_base(&base_oid, &head_oid)?.unwrap_or(base_oid); | ||
| 1576 | let commits = repo.commits_between(&merge_base, &head_oid, 200)?; | ||
| 1577 | let diffs = repo.diff( | ||
| 1578 | Some(&merge_base), | ||
| 1579 | &head_oid, | ||
| 1580 | git::DiffOpts { context_lines }, | ||
| 1581 | )?; | ||
| 1582 | |||
| 1583 | let head_rev = git::Resolved { | ||
| 1584 | oid: head_oid.clone(), | ||
| 1585 | kind: git::RefKind::Commit, | ||
| 1586 | name: head.clone(), | ||
| 1587 | }; | ||
| 1588 | let base_rev = git::Resolved { | ||
| 1589 | oid: merge_base.clone(), | ||
| 1590 | kind: git::RefKind::Commit, | ||
| 1591 | name: merge_base.to_string(), | ||
| 1592 | }; | ||
| 1593 | let rendered = diffs | ||
| 1594 | .files | ||
| 1595 | .into_iter() | ||
| 1596 | .map(|file| render_one_file(repo, &head_rev, Some(&base_rev), file)) | ||
| 1597 | .collect(); | ||
| 1598 | Ok((commits, rendered)) | ||
| 1599 | }) | ||
| 1600 | .await | ||
| 1601 | } | ||
| 1602 | |||
| 1541 | /// Build a [`RenderedFile`] for one diff entry, highlighting each side as a whole | 1603 | /// Build a [`RenderedFile`] for one diff entry, highlighting each side as a whole |
| 1542 | /// document. | 1604 | /// document. |
| 1543 | fn render_one_file( | 1605 | fn render_one_file( |
crates/web/src/tests.rs +206 −0
| @@ -621,6 +621,212 @@ async fn issues_404_when_disabled() { | |||
| 621 | assert_eq!(res.status(), StatusCode::NOT_FOUND); | 621 | assert_eq!(res.status(), StatusCode::NOT_FOUND); |
| 622 | } | 622 | } |
| 623 | 623 | ||
| 624 | /// Build an app whose storage lives under a temp dir, so real bare repositories | ||
| 625 | /// can be created on disk — needed for pull-request diffs and merges. The temp | ||
| 626 | /// dir is returned so it outlives the test. | ||
| 627 | async fn app_with_git() -> (AppState, Router, tempfile::TempDir) { | ||
| 628 | let store = Store::connect("sqlite::memory:", 1).await.unwrap(); | ||
| 629 | store.migrate().await.unwrap(); | ||
| 630 | let hash = auth::hash_password("secret12", auth::HashParams::default()).unwrap(); | ||
| 631 | store | ||
| 632 | .create_user(NewUser { | ||
| 633 | username: "ada".to_string(), | ||
| 634 | email: "ada@example.com".to_string(), | ||
| 635 | display_name: Some("Ada".to_string()), | ||
| 636 | password_hash: Some(hash), | ||
| 637 | is_admin: false, | ||
| 638 | must_change_password: false, | ||
| 639 | }) | ||
| 640 | .await | ||
| 641 | .unwrap(); | ||
| 642 | |||
| 643 | let dir = tempfile::tempdir().unwrap(); | ||
| 644 | let mut config = Config::default(); | ||
| 645 | config.instance.name = "Forge".to_string(); | ||
| 646 | config.auth.cookie_secure = false; | ||
| 647 | config.storage.repo_dir = dir.path().join("repos"); | ||
| 648 | config.storage.data_dir = dir.path().to_path_buf(); | ||
| 649 | std::fs::create_dir_all(config.storage.data_dir.join("tmp")).unwrap(); | ||
| 650 | |||
| 651 | let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap(); | ||
| 652 | let router = crate::build_router(state.clone()); | ||
| 653 | (state, router, dir) | ||
| 654 | } | ||
| 655 | |||
| 656 | /// Commit `files` onto `branch` in a bare repo via libgit2, returning the new oid. | ||
| 657 | fn seed_commit( | ||
| 658 | raw: &git2::Repository, | ||
| 659 | branch: &str, | ||
| 660 | parents: &[git2::Oid], | ||
| 661 | files: &[(&str, &str)], | ||
| 662 | message: &str, | ||
| 663 | t: i64, | ||
| 664 | ) -> git2::Oid { | ||
| 665 | let mut root = raw.treebuilder(None).unwrap(); | ||
| 666 | for (name, content) in files { | ||
| 667 | let blob = raw.blob(content.as_bytes()).unwrap(); | ||
| 668 | root.insert(name, blob, 0o100_644).unwrap(); | ||
| 669 | } | ||
| 670 | let tree = raw.find_tree(root.write().unwrap()).unwrap(); | ||
| 671 | let sig = git2::Signature::new("Ada", "ada@example.com", &git2::Time::new(t, 0)).unwrap(); | ||
| 672 | let parent_commits: Vec<_> = parents | ||
| 673 | .iter() | ||
| 674 | .map(|p| raw.find_commit(*p).unwrap()) | ||
| 675 | .collect(); | ||
| 676 | let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect(); | ||
| 677 | raw.commit( | ||
| 678 | Some(&format!("refs/heads/{branch}")), | ||
| 679 | &sig, | ||
| 680 | &sig, | ||
| 681 | message, | ||
| 682 | &tree, | ||
| 683 | &parent_refs, | ||
| 684 | ) | ||
| 685 | .unwrap() | ||
| 686 | } | ||
| 687 | |||
| 688 | #[tokio::test] | ||
| 689 | async fn pull_request_create_view_and_merge() { | ||
| 690 | // Merge shells out to `git`; skip cleanly where it is absent. | ||
| 691 | if std::process::Command::new("git") | ||
| 692 | .arg("--version") | ||
| 693 | .output() | ||
| 694 | .is_err() | ||
| 695 | { | ||
| 696 | return; | ||
| 697 | } | ||
| 698 | |||
| 699 | let (state, app, _dir) = app_with_git().await; | ||
| 700 | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | ||
| 701 | let repo = state | ||
| 702 | .store | ||
| 703 | .create_repo(NewRepo { | ||
| 704 | owner_id: ada.id, | ||
| 705 | group_id: None, | ||
| 706 | name: "proj".to_string(), | ||
| 707 | path: "proj".to_string(), | ||
| 708 | description: None, | ||
| 709 | visibility: model::Visibility::Public, | ||
| 710 | default_branch: "main".to_string(), | ||
| 711 | }) | ||
| 712 | .await | ||
| 713 | .unwrap(); | ||
| 714 | |||
| 715 | // Create the bare repo on disk with a mergeable feature branch. | ||
| 716 | let hook = state.config.storage.data_dir.join("fabrica"); | ||
| 717 | git::create_bare(&state.config.storage.repo_dir, &repo.id, "main", &hook).unwrap(); | ||
| 718 | let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap(); | ||
| 719 | let raw = git2::Repository::open_bare(&path).unwrap(); | ||
| 720 | let base = seed_commit(&raw, "main", &[], &[("README.md", "hello\n")], "init", 1); | ||
| 721 | seed_commit( | ||
| 722 | &raw, | ||
| 723 | "feature", | ||
| 724 | &[base], | ||
| 725 | &[("README.md", "hello\n"), ("feature.txt", "new\n")], | ||
| 726 | "add feature", | ||
| 727 | 2, | ||
| 728 | ); | ||
| 729 | |||
| 730 | let cookie = login(&app).await; | ||
| 731 | let token = cookie | ||
| 732 | .split("fabrica_csrf=") | ||
| 733 | .nth(1) | ||
| 734 | .and_then(|s| s.split(';').next()) | ||
| 735 | .unwrap() | ||
| 736 | .to_string(); | ||
| 737 | |||
| 738 | // Open a pull request comparing feature → main. | ||
| 739 | let req = Request::builder() | ||
| 740 | .method("POST") | ||
| 741 | .uri(format!("/pull-new/{}", repo.id)) | ||
| 742 | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | ||
| 743 | .header(header::COOKIE, cookie.clone()) | ||
| 744 | .body(Body::from(format!( | ||
| 745 | "base_ref=main&head_ref=feature&title=Add+feature&body=please&_csrf={token}" | ||
| 746 | ))) | ||
| 747 | .unwrap(); | ||
| 748 | let res = app.clone().oneshot(req).await.unwrap(); | ||
| 749 | assert_eq!(res.status(), StatusCode::SEE_OTHER); | ||
| 750 | assert_eq!( | ||
| 751 | res.headers().get(header::LOCATION).unwrap(), | ||
| 752 | "/ada/proj/-/pulls/1" | ||
| 753 | ); | ||
| 754 | |||
| 755 | // The PR page shows the compare summary, the diff, and a clean merge panel. | ||
| 756 | let req = Request::builder() | ||
| 757 | .uri("/ada/proj/-/pulls/1") | ||
| 758 | .header(header::COOKIE, cookie.clone()) | ||
| 759 | .body(Body::empty()) | ||
| 760 | .unwrap(); | ||
| 761 | let res = app.clone().oneshot(req).await.unwrap(); | ||
| 762 | assert_eq!(res.status(), StatusCode::OK); | ||
| 763 | let html = body_string(res).await; | ||
| 764 | assert!(html.contains("wants to merge"), "compare summary shown"); | ||
| 765 | assert!(html.contains("feature.txt"), "diff lists the new file"); | ||
| 766 | assert!(html.contains("Merge pull request"), "merge button shown"); | ||
| 767 | |||
| 768 | // Merge it (default merge-commit strategy). | ||
| 769 | let issue = state | ||
| 770 | .store | ||
| 771 | .issue_by_number(&repo.id, 1, true) | ||
| 772 | .await | ||
| 773 | .unwrap() | ||
| 774 | .unwrap(); | ||
| 775 | let req = Request::builder() | ||
| 776 | .method("POST") | ||
| 777 | .uri(format!("/pull/{}/merge", issue.id)) | ||
| 778 | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | ||
| 779 | .header(header::COOKIE, cookie) | ||
| 780 | .body(Body::from(format!("strategy=merge&_csrf={token}"))) | ||
| 781 | .unwrap(); | ||
| 782 | let res = app.oneshot(req).await.unwrap(); | ||
| 783 | assert_eq!(res.status(), StatusCode::SEE_OTHER); | ||
| 784 | |||
| 785 | // The PR is now merged and closed, and main carries the feature file. | ||
| 786 | let pr = state.store.pull_by_issue(&issue.id).await.unwrap().unwrap(); | ||
| 787 | assert!(pr.merged_at.is_some(), "merge recorded"); | ||
| 788 | assert!(pr.merge_sha.is_some(), "merge sha stored"); | ||
| 789 | let closed = state.store.issue_by_id(&issue.id).await.unwrap().unwrap(); | ||
| 790 | assert_eq!(closed.state, model::IssueState::Closed, "PR auto-closed"); | ||
| 791 | |||
| 792 | let main_tip = raw | ||
| 793 | .find_reference("refs/heads/main") | ||
| 794 | .unwrap() | ||
| 795 | .peel_to_commit() | ||
| 796 | .unwrap(); | ||
| 797 | assert!( | ||
| 798 | main_tip.tree().unwrap().get_name("feature.txt").is_some(), | ||
| 799 | "main now contains the merged file" | ||
| 800 | ); | ||
| 801 | assert_eq!(main_tip.parent_count(), 2, "a merge commit"); | ||
| 802 | } | ||
| 803 | |||
| 804 | #[tokio::test] | ||
| 805 | async fn pulls_404_when_disabled() { | ||
| 806 | let (state, app) = app().await; | ||
| 807 | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | ||
| 808 | let repo = state | ||
| 809 | .store | ||
| 810 | .create_repo(NewRepo { | ||
| 811 | owner_id: ada.id, | ||
| 812 | group_id: None, | ||
| 813 | name: "nopr".to_string(), | ||
| 814 | path: "nopr".to_string(), | ||
| 815 | description: None, | ||
| 816 | visibility: model::Visibility::Public, | ||
| 817 | default_branch: "main".to_string(), | ||
| 818 | }) | ||
| 819 | .await | ||
| 820 | .unwrap(); | ||
| 821 | state | ||
| 822 | .store | ||
| 823 | .set_feature_enabled(&repo.id, "pulls", false) | ||
| 824 | .await | ||
| 825 | .unwrap(); | ||
| 826 | let res = app.oneshot(get("/ada/nopr/-/pulls")).await.unwrap(); | ||
| 827 | assert_eq!(res.status(), StatusCode::NOT_FOUND); | ||
| 828 | } | ||
| 829 | |||
| 624 | #[tokio::test] | 830 | #[tokio::test] |
| 625 | async fn settings_requires_authentication() { | 831 | async fn settings_requires_authentication() { |
| 626 | let (_state, app) = app().await; | 832 | let (_state, app) = app().await; |