Skip to main content

max / makenotwork

Wire git repos to projects with bidirectional linking and releases Projects can now link to a git repository via git_repo_name. The project page shows a "Source Code" link to the repo, and the git repo page shows the linked project with versioned items as releases. - Add git_repo_name column with unique index per user (migration 018) - Add set_project_git_repo and get_project_by_git_repo DB functions - Validate and set git_repo_name through project update API - Show git repo input in project settings when git hosting is enabled - Render linked project and release items on git repo page - Add 6 integration tests covering API, DB, uniqueness, and page rendering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-09 16:59 UTC
Commit: e5b6858d6821427dbe6a9a883c43ed3cb3739f02
Parent: a58cc29
13 files changed, +477 insertions, -1 deletion
@@ -179,6 +179,8 @@
179 179 pub created_at: DateTime<Utc>,
180 180 /// When the project was last modified.
181 181 pub updated_at: DateTime<Utc>,
182 + /// Optional linked git repository name (bare name, not full path).
183 + pub git_repo_name: Option<String>,
182 184 }
183 185
184 186 /// A purchasable or free item within a project.
@@ -164,6 +164,38 @@
164 164 Ok(projects)
165 165 }
166 166
167 + /// Set or clear a project's linked git repository name.
168 + pub async fn set_project_git_repo(
169 + pool: &PgPool,
170 + id: ProjectId,
171 + repo_name: Option<&str>,
172 + ) -> Result<()> {
173 + sqlx::query("UPDATE projects SET git_repo_name = $2 WHERE id = $1")
174 + .bind(id)
175 + .bind(repo_name)
176 + .execute(pool)
177 + .await?;
178 +
179 + Ok(())
180 + }
181 +
182 + /// Look up a project by its linked git repo name and owning user.
183 + pub async fn get_project_by_git_repo(
184 + pool: &PgPool,
185 + user_id: UserId,
186 + repo_name: &str,
187 + ) -> Result<Option<DbProject>> {
188 + let project = sqlx::query_as::<_, DbProject>(
189 + "SELECT * FROM projects WHERE user_id = $1 AND git_repo_name = $2 LIMIT 1",
190 + )
191 + .bind(user_id)
192 + .bind(repo_name)
193 + .fetch_optional(pool)
194 + .await?;
195 +
196 + Ok(project)
197 + }
198 +
167 199 /// Fetch a public project by its URL slug. Returns `None` if not found or not public.
168 200 pub async fn get_public_project_by_slug(
169 201 pool: &PgPool,
@@ -14,10 +14,12 @@
14 14 use crate::{
15 15 auth::MaybeUser,
16 16 constants,
17 + db::{self, Username},
17 18 error::{AppError, Result},
18 19 git,
19 20 helpers::get_csrf_token,
20 21 templates::*,
22 + types::*,
21 23 AppState,
22 24 };
23 25
@@ -105,6 +107,51 @@
105 107 }
106 108 }
107 109
110 + // ============================================================================
111 + // Linked project + releases
112 + // ============================================================================
113 +
114 + /// Look up the project linked to a git repo and fetch its public items with versions.
115 + async fn fetch_linked_releases(
116 + state: &AppState,
117 + owner: &str,
118 + repo_name: &str,
119 + ) -> (Option<Project>, Vec<ReleaseItem>) {
120 + let username = Username::from_trusted(owner.to_string());
121 + let db_user = match db::users::get_user_by_username(&state.db, &username).await {
122 + Ok(Some(u)) => u,
123 + _ => return (None, Vec::new()),
124 + };
125 +
126 + let db_project = match db::projects::get_project_by_git_repo(&state.db, db_user.id, repo_name).await {
127 + Ok(Some(p)) if p.is_public => p,
128 + _ => return (None, Vec::new()),
129 + };
130 +
131 + let db_items = match db::items::get_public_items_by_project(&state.db, db_project.id).await {
132 + Ok(items) => items,
133 + Err(_) => return (Some(Project::from_db(&db_project, 0)), Vec::new()),
134 + };
135 +
136 + let mut release_items = Vec::new();
137 + for item in &db_items {
138 + let versions = match db::versions::get_versions_by_item(&state.db, item.id).await {
139 + Ok(v) if !v.is_empty() => v,
140 + _ => continue,
141 + };
142 + let tags = db::tags::get_tags_for_item(&state.db, item.id).await.unwrap_or_default();
143 + let view_item = Item::from_db_list(item, &tags, item.price_cents == 0, false);
144 + let view_versions: Vec<Version> = versions.iter().map(Version::from_db).collect();
145 + release_items.push(ReleaseItem {
146 + item: view_item,
147 + versions: view_versions,
148 + });
149 + }
150 +
151 + let project = Project::from_db(&db_project, db_items.len() as u32);
152 + (Some(project), release_items)
153 + }
154 +
108 155 // ============================================================================
109 156 // Browsing handlers
110 157 // ============================================================================
@@ -128,6 +175,9 @@
128 175
129 176 let csrf_token = get_csrf_token(&session).await;
130 177
178 + // Look up linked project + releases
179 + let (linked_project, release_items) = fetch_linked_releases(&state, &owner, &repo_name).await;
180 +
131 181 Ok(GitRepoTemplate {
132 182 csrf_token,
133 183 session_user: maybe_user,
@@ -139,6 +189,8 @@
139 189 tree_items,
140 190 readme_html,
141 191 host_url: state.config.host_url.clone(),
192 + linked_project,
193 + release_items,
142 194 })
143 195 }
144 196
@@ -159,6 +211,9 @@
159 211
160 212 let csrf_token = get_csrf_token(&session).await;
161 213
214 + // Look up linked project + releases
215 + let (linked_project, release_items) = fetch_linked_releases(&state, &owner, &repo_name).await;
216 +
162 217 Ok(GitRepoTemplate {
163 218 csrf_token,
164 219 session_user: maybe_user,
@@ -170,6 +225,8 @@
170 225 tree_items,
171 226 readme_html,
172 227 host_url: state.config.host_url.clone(),
228 + linked_project,
229 + release_items,
173 230 })
174 231 }
175 232
@@ -233,6 +233,10 @@
233 233 pub project: Project,
234 234 /// Current category name for pre-populating the form, or empty.
235 235 pub category_name: String,
236 + /// Current linked git repo name, or empty.
237 + pub git_repo_name: String,
238 + /// Whether git hosting is configured on this server.
239 + pub git_enabled: bool,
236 240 }
237 241
238 242 /// Dashboard blog tab partial.
@@ -140,6 +140,8 @@
140 140 pub has_subscription: bool,
141 141 /// Base URL for OG meta tags.
142 142 pub host_url: String,
143 + /// URL to linked git repository, if configured.
144 + pub git_repo_url: Option<String>,
143 145 }
144 146
145 147 /// Public item detail page.
@@ -402,6 +404,16 @@
402 404 pub link_text: String,
403 405 }
404 406
407 + /// Confirmation page shown before account deletion (GET step).
408 + #[derive(Template)]
409 + #[template(path = "pages/confirm_delete.html")]
410 + pub struct ConfirmDeleteTemplate {
411 + pub csrf_token: CsrfTokenOption,
412 + pub user: String,
413 + pub expires: String,
414 + pub sig: String,
415 + }
416 +
405 417 #[derive(Template)]
406 418 #[template(path = "pages/account-deleted.html")]
407 419 pub struct AccountDeletedTemplate {
@@ -504,6 +516,12 @@
504 516 // Git Source Browser
505 517 // ============================================================================
506 518
519 + /// An item paired with its versions, for release display on the git repo page.
520 + pub struct ReleaseItem {
521 + pub item: Item,
522 + pub versions: Vec<Version>,
523 + }
524 +
507 525 /// Repository overview: file tree at HEAD + README.
508 526 #[derive(Template)]
509 527 #[template(path = "pages/git/repo.html")]
@@ -518,6 +536,10 @@
518 536 pub tree_items: Vec<git::TreeItem>,
519 537 pub readme_html: Option<String>,
520 538 pub host_url: String,
539 + /// Linked project, if this repo is associated with a public project.
540 + pub linked_project: Option<Project>,
541 + /// Public items with versions from the linked project (releases).
542 + pub release_items: Vec<ReleaseItem>,
521 543 }
522 544
523 545 /// Subdirectory listing with breadcrumb navigation.