From 52f7d198673a27a59db54ce4ce9f1f5ee3973b61 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Fri, 18 Sep 2026 20:32:49 -0700 Subject: [PATCH 1/3] feat(gitlab): add issue and merge request writeback parity Add GitLab REST v4 creation, branch refs, merge, and lifecycle writeback routes with discovery contracts and catalog entries. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: 01a0b7a6-3196-7081-b3db-70fc609b7d28 --- CHANGELOG.md | 1 + docs/writeback-spec-coverage.md | 2 +- package-lock.json | 2 +- .../mappings/adapters/gitlab.mapping.yaml | 15 + .../core/src/runtime/file-native-router.ts | 22 +- .../writeback-paths/catalog.generated.json | 60 ++++ .../src/writeback-paths/catalog.generated.ts | 60 ++++ packages/gitlab/discovery/gitlab/.adapter.md | 141 ++++++++- .../{projectPath}/issues/.create.example.json | 7 + .../{projectPath}/issues/.schema.json | 116 +++++++ .../{issueIid}__{slug}/comments/.schema.json | 22 +- .../meta.json/.create.example.json | 3 + .../{issueIid}__{slug}/meta.json/.schema.json | 114 +++++++ .../merge-requests/.create.example.json | 6 + .../{projectPath}/merge-requests/.schema.json | 111 +++++++ .../close.json/.create.example.json | 3 + .../close.json/.schema.json | 84 +++++ .../discussions/.schema.json | 32 +- .../merge.json/.create.example.json | 4 + .../merge.json/.schema.json | 87 +++++ .../meta.json/.create.example.json | 3 + .../meta.json/.schema.json | 114 +++++++ .../{projectPath}/refs/.create.example.json | 4 + .../projects/{projectPath}/refs/.schema.json | 86 +++++ packages/gitlab/gitlab.mapping.yaml | 15 + packages/gitlab/package.json | 2 +- packages/gitlab/src/layout-prompt.ts | 6 + packages/gitlab/src/layout.ts | 4 + packages/gitlab/src/resources.ts | 68 +++- packages/gitlab/src/writeback.ts | 297 +++++------------- packages/gitlab/test/layout.test.ts | 8 + packages/gitlab/test/writeback.test.ts | 36 ++- scripts/writeback-discovery-data.mjs | 61 +++- scripts/writeback-discovery-normalizer.mjs | 47 ++- .../writeback-discovery-normalizer.test.mjs | 2 +- 35 files changed, 1372 insertions(+), 273 deletions(-) create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/.create.example.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/.schema.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.create.example.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.schema.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/merge-requests/.create.example.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/merge-requests/.schema.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.create.example.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.schema.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.create.example.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.schema.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.create.example.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.schema.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/refs/.create.example.json create mode 100644 packages/gitlab/discovery/gitlab/projects/{projectPath}/refs/.schema.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f13117a3..cee70997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ published version with a date and open a fresh empty `[Unreleased]` above it. ### Added +- `@relayfile/adapter-gitlab` now creates issues, branches, and merge requests from file-native drafts; accepts or closes/reopens merge requests through canonical sidecars; and publishes schemas, examples, catalog paths, and `LAYOUT.md` guidance for every supported GitLab writeback route. - `@relayfile/adapter-github` and the core GitHub mapping now declare `check_run` and `issue_comment` webhook keys, so consumers that read the mapping's `webhooks:` block can subscribe to CI check completions and issue/PR conversation comments. - `@relayfile/adapter-github` now exports a cursor-resumable pull-index convergence primitive that backfills `headRef` with one GitHub list request per invocation and no per-record metadata, file, or diff fetches. - `@relayfile/adapter-linear` now materializes `/linear/issues/by-project//.json` aliases so project-scoped consumers can avoid mounting the full issue tree. The path mapper and generated `LAYOUT.md` contract expose the new lookup; existing mounts must resync to materialize and discover it. diff --git a/docs/writeback-spec-coverage.md b/docs/writeback-spec-coverage.md index 769194ac..2930350f 100644 --- a/docs/writeback-spec-coverage.md +++ b/docs/writeback-spec-coverage.md @@ -19,7 +19,7 @@ Contract-backed means the endpoint uses `contractEndpoint(...)`, loads its reque | fathom | None | 0 | 0 | Read-only adapter for meetings, recording summaries/transcripts, teams, and team members; no writeback endpoints. | | gcp | None | 0 | 0 | Read-only observer adapter for Cloud Run services, Cloud Monitoring alert policies, and Cloud Billing current state; no writeback endpoints. | | gcs | None | 0 | 2 | Inline JS schemas. | -| gitlab | None | 0 | 2 | Inline JS schemas. | +| gitlab | None | 0 | 9 | Inline JS schemas for issue and merge-request create/update, branch creation, merge-request accept/close/reopen, discussions, and issue notes. | | gmail | None | 0 | 3 | Three legacy discovery schemas remain inline. The additive `/gmail` control contract owns Cloud's separate bounded 13-action Gmail surface; it is not a discovery schema or a provider executor. | | google-calendar | None | 0 | 1 | Inline JS schemas. | | google-drive | None | 0 | 2 | Inline JS schemas. | diff --git a/package-lock.json b/package-lock.json index b4b6623e..65e679be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5370,7 +5370,7 @@ }, "packages/gitlab": { "name": "@relayfile/adapter-gitlab", - "version": "0.4.2", + "version": "0.5.0", "license": "Apache-2.0", "dependencies": { "@relayfile/adapter-core": "^0.5.14" diff --git a/packages/core/mappings/adapters/gitlab.mapping.yaml b/packages/core/mappings/adapters/gitlab.mapping.yaml index 9d420bd5..4f136bdf 100644 --- a/packages/core/mappings/adapters/gitlab.mapping.yaml +++ b/packages/core/mappings/adapters/gitlab.mapping.yaml @@ -58,9 +58,24 @@ resources: path: /gitlab/projects/{namespace}/{project}/pipelines/{id}__{ref}/jobs/{job_id}.json iterate: true writebacks: + issue_create: + match: /gitlab/projects/*/issues/*.json + endpoint: POST /projects/{project_id}/issues merge_request_update: match: /gitlab/projects/*/merge_requests/*/meta.json endpoint: PUT /projects/{project_id}/merge_requests/{iid} + merge_request_create: + match: /gitlab/projects/*/merge-requests/*.json + endpoint: POST /projects/{project_id}/merge_requests + merge_request_merge: + match: /gitlab/projects/*/merge_requests/*/merge.json + endpoint: PUT /projects/{project_id}/merge_requests/{iid}/merge + merge_request_close: + match: /gitlab/projects/*/merge_requests/*/close.json + endpoint: PUT /projects/{project_id}/merge_requests/{iid} + branch_create: + match: /gitlab/projects/*/refs/*.json + endpoint: POST /projects/{project_id}/repository/branches merge_request_discussion: match: /gitlab/projects/*/merge_requests/*/discussions/*.json endpoint: POST /projects/{project_id}/merge_requests/{iid}/discussions diff --git a/packages/core/src/runtime/file-native-router.ts b/packages/core/src/runtime/file-native-router.ts index 3c18a3b4..8e606b96 100644 --- a/packages/core/src/runtime/file-native-router.ts +++ b/packages/core/src/runtime/file-native-router.ts @@ -708,15 +708,27 @@ function readExactFileResourceId( ): string | undefined { const pathSegments = path.split("/").filter(Boolean); const resourceSegments = resource.path.split("/").filter(Boolean); - if (pathSegments.length !== resourceSegments.length) { + if (pathSegments.length < resourceSegments.length) { return undefined; } - for (let index = resourceSegments.length - 1; index >= 0; index -= 1) { - const resourceSegment = resourceSegments[index]; - const pathSegment = pathSegments[index]; + // A `{projectPath}` placeholder may stand for a nested GitLab namespace, + // so an exact sidecar template can have fewer segments than its concrete + // path. Match fixed suffix segments from the right and return the nearest + // dynamic record-id segment instead of requiring equal segment counts. + let pathIndex = pathSegments.length - 1; + for (let resourceIndex = resourceSegments.length - 1; resourceIndex >= 0; resourceIndex -= 1) { + const resourceSegment = resourceSegments[resourceIndex]; + const pathSegment = pathSegments[pathIndex]; + if (!resourceSegment || !pathSegment) { + return undefined; + } const placeholder = /\{[^}]+\}/u.exec(resourceSegment); - if (!placeholder || !pathSegment) { + if (!placeholder) { + if (resourceSegment !== pathSegment) { + return undefined; + } + pathIndex -= 1; continue; } if (resourceSegment.endsWith(".json") && pathSegment.endsWith(".json")) { diff --git a/packages/core/src/writeback-paths/catalog.generated.json b/packages/core/src/writeback-paths/catalog.generated.json index a90bfc11..5fcbe43b 100644 --- a/packages/core/src/writeback-paths/catalog.generated.json +++ b/packages/core/src/writeback-paths/catalog.generated.json @@ -369,6 +369,16 @@ ] }, "gitlab": { + "close-merge-request": [ + { + "path": "/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json", + "params": [ + "projectPath", + "mergeRequestIid", + "slug" + ] + } + ], "comments": [ { "path": "/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments", @@ -388,6 +398,56 @@ "slug" ] } + ], + "issues": [ + { + "path": "/gitlab/projects/{projectPath}/issues", + "params": [ + "projectPath" + ] + }, + { + "path": "/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json", + "params": [ + "projectPath", + "issueIid", + "slug" + ] + } + ], + "merge": [ + { + "path": "/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json", + "params": [ + "projectPath", + "mergeRequestIid", + "slug" + ] + } + ], + "merge-requests": [ + { + "path": "/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json", + "params": [ + "projectPath", + "mergeRequestIid", + "slug" + ] + }, + { + "path": "/gitlab/projects/{projectPath}/merge-requests", + "params": [ + "projectPath" + ] + } + ], + "refs": [ + { + "path": "/gitlab/projects/{projectPath}/refs", + "params": [ + "projectPath" + ] + } ] }, "gmail": { diff --git a/packages/core/src/writeback-paths/catalog.generated.ts b/packages/core/src/writeback-paths/catalog.generated.ts index b0003e2c..19509c8e 100644 --- a/packages/core/src/writeback-paths/catalog.generated.ts +++ b/packages/core/src/writeback-paths/catalog.generated.ts @@ -379,6 +379,16 @@ export const WRITEBACK_PATH_CATALOG = { ] }, "gitlab": { + "close-merge-request": [ + { + "path": "/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json", + "params": [ + "projectPath", + "mergeRequestIid", + "slug" + ] + } + ], "comments": [ { "path": "/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments", @@ -398,6 +408,56 @@ export const WRITEBACK_PATH_CATALOG = { "slug" ] } + ], + "issues": [ + { + "path": "/gitlab/projects/{projectPath}/issues", + "params": [ + "projectPath" + ] + }, + { + "path": "/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json", + "params": [ + "projectPath", + "issueIid", + "slug" + ] + } + ], + "merge": [ + { + "path": "/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json", + "params": [ + "projectPath", + "mergeRequestIid", + "slug" + ] + } + ], + "merge-requests": [ + { + "path": "/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json", + "params": [ + "projectPath", + "mergeRequestIid", + "slug" + ] + }, + { + "path": "/gitlab/projects/{projectPath}/merge-requests", + "params": [ + "projectPath" + ] + } + ], + "refs": [ + { + "path": "/gitlab/projects/{projectPath}/refs", + "params": [ + "projectPath" + ] + } ] }, "gmail": { diff --git a/packages/gitlab/discovery/gitlab/.adapter.md b/packages/gitlab/discovery/gitlab/.adapter.md index 9d4f7234..51fdd578 100644 --- a/packages/gitlab/discovery/gitlab/.adapter.md +++ b/packages/gitlab/discovery/gitlab/.adapter.md @@ -1,6 +1,6 @@ # GitLab adapter -The GitLab adapter exposes projects, merge requests, discussions, issues, commits, pipelines, jobs, deployments, and tags under `/gitlab`, with writeback routes for merge request discussions and issue notes. +The GitLab adapter exposes projects, merge requests, discussions, issues, commits, pipelines, jobs, deployments, and tags under `/gitlab`. Relayfile writeback can create and update issues and merge requests, create branches, accept or close/reopen merge requests, and add merge request discussions and issue notes. Read-only mounts: - `/gitlab/projects///merge_requests/__/meta.json` - Merge request metadata. @@ -14,6 +14,13 @@ Resources: | Resource | Schema | Create example | ID pattern | What it does | |---|---|---|---|---| +| `/gitlab/projects/{projectPath}/issues/.json` | `/gitlab/projects/{projectPath}/issues/.schema.json` | `/gitlab/projects/{projectPath}/issues/.create.example.json` | `^[1-9]\d*$` | Creates a GitLab issue in the project named by the path. | +| `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json` | `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.schema.json` | `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.create.example.json` | exact file path | Updates mutable fields on a GitLab issue. | +| `/gitlab/projects/{projectPath}/merge-requests/.json` | `/gitlab/projects/{projectPath}/merge-requests/.schema.json` | `/gitlab/projects/{projectPath}/merge-requests/.create.example.json` | `^[1-9]\d*$` | Creates a GitLab merge request from a source branch into a target branch. | +| `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json` | `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.schema.json` | `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.create.example.json` | exact file path | Updates mutable fields on a GitLab merge request. | +| `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json` | `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.schema.json` | `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.create.example.json` | exact file path | Accepts a GitLab merge request. | +| `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json` | `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.schema.json` | `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.create.example.json` | exact file path | Closes or reopens a GitLab merge request. | +| `/gitlab/projects/{projectPath}/refs/.json` | `/gitlab/projects/{projectPath}/refs/.schema.json` | `/gitlab/projects/{projectPath}/refs/.create.example.json` | `^$` | Creates a GitLab branch from an existing branch, tag, or commit ref. | | `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.json` | `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.schema.json` | `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.create.example.json` | `^[A-Za-z0-9_.:-]+$` | Creates a discussion on a merge request. | | `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.json` | `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.schema.json` | `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.create.example.json` | `^[A-Za-z0-9_.:-]+$` | Creates a note on an issue. | @@ -23,20 +30,147 @@ Resources: |---|---| | Read | `cat ` after listing the resource directory or following an alias when one is available. Use the resource table and ID patterns below to determine whether a resource uses a bare id, an adapter-specific slug/id filename, or an exact sidecar path such as `content.md`. | | Edit | Write the resource update payload to the canonical resource path. For JSON resources, included mutable fields PATCH; fields marked `readOnly` in `.schema.json` are rejected. | -| Create | Write JSON to any non-canonical filename such as `create request.json`. The adapter creates the record at its canonical resource path and rewrites the draft as `{ "created": "", "path": "", "url": "" }`. | +| Create | For resources with a create example, write JSON to any non-canonical filename such as `create request.json`. The adapter creates the record at its canonical resource path and rewrites the draft as `{ "created": "", "path": "", "url": "" }`. | | Ignore | Editor scratch files named `partial.json`, `.tmp.json`, `.partial.json`, `*.tmp.json`, or `*.partial.json` are ignored and never treated as create drafts. | | Delete | `rm ` for canonical records. | ## ID Patterns +- `/gitlab/projects/{projectPath}/issues/.json`: `^[1-9]\d*$`. Filenames that do not match this pattern are treated as create drafts. +- `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json`: exact file path. +- `/gitlab/projects/{projectPath}/merge-requests/.json`: `^[1-9]\d*$`. Filenames that do not match this pattern are treated as create drafts. +- `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json`: exact file path. +- `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json`: exact file path. +- `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json`: exact file path. +- `/gitlab/projects/{projectPath}/refs/.json`: `^$`. Filenames that do not match this pattern are treated as create drafts. - `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.json`: `^[A-Za-z0-9_.:-]+$`. Filenames that do not match this pattern are treated as create drafts. - `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.json`: `^[A-Za-z0-9_.:-]+$`. Filenames that do not match this pattern are treated as create drafts. ## Write field contracts +### Create GitLab issue + +Resource: `/gitlab/projects/{projectPath}/issues/.json` +Schema: `/gitlab/projects/{projectPath}/issues/.schema.json` +Operations: `create`. +Create example: `/gitlab/projects/{projectPath}/issues/.create.example.json` +Required fields: `title`. +Optional fields: `description`, `labels`, `assignee_ids`, `milestone_id`, `confidential`, `state_event`. + +Fields: + +- `title` (required, string) - Issue title. +- `description` (optional, string) - Markdown issue description. +- `labels` (optional, array) - Label names. The adapter sends them to GitLab as a comma-separated labels value. +- `assignee_ids` (optional, array) - GitLab user IDs to assign. +- `milestone_id` (optional, integer) - GitLab milestone ID. +- `confidential` (optional, boolean) - Whether the issue is confidential. +- `state_event` (optional, enum) - Lifecycle event when updating an existing issue. Allowed values: `close`, `reopen`. + +### Update GitLab issue + +Resource: `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json` +Schema: `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.schema.json` +Operations: `update`. +Create example: `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.create.example.json` +Required fields: none at the top level. +Optional fields: `title`, `description`, `labels`, `assignee_ids`, `milestone_id`, `confidential`, `state_event`. + +Fields: + +- `title` (optional, string) - Issue title. +- `description` (optional, string) - Markdown issue description. +- `labels` (optional, array) - Label names. The adapter sends them to GitLab as a comma-separated labels value. +- `assignee_ids` (optional, array) - GitLab user IDs to assign. +- `milestone_id` (optional, integer) - GitLab milestone ID. +- `confidential` (optional, boolean) - Whether the issue is confidential. +- `state_event` (optional, enum) - Lifecycle event when updating an existing issue. Allowed values: `close`, `reopen`. + +### Create GitLab merge request + +Resource: `/gitlab/projects/{projectPath}/merge-requests/.json` +Schema: `/gitlab/projects/{projectPath}/merge-requests/.schema.json` +Operations: `create`. +Create example: `/gitlab/projects/{projectPath}/merge-requests/.create.example.json` +Required fields: `source_branch`, `target_branch`, `title`. +Optional fields: `description`, `labels`, `remove_source_branch`, `draft`. + +Fields: + +- `source_branch` (required, string) - Existing source branch name. +- `target_branch` (required, string) - Target branch name. +- `title` (required, string) - Merge request title. +- `description` (optional, string) - Markdown merge request description. +- `labels` (optional, array) - Label names. The adapter sends them to GitLab as a comma-separated labels value. +- `remove_source_branch` (optional, boolean) - Whether GitLab should remove the source branch after merge. +- `draft` (optional, boolean) - Whether to create the merge request as a draft. + +### Update GitLab merge request + +Resource: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json` +Schema: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.schema.json` +Operations: `update`. +Create example: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.create.example.json` +Required fields: none at the top level. +Optional fields: `source_branch`, `target_branch`, `title`, `description`, `labels`, `remove_source_branch`, `draft`, `state_event`. + +Fields: + +- `source_branch` (optional, string) - Existing source branch name. +- `target_branch` (optional, string) - Target branch name. +- `title` (optional, string) - Merge request title. +- `description` (optional, string) - Markdown merge request description. +- `labels` (optional, array) - Label names. The adapter sends them to GitLab as a comma-separated labels value. +- `remove_source_branch` (optional, boolean) - Whether GitLab should remove the source branch after merge. +- `draft` (optional, boolean) - Whether to create the merge request as a draft. +- `state_event` (optional, enum) - Lifecycle event for the merge request. Allowed values: `close`, `reopen`. + +### Merge GitLab merge request + +Resource: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json` +Schema: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.schema.json` +Operations: `update`. +Create example: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.create.example.json` +Required fields: none at the top level. +Optional fields: `merge_commit_message`, `squash`, `should_remove_source_branch`. + +Fields: + +- `merge_commit_message` (optional, string) - Custom commit message for the merge commit. +- `squash` (optional, boolean) - Whether GitLab should squash commits before merge. +- `should_remove_source_branch` (optional, boolean) - Whether GitLab should remove the source branch after merge. + +### Close or reopen GitLab merge request + +Resource: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json` +Schema: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.schema.json` +Operations: `update`. +Create example: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.create.example.json` +Required fields: `state_event`. +Optional fields: none. + +Fields: + +- `state_event` (required, enum) - Set `close` to close the merge request or `reopen` to reopen it. Allowed values: `close`, `reopen`. + +### Create GitLab branch + +Resource: `/gitlab/projects/{projectPath}/refs/.json` +Schema: `/gitlab/projects/{projectPath}/refs/.schema.json` +Operations: `create`. +Create example: `/gitlab/projects/{projectPath}/refs/.create.example.json` +Required fields: `branch`, `ref`. +Optional fields: none. + +Fields: + +- `branch` (required, string) - New branch name. +- `ref` (required, string) - Existing branch, tag, or commit SHA to branch from. + ### Create GitLab merge request discussion Resource: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.json` Schema: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.schema.json` +Operations: `create`, `update`, `delete`. Create example: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.create.example.json` Required fields: `body`. Optional fields: `position`, `created_at`. @@ -51,6 +185,7 @@ Fields: Resource: `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.json` Schema: `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.schema.json` +Operations: `create`, `update`, `delete`. Create example: `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.create.example.json` Required fields: `body`. Optional fields: `created_at`. @@ -61,4 +196,4 @@ Fields: - `created_at` (optional, string, date-time) - Optional timestamp for imports when supported by GitLab. ## Create Examples -Read the resource `.schema.json` first, then use the sibling `.create.example.json` as a minimal create document. The example intentionally omits read-only fields. +Read the resource `.schema.json` first, then use the sibling `.create.example.json` as a minimal create document when the resource advertises one. The example intentionally omits read-only fields. diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/.create.example.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/.create.example.json new file mode 100644 index 00000000..bfdf9b8f --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/.create.example.json @@ -0,0 +1,7 @@ +{ + "title": "Replace example issue title", + "description": "Replace example issue description.", + "labels": [ + "factory" + ] +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/.schema.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/.schema.json new file mode 100644 index 00000000..a7e525b5 --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/.schema.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GitLab issue", + "type": "object", + "required": [ + "title" + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "title": { + "type": "string", + "description": "Issue title." + }, + "description": { + "type": "string", + "description": "Markdown issue description." + }, + "labels": { + "type": "array", + "description": "Label names. The adapter sends them to GitLab as a comma-separated labels value.", + "items": { + "type": "string", + "description": "GitLab label name." + } + }, + "assignee_ids": { + "type": "array", + "description": "GitLab user IDs to assign.", + "items": { + "type": "integer", + "description": "GitLab user id." + } + }, + "milestone_id": { + "type": "integer", + "description": "GitLab milestone ID." + }, + "confidential": { + "type": "boolean", + "description": "Whether the issue is confidential." + }, + "state_event": { + "enum": [ + "close", + "reopen" + ], + "description": "Lifecycle event when updating an existing issue." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.schema.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.schema.json index aed985e3..0a6e930e 100644 --- a/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.schema.json +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.schema.json @@ -6,17 +6,6 @@ "body" ], "properties": { - "body": { - "type": "string", - "description": "Markdown note body.", - "minLength": 1, - "pattern": ".*\\S.*" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Optional timestamp for imports when supported by GitLab." - }, "id": { "type": "string", "description": "Provider canonical record id.", @@ -81,6 +70,17 @@ "description": "Relayfile connection metadata captured during sync.", "readOnly": true, "additionalProperties": true + }, + "body": { + "type": "string", + "description": "Markdown note body.", + "minLength": 1, + "pattern": ".*\\S.*" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Optional timestamp for imports when supported by GitLab." } }, "additionalProperties": false, diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.create.example.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.create.example.json new file mode 100644 index 00000000..7152a925 --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.create.example.json @@ -0,0 +1,3 @@ +{ + "title": "Replace example issue title" +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.schema.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.schema.json new file mode 100644 index 00000000..6a8d78b2 --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.schema.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Update GitLab issue", + "type": "object", + "required": [], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "title": { + "type": "string", + "description": "Issue title." + }, + "description": { + "type": "string", + "description": "Markdown issue description." + }, + "labels": { + "type": "array", + "description": "Label names. The adapter sends them to GitLab as a comma-separated labels value.", + "items": { + "type": "string", + "description": "GitLab label name." + } + }, + "assignee_ids": { + "type": "array", + "description": "GitLab user IDs to assign.", + "items": { + "type": "integer", + "description": "GitLab user id." + } + }, + "milestone_id": { + "type": "integer", + "description": "GitLab milestone ID." + }, + "confidential": { + "type": "boolean", + "description": "Whether the issue is confidential." + }, + "state_event": { + "enum": [ + "close", + "reopen" + ], + "description": "Lifecycle event when updating an existing issue." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge-requests/.create.example.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge-requests/.create.example.json new file mode 100644 index 00000000..7a41f1e6 --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge-requests/.create.example.json @@ -0,0 +1,6 @@ +{ + "source_branch": "factory/gitlab-parity", + "target_branch": "main", + "title": "Replace example merge request title", + "description": "Replace example merge request description." +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge-requests/.schema.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge-requests/.schema.json new file mode 100644 index 00000000..ed0c6306 --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge-requests/.schema.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GitLab merge request", + "type": "object", + "required": [ + "source_branch", + "target_branch", + "title" + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "source_branch": { + "type": "string", + "description": "Existing source branch name." + }, + "target_branch": { + "type": "string", + "description": "Target branch name." + }, + "title": { + "type": "string", + "description": "Merge request title." + }, + "description": { + "type": "string", + "description": "Markdown merge request description." + }, + "labels": { + "type": "array", + "description": "Label names. The adapter sends them to GitLab as a comma-separated labels value.", + "items": { + "type": "string", + "description": "GitLab label name." + } + }, + "remove_source_branch": { + "type": "boolean", + "description": "Whether GitLab should remove the source branch after merge." + }, + "draft": { + "type": "boolean", + "description": "Whether to create the merge request as a draft." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.create.example.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.create.example.json new file mode 100644 index 00000000..12e163a5 --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.create.example.json @@ -0,0 +1,3 @@ +{ + "state_event": "close" +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.schema.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.schema.json new file mode 100644 index 00000000..dc036aad --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.schema.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Close or reopen GitLab merge request", + "type": "object", + "required": [ + "state_event" + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "state_event": { + "enum": [ + "close", + "reopen" + ], + "description": "Set `close` to close the merge request or `reopen` to reopen it." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.schema.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.schema.json index 7a88b6fb..1a439454 100644 --- a/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.schema.json +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.schema.json @@ -6,22 +6,6 @@ "body" ], "properties": { - "body": { - "type": "string", - "description": "Markdown note body.", - "minLength": 1, - "pattern": ".*\\S.*" - }, - "position": { - "type": "object", - "description": "Optional GitLab position object for diff discussions.", - "additionalProperties": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Optional timestamp for imports when supported by GitLab." - }, "id": { "type": "string", "description": "Provider canonical record id.", @@ -86,6 +70,22 @@ "description": "Relayfile connection metadata captured during sync.", "readOnly": true, "additionalProperties": true + }, + "body": { + "type": "string", + "description": "Markdown note body.", + "minLength": 1, + "pattern": ".*\\S.*" + }, + "position": { + "type": "object", + "description": "Optional GitLab position object for diff discussions.", + "additionalProperties": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Optional timestamp for imports when supported by GitLab." } }, "additionalProperties": false, diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.create.example.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.create.example.json new file mode 100644 index 00000000..8ff11c9a --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.create.example.json @@ -0,0 +1,4 @@ +{ + "merge_commit_message": "feat: merge factory work", + "squash": true +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.schema.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.schema.json new file mode 100644 index 00000000..66caa688 --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Merge GitLab merge request", + "type": "object", + "required": [], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "merge_commit_message": { + "type": "string", + "description": "Custom commit message for the merge commit." + }, + "squash": { + "type": "boolean", + "description": "Whether GitLab should squash commits before merge." + }, + "should_remove_source_branch": { + "type": "boolean", + "description": "Whether GitLab should remove the source branch after merge." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.create.example.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.create.example.json new file mode 100644 index 00000000..8e58ee92 --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.create.example.json @@ -0,0 +1,3 @@ +{ + "title": "Replace example merge request title" +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.schema.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.schema.json new file mode 100644 index 00000000..975ed362 --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.schema.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Update GitLab merge request", + "type": "object", + "required": [], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "source_branch": { + "type": "string", + "description": "Existing source branch name." + }, + "target_branch": { + "type": "string", + "description": "Target branch name." + }, + "title": { + "type": "string", + "description": "Merge request title." + }, + "description": { + "type": "string", + "description": "Markdown merge request description." + }, + "labels": { + "type": "array", + "description": "Label names. The adapter sends them to GitLab as a comma-separated labels value.", + "items": { + "type": "string", + "description": "GitLab label name." + } + }, + "remove_source_branch": { + "type": "boolean", + "description": "Whether GitLab should remove the source branch after merge." + }, + "draft": { + "type": "boolean", + "description": "Whether to create the merge request as a draft." + }, + "state_event": { + "enum": [ + "close", + "reopen" + ], + "description": "Lifecycle event for the merge request." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/refs/.create.example.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/refs/.create.example.json new file mode 100644 index 00000000..efa03303 --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/refs/.create.example.json @@ -0,0 +1,4 @@ +{ + "branch": "factory/gitlab-parity", + "ref": "main" +} diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/refs/.schema.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/refs/.schema.json new file mode 100644 index 00000000..7dfbbafd --- /dev/null +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/refs/.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GitLab branch", + "type": "object", + "required": [ + "branch", + "ref" + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "branch": { + "type": "string", + "description": "New branch name." + }, + "ref": { + "type": "string", + "description": "Existing branch, tag, or commit SHA to branch from." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/gitlab/gitlab.mapping.yaml b/packages/gitlab/gitlab.mapping.yaml index 9d420bd5..4f136bdf 100644 --- a/packages/gitlab/gitlab.mapping.yaml +++ b/packages/gitlab/gitlab.mapping.yaml @@ -58,9 +58,24 @@ resources: path: /gitlab/projects/{namespace}/{project}/pipelines/{id}__{ref}/jobs/{job_id}.json iterate: true writebacks: + issue_create: + match: /gitlab/projects/*/issues/*.json + endpoint: POST /projects/{project_id}/issues merge_request_update: match: /gitlab/projects/*/merge_requests/*/meta.json endpoint: PUT /projects/{project_id}/merge_requests/{iid} + merge_request_create: + match: /gitlab/projects/*/merge-requests/*.json + endpoint: POST /projects/{project_id}/merge_requests + merge_request_merge: + match: /gitlab/projects/*/merge_requests/*/merge.json + endpoint: PUT /projects/{project_id}/merge_requests/{iid}/merge + merge_request_close: + match: /gitlab/projects/*/merge_requests/*/close.json + endpoint: PUT /projects/{project_id}/merge_requests/{iid} + branch_create: + match: /gitlab/projects/*/refs/*.json + endpoint: POST /projects/{project_id}/repository/branches merge_request_discussion: match: /gitlab/projects/*/merge_requests/*/discussions/*.json endpoint: POST /projects/{project_id}/merge_requests/{iid}/discussions diff --git a/packages/gitlab/package.json b/packages/gitlab/package.json index 38f1fe1d..c86eea19 100644 --- a/packages/gitlab/package.json +++ b/packages/gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/adapter-gitlab", - "version": "0.4.2", + "version": "0.5.0", "description": "GitLab adapter for relayfile — maps GitLab merge requests, issues, pipelines, jobs, commits, and webhooks to relayfile VFS paths", "type": "module", "main": "dist/index.js", diff --git a/packages/gitlab/src/layout-prompt.ts b/packages/gitlab/src/layout-prompt.ts index 1bae8f0f..306c2c4d 100644 --- a/packages/gitlab/src/layout-prompt.ts +++ b/packages/gitlab/src/layout-prompt.ts @@ -12,6 +12,12 @@ Always run \`ls\` before constructing a path. GitLab projects may live in nested Directory records own child files and use \`__/meta.json\`: merge requests, issues, pipelines, and commits. Merge requests may have \`diff.patch\`, \`discussions/*.json\`, and \`approvals.json\` next to \`meta.json\`; issues and commits may have \`comments/*.json\`; pipelines may have \`jobs/*.json\`. Flat records with no child files use \`__.json\` or \`.json\` when no useful slug exists. +## Writeback + +Create an issue by writing a valid JSON document with \`title\` to any non-canonical filename under \`/gitlab/projects///issues/\`. Create a merge request by writing \`source_branch\`, \`target_branch\`, and \`title\` under \`/gitlab/projects///merge-requests/\`; create its branch first by writing \`branch\` and \`ref\` under \`refs/\`. To update an existing issue or merge request, write mutable fields to its canonical \`meta.json\`. Accept a merge request with an optional \`merge_commit_message\`, \`squash\`, or \`should_remove_source_branch\` payload at \`merge_requests/__/merge.json\`; close or reopen it with \`{ "state_event": "close" }\` or \`{ "state_event": "reopen" }\` at sibling \`close.json\`. + +Declared writable templates: \`/gitlab/projects/{projectPath}/issues\`, \`/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json\`, \`/gitlab/projects/{projectPath}/merge-requests\`, \`/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json\`, \`/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json\`, \`/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json\`, \`/gitlab/projects/{projectPath}/refs\`, \`/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions\`, and \`/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments\`. + ## Indexes Project rows use: diff --git a/packages/gitlab/src/layout.ts b/packages/gitlab/src/layout.ts index 42a68467..0c3a7bb6 100644 --- a/packages/gitlab/src/layout.ts +++ b/packages/gitlab/src/layout.ts @@ -26,10 +26,14 @@ export const layoutManifest: CoreLayoutManifestProvider = () => ({ materialization: 'eager', aliasSegments: ['by-id', 'by-title', 'by-state', 'by-assignee', 'by-creator', 'by-priority'], writebackResources: [ + { path: 'gitlab/projects/**/merge-requests', schemaId: 'gitlab/merge-request' }, { path: 'gitlab/projects/**/merge_requests', schemaId: 'gitlab/merge-request' }, { path: 'gitlab/projects/**/merge_requests/discussions', schemaId: 'gitlab/merge-request-discussion' }, + { path: 'gitlab/projects/**/merge_requests/*/merge.json', schemaId: 'gitlab/merge-request-merge' }, + { path: 'gitlab/projects/**/merge_requests/*/close.json', schemaId: 'gitlab/merge-request-close' }, ], }, + { path: 'gitlab/projects/**/refs', title: 'Branch refs', materialization: 'lazy', aliasSegments: [], writebackResources: [{ path: 'gitlab/projects/**/refs', schemaId: 'gitlab/ref' }] }, { path: 'gitlab/projects/**/issues', title: 'Issues', diff --git a/packages/gitlab/src/resources.ts b/packages/gitlab/src/resources.ts index 72354234..53fdb52d 100644 --- a/packages/gitlab/src/resources.ts +++ b/packages/gitlab/src/resources.ts @@ -4,10 +4,76 @@ export interface AdapterResourceConfig { readonly pathPattern: RegExp; readonly idPattern: RegExp; readonly schema: string; - readonly createExample: string; + readonly createExample?: string; + readonly operations?: readonly AdapterResourceOperation[]; } +export type AdapterResourceOperation = "create" | "update" | "delete"; + export const resources = [ + { + name: "issues", + path: "/gitlab/projects/{projectPath}/issues", + pathPattern: /^\/gitlab\/projects\/.+?\/issues(?:\/[^\/]+(?:\.json)?)?$/, + idPattern: /^[1-9]\d*$/, + schema: "discovery/gitlab/projects/{projectPath}/issues/.schema.json", + createExample: "discovery/gitlab/projects/{projectPath}/issues/.create.example.json", + operations: ["create"], + }, + { + name: "issues", + path: "/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json", + pathPattern: /^\/gitlab\/projects\/.+?\/issues\/[1-9]\d*(?:__[^\/]+)?\/meta\.json$/, + idPattern: /^[1-9]\d*(?:__.*)?$/, + schema: "discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.schema.json", + createExample: "discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.create.example.json", + operations: ["update"], + }, + { + name: "merge-requests", + path: "/gitlab/projects/{projectPath}/merge-requests", + pathPattern: /^\/gitlab\/projects\/.+?\/merge-requests(?:\/[^\/]+(?:\.json)?)?$/, + idPattern: /^[1-9]\d*$/, + schema: "discovery/gitlab/projects/{projectPath}/merge-requests/.schema.json", + createExample: "discovery/gitlab/projects/{projectPath}/merge-requests/.create.example.json", + operations: ["create"], + }, + { + name: "merge-requests", + path: "/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json", + pathPattern: /^\/gitlab\/projects\/.+?\/merge_requests\/[1-9]\d*(?:__[^\/]+)?\/meta\.json$/, + idPattern: /^[1-9]\d*(?:__.*)?$/, + schema: "discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.schema.json", + createExample: "discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json/.create.example.json", + operations: ["update"], + }, + { + name: "merge", + path: "/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json", + pathPattern: /^\/gitlab\/projects\/.+?\/merge_requests\/[1-9]\d*(?:__[^\/]+)?\/merge\.json$/, + idPattern: /^[1-9]\d*(?:__.*)?$/, + schema: "discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.schema.json", + createExample: "discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json/.create.example.json", + operations: ["update"], + }, + { + name: "close-merge-request", + path: "/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json", + pathPattern: /^\/gitlab\/projects\/.+?\/merge_requests\/[1-9]\d*(?:__[^\/]+)?\/close\.json$/, + idPattern: /^[1-9]\d*(?:__.*)?$/, + schema: "discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.schema.json", + createExample: "discovery/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json/.create.example.json", + operations: ["update"], + }, + { + name: "refs", + path: "/gitlab/projects/{projectPath}/refs", + pathPattern: /^\/gitlab\/projects\/.+?\/refs(?:\/[^\/]+(?:\.json)?)?$/, + idPattern: /^$/, + schema: "discovery/gitlab/projects/{projectPath}/refs/.schema.json", + createExample: "discovery/gitlab/projects/{projectPath}/refs/.create.example.json", + operations: ["create"], + }, { name: "discussions", path: "/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions", diff --git a/packages/gitlab/src/writeback.ts b/packages/gitlab/src/writeback.ts index 3326e20b..e578c097 100644 --- a/packages/gitlab/src/writeback.ts +++ b/packages/gitlab/src/writeback.ts @@ -1,260 +1,107 @@ +// @ts-nocheck -- payloads remain runtime-validated so provider input is never implicit. import { withProxyRetry } from '@relayfile/adapter-core/http'; import { ReadOnlyFieldError, classifyWrite } from '@relayfile/adapter-core'; -import type { ConnectionProvider, WritebackPathTarget, WritebackResult } from './types.js'; import { decodeProjectPath, parseGitLabPath } from './path-mapper.js'; import { resources } from './resources.js'; export { ReadOnlyFieldError } from '@relayfile/adapter-core'; -export interface GitLabWritebackRequest { - action: 'delete_issue_note' | 'delete_merge_request_discussion'; - method: 'DELETE'; - endpoint: string; - body?: Record; -} +const READ_ONLY_FIELDS = new Set(['id', 'createdAt', 'updatedAt', 'url', 'identifier', 'provider', 'objectType', 'objectId', 'workspaceId', 'connectionId', '_webhook', '_connection']); +const ISSUE_FIELDS = ['title', 'description', 'labels', 'assignee_ids', 'milestone_id', 'confidential', 'state_event']; +const MR_FIELDS = ['source_branch', 'target_branch', 'title', 'description', 'labels', 'remove_source_branch', 'draft', 'state_event']; export class GitLabWritebackHandler { - constructor( - private readonly provider: ConnectionProvider, - private readonly options: { baseUrl?: string; connectionId?: string } = {}, - ) {} + constructor(provider, options = {}) { this.provider = provider; this.options = options; } - extractWritebackTarget(path: string): WritebackPathTarget { + extractWritebackTarget(path) { const parsed = parseGitLabPath(path); - if (!parsed) { - throw new Error(`Unsupported GitLab writeback path: ${path}`); - } - + if (!parsed) throw unsupported(path); const route = classifyWrite(path, resources); - const isMetadataPath = parsed.subResource === 'meta.json' || parsed.subResource === 'metadata.json' || parsed.subResource === undefined; - - if (parsed.objectType === 'merge_requests' && isMetadataPath) { - return { - entity: 'merge_request', - projectPath: parsed.projectPath, - resourceId: parsed.objectId, - }; - } - - if ( - route?.resource.name === 'discussions' && - parsed.objectType === 'merge_requests' && - parsed.subResource === 'discussions' && - parsed.subResourceId && - route.kind === 'create' - ) { - return { - entity: 'merge_request_discussion', - projectPath: parsed.projectPath, - resourceId: parsed.objectId, - }; - } - - if (parsed.objectType === 'issues' && isMetadataPath) { - return { - entity: 'issue', - projectPath: parsed.projectPath, - resourceId: parsed.objectId, - }; - } - - if ( - route?.resource.name === 'comments' && - parsed.objectType === 'issues' && - parsed.subResource === 'comments' && - parsed.subResourceId && - route.kind === 'create' - ) { - return { - entity: 'issue_note', - projectPath: parsed.projectPath, - resourceId: parsed.objectId, - }; - } - - throw new Error(`Unsupported GitLab writeback path: ${path}`); + const meta = parsed.subResource === 'meta.json' || parsed.subResource === 'metadata.json' || parsed.subResource === undefined; + if (meta && parsed.objectType === 'issues') return { entity: 'issue', projectPath: parsed.projectPath, resourceId: parsed.objectId }; + if (meta && parsed.objectType === 'merge_requests') return { entity: 'merge_request', projectPath: parsed.projectPath, resourceId: parsed.objectId }; + if (route?.resource.name === 'comments' && route.kind === 'create') return { entity: 'issue_note', projectPath: parsed.projectPath, resourceId: parsed.objectId }; + if (route?.resource.name === 'discussions' && route.kind === 'create') return { entity: 'merge_request_discussion', projectPath: parsed.projectPath, resourceId: parsed.objectId }; + throw unsupported(path); } - resolveDeleteRequest(path: string): GitLabWritebackRequest { - return resolveDeleteRequest(path); - } + resolveDeleteRequest(path) { return resolveDeleteRequest(path); } - async writeBack(workspaceId: string, path: string, content: string): Promise { + async writeBack(workspaceId, path, content) { try { - const target = this.extractWritebackTarget(path); - const body = readWritablePayload(content); - const projectId = encodeURIComponent(target.projectPath); - - let endpoint = ''; - let method: 'POST' | 'PUT' = 'PUT'; - - switch (target.entity) { - case 'merge_request': - endpoint = `/api/v4/projects/${projectId}/merge_requests/${target.resourceId}`; - method = 'PUT'; - break; - case 'merge_request_discussion': - requireString(body, 'body', 'merge request discussion'); - endpoint = `/api/v4/projects/${projectId}/merge_requests/${target.resourceId}/discussions`; - method = 'POST'; - break; - case 'issue': - endpoint = `/api/v4/projects/${projectId}/issues/${target.resourceId}`; - method = 'PUT'; - break; - case 'issue_note': - requireString(body, 'body', 'issue note'); - endpoint = `/api/v4/projects/${projectId}/issues/${target.resourceId}/notes`; - method = 'POST'; - break; - } - + const request = resolveGitLabWritebackRequest(path, content); const response = await withProxyRetry(this.provider).proxy({ - method, - baseUrl: this.options.baseUrl ?? 'https://gitlab.com', - endpoint, + ...request, baseUrl: this.options.baseUrl ?? 'https://gitlab.com', connectionId: this.options.connectionId ?? workspaceId, headers: { 'Content-Type': 'application/json' }, - body, }); - - if (response.status >= 400) { - return { - success: false, - error: `${method} ${endpoint} failed with ${response.status}`, - }; - } - - const result = response.data as { id?: number | string; iid?: number | string } | null; - return { - success: true, - externalId: result?.id ? String(result.id) : result?.iid ? String(result.iid) : undefined, - }; + if (response.status >= 400) return { success: false, error: `${request.method} ${request.endpoint} failed with ${response.status}` }; + const result = response.data; + return { success: true, externalId: result?.id ? String(result.id) : result?.iid ? String(result.iid) : undefined }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; + return { success: false, error: error instanceof Error ? error.message : String(error) }; } } } -export function resolveDeleteRequest(path: string): GitLabWritebackRequest { - const mergeRequestDiscussionNote = parseMergeRequestDiscussionNoteDeletePath(path); - if (mergeRequestDiscussionNote) { - const projectId = encodeURIComponent(mergeRequestDiscussionNote.projectPath); - return { - action: 'delete_merge_request_discussion', - method: 'DELETE', - endpoint: `/api/v4/projects/${projectId}/merge_requests/${mergeRequestDiscussionNote.mergeRequestIid}/discussions/${encodeURIComponent( - mergeRequestDiscussionNote.discussionId, - )}/notes/${encodeURIComponent(mergeRequestDiscussionNote.noteId)}`, - }; - } - - const parsed = parseGitLabPath(path); - if (!parsed) { - throw new Error(`Unsupported GitLab delete writeback path: ${path}`); +export function resolveGitLabWritebackRequest(path, content) { + const route = classifyWrite(path, resources); + if (!route) throw unsupported(path); + if (route.resource.name === 'issues') { + if (route.kind === 'create') return { method: 'POST', endpoint: issueEndpoint(collectionProject(path, 'issues')), body: issuePayload(content, true) }; + if (route.kind === 'patch') { const target = canonicalTarget(path, 'issues'); return { method: 'PUT', endpoint: `${issueEndpoint(target.projectPath)}/${target.iid}`, body: issuePayload(content, false) }; } } - const route = classifyWrite(path, resources, { fsEvent: 'delete' }); - const projectId = encodeURIComponent(parsed.projectPath); - - if ( - route?.resource.name === 'comments' && - route.kind === 'delete' && - parsed.objectType === 'issues' && - parsed.subResource === 'comments' && - parsed.subResourceId - ) { - return { - action: 'delete_issue_note', - method: 'DELETE', - endpoint: `/api/v4/projects/${projectId}/issues/${parsed.objectId}/notes/${encodeURIComponent(parsed.subResourceId)}`, - }; + if (route.resource.name === 'merge-requests') { + if (route.kind === 'create') return { method: 'POST', endpoint: mrEndpoint(collectionProject(path, 'merge-requests')), body: mrPayload(content, true) }; + if (route.kind === 'patch') { const target = canonicalTarget(path, 'merge_requests'); return { method: 'PUT', endpoint: `${mrEndpoint(target.projectPath)}/${target.iid}`, body: mrPayload(content, false) }; } } - - throw new Error(`Unsupported GitLab delete writeback path: ${path}`); -} - -interface MergeRequestDiscussionNoteDeletePath { - discussionId: string; - mergeRequestIid: string; - noteId: string; - projectPath: string; + if (route.resource.name === 'refs' && route.kind === 'create') return { method: 'POST', endpoint: `/api/v4/projects/${projectId(collectionProject(path, 'refs'))}/repository/branches`, body: refPayload(content) }; + if (route.resource.name === 'merge' && route.kind === 'patch') { const target = canonicalTarget(path, 'merge_requests'); return { method: 'PUT', endpoint: `${mrEndpoint(target.projectPath)}/${target.iid}/merge`, body: mergePayload(content) }; } + if (route.resource.name === 'close-merge-request' && route.kind === 'patch') { const target = canonicalTarget(path, 'merge_requests'); return { method: 'PUT', endpoint: `${mrEndpoint(target.projectPath)}/${target.iid}`, body: statePayload(content) }; } + if (route.resource.name === 'discussions' && route.kind === 'create') { const target = canonicalTarget(path, 'merge_requests'); const body = payload(content); required(body, 'body', 'GitLab merge request discussion create writeback'); return { method: 'POST', endpoint: `${mrEndpoint(target.projectPath)}/${target.iid}/discussions`, body }; } + if (route.resource.name === 'comments' && route.kind === 'create') { const target = canonicalTarget(path, 'issues'); const body = payload(content); required(body, 'body', 'GitLab issue note create writeback'); return { method: 'POST', endpoint: `${issueEndpoint(target.projectPath)}/${target.iid}/notes`, body }; } + throw unsupported(path); } -function parseMergeRequestDiscussionNoteDeletePath(path: string): MergeRequestDiscussionNoteDeletePath | null { - const segments = path.split('/').filter(Boolean); - const mergeRequestIndex = segments.lastIndexOf('merge_requests'); - if ( - segments[0] !== 'gitlab' || - segments[1] !== 'projects' || - mergeRequestIndex < 3 || - segments[mergeRequestIndex + 2] !== 'discussions' || - segments[mergeRequestIndex + 4] !== 'notes' || - mergeRequestIndex + 6 !== segments.length - ) { - return null; - } - - const noteSegment = segments[mergeRequestIndex + 5]; - if (!noteSegment?.endsWith('.json')) { - return null; - } - - const projectPath = decodeProjectPath(segments.slice(2, mergeRequestIndex).join('/')); - const mergeRequestIid = decodeDirectoryObjectId(segments[mergeRequestIndex + 1] ?? ''); - const discussionId = decodeURIComponent(segments[mergeRequestIndex + 3] ?? ''); - const noteId = decodeURIComponent(noteSegment.slice(0, -5)); - if (!projectPath || !mergeRequestIid || !discussionId || !noteId) { - return null; - } - - return { - discussionId, - mergeRequestIid, - noteId, - projectPath, - }; +export function resolveDeleteRequest(path) { + const nested = path.match(/^\/gitlab\/projects\/(.+?)\/merge_requests\/([^/]+)\/discussions\/([^/]+)\/notes\/([^/]+)\.json$/); + if (nested) return { action: 'delete_merge_request_discussion', method: 'DELETE', endpoint: `${mrEndpoint(decodeProjectPath(nested[1]))}/${iid(nested[2])}/discussions/${encodeURIComponent(nested[3])}/notes/${encodeURIComponent(nested[4])}` }; + const parsed = parseGitLabPath(path); const route = classifyWrite(path, resources, { fsEvent: 'delete' }); + if (parsed && route?.resource.name === 'comments' && route.kind === 'delete' && parsed.objectType === 'issues' && parsed.subResourceId) return { action: 'delete_issue_note', method: 'DELETE', endpoint: `${issueEndpoint(parsed.projectPath)}/${parsed.objectId}/notes/${encodeURIComponent(parsed.subResourceId)}` }; + throw new Error(`Unsupported GitLab delete writeback path: ${path}`); } -function decodeDirectoryObjectId(segment: string): string { - const decoded = decodeURIComponent(segment); - const separatorIndex = decoded.indexOf('__'); - return separatorIndex > 0 ? decoded.slice(0, separatorIndex) : decoded; +function issuePayload(content, creating) { + const source = typed(content, 'GitLab issue ' + (creating ? 'create' : 'update') + ' payload', ISSUE_FIELDS); const body = {}; + addString(body, source, 'title', creating); addString(body, source, 'description'); labels(body, source); addIntArray(body, source, 'assignee_ids'); addInt(body, source, 'milestone_id'); addBool(body, source, 'confidential'); if (!creating) addState(body, source); + mutable(body, 'GitLab issue ' + (creating ? 'create' : 'update') + ' payload'); return body; } -const READ_ONLY_FIELDS = new Set([ - 'id', - 'createdAt', - 'updatedAt', - 'url', - 'identifier', - 'provider', - 'objectType', - 'objectId', - 'workspaceId', - 'connectionId', - '_webhook', - '_connection', -]); - -function readWritablePayload(content: string): Record { - const payload = JSON.parse(content) as unknown; - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - throw new Error('GitLab writeback payload must be a JSON object'); - } - const record = payload as Record; - for (const key of Object.keys(record)) { - if (READ_ONLY_FIELDS.has(key)) { - throw new ReadOnlyFieldError(key); - } - } - return record; +function mrPayload(content, creating) { + const context = 'GitLab merge request ' + (creating ? 'create' : 'update') + ' payload'; const source = typed(content, context, MR_FIELDS); const body = {}; + for (const field of ['source_branch', 'target_branch', 'title']) addString(body, source, field, creating); + addString(body, source, 'description'); labels(body, source); addBool(body, source, 'remove_source_branch'); addBool(body, source, 'draft'); if (!creating) addState(body, source); mutable(body, context); return body; } -function requireString(payload: Record, key: string, label: string): void { - if (typeof payload[key] !== 'string' || payload[key].trim() === '') { - throw new Error(`GitLab ${label} create writeback requires \`${key}\``); - } -} +function refPayload(content) { const context = 'GitLab branch create payload'; const source = typed(content, context, ['branch', 'ref']); return { branch: required(source, 'branch', context), ref: required(source, 'ref', context) }; } +function mergePayload(content) { const context = 'GitLab merge request merge payload'; const source = typed(content, context, ['merge_commit_message', 'squash', 'should_remove_source_branch']); const body = {}; addString(body, source, 'merge_commit_message'); addBool(body, source, 'squash'); addBool(body, source, 'should_remove_source_branch'); return body; } +function statePayload(content) { const context = 'GitLab merge request close payload'; const source = typed(content, context, ['state_event']); const state_event = required(source, 'state_event', context); if (!['close', 'reopen'].includes(state_event)) throw new Error(`${context}.state_event must be one of close, reopen`); return { state_event }; } + +function payload(content) { let value; try { value = JSON.parse(content); } catch (error) { throw new Error(`Invalid GitLab writeback JSON: ${error instanceof Error ? error.message : String(error)}`); } if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('GitLab writeback payload must be a JSON object'); for (const key of Object.keys(value)) if (READ_ONLY_FIELDS.has(key)) throw new ReadOnlyFieldError(key); return value; } +function typed(content, context, fields) { const value = payload(content); for (const key of Object.keys(value)) if (!fields.includes(key)) throw new Error(`${context}.${key} is not supported`); return value; } +function required(value, key, context) { const field = string(value, key, context); if (!field) throw new Error(`${context}.${key} must be a non-empty string`); return field; } +function string(value, key, context) { const field = value[key]; if (field === undefined) return undefined; if (typeof field !== 'string' || !field.trim()) throw new Error(`${context}.${key} must be a non-empty string`); return field.trim(); } +function addString(body, value, key, requiredValue = false) { const field = string(value, key, `GitLab ${key === 'title' ? 'issue create payload' : 'writeback payload'}`); if (requiredValue && !field) throw new Error(`GitLab ${key === 'title' ? 'issue create payload' : 'merge request create payload'}.${key} must be a non-empty string`); if (field !== undefined) body[key] = field; } +function labels(body, value) { if (value.labels === undefined) return; if (typeof value.labels === 'string' && value.labels.trim()) { body.labels = value.labels.trim(); return; } if (!Array.isArray(value.labels) || value.labels.some((item) => typeof item !== 'string' || !item.trim())) throw new Error('GitLab writeback payload.labels must be a non-empty string or an array of non-empty strings'); body.labels = value.labels.map((item) => item.trim()).join(','); } +function addBool(body, value, key) { if (value[key] === undefined) return; if (typeof value[key] !== 'boolean') throw new Error(`GitLab writeback payload.${key} must be a boolean`); body[key] = value[key]; } +function addInt(body, value, key) { if (value[key] === undefined) return; if (!Number.isInteger(value[key]) || value[key] < 1) throw new Error(`GitLab writeback payload.${key} must be a positive integer`); body[key] = value[key]; } +function addIntArray(body, value, key) { if (value[key] === undefined) return; if (!Array.isArray(value[key]) || value[key].some((item) => !Number.isInteger(item) || item < 1)) throw new Error(`GitLab writeback payload.${key} must be an array of positive integers`); body[key] = value[key]; } +function addState(body, value) { if (value.state_event === undefined) return; if (!['close', 'reopen'].includes(value.state_event)) throw new Error('GitLab writeback payload.state_event must be one of close, reopen'); body.state_event = value.state_event; } +function mutable(body, context) { if (!Object.keys(body).length) throw new Error(`${context} requires at least one mutable field`); } +function collectionProject(path, collection) { const match = path.match(new RegExp(`^/gitlab/projects/(.+?)/${collection}/[^/]+\\.json$`)); if (!match?.[1]) throw unsupported(path); return decodeProjectPath(match[1]); } +function canonicalTarget(path, expected) { const parsed = parseGitLabPath(path); if (!parsed || parsed.objectType !== expected || !/^[1-9]\d*$/.test(parsed.objectId)) throw unsupported(path); return { projectPath: parsed.projectPath, iid: parsed.objectId }; } +function iid(segment) { return decodeURIComponent(segment).split('__')[0]; } +function projectId(projectPath) { return encodeURIComponent(projectPath); } +function issueEndpoint(projectPath) { return `/api/v4/projects/${projectId(projectPath)}/issues`; } +function mrEndpoint(projectPath) { return `/api/v4/projects/${projectId(projectPath)}/merge_requests`; } +function unsupported(path) { return new Error(`Unsupported GitLab writeback path: ${path}. Expected an issue, merge request create/update, branch ref, merge request merge/close, discussion, or issue note.`); } diff --git a/packages/gitlab/test/layout.test.ts b/packages/gitlab/test/layout.test.ts index 2ac39c5b..497b48ad 100644 --- a/packages/gitlab/test/layout.test.ts +++ b/packages/gitlab/test/layout.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { layoutManifest } from '../src/layout.js'; import { gitLabLayoutPromptFile } from '../src/layout-prompt.js'; +import { resources } from '../src/resources.js'; test('layoutManifest exposes GitLab resources with canonical aliases and writeback schema pointers', () => { const manifest = layoutManifest(); @@ -50,3 +51,10 @@ test('gitLabLayoutPromptFile emits a provider-specific root guide', () => { assert.match(file.content, /by-ref/u); assert.match(file.content, /jq/u); }); + +test('GitLab LAYOUT advertises every declared writeback resource', () => { + const content = gitLabLayoutPromptFile().content; + for (const resource of resources) assert.ok(content.includes(resource.path), `missing ${resource.path}`); + const manifestPaths = layoutManifest().resources.flatMap((resource) => resource.writebackResources.map((writeback) => writeback.path)); + for (const path of ['gitlab/projects/**/issues', 'gitlab/projects/**/merge-requests', 'gitlab/projects/**/merge_requests', 'gitlab/projects/**/refs']) assert.ok(manifestPaths.includes(path), `missing ${path}`); +}); diff --git a/packages/gitlab/test/writeback.test.ts b/packages/gitlab/test/writeback.test.ts index 62c0d771..e41db0d2 100644 --- a/packages/gitlab/test/writeback.test.ts +++ b/packages/gitlab/test/writeback.test.ts @@ -2,11 +2,37 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { classifyWrite } from '@relayfile/adapter-core'; -import { GitLabWritebackHandler, resolveDeleteRequest } from '../src/writeback.js'; +import { GitLabWritebackHandler, resolveDeleteRequest, resolveGitLabWritebackRequest } from '../src/writeback.js'; import { resources } from '../src/resources.js'; import { MockProvider, ok } from './helpers.js'; describe('GitLabWritebackHandler', () => { + it('creates issues, merge requests, and branches through GitLab v4', () => { + assert.deepStrictEqual(resolveGitLabWritebackRequest('/gitlab/projects/acme/api/issues/factory-draft.json', JSON.stringify({ title: 'Parity', labels: ['factory'] })), { + method: 'POST', endpoint: '/api/v4/projects/acme%2Fapi/issues', body: { title: 'Parity', labels: 'factory' }, + }); + assert.deepStrictEqual(resolveGitLabWritebackRequest('/gitlab/projects/acme/api/merge-requests/factory-draft.json', JSON.stringify({ source_branch: 'factory/parity', target_branch: 'main', title: 'Parity' })), { + method: 'POST', endpoint: '/api/v4/projects/acme%2Fapi/merge_requests', body: { source_branch: 'factory/parity', target_branch: 'main', title: 'Parity' }, + }); + assert.deepStrictEqual(resolveGitLabWritebackRequest('/gitlab/projects/acme/api/refs/factory-branch.json', JSON.stringify({ branch: 'factory/parity', ref: 'main' })), { + method: 'POST', endpoint: '/api/v4/projects/acme%2Fapi/repository/branches', body: { branch: 'factory/parity', ref: 'main' }, + }); + }); + + it('accepts and closes merge requests through canonical sidecars', () => { + assert.deepStrictEqual(resolveGitLabWritebackRequest('/gitlab/projects/acme/api/merge_requests/42__parity/merge.json', JSON.stringify({ squash: true })), { + method: 'PUT', endpoint: '/api/v4/projects/acme%2Fapi/merge_requests/42/merge', body: { squash: true }, + }); + assert.deepStrictEqual(resolveGitLabWritebackRequest('/gitlab/projects/acme/api/merge_requests/42__parity/close.json', JSON.stringify({ state_event: 'close' })), { + method: 'PUT', endpoint: '/api/v4/projects/acme%2Fapi/merge_requests/42', body: { state_event: 'close' }, + }); + }); + + it('rejects missing GitLab create fields, invalid lifecycle states, and unsupported paths', () => { + assert.throws(() => resolveGitLabWritebackRequest('/gitlab/projects/acme/api/issues/draft.json', JSON.stringify({})), /GitLab issue create payload.title must be a non-empty string/); + assert.throws(() => resolveGitLabWritebackRequest('/gitlab/projects/acme/api/merge_requests/42__parity/close.json', JSON.stringify({ state_event: 'merged' })), /state_event must be one of close, reopen/); + assert.throws(() => resolveGitLabWritebackRequest('/gitlab/projects/acme/api/pipelines/draft.json', '{}'), /Expected an issue, merge request create\/update, branch ref, merge request merge\/close, discussion, or issue note/); + }); it('matches merge request metadata writebacks', async () => { const provider = new MockProvider(); provider.register('PUT', '/api/v4/projects/acme%2Fapi/merge_requests/42', ok({ iid: 42 })); @@ -60,7 +86,7 @@ describe('GitLabWritebackHandler', () => { ); assert.strictEqual(missingBody.success, false); - assert.match(missingBody.error ?? '', /requires `body`/); + assert.match(missingBody.error ?? '', /body must be a non-empty string/); assert.strictEqual(readOnly.success, false); assert.match(readOnly.error ?? '', /read-only/); assert.strictEqual(provider.requests.length, 0); @@ -83,10 +109,8 @@ describe('GitLabWritebackHandler', () => { ); assert.deepStrictEqual(update, { success: true, externalId: '7' }); - assert.deepStrictEqual(invalid, { - success: false, - error: 'Unsupported GitLab writeback path: /gitlab/projects/acme/api/issues/7__fix-bug/comments/11.json', - }); + assert.strictEqual(invalid.success, false); + assert.match(invalid.error ?? '', /Expected an issue, merge request create\/update, branch ref, merge request merge\/close, discussion, or issue note/); }); it('maps canonical discussion and note paths to DELETE requests', () => { diff --git a/scripts/writeback-discovery-data.mjs b/scripts/writeback-discovery-data.mjs index 8311dc11..b8d740b4 100644 --- a/scripts/writeback-discovery-data.mjs +++ b/scripts/writeback-discovery-data.mjs @@ -171,7 +171,7 @@ export const adapters = [ slug: 'gitlab', title: 'GitLab adapter', overview: - 'The GitLab adapter exposes projects, merge requests, discussions, issues, commits, pipelines, jobs, deployments, and tags under `/gitlab`, with writeback routes for merge request discussions and issue notes.', + 'The GitLab adapter exposes projects, merge requests, discussions, issues, commits, pipelines, jobs, deployments, and tags under `/gitlab`. Relayfile writeback can create and update issues and merge requests, create branches, accept or close/reopen merge requests, and add merge request discussions and issue notes.', readPaths: [ ['/gitlab/projects///merge_requests/__/meta.json', 'Merge request metadata.'], ['/gitlab/projects///merge_requests/__/discussions/.json', 'Merge request discussions.'], @@ -181,6 +181,13 @@ export const adapters = [ ['/gitlab/projects///tags//meta.json', 'Tag records.'], ], endpoints: [ + endpoint('/gitlab/projects/{projectPath}/issues/new.json', 'Create GitLab issue', 'Creates a GitLab issue in the project named by the path.', ['title'], gitlabIssueProps(), { title: 'Replace example issue title', description: 'Replace example issue description.', labels: ['factory'] }, { operations: ['create'] }), + endpoint('/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json', 'Update GitLab issue', 'Updates mutable fields on a GitLab issue.', [], gitlabIssueProps(), { title: 'Replace example issue title' }, { operations: ['update'] }), + endpoint('/gitlab/projects/{projectPath}/merge-requests/new.json', 'Create GitLab merge request', 'Creates a GitLab merge request from a source branch into a target branch.', ['source_branch', 'target_branch', 'title'], gitlabMergeRequestProps(), { source_branch: 'factory/gitlab-parity', target_branch: 'main', title: 'Replace example merge request title', description: 'Replace example merge request description.' }, { operations: ['create'] }), + endpoint('/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json', 'Update GitLab merge request', 'Updates mutable fields on a GitLab merge request.', [], gitlabMergeRequestUpdateProps(), { title: 'Replace example merge request title' }, { operations: ['update'] }), + endpoint('/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json', 'Merge GitLab merge request', 'Accepts a GitLab merge request.', [], gitlabMergeProps(), { merge_commit_message: 'feat: merge factory work', squash: true }, { operations: ['update'] }), + endpoint('/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json', 'Close or reopen GitLab merge request', 'Closes or reopens a GitLab merge request.', ['state_event'], gitlabMergeRequestCloseProps(), { state_event: 'close' }, { operations: ['update'] }), + endpoint('/gitlab/projects/{projectPath}/refs/new.json', 'Create GitLab branch', 'Creates a GitLab branch from an existing branch, tag, or commit ref.', ['branch', 'ref'], gitlabRefProps(), { branch: 'factory/gitlab-parity', ref: 'main' }, { operations: ['create'] }), endpoint('/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/new.json', 'Create GitLab merge request discussion', 'Creates a discussion on a merge request.', ['body'], gitlabDiscussionProps(), { body: 'Replace example discussion body.' }), endpoint('/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/new.json', 'Create GitLab issue note', 'Creates a note on an issue.', ['body'], gitlabIssueNoteProps(), { body: 'Replace example note body.' }), ], @@ -1011,6 +1018,58 @@ function gitlabDiscussionProps() { }; } +function gitlabIssueProps() { + return { + title: str('Issue title.'), + description: str('Markdown issue description.'), + labels: arr(str('GitLab label name.'), 'Label names. The adapter sends them to GitLab as a comma-separated labels value.'), + assignee_ids: arr(int('GitLab user id.'), 'GitLab user IDs to assign.'), + milestone_id: int('GitLab milestone ID.'), + confidential: bool('Whether the issue is confidential.'), + state_event: en(['close', 'reopen'], 'Lifecycle event when updating an existing issue.'), + }; +} + +function gitlabMergeRequestProps() { + return { + source_branch: str('Existing source branch name.'), + target_branch: str('Target branch name.'), + title: str('Merge request title.'), + description: str('Markdown merge request description.'), + labels: arr(str('GitLab label name.'), 'Label names. The adapter sends them to GitLab as a comma-separated labels value.'), + remove_source_branch: bool('Whether GitLab should remove the source branch after merge.'), + draft: bool('Whether to create the merge request as a draft.'), + }; +} + +function gitlabMergeRequestUpdateProps() { + return { + ...gitlabMergeRequestProps(), + state_event: en(['close', 'reopen'], 'Lifecycle event for the merge request.'), + }; +} + +function gitlabMergeProps() { + return { + merge_commit_message: str('Custom commit message for the merge commit.'), + squash: bool('Whether GitLab should squash commits before merge.'), + should_remove_source_branch: bool('Whether GitLab should remove the source branch after merge.'), + }; +} + +function gitlabMergeRequestCloseProps() { + return { + state_event: en(['close', 'reopen'], 'Set `close` to close the merge request or `reopen` to reopen it.'), + }; +} + +function gitlabRefProps() { + return { + branch: str('New branch name.'), + ref: str('Existing branch, tag, or commit SHA to branch from.'), + }; +} + function gitlabIssueNoteProps() { return { body: str('Markdown note body.', undefined, { minLength: 1, pattern: '.*\\S.*' }), diff --git a/scripts/writeback-discovery-normalizer.mjs b/scripts/writeback-discovery-normalizer.mjs index aa25e30f..ec4324aa 100644 --- a/scripts/writeback-discovery-normalizer.mjs +++ b/scripts/writeback-discovery-normalizer.mjs @@ -217,6 +217,22 @@ function readOnlyString(description, format) { } function resourceNameFor(adapterSlug, resourcePath) { + if (adapterSlug === 'gitlab') { + if ( + resourcePath === '/gitlab/projects/{projectPath}/issues' || + resourcePath === '/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json' + ) { + return 'issues'; + } + if ( + resourcePath === '/gitlab/projects/{projectPath}/merge-requests' || + resourcePath === '/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json' + ) { + return 'merge-requests'; + } + if (resourcePath.endsWith('/merge.json')) return 'merge'; + if (resourcePath.endsWith('/close.json')) return 'close-merge-request'; + } if (adapterSlug === 'github' && resourcePath.includes('/issues/') && resourcePath.endsWith('/comments')) { return 'issue-comments'; } @@ -263,11 +279,23 @@ function pathPatternSourceFor(adapterSlug, resourcePath) { return '^/slack/channels/[^/]+/messages(?:/[^/]+(?:\\.json|/meta\\.json)?)?$'; } if (adapterSlug === 'gitlab' && resourcePath.includes('/merge_requests/{mergeRequestIid}__{slug}/discussions')) { - return '^/gitlab/projects/.+?/merge_requests/[^/]+(?:__[^/]+)?/discussions(?:/[^/]+(?:\\.json)?)?$'; + return '^/gitlab/projects/.+?/merge_requests/[^/]+(?:__[^/]+)?/discussions(?:/[^/]+(?:\\.json)?|/[^/]+/notes/[^/]+\\.json)?$'; } if (adapterSlug === 'gitlab' && resourcePath.includes('/issues/{issueIid}__{slug}/comments')) { return '^/gitlab/projects/.+?/issues/[^/]+(?:__[^/]+)?/comments(?:/[^/]+(?:\\.json)?)?$'; } + if (adapterSlug === 'gitlab' && resourcePath === '/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json') { + return '^/gitlab/projects/.+?/issues/[1-9]\\d*(?:__[^/]+)?/meta\\.json$'; + } + if (adapterSlug === 'gitlab' && resourcePath === '/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json') { + return '^/gitlab/projects/.+?/merge_requests/[1-9]\\d*(?:__[^/]+)?/meta\\.json$'; + } + if (adapterSlug === 'gitlab' && resourcePath === '/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json') { + return '^/gitlab/projects/.+?/merge_requests/[1-9]\\d*(?:__[^/]+)?/merge\\.json$'; + } + if (adapterSlug === 'gitlab' && resourcePath === '/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/close.json') { + return '^/gitlab/projects/.+?/merge_requests/[1-9]\\d*(?:__[^/]+)?/close\\.json$'; + } if (adapterSlug === 'github' && resourcePath === '/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json') { return '^/github/repos/[^/]+/[^/]+/pulls/[1-9]\\d*(?:__[^/]+)?/merge\\.json$'; } @@ -328,6 +356,23 @@ function idPatternFor(adapterSlug, resourcePath) { return pattern('^[A-Za-z0-9_.:-]+(?:--[A-Za-z0-9_.:-]+)*$'); } if (adapterSlug === 'gitlab') { + if (resourcePath === '/gitlab/projects/{projectPath}/issues') { + return pattern('^[1-9]\\d*$'); + } + if (resourcePath === '/gitlab/projects/{projectPath}/merge-requests') { + return pattern('^[1-9]\\d*$'); + } + if (resourcePath === '/gitlab/projects/{projectPath}/refs') { + return pattern('^$'); + } + if ( + resourcePath === '/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json' || + resourcePath === '/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json' || + resourcePath.endsWith('/merge.json') || + resourcePath.endsWith('/close.json') + ) { + return pattern('^[1-9]\\d*(?:__.*)?$'); + } return pattern('^[A-Za-z0-9_.:-]+$'); } if (adapterSlug === 'granola') { diff --git a/scripts/writeback-discovery-normalizer.test.mjs b/scripts/writeback-discovery-normalizer.test.mjs index be6caf50..9118a2ce 100644 --- a/scripts/writeback-discovery-normalizer.test.mjs +++ b/scripts/writeback-discovery-normalizer.test.mjs @@ -230,7 +230,7 @@ test('normalizes GitLab slugged nested writeback paths to runtime matchers', () assert.ok(commentEndpoint, 'expected GitLab issue comment endpoint'); assert.equal( discussionEndpoint.resource.pathPatternSource, - '^/gitlab/projects/.+?/merge_requests/[^/]+(?:__[^/]+)?/discussions(?:/[^/]+(?:\\.json)?)?$', + '^/gitlab/projects/.+?/merge_requests/[^/]+(?:__[^/]+)?/discussions(?:/[^/]+(?:\\.json)?|/[^/]+/notes/[^/]+\\.json)?$', ); assert.equal( commentEndpoint.resource.pathPatternSource, From 6e7aa4758960961faed08aead419495a426c8a09 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Fri, 18 Sep 2026 20:43:44 -0700 Subject: [PATCH 2/3] refactor(gitlab): restore explicit types on the writeback handler Co-Authored-By: Claude Opus 5 (1M context) Session-Id: 01a0b7be-e7b0-7631-857e-3fa62f47bbc9 --- packages/gitlab/src/writeback.ts | 627 +++++++++++++++++++++++++++---- 1 file changed, 556 insertions(+), 71 deletions(-) diff --git a/packages/gitlab/src/writeback.ts b/packages/gitlab/src/writeback.ts index e578c097..d6046aae 100644 --- a/packages/gitlab/src/writeback.ts +++ b/packages/gitlab/src/writeback.ts @@ -1,107 +1,592 @@ -// @ts-nocheck -- payloads remain runtime-validated so provider input is never implicit. import { withProxyRetry } from '@relayfile/adapter-core/http'; import { ReadOnlyFieldError, classifyWrite } from '@relayfile/adapter-core'; +import type { + ConnectionProvider, + ProxyRequest, + ProxyResponse, + WritebackPathTarget, + WritebackResult, +} from './types.js'; import { decodeProjectPath, parseGitLabPath } from './path-mapper.js'; import { resources } from './resources.js'; export { ReadOnlyFieldError } from '@relayfile/adapter-core'; -const READ_ONLY_FIELDS = new Set(['id', 'createdAt', 'updatedAt', 'url', 'identifier', 'provider', 'objectType', 'objectId', 'workspaceId', 'connectionId', '_webhook', '_connection']); -const ISSUE_FIELDS = ['title', 'description', 'labels', 'assignee_ids', 'milestone_id', 'confidential', 'state_event']; -const MR_FIELDS = ['source_branch', 'target_branch', 'title', 'description', 'labels', 'remove_source_branch', 'draft', 'state_event']; +type GitLabWritebackAction = 'delete_issue_note' | 'delete_merge_request_discussion'; +type GitLabStateEvent = 'close' | 'reopen'; +type WritebackPayload = Record; + +export interface GitLabWritebackRequest { + method: ProxyRequest['method']; + endpoint: string; + body?: WritebackPayload; + action?: GitLabWritebackAction; +} + +export interface GitLabWritebackHandlerOptions { + baseUrl?: string; + connectionId?: string; +} + +interface CanonicalTarget { + iid: string; + projectPath: string; +} + +interface GitLabWritebackResponse { + id?: number | string; + iid?: number | string; +} + +interface IssueWritebackPayload extends WritebackPayload { + assignee_ids?: number[]; + confidential?: boolean; + description?: string; + labels?: string; + milestone_id?: number; + state_event?: GitLabStateEvent; + title?: string; +} + +interface MergeRequestWritebackPayload extends WritebackPayload { + description?: string; + draft?: boolean; + labels?: string; + remove_source_branch?: boolean; + source_branch?: string; + state_event?: GitLabStateEvent; + target_branch?: string; + title?: string; +} + +interface RefWritebackPayload extends WritebackPayload { + branch: string; + ref: string; +} + +interface MergeWritebackPayload extends WritebackPayload { + merge_commit_message?: string; + should_remove_source_branch?: boolean; + squash?: boolean; +} + +interface StateWritebackPayload extends WritebackPayload { + state_event: GitLabStateEvent; +} + +const READ_ONLY_FIELDS = new Set([ + 'id', + 'createdAt', + 'updatedAt', + 'url', + 'identifier', + 'provider', + 'objectType', + 'objectId', + 'workspaceId', + 'connectionId', + '_webhook', + '_connection', +]); +const ISSUE_FIELDS = [ + 'title', + 'description', + 'labels', + 'assignee_ids', + 'milestone_id', + 'confidential', + 'state_event', +] as const; +const MR_FIELDS = [ + 'source_branch', + 'target_branch', + 'title', + 'description', + 'labels', + 'remove_source_branch', + 'draft', + 'state_event', +] as const; export class GitLabWritebackHandler { - constructor(provider, options = {}) { this.provider = provider; this.options = options; } + constructor( + private readonly provider: ConnectionProvider, + private readonly options: GitLabWritebackHandlerOptions = {}, + ) {} - extractWritebackTarget(path) { + extractWritebackTarget(path: string): WritebackPathTarget { const parsed = parseGitLabPath(path); - if (!parsed) throw unsupported(path); + if (!parsed) { + throw unsupported(path); + } + const route = classifyWrite(path, resources); - const meta = parsed.subResource === 'meta.json' || parsed.subResource === 'metadata.json' || parsed.subResource === undefined; - if (meta && parsed.objectType === 'issues') return { entity: 'issue', projectPath: parsed.projectPath, resourceId: parsed.objectId }; - if (meta && parsed.objectType === 'merge_requests') return { entity: 'merge_request', projectPath: parsed.projectPath, resourceId: parsed.objectId }; - if (route?.resource.name === 'comments' && route.kind === 'create') return { entity: 'issue_note', projectPath: parsed.projectPath, resourceId: parsed.objectId }; - if (route?.resource.name === 'discussions' && route.kind === 'create') return { entity: 'merge_request_discussion', projectPath: parsed.projectPath, resourceId: parsed.objectId }; + const meta = + parsed.subResource === 'meta.json' || + parsed.subResource === 'metadata.json' || + parsed.subResource === undefined; + + if (meta && parsed.objectType === 'issues') { + return { + entity: 'issue', + projectPath: parsed.projectPath, + resourceId: parsed.objectId, + }; + } + if (meta && parsed.objectType === 'merge_requests') { + return { + entity: 'merge_request', + projectPath: parsed.projectPath, + resourceId: parsed.objectId, + }; + } + if (route?.resource.name === 'comments' && route.kind === 'create') { + return { + entity: 'issue_note', + projectPath: parsed.projectPath, + resourceId: parsed.objectId, + }; + } + if (route?.resource.name === 'discussions' && route.kind === 'create') { + return { + entity: 'merge_request_discussion', + projectPath: parsed.projectPath, + resourceId: parsed.objectId, + }; + } + throw unsupported(path); } - resolveDeleteRequest(path) { return resolveDeleteRequest(path); } + resolveDeleteRequest(path: string): GitLabWritebackRequest { + return resolveDeleteRequest(path); + } - async writeBack(workspaceId, path, content) { + async writeBack( + workspaceId: string, + path: string, + content: string, + ): Promise { try { const request = resolveGitLabWritebackRequest(path, content); - const response = await withProxyRetry(this.provider).proxy({ - ...request, baseUrl: this.options.baseUrl ?? 'https://gitlab.com', + const proxyRequest: ProxyRequest = { + ...request, + baseUrl: this.options.baseUrl ?? 'https://gitlab.com', connectionId: this.options.connectionId ?? workspaceId, headers: { 'Content-Type': 'application/json' }, - }); - if (response.status >= 400) return { success: false, error: `${request.method} ${request.endpoint} failed with ${response.status}` }; + }; + const response: ProxyResponse = await withProxyRetry( + this.provider, + ).proxy(proxyRequest); + + if (response.status >= 400) { + return { + success: false, + error: `${request.method} ${request.endpoint} failed with ${response.status}`, + }; + } + const result = response.data; - return { success: true, externalId: result?.id ? String(result.id) : result?.iid ? String(result.iid) : undefined }; + return { + success: true, + externalId: result?.id ? String(result.id) : result?.iid ? String(result.iid) : undefined, + }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; } } } -export function resolveGitLabWritebackRequest(path, content) { +export function resolveGitLabWritebackRequest( + path: string, + content: string, +): GitLabWritebackRequest { const route = classifyWrite(path, resources); - if (!route) throw unsupported(path); + if (!route) { + throw unsupported(path); + } + if (route.resource.name === 'issues') { - if (route.kind === 'create') return { method: 'POST', endpoint: issueEndpoint(collectionProject(path, 'issues')), body: issuePayload(content, true) }; - if (route.kind === 'patch') { const target = canonicalTarget(path, 'issues'); return { method: 'PUT', endpoint: `${issueEndpoint(target.projectPath)}/${target.iid}`, body: issuePayload(content, false) }; } + if (route.kind === 'create') { + return { + method: 'POST', + endpoint: issueEndpoint(collectionProject(path, 'issues')), + body: issuePayload(content, true), + }; + } + if (route.kind === 'patch') { + const target = canonicalTarget(path, 'issues'); + return { + method: 'PUT', + endpoint: `${issueEndpoint(target.projectPath)}/${target.iid}`, + body: issuePayload(content, false), + }; + } } + if (route.resource.name === 'merge-requests') { - if (route.kind === 'create') return { method: 'POST', endpoint: mrEndpoint(collectionProject(path, 'merge-requests')), body: mrPayload(content, true) }; - if (route.kind === 'patch') { const target = canonicalTarget(path, 'merge_requests'); return { method: 'PUT', endpoint: `${mrEndpoint(target.projectPath)}/${target.iid}`, body: mrPayload(content, false) }; } - } - if (route.resource.name === 'refs' && route.kind === 'create') return { method: 'POST', endpoint: `/api/v4/projects/${projectId(collectionProject(path, 'refs'))}/repository/branches`, body: refPayload(content) }; - if (route.resource.name === 'merge' && route.kind === 'patch') { const target = canonicalTarget(path, 'merge_requests'); return { method: 'PUT', endpoint: `${mrEndpoint(target.projectPath)}/${target.iid}/merge`, body: mergePayload(content) }; } - if (route.resource.name === 'close-merge-request' && route.kind === 'patch') { const target = canonicalTarget(path, 'merge_requests'); return { method: 'PUT', endpoint: `${mrEndpoint(target.projectPath)}/${target.iid}`, body: statePayload(content) }; } - if (route.resource.name === 'discussions' && route.kind === 'create') { const target = canonicalTarget(path, 'merge_requests'); const body = payload(content); required(body, 'body', 'GitLab merge request discussion create writeback'); return { method: 'POST', endpoint: `${mrEndpoint(target.projectPath)}/${target.iid}/discussions`, body }; } - if (route.resource.name === 'comments' && route.kind === 'create') { const target = canonicalTarget(path, 'issues'); const body = payload(content); required(body, 'body', 'GitLab issue note create writeback'); return { method: 'POST', endpoint: `${issueEndpoint(target.projectPath)}/${target.iid}/notes`, body }; } + if (route.kind === 'create') { + return { + method: 'POST', + endpoint: mrEndpoint(collectionProject(path, 'merge-requests')), + body: mrPayload(content, true), + }; + } + if (route.kind === 'patch') { + const target = canonicalTarget(path, 'merge_requests'); + return { + method: 'PUT', + endpoint: `${mrEndpoint(target.projectPath)}/${target.iid}`, + body: mrPayload(content, false), + }; + } + } + + if (route.resource.name === 'refs' && route.kind === 'create') { + return { + method: 'POST', + endpoint: `/api/v4/projects/${projectId(collectionProject(path, 'refs'))}/repository/branches`, + body: refPayload(content), + }; + } + + if (route.resource.name === 'merge' && route.kind === 'patch') { + const target = canonicalTarget(path, 'merge_requests'); + return { + method: 'PUT', + endpoint: `${mrEndpoint(target.projectPath)}/${target.iid}/merge`, + body: mergePayload(content), + }; + } + + if (route.resource.name === 'close-merge-request' && route.kind === 'patch') { + const target = canonicalTarget(path, 'merge_requests'); + return { + method: 'PUT', + endpoint: `${mrEndpoint(target.projectPath)}/${target.iid}`, + body: statePayload(content), + }; + } + + if (route.resource.name === 'discussions' && route.kind === 'create') { + const target = canonicalTarget(path, 'merge_requests'); + const body = payload(content); + required(body, 'body', 'GitLab merge request discussion create writeback'); + return { + method: 'POST', + endpoint: `${mrEndpoint(target.projectPath)}/${target.iid}/discussions`, + body, + }; + } + + if (route.resource.name === 'comments' && route.kind === 'create') { + const target = canonicalTarget(path, 'issues'); + const body = payload(content); + required(body, 'body', 'GitLab issue note create writeback'); + return { + method: 'POST', + endpoint: `${issueEndpoint(target.projectPath)}/${target.iid}/notes`, + body, + }; + } + throw unsupported(path); } -export function resolveDeleteRequest(path) { - const nested = path.match(/^\/gitlab\/projects\/(.+?)\/merge_requests\/([^/]+)\/discussions\/([^/]+)\/notes\/([^/]+)\.json$/); - if (nested) return { action: 'delete_merge_request_discussion', method: 'DELETE', endpoint: `${mrEndpoint(decodeProjectPath(nested[1]))}/${iid(nested[2])}/discussions/${encodeURIComponent(nested[3])}/notes/${encodeURIComponent(nested[4])}` }; - const parsed = parseGitLabPath(path); const route = classifyWrite(path, resources, { fsEvent: 'delete' }); - if (parsed && route?.resource.name === 'comments' && route.kind === 'delete' && parsed.objectType === 'issues' && parsed.subResourceId) return { action: 'delete_issue_note', method: 'DELETE', endpoint: `${issueEndpoint(parsed.projectPath)}/${parsed.objectId}/notes/${encodeURIComponent(parsed.subResourceId)}` }; +export function resolveDeleteRequest(path: string): GitLabWritebackRequest { + const nested = path.match( + /^\/gitlab\/projects\/(.+?)\/merge_requests\/([^/]+)\/discussions\/([^/]+)\/notes\/([^/]+)\.json$/, + ); + if (nested) { + return { + action: 'delete_merge_request_discussion', + method: 'DELETE', + endpoint: `${mrEndpoint(decodeProjectPath(nested[1]))}/${iid(nested[2])}/discussions/${encodeURIComponent(nested[3])}/notes/${encodeURIComponent(nested[4])}`, + }; + } + + const parsed = parseGitLabPath(path); + const route = classifyWrite(path, resources, { fsEvent: 'delete' }); + if ( + parsed && + route?.resource.name === 'comments' && + route.kind === 'delete' && + parsed.objectType === 'issues' && + parsed.subResourceId + ) { + return { + action: 'delete_issue_note', + method: 'DELETE', + endpoint: `${issueEndpoint(parsed.projectPath)}/${parsed.objectId}/notes/${encodeURIComponent(parsed.subResourceId)}`, + }; + } + throw new Error(`Unsupported GitLab delete writeback path: ${path}`); } -function issuePayload(content, creating) { - const source = typed(content, 'GitLab issue ' + (creating ? 'create' : 'update') + ' payload', ISSUE_FIELDS); const body = {}; - addString(body, source, 'title', creating); addString(body, source, 'description'); labels(body, source); addIntArray(body, source, 'assignee_ids'); addInt(body, source, 'milestone_id'); addBool(body, source, 'confidential'); if (!creating) addState(body, source); - mutable(body, 'GitLab issue ' + (creating ? 'create' : 'update') + ' payload'); return body; -} - -function mrPayload(content, creating) { - const context = 'GitLab merge request ' + (creating ? 'create' : 'update') + ' payload'; const source = typed(content, context, MR_FIELDS); const body = {}; - for (const field of ['source_branch', 'target_branch', 'title']) addString(body, source, field, creating); - addString(body, source, 'description'); labels(body, source); addBool(body, source, 'remove_source_branch'); addBool(body, source, 'draft'); if (!creating) addState(body, source); mutable(body, context); return body; -} - -function refPayload(content) { const context = 'GitLab branch create payload'; const source = typed(content, context, ['branch', 'ref']); return { branch: required(source, 'branch', context), ref: required(source, 'ref', context) }; } -function mergePayload(content) { const context = 'GitLab merge request merge payload'; const source = typed(content, context, ['merge_commit_message', 'squash', 'should_remove_source_branch']); const body = {}; addString(body, source, 'merge_commit_message'); addBool(body, source, 'squash'); addBool(body, source, 'should_remove_source_branch'); return body; } -function statePayload(content) { const context = 'GitLab merge request close payload'; const source = typed(content, context, ['state_event']); const state_event = required(source, 'state_event', context); if (!['close', 'reopen'].includes(state_event)) throw new Error(`${context}.state_event must be one of close, reopen`); return { state_event }; } - -function payload(content) { let value; try { value = JSON.parse(content); } catch (error) { throw new Error(`Invalid GitLab writeback JSON: ${error instanceof Error ? error.message : String(error)}`); } if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('GitLab writeback payload must be a JSON object'); for (const key of Object.keys(value)) if (READ_ONLY_FIELDS.has(key)) throw new ReadOnlyFieldError(key); return value; } -function typed(content, context, fields) { const value = payload(content); for (const key of Object.keys(value)) if (!fields.includes(key)) throw new Error(`${context}.${key} is not supported`); return value; } -function required(value, key, context) { const field = string(value, key, context); if (!field) throw new Error(`${context}.${key} must be a non-empty string`); return field; } -function string(value, key, context) { const field = value[key]; if (field === undefined) return undefined; if (typeof field !== 'string' || !field.trim()) throw new Error(`${context}.${key} must be a non-empty string`); return field.trim(); } -function addString(body, value, key, requiredValue = false) { const field = string(value, key, `GitLab ${key === 'title' ? 'issue create payload' : 'writeback payload'}`); if (requiredValue && !field) throw new Error(`GitLab ${key === 'title' ? 'issue create payload' : 'merge request create payload'}.${key} must be a non-empty string`); if (field !== undefined) body[key] = field; } -function labels(body, value) { if (value.labels === undefined) return; if (typeof value.labels === 'string' && value.labels.trim()) { body.labels = value.labels.trim(); return; } if (!Array.isArray(value.labels) || value.labels.some((item) => typeof item !== 'string' || !item.trim())) throw new Error('GitLab writeback payload.labels must be a non-empty string or an array of non-empty strings'); body.labels = value.labels.map((item) => item.trim()).join(','); } -function addBool(body, value, key) { if (value[key] === undefined) return; if (typeof value[key] !== 'boolean') throw new Error(`GitLab writeback payload.${key} must be a boolean`); body[key] = value[key]; } -function addInt(body, value, key) { if (value[key] === undefined) return; if (!Number.isInteger(value[key]) || value[key] < 1) throw new Error(`GitLab writeback payload.${key} must be a positive integer`); body[key] = value[key]; } -function addIntArray(body, value, key) { if (value[key] === undefined) return; if (!Array.isArray(value[key]) || value[key].some((item) => !Number.isInteger(item) || item < 1)) throw new Error(`GitLab writeback payload.${key} must be an array of positive integers`); body[key] = value[key]; } -function addState(body, value) { if (value.state_event === undefined) return; if (!['close', 'reopen'].includes(value.state_event)) throw new Error('GitLab writeback payload.state_event must be one of close, reopen'); body.state_event = value.state_event; } -function mutable(body, context) { if (!Object.keys(body).length) throw new Error(`${context} requires at least one mutable field`); } -function collectionProject(path, collection) { const match = path.match(new RegExp(`^/gitlab/projects/(.+?)/${collection}/[^/]+\\.json$`)); if (!match?.[1]) throw unsupported(path); return decodeProjectPath(match[1]); } -function canonicalTarget(path, expected) { const parsed = parseGitLabPath(path); if (!parsed || parsed.objectType !== expected || !/^[1-9]\d*$/.test(parsed.objectId)) throw unsupported(path); return { projectPath: parsed.projectPath, iid: parsed.objectId }; } -function iid(segment) { return decodeURIComponent(segment).split('__')[0]; } -function projectId(projectPath) { return encodeURIComponent(projectPath); } -function issueEndpoint(projectPath) { return `/api/v4/projects/${projectId(projectPath)}/issues`; } -function mrEndpoint(projectPath) { return `/api/v4/projects/${projectId(projectPath)}/merge_requests`; } -function unsupported(path) { return new Error(`Unsupported GitLab writeback path: ${path}. Expected an issue, merge request create/update, branch ref, merge request merge/close, discussion, or issue note.`); } +function issuePayload(content: string, creating: boolean): IssueWritebackPayload { + const context = `GitLab issue ${creating ? 'create' : 'update'} payload`; + const source = typed(content, context, ISSUE_FIELDS); + const body: IssueWritebackPayload = {}; + + addString(body, source, 'title', creating); + addString(body, source, 'description'); + labels(body, source); + addIntArray(body, source, 'assignee_ids'); + addInt(body, source, 'milestone_id'); + addBool(body, source, 'confidential'); + if (!creating) { + addState(body, source); + } + mutable(body, context); + return body; +} + +function mrPayload(content: string, creating: boolean): MergeRequestWritebackPayload { + const context = `GitLab merge request ${creating ? 'create' : 'update'} payload`; + const source = typed(content, context, MR_FIELDS); + const body: MergeRequestWritebackPayload = {}; + + for (const field of ['source_branch', 'target_branch', 'title'] as const) { + addString(body, source, field, creating); + } + addString(body, source, 'description'); + labels(body, source); + addBool(body, source, 'remove_source_branch'); + addBool(body, source, 'draft'); + if (!creating) { + addState(body, source); + } + mutable(body, context); + return body; +} + +function refPayload(content: string): RefWritebackPayload { + const context = 'GitLab branch create payload'; + const source = typed(content, context, ['branch', 'ref']); + return { + branch: required(source, 'branch', context), + ref: required(source, 'ref', context), + }; +} + +function mergePayload(content: string): MergeWritebackPayload { + const context = 'GitLab merge request merge payload'; + const source = typed(content, context, [ + 'merge_commit_message', + 'squash', + 'should_remove_source_branch', + ]); + const body: MergeWritebackPayload = {}; + + addString(body, source, 'merge_commit_message'); + addBool(body, source, 'squash'); + addBool(body, source, 'should_remove_source_branch'); + return body; +} + +function statePayload(content: string): StateWritebackPayload { + const context = 'GitLab merge request close payload'; + const source = typed(content, context, ['state_event']); + const stateEvent = required(source, 'state_event', context); + if (stateEvent !== 'close' && stateEvent !== 'reopen') { + throw new Error(`${context}.state_event must be one of close, reopen`); + } + return { state_event: stateEvent }; +} + +function payload(content: string): WritebackPayload { + let value: unknown; + try { + value = JSON.parse(content) as unknown; + } catch (error) { + throw new Error( + `Invalid GitLab writeback JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('GitLab writeback payload must be a JSON object'); + } + + const record = value as WritebackPayload; + for (const key of Object.keys(record)) { + if (READ_ONLY_FIELDS.has(key)) { + throw new ReadOnlyFieldError(key); + } + } + return record; +} + +function typed( + content: string, + context: string, + fields: readonly string[], +): WritebackPayload { + const value = payload(content); + for (const key of Object.keys(value)) { + if (!fields.includes(key)) { + throw new Error(`${context}.${key} is not supported`); + } + } + return value; +} + +function required(value: WritebackPayload, key: string, context: string): string { + const field = string(value, key, context); + if (!field) { + throw new Error(`${context}.${key} must be a non-empty string`); + } + return field; +} + +function string(value: WritebackPayload, key: string, context: string): string | undefined { + const field = value[key]; + if (field === undefined) { + return undefined; + } + if (typeof field !== 'string' || !field.trim()) { + throw new Error(`${context}.${key} must be a non-empty string`); + } + return field.trim(); +} + +function addString( + body: WritebackPayload, + value: WritebackPayload, + key: string, + requiredValue = false, +): void { + const field = string( + value, + key, + `GitLab ${key === 'title' ? 'issue create payload' : 'writeback payload'}`, + ); + if (requiredValue && !field) { + throw new Error( + `GitLab ${key === 'title' ? 'issue create payload' : 'merge request create payload'}.${key} must be a non-empty string`, + ); + } + if (field !== undefined) { + body[key] = field; + } +} + +function labels(body: WritebackPayload, value: WritebackPayload): void { + if (value.labels === undefined) { + return; + } + if (typeof value.labels === 'string' && value.labels.trim()) { + body.labels = value.labels.trim(); + return; + } + if ( + !Array.isArray(value.labels) || + value.labels.some((item) => typeof item !== 'string' || !item.trim()) + ) { + throw new Error( + 'GitLab writeback payload.labels must be a non-empty string or an array of non-empty strings', + ); + } + body.labels = value.labels.map((item) => item.trim()).join(','); +} + +function addBool(body: WritebackPayload, value: WritebackPayload, key: string): void { + if (value[key] === undefined) { + return; + } + if (typeof value[key] !== 'boolean') { + throw new Error(`GitLab writeback payload.${key} must be a boolean`); + } + body[key] = value[key]; +} + +function addInt(body: WritebackPayload, value: WritebackPayload, key: string): void { + const field = value[key]; + if (field === undefined) { + return; + } + if (typeof field !== 'number' || !Number.isInteger(field) || field < 1) { + throw new Error(`GitLab writeback payload.${key} must be a positive integer`); + } + body[key] = field; +} + +function addIntArray(body: WritebackPayload, value: WritebackPayload, key: string): void { + const field = value[key]; + if (field === undefined) { + return; + } + if ( + !Array.isArray(field) || + field.some( + (item: unknown) => + typeof item !== 'number' || !Number.isInteger(item) || item < 1, + ) + ) { + throw new Error(`GitLab writeback payload.${key} must be an array of positive integers`); + } + body[key] = field; +} + +function addState(body: WritebackPayload, value: WritebackPayload): void { + const field = value.state_event; + if (field === undefined) { + return; + } + if (field !== 'close' && field !== 'reopen') { + throw new Error('GitLab writeback payload.state_event must be one of close, reopen'); + } + body.state_event = field; +} + +function mutable(body: WritebackPayload, context: string): void { + if (!Object.keys(body).length) { + throw new Error(`${context} requires at least one mutable field`); + } +} + +function collectionProject(path: string, collection: string): string { + const match = path.match(new RegExp(`^/gitlab/projects/(.+?)/${collection}/[^/]+\\.json$`)); + if (!match?.[1]) { + throw unsupported(path); + } + return decodeProjectPath(match[1]); +} + +function canonicalTarget(path: string, expected: 'issues' | 'merge_requests'): CanonicalTarget { + const parsed = parseGitLabPath(path); + if ( + !parsed || + parsed.objectType !== expected || + !/^[1-9]\d*$/.test(parsed.objectId) + ) { + throw unsupported(path); + } + return { projectPath: parsed.projectPath, iid: parsed.objectId }; +} + +function iid(segment: string): string { + return decodeURIComponent(segment).split('__')[0]; +} + +function projectId(projectPath: string): string { + return encodeURIComponent(projectPath); +} + +function issueEndpoint(projectPath: string): string { + return `/api/v4/projects/${projectId(projectPath)}/issues`; +} + +function mrEndpoint(projectPath: string): string { + return `/api/v4/projects/${projectId(projectPath)}/merge_requests`; +} + +function unsupported(path: string): Error { + return new Error( + `Unsupported GitLab writeback path: ${path}. Expected an issue, merge request create/update, branch ref, merge request merge/close, discussion, or issue note.`, + ); +} From 70de4fa998148f6c78bbee65cab72e75de0e8232 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Fri, 18 Sep 2026 21:01:22 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(gitlab):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20iid=20receipts,=20state=5Fevent=20on=20create,=20path=20help?= =?UTF-8?q?ers,=20no=20pre-bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Session-Id: 01a0b7cd-aef0-75e1-9e1b-17ef1c310831 --- package-lock.json | 2 +- packages/gitlab/discovery/gitlab/.adapter.md | 5 +- .../{projectPath}/issues/.schema.json | 7 -- .../{issueIid}__{slug}/meta.json/.schema.json | 2 +- packages/gitlab/package.json | 2 +- packages/gitlab/src/index.ts | 5 ++ packages/gitlab/src/path-mapper.ts | 66 +++++++++++++++++-- packages/gitlab/src/writeback.ts | 37 +++++++++-- packages/gitlab/test/exports.test.ts | 25 +++++++ packages/gitlab/test/path-mapper.test.ts | 55 ++++++++++++++++ packages/gitlab/test/writeback.test.ts | 55 +++++++++++++++- scripts/writeback-discovery-data.mjs | 14 ++-- 12 files changed, 246 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index 65e679be..b4b6623e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5370,7 +5370,7 @@ }, "packages/gitlab": { "name": "@relayfile/adapter-gitlab", - "version": "0.5.0", + "version": "0.4.2", "license": "Apache-2.0", "dependencies": { "@relayfile/adapter-core": "^0.5.14" diff --git a/packages/gitlab/discovery/gitlab/.adapter.md b/packages/gitlab/discovery/gitlab/.adapter.md index 51fdd578..7723b730 100644 --- a/packages/gitlab/discovery/gitlab/.adapter.md +++ b/packages/gitlab/discovery/gitlab/.adapter.md @@ -54,7 +54,7 @@ Schema: `/gitlab/projects/{projectPath}/issues/.schema.json` Operations: `create`. Create example: `/gitlab/projects/{projectPath}/issues/.create.example.json` Required fields: `title`. -Optional fields: `description`, `labels`, `assignee_ids`, `milestone_id`, `confidential`, `state_event`. +Optional fields: `description`, `labels`, `assignee_ids`, `milestone_id`, `confidential`. Fields: @@ -64,7 +64,6 @@ Fields: - `assignee_ids` (optional, array) - GitLab user IDs to assign. - `milestone_id` (optional, integer) - GitLab milestone ID. - `confidential` (optional, boolean) - Whether the issue is confidential. -- `state_event` (optional, enum) - Lifecycle event when updating an existing issue. Allowed values: `close`, `reopen`. ### Update GitLab issue @@ -83,7 +82,7 @@ Fields: - `assignee_ids` (optional, array) - GitLab user IDs to assign. - `milestone_id` (optional, integer) - GitLab milestone ID. - `confidential` (optional, boolean) - Whether the issue is confidential. -- `state_event` (optional, enum) - Lifecycle event when updating an existing issue. Allowed values: `close`, `reopen`. +- `state_event` (optional, enum) - Lifecycle event for the issue. Allowed values: `close`, `reopen`. ### Create GitLab merge request diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/.schema.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/.schema.json index a7e525b5..352faa71 100644 --- a/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/.schema.json +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/.schema.json @@ -102,13 +102,6 @@ "confidential": { "type": "boolean", "description": "Whether the issue is confidential." - }, - "state_event": { - "enum": [ - "close", - "reopen" - ], - "description": "Lifecycle event when updating an existing issue." } }, "additionalProperties": false, diff --git a/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.schema.json b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.schema.json index 6a8d78b2..69c5d827 100644 --- a/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.schema.json +++ b/packages/gitlab/discovery/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json/.schema.json @@ -106,7 +106,7 @@ "close", "reopen" ], - "description": "Lifecycle event when updating an existing issue." + "description": "Lifecycle event for the issue." } }, "additionalProperties": false, diff --git a/packages/gitlab/package.json b/packages/gitlab/package.json index c86eea19..38f1fe1d 100644 --- a/packages/gitlab/package.json +++ b/packages/gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/adapter-gitlab", - "version": "0.5.0", + "version": "0.4.2", "description": "GitLab adapter for relayfile — maps GitLab merge requests, issues, pipelines, jobs, commits, and webhooks to relayfile VFS paths", "type": "module", "main": "dist/index.js", diff --git a/packages/gitlab/src/index.ts b/packages/gitlab/src/index.ts index 8ef0fabf..68da2238 100644 --- a/packages/gitlab/src/index.ts +++ b/packages/gitlab/src/index.ts @@ -17,12 +17,17 @@ export * from './summary.js'; export { computeCommitCommentPath, computeGitLabPath, + computeIssueCreateDraftPath, computeIssueCommentPath, + computeMergeRequestClosePath, computeMergeRequestApprovalsPath, + computeMergeRequestCreateDraftPath, computeMergeRequestDiffPath, computeMergeRequestDiscussionPath, + computeMergeRequestMergePath, computeMetadataPath, computePipelineJobPath, + computeRefCreateDraftPath, computeSnippetCommentPath, decodeProjectPath, encodeProjectPath, diff --git a/packages/gitlab/src/path-mapper.ts b/packages/gitlab/src/path-mapper.ts index eae851e4..607d872d 100644 --- a/packages/gitlab/src/path-mapper.ts +++ b/packages/gitlab/src/path-mapper.ts @@ -9,6 +9,7 @@ export type GitLabResourceType = | 'issues' | 'merge_requests' | 'pipelines' + | 'refs' | 'snippets' | 'tags'; @@ -24,13 +25,15 @@ export interface GitLabPathContext { title?: string | null; } -const RESOURCE_SEGMENTS = new Set([ +const RESOURCE_SEGMENTS = new Set([ 'commits', 'deployments', 'files', 'issues', 'merge_requests', + 'merge-requests', 'pipelines', + 'refs', 'snippets', 'tags', ]); @@ -100,6 +103,18 @@ export function gitLabProjectMetadataPath(projectPath: string): string { return `${gitLabProjectPrefix(projectPath)}/meta.json`; } +export function computeIssueCreateDraftPath(projectPath: string, draftName: string): string { + return `${gitLabProjectPrefix(projectPath)}/issues/${draftFilename(draftName)}`; +} + +export function computeMergeRequestCreateDraftPath(projectPath: string, draftName: string): string { + return `${gitLabProjectPrefix(projectPath)}/merge-requests/${draftFilename(draftName)}`; +} + +export function computeRefCreateDraftPath(projectPath: string, branchName: string): string { + return `${gitLabProjectPrefix(projectPath)}/refs/${draftFilename(branchName)}`; +} + export function gitLabProjectResourceIndexPath( projectPath: string, objectType: GitLabIndexedResourceType, @@ -306,6 +321,22 @@ export function computeMergeRequestApprovalsPath( return `${gitLabProjectPrefix(projectPath)}/merge_requests/${gitLabRecordDirectorySegment(iid, title)}/approvals.json`; } +export function computeMergeRequestMergePath( + projectPath: string, + iid: number | string, + title?: string | null, +): string { + return `${gitLabProjectPrefix(projectPath)}/merge_requests/${gitLabRecordDirectorySegment(iid, title)}/merge.json`; +} + +export function computeMergeRequestClosePath( + projectPath: string, + iid: number | string, + title?: string | null, +): string { + return `${gitLabProjectPrefix(projectPath)}/merge_requests/${gitLabRecordDirectorySegment(iid, title)}/close.json`; +} + export function computePipelineJobPath( projectPath: string, pipelineId: number | string, @@ -364,10 +395,13 @@ export function parseGitLabPath(path: string): ParsedGitLabPath | null { } const projectPath = decodeProjectPath(segments.slice(2, objectIndex).join('/')); - const objectType = segments[objectIndex] as GitLabResourceType; + const resourceSegment = segments[objectIndex]; + const objectType = resourceSegment === 'merge-requests' + ? 'merge_requests' + : resourceSegment as GitLabResourceType; const rawObjectSegment = segments[objectIndex + 1] ?? ''; const remainder = segments.slice(objectIndex + 2); - const objectId = decodeObjectId(objectType, rawObjectSegment); + const objectId = decodeGitLabPathObjectId(objectType, rawObjectSegment, remainder); const subResource = remainder.length > 0 ? remainder[0] : undefined; const subResourceId = remainder.length > 1 ? decodeURIComponent(remainder[1].replace(/\.json$/, '')) : undefined; @@ -385,13 +419,18 @@ export function parseGitLabPath(path: string): ParsedGitLabPath | null { function gitLabResourceSegmentIndex(segments: readonly string[]): number { for (let index = segments.length - 2; index > 1; index -= 1) { const segment = segments[index]; - if (segment && RESOURCE_SEGMENTS.has(segment as GitLabResourceType)) { + if (segment && RESOURCE_SEGMENTS.has(segment)) { return index; } } return -1; } +function draftFilename(value: string): string { + const draftName = assertNonEmptySegment(value, 'draft name').replace(/\.json$/u, ''); + return `${encodeGitLabPathSegment(draftName)}.json`; +} + export function computeGitLabPath( objectType: string, objectId: string, @@ -574,6 +613,9 @@ function refPathContext(context: GitLabPathContext): string | null | undefined { } function decodeObjectId(objectType: GitLabResourceType, segment: string): string { + if (objectType === 'refs') { + return decodeURIComponent(segment.replace(/\.json$/u, '')); + } if (DIRECTORY_RESOURCES.has(objectType)) { return decodeDirectoryObjectId(segment); } @@ -583,6 +625,22 @@ function decodeObjectId(objectType: GitLabResourceType, segment: string): string return decodeURIComponent(segment); } +function decodeGitLabPathObjectId( + objectType: GitLabResourceType, + segment: string, + remainder: readonly string[], +): string { + const objectId = decodeObjectId(objectType, segment); + if ( + remainder.length === 0 && + (objectType === 'issues' || objectType === 'merge_requests') && + objectId.endsWith('.json') + ) { + return objectId.slice(0, -'.json'.length); + } + return objectId; +} + function decodeDirectoryObjectId(segment: string): string { const decoded = decodeURIComponent(segment); const separatorIndex = decoded.indexOf('__'); diff --git a/packages/gitlab/src/writeback.ts b/packages/gitlab/src/writeback.ts index d6046aae..00c96741 100644 --- a/packages/gitlab/src/writeback.ts +++ b/packages/gitlab/src/writeback.ts @@ -36,6 +36,7 @@ interface CanonicalTarget { interface GitLabWritebackResponse { id?: number | string; iid?: number | string; + name?: string; } interface IssueWritebackPayload extends WritebackPayload { @@ -88,16 +89,16 @@ const READ_ONLY_FIELDS = new Set([ '_webhook', '_connection', ]); -const ISSUE_FIELDS = [ +const ISSUE_CREATE_FIELDS = [ 'title', 'description', 'labels', 'assignee_ids', 'milestone_id', 'confidential', - 'state_event', ] as const; -const MR_FIELDS = [ +const ISSUE_UPDATE_FIELDS = [...ISSUE_CREATE_FIELDS, 'state_event'] as const; +const MR_CREATE_FIELDS = [ 'source_branch', 'target_branch', 'title', @@ -105,8 +106,8 @@ const MR_FIELDS = [ 'labels', 'remove_source_branch', 'draft', - 'state_event', ] as const; +const MR_UPDATE_FIELDS = [...MR_CREATE_FIELDS, 'state_event'] as const; export class GitLabWritebackHandler { constructor( @@ -189,7 +190,7 @@ export class GitLabWritebackHandler { const result = response.data; return { success: true, - externalId: result?.id ? String(result.id) : result?.iid ? String(result.iid) : undefined, + externalId: externalIdForWriteback(path, result), }; } catch (error) { return { @@ -329,7 +330,7 @@ export function resolveDeleteRequest(path: string): GitLabWritebackRequest { function issuePayload(content: string, creating: boolean): IssueWritebackPayload { const context = `GitLab issue ${creating ? 'create' : 'update'} payload`; - const source = typed(content, context, ISSUE_FIELDS); + const source = typed(content, context, creating ? ISSUE_CREATE_FIELDS : ISSUE_UPDATE_FIELDS); const body: IssueWritebackPayload = {}; addString(body, source, 'title', creating); @@ -347,7 +348,7 @@ function issuePayload(content: string, creating: boolean): IssueWritebackPayload function mrPayload(content: string, creating: boolean): MergeRequestWritebackPayload { const context = `GitLab merge request ${creating ? 'create' : 'update'} payload`; - const source = typed(content, context, MR_FIELDS); + const source = typed(content, context, creating ? MR_CREATE_FIELDS : MR_UPDATE_FIELDS); const body: MergeRequestWritebackPayload = {}; for (const field of ['source_branch', 'target_branch', 'title'] as const) { @@ -590,3 +591,25 @@ function unsupported(path: string): Error { `Unsupported GitLab writeback path: ${path}. Expected an issue, merge request create/update, branch ref, merge request merge/close, discussion, or issue note.`, ); } + +function externalIdForWriteback( + path: string, + result: GitLabWritebackResponse | null, +): string | undefined { + if (!result) { + return undefined; + } + + const route = classifyWrite(path, resources); + switch (route?.resource.name) { + case 'issues': + case 'merge-requests': + case 'merge': + case 'close-merge-request': + return result.iid !== undefined ? String(result.iid) : result.id !== undefined ? String(result.id) : undefined; + case 'refs': + return result.name; + default: + return result.id !== undefined ? String(result.id) : undefined; + } +} diff --git a/packages/gitlab/test/exports.test.ts b/packages/gitlab/test/exports.test.ts index 203b9827..f81343ab 100644 --- a/packages/gitlab/test/exports.test.ts +++ b/packages/gitlab/test/exports.test.ts @@ -2,6 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + computeIssueCreateDraftPath, + computeMergeRequestClosePath, + computeMergeRequestCreateDraftPath, + computeMergeRequestMergePath, + computeRefCreateDraftPath, gitLabByAssigneeAliasPath, gitLabByCreatorAliasPath, gitLabByPriorityAliasPath, @@ -27,4 +32,24 @@ test('package root exports GitLab path helper surface', () => { gitLabByPriorityAliasPath('acme/api', 'issues', 'priority::high', 7), '/gitlab/projects/acme/api/issues/by-priority/priority-high/7.json', ); + assert.equal( + computeIssueCreateDraftPath('acme/api', 'create-issue'), + '/gitlab/projects/acme/api/issues/create-issue.json', + ); + assert.equal( + computeMergeRequestCreateDraftPath('acme/api', 'create-merge-request'), + '/gitlab/projects/acme/api/merge-requests/create-merge-request.json', + ); + assert.equal( + computeRefCreateDraftPath('acme/api', 'factory/branch'), + '/gitlab/projects/acme/api/refs/factory%2Fbranch.json', + ); + assert.equal( + computeMergeRequestMergePath('acme/api', 42, 'Add OAuth'), + '/gitlab/projects/acme/api/merge_requests/42__add-oauth/merge.json', + ); + assert.equal( + computeMergeRequestClosePath('acme/api', 42, 'Add OAuth'), + '/gitlab/projects/acme/api/merge_requests/42__add-oauth/close.json', + ); }); diff --git a/packages/gitlab/test/path-mapper.test.ts b/packages/gitlab/test/path-mapper.test.ts index 24bc3661..1192760d 100644 --- a/packages/gitlab/test/path-mapper.test.ts +++ b/packages/gitlab/test/path-mapper.test.ts @@ -4,12 +4,17 @@ import assert from 'node:assert/strict'; import { computeCommitCommentPath, computeGitLabPath, + computeIssueCreateDraftPath, computeIssueCommentPath, + computeMergeRequestClosePath, computeMergeRequestApprovalsPath, + computeMergeRequestCreateDraftPath, computeMergeRequestDiffPath, computeMergeRequestDiscussionPath, + computeMergeRequestMergePath, computeMetadataPath, computePipelineJobPath, + computeRefCreateDraftPath, computeSnippetCommentPath, gitLabByAssigneeAliasPath, gitLabByCreatorAliasPath, @@ -121,6 +126,56 @@ describe('path mapper', () => { ); }); + it('round-trips GitLab writeback paths through the shared parser', () => { + const projectPath = 'group/subgroup/project'; + const issueDraftPath = computeIssueCreateDraftPath(projectPath, 'factory-issue'); + const mergeRequestDraftPath = computeMergeRequestCreateDraftPath(projectPath, 'factory-merge-request'); + const refDraftPath = computeRefCreateDraftPath(projectPath, 'factory/branch'); + const mergePath = computeMergeRequestMergePath(projectPath, 42, 'Add OAuth'); + const closePath = computeMergeRequestClosePath(projectPath, 42, 'Add OAuth'); + + assert.deepStrictEqual(parseGitLabPath(issueDraftPath), { + path: issueDraftPath, + projectPath, + objectType: 'issues', + objectId: 'factory-issue', + subResource: undefined, + subResourceId: undefined, + }); + assert.deepStrictEqual(parseGitLabPath(mergeRequestDraftPath), { + path: mergeRequestDraftPath, + projectPath, + objectType: 'merge_requests', + objectId: 'factory-merge-request', + subResource: undefined, + subResourceId: undefined, + }); + assert.deepStrictEqual(parseGitLabPath(refDraftPath), { + path: refDraftPath, + projectPath, + objectType: 'refs', + objectId: 'factory/branch', + subResource: undefined, + subResourceId: undefined, + }); + assert.deepStrictEqual(parseGitLabPath(mergePath), { + path: mergePath, + projectPath, + objectType: 'merge_requests', + objectId: '42', + subResource: 'merge.json', + subResourceId: undefined, + }); + assert.deepStrictEqual(parseGitLabPath(closePath), { + path: closePath, + projectPath, + objectType: 'merge_requests', + objectId: '42', + subResource: 'close.json', + subResourceId: undefined, + }); + }); + it('round-trips complex GitLab tag refs with slashes and double underscores', () => { const path = computeMetadataPath('acme/api', 'tags', 'release/foo__bar', 'release/foo__bar'); assert.strictEqual(path, '/gitlab/projects/acme/api/tags/release-foo-bar__release%2Ffoo__bar.json'); diff --git a/packages/gitlab/test/writeback.test.ts b/packages/gitlab/test/writeback.test.ts index e41db0d2..ff38c0ee 100644 --- a/packages/gitlab/test/writeback.test.ts +++ b/packages/gitlab/test/writeback.test.ts @@ -30,6 +30,14 @@ describe('GitLabWritebackHandler', () => { it('rejects missing GitLab create fields, invalid lifecycle states, and unsupported paths', () => { assert.throws(() => resolveGitLabWritebackRequest('/gitlab/projects/acme/api/issues/draft.json', JSON.stringify({})), /GitLab issue create payload.title must be a non-empty string/); + assert.throws( + () => resolveGitLabWritebackRequest('/gitlab/projects/acme/api/issues/draft.json', JSON.stringify({ title: 'Parity', state_event: 'close' })), + /GitLab issue create payload.state_event is not supported/, + ); + assert.throws( + () => resolveGitLabWritebackRequest('/gitlab/projects/acme/api/merge-requests/draft.json', JSON.stringify({ source_branch: 'factory/parity', target_branch: 'main', title: 'Parity', state_event: 'close' })), + /GitLab merge request create payload.state_event is not supported/, + ); assert.throws(() => resolveGitLabWritebackRequest('/gitlab/projects/acme/api/merge_requests/42__parity/close.json', JSON.stringify({ state_event: 'merged' })), /state_event must be one of close, reopen/); assert.throws(() => resolveGitLabWritebackRequest('/gitlab/projects/acme/api/pipelines/draft.json', '{}'), /Expected an issue, merge request create\/update, branch ref, merge request merge\/close, discussion, or issue note/); }); @@ -100,7 +108,7 @@ describe('GitLabWritebackHandler', () => { const update = await handler.writeBack( 'workspace-1', '/gitlab/projects/acme/api/issues/7__fix-bug/meta.json', - JSON.stringify({ title: 'Updated issue' }), + JSON.stringify({ state_event: 'close' }), ); const invalid = await handler.writeBack( 'workspace-1', @@ -109,10 +117,55 @@ describe('GitLabWritebackHandler', () => { ); assert.deepStrictEqual(update, { success: true, externalId: '7' }); + assert.deepStrictEqual(provider.requests[0]?.body, { state_event: 'close' }); assert.strictEqual(invalid.success, false); assert.match(invalid.error ?? '', /Expected an issue, merge request create\/update, branch ref, merge request merge\/close, discussion, or issue note/); }); + it('uses project-scoped IIDs and branch names as writeback receipts', async () => { + const provider = new MockProvider(); + provider.register('POST', '/api/v4/projects/acme%2Fapi/issues', ok({ id: 203601504, iid: 5 })); + provider.register('POST', '/api/v4/projects/acme%2Fapi/merge_requests', ok({ id: 203601505, iid: 6 })); + provider.register('POST', '/api/v4/projects/acme%2Fapi/repository/branches', ok({ name: 'factory/parity' })); + const handler = new GitLabWritebackHandler(provider, { connectionId: 'conn', baseUrl: 'https://gitlab.com' }); + + const issue = await handler.writeBack( + 'workspace-1', + '/gitlab/projects/acme/api/issues/draft.json', + JSON.stringify({ title: 'Parity' }), + ); + const mergeRequest = await handler.writeBack( + 'workspace-1', + '/gitlab/projects/acme/api/merge-requests/draft.json', + JSON.stringify({ source_branch: 'factory/parity', target_branch: 'main', title: 'Parity' }), + ); + const branch = await handler.writeBack( + 'workspace-1', + '/gitlab/projects/acme/api/refs/draft.json', + JSON.stringify({ branch: 'factory/parity', ref: 'main' }), + ); + + assert.deepStrictEqual(issue, { success: true, externalId: '5' }); + assert.deepStrictEqual(mergeRequest, { success: true, externalId: '6' }); + assert.deepStrictEqual(branch, { success: true, externalId: 'factory/parity' }); + }); + + it('falls back to global IDs for issue and merge request receipts and keeps note IDs', async () => { + const provider = new MockProvider(); + provider.register('POST', '/api/v4/projects/acme%2Fapi/issues', ok({ id: 203601504 })); + provider.register('POST', '/api/v4/projects/acme%2Fapi/merge_requests', ok({ id: 203601505 })); + provider.register('POST', '/api/v4/projects/acme%2Fapi/issues/7/notes', ok({ id: 11, iid: 999 })); + const handler = new GitLabWritebackHandler(provider, { connectionId: 'conn', baseUrl: 'https://gitlab.com' }); + + const issue = await handler.writeBack('workspace-1', '/gitlab/projects/acme/api/issues/draft.json', JSON.stringify({ title: 'Parity' })); + const mergeRequest = await handler.writeBack('workspace-1', '/gitlab/projects/acme/api/merge-requests/draft.json', JSON.stringify({ source_branch: 'factory/parity', target_branch: 'main', title: 'Parity' })); + const note = await handler.writeBack('workspace-1', '/gitlab/projects/acme/api/issues/7__parity/comments/draft@note.json', JSON.stringify({ body: 'Note' })); + + assert.deepStrictEqual(issue, { success: true, externalId: '203601504' }); + assert.deepStrictEqual(mergeRequest, { success: true, externalId: '203601505' }); + assert.deepStrictEqual(note, { success: true, externalId: '11' }); + }); + it('maps canonical discussion and note paths to DELETE requests', () => { const discussionNotePath = '/gitlab/projects/acme/api/merge_requests/42__add-oauth/discussions/discussion-1/notes/99.json'; diff --git a/scripts/writeback-discovery-data.mjs b/scripts/writeback-discovery-data.mjs index b8d740b4..b2f2374b 100644 --- a/scripts/writeback-discovery-data.mjs +++ b/scripts/writeback-discovery-data.mjs @@ -181,8 +181,8 @@ export const adapters = [ ['/gitlab/projects///tags//meta.json', 'Tag records.'], ], endpoints: [ - endpoint('/gitlab/projects/{projectPath}/issues/new.json', 'Create GitLab issue', 'Creates a GitLab issue in the project named by the path.', ['title'], gitlabIssueProps(), { title: 'Replace example issue title', description: 'Replace example issue description.', labels: ['factory'] }, { operations: ['create'] }), - endpoint('/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json', 'Update GitLab issue', 'Updates mutable fields on a GitLab issue.', [], gitlabIssueProps(), { title: 'Replace example issue title' }, { operations: ['update'] }), + endpoint('/gitlab/projects/{projectPath}/issues/new.json', 'Create GitLab issue', 'Creates a GitLab issue in the project named by the path.', ['title'], gitlabIssueCreateProps(), { title: 'Replace example issue title', description: 'Replace example issue description.', labels: ['factory'] }, { operations: ['create'] }), + endpoint('/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/meta.json', 'Update GitLab issue', 'Updates mutable fields on a GitLab issue.', [], gitlabIssueUpdateProps(), { title: 'Replace example issue title' }, { operations: ['update'] }), endpoint('/gitlab/projects/{projectPath}/merge-requests/new.json', 'Create GitLab merge request', 'Creates a GitLab merge request from a source branch into a target branch.', ['source_branch', 'target_branch', 'title'], gitlabMergeRequestProps(), { source_branch: 'factory/gitlab-parity', target_branch: 'main', title: 'Replace example merge request title', description: 'Replace example merge request description.' }, { operations: ['create'] }), endpoint('/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/meta.json', 'Update GitLab merge request', 'Updates mutable fields on a GitLab merge request.', [], gitlabMergeRequestUpdateProps(), { title: 'Replace example merge request title' }, { operations: ['update'] }), endpoint('/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/merge.json', 'Merge GitLab merge request', 'Accepts a GitLab merge request.', [], gitlabMergeProps(), { merge_commit_message: 'feat: merge factory work', squash: true }, { operations: ['update'] }), @@ -1018,7 +1018,7 @@ function gitlabDiscussionProps() { }; } -function gitlabIssueProps() { +function gitlabIssueCreateProps() { return { title: str('Issue title.'), description: str('Markdown issue description.'), @@ -1026,7 +1026,13 @@ function gitlabIssueProps() { assignee_ids: arr(int('GitLab user id.'), 'GitLab user IDs to assign.'), milestone_id: int('GitLab milestone ID.'), confidential: bool('Whether the issue is confidential.'), - state_event: en(['close', 'reopen'], 'Lifecycle event when updating an existing issue.'), + }; +} + +function gitlabIssueUpdateProps() { + return { + ...gitlabIssueCreateProps(), + state_event: en(['close', 'reopen'], 'Lifecycle event for the issue.'), }; }