diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 1ba3727b..23955471 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -27,6 +27,14 @@ pub enum MergeRequestState { Closed, } impl MergeRequestState { + fn as_str(self) -> &'static str { + match self { + Self::Open => "open", + Self::Merged => "merged", + Self::Closed => "closed", + } + } + fn parse(v: &str) -> Result { match v { "draft" | "open" => Ok(Self::Open), @@ -216,6 +224,23 @@ impl MergeRequest { } } +#[derive(Clone, Debug, Default)] +pub struct MergeRequestListQuery { + pub state: Option, + pub repository_id: Option, + pub ticket_id: Option, + pub selector_from: Option, + pub selector_to: Option, + pub cursor: Option, + pub limit: usize, +} + +#[derive(Clone, Debug)] +pub struct MergeRequestListPage { + pub items: Vec, + pub next_cursor: Option, +} + #[derive(Debug, Clone)] pub struct OpenMergeRequest { pub merge_request_id: String, @@ -935,6 +960,80 @@ impl MergeRequestStore { None => Err(MergeRequestError::NotFound), } } + pub fn get_by_id( + &self, + workspace_id: &str, + merge_request_id: &str, + ) -> Result { + let c = self.lock()?; + load_mr(&c, workspace_id, merge_request_id)?.ok_or(MergeRequestError::NotFound) + } + pub fn list( + &self, + workspace_id: &str, + query: &MergeRequestListQuery, + ) -> Result { + let c = self.lock()?; + let limit = query.limit.clamp(1, 100); + let cursor_position = match query.cursor.as_deref() { + Some(cursor) => Some( + c.query_row( + "SELECT updated_at,merge_request_id FROM merge_requests WHERE workspace_id=?1 AND merge_request_id=?2", + params![workspace_id, cursor], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()? + .ok_or_else(|| MergeRequestError::Validation("invalid merge request cursor".into()))?, + ), + None => None, + }; + let cursor_updated_at = cursor_position + .as_ref() + .map(|(updated_at, _)| updated_at.as_str()); + let cursor_id = cursor_position.as_ref().map(|(_, id)| id.as_str()); + let state = query.state.map(MergeRequestState::as_str); + let mut statement = c.prepare( + "SELECT mr.merge_request_id + FROM merge_requests mr + WHERE mr.workspace_id=?1 + AND (?2 IS NULL OR mr.state=?2) + AND (?3 IS NULL OR mr.repository_id=?3) + AND (?4 IS NULL OR mr.selector_from=?4) + AND (?5 IS NULL OR mr.selector_to=?5) + AND (?6 IS NULL OR EXISTS ( + SELECT 1 FROM merge_request_ticket_relations relation + WHERE relation.workspace_id=mr.workspace_id + AND relation.merge_request_id=mr.merge_request_id + AND relation.ticket_id=?6 + )) + AND (?7 IS NULL OR mr.updated_at?8)) + ORDER BY mr.updated_at DESC,mr.merge_request_id ASC + LIMIT ?9", + )?; + let rows = statement.query_map( + params![ + workspace_id, + state, + query.repository_id.as_deref(), + query.selector_from.as_deref(), + query.selector_to.as_deref(), + query.ticket_id.as_deref(), + cursor_updated_at, + cursor_id, + (limit + 1) as i64, + ], + |row| row.get::<_, String>(0), + )?; + let mut ids = rows.collect::, _>>()?; + let has_more = ids.len() > limit; + ids.truncate(limit); + let next_cursor = has_more.then(|| ids.last().cloned()).flatten(); + let items = ids + .iter() + .map(|id| load_mr(&c, workspace_id, id)?.ok_or(MergeRequestError::NotFound)) + .collect::, _>>()?; + Ok(MergeRequestListPage { items, next_cursor }) + } pub fn thread_page( &self, ws: &str, @@ -946,6 +1045,25 @@ impl MergeRequestStore { let c = self.lock()?; load_thread(&c, ws, &mr.merge_request_id, after, limit.clamp(1, 200)) } + pub fn thread_page_by_id( + &self, + workspace_id: &str, + merge_request_id: &str, + after: Option, + limit: usize, + ) -> Result, MergeRequestError> { + let c = self.lock()?; + if load_mr(&c, workspace_id, merge_request_id)?.is_none() { + return Err(MergeRequestError::NotFound); + } + load_thread( + &c, + workspace_id, + merge_request_id, + after, + limit.clamp(1, 200), + ) + } fn assigned(&self, a: &MergeRequestAuth, t: &str, r: &str) -> Result<(), MergeRequestError> { self.repo(a, r)?; let x = self diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index 55113aea..b7f0f76b 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -436,6 +436,75 @@ fn selector_repair_rejects_unapproved_resolved_subject() { assert!(matches!(result, Err(MergeRequestError::NotReady(_)))); } +#[test] +fn first_class_list_and_detail_are_workspace_scoped_and_cursor_bounded() { + let (dir, store) = fixture(); + open(&store); + Connection::open(dir.path().join("db")) + .unwrap() + .execute( + "UPDATE merge_requests SET state='closed' WHERE workspace_id='W' AND merge_request_id='MR'", + [], + ) + .unwrap(); + store + .open_merge_request(OpenMergeRequest { + merge_request_id: "MR-2".into(), + ticket_id: "T".into(), + repository_id: "R".into(), + selector_from: "work/t-2".into(), + selector_to: "develop".into(), + summary: "second".into(), + auth: auth(), + now: at(8), + }) + .unwrap(); + + let first = store + .list( + "W", + &MergeRequestListQuery { + ticket_id: Some("T".into()), + limit: 1, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(first.items[0].merge_request_id, "MR-2"); + assert_eq!(first.next_cursor.as_deref(), Some("MR-2")); + + let second = store + .list( + "W", + &MergeRequestListQuery { + ticket_id: Some("T".into()), + cursor: first.next_cursor, + limit: 1, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(second.items[0].merge_request_id, "MR"); + assert!(second.next_cursor.is_none()); + + let closed = store + .list( + "W", + &MergeRequestListQuery { + state: Some(MergeRequestState::Closed), + limit: 10, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(closed.items.len(), 1); + assert_eq!(store.get_by_id("W", "MR").unwrap().merge_request_id, "MR"); + assert!(matches!( + store.get_by_id("other", "MR"), + Err(MergeRequestError::NotFound) + )); +} + #[test] fn transactional_completion_rejects_assignment_changed_in_control_plane_db() { let (dir, store) = fixture(); diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 96dddde0..2f8fc116 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -1589,7 +1589,7 @@ fn ticket_evidence_event(sequence: usize, event: &TicketEvent) -> TicketEvidence } } -fn merge_request_summary( +pub(crate) fn merge_request_summary( request: MergeRequest, current_subject_ref: Option, ) -> TicketMergeRequestSummary { diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index adda4325..1f9cb09b 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -285,6 +285,21 @@ pub struct TicketMergeRequestSummary { pub review_excerpt: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct MergeRequestListItem { + pub summary: TicketMergeRequestSummary, + pub ticket_ids: Vec, + pub thread_event_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct MergeRequestListResponse { + pub items: Vec, + pub next_cursor: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketEvidenceSummary { @@ -474,6 +489,8 @@ pub fn ticket_api_typescript() -> String { TicketEvidenceEvent::decl(&config), TicketAssignmentSummary::decl(&config), TicketMergeRequestSummary::decl(&config), + MergeRequestListItem::decl(&config), + MergeRequestListResponse::decl(&config), TicketEvidenceSummary::decl(&config), TicketQueryRequest::decl(&config), TicketQueryItem::decl(&config), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 183c7f56..6e80ff11 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -65,7 +65,7 @@ use crate::auth::{ }; use crate::authority::{ MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput, - SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, + SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, merge_request_summary, }; use crate::companion::{ CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, @@ -97,8 +97,9 @@ use crate::observation::{ }; use crate::profile_settings::UpdateWorkspaceMetadataRequest; use crate::records::{ - ObjectiveDetail, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveShowRequest, - ProjectRecordList, TicketDetail, TicketQueryRequest, TicketQueryResponse, TicketShowRequest, + MergeRequestListItem, MergeRequestListResponse, ObjectiveDetail, ObjectiveQueryRequest, + ObjectiveQueryResponse, ObjectiveShowRequest, ProjectRecordList, TicketDetail, + TicketQueryRequest, TicketQueryResponse, TicketShowRequest, }; use crate::repositories::{ ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, @@ -1729,9 +1730,17 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/tickets/{id}/workflow/queue", post(scoped_queue_ticket_record), ) + .route( + "/api/w/{workspace_id}/merge-requests", + get(scoped_list_merge_requests), + ) + .route( + "/api/w/{workspace_id}/merge-requests/{merge_request_id}", + get(scoped_show_merge_request), + ) .route( "/api/w/{workspace_id}/tickets/{id}/merge-request", - get(scoped_show_merge_request).post(scoped_open_merge_request), + post(scoped_open_merge_request), ) .route( "/api/w/{workspace_id}/tickets/{id}/merge-request/readiness", @@ -4401,39 +4410,172 @@ fn resolve_workspace_ticket_reference( .ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into()) } +#[derive(Debug, serde::Deserialize)] +struct MergeRequestListHttpQuery { + state: Option, + repository_id: Option, + ticket_ref: Option, + selector_from: Option, + selector_to: Option, + cursor: Option, + limit: Option, +} + +#[derive(Debug, serde::Serialize)] +struct MergeRequestRefResponse { + status: String, + #[serde(rename = "ref")] + revision_ref: Option, + observed_at: String, +} + +#[derive(Debug, serde::Serialize)] +struct MergeRequestLinkedTicketResponse { + ticket_id: String, + key: Option, +} + +#[derive(Debug, serde::Serialize)] +struct MergeRequestDetailResponse { + #[serde(flatten)] + merge_request: merge_request::MergeRequest, + source: MergeRequestRefResponse, + target: MergeRequestRefResponse, + linked_tickets: Vec, +} + +async fn scoped_list_merge_requests( + State(api): State, + AxumPath(workspace_id): AxumPath, + Query(query): Query, +) -> ApiResult> { + let workspace_id = parse_workspace_id(&workspace_id)?; + require_workspace_access(&workspace_id, &api)?; + let ticket_id = query + .ticket_ref + .as_deref() + .map(|reference| resolve_workspace_ticket_reference(&api, &workspace_id, reference)) + .transpose()?; + let state = query + .state + .as_deref() + .map(|state| match state { + "open" => Ok(merge_request::MergeRequestState::Open), + "merged" => Ok(merge_request::MergeRequestState::Merged), + "closed" => Ok(merge_request::MergeRequestState::Closed), + _ => Err(settings_bad_request( + "invalid_merge_request_state", + "state must be one of open, merged, or closed", + )), + }) + .transpose()?; + let store = merge_request_store(&api, &workspace_id)?; + let page = store.list( + &workspace_id, + &merge_request::MergeRequestListQuery { + state, + repository_id: query.repository_id, + ticket_id, + selector_from: query.selector_from, + selector_to: query.selector_to, + cursor: query.cursor, + limit: query.limit.unwrap_or(50), + }, + )?; + let reader = api.repository_reader(); + let items = page + .items + .into_iter() + .map(|merge_request| { + let current_subject_ref = merge_request.selector_from.as_deref().and_then(|selector| { + reader + .observe_merge_target(&merge_request.repository_id, Some(selector)) + .ok() + .map(|observation| observation.commit) + }); + let ticket_ids = merge_request.ticket_ids.clone(); + let thread_event_count = merge_request.thread.len(); + MergeRequestListItem { + summary: merge_request_summary(merge_request, current_subject_ref), + ticket_ids, + thread_event_count, + } + }) + .collect(); + Ok(Json(MergeRequestListResponse { + items, + next_cursor: page.next_cursor, + })) +} + async fn scoped_show_merge_request( State(api): State, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, -) -> ApiResult> { + AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, + Query(query): Query, +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; - let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; + require_workspace_access(&workspace_id, &api)?; let store = merge_request_store(&api, &workspace_id)?; - let mut mr = store.get(&workspace_id, &ticket_id)?; - mr.thread = store.thread_page(&workspace_id, &ticket_id, None, 100)?; + let mut mr = store.get_by_id(&workspace_id, &merge_request_id)?; + mr.thread = store.thread_page_by_id( + &workspace_id, + &merge_request_id, + query.after, + query.limit.unwrap_or(100), + )?; let reader = api.repository_reader(); let observed_at = Utc::now().to_rfc3339(); let source = match mr.selector_from.as_deref() { Some(selector) => match reader.observe_merge_target(&mr.repository_id, Some(selector)) { - Ok(value) => { - serde_json::json!({"status":"known","ref":value.commit,"observed_at":observed_at}) - } - Err(_) => serde_json::json!({"status":"unknown","observed_at":observed_at}), + Ok(value) => MergeRequestRefResponse { + status: "known".into(), + revision_ref: Some(value.commit), + observed_at: observed_at.clone(), + }, + Err(_) => MergeRequestRefResponse { + status: "unknown".into(), + revision_ref: None, + observed_at: observed_at.clone(), + }, + }, + None => MergeRequestRefResponse { + status: "requires_repair".into(), + revision_ref: None, + observed_at: observed_at.clone(), }, - None => serde_json::json!({"status":"requires_repair","observed_at":observed_at}), }; let target = match reader.observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) { - Ok(value) => { - serde_json::json!({"status":"known","ref":value.commit,"observed_at":observed_at}) - } - Err(_) => serde_json::json!({"status":"unknown","observed_at":observed_at}), + Ok(value) => MergeRequestRefResponse { + status: "known".into(), + revision_ref: Some(value.commit), + observed_at, + }, + Err(_) => MergeRequestRefResponse { + status: "unknown".into(), + revision_ref: None, + observed_at, + }, }; - let mut response = - serde_json::to_value(mr).map_err(|error| Error::InvalidInput(error.to_string()))?; - if let Some(object) = response.as_object_mut() { - object.insert("source".into(), source); - object.insert("target".into(), target); - } - Ok(Json(response)) + let linked_tickets = mr + .ticket_ids + .iter() + .map(|ticket_id| { + Ok(MergeRequestLinkedTicketResponse { + ticket_id: ticket_id.clone(), + key: api.store.resource_human_key( + &workspace_id, + WorkspaceResourceKind::Ticket, + ticket_id, + )?, + }) + }) + .collect::>>()?; + Ok(Json(MergeRequestDetailResponse { + merge_request: mr, + source, + target, + linked_tickets, + })) } async fn scoped_merge_request_readiness( @@ -17849,6 +17991,67 @@ mod tests { ); } + #[tokio::test] + async fn merge_request_reads_use_first_class_workspace_resources() { + let dir = tempfile::tempdir().unwrap(); + let app = build_router(test_api(dir.path()).await); + + let collection = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/w/{TEST_WORKSPACE_ID}/merge-requests")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(collection.status(), StatusCode::OK); + let body = to_bytes(collection.into_body(), usize::MAX).await.unwrap(); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["items"], json!([])); + assert!(body["next_cursor"].is_null()); + + let missing = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/w/{TEST_WORKSPACE_ID}/merge-requests/missing")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + + let nested = app + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/T-1/merge-request" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(nested.status(), StatusCode::METHOD_NOT_ALLOWED); + + let invalid_filter = app + .oneshot( + Request::builder() + .uri(format!( + "/api/w/{TEST_WORKSPACE_ID}/merge-requests?state=unknown" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(invalid_filter.status(), StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn ticket_rest_search_requires_worker_source_identity_and_rpc_route_is_removed() { let dir = tempfile::tempdir().unwrap(); diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 0867cc8b..4bcefbed 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts", + "test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index cfa0c459..b3424f6f 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -90,6 +90,17 @@ export type TicketMergeRequestSummary = { review_excerpt: string | null; }; +export type MergeRequestListItem = { + summary: TicketMergeRequestSummary; + ticket_ids: Array; + thread_event_count: number; +}; + +export type MergeRequestListResponse = { + items: Array; + next_cursor: string | null; +}; + export type TicketEvidenceSummary = { has_merge_request: boolean; has_current_subject_ref: boolean; diff --git a/web/workspace/src/lib/workspace/api/merge-requests.ts b/web/workspace/src/lib/workspace/api/merge-requests.ts new file mode 100644 index 00000000..db61cc7e --- /dev/null +++ b/web/workspace/src/lib/workspace/api/merge-requests.ts @@ -0,0 +1,69 @@ +import type { MergeRequestListResponse } from "$lib/generated/ticket-api"; +import { workspaceApiPath } from "./http"; + +export type MergeRequestState = "open" | "merged" | "closed"; + +export type MergeRequestActor = { + runtime_id: string; + worker_id: string; + assignment_id: string; +}; + +export type MergeRequestThreadEvent = { + kind: string; + sequence: number; + at: string; + [key: string]: unknown; +}; + +export type MergeRequestRecord = { + merge_request_id: string; + workspace_id: string; + repository_id: string; + selector_from: string | null; + selector_to: string; + ticket_ids: string[]; + state: MergeRequestState; + opened_by: MergeRequestActor; + created_at: string; + updated_at: string; + thread: MergeRequestThreadEvent[]; +}; + +export type MergeRequestRefObservation = { + status: string; + ref: string | null; + observed_at: string; +}; + +export type MergeRequestDetail = MergeRequestRecord & { + source: MergeRequestRefObservation; + target: MergeRequestRefObservation; + linked_tickets: Array<{ ticket_id: string; key: string | null }>; +}; + +export type MergeRequestListPage = MergeRequestListResponse; + +export function mergeRequestCollectionPath(workspaceId: string): string { + return workspaceApiPath(workspaceId, "/merge-requests"); +} + +export function mergeRequestDetailPath( + workspaceId: string, + mergeRequestId: string, +): string { + return workspaceApiPath( + workspaceId, + `/merge-requests/${encodeURIComponent(mergeRequestId)}`, + ); +} + +export function mergeRequestPagePath( + workspaceId: string, + mergeRequestId?: string, +): string { + const root = `/w/${encodeURIComponent(workspaceId)}/merge-requests`; + return mergeRequestId + ? `${root}/${encodeURIComponent(mergeRequestId)}` + : root; +} diff --git a/web/workspace/src/lib/workspace/sidebar/MergeRequestsNavSection.svelte b/web/workspace/src/lib/workspace/sidebar/MergeRequestsNavSection.svelte new file mode 100644 index 00000000..06221384 --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/MergeRequestsNavSection.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte index b5c8f75c..febd1ff8 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte @@ -2,6 +2,7 @@ import './sidebar.css'; import ObjectivesNavSection from './ObjectivesNavSection.svelte'; import MemoryNavSection from './MemoryNavSection.svelte'; + import MergeRequestsNavSection from './MergeRequestsNavSection.svelte'; import TicketsNavSection from './TicketsNavSection.svelte'; import WorkersNavSection from './WorkersNavSection.svelte'; import WorkspaceSwitcher from './WorkspaceSwitcher.svelte'; @@ -41,6 +42,7 @@ + diff --git a/web/workspace/src/lib/workspace/tickets/merge-request-resources.test.ts b/web/workspace/src/lib/workspace/tickets/merge-request-resources.test.ts new file mode 100644 index 00000000..8cb854c5 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/merge-request-resources.test.ts @@ -0,0 +1,66 @@ +declare const Deno: { + test(name: string, fn: () => Promise | void): void; + readTextFile(path: string | URL): Promise; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +const ticketLoader = await Deno.readTextFile( + new URL( + "../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.ts", + import.meta.url, + ), +); +const ticketPage = await Deno.readTextFile( + new URL( + "../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte", + import.meta.url, + ), +); +const listLoader = await Deno.readTextFile( + new URL( + "../../../routes/w/[workspaceId]/merge-requests/+page.ts", + import.meta.url, + ), +); +const detailLoader = await Deno.readTextFile( + new URL( + "../../../routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.ts", + import.meta.url, + ), +); +const sidebar = await Deno.readTextFile( + new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url), +); + +Deno.test("Ticket detail links to a first-class Merge Request resource", () => { + assert( + !ticketLoader.includes("`${ticketPath}/merge-request`"), + "Ticket loader still locates MR detail through a Ticket route", + ); + assert( + ticketPage.includes( + "mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)", + ), + "Ticket panel does not link to the MR resource identity", + ); +}); + +Deno.test("Workspace exposes Merge Request collection and detail pages", () => { + assert( + listLoader.includes("mergeRequestCollectionPath(params.workspaceId)"), + "missing MR list API", + ); + assert( + detailLoader.includes( + "mergeRequestDetailPath(params.workspaceId, params.mergeRequestId)", + ), + "missing MR detail API", + ); + assert( + sidebar.includes("MergeRequestsNavSection"), + "MR resources are absent from navigation", + ); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.svelte new file mode 100644 index 00000000..d74ae972 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.svelte @@ -0,0 +1,56 @@ + + +Merge Requests · Yoi + +
+
+
+

Workspace resources

+

Merge Requests

+
+ {data.mergeRequests?.items.length ?? 0} +
+ + {#if data.error} +

{data.error}

+ {:else} + + {/if} +
diff --git a/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.ts b/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.ts new file mode 100644 index 00000000..c8639c92 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.ts @@ -0,0 +1,33 @@ +import type { PageLoad } from "./$types"; +import { + mergeRequestCollectionPath, + type MergeRequestListPage, +} from "$lib/workspace/api/merge-requests"; + +export const load: PageLoad = async ({ params, fetch }) => { + try { + const response = await fetch( + `${mergeRequestCollectionPath(params.workspaceId)}?limit=100`, + ); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + return { + workspaceId: params.workspaceId, + mergeRequests: null, + error: body?.error ?? body?.message ?? + `Request failed (${response.status})`, + }; + } + return { + workspaceId: params.workspaceId, + mergeRequests: body as MergeRequestListPage, + error: null, + }; + } catch (error) { + return { + workspaceId: params.workspaceId, + mergeRequests: null, + error: error instanceof Error ? error.message : String(error), + }; + } +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte new file mode 100644 index 00000000..c4a8d7be --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte @@ -0,0 +1,104 @@ + + +Merge Request · Yoi + +
+
+
+

Merge Request

+

{data.mergeRequest?.merge_request_id ?? data.mergeRequestId}

+ {#if data.mergeRequest} +

+ {data.mergeRequest.selector_from ?? "Source selector requires repair"} + → {data.mergeRequest.selector_to} +

+ {/if} +
+ + All Merge Requests + +
+ + {#if data.error} +

{data.error}

+ {:else if data.mergeRequest} + {@const mergeRequest = data.mergeRequest} +
+
+
+

Selectors

+
+
Repository
{mergeRequest.repository_id}
+
State
{mergeRequest.state}
+
Source selector
{mergeRequest.selector_from ?? "requires repair"}
+
Source revision
{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}
+
Target selector
{mergeRequest.selector_to}
+
Target revision
{mergeRequest.target.status}{mergeRequest.target.ref ? ` · ${mergeRequest.target.ref}` : ""}
+
Updated
{prettyDate(mergeRequest.updated_at)}
+
+
+ +
+
+

Thread

{mergeRequest.thread.length} +
+
+ {#each mergeRequest.thread as event (event.sequence)} +
+
+
+
+ {event.kind} + +
+ {#if textField(event, "subject_ref")} +

{textField(event, "subject_ref")}

+ {/if} + {#if textField(event, "decision")}

{textField(event, "decision")}

{/if} + {#if textField(event, "reason")}

{textField(event, "reason")}

{/if} + {#if textField(event, "body")}

{textField(event, "body")}

{/if} +
+
+ {:else} +

No Merge Request events.

+ {/each} +
+
+
+ + +
+ {/if} +
diff --git a/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.ts new file mode 100644 index 00000000..0212b709 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.ts @@ -0,0 +1,38 @@ +import type { PageLoad } from "./$types"; +import { + type MergeRequestDetail, + mergeRequestDetailPath, +} from "$lib/workspace/api/merge-requests"; + +export const load: PageLoad = async ({ params, fetch }) => { + try { + const response = await fetch( + `${ + mergeRequestDetailPath(params.workspaceId, params.mergeRequestId) + }?limit=100`, + ); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + return { + workspaceId: params.workspaceId, + mergeRequestId: params.mergeRequestId, + mergeRequest: null, + error: body?.error ?? body?.message ?? + `Request failed (${response.status})`, + }; + } + return { + workspaceId: params.workspaceId, + mergeRequestId: params.mergeRequestId, + mergeRequest: body as MergeRequestDetail, + error: null, + }; + } catch (error) { + return { + workspaceId: params.workspaceId, + mergeRequestId: params.mergeRequestId, + mergeRequest: null, + error: error instanceof Error ? error.message : String(error), + }; + } +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index f7361ae5..22a457b3 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -5,6 +5,7 @@ workspaceApiJsonWithBody, workspaceApiPath, } from "$lib/workspace/api/http"; + import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests"; import { relationLabel, TICKET_STATES, @@ -18,54 +19,6 @@ TicketDetail, } from "$lib/workspace/sidebar/types"; - type MergeRequestThreadEvent = - | { - kind: "review_requested"; - event_id: string; - sequence: number; - subject_ref: string; - requested_by: { runtime_id: string; worker_id: string }; - reviewer: { runtime_id: string; worker_id: string }; - } - | { - kind: "review"; - event_id: string; - sequence: number; - request_event_id: string; - subject_ref: string; - decision: "approve" | "request_changes"; - body: string; - reviewer: { runtime_id: string; worker_id: string }; - } - | { kind: "review_revoked"; sequence: number; review_event_id: string; reason: string } - | { kind: "review_cancelled"; sequence: number; request_event_id: string; reason: string } - | { - kind: "comment"; - sequence: number; - body: string; - author: { runtime_id: string; worker_id: string }; - } - | { - kind: "merge"; - sequence: number; - approval_event_id: string; - approved_source_ref: string; - target_ref_after: string; - strategy: "fast_forward" | "merge"; - resolution: "none" | "clean" | "conflicts_resolved"; - merged_by: { runtime_id: string; worker_id: string }; - }; - - type RefProjection = { status: "known" | "unknown" | "requires_repair"; ref?: string }; - type MergeRequestDetail = { - state: "open" | "closed" | "merged"; - selector_from: string | null; - selector_to: string; - source: RefProjection; - target: RefProjection; - thread: MergeRequestThreadEvent[]; - }; - const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done" && state !== "ready" && state !== "queued" ); @@ -77,7 +30,6 @@ ticket: ApiResult; repositories: ApiResult; orchestrator: ApiResult; - mergeRequest: ApiResult; }; }>(); @@ -88,24 +40,7 @@ const orchestratorOnline = initialData.orchestrator.data?.online ?? false; let ticket = $state(loadedTicket); - let mergeRequest = $state(initialData.mergeRequest.data ?? null); - const currentReviewRequest = $derived( - mergeRequest?.thread.findLast((event) => event.kind === "review_requested") ?? null, - ); - const currentReview = $derived.by(() => { - if (mergeRequest?.source.status !== "known") return null; - const review = mergeRequest.thread.findLast( - (event) => event.kind === "review" && event.subject_ref === mergeRequest.source.ref, - ); - if (!review || review.kind !== "review") return null; - const revoked = mergeRequest.thread.some( - (event) => event.kind === "review_revoked" && event.review_event_id === review.event_id, - ); - return revoked ? null : review; - }); - const mergeEvent = $derived( - mergeRequest?.thread.findLast((event) => event.kind === "merge") ?? null, - ); + const mergeRequest = $derived(ticket.merge_request); let editing = $state(false); let editTitle = $state(loadedTicket.title); let editBody = $state(loadedTicket.body); @@ -458,41 +393,19 @@

Merge Request

- {#if data.mergeRequest.error} -

{data.mergeRequest.error}

- {:else if mergeRequest} -

{mergeRequest.state}

-

From {mergeRequest.selector_from ?? "requires repair"} · {mergeRequest.source.status}{mergeRequest.source.ref ? ` @ ${mergeRequest.source.ref}` : ""}

-

To {mergeRequest.selector_to} · {mergeRequest.target.status}{mergeRequest.target.ref ? ` @ ${mergeRequest.target.ref}` : ""}

- {#if currentReviewRequest?.kind === "review_requested"} -

Review requested for {currentReviewRequest.subject_ref}

+ {#if mergeRequest} +

{mergeRequest.state} · review {mergeRequest.review_status}

+

+ From {mergeRequest.selector_from ?? "requires repair"} + to {mergeRequest.selector_to} +

+ {#if mergeRequest.current_subject_ref} +

Current source {mergeRequest.current_subject_ref}

{/if} - {#if currentReview?.kind === "review"} -

{currentReview.decision} by {currentReview.reviewer.runtime_id}/{currentReview.reviewer.worker_id}

- {#if currentReview.body}{/if} - {/if} - {#if mergeEvent?.kind === "merge"} -

Final merge · {mergeEvent.strategy} / {mergeEvent.resolution}

-

Target ref {mergeEvent.target_ref_after}

-

Completed by {mergeEvent.merged_by.runtime_id}/{mergeEvent.merged_by.worker_id}

- {/if} -

Thread

- {#each mergeRequest.thread as event (event.sequence)} -

- #{event.sequence} · {event.kind} - {#if event.kind === "review_requested"} - · {event.subject_ref} · {event.requested_by.runtime_id}/{event.requested_by.worker_id} - {:else if event.kind === "review"} - · {event.subject_ref} · {event.reviewer.runtime_id}/{event.reviewer.worker_id} - {:else if event.kind === "comment"} - · {event.author.runtime_id}/{event.author.worker_id} · {event.body} - {:else if event.kind === "review_cancelled" || event.kind === "review_revoked"} - · {event.reason} - {:else if event.kind === "merge"} - · approval {event.approval_event_id} - {/if} -

- {/each} + Open Merge Request {:else}

The assigned Coder has not opened a Merge Request.

{/if} diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts index d1716bc8..c8e9ebba 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts @@ -5,34 +5,48 @@ import { resourceHumanKey, } from "$lib/workspace/resource-links"; import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel"; -import type { RepositoryListResponse, TicketDetail } from "$lib/workspace/sidebar/types"; +import type { + RepositoryListResponse, + TicketDetail, +} from "$lib/workspace/sidebar/types"; import type { PageLoad } from "./$types"; -async function loadOptionalJson(fetcher: typeof fetch, path: string): Promise<{ data: T | null; error: string | null }> { - try { - const response = await fetcher(path); - if (response.status === 404) return { data: null, error: null }; - if (!response.ok) return { data: null, error: await response.text() || `HTTP ${response.status}` }; - return { data: await response.json() as T, error: null }; - } catch (error) { - return { data: null, error: error instanceof Error ? error.message : String(error) }; - } -} - export const load = (async ({ fetch, params }) => { const reference = resourceHumanKey(params.ticketId); - const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(reference)}`); - const [ticket, repositories, orchestrator, mergeRequest] = await Promise.all([ + const ticketPath = workspaceApiPath( + params.workspaceId, + `/tickets/${encodeURIComponent(reference)}`, + ); + const [ticket, repositories, orchestrator] = await Promise.all([ loadJson(fetch, ticketPath), - loadJson(fetch, workspaceApiPath(params.workspaceId, "/repositories")), - loadJson(fetch, workspaceApiPath(params.workspaceId, "/orchestrator")), - loadOptionalJson>(fetch, `${ticketPath}/merge-request`), + loadJson( + fetch, + workspaceApiPath(params.workspaceId, "/repositories"), + ), + loadJson( + fetch, + workspaceApiPath(params.workspaceId, "/orchestrator"), + ), ]); if (ticket.data) { - const canonical = canonicalResourceReference(ticket.data.human_key, ticket.data.title); + const canonical = canonicalResourceReference( + ticket.data.human_key, + ticket.data.title, + ); if (params.ticketId !== canonical) { - redirect(308, `/w/${encodeURIComponent(params.workspaceId)}/tickets/${encodeURIComponent(canonical)}`); + redirect( + 308, + `/w/${encodeURIComponent(params.workspaceId)}/tickets/${ + encodeURIComponent(canonical) + }`, + ); } } - return { workspaceId: params.workspaceId, ticketId: ticket.data?.id ?? reference, ticket, repositories, orchestrator, mergeRequest }; + return { + workspaceId: params.workspaceId, + ticketId: ticket.data?.id ?? reference, + ticket, + repositories, + orchestrator, + }; }) satisfies PageLoad;