merge: repository registry implementation
This commit is contained in:
commit
a786fd85a7
9
.yoi/workspace-backend.local.toml
Normal file
9
.yoi/workspace-backend.local.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Dogfood Workspace Backend configuration.
|
||||
# Repository URI values are resolved from this workspace config root.
|
||||
|
||||
[[repositories]]
|
||||
id = "main"
|
||||
provider = "git"
|
||||
uri = "."
|
||||
display_name = "Yoi"
|
||||
default_selector = "HEAD"
|
||||
|
|
@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::hosts::RemoteRuntimeConfig;
|
||||
use crate::identity::WorkspaceIdentity;
|
||||
use crate::repositories::ConfiguredRepository;
|
||||
use crate::server::{AuthConfig, ServerConfig};
|
||||
use crate::{Error, Result};
|
||||
|
||||
|
|
@ -26,6 +27,8 @@ pub struct WorkspaceBackendConfigFile {
|
|||
#[serde(default)]
|
||||
pub limits: WorkspaceBackendLimitsConfig,
|
||||
#[serde(default)]
|
||||
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
|
||||
#[serde(default)]
|
||||
pub runtimes: WorkspaceBackendRuntimesConfig,
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +61,18 @@ pub struct WorkspaceBackendLimitsConfig {
|
|||
pub max_records: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceRepositoryConfigFile {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub uri: String,
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_selector: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceBackendRuntimesConfig {
|
||||
|
|
@ -264,6 +279,11 @@ impl WorkspaceBackendConfigFile {
|
|||
.map(|path| resolve_workspace_path(workspace_root, path));
|
||||
server.embedded_runtime_store_root = embedded_runtime_store_root;
|
||||
server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS);
|
||||
server.repositories = self
|
||||
.repositories
|
||||
.iter()
|
||||
.map(|repository| resolve_repository(workspace_root, repository))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
server.remote_runtime_sources = self
|
||||
.runtimes
|
||||
.remote
|
||||
|
|
@ -299,6 +319,70 @@ impl ResolvedWorkspaceBackendConfig {
|
|||
}
|
||||
}
|
||||
|
||||
fn resolve_repository(
|
||||
workspace_root: &Path,
|
||||
config: &WorkspaceRepositoryConfigFile,
|
||||
) -> Result<ConfiguredRepository> {
|
||||
let id = normalize_required_string("repository id", &config.id)?;
|
||||
validate_repository_id(&id)?;
|
||||
let provider =
|
||||
normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase();
|
||||
let uri = normalize_required_string("repository uri", &config.uri)?;
|
||||
let path = resolve_repository_uri(workspace_root, &id, &uri)?;
|
||||
let display_name = normalize_optional_string(config.display_name.as_deref());
|
||||
let default_selector = normalize_optional_string(config.default_selector.as_deref());
|
||||
|
||||
Ok(ConfiguredRepository {
|
||||
id,
|
||||
provider,
|
||||
uri,
|
||||
path,
|
||||
display_name,
|
||||
default_selector,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_required_string(field: &str, value: &str) -> Result<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(Error::Config(format!("{field} must not be empty")));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_repository_id(id: &str) -> Result<()> {
|
||||
if id
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Config(format!(
|
||||
"repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_repository_uri(workspace_root: &Path, id: &str, uri: &str) -> Result<PathBuf> {
|
||||
if uri.contains("://") {
|
||||
return Err(Error::Config(format!(
|
||||
"repository `{id}` uses a remote URI, but remote repository materialization is not implemented"
|
||||
)));
|
||||
}
|
||||
Ok(resolve_workspace_path(workspace_root, Path::new(uri)))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_remote_runtime(
|
||||
config: &RemoteRuntimeConfigFile,
|
||||
) -> Result<RemoteRuntimeConfig> {
|
||||
|
|
@ -479,6 +563,57 @@ root = ".local-data"
|
|||
assert!(diff.text.contains("127.0.0.1:9999"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_repository_uri_relative_to_workspace_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = WorkspaceBackendConfigFile::parse_str(
|
||||
r#"
|
||||
[[repositories]]
|
||||
id = "main"
|
||||
provider = "git"
|
||||
uri = "."
|
||||
display_name = "Main"
|
||||
default_selector = "HEAD"
|
||||
"#,
|
||||
"test",
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
||||
let repository = resolved.server.repositories.first().unwrap();
|
||||
|
||||
assert_eq!(repository.id, "main");
|
||||
assert_eq!(repository.provider, "git");
|
||||
assert_eq!(repository.path, dir.path());
|
||||
assert_eq!(repository.display_name.as_deref(), Some("Main"));
|
||||
assert_eq!(repository.default_selector.as_deref(), Some("HEAD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_repository_uri_fails_closed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = WorkspaceBackendConfigFile::parse_str(
|
||||
r#"
|
||||
[[repositories]]
|
||||
id = "main"
|
||||
provider = "git"
|
||||
uri = "https://example.com/org/repo.git"
|
||||
"#,
|
||||
"test",
|
||||
)
|
||||
.unwrap();
|
||||
let error = match config.resolve(dir.path(), identity()) {
|
||||
Ok(_) => panic!("remote repository URI should fail closed"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("remote repository materialization is not implemented"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_value_field_is_not_in_schema() {
|
||||
let error = WorkspaceBackendConfigFile::parse_str(
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ pub use records::{
|
|||
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
|
||||
};
|
||||
pub use repositories::{
|
||||
GitCommitSummary, GitRemoteSummary, GitRepositorySummary, LocalRepositoryReader,
|
||||
RepositoryLogRead, RepositorySummary,
|
||||
ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
|
||||
RepositoryLogRead, RepositoryRegistryReader, RepositorySummary,
|
||||
};
|
||||
pub use server::{AuthConfig, ServerConfig, WorkspaceApi, build_router, serve};
|
||||
pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
||||
|
|
|
|||
|
|
@ -1,351 +1,379 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic};
|
||||
pub type RepositoryId = String;
|
||||
pub type RepositorySelector = String;
|
||||
|
||||
const LEGACY_LOCAL_REPOSITORY_ID: &str = "local";
|
||||
const LOCAL_REPOSITORY_PREFIX: &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,
|
||||
workspace_id: String,
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfiguredRepository {
|
||||
pub id: RepositoryId,
|
||||
pub provider: String,
|
||||
pub uri: String,
|
||||
pub path: PathBuf,
|
||||
pub display_name: Option<String>,
|
||||
pub default_selector: Option<RepositorySelector>,
|
||||
}
|
||||
|
||||
impl LocalRepositoryReader {
|
||||
pub fn new(workspace_root: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
workspace_root: workspace_root.into(),
|
||||
workspace_id: workspace_id.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: Self::repository_id_for_workspace(&self.workspace_id),
|
||||
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 repository_id_for_workspace(workspace_id: &str) -> String {
|
||||
format!(
|
||||
"{LOCAL_REPOSITORY_PREFIX}{}",
|
||||
sanitize_identifier_fragment(workspace_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_local_repository_id(id: &str, workspace_id: &str) -> bool {
|
||||
id == LEGACY_LOCAL_REPOSITORY_ID || id == Self::repository_id_for_workspace(workspace_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_identifier_fragment(value: &str) -> String {
|
||||
let mut output = String::with_capacity(value.len());
|
||||
let mut previous_dash = false;
|
||||
for ch in value.chars() {
|
||||
let mapped = if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
};
|
||||
if mapped == '-' {
|
||||
if !previous_dash {
|
||||
output.push(mapped);
|
||||
}
|
||||
previous_dash = true;
|
||||
} else {
|
||||
output.push(mapped);
|
||||
previous_dash = false;
|
||||
}
|
||||
}
|
||||
let output = output.trim_matches('-').to_string();
|
||||
if output.is_empty() {
|
||||
"workspace".to_string()
|
||||
} else {
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositorySummary {
|
||||
pub id: String,
|
||||
pub id: RepositoryId,
|
||||
pub display_name: String,
|
||||
pub kind: String,
|
||||
pub workspace_root: PathBuf,
|
||||
pub provider: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_selector: Option<RepositorySelector>,
|
||||
pub record_authority: String,
|
||||
pub git: GitRepositorySummary,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub git: Option<GitRepositorySummary>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub diagnostics: Vec<RepositoryDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
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>,
|
||||
pub branch: Option<String>,
|
||||
pub dirty: bool,
|
||||
pub remotes: Vec<GitRemoteSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GitRemoteSummary {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub redacted: bool,
|
||||
pub fetch_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositoryDiagnostic {
|
||||
pub severity: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepositoryListProjection {
|
||||
pub items: Vec<RepositorySummary>,
|
||||
pub diagnostics: Vec<RepositoryDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositoryLogRead {
|
||||
pub repository_id: RepositoryId,
|
||||
pub default_selector: Option<RepositorySelector>,
|
||||
pub limit: usize,
|
||||
pub commits: Vec<GitCommitSummary>,
|
||||
pub diagnostics: Vec<RepositoryDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GitCommitSummary {
|
||||
pub hash: String,
|
||||
pub subject: String,
|
||||
pub short_hash: String,
|
||||
pub summary: String,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
pub timestamp: String,
|
||||
pub author_date: String,
|
||||
pub parents: Vec<String>,
|
||||
pub refs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RepositoryLogRead {
|
||||
pub limit: usize,
|
||||
pub items: Vec<GitCommitSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RepositoryLookupError {
|
||||
UnknownRepository { id: RepositoryId },
|
||||
UnsupportedProvider { id: RepositoryId, provider: String },
|
||||
}
|
||||
|
||||
fn inspect_git(workspace_root: &Path) -> GitRepositorySummary {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RepositoryRegistryReader {
|
||||
repositories: Vec<ConfiguredRepository>,
|
||||
}
|
||||
|
||||
impl RepositoryRegistryReader {
|
||||
pub fn new(repositories: Vec<ConfiguredRepository>) -> Self {
|
||||
Self { repositories }
|
||||
}
|
||||
|
||||
pub fn list(&self) -> RepositoryListProjection {
|
||||
if self.repositories.is_empty() {
|
||||
return RepositoryListProjection {
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![RepositoryDiagnostic {
|
||||
severity: "warning".to_string(),
|
||||
code: "repository_config_empty".to_string(),
|
||||
message: "No repositories are configured for this workspace backend."
|
||||
.to_string(),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
RepositoryListProjection {
|
||||
items: self
|
||||
.repositories
|
||||
.iter()
|
||||
.map(|repository| self.summary_for_config(repository))
|
||||
.collect(),
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn summary(&self, id: &str) -> Result<RepositorySummary, RepositoryLookupError> {
|
||||
let repository = self
|
||||
.find(id)
|
||||
.ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.to_string() })?;
|
||||
Ok(self.summary_for_config(repository))
|
||||
}
|
||||
|
||||
pub fn recent_log(
|
||||
&self,
|
||||
id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<RepositoryLogRead, RepositoryLookupError> {
|
||||
let repository = self
|
||||
.find(id)
|
||||
.ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.to_string() })?;
|
||||
if repository.provider != "git" {
|
||||
return Err(RepositoryLookupError::UnsupportedProvider {
|
||||
id: repository.id.clone(),
|
||||
provider: repository.provider.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let limit = limit.unwrap_or(40).clamp(1, 200);
|
||||
let mut diagnostics = Vec::new();
|
||||
let root = match git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) {
|
||||
Ok(root) => PathBuf::from(root.trim()),
|
||||
let commits = match self.git_log(repository, limit) {
|
||||
Ok(commits) => commits,
|
||||
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,
|
||||
};
|
||||
diagnostics.push(RepositoryDiagnostic {
|
||||
severity: "warning".to_string(),
|
||||
code: "repository_git_log_unavailable".to_string(),
|
||||
message,
|
||||
});
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
Ok(RepositoryLogRead {
|
||||
repository_id: repository.id.clone(),
|
||||
default_selector: repository.default_selector.clone(),
|
||||
limit,
|
||||
commits,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
Err(_) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_origin_remote_missing",
|
||||
"info",
|
||||
"No origin remote is configured or visible through the bounded Git summary."
|
||||
.to_string(),
|
||||
));
|
||||
|
||||
fn find(&self, id: &str) -> Option<&ConfiguredRepository> {
|
||||
self.repositories
|
||||
.iter()
|
||||
.find(|repository| repository.id == id)
|
||||
}
|
||||
|
||||
fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary {
|
||||
let display_name = repository
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| repository.id.clone());
|
||||
let mut diagnostics = Vec::new();
|
||||
let git = match repository.provider.as_str() {
|
||||
"git" => match self.inspect_git(repository) {
|
||||
Ok(git) => Some(git),
|
||||
Err(message) => {
|
||||
diagnostics.push(RepositoryDiagnostic {
|
||||
severity: "warning".to_string(),
|
||||
code: "repository_git_unavailable".to_string(),
|
||||
message,
|
||||
});
|
||||
None
|
||||
}
|
||||
},
|
||||
provider => {
|
||||
diagnostics.push(RepositoryDiagnostic {
|
||||
severity: "warning".to_string(),
|
||||
code: "repository_provider_unsupported".to_string(),
|
||||
message: format!(
|
||||
"Repository provider `{provider}` is configured but is not supported by the workspace backend API."
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
GitRepositorySummary {
|
||||
RepositorySummary {
|
||||
id: repository.id.clone(),
|
||||
display_name,
|
||||
kind: repository.provider.clone(),
|
||||
provider: repository.provider.clone(),
|
||||
default_selector: repository.default_selector.clone(),
|
||||
record_authority: "workspace-backend-config".to_string(),
|
||||
git,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_git(
|
||||
&self,
|
||||
repository: &ConfiguredRepository,
|
||||
) -> Result<GitRepositorySummary, String> {
|
||||
let head = git_stdout(&repository.path, ["rev-parse", "HEAD"])?;
|
||||
let branch = git_stdout(&repository.path, ["branch", "--show-current"])
|
||||
.ok()
|
||||
.and_then(|value| non_empty_string(value.trim()));
|
||||
let status = git_stdout(&repository.path, ["status", "--porcelain"])?;
|
||||
let remotes = git_stdout(&repository.path, ["remote", "-v"])
|
||||
.map(|raw| parse_remotes(&raw))
|
||||
.unwrap_or_default();
|
||||
Ok(GitRepositorySummary {
|
||||
status: "available".to_string(),
|
||||
root: Some(root),
|
||||
head: non_empty_string(head.trim()),
|
||||
branch,
|
||||
head,
|
||||
dirty,
|
||||
dirty_scope: "tracked_changes_only".to_string(),
|
||||
remote,
|
||||
diagnostics,
|
||||
}
|
||||
dirty: !status.trim().is_empty(),
|
||||
remotes,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
&[
|
||||
fn git_log(
|
||||
&self,
|
||||
repository: &ConfiguredRepository,
|
||||
limit: usize,
|
||||
) -> Result<Vec<GitCommitSummary>, String> {
|
||||
let limit_arg = format!("-{limit}");
|
||||
let output = git_stdout(
|
||||
&repository.path,
|
||||
[
|
||||
"log",
|
||||
"--no-show-signature",
|
||||
"--date=iso-strict",
|
||||
"--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e",
|
||||
"-n",
|
||||
&limit.to_string(),
|
||||
"--decorate=short",
|
||||
"--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%P%x1f%D%x1e",
|
||||
limit_arg.as_str(),
|
||||
],
|
||||
) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
)?;
|
||||
Ok(parse_git_log(&output))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_log(output: &str) -> Vec<GitCommitSummary> {
|
||||
output
|
||||
.split('\u{1e}')
|
||||
fn git_stdout<'a, I>(repository_path: &PathBuf, args: I) -> Result<String, String>
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repository_path)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|_| {
|
||||
"Git command could not be executed; backend-private path details were omitted."
|
||||
.to_string()
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err("Git command failed; backend-private path details were omitted.".to_string());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn parse_remotes(raw: &str) -> Vec<GitRemoteSummary> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut remotes = Vec::new();
|
||||
for line in raw.lines() {
|
||||
let mut parts = line.split_whitespace();
|
||||
let Some(name) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
let Some(url) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert((name.to_string(), url.to_string())) {
|
||||
continue;
|
||||
}
|
||||
remotes.push(GitRemoteSummary {
|
||||
name: name.to_string(),
|
||||
fetch_url: sanitize_remote_url(url),
|
||||
});
|
||||
}
|
||||
remotes
|
||||
}
|
||||
|
||||
fn parse_git_log(raw: &str) -> Vec<GitCommitSummary> {
|
||||
raw.split('\u{1e}')
|
||||
.filter_map(|record| {
|
||||
let record = record.trim_matches('\n');
|
||||
if record.is_empty() {
|
||||
let trimmed = record.trim_matches('\n').trim_end();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut fields = record.split('\u{1f}');
|
||||
let mut fields = trimmed.split('\u{1f}');
|
||||
let hash = fields.next()?.to_string();
|
||||
let short_hash = fields.next().unwrap_or_default().to_string();
|
||||
let summary = fields.next().unwrap_or_default().to_string();
|
||||
let author_name = fields.next().unwrap_or_default().to_string();
|
||||
let author_email = fields.next().unwrap_or_default().to_string();
|
||||
let author_date = fields.next().unwrap_or_default().to_string();
|
||||
let parents = fields
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
let refs = fields
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|reference| !reference.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
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),
|
||||
hash,
|
||||
short_hash,
|
||||
summary,
|
||||
author_name,
|
||||
author_email,
|
||||
author_date,
|
||||
parents,
|
||||
refs,
|
||||
})
|
||||
})
|
||||
.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 sanitize_remote_url(url: &str) -> String {
|
||||
let trimmed = url.trim();
|
||||
if is_local_path_like(trimmed) {
|
||||
return "<redacted-local-path>".to_string();
|
||||
}
|
||||
|
||||
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 Some((scheme, rest)) = trimmed.split_once("://") else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
if scheme.eq_ignore_ascii_case("file") {
|
||||
return "file://<redacted-local-path>".to_string();
|
||||
}
|
||||
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))
|
||||
let Some((_credentials, host_path)) = rest.split_once('@') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
format!("{scheme}://<redacted>@{host_path}")
|
||||
}
|
||||
|
||||
fn is_local_path_like(value: &str) -> bool {
|
||||
Path::new(value).is_absolute() || is_windows_absolute_path_like(value)
|
||||
}
|
||||
|
||||
fn is_windows_absolute_path_like(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
bytes.len() >= 3
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[1] == b':'
|
||||
&& matches!(bytes[2], b'\\' | b'/')
|
||||
}
|
||||
|
||||
fn non_empty_string(value: &str) -> Option<String> {
|
||||
if value.is_empty() {
|
||||
None
|
||||
} 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: match severity {
|
||||
"error" => DiagnosticSeverity::Error,
|
||||
"warning" => DiagnosticSeverity::Warning,
|
||||
_ => DiagnosticSeverity::Info,
|
||||
},
|
||||
message,
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -354,24 +382,55 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitizes_userinfo_from_url_remotes() {
|
||||
fn sanitizes_remote_credentials_and_local_paths() {
|
||||
assert_eq!(
|
||||
sanitize_remote_url("https://token@example.com/org/repo.git"),
|
||||
("https://example.com/org/repo.git".to_string(), true)
|
||||
sanitize_remote_url("https://user:token@example.com/org/repo.git"),
|
||||
"https://<redacted>@example.com/org/repo.git"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("git@example.com:org/repo.git"),
|
||||
("git@example.com:org/repo.git".to_string(), false)
|
||||
"git@example.com:org/repo.git"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("/home/alice/private/repo.git"),
|
||||
"<redacted-local-path>"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("/Users/alice/private/repo.git"),
|
||||
"<redacted-local-path>"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("C:\\Users\\alice\\private\\repo.git"),
|
||||
"<redacted-local-path>"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("file:///home/alice/private/repo.git"),
|
||||
"file://<redacted-local-path>"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("file://localhost/home/alice/private/repo.git"),
|
||||
"file://<redacted-local-path>"
|
||||
);
|
||||
}
|
||||
|
||||
#[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",
|
||||
fn empty_registry_reports_diagnostic_without_implicit_repository() {
|
||||
let projection = RepositoryRegistryReader::new(Vec::new()).list();
|
||||
|
||||
assert!(projection.items.is_empty());
|
||||
assert_eq!(projection.diagnostics.len(), 1);
|
||||
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_repository_is_not_resolved_from_fallback() {
|
||||
let reader = RepositoryRegistryReader::new(Vec::new());
|
||||
|
||||
assert_eq!(
|
||||
reader.summary("main").unwrap_err(),
|
||||
RepositoryLookupError::UnknownRepository {
|
||||
id: "main".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].hash, "0123456789abcdef");
|
||||
assert_eq!(parsed[0].subject, "Subject");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,10 @@ use crate::observation::{
|
|||
use crate::records::{
|
||||
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
||||
};
|
||||
use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary};
|
||||
use crate::repositories::{
|
||||
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
||||
RepositoryRegistryReader, RepositorySummary,
|
||||
};
|
||||
use crate::store::{ControlPlaneStore, WorkspaceRecord};
|
||||
use crate::{Error, Result};
|
||||
use worker_runtime::catalog::{ConfigBundleRef, ProfileSelector};
|
||||
|
|
@ -68,6 +71,7 @@ pub struct ServerConfig {
|
|||
pub static_assets_dir: Option<PathBuf>,
|
||||
pub auth: AuthConfig,
|
||||
pub max_records: usize,
|
||||
pub repositories: Vec<ConfiguredRepository>,
|
||||
pub runtime_event_sources: Vec<RuntimeObservationSourceConfig>,
|
||||
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
|
||||
}
|
||||
|
|
@ -89,6 +93,7 @@ impl ServerConfig {
|
|||
token_configured: false,
|
||||
},
|
||||
max_records: 200,
|
||||
repositories: Vec::new(),
|
||||
runtime_event_sources: Vec::new(),
|
||||
remote_runtime_sources: Vec::new(),
|
||||
}
|
||||
|
|
@ -202,19 +207,8 @@ impl WorkspaceApi {
|
|||
self.config.workspace_id.as_str()
|
||||
}
|
||||
|
||||
fn local_repository_reader(&self) -> LocalRepositoryReader {
|
||||
LocalRepositoryReader::new(
|
||||
self.config.workspace_root.clone(),
|
||||
self.config.workspace_id.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn local_repository_id(&self) -> String {
|
||||
LocalRepositoryReader::repository_id_for_workspace(self.workspace_id())
|
||||
}
|
||||
|
||||
fn workspace_display_name(&self) -> &str {
|
||||
self.config.workspace_display_name.as_str()
|
||||
fn repository_reader(&self) -> RepositoryRegistryReader {
|
||||
RepositoryRegistryReader::new(self.config.repositories.clone())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -479,6 +473,8 @@ pub struct RepositoryDetailResponse {
|
|||
pub struct RepositoryLogResponse {
|
||||
pub workspace_id: String,
|
||||
pub repository_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_selector: Option<String>,
|
||||
pub limit: usize,
|
||||
pub items: Vec<crate::repositories::GitCommitSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
|
|
@ -633,13 +629,12 @@ async fn get_objective(
|
|||
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());
|
||||
let RepositoryListProjection { items, diagnostics } = api.repository_reader().list();
|
||||
Ok(Json(RepositoryListResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
items,
|
||||
source: "local_workspace_root".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
source: "workspace_backend_config".to_string(),
|
||||
diagnostics: repository_diagnostics(diagnostics),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -647,12 +642,11 @@ async fn repository_detail(
|
|||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(repository_id): AxumPath<String>,
|
||||
) -> ApiResult<Json<RepositoryDetailResponse>> {
|
||||
let _canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||
let reader = api.local_repository_reader();
|
||||
let item = repository_lookup(api.repository_reader().summary(&repository_id))?;
|
||||
Ok(Json(RepositoryDetailResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
item: reader.summary(api.workspace_display_name()),
|
||||
source: "local_workspace_root".to_string(),
|
||||
item,
|
||||
source: "workspace_backend_config".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -661,18 +655,23 @@ async fn repository_log(
|
|||
AxumPath(repository_id): AxumPath<String>,
|
||||
Query(query): Query<LogQuery>,
|
||||
) -> ApiResult<Json<RepositoryLogResponse>> {
|
||||
let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||
let RepositoryLogRead {
|
||||
repository_id,
|
||||
default_selector,
|
||||
limit,
|
||||
items,
|
||||
commits,
|
||||
diagnostics,
|
||||
} = api.local_repository_reader().recent_log(query.limit);
|
||||
} = repository_lookup(
|
||||
api.repository_reader()
|
||||
.recent_log(&repository_id, query.limit),
|
||||
)?;
|
||||
Ok(Json(RepositoryLogResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
repository_id: canonical_repository_id,
|
||||
repository_id,
|
||||
default_selector,
|
||||
limit,
|
||||
items,
|
||||
diagnostics,
|
||||
items: commits,
|
||||
diagnostics: repository_diagnostics(diagnostics),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -681,7 +680,8 @@ async fn repository_tickets(
|
|||
AxumPath(repository_id): AxumPath<String>,
|
||||
Query(query): Query<TicketKanbanQuery>,
|
||||
) -> ApiResult<Json<RepositoryTicketsResponse>> {
|
||||
let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||
repository_lookup(api.repository_reader().summary(&repository_id))?;
|
||||
let canonical_repository_id = repository_id;
|
||||
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
|
||||
let ProjectRecordList {
|
||||
items,
|
||||
|
|
@ -2056,13 +2056,58 @@ fn sanitize_backend_error(_message: &str) -> String {
|
|||
"operation failed; backend-private details were omitted".to_string()
|
||||
}
|
||||
|
||||
fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result<String> {
|
||||
let canonical_repository_id = api.local_repository_id();
|
||||
if LocalRepositoryReader::is_local_repository_id(repository_id, api.workspace_id()) {
|
||||
Ok(canonical_repository_id)
|
||||
} else {
|
||||
Err(Error::UnknownRepository(repository_id.to_string()))
|
||||
fn repository_diagnostics(
|
||||
diagnostics: Vec<crate::repositories::RepositoryDiagnostic>,
|
||||
) -> Vec<RuntimeDiagnostic> {
|
||||
diagnostics
|
||||
.into_iter()
|
||||
.map(|diagnostic| RuntimeDiagnostic {
|
||||
code: diagnostic.code,
|
||||
severity: match diagnostic.severity.as_str() {
|
||||
"error" => DiagnosticSeverity::Error,
|
||||
"warning" => DiagnosticSeverity::Warning,
|
||||
_ => DiagnosticSeverity::Info,
|
||||
},
|
||||
message: diagnostic.message,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -> ApiResult<T> {
|
||||
result.map_err(|error| match error {
|
||||
RepositoryLookupError::UnknownRepository { id } => {
|
||||
let message = format!("repository `{id}` is not configured for this workspace");
|
||||
ApiError::with_diagnostics(
|
||||
Error::RuntimeOperationFailed {
|
||||
runtime_id: "workspace-repository-registry".to_string(),
|
||||
code: "repository_not_configured".to_string(),
|
||||
message: message.clone(),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
code: "repository_not_configured".to_string(),
|
||||
severity: DiagnosticSeverity::Error,
|
||||
message,
|
||||
}],
|
||||
)
|
||||
}
|
||||
RepositoryLookupError::UnsupportedProvider { id, provider } => {
|
||||
let message = format!(
|
||||
"repository `{id}` uses unsupported provider `{provider}` for this operation"
|
||||
);
|
||||
ApiError::with_diagnostics(
|
||||
Error::RuntimeOperationFailed {
|
||||
runtime_id: "workspace-repository-registry".to_string(),
|
||||
code: "repository_provider_unsupported".to_string(),
|
||||
message: message.clone(),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
code: "repository_provider_unsupported".to_string(),
|
||||
severity: DiagnosticSeverity::Error,
|
||||
message,
|
||||
}],
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn ticket_kanban_columns(items: Vec<TicketSummary>) -> Vec<TicketKanbanColumn> {
|
||||
|
|
@ -2231,6 +2276,14 @@ impl IntoResponse for ApiError {
|
|||
| Error::UnknownRepository(_) => StatusCode::NOT_FOUND,
|
||||
Error::Ticket(_) => StatusCode::NOT_FOUND,
|
||||
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "repository_provider_unsupported" =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => {
|
||||
StatusCode::UNAUTHORIZED
|
||||
}
|
||||
|
|
@ -2290,7 +2343,7 @@ mod tests {
|
|||
use crate::store::SqliteWorkspaceStore;
|
||||
|
||||
const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
|
||||
const TEST_REPOSITORY_ID: &str = "local-0192f0e8-4d84-7d6e-a000-000000000001";
|
||||
const TEST_REPOSITORY_ID: &str = "main";
|
||||
const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z";
|
||||
|
||||
#[test]
|
||||
|
|
@ -2402,8 +2455,17 @@ mod tests {
|
|||
fn test_server_config(workspace_root: impl Into<PathBuf>) -> ServerConfig {
|
||||
let workspace_root = workspace_root.into();
|
||||
let store_root = workspace_root.join(".test-embedded-runtime-store");
|
||||
ServerConfig::local_dev(workspace_root, test_identity())
|
||||
.with_embedded_runtime_store_root(store_root)
|
||||
let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity())
|
||||
.with_embedded_runtime_store_root(store_root);
|
||||
config.repositories = vec![ConfiguredRepository {
|
||||
id: TEST_REPOSITORY_ID.to_string(),
|
||||
provider: "git".to_string(),
|
||||
uri: ".".to_string(),
|
||||
path: workspace_root,
|
||||
display_name: Some("Test Repository".to_string()),
|
||||
default_selector: Some("HEAD".to_string()),
|
||||
}];
|
||||
config
|
||||
}
|
||||
|
||||
async fn test_app(workspace_root: impl Into<PathBuf>) -> Router {
|
||||
|
|
@ -2861,16 +2923,26 @@ mod tests {
|
|||
|
||||
let repositories = get_json(app.clone(), "/api/repositories").await;
|
||||
assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID);
|
||||
assert_eq!(repositories["items"][0]["kind"], "local");
|
||||
assert_eq!(repositories["items"][0]["kind"], "git");
|
||||
assert_eq!(
|
||||
repositories["items"][0]["record_authority"],
|
||||
"workspace-backend-config"
|
||||
);
|
||||
assert!(
|
||||
repositories
|
||||
.to_string()
|
||||
.contains("repository_git_unavailable")
|
||||
);
|
||||
|
||||
let repository_detail = get_json(app.clone(), "/api/repositories/local").await;
|
||||
let repository_detail = get_json(app.clone(), "/api/repositories/main").await;
|
||||
assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID);
|
||||
|
||||
let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await;
|
||||
let repository_log = get_json(app.clone(), "/api/repositories/main/log?limit=3").await;
|
||||
assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);
|
||||
assert_eq!(repository_log["default_selector"], "HEAD");
|
||||
assert_eq!(repository_log["limit"], 3);
|
||||
|
||||
let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await;
|
||||
let repository_tickets = get_json(app.clone(), "/api/repositories/main/tickets").await;
|
||||
assert_eq!(repository_tickets["repository_id"], TEST_REPOSITORY_ID);
|
||||
let ready_column = repository_tickets["columns"]
|
||||
.as_array()
|
||||
|
|
@ -3324,6 +3396,77 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_repository_config_returns_empty_list_with_warning() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut config = test_server_config(root.path());
|
||||
config.repositories.clear();
|
||||
let api = WorkspaceApi::new_with_execution_backend(
|
||||
config,
|
||||
Arc::new(SqliteWorkspaceStore::in_memory().unwrap()),
|
||||
Arc::new(DeterministicExecutionBackend::default()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_router(api);
|
||||
|
||||
let repositories = get_json(app, "/api/repositories").await;
|
||||
|
||||
assert!(repositories["items"].as_array().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
repositories["diagnostics"][0]["code"],
|
||||
"repository_config_empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_log_rejects_unknown_or_unsupported_configured_repository() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut config = test_server_config(root.path());
|
||||
config.repositories = vec![ConfiguredRepository {
|
||||
id: "files".to_string(),
|
||||
provider: "local_fs".to_string(),
|
||||
uri: ".".to_string(),
|
||||
path: root.path().to_path_buf(),
|
||||
display_name: None,
|
||||
default_selector: None,
|
||||
}];
|
||||
let api = WorkspaceApi::new_with_execution_backend(
|
||||
config,
|
||||
Arc::new(SqliteWorkspaceStore::in_memory().unwrap()),
|
||||
Arc::new(DeterministicExecutionBackend::default()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_router(api);
|
||||
|
||||
let unknown = request_json(
|
||||
app.clone(),
|
||||
"GET",
|
||||
"/api/repositories/main/log",
|
||||
None,
|
||||
StatusCode::NOT_FOUND,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
unknown["diagnostics"][0]["code"],
|
||||
"repository_not_configured"
|
||||
);
|
||||
|
||||
let unsupported = request_json(
|
||||
app,
|
||||
"GET",
|
||||
"/api/repositories/files/log",
|
||||
None,
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
unsupported["diagnostics"][0]["code"],
|
||||
"repository_provider_unsupported"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_runtime_api_routes_by_runtime_and_worker_ids_without_leaking_internals() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -41,6 +41,17 @@ frontend_url = "http://127.0.0.1:5173"
|
|||
[limits]
|
||||
max_records = 200
|
||||
|
||||
# Repository registry. Browser/API repository projection reads only configured
|
||||
# entries and never falls back to the backend process cwd. Relative URI values
|
||||
# are resolved from this workspace config root. Git is the v0 supported provider.
|
||||
#
|
||||
# [[repositories]]
|
||||
# id = "main"
|
||||
# provider = "git"
|
||||
# uri = "."
|
||||
# display_name = "Main repository"
|
||||
# default_selector = "HEAD"
|
||||
|
||||
# Remote Runtime sources. Token values must not be written here.
|
||||
# Use token_ref only after secret-ref resolution is implemented for this config.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --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 src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts",
|
||||
"test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
ObjectiveDetail,
|
||||
ObjectiveListResponse,
|
||||
RepositoryDetailResponse,
|
||||
RepositoryListResponse,
|
||||
RepositorySummary,
|
||||
RepositoryTicketsResponse,
|
||||
Worker,
|
||||
|
|
@ -17,19 +18,21 @@
|
|||
type WorkspaceView = 'overview' | 'repository' | 'objectives' | 'objective';
|
||||
|
||||
type RouteState =
|
||||
| { page: 'overview'; objectiveId?: undefined }
|
||||
| { page: 'repository'; objectiveId?: undefined }
|
||||
| { page: 'objectives'; objectiveId?: undefined }
|
||||
| { page: 'objective'; objectiveId: string };
|
||||
| { page: 'overview'; objectiveId?: undefined; repositoryId?: undefined }
|
||||
| { page: 'repository'; repositoryId: string; objectiveId?: undefined }
|
||||
| { page: 'objectives'; objectiveId?: undefined; repositoryId?: undefined }
|
||||
| { page: 'objective'; objectiveId: string; repositoryId?: undefined };
|
||||
|
||||
let {
|
||||
view = 'overview',
|
||||
objectiveId = null
|
||||
}: { view?: WorkspaceView; repositoryId?: string; objectiveId?: string | null } = $props();
|
||||
objectiveId = null,
|
||||
repositoryId = null
|
||||
}: { view?: WorkspaceView; repositoryId?: string | null; objectiveId?: string | null } = $props();
|
||||
|
||||
let workspace = $state<WorkspaceResponse | null>(null);
|
||||
let hosts = $state<ListResponse<Host> | null>(null);
|
||||
let workers = $state<ListResponse<Worker> | null>(null);
|
||||
let repositories = $state<RepositoryListResponse | null>(null);
|
||||
let repository = $state<RepositorySummary | null>(null);
|
||||
let repositoryTickets = $state<RepositoryTicketsResponse | null>(null);
|
||||
let objectives = $state<ObjectiveListResponse | null>(null);
|
||||
|
|
@ -38,13 +41,14 @@
|
|||
let workspaceError = $state<string | null>(null);
|
||||
let hostsError = $state<string | null>(null);
|
||||
let workersError = $state<string | null>(null);
|
||||
let repositoriesError = $state<string | null>(null);
|
||||
let repositoryError = $state<string | null>(null);
|
||||
let repositoryTicketsError = $state<string | null>(null);
|
||||
let objectivesError = $state<string | null>(null);
|
||||
let objectiveDetailError = $state<string | null>(null);
|
||||
let objectiveDetailLoading = $state(false);
|
||||
let objectiveDetailRequest = 0;
|
||||
let route = $derived(routeFromView(view, objectiveId));
|
||||
let route = $derived(routeFromView(view, objectiveId, repositoryId));
|
||||
let currentPath = $derived(pathFromRoute(route));
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
|
|
@ -85,10 +89,22 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function loadRepository() {
|
||||
async function loadRepositories() {
|
||||
repositoriesError = null;
|
||||
try {
|
||||
repositories = await getJson<RepositoryListResponse>('/api/repositories');
|
||||
} catch (error) {
|
||||
repositoriesError = error instanceof Error ? error.message : String(error);
|
||||
repositories = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRepository(id: string) {
|
||||
repositoryError = null;
|
||||
try {
|
||||
const detail = await getJson<RepositoryDetailResponse>('/api/repositories/local');
|
||||
const detail = await getJson<RepositoryDetailResponse>(
|
||||
`/api/repositories/${encodeURIComponent(id)}`
|
||||
);
|
||||
repository = detail.item;
|
||||
} catch (error) {
|
||||
repositoryError = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -96,10 +112,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function loadRepositoryTickets() {
|
||||
async function loadRepositoryTickets(id: string) {
|
||||
repositoryTicketsError = null;
|
||||
try {
|
||||
repositoryTickets = await getJson<RepositoryTicketsResponse>('/api/repositories/local/tickets');
|
||||
repositoryTickets = await getJson<RepositoryTicketsResponse>(
|
||||
`/api/repositories/${encodeURIComponent(id)}/tickets`
|
||||
);
|
||||
} catch (error) {
|
||||
repositoryTicketsError = error instanceof Error ? error.message : String(error);
|
||||
repositoryTickets = null;
|
||||
|
|
@ -137,9 +155,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
function routeFromView(view: WorkspaceView, objectiveId: string | null): RouteState {
|
||||
if (view === 'repository') {
|
||||
return { page: 'repository' };
|
||||
function routeFromView(
|
||||
view: WorkspaceView,
|
||||
objectiveId: string | null,
|
||||
repositoryId: string | null
|
||||
): RouteState {
|
||||
if (view === 'repository' && repositoryId) {
|
||||
return { page: 'repository', repositoryId };
|
||||
}
|
||||
if (view === 'objective' && objectiveId) {
|
||||
return { page: 'objective', objectiveId };
|
||||
|
|
@ -152,7 +174,7 @@
|
|||
|
||||
function pathFromRoute(route: RouteState): string {
|
||||
if (route.page === 'repository') {
|
||||
return '/repositories/local';
|
||||
return `/repositories/${route.repositoryId}`;
|
||||
}
|
||||
if (route.page === 'objective') {
|
||||
return `/objectives/${route.objectiveId}`;
|
||||
|
|
@ -171,11 +193,22 @@
|
|||
void loadWorkspace();
|
||||
void loadHosts();
|
||||
void loadWorkers();
|
||||
void loadRepository();
|
||||
void loadRepositoryTickets();
|
||||
void loadRepositories();
|
||||
void loadObjectives();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (route.page === 'repository') {
|
||||
void loadRepository(route.repositoryId);
|
||||
void loadRepositoryTickets(route.repositoryId);
|
||||
} else {
|
||||
repository = null;
|
||||
repositoryTickets = null;
|
||||
repositoryError = null;
|
||||
repositoryTicketsError = null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const selectedObjectiveId = route.page === 'objective' ? route.objectiveId : null;
|
||||
if (selectedObjectiveId) {
|
||||
|
|
@ -199,7 +232,7 @@
|
|||
</svelte:head>
|
||||
|
||||
<div class="workspace-layout">
|
||||
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} />
|
||||
<WorkspaceSidebar {workspace} {workspaceError} {repositories} {repositoriesError} {currentPath} />
|
||||
|
||||
<main class="shell">
|
||||
{#if route.page === 'repository'}
|
||||
|
|
@ -216,8 +249,12 @@
|
|||
<dd>{repository.kind}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Workspace root</dt>
|
||||
<dd><code>{repository.workspace_root}</code></dd>
|
||||
<dt>Provider</dt>
|
||||
<dd>{repository.provider}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Default selector</dt>
|
||||
<dd>{repository.default_selector ?? 'none configured'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Record authority</dt>
|
||||
|
|
@ -225,13 +262,25 @@
|
|||
</div>
|
||||
<div>
|
||||
<dt>Git</dt>
|
||||
<dd>{repository.git.status}</dd>
|
||||
<dd>{repository.git?.status ?? 'not available'}</dd>
|
||||
</div>
|
||||
{#if repository.diagnostics && repository.diagnostics.length > 0}
|
||||
<div>
|
||||
<dt>Diagnostics</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
{#each repository.diagnostics as diagnostic}
|
||||
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
{:else if repositoryError}
|
||||
<p class="error">{repositoryError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/repositories/local</code>…</p>
|
||||
<p>Waiting for <code>/api/repositories/{route.repositoryId}</code>…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
|
@ -245,7 +294,7 @@
|
|||
{:else if repositoryTicketsError}
|
||||
<p class="error">{repositoryTicketsError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/repositories/local/tickets</code>…</p>
|
||||
<p>Waiting for <code>/api/repositories/{route.repositoryId}/tickets</code>…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,46 @@
|
|||
<script lang="ts">
|
||||
import type { WorkspaceResponse } from './types';
|
||||
import { projectRepositoryNav } from './repository-nav';
|
||||
import type { RepositoryListResponse } from './types';
|
||||
|
||||
type Props = {
|
||||
workspace: WorkspaceResponse | null;
|
||||
repositories: RepositoryListResponse | null;
|
||||
repositoriesError?: string | null;
|
||||
currentPath?: string;
|
||||
};
|
||||
|
||||
let { workspace, currentPath = '/' }: Props = $props();
|
||||
let { repositories, repositoriesError = null, currentPath = '/' }: Props = $props();
|
||||
let navigation = $derived(projectRepositoryNav(repositories, currentPath));
|
||||
</script>
|
||||
|
||||
<section class="nav-section" aria-labelledby="repositories-heading">
|
||||
<div class="section-heading-row">
|
||||
<h2 id="repositories-heading">repositories</h2>
|
||||
<span class="section-count">1</span>
|
||||
<span class="section-count">{navigation.count}</span>
|
||||
</div>
|
||||
|
||||
{#if repositoriesError}
|
||||
<p class="nav-empty error">Repository registry unavailable.</p>
|
||||
{:else if !repositories}
|
||||
<p class="nav-empty">Loading repositories…</p>
|
||||
{:else if navigation.items.length === 0}
|
||||
<p class="nav-empty">No repositories configured.</p>
|
||||
{#if navigation.diagnostics.length > 0}
|
||||
<ul class="diagnostics" aria-label="Repository diagnostics">
|
||||
{#each navigation.diagnostics as diagnostic}
|
||||
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<ul class="nav-list" aria-label="Repositories">
|
||||
{#each navigation.items as item (item.id)}
|
||||
<li>
|
||||
<a class="nav-item" class:active={currentPath.startsWith('/repositories')} href="/repositories/local">
|
||||
<span class="item-title">{workspace?.display_name ?? 'local workspace'}</span>
|
||||
<span class="item-meta">local repository · read-only</span>
|
||||
<a class="nav-item" class:active={item.active} href={item.href} aria-current={item.active ? 'page' : undefined}>
|
||||
<span class="item-title">{item.title}</span>
|
||||
<span class="item-meta">{item.meta}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -2,15 +2,23 @@
|
|||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||
import type { WorkspaceResponse } from './types';
|
||||
import type { RepositoryListResponse, WorkspaceResponse } from './types';
|
||||
|
||||
type Props = {
|
||||
workspace: WorkspaceResponse | null;
|
||||
workspaceError?: string | null;
|
||||
repositories?: RepositoryListResponse | null;
|
||||
repositoriesError?: string | null;
|
||||
currentPath?: string;
|
||||
};
|
||||
|
||||
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props();
|
||||
let {
|
||||
workspace,
|
||||
workspaceError = null,
|
||||
repositories = null,
|
||||
repositoriesError = null,
|
||||
currentPath = '/'
|
||||
}: Props = $props();
|
||||
let settingsActive = $derived(currentPath.startsWith("/settings"));
|
||||
</script>
|
||||
|
||||
|
|
@ -43,7 +51,7 @@
|
|||
</header>
|
||||
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<RepositoriesNavSection {workspace} {currentPath} />
|
||||
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} />
|
||||
<ObjectivesNavSection {currentPath} />
|
||||
<WorkersNavSection {currentPath} />
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { projectRepositoryNav } from "./repository-nav.ts";
|
||||
import type { RepositoryListResponse } from "./types.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
function repositories(
|
||||
items: RepositoryListResponse["items"],
|
||||
): RepositoryListResponse {
|
||||
return {
|
||||
workspace_id: "workspace-1",
|
||||
items,
|
||||
source: "workspace_backend_config",
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("repository nav does not invent main for an empty registry", () => {
|
||||
const projection = projectRepositoryNav({
|
||||
workspace_id: "workspace-1",
|
||||
items: [],
|
||||
source: "workspace_backend_config",
|
||||
diagnostics: [
|
||||
{
|
||||
code: "repository_config_empty",
|
||||
severity: "warning",
|
||||
message: "No repositories configured",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assertEquals(projection.count, 0);
|
||||
assertEquals(projection.items, []);
|
||||
assertEquals(projection.diagnostics[0].code, "repository_config_empty");
|
||||
});
|
||||
|
||||
Deno.test("repository nav links configured non-main repository ids", () => {
|
||||
const projection = projectRepositoryNav(
|
||||
repositories([
|
||||
{
|
||||
id: "infra",
|
||||
display_name: "Infrastructure",
|
||||
kind: "git",
|
||||
provider: "git",
|
||||
record_authority: "workspace-backend-config",
|
||||
git: null,
|
||||
diagnostics: [],
|
||||
},
|
||||
]),
|
||||
"/repositories/infra",
|
||||
);
|
||||
|
||||
assertEquals(projection.count, 1);
|
||||
assertEquals(projection.items[0], {
|
||||
id: "infra",
|
||||
title: "Infrastructure",
|
||||
href: "/repositories/infra",
|
||||
meta: "git repository · read-only",
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
36
web/workspace/src/lib/workspace-sidebar/repository-nav.ts
Normal file
36
web/workspace/src/lib/workspace-sidebar/repository-nav.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { Diagnostic, RepositoryListResponse } from "./types";
|
||||
|
||||
export type RepositoryNavItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
meta: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type RepositoryNavProjection = {
|
||||
count: number;
|
||||
items: RepositoryNavItem[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export function projectRepositoryNav(
|
||||
repositories: RepositoryListResponse | null,
|
||||
currentPath = "/",
|
||||
): RepositoryNavProjection {
|
||||
const summaries = repositories?.items ?? [];
|
||||
return {
|
||||
count: summaries.length,
|
||||
diagnostics: repositories?.diagnostics ?? [],
|
||||
items: summaries.map((repository) => {
|
||||
const href = `/repositories/${encodeURIComponent(repository.id)}`;
|
||||
return {
|
||||
id: repository.id,
|
||||
title: repository.display_name || repository.id,
|
||||
href,
|
||||
meta: `${repository.provider} repository · read-only`,
|
||||
active: currentPath === href || currentPath.startsWith(`${href}/`),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -181,34 +181,42 @@ export type RepositorySummary = {
|
|||
id: string;
|
||||
display_name: string;
|
||||
kind: string;
|
||||
workspace_root: string;
|
||||
provider: string;
|
||||
default_selector?: string | null;
|
||||
record_authority: string;
|
||||
git: GitRepositorySummary;
|
||||
git?: GitRepositorySummary | null;
|
||||
diagnostics?: Diagnostic[];
|
||||
};
|
||||
|
||||
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[];
|
||||
dirty: boolean;
|
||||
remotes: GitRemoteSummary[];
|
||||
};
|
||||
|
||||
export type GitRemoteSummary = {
|
||||
name: string;
|
||||
url: string;
|
||||
redacted: boolean;
|
||||
fetch_url: string;
|
||||
};
|
||||
|
||||
export type GitCommitSummary = {
|
||||
hash: string;
|
||||
subject: string;
|
||||
short_hash: string;
|
||||
summary: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
timestamp: string;
|
||||
author_date: string;
|
||||
parents: string[];
|
||||
refs: string[];
|
||||
};
|
||||
|
||||
export type RepositoryListResponse = {
|
||||
workspace_id: string;
|
||||
items: RepositorySummary[];
|
||||
source: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type RepositoryDetailResponse = {
|
||||
|
|
@ -220,6 +228,7 @@ export type RepositoryDetailResponse = {
|
|||
export type RepositoryLogResponse = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
default_selector?: string | null;
|
||||
limit: number;
|
||||
items: GitCommitSummary[];
|
||||
diagnostics: Diagnostic[];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
|
||||
</script>
|
||||
|
||||
<WorkspacePage view="repository" />
|
||||
<WorkspacePage view="repository" repositoryId={page.params.repositoryId} />
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user