diff --git a/crates/rm-handlers/src/lib.rs b/crates/rm-handlers/src/lib.rs index 5f6b3e9..5102d30 100644 --- a/crates/rm-handlers/src/lib.rs +++ b/crates/rm-handlers/src/lib.rs @@ -50,6 +50,7 @@ pub mod issues; pub mod news; pub mod projects; pub mod roles; +pub mod scm; pub mod taxonomy; pub mod time_entries; pub mod users; diff --git a/crates/rm-handlers/src/scm.rs b/crates/rm-handlers/src/scm.rs new file mode 100644 index 0000000..118f543 --- /dev/null +++ b/crates/rm-handlers/src/scm.rs @@ -0,0 +1,372 @@ +//! **W7** — SCM-light: Repository + Changeset browse handlers. +//! +//! Read-only. Renders metadata Redmine stores ABOUT repos / commits; +//! no live VCS driver (that's a later sprint per the Integration Plan +//! — "W7 SCM-light (read-only; no Git driver yet)"). +//! +//! Routes (mounted by [`router`]): +//! +//! - `GET /repositories` + `/repositories/:id` (0x010A) +//! - `GET /changesets` + `/changesets/:revision` (0x0112) + +use axum::extract::{Path, State}; +use axum::response::Html; +use axum::routing::get; +use axum::Router; +use ogar_render_askama::{ + render_detail, render_list, CellData, CellSource, ColumnKind, RenderColumn, RowSource, +}; +use rm_store::{ChangesetRow, RepositoryRow}; +use surrealdb_types::{RecordId, ToSql}; + +use crate::common::{ + encode_path_segment, html_escape, identifier_to_u64, record_id_to_u64, wrap_in_doc, AppState, + HandlerError, +}; + +// ── Repository (0x010A) ───────────────────────────────────────────── + +/// `GET /repositories` — render the repository list. +pub async fn repository_list(State(state): State) -> Result, HandlerError> { + let repos = state.store.list_repositories().await?; + let cols = repository_list_columns(); + let hrefs: Vec = repos + .iter() + .map(|r| { + r.id.as_ref() + .map(|rid| format!("/repositories/{}", rid.key.to_sql())) + .unwrap_or_default() + }) + .collect(); + let ids: Vec = repos + .iter() + .map(|r| r.id.as_ref().map(record_id_to_u64).unwrap_or(0)) + .collect(); + let rows: Vec> = repos + .iter() + .enumerate() + .map(|(idx, r)| RowSource { + record_id: ids[idx], + css_classes: "repository", + group: None, + inline: vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &r.url, + href: &hrefs[idx], + }, + }, + CellSource { + column: &cols[1], + css_classes: "", + data: CellData::Plain { value: &r.scm_type }, + }, + ], + block: Vec::new(), + }) + .collect(); + let body = render_list( + "Repositories", + 0x010A, + "project_repository", + &cols, + &[], + &rows, + ) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc("Repositories", &body))) +} + +/// `GET /repositories/:id` — render a repository detail page. +pub async fn repository_detail( + State(state): State, + Path(id_str): Path, +) -> Result, HandlerError> { + let rid = RecordId::new("repository", id_str.as_str()); + let repo: RepositoryRow = state.store.find_repository(&rid).await?; + let cols = repository_detail_columns(); + let href = format!("/repositories/{}", id_str); + let headline = format!( + "{}", + html_escape(&href), + html_escape(&repo.url) + ); + let cells = vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &repo.url, + href: &href, + }, + }, + CellSource { + column: &cols[1], + css_classes: "", + data: CellData::Plain { + value: &repo.scm_type, + }, + }, + ]; + let body = render_detail( + 0x010A, + "project_repository", + record_id_to_u64(&rid), + &headline, + &repo.scm_type, + &cols, + &cells, + ) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc(&repo.url, &body))) +} + +fn repository_list_columns() -> [RenderColumn; 2] { + [ + RenderColumn::new("url", "URL", ColumnKind::PrimaryLink) + .sortable() + .frozen(), + RenderColumn::new("scm_type", "SCM", ColumnKind::Plain).sortable(), + ] +} + +fn repository_detail_columns() -> [RenderColumn; 2] { + [ + RenderColumn::new("url", "URL", ColumnKind::PrimaryLink), + RenderColumn::new("scm_type", "SCM", ColumnKind::Plain), + ] +} + +// ── Changeset (0x0112) ────────────────────────────────────────────── + +/// `GET /changesets` — render the changeset list. +pub async fn changeset_list(State(state): State) -> Result, HandlerError> { + let changesets = state.store.list_changesets().await?; + let cols = changeset_list_columns(); + let hrefs: Vec = changesets + .iter() + .map(|c| format!("/changesets/{}", encode_path_segment(&c.revision))) + .collect(); + let ids: Vec = changesets + .iter() + .map(|c| identifier_to_u64(&c.revision)) + .collect(); + let rows: Vec> = changesets + .iter() + .enumerate() + .map(|(idx, c)| RowSource { + record_id: ids[idx], + css_classes: "changeset", + group: None, + inline: vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &c.revision, + href: &hrefs[idx], + }, + }, + CellSource { + column: &cols[1], + css_classes: "", + data: CellData::Plain { + value: &c.commit_date, + }, + }, + CellSource { + column: &cols[2], + css_classes: "", + data: CellData::Plain { value: &c.comments }, + }, + ], + block: Vec::new(), + }) + .collect(); + let body = render_list("Changesets", 0x0112, "project_changeset", &cols, &[], &rows) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc("Changesets", &body))) +} + +/// `GET /changesets/:revision` — render a changeset detail page. +pub async fn changeset_detail( + State(state): State, + Path(revision): Path, +) -> Result, HandlerError> { + let cs: ChangesetRow = state.store.find_changeset_by_revision(&revision).await?; + let cols = changeset_detail_columns(); + let href = format!("/changesets/{}", encode_path_segment(&cs.revision)); + let headline = format!( + "{}", + html_escape(&href), + html_escape(&cs.revision) + ); + let cells = vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &cs.revision, + href: &href, + }, + }, + CellSource { + column: &cols[1], + css_classes: "", + data: CellData::Plain { + value: &cs.commit_date, + }, + }, + CellSource { + column: &cols[2], + css_classes: "", + data: CellData::Plain { + value: &cs.comments, + }, + }, + ]; + let body = render_detail( + 0x0112, + "project_changeset", + identifier_to_u64(&cs.revision), + &headline, + &cs.commit_date, + &cols, + &cells, + ) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc(&cs.revision, &body))) +} + +fn changeset_list_columns() -> [RenderColumn; 3] { + [ + RenderColumn::new("revision", "Revision", ColumnKind::PrimaryLink) + .sortable() + .frozen(), + RenderColumn::new("commit_date", "Date", ColumnKind::Plain).sortable(), + RenderColumn::new("comments", "Comment", ColumnKind::Plain), + ] +} + +fn changeset_detail_columns() -> [RenderColumn; 3] { + [ + RenderColumn::new("revision", "Revision", ColumnKind::PrimaryLink), + RenderColumn::new("commit_date", "Date", ColumnKind::Plain), + RenderColumn::new("comments", "Comment", ColumnKind::Plain), + ] +} + +// ── Router ────────────────────────────────────────────────────────── + +/// Build the SCM-light router (Repository + Changeset). One merge +/// call in rm-server brings both. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/repositories", get(repository_list)) + .route("/repositories/:id", get(repository_detail)) + .route("/changesets", get(changeset_list)) + .route("/changesets/:revision", get(changeset_detail)) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use rm_store::{NewChangeset, NewRepository, Store}; + use tower::ServiceExt; + + async fn body_of(app: Router, uri: &str) -> (StatusCode, String) { + let res = app + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = res.status(); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + (status, String::from_utf8(bytes.to_vec()).unwrap()) + } + + #[tokio::test] + async fn repository_list_empty_state() { + let store = Store::open().await.unwrap(); + let (status, s) = body_of(router(AppState { store }), "/repositories").await; + assert_eq!(status, StatusCode::OK); + assert!(s.contains("data-class-id=\"0x010A\""), "{s}"); + assert!(s.contains("No data."), "{s}"); + } + + #[tokio::test] + async fn repository_list_and_detail() { + let store = Store::open().await.unwrap(); + let inserted = store + .create_repository(NewRepository { + url: "https://example.com/repo.git".to_string(), + scm_type: "Git".to_string(), + }) + .await + .unwrap(); + let key = inserted.id.unwrap().key.to_sql(); + let app = router(AppState { store }); + + let (_, list) = body_of(app.clone(), "/repositories").await; + assert!(list.contains("https://example.com/repo.git"), "{list}"); + assert!(list.contains("Git"), "{list}"); + assert!(list.contains("href=\"/repositories/"), "{list}"); + + let (status, detail) = body_of(app, &format!("/repositories/{key}")).await; + assert_eq!(status, StatusCode::OK); + assert!(detail.contains("https://example.com/repo.git"), "{detail}"); + assert!(detail.contains("data-class-id=\"0x010A\""), "{detail}"); + } + + #[tokio::test] + async fn repository_detail_404() { + let store = Store::open().await.unwrap(); + let (status, _) = body_of(router(AppState { store }), "/repositories/missing").await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn changeset_list_empty_state() { + let store = Store::open().await.unwrap(); + let (status, s) = body_of(router(AppState { store }), "/changesets").await; + assert_eq!(status, StatusCode::OK); + assert!(s.contains("data-class-id=\"0x0112\""), "{s}"); + assert!(s.contains("No data."), "{s}"); + } + + #[tokio::test] + async fn changeset_list_and_detail_by_revision() { + let store = Store::open().await.unwrap(); + store + .create_changeset(NewChangeset { + revision: "deadbeef".to_string(), + commit_date: "2026-06-21".to_string(), + comments: "Initial commit".to_string(), + }) + .await + .unwrap(); + let app = router(AppState { store }); + + let (_, list) = body_of(app.clone(), "/changesets").await; + assert!(list.contains("deadbeef"), "{list}"); + assert!(list.contains("Initial commit"), "{list}"); + assert!(list.contains("href=\"/changesets/deadbeef\""), "{list}"); + + let (status, detail) = body_of(app, "/changesets/deadbeef").await; + assert_eq!(status, StatusCode::OK); + assert!(detail.contains("deadbeef"), "{detail}"); + assert!(detail.contains("data-class-id=\"0x0112\""), "{detail}"); + } + + #[tokio::test] + async fn changeset_detail_404() { + let store = Store::open().await.unwrap(); + let (status, _) = body_of(router(AppState { store }), "/changesets/nope").await; + assert_eq!(status, StatusCode::NOT_FOUND); + } +} diff --git a/crates/rm-server/src/router.rs b/crates/rm-server/src/router.rs index d548925..9afbc89 100644 --- a/crates/rm-server/src/router.rs +++ b/crates/rm-server/src/router.rs @@ -85,6 +85,7 @@ pub fn build_router_with(store: Store, auth_cfg: AuthConfig) -> Router { .merge(rm_handlers::news::router(state.clone())) // W6a: /news .merge(rm_handlers::projects::router(state.clone())) // W2: /projects .merge(rm_handlers::roles::router(state.clone())) // W4b: /roles + .merge(rm_handlers::scm::router(state.clone())) // W7: /repositories + /changesets .merge(rm_handlers::taxonomy::router(state.clone())) // W5: /issue_statuses + /trackers + /enumerations/issue_priorities .merge(rm_handlers::time_entries::router(state.clone())) // W3: /time_entries .merge(rm_handlers::users::router(state.clone())) // W4a: /users diff --git a/crates/rm-store/src/lib.rs b/crates/rm-store/src/lib.rs index 37702b9..29f40b8 100644 --- a/crates/rm-store/src/lib.rs +++ b/crates/rm-store/src/lib.rs @@ -50,6 +50,7 @@ mod issue; mod news; mod project; mod role; +mod scm; mod store; mod taxonomy; mod time_entry; @@ -61,6 +62,7 @@ pub use issue::{IssueRow, NewIssue}; pub use news::{NewNews, NewsRow}; pub use project::{NewProject, ProjectRow}; pub use role::{NewRole, RoleRow}; +pub use scm::{ChangesetRow, NewChangeset, NewRepository, RepositoryRow}; pub use store::Store; pub use taxonomy::{ IssuePriorityRow, IssueStatusRow, NewIssuePriority, NewIssueStatus, NewTracker, TrackerRow, diff --git a/crates/rm-store/src/scm.rs b/crates/rm-store/src/scm.rs new file mode 100644 index 0000000..8f21c40 --- /dev/null +++ b/crates/rm-store/src/scm.rs @@ -0,0 +1,218 @@ +//! SCM-light (W7) — Repository + Changeset row storage. +//! +//! Read-only metadata: the rows carry what Redmine stores ABOUT a +//! repository / commit, NOT a live VCS connection. The Git / SVN / +//! Mercurial driver layer (Redmine's `Repository::Git` etc.) is a +//! later sprint — W7 ships the browse surface over already-imported +//! metadata. +//! +//! | Concept | Class id | Canonical | Redmine model | +//! |---|---|---|---| +//! | Repository | `0x010A project_repository` | url + scm_type | Repository | +//! | Changeset | `0x0112 project_changeset` | revision + commit_date + comments | Changeset | + +use surrealdb_types::{RecordId, SurrealValue}; + +use crate::{Store, StoreError}; + +// ── Repository (0x010A) ───────────────────────────────────────────── + +/// Input for [`Store::create_repository`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct NewRepository { + /// Clone / checkout URL. + pub url: String, + /// SCM kind: `"Git"`, `"Subversion"`, `"Mercurial"`, … (Redmine's + /// `Repository#type` minus the `Repository::` prefix). + pub scm_type: String, +} + +/// Row returned by [`Store::find_repository`] / [`Store::list_repositories`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct RepositoryRow { + /// SurrealDB record id (`repository:`). + pub id: Option, + /// Clone / checkout URL. + pub url: String, + /// SCM kind. + pub scm_type: String, +} + +// ── Changeset (0x0112) ────────────────────────────────────────────── + +/// Input for [`Store::create_changeset`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct NewChangeset { + /// Revision identifier — git sha / svn revision number. The URL + /// key (Redmine: `/repository/revisions/:rev`). + pub revision: String, + /// `YYYY-MM-DD` commit date (string today; a `Date` SurrealValue + /// conversion lands when D2 needs date-range filtering). + pub commit_date: String, + /// Commit message. + pub comments: String, +} + +/// Row returned by [`Store::find_changeset_by_revision`] / [`Store::list_changesets`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct ChangesetRow { + /// SurrealDB record id. + pub id: Option, + /// Revision identifier. + pub revision: String, + /// Commit date. + pub commit_date: String, + /// Commit message. + pub comments: String, +} + +impl Store { + /// Insert a Repository. + pub async fn create_repository(&self, new: NewRepository) -> Result { + let row: Option = self.db().create("repository").content(new).await?; + row.ok_or(StoreError::NotFound) + } + + /// Read a Repository by its SurrealDB record id (Redmine keys + /// repositories by numeric PK, not a slug). + pub async fn find_repository(&self, id: &RecordId) -> Result { + match self.db().select(id.clone()).await { + Ok(Some(row)) => Ok(row), + Ok(None) => Err(StoreError::NotFound), + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + } + } + + /// List every Repository. + pub async fn list_repositories(&self) -> Result, StoreError> { + match self.db().select::>("repository").await { + Ok(rows) => Ok(rows), + Err(e) if e.is_not_found() => Ok(Vec::new()), + Err(e) => Err(StoreError::Surreal(e)), + } + } + + /// Insert a Changeset. + pub async fn create_changeset(&self, new: NewChangeset) -> Result { + let row: Option = self.db().create("changeset").content(new).await?; + row.ok_or(StoreError::NotFound) + } + + /// Find a Changeset by its revision identifier (the URL key). + pub async fn find_changeset_by_revision( + &self, + revision: &str, + ) -> Result { + match self + .db() + .query("SELECT * FROM changeset WHERE revision = $r LIMIT 1") + .bind(("r", revision.to_string())) + .await + { + Ok(mut res) => match res.take::>(0) { + Ok(rows) => rows.into_iter().next().ok_or(StoreError::NotFound), + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + }, + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + } + } + + /// List every Changeset, in insertion order. + pub async fn list_changesets(&self) -> Result, StoreError> { + match self.db().select::>("changeset").await { + Ok(rows) => Ok(rows), + Err(e) if e.is_not_found() => Ok(Vec::new()), + Err(e) => Err(StoreError::Surreal(e)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn repository_round_trips() { + let store = Store::open().await.unwrap(); + let inserted = store + .create_repository(NewRepository { + url: "https://github.com/AdaWorldAPI/redmine-rs.git".to_string(), + scm_type: "Git".to_string(), + }) + .await + .unwrap(); + assert_eq!(inserted.scm_type, "Git"); + let id = inserted.id.clone().unwrap(); + let fetched = store.find_repository(&id).await.unwrap(); + assert_eq!(fetched.url, inserted.url); + } + + #[tokio::test] + async fn repository_find_not_found() { + let store = Store::open().await.unwrap(); + let id = RecordId::new("repository", "missing"); + assert!(matches!( + store.find_repository(&id).await, + Err(StoreError::NotFound) + )); + } + + #[tokio::test] + async fn repository_list_empty_then_populated() { + let store = Store::open().await.unwrap(); + assert!(store.list_repositories().await.unwrap().is_empty()); + store + .create_repository(NewRepository { + url: "u".to_string(), + scm_type: "Git".to_string(), + }) + .await + .unwrap(); + assert_eq!(store.list_repositories().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn changeset_round_trips_by_revision() { + let store = Store::open().await.unwrap(); + store + .create_changeset(NewChangeset { + revision: "abc123".to_string(), + commit_date: "2026-06-21".to_string(), + comments: "Fix the foo".to_string(), + }) + .await + .unwrap(); + let r = store.find_changeset_by_revision("abc123").await.unwrap(); + assert_eq!(r.revision, "abc123"); + assert_eq!(r.comments, "Fix the foo"); + } + + #[tokio::test] + async fn changeset_find_not_found() { + let store = Store::open().await.unwrap(); + assert!(matches!( + store.find_changeset_by_revision("nope").await, + Err(StoreError::NotFound) + )); + } + + #[tokio::test] + async fn changeset_list_empty_then_populated() { + let store = Store::open().await.unwrap(); + assert!(store.list_changesets().await.unwrap().is_empty()); + for rev in ["r1", "r2"] { + store + .create_changeset(NewChangeset { + revision: rev.to_string(), + commit_date: "2026-06-21".to_string(), + comments: String::new(), + }) + .await + .unwrap(); + } + assert_eq!(store.list_changesets().await.unwrap().len(), 2); + } +}