Skip to main content

max / makenotwork

Bridge git issues into Multithreaded forum threads When an issue is opened via the inbound Postmark webhook, spawn a thread in the project's "issues" category on Multithreaded and stash its UUID on the issue row. Replies via email route into the same thread as forum posts. Best-effort: if MT is unreachable or the repo has no project, the issue itself is still created — the bridge fails open. Migration 115 adds the nullable mt_thread_id column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-15 16:20 UTC
Commit: 3259753b0ed7aa245c7017287fdfdecf281e7635
Parent: 55e72a7
5 files changed, +398 insertions, -1 deletion
@@ -0,0 +1,11 @@
1 + -- Link issues to their Multithreaded forum thread.
2 + --
3 + -- When an issue is created via Postmark inbound, we spawn an MT thread in
4 + -- the project's "issues" category and stash its ID here. Replies via email
5 + -- can then look up the MT thread directly without an extra MNW→MT round trip.
6 + --
7 + -- Nullable: not every issue has a corresponding MT thread (e.g., orphan
8 + -- repos with no `project_id`, or MT client not configured at issue-creation
9 + -- time). The bridge fails open — missing thread doesn't block the issue.
10 +
11 + ALTER TABLE issues ADD COLUMN mt_thread_id UUID;
@@ -280,6 +280,37 @@ pub async fn get_issue_participants(pool: &PgPool, issue_id: IssueId) -> Result<
280 280 Ok(ids)
281 281 }
282 282
283 + /// Record the Multithreaded forum thread that mirrors this issue. Best-effort:
284 + /// log and proceed on DB error so the issue itself isn't lost.
285 + #[tracing::instrument(skip_all)]
286 + pub async fn set_mt_thread_id(
287 + pool: &PgPool,
288 + issue_id: IssueId,
289 + mt_thread_id: uuid::Uuid,
290 + ) -> Result<()> {
291 + sqlx::query("UPDATE issues SET mt_thread_id = $2 WHERE id = $1")
292 + .bind(issue_id)
293 + .bind(mt_thread_id)
294 + .execute(pool)
295 + .await?;
296 + Ok(())
297 + }
298 +
299 + /// Fetch the MT thread linked to an issue, if any.
300 + #[tracing::instrument(skip_all)]
301 + pub async fn get_mt_thread_id(
302 + pool: &PgPool,
303 + issue_id: IssueId,
304 + ) -> Result<Option<uuid::Uuid>> {
305 + let row: Option<(Option<uuid::Uuid>,)> = sqlx::query_as(
306 + "SELECT mt_thread_id FROM issues WHERE id = $1",
307 + )
308 + .bind(issue_id)
309 + .fetch_optional(pool)
310 + .await?;
311 + Ok(row.and_then(|r| r.0))
312 + }
313 +
283 314 // ── Issue message ID mapping (for email threading) ──
284 315
285 316 /// Store a mapping from an email Message-ID to an issue.
@@ -79,6 +79,9 @@ pub struct DbIssue {
79 79 pub status: super::super::IssueStatus,
80 80 pub created_at: DateTime<Utc>,
81 81 pub updated_at: DateTime<Utc>,
82 + /// Multithreaded forum thread mirroring this issue. Populated by the
83 + /// inbound issues handler when MT is configured; `None` otherwise.
84 + pub mt_thread_id: Option<uuid::Uuid>,
82 85 }
83 86
84 87 /// An issue with joined metadata for list display.
@@ -7,7 +7,8 @@ use axum::{
7 7 Json,
8 8 };
9 9
10 - use crate::{db, AppState};
10 + use crate::{db, mt_client, AppState};
11 + use crate::db::{DbGitRepo, DbIssue};
11 12
12 13 use super::{verify_token, PostmarkInboundPayload};
13 14
@@ -147,6 +148,12 @@ async fn handle_new_issue(
147 148 tracing::error!(error = ?e, "inbound-issues: failed to store message-id mapping");
148 149 }
149 150
151 + // Bridge to Multithreaded: open a forum thread in the project's "issues"
152 + // category so discussion happens on the forum rather than in long email
153 + // chains. Best-effort — if MT is unreachable or the repo has no project,
154 + // the issue itself is still created.
155 + bridge_new_issue_to_mt(state, &repo, &issue, sender.id, &sender.username, sender.display_name.as_deref()).await;
156 +
150 157 tracing::info!(
151 158 issue_number = issue.number,
152 159 message_id = %payload.message_id,
@@ -288,6 +295,9 @@ async fn handle_issue_reply(
288 295 tracing::error!(error = ?e, "inbound-issues: failed to store reply message-id");
289 296 }
290 297
298 + // Bridge reply into the issue's MT thread (if one exists).
299 + bridge_issue_reply_to_mt(state, &issue, sender.id, &sender.username, sender.display_name.as_deref(), body_md).await;
300 +
291 301 tracing::info!(
292 302 issue_id = %issue.id,
293 303 message_id = %payload.message_id,
@@ -373,6 +383,152 @@ async fn handle_issue_reply(
373 383 StatusCode::OK
374 384 }
375 385
386 + // ============================================================================
387 + // Multithreaded bridge — issues mirror into a forum thread
388 + // ============================================================================
389 + //
390 + // Email lists carry signal (one-line "new issue from X" notifications eventually
391 + // — not yet wired); discussion lives on the forum. The bridge spawns a thread
392 + // in the project's "issues" category at issue-creation time and routes reply
393 + // emails into that thread as posts. See `docs/architecture.md` for the
394 + // philosophy.
395 +
396 + async fn bridge_new_issue_to_mt(
397 + state: &AppState,
398 + repo: &DbGitRepo,
399 + issue: &DbIssue,
400 + sender_id: crate::db::UserId,
401 + sender_username: &str,
402 + sender_display_name: Option<&str>,
403 + ) {
404 + let mt = match &state.mt_client {
405 + Some(c) => c,
406 + None => return,
407 + };
408 +
409 + let Some(project_id) = repo.project_id else {
410 + tracing::debug!(issue_id = %issue.id, "inbound-issues: repo has no project, skipping MT bridge");
411 + return;
412 + };
413 +
414 + let project = match db::projects::get_project_by_id(&state.db, project_id).await {
415 + Ok(Some(p)) => p,
416 + _ => {
417 + tracing::warn!(project_id = %project_id, "inbound-issues: project lookup failed for MT bridge");
418 + return;
419 + }
420 + };
421 +
422 + let title = format!("#{} {}", issue.number, issue.title);
423 + let body_markdown = format!(
424 + "**Issue [#{n}]({host}/git/{repo_owner}/{repo}/issues/{n})** opened by **{user}**.\n\n{body}",
425 + n = issue.number,
426 + host = state.config.host_url,
427 + repo_owner = sender_username, // placeholder — refined below
428 + repo = repo.name,
429 + user = sender_display_name.unwrap_or(sender_username),
430 + body = issue.body_markdown,
431 + );
432 +
433 + // The git issue URL needs the *repo owner's* username, which we can derive
434 + // from the repo row's user_id — fetch it (cheap, one row).
435 + let repo_owner_username = match db::users::get_user_by_id(&state.db, repo.user_id).await {
436 + Ok(Some(u)) => u.username.to_string(),
437 + _ => sender_username.to_string(),
438 + };
439 + let body_markdown = body_markdown.replace(
440 + &format!("/git/{}/", sender_username),
441 + &format!("/git/{}/", repo_owner_username),
442 + );
443 +
444 + let req = mt_client::CreateThreadRequest {
445 + community_slug: project.slug.to_string(),
446 + category_slug: "issues".to_string(),
447 + title,
448 + body_markdown,
449 + author_mnw_id: *sender_id,
450 + author_username: sender_username.to_string(),
451 + author_display_name: sender_display_name.map(String::from),
452 + external_ref: format!("mnw:issue:{}", issue.id),
453 + };
454 +
455 + match mt.create_thread(&req).await {
456 + Ok(resp) => {
457 + if let Err(e) = db::issues::set_mt_thread_id(&state.db, issue.id, *resp.thread_id).await {
458 + tracing::warn!(error = ?e, "inbound-issues: failed to store mt_thread_id");
459 + }
460 + }
461 + Err(e) => {
462 + tracing::warn!(error = ?e, issue_id = %issue.id, "inbound-issues: MT thread creation failed");
463 + }
464 + }
465 + }
466 +
467 + async fn bridge_issue_reply_to_mt(
468 + state: &AppState,
469 + issue: &DbIssue,
470 + sender_id: crate::db::UserId,
471 + sender_username: &str,
472 + sender_display_name: Option<&str>,
473 + body_markdown: &str,
474 + ) {
475 + let mt = match &state.mt_client {
476 + Some(c) => c,
477 + None => return,
478 + };
479 +
480 + // Prefer the cached thread ID; fall back to look-up by external_ref via an
481 + // idempotent create_thread call (no-op if the thread already exists).
482 + let thread_id = match issue.mt_thread_id {
483 + Some(id) => id,
484 + None => match resolve_or_create_issue_thread(state, mt, issue, sender_id, sender_username, sender_display_name).await {
485 + Some(id) => id,
486 + None => return,
487 + },
488 + };
489 +
490 + let req = mt_client::CreatePostRequest {
491 + body_markdown: body_markdown.to_string(),
492 + author_mnw_id: *sender_id,
493 + author_username: sender_username.to_string(),
494 + author_display_name: sender_display_name.map(String::from),
495 + };
496 + if let Err(e) = mt.create_post(crate::db::MtThreadId::from(thread_id), &req).await {
497 + tracing::warn!(error = ?e, issue_id = %issue.id, "inbound-issues: MT reply post failed");
498 + }
499 + }
500 +
501 + /// Fallback when an issue predates the MT bridge or the initial create_thread
502 + /// failed: re-issues `create_thread` (idempotent via `external_ref`) to obtain
503 + /// the canonical thread ID, then caches it.
504 + async fn resolve_or_create_issue_thread(
505 + state: &AppState,
506 + mt: &mt_client::MtClient,
507 + issue: &DbIssue,
508 + sender_id: crate::db::UserId,
509 + sender_username: &str,
510 + sender_display_name: Option<&str>,
511 + ) -> Option<uuid::Uuid> {
512 + let repo = db::git_repos::get_repo_by_id(&state.db, issue.repo_id).await.ok().flatten()?;
513 + let project_id = repo.project_id?;
514 + let project = db::projects::get_project_by_id(&state.db, project_id).await.ok().flatten()?;
515 +
516 + let req = mt_client::CreateThreadRequest {
517 + community_slug: project.slug.to_string(),
518 + category_slug: "issues".to_string(),
519 + title: format!("#{} {}", issue.number, issue.title),
520 + body_markdown: issue.body_markdown.clone(),
521 + author_mnw_id: *sender_id,
522 + author_username: sender_username.to_string(),
523 + author_display_name: sender_display_name.map(String::from),
524 + external_ref: format!("mnw:issue:{}", issue.id),
525 + };
526 + let resp = mt.create_thread(&req).await.ok()?;
527 + let thread_id: uuid::Uuid = *resp.thread_id;
528 + let _ = db::issues::set_mt_thread_id(&state.db, issue.id, thread_id).await;
529 + Some(thread_id)
530 + }
531 +
376 532 /// Extract `(owner, repo)` from a To address like `{owner}+{repo}@issues.makenot.work`.
377 533 fn extract_issue_address(to: &str) -> Option<(String, String)> {
378 534 for addr in to.split(',') {
@@ -5,6 +5,8 @@
5 5 //! write routes return 404/405.
6 6
7 7 use crate::harness::{BuildOptions, TestHarness};
8 + use wiremock::matchers::{method, path};
9 + use wiremock::{Mock, MockServer, ResponseTemplate};
8 10
9 11 /// Compute the per-repo HMAC that the push endpoint expects.
10 12 fn push_token(owner: &str, repo: &str) -> String {
@@ -710,3 +712,197 @@ async fn repo_page_shows_issues_nav_link() {
710 712 assert!(resp.status.is_success());
711 713 assert!(resp.text.contains("/issues"), "Repo page should have issues link");
712 714 }
715 +
716 + // ══════════════════════════════════════════════════════════════════════
717 + // Multithreaded bridge — issues mirror into a forum thread
718 + // ══════════════════════════════════════════════════════════════════════
719 +
720 + async fn setup_with_inbound_and_mt(
721 + tmp: &tempfile::TempDir,
722 + mt_url: String,
723 + ) -> TestHarness {
724 + let mut h = TestHarness::build(BuildOptions {
725 + git_repos_path: Some(tmp.path().to_str().unwrap().to_string()),
726 + postmark_inbound_webhook_token: Some("test-inbound-secret".to_string()),
727 + build_trigger_token: Some("test-trigger-secret".to_string()),
728 + mt_base_url: Some(mt_url),
729 + internal_shared_secret: Some("test-mt-secret".to_string()),
730 + ..Default::default()
731 + })
732 + .await;
733 + let user_id = h.signup("testowner", "testowner@example.com", "password123").await;
734 + sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
735 + .bind(user_id)
736 + .execute(&h.db)
737 + .await
738 + .unwrap();
739 + let resp = h.client.get("/git/testowner/testrepo").await;
740 + assert!(resp.status.is_success(), "Repo setup failed: {}", resp.text);
741 + h
742 + }
743 +
744 + /// Link the test repo to a project so the bridge has a community slug.
745 + async fn attach_repo_to_project(h: &TestHarness, project_slug: &str) -> uuid::Uuid {
746 + let user_id: uuid::Uuid = sqlx::query_scalar(
747 + "SELECT id FROM users WHERE username = 'testowner'",
748 + )
749 + .fetch_one(&h.db)
750 + .await
751 + .unwrap();
752 + let project_id: uuid::Uuid = sqlx::query_scalar(
753 + "INSERT INTO projects (user_id, title, slug)
754 + VALUES ($1, 'Test Project', $2) RETURNING id",
755 + )
756 + .bind(user_id)
757 + .bind(project_slug)
758 + .fetch_one(&h.db)
759 + .await
760 + .unwrap();
761 + sqlx::query("UPDATE git_repos SET project_id = $1 WHERE name = 'testrepo'")
762 + .bind(project_id)
763 + .execute(&h.db)
764 + .await
765 + .unwrap();
766 + project_id
767 + }
768 +
769 + #[tokio::test]
770 + async fn inbound_new_issue_bridges_to_mt() {
771 + let tmp = tempfile::TempDir::new().unwrap();
772 + make_test_repo(tmp.path());
773 + let mock = MockServer::start().await;
774 + let mt_thread_id = uuid::Uuid::new_v4();
775 + Mock::given(method("POST"))
776 + .and(path("/internal/threads"))
777 + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
778 + "thread_id": mt_thread_id,
779 + "post_id": uuid::Uuid::new_v4(),
780 + "created": true,
781 + })))
782 + .expect(1)
783 + .mount(&mock)
784 + .await;
785 +
786 + let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await;
787 + attach_repo_to_project(&h, "test-project").await;
788 +
789 + h.client.set_bearer_token("test-inbound-secret");
790 + let payload = inbound_payload(
791 + "testowner+testrepo@issues.makenot.work",
792 + "testowner@example.com",
793 + "Bridge me",
794 + "Body text",
795 + );
796 + let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
797 + assert_eq!(resp.status, 200, "inbound: {}", resp.text);
798 +
799 + let stored: Option<uuid::Uuid> =
800 + sqlx::query_scalar("SELECT mt_thread_id FROM issues LIMIT 1")
801 + .fetch_one(&h.db)
802 + .await
803 + .unwrap();
804 + assert_eq!(stored, Some(mt_thread_id), "mt_thread_id should be cached on the issue");
805 + }
806 +
807 + #[tokio::test]
808 + async fn inbound_new_issue_without_project_skips_bridge() {
809 + let tmp = tempfile::TempDir::new().unwrap();
810 + make_test_repo(tmp.path());
811 + let mock = MockServer::start().await;
812 + // No mount — any call would fail with 404 from wiremock. We assert that
813 + // the issue creation still succeeds and no MT call is made.
814 + let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await;
815 + // Deliberately do NOT call attach_repo_to_project — repo has no project_id.
816 +
817 + h.client.set_bearer_token("test-inbound-secret");
818 + let payload = inbound_payload(
819 + "testowner+testrepo@issues.makenot.work",
820 + "testowner@example.com",
821 + "Orphan issue",
822 + "Body",
823 + );
824 + let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
825 + assert_eq!(resp.status, 200);
826 +
827 + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues")
828 + .fetch_one(&h.db)
829 + .await
830 + .unwrap();
831 + assert_eq!(count, 1, "issue should still be created without a project");
832 +
833 + let stored: Option<uuid::Uuid> =
834 + sqlx::query_scalar("SELECT mt_thread_id FROM issues LIMIT 1")
835 + .fetch_one(&h.db)
836 + .await
837 + .unwrap();
838 + assert!(stored.is_none(), "mt_thread_id should remain unset");
839 + }
840 +
841 + #[tokio::test]
842 + async fn inbound_issue_reply_bridges_to_mt_thread() {
843 + let tmp = tempfile::TempDir::new().unwrap();
844 + make_test_repo(tmp.path());
845 + let mock = MockServer::start().await;
846 + let mt_thread_id = uuid::Uuid::new_v4();
847 + // Initial issue creation
848 + Mock::given(method("POST"))
849 + .and(path("/internal/threads"))
850 + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
851 + "thread_id": mt_thread_id,
852 + "post_id": uuid::Uuid::new_v4(),
853 + "created": true,
854 + })))
855 + .mount(&mock)
856 + .await;
857 + // Reply post
858 + Mock::given(method("POST"))
859 + .and(path(format!("/internal/threads/{}/posts", mt_thread_id)))
860 + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
861 + "post_id": uuid::Uuid::new_v4(),
862 + })))
863 + .expect(1)
864 + .mount(&mock)
865 + .await;
866 +
867 + let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await;
868 + attach_repo_to_project(&h, "test-project").await;
869 +
870 + h.client.set_bearer_token("test-inbound-secret");
871 + // Open the issue
872 + let payload = inbound_payload(
873 + "testowner+testrepo@issues.makenot.work",
874 + "testowner@example.com",
875 + "Reply-bridged",
876 + "Initial body",
877 + );
878 + let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
879 + assert_eq!(resp.status, 200);
880 +
881 + // Generate reply address for the new issue
882 + let issue_id: uuid::Uuid =
883 + sqlx::query_scalar("SELECT id FROM issues LIMIT 1")
884 + .fetch_one(&h.db)
885 + .await
886 + .unwrap();
887 + let user_id: uuid::Uuid =
888 + sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
889 + .fetch_one(&h.db)
890 + .await
891 + .unwrap();
892 + let reply_addr = makenotwork::email::generate_issue_reply_address(
893 + makenotwork::db::IssueId::from_uuid(issue_id),
894 + makenotwork::db::UserId::from_uuid(user_id),
895 + "test-signing-secret-for-integration-tests",
896 + );
897 +
898 + // Send the reply
899 + let payload = inbound_payload(
900 + &reply_addr,
901 + "testowner@example.com",
902 + "Re: Reply-bridged",
903 + "Following up on this",
904 + );
905 + let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
906 + assert_eq!(resp.status, 200);
907 + }
908 +