Skip to main content

max / makenotwork

Email-first issue tracker + I5 git patch inbound + CSRF fix Email-first issue tracker: web write UI removed, issues via inbound email only, comments via HMAC-signed Reply-To, labels removed, close/ reopen via commit messages, notification emails with threading headers. Migration 045 (issue_message_ids). I5 verified code-complete: postmark inbound handler, patches category auto-provisioned, message-ID threading for multi-part series. CSRF exempt paths broadened: /postmark/ covers all webhook routes (fixes patches test failures). ssh_management test updated for email-only issue creation. Dead code removed (7 label functions, 2 comment functions, labels.rs, 3 templates). 547 tests, 0 failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-25 23:40 UTC
Commit: 4db53ab7f27fba84e940e249292cc6b25767ee42
Parent: 3d93b33
25 files changed, +1775 insertions, -2036 deletions
@@ -3350,7 +3350,7 @@ dependencies = [
3350 3350
3351 3351 [[package]]
3352 3352 name = "makenotwork"
3353 - version = "0.3.9"
3353 + version = "0.3.10"
3354 3354 dependencies = [
3355 3355 "anyhow",
3356 3356 "argon2",
@@ -0,0 +1,10 @@
1 + -- Issue inbound email: map email Message-ID headers to issues for threading.
2 +
3 + CREATE TABLE issue_message_ids (
4 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5 + message_id TEXT NOT NULL UNIQUE,
6 + issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
7 + created_at TIMESTAMPTZ NOT NULL DEFAULT now()
8 + );
9 +
10 + CREATE INDEX idx_issue_message_ids_issue ON issue_message_ids(issue_id);
@@ -17,7 +17,16 @@
17 17 //! mnw-admin export <user> CSV export of a user's sales
18 18 //! mnw-admin storage <user> S3 storage audit for a user
19 19 //! mnw-admin rebuild-keys Rebuild authorized_keys from DB
20 - //! mnw-admin git-auth <key_id> Authenticate SSH git operations
20 + //! mnw-admin git-auth <key_id> Authenticate SSH git/management operations
21 + //!
22 + //! SSH management commands (via git-auth dispatcher):
23 + //! repo list List your repositories
24 + //! repo info <name> Show repo details + issue counts
25 + //! repo delete <name> --confirm Delete a repo (DB + disk)
26 + //! repo set-visibility <name> <v> Set public/private/unlisted
27 + //! repo set-description <name> "d" Set description (quote for spaces)
28 + //! key list List your SSH keys
29 + //! key rm <fingerprint> Remove an SSH key by fingerprint
21 30
22 31 use clap::{Parser, Subcommand};
23 32 use sqlx::PgPool;
@@ -105,6 +114,9 @@ enum Command {
105 114
106 115 #[tokio::main]
107 116 async fn main() -> anyhow::Result<()> {
117 + // Try the production .env first (SSH invocations have CWD=/opt/git),
118 + // then fall back to the local directory for dev usage.
119 + dotenvy::from_path("/opt/makenotwork/.env").ok();
108 120 dotenvy::dotenv().ok();
109 121
110 122 let database_url = std::env::var("DATABASE_URL")
@@ -598,6 +610,23 @@ async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
598 610
599 611 // ── Build hooks command ──
600 612
613 + /// Install a post-receive hook into a single bare repo directory.
614 + fn install_hook_for_repo(repo_path: &std::path::Path, hook_content: &str) -> anyhow::Result<()> {
615 + let hooks_dir = repo_path.join("hooks");
616 + std::fs::create_dir_all(&hooks_dir)?;
617 +
618 + let hook_path = hooks_dir.join("post-receive");
619 + std::fs::write(&hook_path, hook_content)?;
620 +
621 + #[cfg(unix)]
622 + {
623 + use std::os::unix::fs::PermissionsExt;
624 + std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
625 + }
626 +
627 + Ok(())
628 + }
629 +
601 630 async fn cmd_install_hooks() -> anyhow::Result<()> {
602 631 let token = std::env::var("BUILD_TRIGGER_TOKEN")
603 632 .map_err(|_| anyhow::anyhow!("BUILD_TRIGGER_TOKEN must be set"))?;
@@ -631,21 +660,7 @@ async fn cmd_install_hooks() -> anyhow::Result<()> {
631 660 continue;
632 661 }
633 662
634 - let hooks_dir = repo_path.join("hooks");
635 - std::fs::create_dir_all(&hooks_dir)?;
636 -
637 - let hook_path = hooks_dir.join("post-receive");
638 - std::fs::write(&hook_path, &hook_content)?;
639 -
640 - #[cfg(unix)]
641 - {
642 - use std::os::unix::fs::PermissionsExt;
643 - std::fs::set_permissions(
644 - &hook_path,
645 - std::fs::Permissions::from_mode(0o755),
646 - )?;
647 - }
648 -
663 + install_hook_for_repo(&repo_path, &hook_content)?;
649 664 installed += 1;
650 665 }
651 666 }
@@ -660,64 +675,50 @@ const AUTHORIZED_KEYS_PATH: &str = "/opt/git/.ssh/authorized_keys";
660 675 const MNW_ADMIN_PATH: &str = "/opt/makenotwork/mnw-admin";
661 676
662 677 async fn cmd_rebuild_keys(pool: &PgPool) -> anyhow::Result<()> {
663 - let keys = db::ssh_keys::get_all_keys_with_username(pool).await?;
664 -
665 - let mut content = String::new();
666 - content.push_str("# Managed by mnw-admin rebuild-keys. Do not edit manually.\n");
667 -
668 - for key in &keys {
669 - // Each line: command="...",restrictions {public_key}
670 - content.push_str(&format!(
671 - "command=\"{} git-auth {}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}\n",
672 - MNW_ADMIN_PATH, key.id, key.public_key,
673 - ));
674 - }
675 -
676 - // Atomic write: write to temp file, then rename
677 - let tmp_path = format!("{}.tmp", AUTHORIZED_KEYS_PATH);
678 - std::fs::write(&tmp_path, &content)?;
679 - std::fs::rename(&tmp_path, AUTHORIZED_KEYS_PATH)?;
680 -
681 - // Set ownership to git:git and permissions to 600.
682 - // mnw-admin runs as root, but sshd requires authorized_keys owned by the target user.
683 - #[cfg(unix)]
684 - {
685 - use std::os::unix::fs::PermissionsExt;
686 - std::fs::set_permissions(AUTHORIZED_KEYS_PATH, std::fs::Permissions::from_mode(0o600))?;
687 -
688 - let status = std::process::Command::new("chown")
689 - .args(["git:git", AUTHORIZED_KEYS_PATH])
690 - .status()?;
691 - if !status.success() {
692 - anyhow::bail!("chown git:git failed on {}", AUTHORIZED_KEYS_PATH);
693 - }
694 - }
695 -
696 - println!("Rebuilt authorized_keys with {} key(s).", keys.len());
678 + let key_count = db::ssh_keys::get_all_keys_with_username(pool).await?.len();
679 + write_authorized_keys(pool, true).await?;
680 + println!("Rebuilt authorized_keys with {} key(s).", key_count);
697 681 Ok(())
698 682 }
699 683
700 684 async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> {
701 - // Parse the SSH_ORIGINAL_COMMAND
702 685 let original_cmd = std::env::var("SSH_ORIGINAL_COMMAND")
703 686 .map_err(|_| anyhow::anyhow!("SSH_ORIGINAL_COMMAND not set"))?;
704 687
705 - // Parse: "git-upload-pack 'owner/repo.git'" or "git-receive-pack 'owner/repo.git'"
706 - let (operation, repo_path) = parse_ssh_command(&original_cmd)?;
707 -
708 - // Parse repo path: "owner/repo.git" → (owner, repo_name)
709 - let (owner, repo_name) = parse_repo_path(&repo_path)?;
710 -
711 688 // Look up the SSH key → user
712 689 let key_id: db::SshKeyId = key_id_str
713 690 .parse()
714 691 .map_err(|_| anyhow::anyhow!("invalid key ID"))?;
715 692
716 - let (_, user_id, _ssh_username) = db::ssh_keys::get_key_with_user(pool, key_id)
693 + let (_, user_id, ssh_username) = db::ssh_keys::get_key_with_user(pool, key_id)
717 694 .await?
718 695 .ok_or_else(|| anyhow::anyhow!("SSH key not found"))?;
719 696
720 - // Look up the repo owner
697 + // Dispatch: git operations start with "git-", everything else is a management command
698 + if original_cmd.starts_with("git-") {
699 + exec_git_operation(pool, user_id, &original_cmd).await
700 + } else {
701 + exec_management_command(pool, user_id, &ssh_username, &original_cmd).await
702 + }
703 + }
704 +
705 + // ── Git operations ──
706 +
707 + #[derive(Debug)]
708 + enum GitOperation {
709 + UploadPack,
710 + ReceivePack,
711 + Archive,
712 + }
713 +
714 + async fn exec_git_operation(
715 + pool: &PgPool,
716 + user_id: db::UserId,
717 + original_cmd: &str,
718 + ) -> anyhow::Result<()> {
719 + let (operation, repo_path) = parse_ssh_command(original_cmd)?;
720 + let (owner, repo_name) = parse_repo_path(&repo_path)?;
721 +
721 722 let owner_user = db::users::get_user_by_username(pool, &Username::from_trusted(owner.to_string()))
722 723 .await?
723 724 .ok_or_else(|| anyhow::anyhow!("repository not found"))?;
@@ -738,6 +739,12 @@ async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> {
738 739 std::fs::create_dir_all(&owner_dir)?;
739 740 git2::Repository::init_bare(&repo_dir)?;
740 741
742 + // Install post-receive hook if build trigger token is configured
743 + if let Ok(token) = std::env::var("BUILD_TRIGGER_TOKEN") {
744 + let hook_content = makenotwork::build_runner::post_receive_hook(&token);
745 + install_hook_for_repo(&repo_dir, &hook_content)?;
746 + }
747 +
741 748 // Fix ownership so the git user can write
742 749 let status = std::process::Command::new("chown")
743 750 .args(["-R", "git:git"])
@@ -767,18 +774,10 @@ async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> {
767 774 }
768 775
769 776 // Authorized — exec git-shell
770 - let err = exec_git_shell(&original_cmd);
771 - // exec_git_shell only returns on error
777 + let err = exec_git_shell(original_cmd);
772 778 anyhow::bail!("failed to exec git-shell: {}", err);
773 779 }
774 780
775 - #[derive(Debug)]
776 - enum GitOperation {
777 - UploadPack,
778 - ReceivePack,
779 - Archive,
780 - }
781 -
782 781 fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> {
783 782 let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
784 783 if parts.len() != 2 {
@@ -792,15 +791,12 @@ fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> {
792 791 _ => anyhow::bail!("unsupported git command: {}", parts[0]),
793 792 };
794 793
795 - // Strip surrounding quotes from the repo path
796 794 let repo_path = parts[1].trim_matches('\'').trim_matches('"');
797 795 Ok((operation, repo_path.to_string()))
798 796 }
799 797
800 798 fn parse_repo_path(path: &str) -> anyhow::Result<(&str, String)> {
801 - // Strip leading / if present
802 799 let path = path.trim_start_matches('/');
803 -
804 800 let parts: Vec<&str> = path.splitn(2, '/').collect();
805 801 if parts.len() != 2 {
806 802 anyhow::bail!("invalid repository path");
@@ -809,12 +805,10 @@ fn parse_repo_path(path: &str) -> anyhow::Result<(&str, String)> {
809 805 let owner = parts[0];
810 806 let mut repo_name = parts[1].to_string();
811 807
812 - // Reject path traversal
813 808 if owner.contains("..") || repo_name.contains("..") {
814 809 anyhow::bail!("invalid repository path");
815 810 }
816 811
817 - // Strip .git suffix if present
818 812 if repo_name.ends_with(".git") {
819 813 repo_name.truncate(repo_name.len() - 4);
820 814 }
@@ -833,3 +827,414 @@ fn exec_git_shell(original_cmd: &str) -> std::io::Error {
833 827 .args(["-c", original_cmd])
834 828 .exec()
835 829 }
830 +
831 + // ── SSH management commands ──
832 +
833 + #[derive(Debug, PartialEq)]
834 + enum ManagementCommand {
835 + RepoList,
836 + RepoInfo { name: String },
837 + RepoDelete { name: String },
838 + RepoSetVisibility { name: String, visibility: String },
839 + RepoSetDescription { name: String, description: String },
840 + KeyList,
841 + KeyRemove { fingerprint: String },
842 + }
843 +
844 + /// Split a command string on whitespace, respecting double-quoted segments.
845 + fn shell_tokenize(input: &str) -> Vec<String> {
846 + let mut tokens = Vec::new();
847 + let mut current = String::new();
848 + let mut in_quotes = false;
849 + let mut chars = input.chars();
850 +
851 + while let Some(ch) = chars.next() {
852 + if in_quotes {
853 + if ch == '"' {
854 + in_quotes = false;
855 + } else {
856 + current.push(ch);
857 + }
858 + } else if ch == '"' {
859 + in_quotes = true;
860 + } else if ch.is_ascii_whitespace() {
861 + if !current.is_empty() {
862 + tokens.push(std::mem::take(&mut current));
863 + }
864 + } else {
865 + current.push(ch);
866 + }
867 + }
868 +
869 + if !current.is_empty() {
870 + tokens.push(current);
871 + }
872 +
873 + tokens
874 + }
875 +
876 + fn parse_management_command(tokens: &[String]) -> anyhow::Result<ManagementCommand> {
877 + let strs: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
878 +
879 + match strs.as_slice() {
880 + ["repo", "list"] => Ok(ManagementCommand::RepoList),
881 + ["repo", "info", name] => Ok(ManagementCommand::RepoInfo { name: name.to_string() }),
882 + ["repo", "delete", name, "--confirm"] => Ok(ManagementCommand::RepoDelete { name: name.to_string() }),
883 + ["repo", "delete", _, ..] => anyhow::bail!("repo delete requires --confirm flag"),
884 + ["repo", "set-visibility", name, vis] => {
885 + match *vis {
886 + "public" | "private" | "unlisted" => {}
887 + _ => anyhow::bail!("visibility must be public, private, or unlisted"),
888 + }
889 + Ok(ManagementCommand::RepoSetVisibility {
890 + name: name.to_string(),
891 + visibility: vis.to_string(),
892 + })
893 + }
894 + ["repo", "set-description", name, desc] => Ok(ManagementCommand::RepoSetDescription {
895 + name: name.to_string(),
896 + description: desc.to_string(),
897 + }),
898 + ["key", "list"] => Ok(ManagementCommand::KeyList),
899 + ["key", "rm", fingerprint] => Ok(ManagementCommand::KeyRemove { fingerprint: fingerprint.to_string() }),
900 + _ => anyhow::bail!("unknown command. Available: repo list|info|delete|set-visibility|set-description, key list|rm"),
901 + }
902 + }
903 +
904 + async fn exec_management_command(
905 + pool: &PgPool,
906 + user_id: db::UserId,
907 + username: &str,
908 + original_cmd: &str,
909 + ) -> anyhow::Result<()> {
910 + let tokens = shell_tokenize(original_cmd);
911 + let cmd = parse_management_command(&tokens)?;
912 +
913 + match cmd {
914 + ManagementCommand::RepoList => cmd_ssh_repo_list(pool, user_id).await,
915 + ManagementCommand::RepoInfo { name } => cmd_ssh_repo_info(pool, user_id, &name).await,
916 + ManagementCommand::RepoDelete { name } => cmd_ssh_repo_delete(pool, user_id, username, &name).await,
917 + ManagementCommand::RepoSetVisibility { name, visibility } => {
918 + cmd_ssh_repo_set_visibility(pool, user_id, &name, &visibility).await
919 + }
920 + ManagementCommand::RepoSetDescription { name, description } => {
921 + cmd_ssh_repo_set_description(pool, user_id, &name, &description).await
922 + }
923 + ManagementCommand::KeyList => cmd_ssh_key_list(pool, user_id).await,
924 + ManagementCommand::KeyRemove { fingerprint } => cmd_ssh_key_remove(pool, user_id, &fingerprint).await,
925 + }
926 + }
927 +
928 + async fn cmd_ssh_repo_list(pool: &PgPool, user_id: db::UserId) -> anyhow::Result<()> {
929 + let repos = db::git_repos::get_repos_by_user(pool, user_id).await?;
930 +
931 + if repos.is_empty() {
932 + println!("No repositories.");
933 + return Ok(());
934 + }
935 +
936 + println!("{:<30} {:<10} Description", "Name", "Visibility");
937 + println!("{}", "-".repeat(70));
938 +
939 + for repo in &repos {
940 + let desc = if repo.description.is_empty() {
941 + "-".to_string()
942 + } else if repo.description.len() > 28 {
943 + format!("{}...", &repo.description[..25])
944 + } else {
945 + repo.description.clone()
946 + };
947 + println!("{:<30} {:<10} {}", repo.name, repo.visibility, desc);
948 + }
949 +
950 + println!("\n{} repo(s).", repos.len());
951 + Ok(())
952 + }
953 +
954 + async fn cmd_ssh_repo_info(pool: &PgPool, user_id: db::UserId, name: &str) -> anyhow::Result<()> {
955 + let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
956 + .await?
957 + .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
958 +
959 + let (open_issues, closed_issues) = db::issues::get_issue_counts(pool, repo.id).await?;
960 +
961 + println!("Name: {}", repo.name);
962 + println!("Visibility: {}", repo.visibility);
963 + println!("Description: {}", if repo.description.is_empty() { "-" } else { &repo.description });
964 + println!("Created: {}", repo.created_at.format("%Y-%m-%d %H:%M UTC"));
965 + println!("Issues: {} open, {} closed", open_issues, closed_issues);
966 +
967 + Ok(())
968 + }
969 +
970 + async fn cmd_ssh_repo_delete(
971 + pool: &PgPool,
972 + user_id: db::UserId,
973 + username: &str,
974 + name: &str,
975 + ) -> anyhow::Result<()> {
976 + let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
977 + .await?
978 + .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
979 +
980 + // Delete from DB (issues cascade)
981 + db::git_repos::delete_repo(pool, repo.id).await?;
982 +
983 + // Delete from disk
984 + let git_root = std::env::var("GIT_REPOS_PATH")
985 + .unwrap_or_else(|_| "/opt/git".to_string());
986 + let repo_dir = std::path::Path::new(&git_root)
987 + .join(username)
988 + .join(format!("{}.git", name));
989 +
990 + if repo_dir.exists() {
991 + std::fs::remove_dir_all(&repo_dir)?;
992 + }
993 +
994 + println!("Deleted repository '{}'.", name);
995 + Ok(())
996 + }
997 +
998 + async fn cmd_ssh_repo_set_visibility(
999 + pool: &PgPool,
1000 + user_id: db::UserId,
1001 + name: &str,
1002 + visibility: &str,
1003 + ) -> anyhow::Result<()> {
1004 + let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
1005 + .await?
1006 + .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
1007 +
1008 + db::git_repos::update_visibility(pool, repo.id, visibility).await?;
1009 +
1010 + println!("Set visibility of '{}' to '{}'.", name, visibility);
1011 + Ok(())
1012 + }
1013 +
1014 + async fn cmd_ssh_repo_set_description(
1015 + pool: &PgPool,
1016 + user_id: db::UserId,
1017 + name: &str,
1018 + description: &str,
1019 + ) -> anyhow::Result<()> {
1020 + let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
1021 + .await?
1022 + .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
1023 +
1024 + db::git_repos::update_repo_settings(pool, repo.id, description, &repo.visibility).await?;
1025 +
1026 + println!("Updated description of '{}'.", name);
1027 + Ok(())
1028 + }
1029 +
1030 + async fn cmd_ssh_key_list(pool: &PgPool, user_id: db::UserId) -> anyhow::Result<()> {
1031 + let keys = db::ssh_keys::list_keys_by_user(pool, user_id).await?;
1032 +
1033 + if keys.is_empty() {
1034 + println!("No SSH keys.");
1035 + return Ok(());
1036 + }
1037 +
1038 + println!("{:<50} {:<20} Added", "Fingerprint", "Label");
1039 + println!("{}", "-".repeat(80));
1040 +
1041 + for key in &keys {
1042 + let label = if key.label.is_empty() { "-" } else { &key.label };
1043 + println!(
1044 + "{:<50} {:<20} {}",
1045 + key.fingerprint,
1046 + label,
1047 + key.created_at.format("%Y-%m-%d"),
1048 + );
1049 + }
1050 +
1051 + println!("\n{} key(s).", keys.len());
1052 + Ok(())
1053 + }
1054 +
1055 + async fn cmd_ssh_key_remove(pool: &PgPool, user_id: db::UserId, fingerprint: &str) -> anyhow::Result<()> {
1056 + let deleted = db::ssh_keys::delete_key_by_fingerprint(pool, user_id, fingerprint).await?;
1057 +
1058 + if !deleted {
1059 + anyhow::bail!("SSH key with fingerprint '{}' not found", fingerprint);
1060 + }
1061 +
1062 + // Rebuild authorized_keys to remove the deleted key
1063 + write_authorized_keys(pool, true).await?;
1064 +
1065 + println!("Removed SSH key '{}'.", fingerprint);
1066 + Ok(())
1067 + }
1068 +
1069 + /// Write the authorized_keys file from all DB keys. Optionally set git:git ownership.
1070 + async fn write_authorized_keys(pool: &PgPool, set_ownership: bool) -> anyhow::Result<()> {
1071 + let keys = db::ssh_keys::get_all_keys_with_username(pool).await?;
1072 +
1073 + let mut content = String::new();
1074 + content.push_str("# Managed by mnw-admin rebuild-keys. Do not edit manually.\n");
1075 +
1076 + for key in &keys {
1077 + content.push_str(&format!(
1078 + "command=\"{} git-auth {}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}\n",
1079 + MNW_ADMIN_PATH, key.id, key.public_key,
1080 + ));
1081 + }
1082 +
1083 + let tmp_path = format!("{}.tmp", AUTHORIZED_KEYS_PATH);
1084 + std::fs::write(&tmp_path, &content)?;
1085 + std::fs::rename(&tmp_path, AUTHORIZED_KEYS_PATH)?;
1086 +
1087 + #[cfg(unix)]
1088 + {
1089 + use std::os::unix::fs::PermissionsExt;
1090 + std::fs::set_permissions(AUTHORIZED_KEYS_PATH, std::fs::Permissions::from_mode(0o600))?;
1091 +
1092 + if set_ownership {
1093 + let status = std::process::Command::new("chown")
1094 + .args(["git:git", AUTHORIZED_KEYS_PATH])
1095 + .status()?;
1096 + if !status.success() {
1097 + anyhow::bail!("chown git:git failed on {}", AUTHORIZED_KEYS_PATH);
1098 + }
1099 + }
1100 + }
1101 +
1102 + Ok(())
1103 + }
1104 +
Lines truncated
@@ -138,7 +138,7 @@ pub async fn csrf_middleware(request: Request, next: Next) -> Response {
138 138 "/login", "/join",
139 139 "/api/sync/auth", "/api/sync/push", "/api/sync/pull", "/api/sync/status",
140 140 "/api/sync/devices", "/api/sync/keys", "/api/sync/blobs",
141 - "/oauth", "/auth/passkey", "/postmark/webhook",
141 + "/oauth", "/auth/passkey", "/postmark/",
142 142 "/unsubscribe", "/confirm-delete",
143 143 ];
144 144
@@ -3,9 +3,9 @@
3 3 use sqlx::PgPool;
4 4
5 5 use super::models::{
6 - DbIssue, DbIssueComment, DbIssueCommentWithAuthor, DbIssueLabel, DbIssueWithMeta,
6 + DbIssue, DbIssueComment, DbIssueCommentWithAuthor, DbIssueWithMeta,
7 7 };
8 - use super::{GitRepoId, IssueCommentId, IssueId, IssueLabelId, IssueStatus, UserId};
8 + use super::{GitRepoId, IssueId, IssueStatus, UserId};
9 9 use crate::error::Result;
10 10
11 11 // ── Issues ──
@@ -56,6 +56,15 @@ async fn try_create_issue(
56 56 Ok(issue)
57 57 }
58 58
59 + /// Get a single issue by its primary key.
60 + pub async fn get_issue_by_id(pool: &PgPool, issue_id: IssueId) -> Result<Option<DbIssue>> {
61 + let issue = sqlx::query_as::<_, DbIssue>("SELECT * FROM issues WHERE id = $1")
62 + .bind(issue_id)
63 + .fetch_optional(pool)
64 + .await?;
65 + Ok(issue)
66 + }
67 +
59 68 /// Get a single issue by its repo-scoped number.
60 69 pub async fn get_issue_by_number(
61 70 pool: &PgPool,
@@ -240,141 +249,62 @@ pub async fn list_comments(
240 249 Ok(comments)
241 250 }
242 251
243 - /// Delete a comment by ID.
244 - pub async fn delete_comment(pool: &PgPool, comment_id: IssueCommentId) -> Result<()> {
245 - sqlx::query("DELETE FROM issue_comments WHERE id = $1")
246 - .bind(comment_id)
247 - .execute(pool)
248 - .await?;
249 -
250 - Ok(())
251 - }
252 -
253 - /// Get a single comment by ID (for permission checks).
254 - pub async fn get_comment(pool: &PgPool, comment_id: IssueCommentId) -> Result<Option<DbIssueComment>> {
255 - let comment = sqlx::query_as::<_, DbIssueComment>(
256 - "SELECT * FROM issue_comments WHERE id = $1",
257 - )
258 - .bind(comment_id)
259 - .fetch_optional(pool)
260 - .await?;
261 -
262 - Ok(comment)
263 - }
264 -
265 - // ── Labels ──
252 + // ── Issue participants (for email notifications) ──
266 253
267 - /// Create a label for a repo.
268 - pub async fn create_label(
269 - pool: &PgPool,
270 - repo_id: GitRepoId,
271 - name: &str,
272 - color: &str,
273 - ) -> Result<DbIssueLabel> {
274 - let label = sqlx::query_as::<_, DbIssueLabel>(
254 + /// Get all distinct participant user IDs for an issue (author + all comment authors).
255 + pub async fn get_issue_participants(pool: &PgPool, issue_id: IssueId) -> Result<Vec<UserId>> {
256 + let ids = sqlx::query_scalar::<_, UserId>(
275 257 r#"
276 - INSERT INTO issue_labels (repo_id, name, color)
277 - VALUES ($1, $2, $3)
278 - RETURNING *
258 + SELECT DISTINCT author_user_id
259 + FROM (
260 + SELECT author_user_id FROM issues WHERE id = $1
261 + UNION
262 + SELECT author_user_id FROM issue_comments WHERE issue_id = $1
263 + ) AS participants
279 264 "#,
280 265 )
281 - .bind(repo_id)
282 - .bind(name)
283 - .bind(color)
284 - .fetch_one(pool)
285 - .await?;
286 -
287 - Ok(label)
288 - }
289 -
290 - /// List all labels for a repo.
291 - pub async fn list_labels(pool: &PgPool, repo_id: GitRepoId) -> Result<Vec<DbIssueLabel>> {
292 - let labels = sqlx::query_as::<_, DbIssueLabel>(
293 - "SELECT * FROM issue_labels WHERE repo_id = $1 ORDER BY name ASC",
294 - )
295 - .bind(repo_id)
266 + .bind(issue_id)
296 267 .fetch_all(pool)
297 268 .await?;
298 269
299 - Ok(labels)
270 + Ok(ids)
300 271 }
301 272
302 - /// Update a label's name and color.
303 - pub async fn update_label(
304 - pool: &PgPool,
305 - label_id: IssueLabelId,
306 - name: &str,
307 - color: &str,
308 - ) -> Result<()> {
309 - sqlx::query("UPDATE issue_labels SET name = $2, color = $3 WHERE id = $1")
310 - .bind(label_id)
311 - .bind(name)
312 - .bind(color)
313 - .execute(pool)
314 - .await?;
315 -
316 - Ok(())
317 - }
273 + // ── Issue message ID mapping (for email threading) ──
318 274
319 - /// Delete a label (cascades to assignments).
320 - pub async fn delete_label(pool: &PgPool, label_id: IssueLabelId) -> Result<()> {
321 - sqlx::query("DELETE FROM issue_labels WHERE id = $1")
322 - .bind(label_id)
323 - .execute(pool)
324 - .await?;
325 -
326 - Ok(())
327 - }
328 -
329 - /// Add a label to an issue.
330 - pub async fn add_label_to_issue(
275 + /// Store a mapping from an email Message-ID to an issue.
276 + pub async fn insert_issue_message_id(
331 277 pool: &PgPool,
278 + message_id: &str,
332 279 issue_id: IssueId,
333 - label_id: IssueLabelId,
334 280 ) -> Result<()> {
335 281 sqlx::query(
336 - "INSERT INTO issue_label_assignments (issue_id, label_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
282 + "INSERT INTO issue_message_ids (message_id, issue_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
337 283 )
284 + .bind(message_id)
338 285 .bind(issue_id)
339 - .bind(label_id)
340 286 .execute(pool)
341 287 .await?;
342 288
343 289 Ok(())
344 290 }
345 291
346 - /// Remove a label from an issue.
347 - pub async fn remove_label_from_issue(
292 + /// Look up an issue ID by any of the given email Message-IDs.
293 + pub async fn get_issue_id_by_any_message_id(
348 294 pool: &PgPool,
349 - issue_id: IssueId,
350 - label_id: IssueLabelId,
351 - ) -> Result<()> {
352 - sqlx::query("DELETE FROM issue_label_assignments WHERE issue_id = $1 AND label_id = $2")
353 - .bind(issue_id)
354 - .bind(label_id)
355 - .execute(pool)
356 - .await?;
357 -
358 - Ok(())
359 - }
295 + message_ids: &[&str],
296 + ) -> Result<Option<IssueId>> {
297 + if message_ids.is_empty() {
298 + return Ok(None);
299 + }
360 300
361 - /// Get all labels attached to an issue.
362 - pub async fn get_labels_for_issue(
363 - pool: &PgPool,
364 - issue_id: IssueId,
365 - ) -> Result<Vec<DbIssueLabel>> {
366 - let labels = sqlx::query_as::<_, DbIssueLabel>(
367 - r#"
368 - SELECT l.*
369 - FROM issue_labels l
370 - JOIN issue_label_assignments a ON a.label_id = l.id
371 - WHERE a.issue_id = $1
372 - ORDER BY l.name ASC
373 - "#,
301 + let ids: Vec<String> = message_ids.iter().map(|s| s.to_string()).collect();
302 + let issue_id = sqlx::query_scalar::<_, IssueId>(
303 + "SELECT issue_id FROM issue_message_ids WHERE message_id = ANY($1) LIMIT 1",
374 304 )
375 - .bind(issue_id)
376 - .fetch_all(pool)
305 + .bind(&ids)
306 + .fetch_optional(pool)
377 307 .await?;
378 308
379 - Ok(labels)
309 + Ok(issue_id)
380 310 }
@@ -40,7 +40,7 @@ pub(crate) mod analytics;
40 40 pub(crate) mod email_suppressions;
41 41 pub mod git_repos;
42 42 pub mod ssh_keys;
43 - pub(crate) mod issues;
43 + pub mod issues;
44 44 pub(crate) mod reports;
45 45 pub(crate) mod fan_plus;
46 46 pub(crate) mod collections;
@@ -55,6 +55,22 @@ pub async fn delete_key(pool: &PgPool, key_id: SshKeyId, user_id: UserId) -> Res
55 55 Ok(result.rows_affected() > 0)
56 56 }
57 57
58 + /// Delete an SSH key by fingerprint. Returns false if not found or not owned by the user.
59 + pub async fn delete_key_by_fingerprint(
60 + pool: &PgPool,
61 + user_id: UserId,
62 + fingerprint: &str,
63 + ) -> Result<bool> {
64 + let result =
65 + sqlx::query("DELETE FROM ssh_keys WHERE user_id = $1 AND fingerprint = $2")
66 + .bind(user_id)
67 + .bind(fingerprint)
68 + .execute(pool)
69 + .await?;
70 +
71 + Ok(result.rows_affected() > 0)
72 + }
73 +
58 74 /// Get all SSH keys with their owner's username, for authorized_keys rebuild.
59 75 pub async fn get_all_keys_with_username(pool: &PgPool) -> Result<Vec<SshKeyWithUsername>> {
60 76 let rows = sqlx::query_as::<_, SshKeyWithUsername>(
@@ -91,6 +91,32 @@ impl EmailClient {
91 91 self.send_with_unsub_inner(to, subject, body, unsub_url, None).await
92 92 }
93 93
94 + /// Send an email with extra headers and an optional unsubscribe link.
95 + ///
96 + /// Used for issue notifications where we need Reply-To, Message-ID, and In-Reply-To headers.
97 + pub(crate) async fn send_email_with_headers_and_unsub(
98 + &self,
99 + to: &str,
100 + subject: &str,
101 + body: &str,
102 + extra_headers: &[(&str, String)],
103 + unsub_url: Option<&str>,
104 + ) -> Result<()> {
105 + match unsub_url {
106 + Some(url) => {
107 + let body_with_footer = format!(
108 + "{}\n\nUnsubscribe from these emails:\n{}",
109 + body, url
110 + );
111 + let mut all_headers: Vec<(&str, String)> = extra_headers.to_vec();
112 + all_headers.push(("List-Unsubscribe", format!("<{}>", url)));
113 + all_headers.push(("List-Unsubscribe-Post", "List-Unsubscribe=One-Click".to_string()));
114 + self.send_email_inner(to, subject, &body_with_footer, &all_headers, None).await
115 + }
116 + None => self.send_email_inner(to, subject, body, extra_headers, None).await,
117 + }
118 + }
119 +
94 120 /// Send via the broadcast stream with an optional unsubscribe link.
95 121 pub(crate) async fn send_email_broadcast_with_unsub(
96 122 &self,
@@ -269,6 +269,8 @@ We built Makenotwork on the principle of no lock-in, and we intend to honor that
269 269 author_username: &str,
270 270 issue_url: &str,
271 271 unsub_url: Option<&str>,
272 + reply_to: Option<&str>,
273 + message_id: Option<&str>,
272 274 ) -> Result<()> {
273 275 let subject = format!("New issue on {}/{}: {}", repo_owner, repo_name, issue_title);
274 276 let body = format!(
@@ -280,6 +282,8 @@ We built Makenotwork on the principle of no lock-in, and we intend to honor that
280 282
281 283 View it here: {url}
282 284
285 + Reply to this email to comment on this issue.
286 +
283 287 - Makenotwork"#,
284 288 name = crate::email::greeting(to_name),
285 289 author = author_username,
@@ -290,7 +294,15 @@ View it here: {url}
290 294 url = issue_url,
291 295 );
292 296
293 - self.send_email_with_unsub(to_email, &subject, &body, unsub_url).await
297 + let mut headers: Vec<(&str, String)> = Vec::new();
298 + if let Some(rt) = reply_to {
299 + headers.push(("Reply-To", rt.to_string()));
300 + }
301 + if let Some(mid) = message_id {
302 + headers.push(("Message-ID", mid.to_string()));
303 + }
304 +
305 + self.send_email_with_headers_and_unsub(to_email, &subject, &body, &headers, unsub_url).await
294 306 }
295 307
296 308 /// Notify about a new comment or status change on an issue.
@@ -307,8 +319,11 @@ View it here: {url}
307 319 comment_preview: &str,
308 320 issue_url: &str,
309 321 unsub_url: Option<&str>,
322 + reply_to: Option<&str>,
323 + message_id: Option<&str>,
324 + in_reply_to: Option<&str>,
310 325 ) -> Result<()> {
311 - let subject = format!("New comment on {}/{}#{}", repo_owner, repo_name, issue_number);
326 + let subject = format!("Re: New issue on {}/{}: {}", repo_owner, repo_name, issue_title);
312 327 let body = format!(
313 328 r#"Hi{name},
314 329
@@ -318,6 +333,8 @@ View it here: {url}
318 333
319 334 View it here: {url}
320 335
336 + Reply to this email to comment on this issue.
337 +
321 338 - Makenotwork"#,
322 339 name = crate::email::greeting(to_name),
323 340 commenter = commenter_username,
@@ -329,7 +346,19 @@ View it here: {url}
329 346 url = issue_url,
330 347 );
331 348
332 - self.send_email_with_unsub(to_email, &subject, &body, unsub_url).await
349 + let mut headers: Vec<(&str, String)> = Vec::new();
350 + if let Some(rt) = reply_to {
351 + headers.push(("Reply-To", rt.to_string()));
352 + }
353 + if let Some(mid) = message_id {
354 + headers.push(("Message-ID", mid.to_string()));
355 + }
356 + if let Some(irt) = in_reply_to {
357 + headers.push(("In-Reply-To", irt.to_string()));
358 + headers.push(("References", irt.to_string()));
359 + }
360 +
361 + self.send_email_with_headers_and_unsub(to_email, &subject, &body, &headers, unsub_url).await
333 362 }
334 363
335 364 /// Send an alert email (monitoring, ops notifications).
@@ -263,6 +263,71 @@ pub fn generate_deletion_signature(
263 263 hex::encode(mac.finalize().into_bytes())
264 264 }
265 265
266 + /// Generate a reply-to email address for an issue comment.
267 + ///
268 + /// Format: `issue+{issue_id}.{user_id}.{sig16}@reply.makenot.work`
269 + ///
270 + /// The signature is the first 16 hex chars of an HMAC-SHA256 over the issue and user IDs.
271 + /// This is stateless — no DB storage needed. The handler will parse and verify the address.
272 + pub fn generate_issue_reply_address(
273 + issue_id: crate::db::IssueId,
274 + user_id: UserId,
275 + secret: &str,
276 + ) -> String {
277 + use hmac::{Hmac, Mac};
278 + use sha2::Sha256;
279 +
280 + let message = format!("issue-reply:{}:{}", issue_id, user_id);
281 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
282 + .expect("HMAC-SHA256 accepts any key length");
283 + mac.update(message.as_bytes());
284 + let sig = &hex::encode(mac.finalize().into_bytes())[..16];
285 +
286 + format!("issue+{}.{}.{}@reply.makenot.work", issue_id, user_id, sig)
287 + }
288 +
289 + /// Parse and verify an issue reply address local part.
290 + ///
291 + /// Input: the part before `@`, e.g. `issue+{issue_id}.{user_id}.{sig16}`
292 + ///
293 + /// Returns `Some((IssueId, UserId))` if the signature is valid, `None` otherwise.
294 + pub fn parse_issue_reply_token(
295 + local_part: &str,
296 + secret: &str,
297 + ) -> Option<(crate::db::IssueId, UserId)> {
298 + use hmac::{Hmac, Mac};
299 + use sha2::Sha256;
300 +
301 + let payload = local_part.strip_prefix("issue+")?;
302 + let mut parts = payload.splitn(3, '.');
303 + let issue_id_str = parts.next()?;
304 + let user_id_str = parts.next()?;
305 + let sig = parts.next()?;
306 +
307 + let issue_id: crate::db::IssueId = issue_id_str.parse().ok()?;
308 + let user_id: UserId = user_id_str.parse().ok()?;
309 +
310 + let message = format!("issue-reply:{}:{}", issue_id, user_id);
311 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
312 + .expect("HMAC-SHA256 accepts any key length");
313 + mac.update(message.as_bytes());
314 + let expected = &hex::encode(mac.finalize().into_bytes())[..16];
315 +
316 + // Constant-time comparison
317 + if expected.len() != sig.len() {
318 + return None;
319 + }
320 + let mut result = 0u8;
321 + for (a, b) in expected.bytes().zip(sig.bytes()) {
322 + result |= a ^ b;
323 + }
324 + if result != 0 {
325 + return None;
326 + }
327 +
328 + Some((issue_id, user_id))
329 + }
330 +
266 331 #[cfg(test)]
267 332 mod tests {
268 333 use super::*;
@@ -497,4 +562,44 @@ mod tests {
497 562 assert!(!constant_time_compare("hello", "hell"));
498 563 assert!(!constant_time_compare("short", "longer"));
499 564 }
565 +
566 + // ── Issue reply token tests ──
567 +
568 + #[test]
569 + fn issue_reply_round_trip() {
570 + let issue_id = crate::db::IssueId::new();
571 + let user_id = UserId::new();
572 + let secret = "reply-secret";
573 +
574 + let addr = generate_issue_reply_address(issue_id, user_id, secret);
575 + assert!(addr.ends_with("@reply.makenot.work"));
576 + assert!(addr.starts_with("issue+"));
577 +
578 + // Extract local part
579 + let local = addr.split('@').next().unwrap();
580 + let result = parse_issue_reply_token(local, secret);
581 + assert!(result.is_some());
582 + let (parsed_issue, parsed_user) = result.unwrap();
583 + assert_eq!(parsed_issue, issue_id);
584 + assert_eq!(parsed_user, user_id);
585 + }
586 +
587 + #[test]
588 + fn issue_reply_wrong_secret_rejected() {
589 + let issue_id = crate::db::IssueId::new();
590 + let user_id = UserId::new();
591 +
592 + let addr = generate_issue_reply_address(issue_id, user_id, "real-secret");
593 + let local = addr.split('@').next().unwrap();
594 + assert!(parse_issue_reply_token(local, "wrong-secret").is_none());
595 + }
596 +
597 + #[test]
598 + fn issue_reply_malformed_input() {
599 + let secret = "test-secret";
600 + assert!(parse_issue_reply_token("garbage", secret).is_none());
601 + assert!(parse_issue_reply_token("issue+", secret).is_none());
602 + assert!(parse_issue_reply_token("issue+a.b", secret).is_none());
603 + assert!(parse_issue_reply_token("issue+not-uuid.not-uuid.abcd1234abcd1234", secret).is_none());
604 + }
500 605 }
@@ -1,21 +1,18 @@
1 - //! Issue CRUD: list, create, detail, edit, close/reopen, comments.
1 + //! Issue read-only views: list and detail.
2 2
3 3 use axum::{
4 4 extract::{Path, Query, State},
5 - response::{IntoResponse, Redirect},
6 - Form,
5 + response::IntoResponse,
7 6 };
8 7 use serde::Deserialize;
9 8 use tower_sessions::Session;
10 9
11 10 use crate::{
12 - auth::{AuthUser, MaybeUser},
13 - db::{self, IssueCommentId, IssueStatus, IssueLabelId, UserId},
14 - email,
11 + auth::MaybeUser,
12 + db::{self, IssueStatus},
15 13 error::{AppError, Result},
16 14 helpers::get_csrf_token,
17 15 templates::*,
18 - validation,
19 16 AppState,
20 17 };
21 18
@@ -56,11 +53,11 @@ pub(super) async fn issue_list(
56 53
57 54 let total_pages = (total + per_page - 1) / per_page;
58 55 let (open_count, closed_count) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await?;
59 - let labels = db::issues::list_labels(&state.db, resolved.db_repo.id).await?;
60 56 let current_ref = default_ref(&state, &owner, &repo_name);
61 57 let csrf_token = get_csrf_token(&session).await;
62 58
63 59 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
60 + let email_address = format!("{}+{}@issues.makenot.work", owner, repo_name);
64 61
65 62 Ok(GitIssueListTemplate {
66 63 csrf_token,
@@ -75,97 +72,11 @@ pub(super) async fn issue_list(
75 72 search_query: query.search.unwrap_or_default(),
76 73 current_page: page,
77 74 total_pages,
78 - labels,
79 75 is_owner,
76 + email_address,
80 77 })
81 78 }
82 79
83 - // ── New Issue ──
84 -
85 - /// `GET /git/{owner}/{repo}/issues/new`
86 - #[tracing::instrument(skip_all, name = "git_issues::new_form")]
87 - pub(super) async fn issue_new_form(
88 - State(state): State<AppState>,
89 - session: Session,
90 - AuthUser(user): AuthUser,
91 - Path((owner, repo_name)): Path<(String, String)>,
92 - ) -> Result<impl IntoResponse> {
93 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
94 - let labels = db::issues::list_labels(&state.db, resolved.db_repo.id).await?;
95 - let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await?;
96 - let current_ref = default_ref(&state, &owner, &repo_name);
97 - let csrf_token = get_csrf_token(&session).await;
98 -
99 - let is_owner = user.id == resolved.db_user.id;
100 -
101 - Ok(GitIssueNewTemplate {
102 - csrf_token,
103 - session_user: Some(user),
104 - owner,
105 - repo_name,
106 - current_ref,
107 - labels,
108 - open_issue_count,
109 - is_owner,
110 - })
111 - }
112 -
113 - #[derive(Deserialize)]
114 - pub(super) struct IssueCreateForm {
115 - title: String,
116 - body: Option<String>,
117 - labels: Option<String>, // comma-separated label IDs
118 - }
119 -
120 - /// `POST /git/{owner}/{repo}/issues`
121 - #[tracing::instrument(skip_all, name = "git_issues::create")]
122 - pub(super) async fn issue_create(
123 - State(state): State<AppState>,
124 - AuthUser(user): AuthUser,
125 - Path((owner, repo_name)): Path<(String, String)>,
126 - Form(form): Form<IssueCreateForm>,
127 - ) -> Result<impl IntoResponse> {
128 - user.check_not_suspended()?;
129 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
130 -
131 - let title = form.title.trim();
132 - let body_md = form.body.as_deref().unwrap_or("").trim();
133 - validation::validate_issue_title(title)?;
134 - validation::validate_issue_body(body_md)?;
135 -
136 - let body_html = if body_md.is_empty() {
137 - String::new()
138 - } else {
139 - docengine::render_permissive(body_md)
140 - };
141 -
142 - let issue = db::issues::create_issue(
143 - &state.db, resolved.db_repo.id, user.id, title, body_md, &body_html,
144 - ).await?;
145 -
146 - // Assign labels if provided
147 - if let Some(label_ids_str) = &form.labels {
148 - for id_str in label_ids_str.split(',').filter(|s| !s.is_empty()) {
149 - if let Ok(label_id) = id_str.parse::<IssueLabelId>() {
150 - let _ = db::issues::add_label_to_issue(&state.db, issue.id, label_id).await;
151 - }
152 - }
153 - }
154 -
155 - // Notify repo owner (if creator != owner and owner has notify_issues enabled)
156 - if user.id != resolved.db_user.id {
157 - notify_new_issue(
158 - &state, &resolved.db_user, &owner, &repo_name,
159 - issue.number, title, &user.username,
160 - );
161 - }
162 -
163 - Ok(Redirect::to(&format!(
164 - "/git/{}/{}/issues/{}",
165 - owner, repo_name, issue.number
166 - )))
167 - }
168 -
169 80 // ── Issue Detail ──
170 81
171 82 /// `GET /git/{owner}/{repo}/issues/{number}`
@@ -187,18 +98,15 @@ pub(super) async fn issue_detail(
187 98 .ok_or(AppError::NotFound)?;
188 99
189 100 let comments = db::issues::list_comments(&state.db, issue.id).await?;
190 - let labels = db::issues::get_labels_for_issue(&state.db, issue.id).await?;
191 - let assigned_label_ids: Vec<db::IssueLabelId> = labels.iter().map(|l| l.id).collect();
192 - let all_labels = db::issues::list_labels(&state.db, resolved.db_repo.id).await?;
193 101
194 - let is_author = maybe_user.as_ref().map(|u| u.id) == Some(issue.author_user_id);
195 102 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
196 - let can_edit = is_author || is_owner;
197 103
198 104 let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await?;
199 105 let current_ref = default_ref(&state, &owner, &repo_name);
200 106 let csrf_token = get_csrf_token(&session).await;
201 107
108 + let email_address = format!("{}+{}@issues.makenot.work", owner, repo_name);
109 +
202 110 Ok(GitIssueDetailTemplate {
203 111 csrf_token,
204 112 session_user: maybe_user,
@@ -208,375 +116,8 @@ pub(super) async fn issue_detail(
208 116 issue,
209 117 author_username: author.username.to_string(),
210 118 comments,
211 - labels,
212 - all_labels,
213 - assigned_label_ids,
214 - can_edit,
215 119 is_owner,
216 120 open_issue_count,
121 + email_address,
217 122 })
218 123 }
219 -
220 - // ── Edit Issue ──
221 -
222 - /// `GET /git/{owner}/{repo}/issues/{number}/edit`
223 - #[tracing::instrument(skip_all, name = "git_issues::edit_form")]
224 - pub(super) async fn issue_edit_form(
225 - State(state): State<AppState>,
226 - session: Session,
227 - AuthUser(user): AuthUser,
228 - Path((owner, repo_name, number)): Path<(String, String, i32)>,
229 - ) -> Result<impl IntoResponse> {
230 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
231 - let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number)
232 - .await?
233 - .ok_or(AppError::NotFound)?;
234 -
235 - // Only author or repo owner can edit
236 - if user.id != issue.author_user_id && user.id != resolved.db_user.id {
237 - return Err(AppError::Forbidden);
238 - }
239 -
240 - let labels = db::issues::list_labels(&state.db, resolved.db_repo.id).await?;
241 - let assigned = db::issues::get_labels_for_issue(&state.db, issue.id).await?;
242 - let assigned_label_ids: Vec<IssueLabelId> = assigned.iter().map(|l| l.id).collect();
243 - let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await?;
244 - let current_ref = default_ref(&state, &owner, &repo_name);
245 - let csrf_token = get_csrf_token(&session).await;
246 -
247 - Ok(GitIssueEditTemplate {
248 - csrf_token,
249 - session_user: Some(user),
250 - owner,
251 - repo_name,
252 - current_ref,
253 - issue,
254 - labels,
255 - assigned_label_ids,
256 - open_issue_count,
257 - is_owner: true, // Only author or owner reaches this handler
258 - })
259 - }
260 -
261 - #[derive(Deserialize)]
262 - pub(super) struct IssueEditForm {
263 - title: String,
264 - body: Option<String>,
265 - labels: Option<String>,
266 - }
267 -
268 - /// `POST /git/{owner}/{repo}/issues/{number}/edit`
269 - #[tracing::instrument(skip_all, name = "git_issues::edit")]
270 - pub(super) async fn issue_edit(
271 - State(state): State<AppState>,
272 - AuthUser(user): AuthUser,
273 - Path((owner, repo_name, number)): Path<(String, String, i32)>,
274 - Form(form): Form<IssueEditForm>,
275 - ) -> Result<impl IntoResponse> {
276 - user.check_not_suspended()?;
277 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
278 - let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number)
279 - .await?
280 - .ok_or(AppError::NotFound)?;
281 -
282 - if user.id != issue.author_user_id && user.id != resolved.db_user.id {
283 - return Err(AppError::Forbidden);
284 - }
285 -
286 - let title = form.title.trim();
287 - let body_md = form.body.as_deref().unwrap_or("").trim();
288 - validation::validate_issue_title(title)?;
289 - validation::validate_issue_body(body_md)?;
290 -
291 - let body_html = if body_md.is_empty() {
292 - String::new()
293 - } else {
294 - docengine::render_permissive(body_md)
295 - };
296 -
297 - db::issues::update_issue(&state.db, issue.id, title, body_md, &body_html).await?;
298 -
299 - // Sync labels: remove all existing, add new
300 - let existing = db::issues::get_labels_for_issue(&state.db, issue.id).await?;
301 - for label in &existing {
302 - db::issues::remove_label_from_issue(&state.db, issue.id, label.id).await?;
303 - }
304 - if let Some(label_ids_str) = &form.labels {
305 - for id_str in label_ids_str.split(',').filter(|s| !s.is_empty()) {
306 - if let Ok(label_id) = id_str.parse::<IssueLabelId>() {
307 - let _ = db::issues::add_label_to_issue(&state.db, issue.id, label_id).await;
308 - }
309 - }
310 - }
311 -
312 - Ok(Redirect::to(&format!(
313 - "/git/{}/{}/issues/{}",
314 - owner, repo_name, number
315 - )))
316 - }
317 -
318 - // ── Close / Reopen ──
319 -
320 - /// `POST /git/{owner}/{repo}/issues/{number}/close`
321 - #[tracing::instrument(skip_all, name = "git_issues::close")]
322 - pub(super) async fn issue_close(
323 - State(state): State<AppState>,
324 - AuthUser(user): AuthUser,
325 - Path((owner, repo_name, number)): Path<(String, String, i32)>,
326 - ) -> Result<impl IntoResponse> {
327 - user.check_not_suspended()?;
328 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
329 - let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number)
330 - .await?
331 - .ok_or(AppError::NotFound)?;
332 -
333 - if user.id != issue.author_user_id && user.id != resolved.db_user.id {
334 - return Err(AppError::Forbidden);
335 - }
336 -
337 - db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Closed).await?;
338 -
339 - // Notify issue author (if actor != author)
340 - if user.id != issue.author_user_id {
341 - let preview = format!("Issue was closed by {}.", user.username);
342 - notify_issue_comment(
343 - &state, &resolved.db_user, issue.author_user_id, user.id,
344 - &owner, &repo_name, number, &issue.title,
345 - &user.username, &preview,
346 - );
347 - }
348 -
349 - Ok(Redirect::to(&format!(
350 - "/git/{}/{}/issues/{}",
351 - owner, repo_name, number
352 - )))
353 - }
354 -
355 - /// `POST /git/{owner}/{repo}/issues/{number}/reopen`
356 - #[tracing::instrument(skip_all, name = "git_issues::reopen")]
357 - pub(super) async fn issue_reopen(
358 - State(state): State<AppState>,
359 - AuthUser(user): AuthUser,
360 - Path((owner, repo_name, number)): Path<(String, String, i32)>,
361 - ) -> Result<impl IntoResponse> {
362 - user.check_not_suspended()?;
363 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
364 - let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number)
365 - .await?
366 - .ok_or(AppError::NotFound)?;
367 -
368 - if user.id != issue.author_user_id && user.id != resolved.db_user.id {
369 - return Err(AppError::Forbidden);
370 - }
371 -
372 - db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Open).await?;
373 -
374 - // Notify issue author (if actor != author)
375 - if user.id != issue.author_user_id {
376 - let preview = format!("Issue was reopened by {}.", user.username);
377 - notify_issue_comment(
378 - &state, &resolved.db_user, issue.author_user_id, user.id,
379 - &owner, &repo_name, number, &issue.title,
380 - &user.username, &preview,
381 - );
382 - }
383 -
384 - Ok(Redirect::to(&format!(
385 - "/git/{}/{}/issues/{}",
386 - owner, repo_name, number
387 - )))
388 - }
389 -
390 - // ── Comments ──
391 -
392 - #[derive(Deserialize)]
393 - pub(super) struct CommentForm {
394 - body: String,
395 - }
396 -
397 - /// `POST /git/{owner}/{repo}/issues/{number}/comment`
398 - #[tracing::instrument(skip_all, name = "git_issues::comment")]
399 - pub(super) async fn issue_comment(
400 - State(state): State<AppState>,
401 - AuthUser(user): AuthUser,
402 - Path((owner, repo_name, number)): Path<(String, String, i32)>,
403 - Form(form): Form<CommentForm>,
404 - ) -> Result<impl IntoResponse> {
405 - user.check_not_suspended()?;
406 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
407 - let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number)
408 - .await?
409 - .ok_or(AppError::NotFound)?;
410 -
411 - let body_md = form.body.trim();
412 - validation::validate_issue_comment_body(body_md)?;
413 - let body_html = docengine::render_permissive(body_md);
414 -
415 - db::issues::create_comment(&state.db, issue.id, user.id, body_md, &body_html).await?;
416 -
417 - // Notify repo owner + issue author (deduplicated, excluding commenter)
418 - notify_issue_comment(
419 - &state, &resolved.db_user, issue.author_user_id, user.id,
420 - &owner, &repo_name, issue.number, &issue.title,
421 - &user.username, body_md,
422 - );
423 -
424 - Ok(Redirect::to(&format!(
425 - "/git/{}/{}/issues/{}",
426 - owner, repo_name, number
427 - )))
428 - }
429 -
430 - /// `POST /git/{owner}/{repo}/issues/{number}/comment/{id}/delete`
431 - #[tracing::instrument(skip_all, name = "git_issues::comment_delete")]
432 - pub(super) async fn comment_delete(
433 - State(state): State<AppState>,
434 - AuthUser(user): AuthUser,
435 - Path((owner, repo_name, number, comment_id)): Path<(String, String, i32, IssueCommentId)>,
436 - ) -> Result<impl IntoResponse> {
437 - user.check_not_suspended()?;
438 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
439 -
440 - // Verify issue exists
441 - let _issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number)
442 - .await?
443 - .ok_or(AppError::NotFound)?;
444 -
445 - let comment = db::issues::get_comment(&state.db, comment_id)
446 - .await?
447 - .ok_or(AppError::NotFound)?;
448 -
449 - // Only comment author or repo owner can delete
450 - if user.id != comment.author_user_id && user.id != resolved.db_user.id {
451 - return Err(AppError::Forbidden);
452 - }
453 -
454 - db::issues::delete_comment(&state.db, comment_id).await?;
455 -
456 - Ok(Redirect::to(&format!(
457 - "/git/{}/{}/issues/{}",
458 - owner, repo_name, number
459 - )))
460 - }
461 -
462 - // ── Email notification helpers ──
463 -
464 - /// Fire-and-forget notification to repo owner about a new issue.
465 - fn notify_new_issue(
466 - state: &AppState,
467 - repo_owner: &db::DbUser,
468 - owner_name: &str,
469 - repo_name: &str,
470 - issue_number: i32,
471 - issue_title: &str,
472 - author_username: &str,
473 - ) {
474 - if !repo_owner.notify_issues {
475 - return;
476 - }
477 - let email_client = state.email.clone();
478 - let to_email = repo_owner.email.clone();
479 - let to_name = repo_owner.display_name.clone();
480 - let owner_id = repo_owner.id;
481 - let host_url = state.config.host_url.clone();
482 - let signing_secret = state.config.signing_secret.clone();
483 - let owner_name = owner_name.to_string();
484 - let repo_name = repo_name.to_string();
485 - let issue_title = issue_title.to_string();
486 - let author_username = author_username.to_string();
487 -
488 - tokio::spawn(async move {
489 - let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number);
490 - let unsub_url = email::generate_unsubscribe_url(
491 - &host_url, owner_id, "issue", &owner_id.to_string(), &signing_secret,
492 - );
493 - if let Err(e) = email_client
494 - .send_new_issue_notification(
495 - &to_email,
496 - to_name.as_deref(),
497 - &owner_name,
498 - &repo_name,
499 - issue_number,
500 - &issue_title,
501 - &author_username,
502 - &issue_url,
503 - Some(&unsub_url),
504 - )
505 - .await
506 - {
507 - tracing::error!(error = ?e, "failed to send new issue notification");
508 - }
509 - });
510 - }
511 -
512 - /// Fire-and-forget notification to repo owner + issue author about a comment or status change.
513 - #[allow(clippy::too_many_arguments)]
514 - fn notify_issue_comment(
515 - state: &AppState,
516 - repo_owner: &db::DbUser,
517 - issue_author_id: UserId,
518 - commenter_id: UserId,
519 - owner_name: &str,
520 - repo_name: &str,
521 - issue_number: i32,
522 - issue_title: &str,
523 - commenter_username: &str,
524 - comment_text: &str,
525 - ) {
526 - // Collect recipients: repo owner + issue author, minus the commenter, deduplicated
527 - let mut recipient_ids = Vec::new();
528 - if repo_owner.id != commenter_id {
529 - recipient_ids.push(repo_owner.id);
530 - }
531 - if issue_author_id != commenter_id && issue_author_id != repo_owner.id {
532 - recipient_ids.push(issue_author_id);
533 - }
534 - if recipient_ids.is_empty() {
535 - return;
536 - }
537 -
538 - let db = state.db.clone();
539 - let email_client = state.email.clone();
540 - let host_url = state.config.host_url.clone();
541 - let signing_secret = state.config.signing_secret.clone();
542 - let owner_name = owner_name.to_string();
543 - let repo_name = repo_name.to_string();
544 - let issue_title = issue_title.to_string();
545 - let commenter_username = commenter_username.to_string();
546 - // Truncate preview to ~200 chars
547 - let preview: String = comment_text.chars().take(200).collect();
548 -
549 - tokio::spawn(async move {
550 - let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number);
551 -
Lines truncated
@@ -1,198 +0,0 @@
1 - //! Label CRUD and label assignment on issues.
2 -
3 - use axum::{
4 - extract::{Path, State},
5 - response::{IntoResponse, Redirect},
6 - Form,
7 - };
8 - use serde::Deserialize;
9 - use tower_sessions::Session;
10 -
11 - use crate::{
12 - auth::AuthUser,
13 - db::{self, IssueLabelId},
14 - error::{AppError, Result},
15 - helpers::get_csrf_token,
16 - templates::*,
17 - validation,
18 - AppState,
19 - };
20 -
21 - use super::default_ref;
22 -
23 - // ── Label CRUD ──
24 -
25 - /// `GET /git/{owner}/{repo}/issues/labels` — label management (owner only).
26 - #[tracing::instrument(skip_all, name = "git_issues::labels")]
27 - pub(super) async fn label_list(
28 - State(state): State<AppState>,
29 - session: Session,
30 - AuthUser(user): AuthUser,
31 - Path((owner, repo_name)): Path<(String, String)>,
32 - ) -> Result<impl IntoResponse> {
33 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
34 -
35 - if user.id != resolved.db_user.id {
36 - return Err(AppError::Forbidden);
37 - }
38 -
39 - let labels = db::issues::list_labels(&state.db, resolved.db_repo.id).await?;
40 - let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await?;
41 - let current_ref = default_ref(&state, &owner, &repo_name);
42 - let csrf_token = get_csrf_token(&session).await;
43 -
44 - Ok(GitIssueLabelManagerTemplate {
45 - csrf_token,
46 - session_user: Some(user),
47 - owner,
48 - repo_name,
49 - current_ref,
50 - labels,
51 - open_issue_count,
52 - is_owner: true, // Only owner reaches this handler
53 - })
54 - }
55 -
56 - #[derive(Deserialize)]
57 - pub(super) struct LabelForm {
58 - name: String,
59 - color: String,
60 - }
61 -
62 - /// `POST /git/{owner}/{repo}/issues/labels`
63 - #[tracing::instrument(skip_all, name = "git_issues::label_create")]
64 - pub(super) async fn label_create(
65 - State(state): State<AppState>,
66 - AuthUser(user): AuthUser,
67 - Path((owner, repo_name)): Path<(String, String)>,
68 - Form(form): Form<LabelForm>,
69 - ) -> Result<impl IntoResponse> {
70 - user.check_not_suspended()?;
71 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
72 -
73 - if user.id != resolved.db_user.id {
74 - return Err(AppError::Forbidden);
75 - }
76 -
77 - let name = form.name.trim();
78 - let color = form.color.trim();
79 - validation::validate_label_name(name)?;
80 - validation::validate_label_color(color)?;
81 -
82 - db::issues::create_label(&state.db, resolved.db_repo.id, name, color).await?;
83 -
84 - Ok(Redirect::to(&format!(
85 - "/git/{}/{}/issues/labels",
86 - owner, repo_name
87 - )))
88 - }
89 -
90 - /// `POST /git/{owner}/{repo}/issues/labels/{id}/edit`
91 - #[tracing::instrument(skip_all, name = "git_issues::label_edit")]
92 - pub(super) async fn label_edit(
93 - State(state): State<AppState>,
94 - AuthUser(user): AuthUser,
95 - Path((owner, repo_name, label_id)): Path<(String, String, IssueLabelId)>,
96 - Form(form): Form<LabelForm>,
97 - ) -> Result<impl IntoResponse> {
98 - user.check_not_suspended()?;
99 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
100 -
101 - if user.id != resolved.db_user.id {
102 - return Err(AppError::Forbidden);
103 - }
104 -
105 - let name = form.name.trim();
106 - let color = form.color.trim();
107 - validation::validate_label_name(name)?;
108 - validation::validate_label_color(color)?;
109 -
110 - db::issues::update_label(&state.db, label_id, name, color).await?;
111 -
112 - Ok(Redirect::to(&format!(
113 - "/git/{}/{}/issues/labels",
114 - owner, repo_name
115 - )))
116 - }
117 -
118 - /// `POST /git/{owner}/{repo}/issues/labels/{id}/delete`
119 - #[tracing::instrument(skip_all, name = "git_issues::label_delete")]
120 - pub(super) async fn label_delete(
121 - State(state): State<AppState>,
122 - AuthUser(user): AuthUser,
123 - Path((owner, repo_name, label_id)): Path<(String, String, IssueLabelId)>,
124 - ) -> Result<impl IntoResponse> {
125 - user.check_not_suspended()?;
126 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
127 -
128 - if user.id != resolved.db_user.id {
129 - return Err(AppError::Forbidden);
130 - }
131 -
132 - db::issues::delete_label(&state.db, label_id).await?;
133 -
134 - Ok(Redirect::to(&format!(
135 - "/git/{}/{}/issues/labels",
136 - owner, repo_name
137 - )))
138 - }
139 -
140 - // ── Label assignment on issue detail ──
141 -
142 - #[derive(Deserialize)]
143 - pub(super) struct IssueLabelForm {
144 - label_id: IssueLabelId,
145 - }
146 -
147 - /// `POST /git/{owner}/{repo}/issues/{number}/labels/add`
148 - #[tracing::instrument(skip_all, name = "git_issues::add_label")]
149 - pub(super) async fn issue_add_label(
150 - State(state): State<AppState>,
151 - AuthUser(user): AuthUser,
152 - Path((owner, repo_name, number)): Path<(String, String, i32)>,
153 - Form(form): Form<IssueLabelForm>,
154 - ) -> Result<impl IntoResponse> {
155 - user.check_not_suspended()?;
156 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
157 - let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number)
158 - .await?
159 - .ok_or(AppError::NotFound)?;
160 -
161 - // Only author or owner can manage labels
162 - if user.id != issue.author_user_id && user.id != resolved.db_user.id {
163 - return Err(AppError::Forbidden);
164 - }
165 -
166 - db::issues::add_label_to_issue(&state.db, issue.id, form.label_id).await?;
167 -
168 - Ok(Redirect::to(&format!(
169 - "/git/{}/{}/issues/{}",
170 - owner, repo_name, number
171 - )))
172 - }
173 -
174 - /// `POST /git/{owner}/{repo}/issues/{number}/labels/remove`
175 - #[tracing::instrument(skip_all, name = "git_issues::remove_label")]
176 - pub(super) async fn issue_remove_label(
177 - State(state): State<AppState>,
178 - AuthUser(user): AuthUser,
179 - Path((owner, repo_name, number)): Path<(String, String, i32)>,
180 - Form(form): Form<IssueLabelForm>,
181 - ) -> Result<impl IntoResponse> {
182 - user.check_not_suspended()?;
183 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
184 - let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number)
185 - .await?
186 - .ok_or(AppError::NotFound)?;
187 -
188 - if user.id != issue.author_user_id && user.id != resolved.db_user.id {
189 - return Err(AppError::Forbidden);
190 - }
191 -
192 - db::issues::remove_label_from_issue(&state.db, issue.id, form.label_id).await?;
193 -
194 - Ok(Redirect::to(&format!(
195 - "/git/{}/{}/issues/{}",
196 - owner, repo_name, number
197 - )))
198 - }
@@ -1,8 +1,7 @@
1 - //! Git issue tracker routes: create, view, list, close/reopen, comment, labels.
1 + //! Git issue tracker routes: read-only list and detail views.
2 2 //! Also includes the commit-message issue reference parser and process-push endpoint.
3 3
4 4 mod issues;
5 - mod labels;
6 5 mod push_refs;
7 6 mod settings;
8 7
@@ -19,26 +18,9 @@ use crate::{
19 18 /// Register all git issue routes.
20 19 pub fn git_issue_routes() -> Router<AppState> {
21 20 Router::new()
22 - // Issue list + create
21 + // Issue list + detail (read-only)
23 22 .route("/git/{owner}/{repo}/issues", get(issues::issue_list))
24 - .route("/git/{owner}/{repo}/issues/new", get(issues::issue_new_form))
25 - .route("/git/{owner}/{repo}/issues", post(issues::issue_create))
26 - // Issue detail + actions
27 23 .route("/git/{owner}/{repo}/issues/{number}", get(issues::issue_detail))
28 - .route("/git/{owner}/{repo}/issues/{number}/edit", get(issues::issue_edit_form))
29 - .route("/git/{owner}/{repo}/issues/{number}/edit", post(issues::issue_edit))
30 - .route("/git/{owner}/{repo}/issues/{number}/close", post(issues::issue_close))
31 - .route("/git/{owner}/{repo}/issues/{number}/reopen", post(issues::issue_reopen))
32 - .route("/git/{owner}/{repo}/issues/{number}/comment", post(issues::issue_comment))
33 - .route("/git/{owner}/{repo}/issues/{number}/comment/{id}/delete", post(issues::comment_delete))
34 - // Labels
35 - .route("/git/{owner}/{repo}/issues/labels", get(labels::label_list))
36 - .route("/git/{owner}/{repo}/issues/labels", post(labels::label_create))
37 - .route("/git/{owner}/{repo}/issues/labels/{id}/edit", post(labels::label_edit))
38 - .route("/git/{owner}/{repo}/issues/labels/{id}/delete", post(labels::label_delete))
39 - // Label assignment on issues
40 - .route("/git/{owner}/{repo}/issues/{number}/labels/add", post(labels::issue_add_label))
41 - .route("/git/{owner}/{repo}/issues/{number}/labels/remove", post(labels::issue_remove_label))
42 24 // Repo settings
43 25 .route("/git/{owner}/{repo}/settings", get(settings::repo_settings_form))
44 26 .route("/git/{owner}/{repo}/settings", post(settings::repo_settings_save))
@@ -23,6 +23,7 @@ use super::repos_root;
23 23 #[derive(Debug, PartialEq, Eq)]
24 24 enum IssueRefAction {
25 25 Close,
26 + Reopen,
26 27 Reference,
27 28 }
28 29
@@ -43,6 +44,9 @@ fn parse_issue_refs(message: &str) -> Vec<IssueRef> {
43 44 static CLOSE_RE: LazyLock<Regex> = LazyLock::new(|| {
44 45 Regex::new(r"(?i)(?:fix|close|resolve)(?:es|ed|s|d)?\s+#(\d+)").unwrap()
45 46 });
47 + static REOPEN_RE: LazyLock<Regex> = LazyLock::new(|| {
48 + Regex::new(r"(?i)reopen(?:s|ed)?\s+#(\d+)").unwrap()
49 + });
46 50 static REF_RE: LazyLock<Regex> = LazyLock::new(|| {
47 51 Regex::new(r"(?i)(?:ref|reference)s?\s+#(\d+)").unwrap()
48 52 });
@@ -51,11 +55,18 @@ fn parse_issue_refs(message: &str) -> Vec<IssueRef> {
51 55
52 56 for cap in CLOSE_RE.captures_iter(message) {
53 57 if let Ok(n) = cap[1].parse::<i32>() {
54 - // Close always wins
58 + // Close always wins over reference
55 59 by_number.insert(n, IssueRefAction::Close);
56 60 }
57 61 }
58 62
63 + for cap in REOPEN_RE.captures_iter(message) {
64 + if let Ok(n) = cap[1].parse::<i32>() {
65 + // Reopen wins over reference but not close
66 + by_number.entry(n).or_insert(IssueRefAction::Reopen);
67 + }
68 + }
69 +
59 70 for cap in REF_RE.captures_iter(message) {
60 71 if let Ok(n) = cap[1].parse::<i32>() {
61 72 by_number.entry(n).or_insert(IssueRefAction::Reference);
@@ -184,6 +195,19 @@ pub(super) async fn process_push(
184 195 &state.db, issue.id, owner_user.id, &body_md, &body_html,
185 196 ).await;
186 197 }
198 + IssueRefAction::Reopen => {
199 + if issue.status == IssueStatus::Closed {
200 + let _ = db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Open).await;
201 + }
202 + let body_md = format!(
203 + "Reopened via commit [`{}`](/git/{}/{}/commit/{}) on `{}`.",
204 + short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name,
205 + );
206 + let body_html = docengine::render_permissive(&body_md);
207 + let _ = db::issues::create_comment(
208 + &state.db, issue.id, owner_user.id, &body_md, &body_html,
209 + ).await;
210 + }
187 211 IssueRefAction::Reference => {
188 212 let body_md = format!(
189 213 "Referenced in commit [`{}`](/git/{}/{}/commit/{}) on `{}`.",
@@ -282,4 +306,40 @@ mod tests {
282 306 assert!(refs.contains(&IssueRef { number: 5, action: IssueRefAction::Close }));
283 307 assert!(refs.contains(&IssueRef { number: 6, action: IssueRefAction::Reference }));
284 308 }
309 +
310 + #[test]
311 + fn parse_issue_refs_reopens() {
312 + assert_eq!(
313 + parse_issue_refs("Reopens #5"),
314 + vec![IssueRef { number: 5, action: IssueRefAction::Reopen }]
315 + );
316 + }
317 +
318 + #[test]
319 + fn parse_issue_refs_reopened() {
320 + assert_eq!(
321 + parse_issue_refs("Reopened #3"),
322 + vec![IssueRef { number: 3, action: IssueRefAction::Reopen }]
323 + );
324 + }
325 +
326 + #[test]
327 + fn parse_issue_refs_reopen_bare() {
328 + assert_eq!(
329 + parse_issue_refs("reopen #7"),
330 + vec![IssueRef { number: 7, action: IssueRefAction::Reopen }]
331 + );
332 + }
333 +
334 + #[test]
335 + fn parse_issue_refs_close_wins_over_reopen() {
336 + let refs = parse_issue_refs("Reopens #1\nFixes #1");
337 + assert_eq!(refs, vec![IssueRef { number: 1, action: IssueRefAction::Close }]);
338 + }
339 +
340 + #[test]
341 + fn parse_issue_refs_reopen_wins_over_ref() {
342 + let refs = parse_issue_refs("Refs #2\nReopens #2");
343 + assert_eq!(refs, vec![IssueRef { number: 2, action: IssueRefAction::Reopen }]);
344 + }
285 345 }
@@ -1,4 +1,4 @@
1 - //! Postmark webhook endpoints for bounce/complaint events and inbound email (patches).
1 + //! Postmark webhook endpoints for bounce/complaint events, inbound patches, and inbound issues.
2 2
3 3 use axum::{
4 4 extract::State,
@@ -327,11 +327,421 @@ fn extract_project_slug(to: &str) -> Option<String> {
327 327 None
328 328 }
329 329
330 + /// Handle Postmark inbound email webhook for git issues.
331 + ///
332 + /// Routes by To address domain:
333 + /// - `@issues.makenot.work` → new issue: `{owner}+{repo}@issues.makenot.work`
334 + /// - `@reply.makenot.work` → reply to existing issue: `issue+{id}.{uid}.{sig}@reply.makenot.work`
335 + #[tracing::instrument(skip_all, name = "postmark::inbound_issues")]
336 + async fn postmark_inbound_issues(
337 + State(state): State<AppState>,
338 + headers: HeaderMap,
339 + Json(payload): Json<PostmarkInboundPayload>,
340 + ) -> impl IntoResponse {
341 + // 1. Auth — verify bearer token
342 + let token_ok = state.config.postmark_inbound_webhook_token.as_deref()
343 + .is_some_and(|t| verify_token(&headers, t));
344 +
345 + if !token_ok {
346 + if state.config.postmark_inbound_webhook_token.is_none() {
347 + tracing::warn!("Postmark inbound-issues received but no token configured");
348 + } else {
349 + tracing::warn!("Postmark inbound-issues: invalid bearer token");
350 + }
351 + return StatusCode::UNAUTHORIZED;
352 + }
353 +
354 + // 2. Route by domain
355 + if let Some((owner, repo)) = extract_issue_address(&payload.to) {
356 + handle_new_issue(&state, &payload, &owner, &repo).await
357 + } else if let Some(local) = extract_reply_local(&payload.to) {
358 + handle_issue_reply(&state, &payload, &local).await
359 + } else {
360 + tracing::debug!(to = %payload.to, "inbound-issues: unrecognized To address");
361 + StatusCode::OK
362 + }
363 + }
364 +
365 + /// Handle a new issue submitted via `{owner}+{repo}@issues.makenot.work`.
366 + async fn handle_new_issue(
367 + state: &AppState,
368 + payload: &PostmarkInboundPayload,
369 + owner: &str,
370 + repo_name: &str,
371 + ) -> StatusCode {
372 + // Look up sender — must be a verified, non-suspended MNW user
373 + let sender_email = payload.from_full.email.to_lowercase();
374 + let sender = match db::users::get_user_by_email(&state.db, &sender_email).await {
375 + Ok(Some(u)) if u.email_verified && !u.is_suspended() => u,
376 + Ok(Some(u)) if !u.email_verified => {
377 + tracing::info!(email = %sender_email, "inbound-issues: sender email not verified");
378 + return StatusCode::OK;
379 + }
380 + Ok(Some(_)) => {
381 + tracing::info!(email = %sender_email, "inbound-issues: sender is suspended");
382 + return StatusCode::OK;
383 + }
384 + Ok(None) => {
385 + tracing::info!(email = %sender_email, "inbound-issues: sender has no MNW account");
386 + return StatusCode::OK;
387 + }
388 + Err(e) => {
389 + tracing::error!(error = ?e, "inbound-issues: user lookup failed");
390 + return StatusCode::OK;
391 + }
392 + };
393 +
394 + // Look up repo owner + repo
395 + let owner_user = match db::users::get_user_by_username(
396 + &state.db,
397 + &db::Username::from_trusted(owner.to_string()),
398 + ).await {
399 + Ok(Some(u)) => u,
400 + Ok(None) => {
401 + tracing::info!(owner = %owner, "inbound-issues: repo owner not found");
402 + return StatusCode::OK;
403 + }
404 + Err(e) => {
405 + tracing::error!(error = ?e, "inbound-issues: owner lookup failed");
406 + return StatusCode::OK;
407 + }
408 + };
409 +
410 + let repo = match db::git_repos::get_repo_by_user_and_name(&state.db, owner_user.id, repo_name).await {
411 + Ok(Some(r)) => r,
412 + Ok(None) => {
413 + tracing::info!(owner = %owner, repo = %repo_name, "inbound-issues: repo not found");
414 + return StatusCode::OK;
415 + }
416 + Err(e) => {
417 + tracing::error!(error = ?e, "inbound-issues: repo lookup failed");
418 + return StatusCode::OK;
419 + }
420 + };
421 +
422 + // Create the issue
423 + let title = payload.subject.trim();
424 + if title.is_empty() {
425 + tracing::info!("inbound-issues: empty subject, skipping");
426 + return StatusCode::OK;
427 + }
428 +
429 + let body_md = payload.text_body.trim();
430 + let body_html = if body_md.is_empty() {
431 + String::new()
432 + } else {
433 + docengine::render_permissive(body_md)
434 + };
435 +
436 + let issue = match db::issues::create_issue(
437 + &state.db, repo.id, sender.id, title, body_md, &body_html,
438 + ).await {
439 + Ok(i) => i,
440 + Err(e) => {
441 + tracing::error!(error = ?e, "inbound-issues: failed to create issue");
442 + return StatusCode::OK;
443 + }
444 + };
445 +
446 + // Store message ID mapping for threading
447 + if let Err(e) = db::issues::insert_issue_message_id(
448 + &state.db, &payload.message_id, issue.id,
449 + ).await {
450 + tracing::error!(error = ?e, "inbound-issues: failed to store message-id mapping");
451 + }
452 +
453 + tracing::info!(
454 + issue_number = issue.number,
455 + message_id = %payload.message_id,
456 + "inbound-issues: new issue created"
457 + );
458 +
459 + // Notify repo owner (if different from sender)
460 + if sender.id != owner_user.id && owner_user.notify_issues {
461 + let email_client = state.email.clone();
462 + let host_url = state.config.host_url.clone();
463 + let signing_secret = state.config.signing_secret.clone();
464 + let to_email = owner_user.email.clone();
465 + let to_name = owner_user.display_name.clone();
466 + let owner_id = owner_user.id;
467 + let owner_name = owner.to_string();
468 + let repo_name = repo_name.to_string();
469 + let issue_title = title.to_string();
470 + let author_username = sender.username.to_string();
471 + let issue_number = issue.number;
472 + let issue_id = issue.id;
473 +
474 + tokio::spawn(async move {
475 + let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number);
476 + let unsub_url = crate::email::generate_unsubscribe_url(
477 + &host_url, owner_id, "issue", &owner_id.to_string(), &signing_secret,
478 + );
479 + let reply_to = crate::email::generate_issue_reply_address(issue_id, owner_id, &signing_secret);
480 + let msg_id = format!("<issue-{}-{}@makenot.work>", issue_id, chrono::Utc::now().timestamp());
481 +
482 + if let Err(e) = email_client
483 + .send_new_issue_notification(
484 + &to_email,
485 + to_name.as_deref(),
486 + &owner_name,
487 + &repo_name,
488 + issue_number,
489 + &issue_title,
490 + &author_username,
491 + &issue_url,
492 + Some(&unsub_url),
493 + Some(&reply_to),
494 + Some(&msg_id),
495 + )
496 + .await
497 + {
498 + tracing::error!(error = ?e, "failed to send new issue notification");
499 + }
500 + });
501 + }
502 +
503 + StatusCode::OK
504 + }
505 +
506 + /// Handle a reply to an existing issue via `issue+{id}.{uid}.{sig}@reply.makenot.work`.
507 + async fn handle_issue_reply(
508 + state: &AppState,
509 + payload: &PostmarkInboundPayload,
510 + local_part: &str,
511 + ) -> StatusCode {
512 + // Parse and verify the reply token
513 + let (issue_id, expected_user_id) = match crate::email::parse_issue_reply_token(
514 + local_part, &state.config.signing_secret,
515 + ) {
516 + Some(ids) => ids,
517 + None => {
518 + tracing::info!(local = %local_part, "inbound-issues: invalid reply token");
519 + return StatusCode::OK;
520 + }
521 + };
522 +
523 + // Look up sender and verify they match the token
524 + let sender_email = payload.from_full.email.to_lowercase();
525 + let sender = match db::users::get_user_by_email(&state.db, &sender_email).await {
526 + Ok(Some(u)) if u.email_verified && !u.is_suspended() => u,
527 + Ok(Some(_)) => {
528 + tracing::info!(email = %sender_email, "inbound-issues: reply sender not verified/suspended");
529 + return StatusCode::OK;
530 + }
531 + Ok(None) => {
532 + tracing::info!(email = %sender_email, "inbound-issues: reply sender has no MNW account");
533 + return StatusCode::OK;
534 + }
535 + Err(e) => {
536 + tracing::error!(error = ?e, "inbound-issues: reply sender lookup failed");
537 + return StatusCode::OK;
538 + }
539 + };
540 +
541 + if sender.id != expected_user_id {
542 + tracing::info!(
543 + sender = %sender.id,
544 + expected = %expected_user_id,
545 + "inbound-issues: reply sender does not match token user_id"
546 + );
547 + return StatusCode::OK;
548 + }
549 +
550 + // Look up the issue
551 + let issue = match db::issues::get_issue_by_id(&state.db, issue_id).await {
552 + Ok(Some(i)) => i,
553 + Ok(None) => {
554 + tracing::info!(issue_id = %issue_id, "inbound-issues: issue not found for reply");
555 + return StatusCode::OK;
556 + }
557 + Err(e) => {
558 + tracing::error!(error = ?e, "inbound-issues: issue lookup failed");
559 + return StatusCode::OK;
560 + }
561 + };
562 +
563 + // Strip quoted text from the reply body
564 + let body_md = strip_quoted_text(&payload.text_body);
565 + let body_md = body_md.trim();
566 + if body_md.is_empty() {
567 + tracing::info!("inbound-issues: empty reply body after stripping quotes");
568 + return StatusCode::OK;
569 + }
570 +
571 + let body_html = docengine::render_permissive(body_md);
572 +
573 + // Create the comment
574 + if let Err(e) = db::issues::create_comment(
575 + &state.db, issue.id, sender.id, body_md, &body_html,
576 + ).await {
577 + tracing::error!(error = ?e, "inbound-issues: failed to create comment");
578 + return StatusCode::OK;
579 + }
580 +
581 + // Store message ID mapping for threading
582 + if let Err(e) = db::issues::insert_issue_message_id(
583 + &state.db, &payload.message_id, issue.id,
584 + ).await {
585 + tracing::error!(error = ?e, "inbound-issues: failed to store reply message-id");
586 + }
587 +
588 + tracing::info!(
589 + issue_id = %issue.id,
590 + message_id = %payload.message_id,
591 + "inbound-issues: reply comment created"
592 + );
593 +
594 + // Notify all participants (minus the commenter)
595 + let db = state.db.clone();
596 + let email_client = state.email.clone();
597 + let host_url = state.config.host_url.clone();
598 + let signing_secret = state.config.signing_secret.clone();
599 + let commenter_id = sender.id;
600 + let commenter_username = sender.username.to_string();
601 + let preview: String = body_md.chars().take(200).collect();
602 + let issue_title = issue.title.clone();
603 + let issue_number = issue.number;
604 + let issue_id = issue.id;
605 + let repo_id = issue.repo_id;
606 +
607 + tokio::spawn(async move {
608 + // Look up repo to get owner name
609 + let repo = match db::git_repos::get_repo_by_id(&db, repo_id).await {
610 + Ok(Some(r)) => r,
611 + _ => return,
612 + };
613 + let owner_user = match db::users::get_user_by_id(&db, repo.user_id).await {
614 + Ok(Some(u)) => u,
615 + _ => return,
616 + };
617 + let owner_name = owner_user.username.to_string();
618 + let repo_name = repo.name.clone();
619 +
620 + let participants = match db::issues::get_issue_participants(&db, issue_id).await {
621 + Ok(p) => p,
622 + Err(e) => {
623 + tracing::error!(error = ?e, "failed to get issue participants for notification");
624 + return;
625 + }
626 + };
627 +
628 + let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number);
629 + let original_msg_id = format!("<issue-{}-{}@makenot.work>", issue_id, chrono::Utc::now().timestamp());
630 +
631 + for participant_id in participants {
632 + if participant_id == commenter_id {
633 + continue;
634 + }
635 + let user = match db::users::get_user_by_id(&db, participant_id).await {
636 + Ok(Some(u)) => u,
637 + _ => continue,
638 + };
639 + if !user.notify_issues {
640 + continue;
641 + }
642 + let unsub_url = crate::email::generate_unsubscribe_url(
643 + &host_url, participant_id, "issue", &participant_id.to_string(), &signing_secret,
644 + );
645 + let reply_to = crate::email::generate_issue_reply_address(issue_id, participant_id, &signing_secret);
646 +
647 + if let Err(e) = email_client
648 + .send_issue_comment_notification(
649 + &user.email,
650 + user.display_name.as_deref(),
651 + &owner_name,
652 + &repo_name,
653 + issue_number,
654 + &issue_title,
655 + &commenter_username,
656 + &preview,
657 + &issue_url,
658 + Some(&unsub_url),
659 + Some(&reply_to),
660 + Some(&original_msg_id),
661 + None,
662 + )
663 + .await
664 + {
665 + tracing::error!(error = ?e, recipient = %participant_id, "failed to send issue comment notification");
666 + }
667 + }
668 + });
669 +
670 + StatusCode::OK
671 + }
672 +
673 + /// Extract `(owner, repo)` from a To address like `{owner}+{repo}@issues.makenot.work`.
674 + fn extract_issue_address(to: &str) -> Option<(String, String)> {
675 + for addr in to.split(',') {
676 + let addr = addr.trim();
677 + let email = if let Some(start) = addr.find('<') {
678 + addr[start + 1..].trim_end_matches('>')
679 + } else {
680 + addr
681 + };
682 + let email = email.trim().to_lowercase();
683 + if let Some(local) = email.strip_suffix("@issues.makenot.work") {
684 + if let Some((owner, repo)) = local.split_once('+') {
685 + if !owner.is_empty() && !repo.is_empty() {
686 + return Some((owner.to_string(), repo.to_string()));
687 + }
688 + }
689 + }
690 + }
691 + None
692 + }
693 +
694 + /// Extract the local part of a `issue+...@reply.makenot.work` address.
695 + fn extract_reply_local(to: &str) -> Option<String> {
696 + for addr in to.split(',') {
697 + let addr = addr.trim();
698 + let email = if let Some(start) = addr.find('<') {
699 + addr[start + 1..].trim_end_matches('>')
700 + } else {
701 + addr
702 + };
703 + let email = email.trim().to_lowercase();
704 + if let Some(local) = email.strip_suffix("@reply.makenot.work") {
705 + if local.starts_with("issue+") {
706 + return Some(local.to_string());
707 + }
708 + }
709 + }
710 + None
711 + }
712 +
713 + /// Strip quoted text from email replies.
714 + ///
715 + /// Removes:
716 + /// - Lines starting with `>`
717 + /// - "On ... wrote:" preamble lines and everything after
718 + fn strip_quoted_text(text: &str) -> String {
719 + let mut result = Vec::new();
720 + for line in text.lines() {
721 + // Stop at "On ... wrote:" preamble
722 + let trimmed = line.trim();
723 + if trimmed.starts_with("On ") && trimmed.ends_with("wrote:") {
724 + break;
725 + }
726 + // Skip quoted lines
727 + if trimmed.starts_with('>') {
728 + continue;
729 + }
730 + result.push(line);
731 + }
732 + // Trim trailing empty lines
733 + while result.last().is_some_and(|l| l.trim().is_empty()) {
734 + result.pop();
735 + }
736 + result.join("\n")
737 + }
738 +
330 739 /// Register Postmark webhook routes.
331 740 pub fn postmark_routes() -> Router<AppState> {
332 741 Router::new()
333 742 .route("/postmark/webhook", post(postmark_webhook))
334 743 .route("/postmark/inbound", post(postmark_inbound))
744 + .route("/postmark/inbound-issues", post(postmark_inbound_issues))
335 745 }
336 746
337 747 #[cfg(test)]
@@ -379,4 +789,99 @@ mod tests {
379 789 Some("my-proj".to_string())
380 790 );
381 791 }
792 +
793 + // ── Issue address parsing ──
794 +
795 + #[test]
796 + fn extract_issue_addr_simple() {
797 + assert_eq!(
798 + extract_issue_address("alice+myrepo@issues.makenot.work"),
799 + Some(("alice".to_string(), "myrepo".to_string()))
800 + );
801 + }
802 +
803 + #[test]
804 + fn extract_issue_addr_with_display_name() {
805 + assert_eq!(
806 + extract_issue_address("Alice <alice+myrepo@issues.makenot.work>"),
807 + Some(("alice".to_string(), "myrepo".to_string()))
808 + );
809 + }
810 +
811 + #[test]
812 + fn extract_issue_addr_multiple_recipients() {
813 + assert_eq!(
814 + extract_issue_address("other@example.com, alice+myrepo@issues.makenot.work"),
815 + Some(("alice".to_string(), "myrepo".to_string()))
816 + );
817 + }
818 +
819 + #[test]
820 + fn extract_issue_addr_wrong_domain() {
821 + assert_eq!(extract_issue_address("alice+myrepo@example.com"), None);
822 + }
823 +
824 + #[test]
825 + fn extract_issue_addr_no_plus() {
826 + assert_eq!(extract_issue_address("alice@issues.makenot.work"), None);
827 + }
828 +
829 + #[test]
830 + fn extract_issue_addr_case_insensitive() {
831 + assert_eq!(
832 + extract_issue_address("Alice+MyRepo@Issues.Makenot.Work"),
833 + Some(("alice".to_string(), "myrepo".to_string()))
834 + );
835 + }
836 +
837 + #[test]
838 + fn extract_issue_addr_empty_parts() {
839 + assert_eq!(extract_issue_address("+repo@issues.makenot.work"), None);
840 + assert_eq!(extract_issue_address("owner+@issues.makenot.work"), None);
841 + }
842 +
843 + // ── Reply local parsing ──
844 +
845 + #[test]
846 + fn extract_reply_simple() {
847 + assert_eq!(
848 + extract_reply_local("issue+abc.def.1234@reply.makenot.work"),
849 + Some("issue+abc.def.1234".to_string())
850 + );
851 + }
852 +
853 + #[test]
854 + fn extract_reply_not_issue_prefix() {
855 + assert_eq!(extract_reply_local("other+abc@reply.makenot.work"), None);
856 + }
857 +
858 + #[test]
859 + fn extract_reply_wrong_domain() {
860 + assert_eq!(extract_reply_local("issue+abc@example.com"), None);
861 + }
862 +
Lines truncated
@@ -172,9 +172,6 @@ impl_into_response!(
172 172 // Git issues
173 173 GitIssueListTemplate,
174 174 GitIssueDetailTemplate,
175 - GitIssueNewTemplate,
176 - GitIssueEditTemplate,
177 - GitIssueLabelManagerTemplate,
178 175 GitRepoSettingsTemplate,
179 176 // Join wizard
180 177 WizardJoinTemplate,
@@ -865,7 +865,7 @@ pub struct GitFileLogTemplate {
865 865 // Git Issues
866 866 // ============================================================================
867 867
868 - /// Issue list page with status tabs and search.
868 + /// Issue list page with status tabs and search (read-only).
869 869 #[derive(Template)]
870 870 #[template(path = "pages/git/issues.html")]
871 871 pub struct GitIssueListTemplate {
@@ -881,12 +881,13 @@ pub struct GitIssueListTemplate {
881 881 pub search_query: String,
882 882 pub current_page: i64,
883 883 pub total_pages: i64,
884 - pub labels: Vec<crate::db::DbIssueLabel>,
885 884 /// Whether the current viewer is the repo owner (for settings link).
886 885 pub is_owner: bool,
886 + /// Email address for submitting new issues.
887 + pub email_address: String,
887 888 }
888 889
889 - /// Issue detail page with comments and status actions.
890 + /// Issue detail page with comments (read-only).
890 891 #[derive(Template)]
891 892 #[template(path = "pages/git/issue.html")]
892 893 pub struct GitIssueDetailTemplate {
@@ -898,59 +899,10 @@ pub struct GitIssueDetailTemplate {
898 899 pub issue: crate::db::DbIssue,
899 900 pub author_username: String,
900 901 pub comments: Vec<crate::db::DbIssueCommentWithAuthor>,
901 - pub labels: Vec<crate::db::DbIssueLabel>,
902 - pub all_labels: Vec<crate::db::DbIssueLabel>,
903 - pub assigned_label_ids: Vec<crate::db::IssueLabelId>,
904 - pub can_edit: bool,
905 902 pub is_owner: bool,
906 903 pub open_issue_count: i64,
907 - }
908 -
909 - /// New issue form.
910 - #[derive(Template)]
911 - #[template(path = "pages/git/issue_new.html")]
912 - pub struct GitIssueNewTemplate {
913 - pub csrf_token: CsrfTokenOption,
914 - pub session_user: Option<SessionUser>,
915 - pub owner: String,
916 - pub repo_name: String,
917 - pub current_ref: String,
918 - pub labels: Vec<crate::db::DbIssueLabel>,
919 - pub open_issue_count: i64,
920 - /// Whether the current viewer is the repo owner (for settings link).
921 - pub is_owner: bool,
922 - }
923 -
924 - /// Edit issue form.
925 - #[derive(Template)]
926 - #[template(path = "pages/git/issue_edit.html")]
927 - pub struct GitIssueEditTemplate {
928 - pub csrf_token: CsrfTokenOption,
929 - pub session_user: Option<SessionUser>,
930 - pub owner: String,
931 - pub repo_name: String,
932 - pub current_ref: String,
933 - pub issue: crate::db::DbIssue,
934 - pub labels: Vec<crate::db::DbIssueLabel>,
935 - pub assigned_label_ids: Vec<crate::db::IssueLabelId>,
936 - pub open_issue_count: i64,
937 - /// Whether the current viewer is the repo owner (for settings link).
938 - pub is_owner: bool,
939 - }
940 -
941 - /// Label management page (owner only).
942 - #[derive(Template)]
943 - #[template(path = "pages/git/labels.html")]
944 - pub struct GitIssueLabelManagerTemplate {
945 - pub csrf_token: CsrfTokenOption,
946 - pub session_user: Option<SessionUser>,
947 - pub owner: String,
948 - pub repo_name: String,
949 - pub current_ref: String,
950 - pub labels: Vec<crate::db::DbIssueLabel>,
951 - pub open_issue_count: i64,
952 - /// Whether the current viewer is the repo owner (for settings link).
953 - pub is_owner: bool,
904 + /// Email address for submitting new issues.
905 + pub email_address: String,
954 906 }
955 907
956 908 /// Repository settings page (owner only).
@@ -32,59 +32,6 @@
32 32 </div>
33 33 </div>
34 34
35 - {% if can_edit %}
36 - <div class="issue-detail-actions">
37 - <a href="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/edit" class="btn btn-small">Edit</a>
38 - {% match issue.status %}
39 - {% when crate::db::IssueStatus::Open %}
40 - <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/close" style="display:inline">
41 - {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
42 - <button type="submit" class="btn btn-small btn-secondary">Close issue</button>
43 - </form>
44 - {% when crate::db::IssueStatus::Closed %}
45 - <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/reopen" style="display:inline">
46 - {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
47 - <button type="submit" class="btn btn-small btn-primary">Reopen issue</button>
48 - </form>
49 - {% endmatch %}
50 - </div>
51 - {% endif %}
52 -
53 - {% if !labels.is_empty() %}
54 - <div class="issue-labels">
55 - {% for label in labels %}
56 - <span class="issue-label" style="background-color: {{ label.color }}">{{ label.name }}</span>
57 - {% endfor %}
58 - </div>
59 - {% endif %}
60 -
61 - {% if is_owner && !all_labels.is_empty() %}
62 - <div class="issue-label-manage">
63 - <details>
64 - <summary>Manage labels</summary>
65 - <div class="issue-label-forms">
66 - {% for label in all_labels %}
67 - <div class="issue-label-toggle">
68 - {% if assigned_label_ids.contains(&label.id) %}
69 - <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/labels/remove" style="display:inline">
70 - {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
71 - <input type="hidden" name="label_id" value="{{ label.id }}">
72 - <button type="submit" class="issue-label active" style="background-color: {{ label.color }}">{{ label.name }} &times;</button>
73 - </form>
74 - {% else %}
75 - <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/labels/add" style="display:inline">
76 - {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
77 - <input type="hidden" name="label_id" value="{{ label.id }}">
78 - <button type="submit" class="issue-label inactive">{{ label.name }} +</button>
79 - </form>
80 - {% endif %}
81 - </div>
82 - {% endfor %}
83 - </div>
84 - </details>
85 - </div>
86 - {% endif %}
87 -
88 35 {% if !issue.body_html.is_empty() %}
89 36 <div class="issue-body markdown-body">
90 37 {{ issue.body_html|safe }}
@@ -98,14 +45,6 @@
98 45 <div class="issue-comment">
99 46 <div class="issue-comment-header">
100 47 <strong>{{ comment.author_username }}</strong>
101 - {% if let Some(user) = &session_user %}
102 - {% if user.id == comment.author_user_id || is_owner %}
103 - <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/comment/{{ comment.id }}/delete" style="display:inline">
104 - {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
105 - <button type="submit" class="btn-link btn-danger-link" onclick="return confirm('Delete this comment?')">delete</button>
106 - </form>
107 - {% endif %}
108 - {% endif %}
109 48 </div>
110 49 <div class="issue-comment-body markdown-body">
111 50 {{ comment.body_html|safe }}
@@ -115,16 +54,7 @@
115 54 </div>
116 55 {% endif %}
117 56
118 - {% if session_user.is_some() %}
119 - <div class="issue-comment-form">
120 - <h3>Add a comment</h3>
121 - <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/comment">
122 - {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
123 - <textarea name="body" rows="6" placeholder="Leave a comment (Markdown supported)..." required></textarea>
124 - <button type="submit" class="btn btn-primary">Comment</button>
125 - </form>
126 - </div>
127 - {% endif %}
57 + <p class="issue-email-hint">To comment, reply to the notification email. To report a new issue, email <code>{{ email_address }}</code>.</p>
128 58 </div>
129 59
130 60 <footer class="text-reader-footer">
@@ -1,72 +0,0 @@
1 - {% extends "base.html" %}
2 -
3 - {% block title %}Edit #{{ issue.number }} - {{ repo_name }} - Makenot.work{% endblock %}
4 - {% block body_attrs %} class="padded-page"{% endblock %}
5 -
6 - {% block content %}
7 - {% include "partials/site_header.html" %}
8 -
9 - <h1 class="git-repo-name">
10 - <a href="/git/{{ owner }}">{{ owner }}</a>
11 - <span class="sep">/</span>
12 - <a href="/git/{{ owner }}/{{ repo_name }}">{{ repo_name }}</a>
13 - </h1>
14 -
15 - <div class="git-ref-bar">
16 - <nav class="git-nav-links">
17 - <a href="/git/{{ owner }}/{{ repo_name }}/tree/{{ current_ref }}">Files</a>
18 - <a href="/git/{{ owner }}/{{ repo_name }}/commits/{{ current_ref }}">Commits</a>
19 - <a href="/git/{{ owner }}/{{ repo_name }}/issues" class="active">Issues ({{ open_issue_count }})</a>
20 - {% if is_owner %}
21 - <a href="/git/{{ owner }}/{{ repo_name }}/settings">Settings</a>
22 - {% endif %}
23 - </nav>
24 - </div>
25 -
26 - <div class="issue-form-container">
27 - <h2>Edit Issue #{{ issue.number }}</h2>
28 - <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/edit">
29 - {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
30 - <div class="form-group">
31 - <label for="title">Title</label>
32 - <input type="text" id="title" name="title" required maxlength="200" value="{{ issue.title }}" class="form-input">
33 - </div>
34 - <div class="form-group">
35 - <label for="body">Description (Markdown)</label>
36 - <textarea id="body" name="body" rows="10" class="form-textarea">{{ issue.body_markdown }}</textarea>
37 - </div>
38 - {% if !labels.is_empty() %}
39 - <div class="form-group">
40 - <label>Labels</label>
41 - <div class="label-checkboxes">
42 - {% for label in labels %}
43 - <label class="label-checkbox">
44 - <input type="checkbox" name="label_{{ label.id }}" value="{{ label.id }}"
45 - {% if assigned_label_ids.contains(&label.id) %}checked{% endif %}>
46 - <span class="issue-label" style="background-color: {{ label.color }}">{{ label.name }}</span>
47 - </label>
48 - {% endfor %}
49 - </div>
50 - </div>
51 - <input type="hidden" id="labels-hidden" name="labels" value="">
52 - <script>
53 - document.querySelector('.issue-form-container form').addEventListener('submit', function() {
54 - var checked = [];
55 - document.querySelectorAll('.label-checkboxes input:checked').forEach(function(cb) {
56 - checked.push(cb.value);
57 - });
58 - document.getElementById('labels-hidden').value = checked.join(',');
59 - });
60 - </script>
61 - {% endif %}
62 - <div class="form-actions">
63 - <button type="submit" class="btn btn-primary">Save changes</button>
64 - <a href="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}" class="btn btn-secondary">Cancel</a>
65 - </div>
66 - </form>
67 - </div>
68 -
69 - <footer class="text-reader-footer">
70 - <a href="/">Makenot<span class="dot">.</span>work</a>
71 - </footer>
72 - {% endblock %}
@@ -1,68 +0,0 @@
1 - {% extends "base.html" %}
2 -
3 - {% block title %}New Issue - {{ repo_name }} - Makenot.work{% endblock %}
4 - {% block body_attrs %} class="padded-page"{% endblock %}
5 -
6 - {% block content %}
7 - {% include "partials/site_header.html" %}
8 -
9 - <h1 class="git-repo-name">
10 - <a href="/git/{{ owner }}">{{ owner }}</a>
11 - <span class="sep">/</span>
12 - <a href="/git/{{ owner }}/{{ repo_name }}">{{ repo_name }}</a>
13 - </h1>
14 -
15 - <div class="git-ref-bar">
16 - <nav class="git-nav-links">
17 - <a href="/git/{{ owner }}/{{ repo_name }}/tree/{{ current_ref }}">Files</a>
18 - <a href="/git/{{ owner }}/{{ repo_name }}/commits/{{ current_ref }}">Commits</a>
19 - <a href="/git/{{ owner }}/{{ repo_name }}/issues" class="active">Issues ({{ open_issue_count }})</a>
20 - {% if is_owner %}
21 - <a href="/git/{{ owner }}/{{ repo_name }}/settings">Settings</a>
22 - {% endif %}
23 - </nav>
24 - </div>
25 -
26 - <div class="issue-form-container">
27 - <h2>New Issue</h2>
28 - <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues">
29 - {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
30 - <div class="form-group">
31 - <label for="title">Title</label>
32 - <input type="text" id="title" name="title" required maxlength="200" placeholder="Issue title" class="form-input">
33 - </div>
34 - <div class="form-group">
35 - <label for="body">Description (Markdown)</label>
36 - <textarea id="body" name="body" rows="10" placeholder="Describe the issue..." class="form-textarea"></textarea>
37 - </div>
38 - {% if !labels.is_empty() %}
39 - <div class="form-group">
40 - <label>Labels</label>
41 - <div class="label-checkboxes">
42 - {% for label in labels %}
43 - <label class="label-checkbox">
44 - <input type="checkbox" name="label_{{ label.id }}" value="{{ label.id }}">
45 - <span class="issue-label" style="background-color: {{ label.color }}">{{ label.name }}</span>
46 - </label>
47 - {% endfor %}
48 - </div>
49 - </div>
50 - <input type="hidden" id="labels-hidden" name="labels" value="">
51 - <script>
52 - document.querySelector('.issue-form-container form').addEventListener('submit', function() {
53 - var checked = [];
54 - document.querySelectorAll('.label-checkboxes input:checked').forEach(function(cb) {
55 - checked.push(cb.value);
56 - });
57 - document.getElementById('labels-hidden').value = checked.join(',');
58 - });
59 - </script>
60 - {% endif %}
61 - <button type="submit" class="btn btn-primary">Submit new issue</button>
62 - </form>
63 - </div>
64 -
65 - <footer class="text-reader-footer">
66 - <a href="/">Makenot<span class="dot">.</span>work</a>
67 - </footer>
68 - {% endblock %}