Skip to main content

max / makenotwork

v0.3.0: Beta-ready milestone — git explore/file-log views, issue notifications, discover UI, validation hardening Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-17 02:58 UTC
Commit: e2793acc235ce09416df53d404ee9565af65d01b
Parent: 972afc7
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 }