Skip to main content

max / makenotwork

Decompose AppState Phase 2: finish git_issues migration Complete the git_issues module (call sites were adapted in the git commit). Every handler is db + config: issue_list, issue_detail, the three repo settings handlers, and process_push all extract State<PgPool> + State<Config>. The default_ref helper takes &Config (repos_root only). No behavior change; 30 git_issues integration tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 20:22 UTC
Commit: e36e40c7e24629410a92daae40f7318cf7fcd2a1
Parent: 2374605
4 files changed, +52 insertions, -39 deletions
@@ -7,13 +7,15 @@ use axum::{
7 7 use serde::Deserialize;
8 8 use tower_sessions::Session;
9 9
10 + use sqlx::PgPool;
11 +
10 12 use crate::{
11 13 auth::MaybeUserVerified,
14 + config::Config,
12 15 db::{self, IssueStatus},
13 16 error::{AppError, Result},
14 17 helpers::get_csrf_token,
15 18 templates::*,
16 - AppState,
17 19 };
18 20
19 21 use super::default_ref;
@@ -30,13 +32,14 @@ pub(super) struct IssueListQuery {
30 32 /// `GET /git/{owner}/{repo}/issues`
31 33 #[tracing::instrument(skip_all, name = "git_issues::list")]
32 34 pub(super) async fn issue_list(
33 - State(state): State<AppState>,
35 + State(db): State<PgPool>,
36 + State(config): State<Config>,
34 37 session: Session,
35 38 MaybeUserVerified(maybe_user): MaybeUserVerified,
36 39 Path((owner, repo_name)): Path<(String, String)>,
37 40 Query(query): Query<IssueListQuery>,
38 41 ) -> Result<impl IntoResponse> {
39 - let resolved = super::resolve_repo(&state.db, &state.config, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
42 + let resolved = super::resolve_repo(&db, &config, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
40 43
41 44 let status_filter = match query.status.as_deref() {
42 45 Some("closed") => Some(IssueStatus::Closed),
@@ -51,12 +54,12 @@ pub(super) async fn issue_list(
51 54 let per_page = 25;
52 55
53 56 let (issues, total) = db::issues::list_issues(
54 - &state.db, resolved.db_repo.id, status_filter, search, page, per_page,
57 + &db, resolved.db_repo.id, status_filter, search, page, per_page,
55 58 ).await?;
56 59
57 60 let total_pages = (total + per_page - 1) / per_page;
58 - let (open_count, closed_count) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await?;
59 - let current_ref = default_ref(&state, &owner, &repo_name).await;
61 + let (open_count, closed_count) = db::issues::get_issue_counts(&db, resolved.db_repo.id).await?;
62 + let current_ref = default_ref(&config, &owner, &repo_name).await;
60 63 let csrf_token = get_csrf_token(&session).await;
61 64
62 65 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
@@ -85,27 +88,28 @@ pub(super) async fn issue_list(
85 88 /// `GET /git/{owner}/{repo}/issues/{number}`
86 89 #[tracing::instrument(skip_all, name = "git_issues::detail")]
87 90 pub(super) async fn issue_detail(
88 - State(state): State<AppState>,
91 + State(db): State<PgPool>,
92 + State(config): State<Config>,
89 93 session: Session,
90 94 MaybeUserVerified(maybe_user): MaybeUserVerified,
91 95 Path((owner, repo_name, number)): Path<(String, String, i32)>,
92 96 ) -> Result<impl IntoResponse> {
93 - let resolved = super::resolve_repo(&state.db, &state.config, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
97 + let resolved = super::resolve_repo(&db, &config, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
94 98
95 - let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number)
99 + let issue = db::issues::get_issue_by_number(&db, resolved.db_repo.id, number)
96 100 .await?
97 101 .ok_or(AppError::NotFound)?;
98 102
99 - let author = db::users::get_user_by_id(&state.db, issue.author_user_id)
103 + let author = db::users::get_user_by_id(&db, issue.author_user_id)
100 104 .await?
101 105 .ok_or(AppError::NotFound)?;
102 106
103 - let comments = db::issues::list_comments(&state.db, issue.id).await?;
107 + let comments = db::issues::list_comments(&db, issue.id).await?;
104 108
105 109 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
106 110
107 - let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await?;
108 - let current_ref = default_ref(&state, &owner, &repo_name).await;
111 + let (open_issue_count, _) = db::issues::get_issue_counts(&db, resolved.db_repo.id).await?;
112 + let current_ref = default_ref(&config, &owner, &repo_name).await;
109 113 let csrf_token = get_csrf_token(&session).await;
110 114
111 115 let email_address = format!("{}+{}@issues.makenot.work", owner, repo_name);
@@ -8,6 +8,7 @@ mod settings;
8 8 use axum::routing::get;
9 9
10 10 use crate::{
11 + config::Config,
11 12 csrf::{post_csrf, post_csrf_skip, CsrfRouter},
12 13 routes::git::{resolve_repo, repos_root},
13 14 AppState,
@@ -28,8 +29,8 @@ pub fn git_issue_routes() -> CsrfRouter<AppState> {
28 29 }
29 30
30 31 /// Get the default branch name for a repo (for nav bar links).
31 - async fn default_ref(state: &AppState, owner: &str, repo_name: &str) -> String {
32 - let root = match repos_root(&state.config) {
32 + async fn default_ref(config: &Config, owner: &str, repo_name: &str) -> String {
33 + let root = match repos_root(config) {
33 34 Ok(r) => r,
34 35 Err(_) => return "main".to_string(),
35 36 };
@@ -10,10 +10,12 @@ use serde::Deserialize;
10 10 use std::collections::HashMap;
11 11 use std::sync::LazyLock;
12 12
13 + use sqlx::PgPool;
14 +
13 15 use crate::{
16 + config::Config,
14 17 db::{self, IssueStatus},
15 18 error::{AppError, Result, ResultExt},
16 - AppState,
17 19 };
18 20
19 21 use super::repos_root;
@@ -101,12 +103,13 @@ pub(super) struct ProcessPushRequest {
101 103 /// Auth: Bearer token (same BUILD_TRIGGER_TOKEN as the build pipeline).
102 104 #[tracing::instrument(skip_all, name = "git_issues::process_push")]
103 105 pub(super) async fn process_push(
104 - State(state): State<AppState>,
106 + State(db): State<PgPool>,
107 + State(config): State<Config>,
105 108 headers: axum::http::HeaderMap,
106 109 Json(req): Json<ProcessPushRequest>,
107 110 ) -> Result<impl axum::response::IntoResponse> {
108 111 // Validate per-repo HMAC (derived from BUILD_TRIGGER_TOKEN, never stored raw)
109 - let trigger_token = state.config.build.trigger_token.as_deref()
112 + let trigger_token = config.build.trigger_token.as_deref()
110 113 .ok_or(AppError::Internal(anyhow::anyhow!("BUILD_TRIGGER_TOKEN not configured")))?;
111 114
112 115 let auth_header = headers
@@ -122,13 +125,13 @@ pub(super) async fn process_push(
122 125
123 126 // Look up repo owner + repo
124 127 let owner_user = db::users::get_user_by_username(
125 - &state.db,
128 + &db,
126 129 &db::Username::from_trusted(req.repo_owner.clone()),
127 130 )
128 131 .await?
129 132 .ok_or(AppError::NotFound)?;
130 133
131 - let repo = db::git_repos::get_repo_by_user_and_name(&state.db, owner_user.id, &req.repo_name)
134 + let repo = db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name)
132 135 .await?
133 136 .ok_or(AppError::NotFound)?;
134 137
@@ -138,7 +141,7 @@ pub(super) async fn process_push(
138 141 // it inline parks a tokio worker thread for the duration (Run #2 Perf SERIOUS).
139 142 // The walk creates and drops all git2 types internally and returns only the
140 143 // owned, Send `Vec` of collected refs.
141 - let root = repos_root(&state.config)?;
144 + let root = repos_root(&config)?;
142 145 let repo_owner = req.repo_owner.clone();
143 146 let repo_name = req.repo_name.clone();
144 147 let after = req.after.clone();
@@ -197,7 +200,7 @@ pub(super) async fn process_push(
197 200 .collect();
198 201 numbers.sort_unstable();
199 202 numbers.dedup();
200 - let mut issues = db::issues::get_issues_by_numbers(&state.db, repo.id, &numbers).await?;
203 + let mut issues = db::issues::get_issues_by_numbers(&db, repo.id, &numbers).await?;
201 204
202 205 // Collect the DB writes and flush them in two bulk statements at the end
203 206 // rather than one status UPDATE + one comment INSERT per referenced commit
@@ -244,10 +247,10 @@ pub(super) async fn process_push(
244 247
245 248 // Flush: final status per changed issue, then every comment, in two queries.
246 249 let status_updates: Vec<(db::IssueId, IssueStatus)> = status_changes.into_iter().collect();
247 - if let Err(e) = db::issues::update_issue_statuses(&state.db, &status_updates).await {
250 + if let Err(e) = db::issues::update_issue_statuses(&db, &status_updates).await {
248 251 tracing::warn!(error = ?e, "failed to bulk-update issue statuses via push");
249 252 }
250 - if let Err(e) = db::issues::create_comments(&state.db, owner_user.id, &comments).await {
253 + if let Err(e) = db::issues::create_comments(&db, owner_user.id, &comments).await {
251 254 tracing::warn!(error = ?e, "failed to bulk-create issue comments via push");
252 255 }
253 256
@@ -9,14 +9,16 @@ use axum::{
9 9 use serde::Deserialize;
10 10 use tower_sessions::Session;
11 11
12 + use sqlx::PgPool;
13 +
12 14 use crate::{
13 15 auth::AuthUser,
16 + config::Config,
14 17 db::{self, ProjectId, Visibility},
15 18 error::{AppError, Result},
16 19 helpers::get_csrf_token,
17 20 templates::*,
18 21 validation,
19 - AppState,
20 22 };
21 23
22 24 use super::default_ref;
@@ -24,21 +26,22 @@ use super::default_ref;
24 26 /// `GET /git/{owner}/{repo}/settings`: settings form (owner only).
25 27 #[tracing::instrument(skip_all, name = "git_issues::repo_settings_form")]
26 28 pub(super) async fn repo_settings_form(
27 - State(state): State<AppState>,
29 + State(db): State<PgPool>,
30 + State(config): State<Config>,
28 31 session: Session,
29 32 AuthUser(user): AuthUser,
30 33 Path((owner, repo_name)): Path<(String, String)>,
31 34 ) -> Result<impl IntoResponse> {
32 - let resolved = super::resolve_repo(&state.db, &state.config, &owner, &repo_name, Some(user.id)).await?;
35 + let resolved = super::resolve_repo(&db, &config, &owner, &repo_name, Some(user.id)).await?;
33 36
34 37 if user.id != resolved.db_user.id {
35 38 return Err(AppError::Forbidden);
36 39 }
37 40
38 - let projects = db::projects::get_projects_by_user(&state.db, user.id).await?;
41 + let projects = db::projects::get_projects_by_user(&db, user.id).await?;
39 42 let linked_project_id = resolved.db_repo.project_id.map(|pid| pid.to_string()).unwrap_or_default();
40 - let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await.unwrap_or((0, 0));
41 - let current_ref = default_ref(&state, &owner, &repo_name).await;
43 + let (open_issue_count, _) = db::issues::get_issue_counts(&db, resolved.db_repo.id).await.unwrap_or((0, 0));
44 + let current_ref = default_ref(&config, &owner, &repo_name).await;
42 45 let csrf_token = get_csrf_token(&session).await;
43 46
44 47 Ok(GitRepoSettingsTemplate {
@@ -64,13 +67,14 @@ pub(super) struct RepoSettingsForm {
64 67 /// `POST /git/{owner}/{repo}/settings`: save settings (owner only).
65 68 #[tracing::instrument(skip_all, name = "git_issues::repo_settings_save")]
66 69 pub(super) async fn repo_settings_save(
67 - State(state): State<AppState>,
70 + State(db): State<PgPool>,
71 + State(config): State<Config>,
68 72 AuthUser(user): AuthUser,
69 73 Path((owner, repo_name)): Path<(String, String)>,
70 74 Form(form): Form<RepoSettingsForm>,
71 75 ) -> Result<impl IntoResponse> {
72 76 user.check_not_suspended()?;
73 - let resolved = super::resolve_repo(&state.db, &state.config, &owner, &repo_name, Some(user.id)).await?;
77 + let resolved = super::resolve_repo(&db, &config, &owner, &repo_name, Some(user.id)).await?;
74 78
75 79 if user.id != resolved.db_user.id {
76 80 return Err(AppError::Forbidden);
@@ -80,7 +84,7 @@ pub(super) async fn repo_settings_save(
80 84 validation::validate_repo_description(description)?;
81 85
82 86 // Update description + visibility (enum deserialization handles validation)
83 - db::git_repos::update_repo_settings(&state.db, resolved.db_repo.id, description, form.visibility).await?;
87 + db::git_repos::update_repo_settings(&db, resolved.db_repo.id, description, form.visibility).await?;
84 88
85 89 // Handle project linking
86 90 let new_project_id: Option<ProjectId> = form
@@ -92,17 +96,17 @@ pub(super) async fn repo_settings_save(
92 96 match (resolved.db_repo.project_id, new_project_id) {
93 97 (Some(_), None) => {
94 98 // Unlink
95 - db::git_repos::unlink_repo_from_project(&state.db, resolved.db_repo.id).await?;
99 + db::git_repos::unlink_repo_from_project(&db, resolved.db_repo.id).await?;
96 100 }
97 101 (None, Some(pid)) | (Some(_), Some(pid)) if resolved.db_repo.project_id != Some(pid) => {
98 102 // Link or change link — verify the project belongs to this user
99 - let project = db::projects::get_project_by_id(&state.db, pid)
103 + let project = db::projects::get_project_by_id(&db, pid)
100 104 .await?
101 105 .ok_or(AppError::validation("Project not found".to_string()))?;
102 106 if project.user_id != user.id {
103 107 return Err(AppError::Forbidden);
104 108 }
105 - db::git_repos::link_repo_to_project(&state.db, resolved.db_repo.id, pid).await?;
109 + db::git_repos::link_repo_to_project(&db, resolved.db_repo.id, pid).await?;
106 110 }
107 111 _ => {} // No change
108 112 }
@@ -113,18 +117,19 @@ pub(super) async fn repo_settings_save(
113 117 /// `POST /git/{owner}/{repo}/settings/delete`: delete repo (owner only).
114 118 #[tracing::instrument(skip_all, name = "git_issues::repo_settings_delete")]
115 119 pub(super) async fn repo_settings_delete(
116 - State(state): State<AppState>,
120 + State(db): State<PgPool>,
121 + State(config): State<Config>,
117 122 AuthUser(user): AuthUser,
118 123 Path((owner, repo_name)): Path<(String, String)>,
119 124 ) -> Result<impl IntoResponse> {
120 125 user.check_not_suspended()?;
121 - let resolved = super::resolve_repo(&state.db, &state.config, &owner, &repo_name, Some(user.id)).await?;
126 + let resolved = super::resolve_repo(&db, &config, &owner, &repo_name, Some(user.id)).await?;
122 127
123 128 if user.id != resolved.db_user.id {
124 129 return Err(AppError::Forbidden);
125 130 }
126 131
127 - db::git_repos::delete_repo(&state.db, resolved.db_repo.id).await?;
132 + db::git_repos::delete_repo(&db, resolved.db_repo.id).await?;
128 133
129 134 Ok((
130 135 StatusCode::OK,