max / makenotwork
40 files changed,
+1658 insertions,
-331 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.2.10" | |
| 3 | + | version = "0.3.0" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "../../LICENSE" | |
| 6 | 6 |
| @@ -0,0 +1 @@ | |||
| 1 | + | ALTER TABLE users ADD COLUMN notify_issues BOOLEAN NOT NULL DEFAULT true; |
| @@ -365,6 +365,9 @@ mod tests { | |||
| 365 | 365 | git_ssh_host: None, | |
| 366 | 366 | mt_base_url: None, | |
| 367 | 367 | fan_plus_price_id: None, | |
| 368 | + | build_trigger_token: None, | |
| 369 | + | build_host_linux: None, | |
| 370 | + | build_host_darwin: None, | |
| 368 | 371 | }; | |
| 369 | 372 | assert!(require_admin(&user, &config).is_ok()); | |
| 370 | 373 | } | |
| @@ -417,6 +420,9 @@ mod tests { | |||
| 417 | 420 | git_ssh_host: None, | |
| 418 | 421 | mt_base_url: None, | |
| 419 | 422 | fan_plus_price_id: None, | |
| 423 | + | build_trigger_token: None, | |
| 424 | + | build_host_linux: None, | |
| 425 | + | build_host_darwin: None, | |
| 420 | 426 | }; | |
| 421 | 427 | assert!(require_admin(&user, &config).is_err()); | |
| 422 | 428 | } |
| @@ -736,14 +736,8 @@ async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> { | |||
| 736 | 736 | } | |
| 737 | 737 | GitOperation::UploadPack | GitOperation::Archive => { | |
| 738 | 738 | // Clone/fetch: check visibility | |
| 739 | - | match repo.visibility.as_str() { | |
| 740 | - | "private" => { | |
| 741 | - | if user_id != owner_user.id { | |
| 742 | - | anyhow::bail!("repository not found"); | |
| 743 | - | } | |
| 744 | - | } | |
| 745 | - | // "public" and "unlisted" allow anyone with SSH access | |
| 746 | - | _ => {} | |
| 739 | + | if repo.visibility == "private" && user_id != owner_user.id { | |
| 740 | + | anyhow::bail!("repository not found"); | |
| 747 | 741 | } | |
| 748 | 742 | } | |
| 749 | 743 | } |
| @@ -26,6 +26,18 @@ while read oldrev newrev refname; do | |||
| 26 | 26 | "http://localhost:3000/api/internal/builds/trigger" \ | |
| 27 | 27 | >/dev/null 2>&1 & | |
| 28 | 28 | ;; | |
| 29 | + | refs/heads/*) | |
| 30 | + | BRANCH="${refname#refs/heads/}" | |
| 31 | + | REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)" | |
| 32 | + | REPO_NAME="$(basename "$REPO_PATH" .git)" | |
| 33 | + | OWNER="$(basename "$(dirname "$REPO_PATH")")" | |
| 34 | + | curl -sf -X POST \ | |
| 35 | + | -H "Authorization: Bearer __TOKEN__" \ | |
| 36 | + | -H "Content-Type: application/json" \ | |
| 37 | + | -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \ | |
| 38 | + | "http://localhost:3000/api/internal/issues/process-push" \ | |
| 39 | + | >/dev/null 2>&1 & | |
| 40 | + | ;; | |
| 29 | 41 | esac | |
| 30 | 42 | done | |
| 31 | 43 | "#; | |
| @@ -433,10 +445,14 @@ async fn append_log_bounded( | |||
| 433 | 445 | ) -> crate::error::Result<()> { | |
| 434 | 446 | // Check current log size (approximate — avoids fetching the full log) | |
| 435 | 447 | let build = db::builds::get_build(&state.db, build_id).await?; | |
| 436 | - | if let Some(b) = build { | |
| 437 | - | if b.log.len() + line.len() > BUILD_MAX_LOG_BYTES { | |
| 438 | - | return Ok(()); // silently drop | |
| 448 | + | if let Some(b) = build | |
| 449 | + | && b.log.len() + line.len() > BUILD_MAX_LOG_BYTES | |
| 450 | + | { | |
| 451 | + | if !b.log.ends_with("[log truncated]\n") { | |
| 452 | + | tracing::warn!(build_id = %build_id, "Build log exceeded {} bytes, truncating", BUILD_MAX_LOG_BYTES); | |
| 453 | + | db::builds::append_build_log(&state.db, build_id, "[log truncated]\n").await?; | |
| 439 | 454 | } | |
| 455 | + | return Ok(()); | |
| 440 | 456 | } | |
| 441 | 457 | db::builds::append_build_log(&state.db, build_id, line).await | |
| 442 | 458 | } |
| @@ -118,6 +118,8 @@ pub const GIT_MAX_FILE_SIZE_BYTES: usize = 1_024_000; // 1MB display limit | |||
| 118 | 118 | pub const GIT_COMMITS_PER_PAGE: usize = 30; | |
| 119 | 119 | pub const GIT_DIFF_MAX_FILES: usize = 20; // Inline diff hunks for first N files | |
| 120 | 120 | pub const GIT_DIFF_MAX_LINES: usize = 500; // Per-file line cap for diff display | |
| 121 | + | pub const GIT_REPOS_PER_PAGE: usize = 30; | |
| 122 | + | pub const GIT_FILE_LOG_MAX_WALK: usize = 1000; // Max commits to walk for per-file history | |
| 121 | 123 | ||
| 122 | 124 | // -- Webhook security -- | |
| 123 | 125 | pub const WEBHOOK_TIMESTAMP_TOLERANCE_SECS: u64 = 300; // 5 minutes |
| @@ -1,11 +1,21 @@ | |||
| 1 | 1 | //! Git repository CRUD and lookup queries. | |
| 2 | 2 | ||
| 3 | - | use sqlx::PgPool; | |
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use sqlx::{FromRow, PgPool}; | |
| 4 | 5 | ||
| 5 | 6 | use super::models::DbGitRepo; | |
| 6 | 7 | use super::{GitRepoId, ProjectId, UserId}; | |
| 7 | 8 | use crate::error::Result; | |
| 8 | 9 | ||
| 10 | + | /// A public repo joined with its owner's username, for the explore page. | |
| 11 | + | #[derive(FromRow)] | |
| 12 | + | pub struct PublicRepoWithOwner { | |
| 13 | + | pub name: String, | |
| 14 | + | pub description: String, | |
| 15 | + | pub created_at: DateTime<Utc>, | |
| 16 | + | pub owner_username: String, | |
| 17 | + | } | |
| 18 | + | ||
| 9 | 19 | /// Register a new git repository for a user (default visibility: public). | |
| 10 | 20 | pub async fn create_repo(pool: &PgPool, user_id: UserId, name: &str) -> Result<DbGitRepo> { | |
| 11 | 21 | let repo = sqlx::query_as::<_, DbGitRepo>( | |
| @@ -180,3 +190,38 @@ pub async fn delete_repo(pool: &PgPool, repo_id: GitRepoId) -> Result<()> { | |||
| 180 | 190 | ||
| 181 | 191 | Ok(()) | |
| 182 | 192 | } | |
| 193 | + | ||
| 194 | + | /// List all public repos across all users, newest first, with owner username. | |
| 195 | + | pub async fn get_all_public_repos( | |
| 196 | + | pool: &PgPool, | |
| 197 | + | limit: i64, | |
| 198 | + | offset: i64, | |
| 199 | + | ) -> Result<Vec<PublicRepoWithOwner>> { | |
| 200 | + | let repos = sqlx::query_as::<_, PublicRepoWithOwner>( | |
| 201 | + | r#" | |
| 202 | + | SELECT g.name, g.description, g.created_at, u.username AS owner_username | |
| 203 | + | FROM git_repos g | |
| 204 | + | JOIN users u ON u.id = g.user_id | |
| 205 | + | WHERE g.visibility = 'public' | |
| 206 | + | ORDER BY g.created_at DESC | |
| 207 | + | LIMIT $1 OFFSET $2 | |
| 208 | + | "#, | |
| 209 | + | ) | |
| 210 | + | .bind(limit) | |
| 211 | + | .bind(offset) | |
| 212 | + | .fetch_all(pool) | |
| 213 | + | .await?; | |
| 214 | + | ||
| 215 | + | Ok(repos) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /// Count all public repos (for pagination). | |
| 219 | + | pub async fn count_all_public_repos(pool: &PgPool) -> Result<i64> { | |
| 220 | + | let count: (i64,) = sqlx::query_as( | |
| 221 | + | "SELECT COUNT(*) FROM git_repos WHERE visibility = 'public'", | |
| 222 | + | ) | |
| 223 | + | .fetch_one(pool) | |
| 224 | + | .await?; | |
| 225 | + | ||
| 226 | + | Ok(count.0) | |
| 227 | + | } |
| @@ -132,6 +132,8 @@ pub struct DbUser { | |||
| 132 | 132 | pub notify_follower: bool, | |
| 133 | 133 | /// Whether to email the user when creators they follow publish new content. | |
| 134 | 134 | pub notify_release: bool, | |
| 135 | + | /// Whether to email the repo owner about new issues and comments. | |
| 136 | + | pub notify_issues: bool, | |
| 135 | 137 | /// When the creator last sent a broadcast email (rate limiting). | |
| 136 | 138 | pub last_broadcast_at: Option<DateTime<Utc>>, | |
| 137 | 139 | // Onboarding email drip | |
| @@ -1606,6 +1608,7 @@ mod tests { | |||
| 1606 | 1608 | notify_sale: true, | |
| 1607 | 1609 | notify_follower: true, | |
| 1608 | 1610 | notify_release: true, | |
| 1611 | + | notify_issues: true, | |
| 1609 | 1612 | last_broadcast_at: None, | |
| 1610 | 1613 | onboarding_email_step: 0, | |
| 1611 | 1614 | onboarding_email_sent_at: None, |
| @@ -42,7 +42,7 @@ pub async fn create_passkey( | |||
| 42 | 42 | /// List passkeys for a user (dashboard display). | |
| 43 | 43 | pub async fn list_passkeys(pool: &PgPool, user_id: UserId) -> Result<Vec<DbPasskey>> { | |
| 44 | 44 | let rows: Vec<DbPasskey> = sqlx::query_as( | |
| 45 | - | "SELECT id, name, created_at, last_used_at FROM user_passkeys WHERE user_id = $1 ORDER BY created_at", | |
| 45 | + | "SELECT id, name, created_at, last_used_at FROM user_passkeys WHERE user_id = $1 ORDER BY created_at LIMIT 100", | |
| 46 | 46 | ) | |
| 47 | 47 | .bind(user_id) | |
| 48 | 48 | .fetch_all(pool) |
| @@ -34,7 +34,7 @@ pub async fn add_key( | |||
| 34 | 34 | /// List all SSH keys for a user, newest first. | |
| 35 | 35 | pub async fn list_keys_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbSshKey>> { | |
| 36 | 36 | let keys = sqlx::query_as::<_, DbSshKey>( | |
| 37 | - | "SELECT * FROM ssh_keys WHERE user_id = $1 ORDER BY created_at DESC", | |
| 37 | + | "SELECT * FROM ssh_keys WHERE user_id = $1 ORDER BY created_at DESC LIMIT 100", | |
| 38 | 38 | ) | |
| 39 | 39 | .bind(user_id) | |
| 40 | 40 | .fetch_all(pool) |
| @@ -435,6 +435,7 @@ pub async fn update_notification_preferences( | |||
| 435 | 435 | notify_follower: bool, | |
| 436 | 436 | notify_release: bool, | |
| 437 | 437 | login_notification_enabled: bool, | |
| 438 | + | notify_issues: bool, | |
| 438 | 439 | ) -> Result<()> { | |
| 439 | 440 | sqlx::query( | |
| 440 | 441 | r#" | |
| @@ -443,6 +444,7 @@ pub async fn update_notification_preferences( | |||
| 443 | 444 | notify_follower = $3, | |
| 444 | 445 | notify_release = $4, | |
| 445 | 446 | login_notification_enabled = $5, | |
| 447 | + | notify_issues = $6, | |
| 446 | 448 | updated_at = NOW() | |
| 447 | 449 | WHERE id = $1 | |
| 448 | 450 | "#, | |
| @@ -452,6 +454,7 @@ pub async fn update_notification_preferences( | |||
| 452 | 454 | .bind(notify_follower) | |
| 453 | 455 | .bind(notify_release) | |
| 454 | 456 | .bind(login_notification_enabled) | |
| 457 | + | .bind(notify_issues) | |
| 455 | 458 | .execute(pool) | |
| 456 | 459 | .await?; | |
| 457 | 460 | ||
| @@ -468,6 +471,7 @@ pub async fn disable_notification(pool: &PgPool, user_id: UserId, preference: &s | |||
| 468 | 471 | "notify_follower" => "UPDATE users SET notify_follower = false, updated_at = NOW() WHERE id = $1", | |
| 469 | 472 | "notify_release" => "UPDATE users SET notify_release = false, updated_at = NOW() WHERE id = $1", | |
| 470 | 473 | "login_notification_enabled" => "UPDATE users SET login_notification_enabled = false, updated_at = NOW() WHERE id = $1", | |
| 474 | + | "notify_issues" => "UPDATE users SET notify_issues = false, updated_at = NOW() WHERE id = $1", | |
| 471 | 475 | _ => return Ok(false), | |
| 472 | 476 | }; | |
| 473 | 477 | let result = sqlx::query(sql).bind(user_id).execute(pool).await?; |
| @@ -638,6 +638,82 @@ Any unused credit codes remain valid until their expiry date. | |||
| 638 | 638 | self.send_email(to_email, subject, &body).await | |
| 639 | 639 | } | |
| 640 | 640 | ||
| 641 | + | /// Notify a repo owner that someone opened a new issue. | |
| 642 | + | #[allow(clippy::too_many_arguments)] | |
| 643 | + | pub async fn send_new_issue_notification( | |
| 644 | + | &self, | |
| 645 | + | to_email: &str, | |
| 646 | + | to_name: Option<&str>, | |
| 647 | + | repo_owner: &str, | |
| 648 | + | repo_name: &str, | |
| 649 | + | issue_number: i32, | |
| 650 | + | issue_title: &str, | |
| 651 | + | author_username: &str, | |
| 652 | + | issue_url: &str, | |
| 653 | + | unsub_url: Option<&str>, | |
| 654 | + | ) -> Result<()> { | |
| 655 | + | let subject = format!("New issue on {}/{}: {}", repo_owner, repo_name, issue_title); | |
| 656 | + | let body = format!( | |
| 657 | + | r#"Hi{name}, | |
| 658 | + | ||
| 659 | + | {author} opened issue #{number} on {owner}/{repo}: | |
| 660 | + | ||
| 661 | + | {title} | |
| 662 | + | ||
| 663 | + | View it here: {url} | |
| 664 | + | ||
| 665 | + | - Makenotwork"#, | |
| 666 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 667 | + | author = author_username, | |
| 668 | + | number = issue_number, | |
| 669 | + | owner = repo_owner, | |
| 670 | + | repo = repo_name, | |
| 671 | + | title = issue_title, | |
| 672 | + | url = issue_url, | |
| 673 | + | ); | |
| 674 | + | ||
| 675 | + | self.send_email_with_unsub(to_email, &subject, &body, unsub_url).await | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | /// Notify about a new comment or status change on an issue. | |
| 679 | + | #[allow(clippy::too_many_arguments)] | |
| 680 | + | pub async fn send_issue_comment_notification( | |
| 681 | + | &self, | |
| 682 | + | to_email: &str, | |
| 683 | + | to_name: Option<&str>, | |
| 684 | + | repo_owner: &str, | |
| 685 | + | repo_name: &str, | |
| 686 | + | issue_number: i32, | |
| 687 | + | issue_title: &str, | |
| 688 | + | commenter_username: &str, | |
| 689 | + | comment_preview: &str, | |
| 690 | + | issue_url: &str, | |
| 691 | + | unsub_url: Option<&str>, | |
| 692 | + | ) -> Result<()> { | |
| 693 | + | let subject = format!("New comment on {}/{}#{}", repo_owner, repo_name, issue_number); | |
| 694 | + | let body = format!( | |
| 695 | + | r#"Hi{name}, | |
| 696 | + | ||
| 697 | + | {commenter} commented on issue #{number} ({title}) in {owner}/{repo}: | |
| 698 | + | ||
| 699 | + | {preview} | |
| 700 | + | ||
| 701 | + | View it here: {url} | |
| 702 | + | ||
| 703 | + | - Makenotwork"#, | |
| 704 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 705 | + | commenter = commenter_username, | |
| 706 | + | number = issue_number, | |
| 707 | + | title = issue_title, | |
| 708 | + | owner = repo_owner, | |
| 709 | + | repo = repo_name, | |
| 710 | + | preview = comment_preview, | |
| 711 | + | url = issue_url, | |
| 712 | + | ); | |
| 713 | + | ||
| 714 | + | self.send_email_with_unsub(to_email, &subject, &body, unsub_url).await | |
| 715 | + | } | |
| 716 | + | ||
| 641 | 717 | /// Send an alert email (monitoring, ops notifications). | |
| 642 | 718 | pub async fn send_alert(&self, to: &str, subject: &str, body: &str) -> Result<()> { | |
| 643 | 719 | self.send_email(to, subject, body).await |
| @@ -472,6 +472,104 @@ pub fn commit_log( | |||
| 472 | 472 | Ok(commits) | |
| 473 | 473 | } | |
| 474 | 474 | ||
| 475 | + | /// Walk commit history for a specific file path, returning only commits that changed the file. | |
| 476 | + | /// | |
| 477 | + | /// Walks up to `max_walk` total commits in the revwalk, collecting those where the blob | |
| 478 | + | /// OID at `path` differs from the parent's blob OID (or the file was added/removed). | |
| 479 | + | pub fn file_commit_log( | |
| 480 | + | repo: &Repository, | |
| 481 | + | commit_oid: Oid, | |
| 482 | + | path: &str, | |
| 483 | + | limit: usize, | |
| 484 | + | offset: usize, | |
| 485 | + | max_walk: usize, | |
| 486 | + | ) -> Result<Vec<CommitInfo>, GitError> { | |
| 487 | + | let mut revwalk = repo.revwalk().map_err(|e| GitError::Git2(e.message().to_string()))?; | |
| 488 | + | revwalk.push(commit_oid).map_err(|e| GitError::Git2(e.message().to_string()))?; | |
| 489 | + | revwalk.set_sorting(Sort::TIME).map_err(|e| GitError::Git2(e.message().to_string()))?; | |
| 490 | + | ||
| 491 | + | let file_path = Path::new(path); | |
| 492 | + | let mut result = Vec::new(); | |
| 493 | + | let mut skipped = 0; | |
| 494 | + | ||
| 495 | + | for (walked, oid_result) in revwalk.enumerate() { | |
| 496 | + | if walked >= max_walk || result.len() >= limit { | |
| 497 | + | break; | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | let oid = match oid_result { | |
| 501 | + | Ok(o) => o, | |
| 502 | + | Err(_) => continue, | |
| 503 | + | }; | |
| 504 | + | let commit = match repo.find_commit(oid) { | |
| 505 | + | Ok(c) => c, | |
| 506 | + | Err(_) => continue, | |
| 507 | + | }; | |
| 508 | + | ||
| 509 | + | let tree = match commit.tree() { | |
| 510 | + | Ok(t) => t, | |
| 511 | + | Err(_) => continue, | |
| 512 | + | }; | |
| 513 | + | ||
| 514 | + | // Get the blob OID at `path` in this commit (None if file doesn't exist) | |
| 515 | + | let current_blob = tree | |
| 516 | + | .get_path(file_path) | |
| 517 | + | .ok() | |
| 518 | + | .map(|entry| entry.id()); | |
| 519 | + | ||
| 520 | + | // Compare against parent(s) | |
| 521 | + | let changed = if commit.parent_count() == 0 { | |
| 522 | + | // Root commit: file is "changed" if it exists | |
| 523 | + | current_blob.is_some() | |
| 524 | + | } else { | |
| 525 | + | // Check first parent only (standard git log behavior) | |
| 526 | + | match commit.parent(0) { | |
| 527 | + | Ok(parent) => { | |
| 528 | + | let parent_blob = parent | |
| 529 | + | .tree() | |
| 530 | + | .ok() | |
| 531 | + | .and_then(|t| t.get_path(file_path).ok()) | |
| 532 | + | .map(|entry| entry.id()); | |
| 533 | + | current_blob != parent_blob | |
| 534 | + | } | |
| 535 | + | Err(_) => current_blob.is_some(), | |
| 536 | + | } | |
| 537 | + | }; | |
| 538 | + | ||
| 539 | + | if !changed { | |
| 540 | + | continue; | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | if skipped < offset { | |
| 544 | + | skipped += 1; | |
| 545 | + | continue; | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | let oid_str = commit.id().to_string(); | |
| 549 | + | let short_oid = oid_str[..7.min(oid_str.len())].to_string(); | |
| 550 | + | let message = commit.message().unwrap_or(""); | |
| 551 | + | let summary = message.lines().next().unwrap_or("").to_string(); | |
| 552 | + | let author = commit.author(); | |
| 553 | + | let author_name = author.name().unwrap_or("Unknown").to_string(); | |
| 554 | + | let author_email = author.email().unwrap_or("").to_string(); | |
| 555 | + | let time = chrono::DateTime::from_timestamp(commit.time().seconds(), 0) | |
| 556 | + | .unwrap_or_default(); | |
| 557 | + | let time_formatted = time.format("%Y-%m-%d %H:%M").to_string(); | |
| 558 | + | ||
| 559 | + | result.push(CommitInfo { | |
| 560 | + | oid: oid_str, | |
| 561 | + | short_oid, | |
| 562 | + | summary, | |
| 563 | + | author_name, | |
| 564 | + | author_email, | |
| 565 | + | time, | |
| 566 | + | time_formatted, | |
| 567 | + | }); | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | Ok(result) | |
| 571 | + | } | |
| 572 | + | ||
| 475 | 573 | /// Look for a README file at the tree root. | |
| 476 | 574 | pub fn find_readme(repo: &Repository, commit_oid: Oid) -> Option<String> { | |
| 477 | 575 | let commit = repo.find_commit(commit_oid).ok()?; | |
| @@ -993,6 +1091,82 @@ mod tests { | |||
| 993 | 1091 | assert!(result.contains("span")); | |
| 994 | 1092 | } | |
| 995 | 1093 | ||
| 1094 | + | /// Create a test repo with two commits: first adds README.md + src/main.rs, | |
| 1095 | + | /// second modifies only src/main.rs. Used for file_commit_log tests. | |
| 1096 | + | fn make_two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf) { | |
| 1097 | + | let tmp = tempfile::TempDir::new().unwrap(); | |
| 1098 | + | let bare_path = tmp.path().join("owner").join("testrepo.git"); | |
| 1099 | + | std::fs::create_dir_all(&bare_path).unwrap(); | |
| 1100 | + | let bare_repo = Repository::init_bare(&bare_path).unwrap(); | |
| 1101 | + | let sig = git2::Signature::now("Test", "test@example.com").unwrap(); | |
| 1102 | + | ||
| 1103 | + | // Commit 1: README.md + src/main.rs | |
| 1104 | + | let readme_oid = bare_repo.blob(b"# Test Repo\n").unwrap(); | |
| 1105 | + | let main_rs_oid = bare_repo.blob(b"fn main() {}\n").unwrap(); | |
| 1106 | + | let mut src_tb = bare_repo.treebuilder(None).unwrap(); | |
| 1107 | + | src_tb.insert("main.rs", main_rs_oid, 0o100644).unwrap(); | |
| 1108 | + | let src_tree_oid = src_tb.write().unwrap(); | |
| 1109 | + | let mut root_tb = bare_repo.treebuilder(None).unwrap(); | |
| 1110 | + | root_tb.insert("README.md", readme_oid, 0o100644).unwrap(); | |
| 1111 | + | root_tb.insert("src", src_tree_oid, 0o040000).unwrap(); | |
| 1112 | + | let root_tree = bare_repo.find_tree(root_tb.write().unwrap()).unwrap(); | |
| 1113 | + | let c1 = bare_repo.commit(Some("refs/heads/main"), &sig, &sig, "Initial commit", &root_tree, &[]).unwrap(); | |
| 1114 | + | bare_repo.set_head("refs/heads/main").unwrap(); | |
| 1115 | + | ||
| 1116 | + | // Commit 2: modify only src/main.rs | |
| 1117 | + | let first = bare_repo.find_commit(c1).unwrap(); | |
| 1118 | + | let main_rs_v2 = bare_repo.blob(b"fn main() { println!(\"hello\"); }\n").unwrap(); | |
| 1119 | + | let mut src_tb2 = bare_repo.treebuilder(None).unwrap(); | |
| 1120 | + | src_tb2.insert("main.rs", main_rs_v2, 0o100644).unwrap(); | |
| 1121 | + | let src2 = src_tb2.write().unwrap(); | |
| 1122 | + | let mut root_tb2 = bare_repo.treebuilder(None).unwrap(); | |
| 1123 | + | root_tb2.insert("README.md", readme_oid, 0o100644).unwrap(); | |
| 1124 | + | root_tb2.insert("src", src2, 0o040000).unwrap(); | |
| 1125 | + | let root_tree2 = bare_repo.find_tree(root_tb2.write().unwrap()).unwrap(); | |
| 1126 | + | bare_repo.commit(Some("refs/heads/main"), &sig, &sig, "Update main.rs", &root_tree2, &[&first]).unwrap(); | |
| 1127 | + | ||
| 1128 | + | (tmp, bare_path) | |
| 1129 | + | } | |
| 1130 | + | ||
| 1131 | + | #[test] | |
| 1132 | + | fn file_commit_log_filters_by_path() { | |
| 1133 | + | let (tmp, _) = make_two_commit_repo(); | |
| 1134 | + | let repo = open_repo(tmp.path(), "owner", "testrepo").unwrap(); | |
| 1135 | + | let oid = resolve_ref(&repo, "main").unwrap(); | |
| 1136 | + | ||
| 1137 | + | // src/main.rs was changed in both commits | |
| 1138 | + | let main_rs_log = file_commit_log(&repo, oid, "src/main.rs", 10, 0, 1000).unwrap(); | |
| 1139 | + | assert_eq!(main_rs_log.len(), 2, "src/main.rs should appear in both commits"); | |
| 1140 | + | assert_eq!(main_rs_log[0].summary, "Update main.rs"); | |
| 1141 | + | assert_eq!(main_rs_log[1].summary, "Initial commit"); | |
| 1142 | + | ||
| 1143 | + | // README.md was only added in the first commit, not changed in the second | |
| 1144 | + | let readme_log = file_commit_log(&repo, oid, "README.md", 10, 0, 1000).unwrap(); | |
| 1145 | + | assert_eq!(readme_log.len(), 1, "README.md should only appear in initial commit"); | |
| 1146 | + | assert_eq!(readme_log[0].summary, "Initial commit"); | |
| 1147 | + | ||
| 1148 | + | // Nonexistent file returns empty | |
| 1149 | + | let nope_log = file_commit_log(&repo, oid, "nope.txt", 10, 0, 1000).unwrap(); | |
| 1150 | + | assert!(nope_log.is_empty(), "Nonexistent file should return empty log"); | |
| 1151 | + | } | |
| 1152 | + | ||
| 1153 | + | #[test] | |
| 1154 | + | fn file_commit_log_pagination() { | |
| 1155 | + | let (tmp, _) = make_two_commit_repo(); | |
| 1156 | + | let repo = open_repo(tmp.path(), "owner", "testrepo").unwrap(); | |
| 1157 | + | let oid = resolve_ref(&repo, "main").unwrap(); | |
| 1158 | + | ||
| 1159 | + | // Get first commit only (limit=1) | |
| 1160 | + | let page1 = file_commit_log(&repo, oid, "src/main.rs", 1, 0, 1000).unwrap(); | |
| 1161 | + | assert_eq!(page1.len(), 1); | |
| 1162 | + | assert_eq!(page1[0].summary, "Update main.rs"); | |
| 1163 | + | ||
| 1164 | + | // Get second commit (offset=1) | |
| 1165 | + | let page2 = file_commit_log(&repo, oid, "src/main.rs", 1, 1, 1000).unwrap(); | |
| 1166 | + | assert_eq!(page2.len(), 1); | |
| 1167 | + | assert_eq!(page2[0].summary, "Initial commit"); | |
| 1168 | + | } | |
| 1169 | + | ||
| 996 | 1170 | #[test] | |
| 997 | 1171 | fn syntax_highlighter_falls_back_to_plain() { | |
| 998 | 1172 | let hl = SyntaxHighlighter::new(); |
| @@ -15,16 +15,16 @@ pub fn is_htmx_request(headers: &HeaderMap) -> bool { | |||
| 15 | 15 | /// Returns `Some(304 Not Modified)` if the client's cached version is still fresh. | |
| 16 | 16 | pub fn check_etag(headers: &HeaderMap, generation: i64) -> Option<Response> { | |
| 17 | 17 | let etag = format!("\"g{}\"", generation); | |
| 18 | - | if let Some(if_none_match) = headers.get(axum::http::header::IF_NONE_MATCH) { | |
| 19 | - | if if_none_match.as_bytes() == etag.as_bytes() { | |
| 20 | - | return Some( | |
| 21 | - | ( | |
| 22 | - | StatusCode::NOT_MODIFIED, | |
| 23 | - | [(axum::http::header::ETAG, HeaderValue::from_str(&etag).unwrap())], | |
| 24 | - | ) | |
| 25 | - | .into_response(), | |
| 26 | - | ); | |
| 27 | - | } | |
| 18 | + | if let Some(if_none_match) = headers.get(axum::http::header::IF_NONE_MATCH) | |
| 19 | + | && if_none_match.as_bytes() == etag.as_bytes() | |
| 20 | + | { | |
| 21 | + | return Some( | |
| 22 | + | ( | |
| 23 | + | StatusCode::NOT_MODIFIED, | |
| 24 | + | [(axum::http::header::ETAG, HeaderValue::from_str(&etag).unwrap())], | |
| 25 | + | ) | |
| 26 | + | .into_response(), | |
| 27 | + | ); | |
| 28 | 28 | } | |
| 29 | 29 | None | |
| 30 | 30 | } |
| @@ -82,15 +82,14 @@ pub(super) async fn add_key( | |||
| 82 | 82 | .await | |
| 83 | 83 | .map_err(|e| { | |
| 84 | 84 | // Check for unique constraint violation (duplicate fingerprint) | |
| 85 | - | if let AppError::Database(ref db_err) = e { | |
| 86 | - | if db_err | |
| 85 | + | if let AppError::Database(ref db_err) = e | |
| 86 | + | && db_err | |
| 87 | 87 | .to_string() | |
| 88 | 88 | .contains("ssh_keys_user_id_fingerprint_key") | |
| 89 | - | { | |
| 90 | - | return AppError::Validation( | |
| 91 | - | "This SSH key is already registered to your account".to_string(), | |
| 92 | - | ); | |
| 93 | - | } | |
| 89 | + | { | |
| 90 | + | return AppError::Validation( | |
| 91 | + | "This SSH key is already registered to your account".to_string(), | |
| 92 | + | ); | |
| 94 | 93 | } | |
| 95 | 94 | e | |
| 96 | 95 | })?; |
| @@ -22,6 +22,7 @@ pub struct UpdatePreferencesForm { | |||
| 22 | 22 | pub notify_follower: Option<String>, | |
| 23 | 23 | pub notify_release: Option<String>, | |
| 24 | 24 | pub login_notification_enabled: Option<String>, | |
| 25 | + | pub notify_issues: Option<String>, | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | 28 | /// Update the authenticated user's notification preferences. | |
| @@ -35,8 +36,9 @@ pub(in crate::routes::api) async fn update_preferences( | |||
| 35 | 36 | let notify_follower = form.notify_follower.as_deref() == Some("on"); | |
| 36 | 37 | let notify_release = form.notify_release.as_deref() == Some("on"); | |
| 37 | 38 | let login_notification_enabled = form.login_notification_enabled.as_deref() == Some("on"); | |
| 39 | + | let notify_issues = form.notify_issues.as_deref() == Some("on"); | |
| 38 | 40 | ||
| 39 | - | db::users::update_notification_preferences(&state.db, user.id, notify_sale, notify_follower, notify_release, login_notification_enabled).await?; | |
| 41 | + | db::users::update_notification_preferences(&state.db, user.id, notify_sale, notify_follower, notify_release, login_notification_enabled, notify_issues).await?; | |
| 40 | 42 | ||
| 41 | 43 | Ok(Html(SaveStatusTemplate { | |
| 42 | 44 | success: true, |
| @@ -375,14 +375,90 @@ pub(super) async fn user_repos( | |||
| 375 | 375 | }) | |
| 376 | 376 | } | |
| 377 | 377 | ||
| 378 | - | /// `GET /git` — redirect to user's repos or login. | |
| 379 | - | #[tracing::instrument(skip_all, name = "git::git_landing")] | |
| 378 | + | /// Query params for explore page pagination. | |
| 379 | + | #[derive(Deserialize)] | |
| 380 | + | pub(super) struct ExploreQuery { | |
| 381 | + | page: Option<usize>, | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | /// `GET /git` — public explore page listing all public repos. | |
| 385 | + | #[tracing::instrument(skip_all, name = "git::git_explore")] | |
| 380 | 386 | pub(super) async fn git_landing( | |
| 387 | + | State(state): State<AppState>, | |
| 388 | + | session: Session, | |
| 381 | 389 | MaybeUser(maybe_user): MaybeUser, | |
| 382 | - | ) -> Response { | |
| 383 | - | if let Some(user) = maybe_user { | |
| 384 | - | axum::response::Redirect::to(&format!("/git/{}", user.username)).into_response() | |
| 385 | - | } else { | |
| 386 | - | axum::response::Redirect::to("/login?next=/git").into_response() | |
| 387 | - | } | |
| 390 | + | Query(query): Query<ExploreQuery>, | |
| 391 | + | ) -> Result<impl IntoResponse> { | |
| 392 | + | let page = query.page.unwrap_or(1).max(1); | |
| 393 | + | let limit = constants::GIT_REPOS_PER_PAGE; | |
| 394 | + | let offset = (page - 1) * limit; | |
| 395 | + | ||
| 396 | + | let repos = db::git_repos::get_all_public_repos(&state.db, (limit + 1) as i64, offset as i64).await?; | |
| 397 | + | let has_more = repos.len() > limit; | |
| 398 | + | let repos: Vec<_> = repos.into_iter().take(limit).collect(); | |
| 399 | + | let total_count = db::git_repos::count_all_public_repos(&state.db).await?; | |
| 400 | + | ||
| 401 | + | let csrf_token = get_csrf_token(&session).await; | |
| 402 | + | ||
| 403 | + | Ok(GitExploreTemplate { | |
| 404 | + | csrf_token, | |
| 405 | + | session_user: maybe_user, | |
| 406 | + | repos, | |
| 407 | + | page, | |
| 408 | + | has_more, | |
| 409 | + | total_count, | |
| 410 | + | }) | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | /// `GET /git/{owner}/{repo}/log/{ref}/{*path}` — per-file commit history. | |
| 414 | + | #[tracing::instrument(skip_all, name = "git::file_log")] | |
| 415 | + | pub(super) async fn file_log( | |
| 416 | + | State(state): State<AppState>, | |
| 417 | + | session: Session, | |
| 418 | + | MaybeUser(maybe_user): MaybeUser, | |
| 419 | + | Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>, | |
| 420 | + | Query(query): Query<CommitQuery>, | |
| 421 | + | ) -> Result<impl IntoResponse> { | |
| 422 | + | let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?; | |
| 423 | + | let refs = git::list_refs(&resolved.git_repo); | |
| 424 | + | let commit_oid = git::resolve_ref(&resolved.git_repo, &git_ref)?; | |
| 425 | + | ||
| 426 | + | let page = query.page.unwrap_or(1).max(1); | |
| 427 | + | let limit = constants::GIT_COMMITS_PER_PAGE; | |
| 428 | + | let offset = (page - 1) * limit; | |
| 429 | + | ||
| 430 | + | let commits = git::file_commit_log( | |
| 431 | + | &resolved.git_repo, | |
| 432 | + | commit_oid, | |
| 433 | + | &path, | |
| 434 | + | limit + 1, | |
| 435 | + | offset, | |
| 436 | + | constants::GIT_FILE_LOG_MAX_WALK, | |
| 437 | + | )?; | |
| 438 | + | let has_more = commits.len() > limit; | |
| 439 | + | let commits: Vec<_> = commits.into_iter().take(limit).collect(); | |
| 440 | + | ||
| 441 | + | let filename = path.rsplit('/').next().unwrap_or(&path).to_string(); | |
| 442 | + | let breadcrumbs = build_breadcrumbs(&path); | |
| 443 | + | let csrf_token = get_csrf_token(&session).await; | |
| 444 | + | let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id); | |
| 445 | + | let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await.unwrap_or((0, 0)); | |
| 446 | + | ||
| 447 | + | Ok(GitFileLogTemplate { | |
| 448 | + | csrf_token, | |
| 449 | + | session_user: maybe_user, | |
| 450 | + | owner, | |
| 451 | + | repo_name, | |
| 452 | + | current_ref: git_ref, | |
| 453 | + | refs, | |
| 454 | + | file_path: path, | |
| 455 | + | filename, | |
| 456 | + | breadcrumbs, | |
| 457 | + | commits, | |
| 458 | + | page, | |
| 459 | + | has_more, | |
| 460 | + | open_issue_count, | |
| 461 | + | is_owner, | |
| 462 | + | active_tab: "files", | |
| 463 | + | }) | |
| 388 | 464 | } |
| @@ -28,6 +28,7 @@ pub fn git_routes() -> Router<AppState> { | |||
| 28 | 28 | .route("/git/{owner}/{repo}/commits/{ref}", get(browsing::commit_log)) | |
| 29 | 29 | .route("/git/{owner}/{repo}/commit/{oid}", get(browsing::commit_detail_page)) | |
| 30 | 30 | .route("/git/{owner}/{repo}/blame/{ref}/{*path}", get(browsing::blame_view)) | |
| 31 | + | .route("/git/{owner}/{repo}/log/{ref}/{*path}", get(browsing::file_log)) | |
| 31 | 32 | .route("/git/{owner}/{repo}/raw/{ref}/{*path}", get(raw::raw_file)) | |
| 32 | 33 | .route("/git/{owner}", get(browsing::user_repos)) | |
| 33 | 34 | .route("/git", get(browsing::git_landing)) |
| @@ -1,17 +1,23 @@ | |||
| 1 | 1 | //! Git issue tracker routes: create, view, list, close/reopen, comment, labels. | |
| 2 | + | //! Also includes the commit-message issue reference parser and process-push endpoint. | |
| 2 | 3 | ||
| 3 | 4 | use axum::{ | |
| 4 | 5 | extract::{Path, Query, State}, | |
| 6 | + | http::StatusCode, | |
| 5 | 7 | response::{IntoResponse, Redirect}, | |
| 6 | 8 | routing::{get, post}, | |
| 7 | - | Form, Router, | |
| 9 | + | Form, Json, Router, | |
| 8 | 10 | }; | |
| 11 | + | use regex::Regex; | |
| 9 | 12 | use serde::Deserialize; | |
| 13 | + | use std::collections::HashMap; | |
| 14 | + | use std::sync::LazyLock; | |
| 10 | 15 | use tower_sessions::Session; | |
| 11 | 16 | ||
| 12 | 17 | use crate::{ | |
| 13 | 18 | auth::{AuthUser, MaybeUser}, | |
| 14 | - | db::{self, IssueCommentId, IssueStatus, IssueLabelId, ProjectId}, | |
| 19 | + | db::{self, IssueCommentId, IssueStatus, IssueLabelId, ProjectId, UserId}, | |
| 20 | + | email, | |
| 15 | 21 | error::{AppError, Result}, | |
| 16 | 22 | helpers::get_csrf_token, | |
| 17 | 23 | markdown, | |
| @@ -48,6 +54,8 @@ pub fn git_issue_routes() -> Router<AppState> { | |||
| 48 | 54 | .route("/git/{owner}/{repo}/settings", get(repo_settings_form)) | |
| 49 | 55 | .route("/git/{owner}/{repo}/settings", post(repo_settings_save)) | |
| 50 | 56 | .route("/git/{owner}/{repo}/settings/delete", post(repo_settings_delete)) | |
| 57 | + | // Internal: commit-message issue references | |
| 58 | + | .route("/api/internal/issues/process-push", post(process_push)) | |
| 51 | 59 | } | |
| 52 | 60 | ||
| 53 | 61 | // ── Helpers ── | |
| @@ -198,6 +206,14 @@ async fn issue_create( | |||
| 198 | 206 | } | |
| 199 | 207 | } | |
| 200 | 208 | ||
| 209 | + | // Notify repo owner (if creator != owner and owner has notify_issues enabled) | |
| 210 | + | if user.id != resolved.db_user.id { | |
| 211 | + | notify_new_issue( | |
| 212 | + | &state, &resolved.db_user, &owner, &repo_name, | |
| 213 | + | issue.number, title, &user.username, | |
| 214 | + | ); | |
| 215 | + | } | |
| 216 | + | ||
| 201 | 217 | Ok(Redirect::to(&format!( | |
| 202 | 218 | "/git/{}/{}/issues/{}", | |
| 203 | 219 | owner, repo_name, issue.number | |
| @@ -374,6 +390,16 @@ async fn issue_close( | |||
| 374 | 390 | ||
| 375 | 391 | db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Closed).await?; | |
| 376 | 392 | ||
| 393 | + | // Notify issue author (if actor != author) | |
| 394 | + | if user.id != issue.author_user_id { | |
| 395 | + | let preview = format!("Issue was closed by {}.", user.username); | |
| 396 | + | notify_issue_comment( | |
| 397 | + | &state, &resolved.db_user, issue.author_user_id, user.id, | |
| 398 | + | &owner, &repo_name, number, &issue.title, | |
| 399 | + | &user.username, &preview, | |
| 400 | + | ); | |
| 401 | + | } | |
| 402 | + | ||
| 377 | 403 | Ok(Redirect::to(&format!( | |
| 378 | 404 | "/git/{}/{}/issues/{}", | |
| 379 | 405 | owner, repo_name, number | |
| @@ -399,6 +425,16 @@ async fn issue_reopen( | |||
| 399 | 425 | ||
| 400 | 426 | db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Open).await?; | |
| 401 | 427 | ||
| 428 | + | // Notify issue author (if actor != author) | |
| 429 | + | if user.id != issue.author_user_id { | |
| 430 | + | let preview = format!("Issue was reopened by {}.", user.username); | |
| 431 | + | notify_issue_comment( | |
| 432 | + | &state, &resolved.db_user, issue.author_user_id, user.id, | |
| 433 | + | &owner, &repo_name, number, &issue.title, | |
| 434 | + | &user.username, &preview, | |
| 435 | + | ); | |
| 436 | + | } | |
| 437 | + | ||
| 402 | 438 | Ok(Redirect::to(&format!( | |
| 403 | 439 | "/git/{}/{}/issues/{}", | |
| 404 | 440 | owner, repo_name, number | |
| @@ -432,6 +468,13 @@ async fn issue_comment( | |||
| 432 | 468 | ||
| 433 | 469 | db::issues::create_comment(&state.db, issue.id, user.id, body_md, &body_html).await?; | |
| 434 | 470 | ||
| 471 | + | // Notify repo owner + issue author (deduplicated, excluding commenter) | |
| 472 | + | notify_issue_comment( | |
| 473 | + | &state, &resolved.db_user, issue.author_user_id, user.id, | |
| 474 | + | &owner, &repo_name, issue.number, &issue.title, | |
| 475 | + | &user.username, body_md, | |
| 476 | + | ); | |
| 477 | + | ||
| 435 | 478 | Ok(Redirect::to(&format!( | |
| 436 | 479 | "/git/{}/{}/issues/{}", | |
| 437 | 480 | owner, repo_name, number | |
| @@ -762,3 +805,391 @@ async fn repo_settings_delete( | |||
| 762 | 805 | ||
| 763 | 806 | Ok(Redirect::to(&format!("/git/{}", owner))) | |
| 764 | 807 | } | |
| 808 | + | ||
| 809 | + | // ── Email notification helpers ── | |
| 810 | + | ||
| 811 | + | /// Fire-and-forget notification to repo owner about a new issue. | |
| 812 | + | fn notify_new_issue( | |
| 813 | + | state: &AppState, | |
| 814 | + | repo_owner: &db::DbUser, | |
| 815 | + | owner_name: &str, | |
| 816 | + | repo_name: &str, | |
| 817 | + | issue_number: i32, | |
| 818 | + | issue_title: &str, | |
| 819 | + | author_username: &str, | |
| 820 | + | ) { | |
| 821 | + | if !repo_owner.notify_issues { | |
| 822 | + | return; | |
| 823 | + | } | |
| 824 | + | let email_client = state.email.clone(); | |
| 825 | + | let to_email = repo_owner.email.clone(); | |
| 826 | + | let to_name = repo_owner.display_name.clone(); | |
| 827 | + | let owner_id = repo_owner.id; | |
| 828 | + | let host_url = state.config.host_url.clone(); | |
| 829 | + | let signing_secret = state.config.signing_secret.clone(); | |
| 830 | + | let owner_name = owner_name.to_string(); | |
| 831 | + | let repo_name = repo_name.to_string(); | |
| 832 | + | let issue_title = issue_title.to_string(); | |
| 833 | + | let author_username = author_username.to_string(); | |
| 834 | + | ||
| 835 | + | tokio::spawn(async move { | |
| 836 | + | let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number); | |
| 837 | + | let unsub_url = email::generate_unsubscribe_url( | |
| 838 | + | &host_url, owner_id, "issue", &owner_id.to_string(), &signing_secret, | |
| 839 | + | ); | |
| 840 | + | if let Err(e) = email_client | |
| 841 | + | .send_new_issue_notification( | |
| 842 | + | &to_email, | |
| 843 | + | to_name.as_deref(), | |
| 844 | + | &owner_name, | |
| 845 | + | &repo_name, | |
| 846 | + | issue_number, | |
| 847 | + | &issue_title, | |
| 848 | + | &author_username, | |
| 849 | + | &issue_url, | |
| 850 | + | Some(&unsub_url), | |
| 851 | + | ) | |
| 852 | + | .await | |
| 853 | + | { | |
| 854 | + | tracing::error!(error = ?e, "failed to send new issue notification"); | |
| 855 | + | } | |
| 856 | + | }); | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | /// Fire-and-forget notification to repo owner + issue author about a comment or status change. | |
| 860 | + | #[allow(clippy::too_many_arguments)] | |
| 861 | + | fn notify_issue_comment( | |
| 862 | + | state: &AppState, | |
| 863 | + | repo_owner: &db::DbUser, | |
| 864 | + | issue_author_id: UserId, | |
| 865 | + | commenter_id: UserId, | |
| 866 | + | owner_name: &str, | |
| 867 | + | repo_name: &str, | |
| 868 | + | issue_number: i32, | |
| 869 | + | issue_title: &str, | |
| 870 | + | commenter_username: &str, | |
| 871 | + | comment_text: &str, | |
| 872 | + | ) { | |
| 873 | + | // Collect recipients: repo owner + issue author, minus the commenter, deduplicated | |
| 874 | + | let mut recipient_ids = Vec::new(); | |
| 875 | + | if repo_owner.id != commenter_id { | |
| 876 | + | recipient_ids.push(repo_owner.id); | |
| 877 | + | } | |
| 878 | + | if issue_author_id != commenter_id && issue_author_id != repo_owner.id { | |
| 879 | + | recipient_ids.push(issue_author_id); | |
| 880 | + | } | |
| 881 | + | if recipient_ids.is_empty() { | |
| 882 | + | return; | |
| 883 | + | } | |
| 884 | + | ||
| 885 | + | let db = state.db.clone(); | |
| 886 | + | let email_client = state.email.clone(); | |
| 887 | + | let host_url = state.config.host_url.clone(); | |
| 888 | + | let signing_secret = state.config.signing_secret.clone(); | |
| 889 | + | let owner_name = owner_name.to_string(); | |
| 890 | + | let repo_name = repo_name.to_string(); | |
| 891 | + | let issue_title = issue_title.to_string(); | |
| 892 | + | let commenter_username = commenter_username.to_string(); | |
| 893 | + | // Truncate preview to ~200 chars | |
| 894 | + | let preview: String = comment_text.chars().take(200).collect(); | |
| 895 | + | ||
| 896 | + | tokio::spawn(async move { | |
| 897 | + | let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number); | |
| 898 | + | ||
| 899 | + | for recipient_id in recipient_ids { | |
| 900 | + | let user = match db::users::get_user_by_id(&db, recipient_id).await { | |
| 901 | + | Ok(Some(u)) => u, | |
| 902 | + | _ => continue, | |
| 903 | + | }; | |
| 904 | + | if !user.notify_issues { | |
| 905 | + | continue; | |
| 906 | + | } | |
| 907 | + | let unsub_url = email::generate_unsubscribe_url( | |
| 908 | + | &host_url, recipient_id, "issue", &recipient_id.to_string(), &signing_secret, | |
| 909 | + | ); | |
| 910 | + | if let Err(e) = email_client | |
| 911 | + | .send_issue_comment_notification( | |
| 912 | + | &user.email, | |
| 913 | + | user.display_name.as_deref(), | |
| 914 | + | &owner_name, | |
| 915 | + | &repo_name, | |
| 916 | + | issue_number, | |
| 917 | + | &issue_title, | |
| 918 | + | &commenter_username, | |
| 919 | + | &preview, | |
| 920 | + | &issue_url, | |
| 921 | + | Some(&unsub_url), | |
| 922 | + | ) | |
| 923 | + | .await | |
| 924 | + | { | |
| 925 | + | tracing::error!(error = ?e, recipient = %recipient_id, "failed to send issue comment notification"); | |
| 926 | + | } | |
| 927 | + | } | |
| 928 | + | }); | |
| 929 | + | } | |
| 930 | + | ||
| 931 | + | // ── Commit message issue references ── | |
| 932 | + | ||
| 933 | + | #[derive(Debug, PartialEq, Eq)] | |
| 934 | + | enum IssueRefAction { | |
| 935 | + | Close, | |
| 936 | + | Reference, | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | #[derive(Debug, PartialEq, Eq)] | |
| 940 | + | struct IssueRef { | |
| 941 | + | number: i32, | |
| 942 | + | action: IssueRefAction, | |
| 943 | + | } | |
| 944 | + | ||
| 945 | + | /// Parse issue references from a commit message. | |
| 946 | + | /// | |
| 947 | + | /// Recognizes: | |
| 948 | + | /// - Close: `fix(es|ed|s|d)? #N`, `close(s|d)? #N`, `resolve(s|d)? #N` | |
| 949 | + | /// - Reference: `ref(s)? #N`, `reference(s)? #N` | |
| 950 | + | /// | |
| 951 | + | /// Deduplicates by issue number (close wins over reference). | |
| 952 | + | fn parse_issue_refs(message: &str) -> Vec<IssueRef> { | |
| 953 | + | static CLOSE_RE: LazyLock<Regex> = LazyLock::new(|| { | |
| 954 | + | Regex::new(r"(?i)(?:fix|close|resolve)(?:es|ed|s|d)?\s+#(\d+)").unwrap() | |
| 955 | + | }); | |
| 956 | + | static REF_RE: LazyLock<Regex> = LazyLock::new(|| { | |
| 957 | + | Regex::new(r"(?i)(?:ref|reference)s?\s+#(\d+)").unwrap() | |
| 958 | + | }); | |
| 959 | + | ||
| 960 | + | let mut by_number: HashMap<i32, IssueRefAction> = HashMap::new(); | |
| 961 | + | ||
| 962 | + | for cap in CLOSE_RE.captures_iter(message) { | |
| 963 | + | if let Ok(n) = cap[1].parse::<i32>() { | |
| 964 | + | // Close always wins | |
| 965 | + | by_number.insert(n, IssueRefAction::Close); | |
| 966 | + | } | |
| 967 | + | } | |
| 968 | + | ||
| 969 | + | for cap in REF_RE.captures_iter(message) { | |
| 970 | + | if let Ok(n) = cap[1].parse::<i32>() { | |
| 971 | + | by_number.entry(n).or_insert(IssueRefAction::Reference); | |
| 972 | + | } | |
| 973 | + | } | |
| 974 | + | ||
| 975 | + | let mut refs: Vec<IssueRef> = by_number | |
| 976 | + | .into_iter() | |
| 977 | + | .map(|(number, action)| IssueRef { number, action }) | |
| 978 | + | .collect(); | |
| 979 | + | refs.sort_by_key(|r| r.number); | |
| 980 | + | refs | |
| 981 | + | } | |
| 982 | + | ||
| 983 | + | // ── Process push endpoint ── | |
| 984 | + | ||
| 985 | + | #[derive(Deserialize)] | |
| 986 | + | struct ProcessPushRequest { | |
| 987 | + | repo_owner: String, | |
| 988 | + | repo_name: String, | |
| 989 | + | ref_name: String, | |
| 990 | + | before: String, | |
| 991 | + | after: String, | |
| 992 | + | } | |
| 993 | + | ||
| 994 | + | /// `POST /api/internal/issues/process-push` | |
| 995 | + | /// | |
| 996 | + | /// Called by the post-receive hook to process commit messages for issue references. | |
| 997 | + | /// Auth: Bearer token (same BUILD_TRIGGER_TOKEN as the build pipeline). | |
| 998 | + | #[tracing::instrument(skip_all, name = "git_issues::process_push")] | |
| 999 | + | async fn process_push( | |
| 1000 | + | State(state): State<AppState>, | |
| 1001 | + | headers: axum::http::HeaderMap, | |
| 1002 | + | Json(req): Json<ProcessPushRequest>, | |
| 1003 | + | ) -> Result<impl axum::response::IntoResponse> { | |
| 1004 | + | // Validate Bearer token | |
| 1005 | + | let expected_token = state.config.build_trigger_token.as_deref() | |
| 1006 | + | .ok_or(AppError::Internal(anyhow::anyhow!("BUILD_TRIGGER_TOKEN not configured")))?; | |
| 1007 | + | ||
| 1008 | + | let auth_header = headers | |
| 1009 | + | .get("authorization") | |
| 1010 | + | .and_then(|v| v.to_str().ok()) | |
| 1011 | + | .unwrap_or(""); | |
| 1012 | + | let provided_token = auth_header.strip_prefix("Bearer ").unwrap_or(""); | |
| 1013 | + | ||
| 1014 | + | if provided_token.is_empty() || provided_token != expected_token { | |
| 1015 | + | return Err(AppError::Forbidden); | |
| 1016 | + | } | |
| 1017 | + | ||
| 1018 | + | // Look up repo owner + repo | |
| 1019 | + | let owner_user = db::users::get_user_by_username( | |
| 1020 | + | &state.db, | |
| 1021 | + | &db::Username::from_trusted(req.repo_owner.clone()), | |
| 1022 | + | ) | |
| 1023 | + | .await? | |
| 1024 | + | .ok_or(AppError::NotFound)?; | |
| 1025 | + | ||
| 1026 | + | let repo = db::git_repos::get_repo_by_user_and_name(&state.db, owner_user.id, &req.repo_name) | |
| 1027 | + | .await? | |
| 1028 | + | .ok_or(AppError::NotFound)?; | |
| 1029 | + | ||
| 1030 | + | // Open the bare repo and collect commit refs synchronously (git2 types are !Send) | |
| 1031 | + | let commit_refs = { | |
| 1032 | + | let root = repos_root(&state)?; | |
| 1033 | + | let git_repo = crate::git::open_repo(&root, &req.repo_owner, &req.repo_name) | |
| 1034 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to open repo: {}", e)))?; | |
| 1035 | + | ||
| 1036 | + | let after_oid = git2::Oid::from_str(&req.after) | |
| 1037 | + | .map_err(|e| AppError::BadRequest(format!("invalid 'after' oid: {}", e)))?; | |
| 1038 | + | ||
| 1039 | + | let mut revwalk = git_repo.revwalk() | |
| 1040 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("revwalk init failed: {}", e)))?; | |
| 1041 | + | revwalk.push(after_oid) | |
| 1042 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("revwalk push failed: {}", e)))?; | |
| 1043 | + | ||
| 1044 | + | let is_new_branch = req.before.chars().all(|c| c == '0'); | |
| 1045 | + | if !is_new_branch | |
| 1046 | + | && let Ok(before_oid) = git2::Oid::from_str(&req.before) | |
| 1047 | + | { | |
| 1048 | + | let _ = revwalk.hide(before_oid); | |
| 1049 | + | } | |
| 1050 | + | ||
| 1051 | + | let max_commits = if is_new_branch { 1 } else { 50 }; | |
| 1052 | + | let mut collected: Vec<(String, String, Vec<IssueRef>)> = Vec::new(); | |
| 1053 | + | ||
| 1054 | + | for oid_result in revwalk.take(max_commits) { | |
| 1055 | + | let oid = match oid_result { | |
| 1056 | + | Ok(o) => o, | |
| 1057 | + | Err(_) => continue, | |
| 1058 | + | }; | |
| 1059 | + | let commit = match git_repo.find_commit(oid) { | |
| 1060 | + | Ok(c) => c, | |
| 1061 | + | Err(_) => continue, | |
| 1062 | + | }; | |
| 1063 | + | let message = commit.message().unwrap_or(""); | |
| 1064 | + | let refs = parse_issue_refs(message); | |
| 1065 | + | if !refs.is_empty() { | |
| 1066 | + | let oid_str = oid.to_string(); | |
| 1067 | + | let short_oid = oid_str[..7.min(oid_str.len())].to_string(); | |
| 1068 | + | collected.push((oid_str, short_oid, refs)); | |
| 1069 | + | } | |
| 1070 | + | } | |
| 1071 | + | collected | |
| 1072 | + | }; | |
| 1073 | + | ||
| 1074 | + | // Now process DB operations (async-safe, git2 types are dropped) | |
| 1075 | + | let mut processed = 0u32; | |
| 1076 | + | for (oid_str, short_oid, refs) in &commit_refs { | |
| 1077 | + | for issue_ref in refs { | |
| 1078 | + | let issue = match db::issues::get_issue_by_number(&state.db, repo.id, issue_ref.number).await { | |
| 1079 | + | Ok(Some(i)) => i, | |
| 1080 | + | _ => continue, | |
| 1081 | + | }; | |
| 1082 | + | ||
| 1083 | + | match issue_ref.action { | |
| 1084 | + | IssueRefAction::Close => { | |
| 1085 | + | if issue.status == IssueStatus::Open { | |
| 1086 | + | let _ = db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Closed).await; | |
| 1087 | + | } | |
| 1088 | + | let body_md = format!( | |
| 1089 | + | "Closed via commit [`{}`](/git/{}/{}/commit/{}) on `{}`.", | |
| 1090 | + | short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name, | |
| 1091 | + | ); | |
| 1092 | + | let body_html = markdown::render_markdown(&body_md); | |
| 1093 | + | let _ = db::issues::create_comment( | |
| 1094 | + | &state.db, issue.id, owner_user.id, &body_md, &body_html, | |
| 1095 | + | ).await; | |
| 1096 | + | } | |
| 1097 | + | IssueRefAction::Reference => { | |
| 1098 | + | let body_md = format!( | |
| 1099 | + | "Referenced in commit [`{}`](/git/{}/{}/commit/{}) on `{}`.", | |
| 1100 | + | short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name, | |
| 1101 | + | ); | |
| 1102 | + | let body_html = markdown::render_markdown(&body_md); | |
| 1103 | + | let _ = db::issues::create_comment( | |
| 1104 | + | &state.db, issue.id, owner_user.id, &body_md, &body_html, | |
| 1105 | + | ).await; | |
| 1106 | + | } | |
| 1107 | + | } | |
| 1108 | + | processed += 1; | |
| 1109 | + | } | |
| 1110 | + | } | |
| 1111 | + | ||
| 1112 | + | Ok((StatusCode::OK, Json(serde_json::json!({ "processed": processed })))) | |
| 1113 | + | } | |
| 1114 | + | ||
| 1115 | + | // ── Unit Tests ── | |
| 1116 | + | ||
| 1117 | + | #[cfg(test)] | |
| 1118 | + | mod tests { | |
| 1119 | + | use super::*; | |
| 1120 | + | ||
| 1121 | + | #[test] | |
| 1122 | + | fn parse_issue_refs_fixes() { | |
| 1123 | + | assert_eq!( | |
| 1124 | + | parse_issue_refs("Fixes #123"), | |
| 1125 | + | vec![IssueRef { number: 123, action: IssueRefAction::Close }] | |
| 1126 | + | ); | |
| 1127 | + | } | |
| 1128 | + | ||
| 1129 | + | #[test] | |
| 1130 | + | fn parse_issue_refs_closes() { | |
| 1131 | + | assert_eq!( | |
| 1132 | + | parse_issue_refs("Closes #456"), | |
| 1133 | + | vec![IssueRef { number: 456, action: IssueRefAction::Close }] | |
| 1134 | + | ); | |
| 1135 | + | } | |
| 1136 | + | ||
| 1137 | + | #[test] | |
| 1138 | + | fn parse_issue_refs_resolves() { | |
| 1139 | + | assert_eq!( | |
| 1140 | + | parse_issue_refs("Resolves #7"), | |
| 1141 | + | vec![IssueRef { number: 7, action: IssueRefAction::Close }] | |
| 1142 | + | ); | |
| 1143 | + | } | |
| 1144 | + | ||
| 1145 | + | #[test] | |
| 1146 | + | fn parse_issue_refs_refs() { | |
| 1147 | + | assert_eq!( | |
| 1148 | + | parse_issue_refs("Refs #10"), | |
| 1149 | + | vec![IssueRef { number: 10, action: IssueRefAction::Reference }] | |
| 1150 | + | ); | |
| 1151 | + | } | |
| 1152 | + | ||
| 1153 | + | #[test] | |
| 1154 | + | fn parse_issue_refs_references() { | |
| 1155 | + | assert_eq!( | |
| 1156 | + | parse_issue_refs("References #42"), | |
| 1157 | + | vec![IssueRef { number: 42, action: IssueRefAction::Reference }] | |
| 1158 | + | ); | |
| 1159 | + | } | |
| 1160 | + | ||
| 1161 | + | #[test] | |
| 1162 | + | fn parse_issue_refs_case_insensitive() { | |
| 1163 | + | assert_eq!( | |
| 1164 | + | parse_issue_refs("fixes #1"), | |
| 1165 | + | vec![IssueRef { number: 1, action: IssueRefAction::Close }] | |
| 1166 | + | ); | |
| 1167 | + | } | |
| 1168 | + | ||
| 1169 | + | #[test] | |
| 1170 | + | fn parse_issue_refs_multiple() { | |
| 1171 | + | let refs = parse_issue_refs("Fixes #1, refs #2"); | |
| 1172 | + | assert_eq!(refs.len(), 2); | |
| 1173 | + | assert!(refs.contains(&IssueRef { number: 1, action: IssueRefAction::Close })); | |
| 1174 | + | assert!(refs.contains(&IssueRef { number: 2, action: IssueRefAction::Reference })); | |
| 1175 | + | } | |
| 1176 | + | ||
| 1177 | + | #[test] | |
| 1178 | + | fn parse_issue_refs_dedup_close_wins() { | |
| 1179 | + | let refs = parse_issue_refs("Refs #1\nFixes #1"); | |
| 1180 | + | assert_eq!(refs, vec![IssueRef { number: 1, action: IssueRefAction::Close }]); | |
| 1181 | + | } | |
| 1182 | + | ||
| 1183 | + | #[test] | |
| 1184 | + | fn parse_issue_refs_no_match() { | |
| 1185 | + | assert!(parse_issue_refs("No issues here").is_empty()); | |
| 1186 | + | } | |
| 1187 | + | ||
| 1188 | + | #[test] | |
| 1189 | + | fn parse_issue_refs_in_sentence() { | |
| 1190 | + | let refs = parse_issue_refs("This fixes #5 and refs #6"); | |
| 1191 | + | assert_eq!(refs.len(), 2); | |
| 1192 | + | assert!(refs.contains(&IssueRef { number: 5, action: IssueRefAction::Close })); | |
| 1193 | + | assert!(refs.contains(&IssueRef { number: 6, action: IssueRefAction::Reference })); | |
| 1194 | + | } | |
| 1195 | + | } |
| @@ -723,6 +723,10 @@ async fn perform_unsubscribe( | |||
| 723 | 723 | db::users::disable_notification(&state.db, user_id, "login_notification_enabled").await?; | |
| 724 | 724 | Ok("You will no longer receive email notifications for new device sign-ins.".to_string()) | |
| 725 | 725 | } | |
| 726 | + | "issue" => { | |
| 727 | + | db::users::disable_notification(&state.db, user_id, "notify_issues").await?; | |
| 728 | + | Ok("You will no longer receive email notifications for issues on your repositories.".to_string()) | |
| 729 | + | } | |
| 726 | 730 | _ => Err(AppError::BadRequest("Unknown unsubscribe action".to_string())), | |
| 727 | 731 | } | |
| 728 | 732 | } |