diff --git a/crates/api/examples/dashboard_e2e_seed.rs b/crates/api/examples/dashboard_e2e_seed.rs index 11f9e605..e55d9d66 100644 --- a/crates/api/examples/dashboard_e2e_seed.rs +++ b/crates/api/examples/dashboard_e2e_seed.rs @@ -905,6 +905,11 @@ async fn seed_projects_workspace_fixture( short_description = EXCLUDED.short_description, visibility = EXCLUDED.visibility, default_repository_id = EXCLUDED.default_repository_id, + state = 'open', + closed_at = NULL, + closed_by_user_id = NULL, + deleted_at = NULL, + deleted_by_user_id = NULL, updated_at = now() RETURNING id "#, diff --git a/crates/api/src/domain/milestones.rs b/crates/api/src/domain/milestones.rs index 6948bc7b..b4738c5e 100644 --- a/crates/api/src/domain/milestones.rs +++ b/crates/api/src/domain/milestones.rs @@ -817,8 +817,8 @@ async fn milestone_rows( MilestoneSort::IssuesAsc => "total_count ASC, milestones.updated_at DESC", }; let sql = format!( - "{} AND ($2::text IS NULL OR milestones.state = $2) ORDER BY {order_by} LIMIT $3 OFFSET $4", - milestone_select_sql("milestones.repository_id = $1") + "{} ORDER BY {order_by} LIMIT $3 OFFSET $4", + milestone_select_sql("milestones.repository_id = $1 AND ($2::text IS NULL OR milestones.state = $2)") ); sqlx::query(&sql) .bind(repository_id) diff --git a/crates/api/src/domain/projects.rs b/crates/api/src/domain/projects.rs index b3c51d78..193b5e03 100644 --- a/crates/api/src/domain/projects.rs +++ b/crates/api/src/domain/projects.rs @@ -1607,12 +1607,17 @@ pub async fn copy_project_for_actor( let owner: String = source .try_get::, _>("owner_login")? .unwrap_or_else(|| "unknown".to_owned()); + let project_href = if owner_organization_id.is_some() { + format!("/orgs/{owner}/projects/{next_number}") + } else { + format!("/{owner}/projects/{next_number}") + }; Ok(CopiedProject { id: new_project_id, number: next_number, title, - href: format!("/{owner}/projects/{next_number}"), - workspace_href: format!("/{owner}/projects/{next_number}/views/1"), + href: project_href.clone(), + workspace_href: format!("{project_href}/views/1"), owner, copied_views, copied_fields, diff --git a/crates/api/tests/projects_list_contract.rs b/crates/api/tests/projects_list_contract.rs index 8e9a5baa..d97c3579 100644 --- a/crates/api/tests/projects_list_contract.rs +++ b/crates/api/tests/projects_list_contract.rs @@ -342,7 +342,7 @@ async fn projects_lists_filter_templates_and_repository_links_without_leaking_pr ); assert_eq!( body["templates"]["items"][0]["projectHref"], - format!("/{marker}/projects/2/views/1") + format!("/orgs/{marker}/projects/2/views/1") ); let copy_uri = format!("/api/projects/{public_project_id}/copies"); @@ -364,7 +364,7 @@ async fn projects_lists_filter_templates_and_repository_links_without_leaking_pr assert_eq!(body["copiedDraftItems"], 1); assert_eq!( body["workspaceHref"], - format!("/{marker}/projects/4/views/1") + format!("/orgs/{marker}/projects/4/views/1") ); let cloned_counts = sqlx::query( r#" diff --git a/crates/api/tests/repository_code_overview.rs b/crates/api/tests/repository_code_overview.rs index a956a839..2358875e 100644 --- a/crates/api/tests/repository_code_overview.rs +++ b/crates/api/tests/repository_code_overview.rs @@ -579,7 +579,7 @@ async fn repository_header_actions_toggle_social_state_and_create_fork() { assert!(fork_body["forkHref"] .as_str() .expect("fork href should exist") - .ends_with(&format!("/{}", repository.name))); + .ends_with(&format!("/{}-fork", repository.name))); assert_eq!(fork_body["social"]["forksCount"], 1); assert!(fork_body["social"]["forkedRepositoryHref"].is_string()); diff --git a/crates/api/tests/repository_foundation.rs b/crates/api/tests/repository_foundation.rs index 9d4a6bda..6e12cff0 100644 --- a/crates/api/tests/repository_foundation.rs +++ b/crates/api/tests/repository_foundation.rs @@ -67,7 +67,10 @@ async fn user_owned_repositories_enforce_uniqueness_permissions_and_pagination() .expect("owner should create own repository"); assert_eq!(repository.owner_user_id, Some(owner.id)); - assert_eq!(repository.owner_login, owner.email); + assert_eq!( + repository.owner_login, + owner.email.split('@').next().unwrap() + ); assert_eq!(repository.visibility, RepositoryVisibility::Private); let permission = repository_permission_for_user(&pool, repository.id, owner.id) diff --git a/crates/api/tests/repository_milestones_contract.rs b/crates/api/tests/repository_milestones_contract.rs index 8e00f40c..0565fbc5 100644 --- a/crates/api/tests/repository_milestones_contract.rs +++ b/crates/api/tests/repository_milestones_contract.rs @@ -248,7 +248,7 @@ async fn repository_milestones_contract_lists_mutates_and_clears_associations() "SELECT COUNT(*)::bigint FROM audit_events WHERE actor_user_id = $1 AND target_id = $2", ) .bind(writer.id) - .bind(milestone.id) + .bind(milestone.id.to_string()) .fetch_one(&pool) .await .expect("audit count should load"); diff --git a/web/src/app/[owner]/projects/[number]/settings/templates/page.tsx b/web/src/app/[owner]/projects/[number]/settings/templates/page.tsx new file mode 100644 index 00000000..cc73a557 --- /dev/null +++ b/web/src/app/[owner]/projects/[number]/settings/templates/page.tsx @@ -0,0 +1,53 @@ +import { AppShell } from "@/components/AppShell"; +import { ProjectTemplateSettingsPage } from "@/components/ProjectTemplateSettingsPage"; +import { + getSessionAndShellContext, + getUserProjectSettings, +} from "@/lib/server-session"; + +type UserProjectTemplateSettingsRouteProps = { + params: Promise<{ owner: string; number: string }>; +}; + +export default async function UserProjectTemplateSettingsRoute({ + params, +}: UserProjectTemplateSettingsRouteProps) { + const [{ owner, number }, { session, shellContext }] = await Promise.all([ + params, + getSessionAndShellContext(), + ]); + const ownerLogin = decodeURIComponent(owner); + const projectNumber = Number.parseInt(number, 10); + const result = Number.isFinite(projectNumber) + ? await getUserProjectSettings(ownerLogin, projectNumber) + : { + ok: false as const, + status: 404, + code: "not_found", + message: "Project template settings could not be found.", + }; + + return ( + + {result.ok ? ( + + ) : ( +
+
+
Project templates unavailable
+

+ This project template cannot be configured. +

+

+ {result.message} +

+
+
+ )} +
+ ); +} diff --git a/web/src/app/orgs/[org]/projects/[number]/settings/templates/page.tsx b/web/src/app/orgs/[org]/projects/[number]/settings/templates/page.tsx new file mode 100644 index 00000000..4baf078b --- /dev/null +++ b/web/src/app/orgs/[org]/projects/[number]/settings/templates/page.tsx @@ -0,0 +1,53 @@ +import { AppShell } from "@/components/AppShell"; +import { ProjectTemplateSettingsPage } from "@/components/ProjectTemplateSettingsPage"; +import { + getOrganizationProjectSettings, + getSessionAndShellContext, +} from "@/lib/server-session"; + +type OrganizationProjectTemplateSettingsRouteProps = { + params: Promise<{ org: string; number: string }>; +}; + +export default async function OrganizationProjectTemplateSettingsRoute({ + params, +}: OrganizationProjectTemplateSettingsRouteProps) { + const [{ org, number }, { session, shellContext }] = await Promise.all([ + params, + getSessionAndShellContext(), + ]); + const orgLogin = decodeURIComponent(org); + const projectNumber = Number.parseInt(number, 10); + const result = Number.isFinite(projectNumber) + ? await getOrganizationProjectSettings(orgLogin, projectNumber) + : { + ok: false as const, + status: 404, + code: "not_found", + message: "Project template settings could not be found.", + }; + + return ( + + {result.ok ? ( + + ) : ( +
+
+
Project templates unavailable
+

+ This project template cannot be configured. +

+

+ {result.message} +

+
+
+ )} +
+ ); +} diff --git a/web/src/components/ProjectSettingsPage.tsx b/web/src/components/ProjectSettingsPage.tsx index 8e320016..f629f335 100644 --- a/web/src/components/ProjectSettingsPage.tsx +++ b/web/src/components/ProjectSettingsPage.tsx @@ -135,6 +135,12 @@ export function ProjectSettingsPage({ const canPublishStatus = settings.viewerPermissions.canPublishStatus && settings.dangerState.state === "open"; + const canLinkRepositories = + settings.viewerPermissions.canLinkRepositories && + settings.dangerState.state === "open"; + const canManageTemplate = + settings.viewerPermissions.canManageTemplate && + settings.dangerState.state === "open"; const latestStatus = settings.statusUpdates[0] ?? null; const visibilityReason = settings.policy.visibilityLockedReason ?? @@ -149,7 +155,7 @@ export function ProjectSettingsPage({ { key: "access", label: "Access", disabled: false }, { key: "fields", label: "Fields", disabled: false }, { key: "workflows", label: "Workflows", disabled: false }, - { key: "templates", label: "Templates", disabled: true }, + { key: "templates", label: "Templates", disabled: false }, { key: "danger", label: "Danger Zone", disabled: false }, ]; @@ -699,7 +705,7 @@ export function ProjectSettingsPage({
Repositories
- {canEditGeneral ? ( + {canLinkRepositories ? (
Default ) : null} - {canEditGeneral && !repository.isDefault ? ( + {canLinkRepositories && !repository.isDefault ? (