feat: add repository objective pages
This commit is contained in:
parent
0f7e78c164
commit
ceb1ee3b56
|
|
@ -6,12 +6,17 @@
|
|||
|
||||
pub mod hosts;
|
||||
pub mod records;
|
||||
pub mod repositories;
|
||||
pub mod server;
|
||||
pub mod store;
|
||||
|
||||
pub use records::{
|
||||
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
|
||||
};
|
||||
pub use repositories::{
|
||||
GitCommitSummary, GitRemoteSummary, GitRepositorySummary, LocalRepositoryReader,
|
||||
RepositoryLogRead, RepositorySummary,
|
||||
};
|
||||
pub use server::{AuthConfig, ServerConfig, WorkspaceApi, build_router, serve};
|
||||
pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
||||
|
||||
|
|
@ -33,6 +38,8 @@ pub enum Error {
|
|||
MissingFrontmatter(String),
|
||||
#[error("unknown local host `{0}`")]
|
||||
UnknownHost(String),
|
||||
#[error("unknown local repository `{0}`")]
|
||||
UnknownRepository(String),
|
||||
#[error("store error: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug};
|
|||
use crate::{Error, Result};
|
||||
|
||||
const DETAIL_BODY_LIMIT: usize = 64 * 1024;
|
||||
const SUMMARY_BODY_LIMIT: usize = 240;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalProjectRecordReader {
|
||||
|
|
@ -201,6 +202,7 @@ pub struct ObjectiveSummary {
|
|||
pub title: String,
|
||||
pub state: String,
|
||||
pub updated_at: Option<String>,
|
||||
pub summary: String,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub record_source: String,
|
||||
}
|
||||
|
|
@ -233,13 +235,14 @@ struct ObjectiveFrontmatter {
|
|||
fn read_objective_summary(path: &Path, id: &str) -> Result<ObjectiveSummary> {
|
||||
validate_project_id(id)?;
|
||||
let raw = fs::read_to_string(path.join("item.md"))?;
|
||||
let (frontmatter, _) = split_frontmatter(&raw, id)?;
|
||||
let (frontmatter, body) = split_frontmatter(&raw, id)?;
|
||||
let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter)?;
|
||||
Ok(ObjectiveSummary {
|
||||
id: id.to_string(),
|
||||
title: meta.title,
|
||||
state: meta.state,
|
||||
updated_at: meta.updated_at,
|
||||
summary: summarize_body(body),
|
||||
linked_tickets: meta.linked_tickets,
|
||||
record_source: "local_yoi_objective".to_string(),
|
||||
})
|
||||
|
|
@ -259,6 +262,20 @@ fn validate_project_id(id: &str) -> Result<()> {
|
|||
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
|
||||
}
|
||||
|
||||
fn summarize_body(body: &str) -> String {
|
||||
let summary = body
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.unwrap_or_default();
|
||||
let (summary, truncated) = truncate_body(summary, SUMMARY_BODY_LIMIT);
|
||||
if truncated {
|
||||
format!("{summary}…")
|
||||
} else {
|
||||
summary
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_body(body: &str, limit: usize) -> (String, bool) {
|
||||
if body.len() <= limit {
|
||||
return (body.to_string(), false);
|
||||
|
|
|
|||
336
crates/workspace-server/src/repositories.rs
Normal file
336
crates/workspace-server/src/repositories.rs
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::hosts::RuntimeDiagnostic;
|
||||
|
||||
const LOCAL_REPOSITORY_ID: &str = "local";
|
||||
const MAX_COMMAND_OUTPUT: usize = 4096;
|
||||
const DEFAULT_LOG_LIMIT: usize = 10;
|
||||
const MAX_LOG_LIMIT: usize = 50;
|
||||
const MAX_FIELD_LEN: usize = 240;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalRepositoryReader {
|
||||
workspace_root: PathBuf,
|
||||
}
|
||||
|
||||
impl LocalRepositoryReader {
|
||||
pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
workspace_root: workspace_root.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list(&self, workspace_display_name: &str) -> Vec<RepositorySummary> {
|
||||
vec![self.summary(workspace_display_name)]
|
||||
}
|
||||
|
||||
pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary {
|
||||
let git = inspect_git(&self.workspace_root);
|
||||
RepositorySummary {
|
||||
id: LOCAL_REPOSITORY_ID.to_string(),
|
||||
display_name: workspace_display_name.to_string(),
|
||||
kind: "local".to_string(),
|
||||
workspace_root: self.workspace_root.clone(),
|
||||
record_authority: "local_workspace_root".to_string(),
|
||||
git,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recent_log(&self, requested_limit: Option<usize>) -> RepositoryLogRead {
|
||||
let limit = requested_limit
|
||||
.unwrap_or(DEFAULT_LOG_LIMIT)
|
||||
.clamp(1, MAX_LOG_LIMIT);
|
||||
git_log(&self.workspace_root, limit)
|
||||
}
|
||||
|
||||
pub fn is_local_repository_id(id: &str) -> bool {
|
||||
id == LOCAL_REPOSITORY_ID
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RepositorySummary {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub kind: String,
|
||||
pub workspace_root: PathBuf,
|
||||
pub record_authority: String,
|
||||
pub git: GitRepositorySummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GitRepositorySummary {
|
||||
pub status: String,
|
||||
pub root: Option<PathBuf>,
|
||||
pub branch: Option<String>,
|
||||
pub head: Option<String>,
|
||||
pub dirty: Option<bool>,
|
||||
pub dirty_scope: String,
|
||||
pub remote: Option<GitRemoteSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GitRemoteSummary {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub redacted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GitCommitSummary {
|
||||
pub hash: String,
|
||||
pub subject: String,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RepositoryLogRead {
|
||||
pub limit: usize,
|
||||
pub items: Vec<GitCommitSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
fn inspect_git(workspace_root: &Path) -> GitRepositorySummary {
|
||||
let mut diagnostics = Vec::new();
|
||||
let root = match git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) {
|
||||
Ok(root) => PathBuf::from(root.trim()),
|
||||
Err(message) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_unavailable",
|
||||
"info",
|
||||
format!("Workspace root is not available as a Git repository: {message}"),
|
||||
));
|
||||
return GitRepositorySummary {
|
||||
status: "unavailable".to_string(),
|
||||
root: None,
|
||||
branch: None,
|
||||
head: None,
|
||||
dirty: None,
|
||||
dirty_scope: "tracked_changes_only".to_string(),
|
||||
remote: None,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let branch = git_stdout(workspace_root, &["branch", "--show-current"])
|
||||
.ok()
|
||||
.map(|value| truncate_field(value.trim(), MAX_FIELD_LEN))
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| Some("detached".to_string()));
|
||||
let head = match git_stdout(workspace_root, &["rev-parse", "--verify", "HEAD"]) {
|
||||
Ok(value) => Some(truncate_field(value.trim(), 40)),
|
||||
Err(message) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_head_unavailable",
|
||||
"warn",
|
||||
format!("Git HEAD summary is unavailable: {message}"),
|
||||
));
|
||||
None
|
||||
}
|
||||
};
|
||||
let dirty = match git_stdout(
|
||||
workspace_root,
|
||||
&["status", "--porcelain=v1", "--untracked-files=no"],
|
||||
) {
|
||||
Ok(value) => Some(!value.trim().is_empty()),
|
||||
Err(message) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_status_unavailable",
|
||||
"warn",
|
||||
format!("Git dirty status is unavailable: {message}"),
|
||||
));
|
||||
None
|
||||
}
|
||||
};
|
||||
let remote = match git_stdout(workspace_root, &["remote", "get-url", "origin"]) {
|
||||
Ok(value) => {
|
||||
let (url, redacted) = sanitize_remote_url(value.trim());
|
||||
Some(GitRemoteSummary {
|
||||
name: "origin".to_string(),
|
||||
url,
|
||||
redacted,
|
||||
})
|
||||
}
|
||||
Err(_) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_origin_remote_missing",
|
||||
"info",
|
||||
"No origin remote is configured or visible through the bounded Git summary."
|
||||
.to_string(),
|
||||
));
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
GitRepositorySummary {
|
||||
status: "available".to_string(),
|
||||
root: Some(root),
|
||||
branch,
|
||||
head,
|
||||
dirty,
|
||||
dirty_scope: "tracked_changes_only".to_string(),
|
||||
remote,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
fn git_log(workspace_root: &Path, limit: usize) -> RepositoryLogRead {
|
||||
let mut diagnostics = Vec::new();
|
||||
if let Err(message) = git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_unavailable",
|
||||
"info",
|
||||
format!("Recent Git log is unavailable for this local repository: {message}"),
|
||||
));
|
||||
return RepositoryLogRead {
|
||||
limit,
|
||||
items: Vec::new(),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
match git_stdout(
|
||||
workspace_root,
|
||||
&[
|
||||
"log",
|
||||
"--no-show-signature",
|
||||
"--date=iso-strict",
|
||||
"--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e",
|
||||
"-n",
|
||||
&limit.to_string(),
|
||||
],
|
||||
) {
|
||||
Ok(output) => RepositoryLogRead {
|
||||
limit,
|
||||
items: parse_log(output.as_str()),
|
||||
diagnostics,
|
||||
},
|
||||
Err(message) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_log_unavailable",
|
||||
"warn",
|
||||
format!("Recent Git log is unavailable: {message}"),
|
||||
));
|
||||
RepositoryLogRead {
|
||||
limit,
|
||||
items: Vec::new(),
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_log(output: &str) -> Vec<GitCommitSummary> {
|
||||
output
|
||||
.split('\u{1e}')
|
||||
.filter_map(|record| {
|
||||
let record = record.trim_matches('\n');
|
||||
if record.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut fields = record.split('\u{1f}');
|
||||
Some(GitCommitSummary {
|
||||
hash: truncate_field(fields.next()?, 40),
|
||||
author_name: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
|
||||
author_email: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
|
||||
timestamp: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
|
||||
subject: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn git_stdout(workspace_root: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workspace_root)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|error| truncate_field(&error.to_string(), MAX_FIELD_LEN))?;
|
||||
command_stdout(output)
|
||||
}
|
||||
|
||||
fn command_stdout(output: Output) -> Result<String, String> {
|
||||
if output.status.success() {
|
||||
return Ok(truncate_output(
|
||||
String::from_utf8_lossy(&output.stdout).as_ref(),
|
||||
));
|
||||
}
|
||||
let stderr = truncate_output(String::from_utf8_lossy(&output.stderr).as_ref());
|
||||
if stderr.trim().is_empty() {
|
||||
Err(format!("git exited with status {}", output.status))
|
||||
} else {
|
||||
Err(stderr.trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_remote_url(raw: &str) -> (String, bool) {
|
||||
let bounded = truncate_field(raw, MAX_FIELD_LEN);
|
||||
let Some(separator) = bounded.find("://") else {
|
||||
return (bounded, false);
|
||||
};
|
||||
let scheme_end = separator + 3;
|
||||
let after_scheme = &bounded[scheme_end..];
|
||||
let Some(at_index) = after_scheme.find('@') else {
|
||||
return (bounded, false);
|
||||
};
|
||||
let host_and_path = &after_scheme[(at_index + 1)..];
|
||||
(format!("{}{}", &bounded[..scheme_end], host_and_path), true)
|
||||
}
|
||||
|
||||
fn truncate_output(value: &str) -> String {
|
||||
truncate_field(value, MAX_COMMAND_OUTPUT)
|
||||
}
|
||||
|
||||
fn truncate_field(value: &str, limit: usize) -> String {
|
||||
if value.len() <= limit {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut end = limit;
|
||||
while !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
value[..end].to_string()
|
||||
}
|
||||
|
||||
fn diagnostic(code: &str, severity: &str, message: String) -> RuntimeDiagnostic {
|
||||
RuntimeDiagnostic {
|
||||
code: code.to_string(),
|
||||
severity: severity.to_string(),
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitizes_userinfo_from_url_remotes() {
|
||||
assert_eq!(
|
||||
sanitize_remote_url("https://token@example.com/org/repo.git"),
|
||||
("https://example.com/org/repo.git".to_string(), true)
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("git@example.com:org/repo.git"),
|
||||
("git@example.com:org/repo.git".to_string(), false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bounded_git_log_records() {
|
||||
let parsed = parse_log(
|
||||
"0123456789abcdef\u{1f}Alice\u{1f}a@example.test\u{1f}2026-01-01T00:00:00+00:00\u{1f}Subject\u{1e}\n",
|
||||
);
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].hash, "0123456789abcdef");
|
||||
assert_eq!(parsed[0].subject, "Subject");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path as AxumPath, State};
|
||||
use axum::extract::{Path as AxumPath, Query, State};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::{StatusCode, Uri};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
|
@ -11,7 +11,10 @@ use serde::{Deserialize, Serialize};
|
|||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary};
|
||||
use crate::records::{LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail};
|
||||
use crate::records::{
|
||||
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
||||
};
|
||||
use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary};
|
||||
use crate::store::{ControlPlaneStore, RunSummary, WorkspaceRecord};
|
||||
use crate::{Error, Result};
|
||||
|
||||
|
|
@ -95,6 +98,19 @@ impl WorkspaceApi {
|
|||
self.config.local_runtime_data_dir.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn local_repository_reader(&self) -> LocalRepositoryReader {
|
||||
LocalRepositoryReader::new(self.config.workspace_root.clone())
|
||||
}
|
||||
|
||||
fn workspace_display_name(&self) -> String {
|
||||
self.config
|
||||
.workspace_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("workspace")
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
|
|
@ -104,6 +120,13 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||
.route("/api/tickets/{id}", get(get_ticket))
|
||||
.route("/api/objectives", get(list_objectives))
|
||||
.route("/api/objectives/{id}", get(get_objective))
|
||||
.route("/api/repositories", get(list_repositories))
|
||||
.route("/api/repositories/{repository_id}", get(repository_detail))
|
||||
.route("/api/repositories/{repository_id}/log", get(repository_log))
|
||||
.route(
|
||||
"/api/repositories/{repository_id}/tickets",
|
||||
get(repository_tickets),
|
||||
)
|
||||
.route("/api/runs", get(list_runs))
|
||||
.route("/api/hosts", get(list_hosts))
|
||||
.route("/api/workers", get(list_workers))
|
||||
|
|
@ -164,6 +187,58 @@ pub struct RuntimeListResponse<T> {
|
|||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RepositoryListResponse {
|
||||
pub workspace_id: String,
|
||||
pub items: Vec<RepositorySummary>,
|
||||
pub source: String,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RepositoryDetailResponse {
|
||||
pub workspace_id: String,
|
||||
pub item: RepositorySummary,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RepositoryLogResponse {
|
||||
pub workspace_id: String,
|
||||
pub repository_id: String,
|
||||
pub limit: usize,
|
||||
pub items: Vec<crate::repositories::GitCommitSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RepositoryTicketsResponse {
|
||||
pub workspace_id: String,
|
||||
pub repository_id: String,
|
||||
pub limit: usize,
|
||||
pub columns: Vec<TicketKanbanColumn>,
|
||||
pub invalid_records: Vec<crate::records::InvalidProjectRecord>,
|
||||
pub record_authority: String,
|
||||
pub source: String,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TicketKanbanColumn {
|
||||
pub state: String,
|
||||
pub items: Vec<TicketSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LogQuery {
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TicketKanbanQuery {
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
|
||||
let schema_version = api.store.schema_version().await?;
|
||||
let stored = api.store.get_workspace(api.workspace_id()).await?;
|
||||
|
|
@ -249,6 +324,80 @@ async fn get_objective(
|
|||
Ok(Json(api.records.objective(&id)?))
|
||||
}
|
||||
|
||||
async fn list_repositories(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<RepositoryListResponse>> {
|
||||
let reader = api.local_repository_reader();
|
||||
let items = reader.list(&api.workspace_display_name());
|
||||
Ok(Json(RepositoryListResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
items,
|
||||
source: "local_workspace_root".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn repository_detail(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(repository_id): AxumPath<String>,
|
||||
) -> ApiResult<Json<RepositoryDetailResponse>> {
|
||||
ensure_local_repository(&repository_id)?;
|
||||
let reader = api.local_repository_reader();
|
||||
Ok(Json(RepositoryDetailResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
item: reader.summary(&api.workspace_display_name()),
|
||||
source: "local_workspace_root".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn repository_log(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(repository_id): AxumPath<String>,
|
||||
Query(query): Query<LogQuery>,
|
||||
) -> ApiResult<Json<RepositoryLogResponse>> {
|
||||
ensure_local_repository(&repository_id)?;
|
||||
let RepositoryLogRead {
|
||||
limit,
|
||||
items,
|
||||
diagnostics,
|
||||
} = api.local_repository_reader().recent_log(query.limit);
|
||||
Ok(Json(RepositoryLogResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
repository_id,
|
||||
limit,
|
||||
items,
|
||||
diagnostics,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn repository_tickets(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(repository_id): AxumPath<String>,
|
||||
Query(query): Query<TicketKanbanQuery>,
|
||||
) -> ApiResult<Json<RepositoryTicketsResponse>> {
|
||||
ensure_local_repository(&repository_id)?;
|
||||
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
|
||||
let ProjectRecordList {
|
||||
items,
|
||||
invalid_records,
|
||||
record_authority,
|
||||
} = api.records.list_tickets(limit)?;
|
||||
Ok(Json(RepositoryTicketsResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
repository_id,
|
||||
limit,
|
||||
columns: ticket_kanban_columns(items),
|
||||
invalid_records,
|
||||
record_authority,
|
||||
source: "workspace_local_ticket_fallback".to_string(),
|
||||
diagnostics: vec![RuntimeDiagnostic {
|
||||
code: "repository_ticket_target_metadata_absent".to_string(),
|
||||
severity: "info".to_string(),
|
||||
message: "Ticket target Repository metadata is not available yet; Kanban groups all workspace-local Tickets by state as a read-only fallback.".to_string(),
|
||||
}],
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_runs(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<RuntimeListResponse<RunSummary>>> {
|
||||
|
|
@ -308,6 +457,60 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
|
|||
})
|
||||
}
|
||||
|
||||
fn ensure_local_repository(repository_id: &str) -> Result<()> {
|
||||
if LocalRepositoryReader::is_local_repository_id(repository_id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::UnknownRepository(repository_id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_kanban_columns(items: Vec<TicketSummary>) -> Vec<TicketKanbanColumn> {
|
||||
let mut columns = vec![
|
||||
TicketKanbanColumn {
|
||||
state: "planning".to_string(),
|
||||
items: Vec::new(),
|
||||
},
|
||||
TicketKanbanColumn {
|
||||
state: "ready".to_string(),
|
||||
items: Vec::new(),
|
||||
},
|
||||
TicketKanbanColumn {
|
||||
state: "queued".to_string(),
|
||||
items: Vec::new(),
|
||||
},
|
||||
TicketKanbanColumn {
|
||||
state: "inprogress".to_string(),
|
||||
items: Vec::new(),
|
||||
},
|
||||
TicketKanbanColumn {
|
||||
state: "done".to_string(),
|
||||
items: Vec::new(),
|
||||
},
|
||||
TicketKanbanColumn {
|
||||
state: "closed".to_string(),
|
||||
items: Vec::new(),
|
||||
},
|
||||
TicketKanbanColumn {
|
||||
state: "other".to_string(),
|
||||
items: Vec::new(),
|
||||
},
|
||||
];
|
||||
for item in items {
|
||||
let index = match item.state.as_str() {
|
||||
"planning" => 0,
|
||||
"ready" => 1,
|
||||
"queued" => 2,
|
||||
"inprogress" => 3,
|
||||
"done" => 4,
|
||||
"closed" => 5,
|
||||
_ => 6,
|
||||
};
|
||||
columns[index].items.push(item);
|
||||
}
|
||||
columns
|
||||
}
|
||||
|
||||
async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Response {
|
||||
if uri.path().starts_with("/api/") || uri.path() == "/api" {
|
||||
return (
|
||||
|
|
@ -407,9 +610,10 @@ impl From<Error> for ApiError {
|
|||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match &self.0 {
|
||||
Error::InvalidRecordId(_) | Error::MissingFrontmatter(_) | Error::UnknownHost(_) => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::InvalidRecordId(_)
|
||||
| Error::MissingFrontmatter(_)
|
||||
| Error::UnknownHost(_)
|
||||
| Error::UnknownRepository(_) => StatusCode::NOT_FOUND,
|
||||
Error::Ticket(_) => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
|
@ -468,6 +672,44 @@ mod tests {
|
|||
|
||||
let objectives = get_json(app.clone(), "/api/objectives").await;
|
||||
assert_eq!(objectives["items"][0]["id"], "00000000001J3");
|
||||
assert_eq!(objectives["items"][0]["summary"], "Objective body.");
|
||||
|
||||
let repositories = get_json(app.clone(), "/api/repositories").await;
|
||||
assert_eq!(repositories["items"][0]["id"], "local");
|
||||
assert_eq!(repositories["items"][0]["kind"], "local");
|
||||
|
||||
let repository_detail = get_json(app.clone(), "/api/repositories/local").await;
|
||||
assert_eq!(repository_detail["item"]["id"], "local");
|
||||
|
||||
let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await;
|
||||
assert_eq!(repository_log["repository_id"], "local");
|
||||
assert_eq!(repository_log["limit"], 3);
|
||||
|
||||
let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await;
|
||||
assert_eq!(repository_tickets["repository_id"], "local");
|
||||
let ready_column = repository_tickets["columns"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|column| column["state"] == "ready")
|
||||
.unwrap();
|
||||
assert_eq!(ready_column["items"][0]["id"], "00000000001J2");
|
||||
assert_eq!(
|
||||
repository_tickets["diagnostics"][0]["code"],
|
||||
"repository_ticket_target_metadata_absent"
|
||||
);
|
||||
|
||||
let unknown_repository_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/repositories/nope")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let hosts = get_json(app.clone(), "/api/hosts").await;
|
||||
assert_eq!(hosts["items"][0]["host_id"], "local-local-test");
|
||||
|
|
|
|||
|
|
@ -56,10 +56,18 @@
|
|||
<p class="section-state">No objectives found.</p>
|
||||
{:else}
|
||||
<ul class="nav-list" aria-label="Objectives">
|
||||
<li>
|
||||
<a class="nav-item active" href="#/objectives">
|
||||
<span class="item-title">All objectives</span>
|
||||
<span class="item-meta">read-only list</span>
|
||||
</a>
|
||||
</li>
|
||||
{#each objectives as objective (objective.id)}
|
||||
<li class="nav-item">
|
||||
<li>
|
||||
<a class="nav-item" href={`#/objectives/${objective.id}`}>
|
||||
<span class="item-title">{objective.title}</span>
|
||||
<span class="item-meta">{objective.state}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
|
@ -117,6 +125,8 @@
|
|||
background: rgba(15, 23, 42, 0.64);
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.item-title,
|
||||
|
|
|
|||
|
|
@ -15,15 +15,16 @@
|
|||
</div>
|
||||
|
||||
<ul class="nav-list" aria-label="Repositories">
|
||||
<li class="nav-item active">
|
||||
<li>
|
||||
<a class="nav-item active" href="#/repositories/local">
|
||||
<span class="item-title">{workspace?.display_name ?? 'local workspace'}</span>
|
||||
<span class="item-meta">local project records</span>
|
||||
<span class="item-meta">local repository · read-only</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="section-note">
|
||||
Repository API is not wired yet; this placeholder keeps the navigation seam
|
||||
ready without adding repository authority.
|
||||
Repository authority remains the current workspace root and canonical project records.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -74,6 +75,8 @@
|
|||
background: rgba(15, 23, 42, 0.64);
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
|
|
|
|||
|
|
@ -59,11 +59,87 @@ export type ListResponse<T> = {
|
|||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type RepositorySummary = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
kind: string;
|
||||
workspace_root: string;
|
||||
record_authority: string;
|
||||
git: GitRepositorySummary;
|
||||
};
|
||||
|
||||
export type GitRepositorySummary = {
|
||||
status: string;
|
||||
root?: string | null;
|
||||
branch?: string | null;
|
||||
head?: string | null;
|
||||
dirty?: boolean | null;
|
||||
dirty_scope: string;
|
||||
remote?: GitRemoteSummary | null;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type GitRemoteSummary = {
|
||||
name: string;
|
||||
url: string;
|
||||
redacted: boolean;
|
||||
};
|
||||
|
||||
export type GitCommitSummary = {
|
||||
hash: string;
|
||||
subject: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type RepositoryDetailResponse = {
|
||||
workspace_id: string;
|
||||
item: RepositorySummary;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type RepositoryLogResponse = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
limit: number;
|
||||
items: GitCommitSummary[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type TicketSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
state: string;
|
||||
priority?: string | null;
|
||||
updated_at?: string | null;
|
||||
queued_by?: string | null;
|
||||
queued_at?: string | null;
|
||||
record_source?: string;
|
||||
};
|
||||
|
||||
export type TicketKanbanColumn = {
|
||||
state: string;
|
||||
items: TicketSummary[];
|
||||
};
|
||||
|
||||
export type RepositoryTicketsResponse = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
limit: number;
|
||||
columns: TicketKanbanColumn[];
|
||||
invalid_records: InvalidProjectRecord[];
|
||||
record_authority: string;
|
||||
source: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type ObjectiveSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
state: string;
|
||||
updated_at?: string | null;
|
||||
summary: string;
|
||||
linked_tickets?: string[];
|
||||
record_source?: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
||||
import type { Diagnostic, Host, ListResponse, Worker, WorkspaceResponse } from '$lib/workspace-sidebar/types';
|
||||
import type {
|
||||
Diagnostic,
|
||||
Host,
|
||||
ListResponse,
|
||||
ObjectiveListResponse,
|
||||
ObjectiveSummary,
|
||||
RepositoryDetailResponse,
|
||||
RepositoryLogResponse,
|
||||
RepositorySummary,
|
||||
RepositoryTicketsResponse,
|
||||
Worker,
|
||||
WorkspaceResponse
|
||||
} from '$lib/workspace-sidebar/types';
|
||||
|
||||
type RouteState =
|
||||
| { page: 'overview'; objectiveId?: undefined }
|
||||
| { page: 'repository'; objectiveId?: undefined }
|
||||
| { page: 'objectives'; objectiveId?: string };
|
||||
|
||||
const endpoints = [
|
||||
{ label: 'Workspace', path: '/api/workspace' },
|
||||
{ label: 'Tickets', path: '/api/tickets' },
|
||||
{ label: 'Objectives', path: '/api/objectives' },
|
||||
{ label: 'Repositories', path: '/api/repositories' },
|
||||
{ label: 'Repository log', path: '/api/repositories/local/log' },
|
||||
{ label: 'Repository tickets', path: '/api/repositories/local/tickets' },
|
||||
{ label: 'Runs', path: '/api/runs' },
|
||||
{ label: 'Hosts', path: '/api/hosts' },
|
||||
{ label: 'Workers', path: '/api/workers' }
|
||||
|
|
@ -14,9 +35,20 @@
|
|||
let workspace = $state<WorkspaceResponse | null>(null);
|
||||
let hosts = $state<ListResponse<Host> | null>(null);
|
||||
let workers = $state<ListResponse<Worker> | null>(null);
|
||||
let repository = $state<RepositorySummary | null>(null);
|
||||
let repositoryLog = $state<RepositoryLogResponse | null>(null);
|
||||
let repositoryTickets = $state<RepositoryTicketsResponse | null>(null);
|
||||
let objectives = $state<ObjectiveListResponse | null>(null);
|
||||
|
||||
let workspaceError = $state<string | null>(null);
|
||||
let hostsError = $state<string | null>(null);
|
||||
let workersError = $state<string | null>(null);
|
||||
let repositoryError = $state<string | null>(null);
|
||||
let repositoryLogError = $state<string | null>(null);
|
||||
let repositoryTicketsError = $state<string | null>(null);
|
||||
let objectivesError = $state<string | null>(null);
|
||||
let currentPath = $state('/');
|
||||
let route = $derived(routeFromPath(currentPath));
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path);
|
||||
|
|
@ -56,14 +88,89 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function loadRepository() {
|
||||
repositoryError = null;
|
||||
try {
|
||||
const detail = await getJson<RepositoryDetailResponse>('/api/repositories/local');
|
||||
repository = detail.item;
|
||||
} catch (error) {
|
||||
repositoryError = error instanceof Error ? error.message : String(error);
|
||||
repository = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRepositoryLog() {
|
||||
repositoryLogError = null;
|
||||
try {
|
||||
repositoryLog = await getJson<RepositoryLogResponse>('/api/repositories/local/log?limit=10');
|
||||
} catch (error) {
|
||||
repositoryLogError = error instanceof Error ? error.message : String(error);
|
||||
repositoryLog = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRepositoryTickets() {
|
||||
repositoryTicketsError = null;
|
||||
try {
|
||||
repositoryTickets = await getJson<RepositoryTicketsResponse>('/api/repositories/local/tickets');
|
||||
} catch (error) {
|
||||
repositoryTicketsError = error instanceof Error ? error.message : String(error);
|
||||
repositoryTickets = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadObjectives() {
|
||||
objectivesError = null;
|
||||
try {
|
||||
objectives = await getJson<ObjectiveListResponse>('/api/objectives');
|
||||
} catch (error) {
|
||||
objectivesError = error instanceof Error ? error.message : String(error);
|
||||
objectives = null;
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticsFor(...groups: Array<Diagnostic[] | undefined>): Diagnostic[] {
|
||||
return groups.flatMap((group) => group ?? []);
|
||||
}
|
||||
|
||||
function routeFromPath(path: string): RouteState {
|
||||
if (path.startsWith('/repositories')) {
|
||||
return { page: 'repository' };
|
||||
}
|
||||
if (path.startsWith('/objectives')) {
|
||||
const [, , objectiveId] = path.split('/');
|
||||
return { page: 'objectives', objectiveId: objectiveId || undefined };
|
||||
}
|
||||
return { page: 'overview' };
|
||||
}
|
||||
|
||||
function updateRouteFromHash() {
|
||||
const hashPath = window.location.hash.replace(/^#/, '') || '/';
|
||||
currentPath = hashPath.startsWith('/') ? hashPath : `/${hashPath}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
return value ?? 'not recorded';
|
||||
}
|
||||
|
||||
function shortHash(hash: string | null | undefined): string {
|
||||
return hash ? hash.slice(0, 12) : 'unknown';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void loadWorkspace();
|
||||
void loadHosts();
|
||||
void loadWorkers();
|
||||
void loadRepository();
|
||||
void loadRepositoryLog();
|
||||
void loadRepositoryTickets();
|
||||
void loadObjectives();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
updateRouteFromHash();
|
||||
window.addEventListener('hashchange', updateRouteFromHash);
|
||||
return () => window.removeEventListener('hashchange', updateRouteFromHash);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -83,13 +190,236 @@
|
|||
<p class="eyebrow">Local / single-workspace bootstrap</p>
|
||||
<h1>Yoi Workspace Control Plane</h1>
|
||||
<p>
|
||||
Static SPA shell for reading canonical <code>.yoi</code> project records
|
||||
and the local Host / Worker execution view through bounded backend APIs.
|
||||
Ticket and Objective lifecycle authority stays in the existing local record
|
||||
workflow.
|
||||
Static SPA shell for reading canonical <code>.yoi</code> project records,
|
||||
bounded local Repository summaries, and the local Host / Worker execution
|
||||
view. Ticket and Objective lifecycle authority stays in the existing local
|
||||
record workflow.
|
||||
</p>
|
||||
<p class="page-links" aria-label="Workspace page links">
|
||||
<a href="#/">Overview</a>
|
||||
<a href="#/repositories/local">Repository</a>
|
||||
<a href="#/objectives">Objectives</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{#if route.page === 'repository'}
|
||||
<section class="grid runtime">
|
||||
<div class="card">
|
||||
<h2>Repository summary</h2>
|
||||
{#if repository}
|
||||
<dl>
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd><code>{repository.id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Kind</dt>
|
||||
<dd>{repository.kind}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Workspace root</dt>
|
||||
<dd><code>{repository.workspace_root}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Record authority</dt>
|
||||
<dd>{repository.record_authority}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Git</dt>
|
||||
<dd>{repository.git.status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{:else if repositoryError}
|
||||
<p class="error">{repositoryError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/repositories/local</code>…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Git summary</h2>
|
||||
{#if repository}
|
||||
{#if repository.git.status === 'available'}
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Root</dt>
|
||||
<dd><code>{repository.git.root ?? 'unknown'}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Branch</dt>
|
||||
<dd>{repository.git.branch ?? 'unknown'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>HEAD</dt>
|
||||
<dd><code>{shortHash(repository.git.head)}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Dirty</dt>
|
||||
<dd>{repository.git.dirty === null || repository.git.dirty === undefined ? 'unknown' : repository.git.dirty ? 'yes' : 'no'} <small>{repository.git.dirty_scope}</small></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Remote</dt>
|
||||
<dd>
|
||||
{#if repository.git.remote}
|
||||
<code>{repository.git.remote.name}</code> · {repository.git.remote.url}
|
||||
{#if repository.git.remote.redacted}<small>credentials redacted</small>{/if}
|
||||
{:else}
|
||||
not configured
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{:else}
|
||||
<p>Git metadata is unavailable for this local Repository.</p>
|
||||
{/if}
|
||||
{:else if repositoryError}
|
||||
<p class="error">{repositoryError}</p>
|
||||
{:else}
|
||||
<p>Waiting for Git summary…</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Recent Git log</h2>
|
||||
{#if repositoryLog}
|
||||
{#if repositoryLog.items.length === 0}
|
||||
<p>No recent commits are available from the bounded Git log API.</p>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Commit</th>
|
||||
<th>Subject</th>
|
||||
<th>Author</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each repositoryLog.items as commit (commit.hash)}
|
||||
<tr>
|
||||
<td><code>{shortHash(commit.hash)}</code></td>
|
||||
<td>{commit.subject}</td>
|
||||
<td>{commit.author_name} <small>{commit.author_email}</small></td>
|
||||
<td>{commit.timestamp}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if repositoryLogError}
|
||||
<p class="error">{repositoryLogError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/repositories/local/log</code>…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Repository Ticket Kanban</h2>
|
||||
<p class="section-note">
|
||||
Read-only grouping of canonical Ticket records. No drag/drop or lifecycle mutation is exposed.
|
||||
</p>
|
||||
{#if repositoryTickets}
|
||||
<div class="kanban">
|
||||
{#each repositoryTickets.columns as column (column.state)}
|
||||
<article class="kanban-column">
|
||||
<h3>{column.state} <span>{column.items.length}</span></h3>
|
||||
{#if column.items.length === 0}
|
||||
<p class="muted">No tickets.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each column.items as ticket (ticket.id)}
|
||||
<li>
|
||||
<strong>{ticket.title}</strong>
|
||||
<small><code>{ticket.id}</code> · updated {formatDate(ticket.updated_at)}</small>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if repositoryTicketsError}
|
||||
<p class="error">{repositoryTicketsError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/repositories/local/tickets</code>…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{@const repositoryDiagnostics = diagnosticsFor(repository?.git.diagnostics, repositoryLog?.diagnostics, repositoryTickets?.diagnostics)}
|
||||
{#if repositoryDiagnostics.length > 0}
|
||||
<section class="card diagnostics">
|
||||
<h2>Repository diagnostics</h2>
|
||||
<ul>
|
||||
{#each repositoryDiagnostics as diagnostic}
|
||||
<li>
|
||||
<strong>{diagnostic.severity}</strong>
|
||||
<code>{diagnostic.code}</code>
|
||||
<span>{diagnostic.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{:else if route.page === 'objectives'}
|
||||
<section class="card">
|
||||
<h2>Objectives</h2>
|
||||
<p class="section-note">
|
||||
Objectives are read from canonical filesystem records through <code>/api/objectives</code>.
|
||||
</p>
|
||||
{#if objectives}
|
||||
{#if objectives.items.length === 0}
|
||||
<p>No Objective records are present.</p>
|
||||
{:else}
|
||||
<div class="stack">
|
||||
{#each objectives.items as objective (objective.id)}
|
||||
<article class="runtime-card selected-card" class:selected={route.objectiveId === objective.id}>
|
||||
<div class="runtime-heading">
|
||||
<strong>{objective.title}</strong>
|
||||
<span>{objective.state}</span>
|
||||
</div>
|
||||
<p>{objective.summary || 'No summary text is available.'}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd><code>{objective.id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{formatDate(objective.updated_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Linked tickets</dt>
|
||||
<dd>{objective.linked_tickets?.length ? objective.linked_tickets.join(', ') : 'none'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p><a href={`#/objectives/${objective.id}`}>Detail placeholder</a></p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if objectives.invalid_records.length > 0}
|
||||
<p class="error">{objectives.invalid_records.length} invalid objective record(s) hidden.</p>
|
||||
{/if}
|
||||
{:else if objectivesError}
|
||||
<p class="error">{objectivesError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/objectives</code>…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if route.objectiveId}
|
||||
<section class="card detail-placeholder">
|
||||
<h2>Objective detail</h2>
|
||||
<p>
|
||||
Selected Objective <code>{route.objectiveId}</code>. This slice keeps detail navigation as a
|
||||
static SPA placeholder; canonical Objective content remains in the filesystem record.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
{:else}
|
||||
<section class="card">
|
||||
<h2>Workspace</h2>
|
||||
{#if workspace}
|
||||
|
|
@ -243,6 +573,7 @@
|
|||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
|
@ -283,6 +614,24 @@
|
|||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.page-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.page-links a,
|
||||
.card a {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.page-links a {
|
||||
border: 1px solid rgba(125, 211, 252, 0.28);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #38bdf8;
|
||||
font-weight: 700;
|
||||
|
|
@ -297,7 +646,8 @@
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h2 {
|
||||
h2,
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
|
@ -331,18 +681,29 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-note,
|
||||
.muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.runtime-card {
|
||||
.runtime-card,
|
||||
.kanban-column {
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.selected-card.selected {
|
||||
border-color: rgba(56, 189, 248, 0.5);
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
.runtime-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -403,6 +764,36 @@
|
|||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.kanban {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr));
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.kanban-column h3 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.95rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.kanban-column ul {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.kanban-column li {
|
||||
border-radius: 12px;
|
||||
background: rgba(30, 41, 59, 0.72);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.diagnostics {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
|
@ -413,6 +804,10 @@
|
|||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-placeholder {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user