diff --git a/docs/generated-operations.md b/docs/generated-operations.md index 46c9a73..1d24030 100644 --- a/docs/generated-operations.md +++ b/docs/generated-operations.md @@ -358,7 +358,6 @@ Auto-generated from the server OpenAPI spec. Each operation is available in both | DELETE | `/api/vcs/branches/{name}` | `autoDeleteApiVcsBranchesName` | `auto_delete_api_vcs_branches_name` | | POST | `/api/vcs/commit` | `vcscommit` | `vcs_commit` | | GET | `/api/vcs/diff` | `vcsdiff` | `vcs_diff` | -| POST | `/api/vcs/init` | `vcsinit` | `vcs_init` | | GET | `/api/vcs/log` | `vcslog` | `vcs_log` | | GET | `/api/vcs/log/dataset/{name}` | `autoGetApiVcsLogDatasetName` | `auto_get_api_vcs_log_dataset_name` | | GET | `/api/vcs/prs` | `autoGetApiVcsPrs` | `auto_get_api_vcs_prs` | @@ -369,6 +368,8 @@ Auto-generated from the server OpenAPI spec. Each operation is available in both | GET | `/api/vcs/prs/{id}/conflicts` | `autoGetApiVcsPrsIdConflicts` | `auto_get_api_vcs_prs_id_conflicts` | | POST | `/api/vcs/prs/{id}/merge` | `autoPostApiVcsPrsIdMerge` | `auto_post_api_vcs_prs_id_merge` | | POST | `/api/vcs/prs/{id}/reviews` | `autoPostApiVcsPrsIdReviews` | `auto_post_api_vcs_prs_id_reviews` | +| GET | `/api/vcs/repos` | `vcslistrepos` | `vcs_list_repos` | +| POST | `/api/vcs/repos` | `vcscreaterepo` | `vcs_create_repo` | | GET | `/api/vcs/tags` | `autoGetApiVcsTags` | `auto_get_api_vcs_tags` | | POST | `/api/vcs/tags` | `autoPostApiVcsTags` | `auto_post_api_vcs_tags` | | DELETE | `/api/vcs/tags/{name}` | `autoDeleteApiVcsTagsName` | `auto_delete_api_vcs_tags_name` | diff --git a/docs/python.md b/docs/python.md index 1c4505e..6b8a40f 100644 --- a/docs/python.md +++ b/docs/python.md @@ -161,7 +161,7 @@ from roteiro import analysis, attachments, collections, layers, raster, vcs | `collections` | `list_collections`, `get_collection`, `get_items`, `get_item`, `create_item`, `update_item`, `delete_item` | | `attachments` | `upload_attachment`, `list_attachments`, `download_attachment`, `delete_attachment` | | `layers` | `upload_layer`, `list_layers`, `get_layer`, `update_layer`, `publish_layer`, `archive_layer`, `upload_layer_data`, `delete_layer`, `preview_layer` | -| `vcs` | `init_repo`, `commit`, `log`, `diff`, `checkout` | +| `vcs` | `create_repo`, `list_repos`, `get_repo`, `delete_repo`, `commit`, `log`, `log_for_dataset`, `diff`, `checkout` | | `raster` | `get_raster_info`, `get_raster_stats`, `get_raster_histogram`, `get_raster_dimensions`, `get_raster_band_values`, `band_math`, `ndvi`, `hillshade`, `zonal_stats`, `export_raster`, `contour`, `viewshed`, `elevation_profile`, `kde`, `process`, `mosaic`, `get_mosaic_info` | ### Example: analysis helpers diff --git a/docs/typescript.md b/docs/typescript.md index d590999..f3aa39f 100644 --- a/docs/typescript.md +++ b/docs/typescript.md @@ -173,7 +173,7 @@ import { | `collections` | `listCollections`, `getCollection`, `getItems`, `getItem`, `createItem`, `updateItem`, `deleteItem` | | `attachments` | `uploadAttachment`, `listAttachments`, `downloadAttachment`, `deleteAttachment` | | `layers` | `uploadLayer`, `listLayers`, `getLayer`, `updateLayer`, `publishLayer`, `archiveLayer`, `uploadLayerData`, `deleteLayer`, `previewLayer` | -| `vcs` | `initRepo`, `commit`, `log`, `diff`, `checkout` | +| `vcs` | `createRepo`, `listRepos`, `getRepo`, `deleteRepo`, `commit`, `log`, `logForDataset`, `diff`, `checkout` | | `raster` | `getRasterInfo`, `getRasterStats`, `getRasterHistogram`, `getRasterDimensions`, `getRasterBandValues`, `bandMath`, `ndvi`, `hillshade`, `zonalStats`, `exportRaster`, `contour`, `viewshed`, `elevationProfile`, `kde`, `process`, `mosaic`, `getMosaicInfo` | ### Example: analysis helpers diff --git a/python/README.md b/python/README.md index 0f63fcb..c74355d 100644 --- a/python/README.md +++ b/python/README.md @@ -72,7 +72,7 @@ print(health.status, len(collections), len(features.features), len(areas), len(h | `collections` | `list_collections`, `get_collection`, `get_items`, `get_item`, `create_item`, `update_item`, `delete_item` | | `attachments` | `upload_attachment`, `list_attachments`, `download_attachment`, `delete_attachment` | | `layers` | `upload_layer`, `list_layers`, `get_layer`, `update_layer`, `publish_layer`, `archive_layer`, `upload_layer_data`, `delete_layer`, `preview_layer` | -| `vcs` | `init_repo`, `commit`, `log`, `diff`, `checkout` | +| `vcs` | `create_repo`, `list_repos`, `get_repo`, `delete_repo`, `commit`, `log`, `log_for_dataset`, `diff`, `checkout` | | `raster` | `get_raster_info`, `get_raster_stats`, `get_raster_histogram`, `get_raster_dimensions`, `get_raster_band_values`, `band_math`, `ndvi`, `hillshade`, `zonal_stats`, `export_raster`, `contour`, `viewshed`, `elevation_profile`, `kde`, `process`, `mosaic`, `get_mosaic_info` | ## Full API Coverage diff --git a/python/examples/quickstart.py b/python/examples/quickstart.py index 90d580d..35fa016 100644 --- a/python/examples/quickstart.py +++ b/python/examples/quickstart.py @@ -89,16 +89,16 @@ from roteiro import vcs -# Initialise a new spatial repository -# repo = vcs.init_repo(client, "/tmp/my-spatial-repo") -# print(f"VCS repo initialised at {repo.path}") +# Create a managed repository +# repo = vcs.create_repo(client, "roads-history", dataset_name="roads") +# print(f"Managed repo {repo.id}: {repo.name}") # Create a commit -# commit_obj = vcs.commit(client, "/tmp/my-spatial-repo", "/data/buildings.geojson", "Initial import") +# commit_obj = vcs.commit(client, repo.id, "/data/buildings.geojson", "Initial import") # print(f"Commit {commit_obj.id}: {commit_obj.message}") # View commit history -# commits = vcs.log(client, "/tmp/my-spatial-repo") +# commits = vcs.log(client, repo.id) # for c in commits: # print(f" [{c.id[:8]}] {c.message}") diff --git a/python/roteiro/generated.py b/python/roteiro/generated.py index 195b04b..5013885 100644 --- a/python/roteiro/generated.py +++ b/python/roteiro/generated.py @@ -4616,19 +4616,6 @@ def vcs_diff(self, query: Optional[Dict[str, Any]] = None, body: Any = None, hea extra_headers['Content-Type'] = 'application/json' return self._client._request('GET', path, body=payload, extra_headers=extra_headers) - def vcs_init(self, query: Optional[Dict[str, Any]] = None, body: Any = None, headers: Optional[Dict[str, str]] = None) -> Any: - """Initialize spatial version control for a dataset""" - path = "/api/vcs/init" - if query: - q = urlencode({k: v for k, v in query.items() if v is not None}) - if q: - path = f"{path}?{q}" - extra_headers = dict(headers or {}) - payload = body - if payload is not None and 'Content-Type' not in extra_headers: - extra_headers['Content-Type'] = 'application/json' - return self._client._request('POST', path, body=payload, extra_headers=extra_headers) - def vcs_log(self, query: Optional[Dict[str, Any]] = None, body: Any = None, headers: Optional[Dict[str, str]] = None) -> Any: """View version history""" path = "/api/vcs/log" @@ -4759,6 +4746,32 @@ def auto_post_api_vcs_prs_id_reviews(self, id: str, query: Optional[Dict[str, An extra_headers['Content-Type'] = 'application/json' return self._client._request('POST', path, body=payload, extra_headers=extra_headers) + def vcs_list_repos(self, query: Optional[Dict[str, Any]] = None, body: Any = None, headers: Optional[Dict[str, str]] = None) -> Any: + """List managed VCS repositories""" + path = "/api/vcs/repos" + if query: + q = urlencode({k: v for k, v in query.items() if v is not None}) + if q: + path = f"{path}?{q}" + extra_headers = dict(headers or {}) + payload = body + if payload is not None and 'Content-Type' not in extra_headers: + extra_headers['Content-Type'] = 'application/json' + return self._client._request('GET', path, body=payload, extra_headers=extra_headers) + + def vcs_create_repo(self, query: Optional[Dict[str, Any]] = None, body: Any = None, headers: Optional[Dict[str, str]] = None) -> Any: + """Create a managed VCS repository""" + path = "/api/vcs/repos" + if query: + q = urlencode({k: v for k, v in query.items() if v is not None}) + if q: + path = f"{path}?{q}" + extra_headers = dict(headers or {}) + payload = body + if payload is not None and 'Content-Type' not in extra_headers: + extra_headers['Content-Type'] = 'application/json' + return self._client._request('POST', path, body=payload, extra_headers=extra_headers) + def auto_get_api_vcs_tags(self, query: Optional[Dict[str, Any]] = None, body: Any = None, headers: Optional[Dict[str, str]] = None) -> Any: """[auto] GET /api/vcs/tags""" path = "/api/vcs/tags" diff --git a/python/roteiro/models.py b/python/roteiro/models.py index 0c5d07d..4b92bcb 100644 --- a/python/roteiro/models.py +++ b/python/roteiro/models.py @@ -528,14 +528,28 @@ def from_dict(cls, data: Dict[str, Any]) -> "DiffSummary": @dataclass class Repo: - """A spatial VCS repository.""" + """A managed spatial VCS repository.""" - path: str + id: str = "" + name: str = "" + tenant_id: Optional[int] = None + project_id: Optional[int] = None + dataset_name: Optional[str] = None + created_by: Optional[int] = None + created_at: str = "" + path: str = "" status: str = "" @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Repo": return cls( + id=data.get("id", ""), + name=data.get("name", ""), + tenant_id=data.get("tenant_id"), + project_id=data.get("project_id"), + dataset_name=data.get("dataset_name"), + created_by=data.get("created_by"), + created_at=data.get("created_at", ""), path=data.get("path", ""), status=data.get("status", ""), ) @@ -549,6 +563,7 @@ class Commit: message: str = "" timestamp: str = "" parent: Optional[str] = None + blob_id: Optional[str] = None blob_hash: Optional[str] = None feature_count: Optional[int] = None @@ -559,6 +574,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "Commit": message=data.get("message", ""), timestamp=data.get("timestamp", ""), parent=data.get("parent"), + blob_id=data.get("blob_id"), blob_hash=data.get("blob_hash"), feature_count=data.get("feature_count"), ) diff --git a/python/roteiro/vcs.py b/python/roteiro/vcs.py index 9739669..0eb041e 100644 --- a/python/roteiro/vcs.py +++ b/python/roteiro/vcs.py @@ -1,40 +1,74 @@ """Spatial version control system (VCS) operations. -Provides functions for initialising repositories, creating commits, viewing -commit history, computing diffs, and checking out historical snapshots. +Provides helpers for managed repository creation, commit history, and diffs. """ from __future__ import annotations from typing import TYPE_CHECKING, List, Optional -from .client import _with_query +from .client import _encode_path_value, _with_query from .models import Commit, DiffResult, Repo if TYPE_CHECKING: from .client import RoteiroClient -def init_repo(client: RoteiroClient, path: str) -> Repo: - """Initialise a new spatial VCS repository. - - Creates a ``.roteiro-vcs/`` directory at the given path to track - content-addressable geospatial snapshots. +def create_repo( + client: RoteiroClient, + name: str, + project_id: Optional[int] = None, + dataset_name: Optional[str] = None, +) -> Repo: + """Create a managed VCS repository. Args: client: An initialised RoteiroClient instance. - path: Filesystem path where the repository should be initialised. + name: Repository display name. + project_id: Optional project association. Defaults to the client's + configured project scope when available. + dataset_name: Optional dataset linkage for the repository. Returns: - A Repo object with the initialised path and status. + The created repository record. """ - data = client._post("/api/vcs/init", {"path": path}) + body = {"name": name} + effective_project_id = client._effective_project_id(project_id) + if effective_project_id is not None: + body["project_id"] = effective_project_id + if dataset_name: + body["dataset_name"] = dataset_name + data = client._post("/api/vcs/repos", body) return Repo.from_dict(data) +def list_repos( + client: RoteiroClient, + project_id: Optional[int] = None, +) -> List[Repo]: + """List managed repositories visible to the current tenant.""" + effective_project_id = client._effective_project_id(project_id) + path = "/api/vcs/repos" + if effective_project_id is not None: + path = _with_query(path, {"project_id": effective_project_id}) + data = client._get(path) + return [Repo.from_dict(repo) for repo in data] + + +def get_repo(client: RoteiroClient, repo_id: str) -> Repo: + """Fetch a managed repository by ID.""" + data = client._get(f"/api/vcs/repos/{_encode_path_value(repo_id)}") + return Repo.from_dict(data) + + +def delete_repo(client: RoteiroClient, repo_id: str) -> None: + """Delete a managed repository by ID.""" + client._delete(f"/api/vcs/repos/{_encode_path_value(repo_id)}") + + def commit( client: RoteiroClient, - repo_path: str, + repo_id: str, input_path: str, message: str, ) -> Commit: @@ -45,7 +79,7 @@ def commit( Args: client: An initialised RoteiroClient instance. - repo_path: Filesystem path to the VCS repository. + repo_id: Managed repository ID. input_path: Path to the geospatial dataset to commit. message: Human-readable commit message. @@ -54,30 +88,38 @@ def commit( """ data = client._post( "/api/vcs/commit", - {"path": repo_path, "input": input_path, "message": message}, + {"repo_id": repo_id, "input": input_path, "message": message}, ) return Commit.from_dict(data) -def log(client: RoteiroClient, repo_path: str) -> List[Commit]: +def log(client: RoteiroClient, repo_id: str) -> List[Commit]: """Get the commit history for a repository. Returns commits ordered from most recent to oldest. Args: client: An initialised RoteiroClient instance. - repo_path: Filesystem path to the VCS repository. + repo_id: Managed repository ID. Returns: A list of Commit objects. """ - data = client._get(_with_query("/api/vcs/log", {"path": repo_path})) + data = client._get(_with_query("/api/vcs/log", {"repo_id": repo_id})) + return [Commit.from_dict(c) for c in data] + + +def log_for_dataset(client: RoteiroClient, dataset_name: str) -> List[Commit]: + """Get commit history for the managed repository linked to a dataset.""" + data = client._get( + f"/api/vcs/log/dataset/{_encode_path_value(dataset_name)}" + ) return [Commit.from_dict(c) for c in data] def diff( client: RoteiroClient, - repo_path: str, + repo_id: str, commit_a: str, commit_b: str, ) -> DiffResult: @@ -88,7 +130,7 @@ def diff( Args: client: An initialised RoteiroClient instance. - repo_path: Filesystem path to the VCS repository. + repo_id: Managed repository ID. commit_a: Commit ID or ref for the base (from) commit. commit_b: Commit ID or ref for the target (to) commit. @@ -98,7 +140,7 @@ def diff( data = client._get( _with_query( "/api/vcs/diff", - {"path": repo_path, "from": commit_a, "to": commit_b}, + {"repo_id": repo_id, "from": commit_a, "to": commit_b}, ) ) return DiffResult.from_dict(data) @@ -106,7 +148,7 @@ def diff( def checkout( client: RoteiroClient, - repo_path: str, + repo_id: str, commit_id: str, ) -> None: """Check out a specific commit (restore a historical snapshot). @@ -117,7 +159,7 @@ def checkout( Args: client: An initialised RoteiroClient instance. - repo_path: Filesystem path to the VCS repository. + repo_id: Managed repository ID. commit_id: The commit ID to check out. Note: @@ -126,7 +168,7 @@ def checkout( commit blob directly. This method retrieves the diff from the initial commit to the target as a lightweight proxy. """ - commits = log(client, repo_path) + commits = log(client, repo_id) if not commits: from .client import RoteiroAPIError @@ -139,4 +181,4 @@ def checkout( return # Retrieve the diff to validate the commit exists. - diff(client, repo_path, oldest.id, commit_id) + diff(client, repo_id, oldest.id, commit_id) diff --git a/python/tests/test_vcs.py b/python/tests/test_vcs.py new file mode 100644 index 0000000..8ced085 --- /dev/null +++ b/python/tests/test_vcs.py @@ -0,0 +1,110 @@ +import unittest + +from roteiro import vcs +from roteiro.client import RoteiroClient + + +class VCSTests(unittest.TestCase): + def test_create_repo_creates_managed_repo(self): + client = RoteiroClient("https://example.com", project_id=42) + captured = {} + + def fake_post(path, body=None): + captured["path"] = path + captured["body"] = body + return { + "id": "repo_123", + "name": "roads-history", + "tenant_id": 7, + "project_id": 42, + "dataset_name": "roads", + "created_by": 99, + "created_at": "2026-03-25T00:00:00Z", + } + + client._post = fake_post # type: ignore[method-assign] + + repo = vcs.create_repo(client, "roads-history", dataset_name="roads") + + self.assertEqual(captured["path"], "/api/vcs/repos") + self.assertEqual( + captured["body"], + { + "name": "roads-history", + "project_id": 42, + "dataset_name": "roads", + }, + ) + self.assertEqual(repo.id, "repo_123") + self.assertEqual(repo.project_id, 42) + + def test_commit_log_and_diff_use_repo_id(self): + client = RoteiroClient("https://example.com") + captured = {"gets": []} + + def fake_post(path, body=None): + captured["post_path"] = path + captured["post_body"] = body + return { + "id": "commit_1", + "message": "Initial import", + "timestamp": "2026-03-25T00:00:00Z", + "parent": "", + "blob_id": "blob_1", + } + + def fake_get(path): + captured["gets"].append(path) + if path.startswith("/api/vcs/log?"): + return [ + { + "id": "commit_1", + "message": "Initial import", + "timestamp": "2026-03-25T00:00:00Z", + } + ] + return { + "added": [], + "removed": [], + "modified": [], + "stats": { + "added": 0, + "removed": 0, + "modified": 0, + "unchanged": 1, + }, + } + + client._post = fake_post # type: ignore[method-assign] + client._get = fake_get # type: ignore[method-assign] + + commit = vcs.commit( + client, + "repo_123", + "/data/roads.geojson", + "Initial import", + ) + commits = vcs.log(client, "repo_123") + diff = vcs.diff(client, "repo_123", "commit_0", "commit_1") + + self.assertEqual(captured["post_path"], "/api/vcs/commit") + self.assertEqual( + captured["post_body"], + { + "repo_id": "repo_123", + "input": "/data/roads.geojson", + "message": "Initial import", + }, + ) + self.assertEqual(captured["gets"][0], "/api/vcs/log?repo_id=repo_123") + self.assertEqual( + captured["gets"][1], + "/api/vcs/diff?repo_id=repo_123&from=commit_0&to=commit_1", + ) + self.assertEqual(commit.blob_id, "blob_1") + self.assertEqual(len(commits), 1) + self.assertEqual(diff.stats["unchanged"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/typescript/README.md b/typescript/README.md index 8f3e03c..97165f0 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -72,7 +72,7 @@ console.log(health.status, collections.length, features.features.length, areas.l | `collections` | `listCollections`, `getCollection`, `getItems`, `getItem`, `createItem`, `updateItem`, `deleteItem` | | `attachments` | `uploadAttachment`, `listAttachments`, `downloadAttachment`, `deleteAttachment` | | `layers` | `uploadLayer`, `listLayers`, `getLayer`, `updateLayer`, `publishLayer`, `archiveLayer`, `uploadLayerData`, `deleteLayer`, `previewLayer` | -| `vcs` | `initRepo`, `commit`, `log`, `diff`, `checkout` | +| `vcs` | `createRepo`, `listRepos`, `getRepo`, `deleteRepo`, `commit`, `log`, `logForDataset`, `diff`, `checkout` | | `raster` | `getRasterInfo`, `getRasterStats`, `getRasterHistogram`, `getRasterDimensions`, `getRasterBandValues`, `bandMath`, `ndvi`, `hillshade`, `zonalStats`, `exportRaster`, `contour`, `viewshed`, `elevationProfile`, `kde`, `process`, `mosaic`, `getMosaicInfo` | ## Full API Coverage diff --git a/typescript/examples/quickstart.ts b/typescript/examples/quickstart.ts index e2b7cd8..c02408c 100644 --- a/typescript/examples/quickstart.ts +++ b/typescript/examples/quickstart.ts @@ -94,15 +94,17 @@ async function main() { // ----------------------------------------------------------------------- // Uncomment to use VCS: - // const repo = await vcs.initRepo(client, '/tmp/my-spatial-repo'); - // console.log(`VCS repo initialised at ${repo.path}`); + // const repo = await vcs.createRepo(client, 'roads-history', { + // datasetName: 'roads', + // }); + // console.log(`Managed repo ${repo.id}: ${repo.name}`); // // const commitObj = await vcs.commit( - // client, '/tmp/my-spatial-repo', '/data/buildings.geojson', 'Initial import' + // client, repo.id, '/data/buildings.geojson', 'Initial import' // ); // console.log(`Commit ${commitObj.id}: ${commitObj.message}`); // - // const commits = await vcs.log(client, '/tmp/my-spatial-repo'); + // const commits = await vcs.log(client, repo.id); // for (const c of commits) { // console.log(` [${c.id.slice(0, 8)}] ${c.message}`); // } diff --git a/typescript/src/client.ts b/typescript/src/client.ts index 1aab839..958f7e4 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -111,6 +111,11 @@ export class RoteiroClient { return `${path}${separator}project_id=${encodeURIComponent(String(this.projectId))}`; } + /** Return the client's configured default project scope, if any. */ + getProjectId(): number | undefined { + return this.projectId; + } + /** * Execute an HTTP request with automatic retry and exponential back-off. * diff --git a/typescript/src/generated.ts b/typescript/src/generated.ts index f603255..7f75bce 100644 --- a/typescript/src/generated.ts +++ b/typescript/src/generated.ts @@ -5685,22 +5685,6 @@ export class RoteiroGeneratedApi { }); } - /** Initialize spatial version control for a dataset */ - async vcsinit(options: GeneratedRequestOptions = {}): Promise { - const path = withQuery('/api/vcs/init', options.query); - const headers: Record = { ...(options.headers ?? {}) }; - let body: BodyInit | undefined; - if (options.body !== undefined) { - body = JSON.stringify(options.body); - if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'; - } - return this.client.request(path, { - method: 'POST', - headers, - body, - }); - } - /** View version history */ async vcslog(options: GeneratedRequestOptions = {}): Promise { const path = withQuery('/api/vcs/log', options.query); @@ -5861,6 +5845,38 @@ export class RoteiroGeneratedApi { }); } + /** List managed VCS repositories */ + async vcslistrepos(options: GeneratedRequestOptions = {}): Promise { + const path = withQuery('/api/vcs/repos', options.query); + const headers: Record = { ...(options.headers ?? {}) }; + let body: BodyInit | undefined; + if (options.body !== undefined) { + body = JSON.stringify(options.body); + if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'; + } + return this.client.request(path, { + method: 'GET', + headers, + body, + }); + } + + /** Create a managed VCS repository */ + async vcscreaterepo(options: GeneratedRequestOptions = {}): Promise { + const path = withQuery('/api/vcs/repos', options.query); + const headers: Record = { ...(options.headers ?? {}) }; + let body: BodyInit | undefined; + if (options.body !== undefined) { + body = JSON.stringify(options.body); + if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'; + } + return this.client.request(path, { + method: 'POST', + headers, + body, + }); + } + /** [auto] GET /api/vcs/tags */ async autoGetApiVcsTags(options: GeneratedRequestOptions = {}): Promise { const path = withQuery('/api/vcs/tags', options.query); diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 671c216..0a44f8d 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -291,8 +291,15 @@ export interface DiffSummary { // --------------------------------------------------------------------------- export interface Repo { - path: string; - status: string; + id: string; + name: string; + tenant_id: number; + project_id?: number | null; + dataset_name?: string | null; + created_by?: number | null; + created_at: string; + path?: string; + status?: string; } export interface Commit { @@ -300,6 +307,7 @@ export interface Commit { message: string; timestamp: string; parent?: string; + blob_id?: string; blob_hash?: string; feature_count?: number; } diff --git a/typescript/src/vcs.ts b/typescript/src/vcs.ts index fbf1e4f..05a3df8 100644 --- a/typescript/src/vcs.ts +++ b/typescript/src/vcs.ts @@ -1,42 +1,110 @@ /** * Spatial version control system (VCS) operations. * - * Provides functions for initialising repositories, creating commits, - * viewing commit history, computing diffs, and checking out snapshots. + * Provides helpers for managed repository creation, commit history, and diffs. * @module vcs */ import type { RoteiroClient } from './client'; import type { Commit, DiffResult, Repo } from './types'; +export interface CreateRepoOptions { + projectId?: number; + datasetName?: string; +} + +export interface ListReposOptions { + projectId?: number; +} + +function resolveProjectId( + client: RoteiroClient, + projectId?: number, +): number | undefined { + return projectId ?? client.getProjectId(); +} + /** - * Initialise a new spatial VCS repository. + * Create a managed VCS repository. * * @param client - An initialised RoteiroClient instance. - * @param path - Filesystem path where the repository should be created. - * @returns A Repo object with the initialised path and status. + * @param name - Display name for the repository. + * @param options - Optional project/dataset linkage. + * @returns The created managed repository record. + */ +export async function createRepo( + client: RoteiroClient, + name: string, + options: CreateRepoOptions = {}, +): Promise { + const body: { + name: string; + project_id?: number; + dataset_name?: string; + } = { name }; + const projectId = resolveProjectId(client, options.projectId); + if (projectId !== undefined) { + body.project_id = projectId; + } + if (options.datasetName) { + body.dataset_name = options.datasetName; + } + return client.post('/api/vcs/repos', body); +} + +/** + * List managed VCS repositories visible to the current tenant. + */ +export async function listRepos( + client: RoteiroClient, + options: ListReposOptions = {}, +): Promise { + const projectId = resolveProjectId(client, options.projectId); + const params = new URLSearchParams(); + if (projectId !== undefined) { + params.set('project_id', String(projectId)); + } + const query = params.toString(); + return client.request(`/api/vcs/repos${query ? `?${query}` : ''}`); +} + +/** + * Fetch a managed VCS repository by ID. */ -export async function initRepo(client: RoteiroClient, path: string): Promise { - return client.post('/api/vcs/init', { path }); +export async function getRepo( + client: RoteiroClient, + repoId: string, +): Promise { + return client.request(`/api/vcs/repos/${encodeURIComponent(repoId)}`); +} + +/** + * Delete a managed VCS repository by ID. + */ +export async function deleteRepo( + client: RoteiroClient, + repoId: string, +): Promise { + await client.del(`/api/vcs/repos/${encodeURIComponent(repoId)}`); } /** * Create a new commit in a VCS repository. * * @param client - An initialised RoteiroClient instance. - * @param repoPath - Filesystem path to the VCS repository. + * @param repoId - Managed repository ID. * @param inputPath - Path to the geospatial dataset to commit. * @param message - Human-readable commit message. * @returns The created Commit object. */ export async function commit( client: RoteiroClient, - repoPath: string, + repoId: string, inputPath: string, message: string, ): Promise { return client.post('/api/vcs/commit', { - path: repoPath, + repo_id: repoId, input: inputPath, message, }); @@ -46,32 +114,44 @@ export async function commit( * Get the commit history for a repository. * * @param client - An initialised RoteiroClient instance. - * @param repoPath - Filesystem path to the VCS repository. + * @param repoId - Managed repository ID. * @returns Commits ordered from most recent to oldest. */ -export async function log(client: RoteiroClient, repoPath: string): Promise { +export async function log(client: RoteiroClient, repoId: string): Promise { + return client.request( + `/api/vcs/log?repo_id=${encodeURIComponent(repoId)}`, + ); +} + +/** + * Get the commit history for a dataset's managed repository. + */ +export async function logForDataset( + client: RoteiroClient, + datasetName: string, +): Promise { return client.request( - `/api/vcs/log?path=${encodeURIComponent(repoPath)}`, + `/api/vcs/log/dataset/${encodeURIComponent(datasetName)}`, ); } /** - * Compute the diff between two commits in a repository. + * Compute the diff between two commits in a managed repository. * * @param client - An initialised RoteiroClient instance. - * @param repoPath - Filesystem path to the VCS repository. + * @param repoId - Managed repository ID. * @param commitA - Commit ID for the base (from) commit. * @param commitB - Commit ID for the target (to) commit. * @returns A DiffResult with added, removed, and modified features. */ export async function diff( client: RoteiroClient, - repoPath: string, + repoId: string, commitA: string, commitB: string, ): Promise { const params = new URLSearchParams({ - path: repoPath, + repo_id: repoId, from: commitA, to: commitB, }); @@ -86,15 +166,15 @@ export async function diff( * reading the commit blob directly. * * @param client - An initialised RoteiroClient instance. - * @param repoPath - Filesystem path to the VCS repository. + * @param repoId - Managed repository ID. * @param commitId - The commit ID to check out. */ export async function checkout( client: RoteiroClient, - repoPath: string, + repoId: string, commitId: string, ): Promise { - const commits = await log(client, repoPath); + const commits = await log(client, repoId); if (commits.length === 0) { throw new Error('No commits in repository'); } @@ -102,5 +182,5 @@ export async function checkout( if (oldest.id === commitId) { return; } - await diff(client, repoPath, oldest.id, commitId); + await diff(client, repoId, oldest.id, commitId); } diff --git a/typescript/test/vcs.test.ts b/typescript/test/vcs.test.ts new file mode 100644 index 0000000..fe993c7 --- /dev/null +++ b/typescript/test/vcs.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RoteiroClient } from '../src/client'; +import * as vcs from '../src/vcs'; + +describe('vcs helpers', () => { + it('creates managed repositories using the current Cairn API', async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + expect(new URL(url).pathname).toBe('/api/vcs/repos'); + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual({ + name: 'roads-history', + project_id: 42, + dataset_name: 'roads', + }); + return new Response( + JSON.stringify({ + id: 'repo_123', + name: 'roads-history', + tenant_id: 7, + project_id: 42, + dataset_name: 'roads', + created_by: 99, + created_at: '2026-03-25T00:00:00Z', + }), + { status: 201 }, + ); + }); + + const client = new RoteiroClient({ + baseUrl: 'https://example.com', + projectId: 42, + fetch: fetchMock as typeof globalThis.fetch, + }); + + const repo = await vcs.createRepo(client, 'roads-history', { + datasetName: 'roads', + }); + + expect(repo.id).toBe('repo_123'); + expect(repo.project_id).toBe(42); + }); + + it('uses repo_id for commit history and diffs', async () => { + const fetchMock = vi + .fn() + .mockImplementationOnce(async (url: string, init?: RequestInit) => { + expect(new URL(url).pathname).toBe('/api/vcs/commit'); + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual({ + repo_id: 'repo_123', + input: '/data/roads.geojson', + message: 'Initial import', + }); + return new Response( + JSON.stringify({ + id: 'commit_1', + message: 'Initial import', + timestamp: '2026-03-25T00:00:00Z', + parent: '', + blob_id: 'blob_1', + }), + { status: 201 }, + ); + }) + .mockImplementationOnce(async (url: string) => { + const parsed = new URL(url); + expect(parsed.pathname).toBe('/api/vcs/log'); + expect(parsed.searchParams.get('repo_id')).toBe('repo_123'); + return new Response( + JSON.stringify([ + { + id: 'commit_1', + message: 'Initial import', + timestamp: '2026-03-25T00:00:00Z', + }, + ]), + { status: 200 }, + ); + }) + .mockImplementationOnce(async (url: string) => { + const parsed = new URL(url); + expect(parsed.pathname).toBe('/api/vcs/diff'); + expect(parsed.searchParams.get('repo_id')).toBe('repo_123'); + expect(parsed.searchParams.get('from')).toBe('commit_0'); + expect(parsed.searchParams.get('to')).toBe('commit_1'); + return new Response( + JSON.stringify({ + added: [], + removed: [], + modified: [], + stats: { + added: 0, + removed: 0, + modified: 0, + unchanged: 1, + }, + }), + { status: 200 }, + ); + }); + + const client = new RoteiroClient({ + baseUrl: 'https://example.com', + fetch: fetchMock as typeof globalThis.fetch, + }); + + const commit = await vcs.commit( + client, + 'repo_123', + '/data/roads.geojson', + 'Initial import', + ); + const commits = await vcs.log(client, 'repo_123'); + const diff = await vcs.diff(client, 'repo_123', 'commit_0', 'commit_1'); + + expect(commit.blob_id).toBe('blob_1'); + expect(commits).toHaveLength(1); + expect(diff.stats?.unchanged).toBe(1); + }); +});