feat: expose merge requests as workspace resources

This commit is contained in:
2026-08-21 11:31:45 +09:00
parent ccb9d03865
commit 0cd5ca11cc
17 changed files with 880 additions and 148 deletions
+118
View File
@@ -27,6 +27,14 @@ pub enum MergeRequestState {
Closed, Closed,
} }
impl MergeRequestState { impl MergeRequestState {
fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Merged => "merged",
Self::Closed => "closed",
}
}
fn parse(v: &str) -> Result<Self, MergeRequestError> { fn parse(v: &str) -> Result<Self, MergeRequestError> {
match v { match v {
"draft" | "open" => Ok(Self::Open), "draft" | "open" => Ok(Self::Open),
@@ -216,6 +224,23 @@ impl MergeRequest {
} }
} }
#[derive(Clone, Debug, Default)]
pub struct MergeRequestListQuery {
pub state: Option<MergeRequestState>,
pub repository_id: Option<String>,
pub ticket_id: Option<String>,
pub selector_from: Option<String>,
pub selector_to: Option<String>,
pub cursor: Option<String>,
pub limit: usize,
}
#[derive(Clone, Debug)]
pub struct MergeRequestListPage {
pub items: Vec<MergeRequest>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct OpenMergeRequest { pub struct OpenMergeRequest {
pub merge_request_id: String, pub merge_request_id: String,
@@ -935,6 +960,80 @@ impl MergeRequestStore {
None => Err(MergeRequestError::NotFound), None => Err(MergeRequestError::NotFound),
} }
} }
pub fn get_by_id(
&self,
workspace_id: &str,
merge_request_id: &str,
) -> Result<MergeRequest, MergeRequestError> {
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<MergeRequestListPage, MergeRequestError> {
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<?7 OR (mr.updated_at=?7 AND mr.merge_request_id>?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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?;
Ok(MergeRequestListPage { items, next_cursor })
}
pub fn thread_page( pub fn thread_page(
&self, &self,
ws: &str, ws: &str,
@@ -946,6 +1045,25 @@ impl MergeRequestStore {
let c = self.lock()?; let c = self.lock()?;
load_thread(&c, ws, &mr.merge_request_id, after, limit.clamp(1, 200)) 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<u64>,
limit: usize,
) -> Result<Vec<MergeRequestThreadEvent>, 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> { fn assigned(&self, a: &MergeRequestAuth, t: &str, r: &str) -> Result<(), MergeRequestError> {
self.repo(a, r)?; self.repo(a, r)?;
let x = self let x = self
+69
View File
@@ -436,6 +436,75 @@ fn selector_repair_rejects_unapproved_resolved_subject() {
assert!(matches!(result, Err(MergeRequestError::NotReady(_)))); 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] #[test]
fn transactional_completion_rejects_assignment_changed_in_control_plane_db() { fn transactional_completion_rejects_assignment_changed_in_control_plane_db() {
let (dir, store) = fixture(); let (dir, store) = fixture();
+1 -1
View File
@@ -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, request: MergeRequest,
current_subject_ref: Option<String>, current_subject_ref: Option<String>,
) -> TicketMergeRequestSummary { ) -> TicketMergeRequestSummary {
+17
View File
@@ -285,6 +285,21 @@ pub struct TicketMergeRequestSummary {
pub review_excerpt: Option<String>, pub review_excerpt: Option<String>,
} }
#[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<String>,
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<MergeRequestListItem>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketEvidenceSummary { pub struct TicketEvidenceSummary {
@@ -474,6 +489,8 @@ pub fn ticket_api_typescript() -> String {
TicketEvidenceEvent::decl(&config), TicketEvidenceEvent::decl(&config),
TicketAssignmentSummary::decl(&config), TicketAssignmentSummary::decl(&config),
TicketMergeRequestSummary::decl(&config), TicketMergeRequestSummary::decl(&config),
MergeRequestListItem::decl(&config),
MergeRequestListResponse::decl(&config),
TicketEvidenceSummary::decl(&config), TicketEvidenceSummary::decl(&config),
TicketQueryRequest::decl(&config), TicketQueryRequest::decl(&config),
TicketQueryItem::decl(&config), TicketQueryItem::decl(&config),
+228 -25
View File
@@ -65,7 +65,7 @@ use crate::auth::{
}; };
use crate::authority::{ use crate::authority::{
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput, MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput,
SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, merge_request_summary,
}; };
use crate::companion::{ use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
@@ -97,8 +97,9 @@ use crate::observation::{
}; };
use crate::profile_settings::UpdateWorkspaceMetadataRequest; use crate::profile_settings::UpdateWorkspaceMetadataRequest;
use crate::records::{ use crate::records::{
ObjectiveDetail, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveShowRequest, MergeRequestListItem, MergeRequestListResponse, ObjectiveDetail, ObjectiveQueryRequest,
ProjectRecordList, TicketDetail, TicketQueryRequest, TicketQueryResponse, TicketShowRequest, ObjectiveQueryResponse, ObjectiveShowRequest, ProjectRecordList, TicketDetail,
TicketQueryRequest, TicketQueryResponse, TicketShowRequest,
}; };
use crate::repositories::{ use crate::repositories::{
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
@@ -1729,9 +1730,17 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/tickets/{id}/workflow/queue", "/api/w/{workspace_id}/tickets/{id}/workflow/queue",
post(scoped_queue_ticket_record), 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( .route(
"/api/w/{workspace_id}/tickets/{id}/merge-request", "/api/w/{workspace_id}/tickets/{id}/merge-request",
get(scoped_show_merge_request).post(scoped_open_merge_request), post(scoped_open_merge_request),
) )
.route( .route(
"/api/w/{workspace_id}/tickets/{id}/merge-request/readiness", "/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()) .ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into())
} }
#[derive(Debug, serde::Deserialize)]
struct MergeRequestListHttpQuery {
state: Option<String>,
repository_id: Option<String>,
ticket_ref: Option<String>,
selector_from: Option<String>,
selector_to: Option<String>,
cursor: Option<String>,
limit: Option<usize>,
}
#[derive(Debug, serde::Serialize)]
struct MergeRequestRefResponse {
status: String,
#[serde(rename = "ref")]
revision_ref: Option<String>,
observed_at: String,
}
#[derive(Debug, serde::Serialize)]
struct MergeRequestLinkedTicketResponse {
ticket_id: String,
key: Option<String>,
}
#[derive(Debug, serde::Serialize)]
struct MergeRequestDetailResponse {
#[serde(flatten)]
merge_request: merge_request::MergeRequest,
source: MergeRequestRefResponse,
target: MergeRequestRefResponse,
linked_tickets: Vec<MergeRequestLinkedTicketResponse>,
}
async fn scoped_list_merge_requests(
State(api): State<WorkspaceApi>,
AxumPath(workspace_id): AxumPath<String>,
Query(query): Query<MergeRequestListHttpQuery>,
) -> ApiResult<Json<MergeRequestListResponse>> {
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( async fn scoped_show_merge_request(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<serde_json::Value>> { Query(query): Query<MergeRequestThreadQuery>,
) -> ApiResult<Json<MergeRequestDetailResponse>> {
let workspace_id = parse_workspace_id(&workspace_id)?; 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 store = merge_request_store(&api, &workspace_id)?;
let mut mr = store.get(&workspace_id, &ticket_id)?; let mut mr = store.get_by_id(&workspace_id, &merge_request_id)?;
mr.thread = store.thread_page(&workspace_id, &ticket_id, None, 100)?; 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 reader = api.repository_reader();
let observed_at = Utc::now().to_rfc3339(); let observed_at = Utc::now().to_rfc3339();
let source = match mr.selector_from.as_deref() { let source = match mr.selector_from.as_deref() {
Some(selector) => match reader.observe_merge_target(&mr.repository_id, Some(selector)) { Some(selector) => match reader.observe_merge_target(&mr.repository_id, Some(selector)) {
Ok(value) => { Ok(value) => MergeRequestRefResponse {
serde_json::json!({"status":"known","ref":value.commit,"observed_at":observed_at}) status: "known".into(),
} revision_ref: Some(value.commit),
Err(_) => serde_json::json!({"status":"unknown","observed_at":observed_at}), 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)) { let target = match reader.observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) {
Ok(value) => { Ok(value) => MergeRequestRefResponse {
serde_json::json!({"status":"known","ref":value.commit,"observed_at":observed_at}) status: "known".into(),
} revision_ref: Some(value.commit),
Err(_) => serde_json::json!({"status":"unknown","observed_at":observed_at}), observed_at,
},
Err(_) => MergeRequestRefResponse {
status: "unknown".into(),
revision_ref: None,
observed_at,
},
}; };
let mut response = let linked_tickets = mr
serde_json::to_value(mr).map_err(|error| Error::InvalidInput(error.to_string()))?; .ticket_ids
if let Some(object) = response.as_object_mut() { .iter()
object.insert("source".into(), source); .map(|ticket_id| {
object.insert("target".into(), target); Ok(MergeRequestLinkedTicketResponse {
} ticket_id: ticket_id.clone(),
Ok(Json(response)) key: api.store.resource_human_key(
&workspace_id,
WorkspaceResourceKind::Ticket,
ticket_id,
)?,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(Json(MergeRequestDetailResponse {
merge_request: mr,
source,
target,
linked_tickets,
}))
} }
async fn scoped_merge_request_readiness( 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] #[tokio::test]
async fn ticket_rest_search_requires_worker_source_identity_and_rpc_route_is_removed() { async fn ticket_rest_search_requires_worker_source_identity_and_rpc_route_is_removed() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "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", "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", "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", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -90,6 +90,17 @@ export type TicketMergeRequestSummary = {
review_excerpt: string | null; review_excerpt: string | null;
}; };
export type MergeRequestListItem = {
summary: TicketMergeRequestSummary;
ticket_ids: Array<string>;
thread_event_count: number;
};
export type MergeRequestListResponse = {
items: Array<MergeRequestListItem>;
next_cursor: string | null;
};
export type TicketEvidenceSummary = { export type TicketEvidenceSummary = {
has_merge_request: boolean; has_merge_request: boolean;
has_current_subject_ref: boolean; has_current_subject_ref: boolean;
@@ -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;
}
@@ -0,0 +1,19 @@
<script lang="ts">
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
type Props = {
currentPath?: string;
workspaceId: string;
};
let { currentPath = "/", workspaceId }: Props = $props();
let href = $derived(workspaceId ? mergeRequestPagePath(workspaceId) : "/");
</script>
<section class="nav-section">
<header class="section-header"><span>Merge Requests</span></header>
<a class="objective-link" class:active={currentPath.startsWith(href)} {href}>
<span class="item-title">All Merge Requests</span>
<span class="item-meta">review and integration resources</span>
</a>
</section>
@@ -2,6 +2,7 @@
import './sidebar.css'; import './sidebar.css';
import ObjectivesNavSection from './ObjectivesNavSection.svelte'; import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import MemoryNavSection from './MemoryNavSection.svelte'; import MemoryNavSection from './MemoryNavSection.svelte';
import MergeRequestsNavSection from './MergeRequestsNavSection.svelte';
import TicketsNavSection from './TicketsNavSection.svelte'; import TicketsNavSection from './TicketsNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte'; import WorkersNavSection from './WorkersNavSection.svelte';
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte'; import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
@@ -41,6 +42,7 @@
<TicketsNavSection {currentPath} {workspaceId} /> <TicketsNavSection {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} /> <ObjectivesNavSection {currentPath} {workspaceId} />
<MemoryNavSection {currentPath} {workspaceId} /> <MemoryNavSection {currentPath} {workspaceId} />
<MergeRequestsNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} /> <WorkersNavSection {currentPath} {workspaceId} />
</nav> </nav>
</div> </div>
@@ -0,0 +1,66 @@
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: string | URL): Promise<string>;
};
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",
);
});
@@ -0,0 +1,56 @@
<script lang="ts">
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
function prettyDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
</script>
<svelte:head><title>Merge Requests · Yoi</title></svelte:head>
<div class="workspace-page">
<header class="workspace-page-header">
<div>
<p class="workspace-eyebrow">Workspace resources</p>
<h1>Merge Requests</h1>
</div>
<span class="workspace-count">{data.mergeRequests?.items.length ?? 0}</span>
</header>
{#if data.error}
<p class="workspace-callout is-error">{data.error}</p>
{:else}
<div class="ticket-list" aria-label="Merge Requests">
{#each data.mergeRequests?.items ?? [] as item (item.summary.merge_request_id)}
{@const mergeRequest = item.summary}
<a
class="ticket-row"
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
>
<div class="ticket-main">
<div class="ticket-title-row">
<span class="ticket-key">{mergeRequest.merge_request_id}</span>
<strong class="ticket-title">
{mergeRequest.selector_from ?? "Source selector requires repair"}
{mergeRequest.selector_to}
</strong>
</div>
<p class="ticket-summary">
Repository {mergeRequest.repository_id} · {item.ticket_ids.length} linked Ticket{item.ticket_ids.length === 1 ? "" : "s"} · review {mergeRequest.review_status}
</p>
</div>
<div class="ticket-meta">
<span class={`ticket-state state-${mergeRequest.state}`}>{mergeRequest.state}</span>
<time>{prettyDate(mergeRequest.updated_at)}</time>
</div>
</a>
{:else}
<p class="workspace-empty-copy">No Merge Requests.</p>
{/each}
</div>
{/if}
</div>
@@ -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),
};
}
};
@@ -0,0 +1,104 @@
<script lang="ts">
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
function prettyDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function textField(event: Record<string, unknown>, key: string): string | null {
const value = event[key];
return typeof value === "string" && value.length > 0 ? value : null;
}
</script>
<svelte:head><title>Merge Request · Yoi</title></svelte:head>
<div class="workspace-page">
<header class="workspace-page-header">
<div>
<p class="workspace-eyebrow">Merge Request</p>
<h1>{data.mergeRequest?.merge_request_id ?? data.mergeRequestId}</h1>
{#if data.mergeRequest}
<p class="workspace-page-lede">
{data.mergeRequest.selector_from ?? "Source selector requires repair"}
{data.mergeRequest.selector_to}
</p>
{/if}
</div>
<a class="workspace-secondary-button" href={mergeRequestPagePath(data.workspaceId)}>
All Merge Requests
</a>
</header>
{#if data.error}
<p class="workspace-callout is-error">{data.error}</p>
{:else if data.mergeRequest}
{@const mergeRequest = data.mergeRequest}
<div class="ticket-detail-grid">
<main class="ticket-detail-main">
<section class="ticket-detail-section">
<div class="ticket-section-heading"><h2>Selectors</h2></div>
<dl class="ticket-facts">
<div><dt>Repository</dt><dd>{mergeRequest.repository_id}</dd></div>
<div><dt>State</dt><dd>{mergeRequest.state}</dd></div>
<div><dt>Source selector</dt><dd><code>{mergeRequest.selector_from ?? "requires repair"}</code></dd></div>
<div><dt>Source revision</dt><dd>{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}</dd></div>
<div><dt>Target selector</dt><dd><code>{mergeRequest.selector_to}</code></dd></div>
<div><dt>Target revision</dt><dd>{mergeRequest.target.status}{mergeRequest.target.ref ? ` · ${mergeRequest.target.ref}` : ""}</dd></div>
<div><dt>Updated</dt><dd>{prettyDate(mergeRequest.updated_at)}</dd></div>
</dl>
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Thread</h2><span>{mergeRequest.thread.length}</span>
</div>
<div class="ticket-timeline">
{#each mergeRequest.thread as event (event.sequence)}
<article>
<div class="ticket-timeline-marker"></div>
<div>
<header>
<strong>{event.kind}</strong>
<time>{prettyDate(event.at)}</time>
</header>
{#if textField(event, "subject_ref")}
<p><code>{textField(event, "subject_ref")}</code></p>
{/if}
{#if textField(event, "decision")}<p>{textField(event, "decision")}</p>{/if}
{#if textField(event, "reason")}<p>{textField(event, "reason")}</p>{/if}
{#if textField(event, "body")}<p>{textField(event, "body")}</p>{/if}
</div>
</article>
{:else}
<p class="workspace-empty-copy">No Merge Request events.</p>
{/each}
</div>
</section>
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card">
<header><h2>Linked Tickets</h2></header>
{#each mergeRequest.linked_tickets as linkedTicket}
{#if linkedTicket.key}
<p>
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(linkedTicket.key)}`}>
{linkedTicket.key}
</a>
</p>
{:else}
<p class="workspace-empty-copy">Linked Ticket key unavailable.</p>
{/if}
{:else}
<p class="workspace-empty-copy">No linked Tickets.</p>
{/each}
</section>
</aside>
</div>
{/if}
</div>
@@ -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),
};
}
};
@@ -5,6 +5,7 @@
workspaceApiJsonWithBody, workspaceApiJsonWithBody,
workspaceApiPath, workspaceApiPath,
} from "$lib/workspace/api/http"; } from "$lib/workspace/api/http";
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
import { import {
relationLabel, relationLabel,
TICKET_STATES, TICKET_STATES,
@@ -18,54 +19,6 @@
TicketDetail, TicketDetail,
} from "$lib/workspace/sidebar/types"; } 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) => const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
state !== "done" && state !== "ready" && state !== "queued" state !== "done" && state !== "ready" && state !== "queued"
); );
@@ -77,7 +30,6 @@
ticket: ApiResult<TicketDetail>; ticket: ApiResult<TicketDetail>;
repositories: ApiResult<RepositoryListResponse>; repositories: ApiResult<RepositoryListResponse>;
orchestrator: ApiResult<WorkspaceOrchestratorStatus>; orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
mergeRequest: ApiResult<MergeRequestDetail | null>;
}; };
}>(); }>();
@@ -88,24 +40,7 @@
const orchestratorOnline = initialData.orchestrator.data?.online ?? false; const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
let ticket = $state<TicketDetail>(loadedTicket); let ticket = $state<TicketDetail>(loadedTicket);
let mergeRequest = $state<MergeRequestDetail | null>(initialData.mergeRequest.data ?? null); const mergeRequest = $derived(ticket.merge_request);
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,
);
let editing = $state(false); let editing = $state(false);
let editTitle = $state(loadedTicket.title); let editTitle = $state(loadedTicket.title);
let editBody = $state(loadedTicket.body); let editBody = $state(loadedTicket.body);
@@ -458,41 +393,19 @@
<section class="ticket-control-card"> <section class="ticket-control-card">
<header><h2>Merge Request</h2></header> <header><h2>Merge Request</h2></header>
{#if data.mergeRequest.error} {#if mergeRequest}
<p class="workspace-callout is-error">{data.mergeRequest.error}</p> <p><strong>{mergeRequest.state}</strong> · review {mergeRequest.review_status}</p>
{:else if mergeRequest} <p>
<p><strong>{mergeRequest.state}</strong></p> From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
<p>From <code>{mergeRequest.selector_from ?? "requires repair"}</code> · {mergeRequest.source.status}{mergeRequest.source.ref ? ` @ ${mergeRequest.source.ref}` : ""}</p> to <code>{mergeRequest.selector_to}</code>
<p>To <code>{mergeRequest.selector_to}</code> · {mergeRequest.target.status}{mergeRequest.target.ref ? ` @ ${mergeRequest.target.ref}` : ""}</p> </p>
{#if currentReviewRequest?.kind === "review_requested"} {#if mergeRequest.current_subject_ref}
<p>Review requested for <code>{currentReviewRequest.subject_ref}</code></p> <p>Current source <code>{mergeRequest.current_subject_ref}</code></p>
{/if} {/if}
{#if currentReview?.kind === "review"} <a
<p><strong>{currentReview.decision}</strong> by <code>{currentReview.reviewer.runtime_id}/{currentReview.reviewer.worker_id}</code></p> class="workspace-secondary-button"
{#if currentReview.body}<RichMarkdown text={currentReview.body} />{/if} href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
{/if} >Open Merge Request</a>
{#if mergeEvent?.kind === "merge"}
<p>Final merge · {mergeEvent.strategy} / {mergeEvent.resolution}</p>
<p>Target ref <code>{mergeEvent.target_ref_after}</code></p>
<p>Completed by <code>{mergeEvent.merged_by.runtime_id}/{mergeEvent.merged_by.worker_id}</code></p>
{/if}
<h4>Thread</h4>
{#each mergeRequest.thread as event (event.sequence)}
<p>
<code>#{event.sequence}</code> · {event.kind}
{#if event.kind === "review_requested"}
· <code>{event.subject_ref}</code> · {event.requested_by.runtime_id}/{event.requested_by.worker_id}
{:else if event.kind === "review"}
· <code>{event.subject_ref}</code> · {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 <code>{event.approval_event_id}</code>
{/if}
</p>
{/each}
{:else} {:else}
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p> <p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
{/if} {/if}
@@ -5,34 +5,48 @@ import {
resourceHumanKey, resourceHumanKey,
} from "$lib/workspace/resource-links"; } from "$lib/workspace/resource-links";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel"; 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"; import type { PageLoad } from "./$types";
async function loadOptionalJson<T>(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 }) => { export const load = (async ({ fetch, params }) => {
const reference = resourceHumanKey(params.ticketId); const reference = resourceHumanKey(params.ticketId);
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(reference)}`); const ticketPath = workspaceApiPath(
const [ticket, repositories, orchestrator, mergeRequest] = await Promise.all([ params.workspaceId,
`/tickets/${encodeURIComponent(reference)}`,
);
const [ticket, repositories, orchestrator] = await Promise.all([
loadJson<TicketDetail>(fetch, ticketPath), loadJson<TicketDetail>(fetch, ticketPath),
loadJson<RepositoryListResponse>(fetch, workspaceApiPath(params.workspaceId, "/repositories")), loadJson<RepositoryListResponse>(
loadJson<WorkspaceOrchestratorStatus>(fetch, workspaceApiPath(params.workspaceId, "/orchestrator")), fetch,
loadOptionalJson<Record<string, unknown>>(fetch, `${ticketPath}/merge-request`), workspaceApiPath(params.workspaceId, "/repositories"),
),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
),
]); ]);
if (ticket.data) { 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) { 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; }) satisfies PageLoad;