From b73719ced33158d3ce979dc9e7adc7f84e138761 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Tue, 26 May 2026 19:05:27 +0200 Subject: [PATCH 1/4] feat(github): support pull request merge writebacks --- packages/github/discovery/github/.adapter.md | 19 ++- packages/github/github.mapping.yaml | 3 + packages/github/src/layout-prompt.ts | 2 +- packages/github/src/types.ts | 9 ++ packages/github/src/writeback.test.ts | 91 +++++++++++ packages/github/src/writeback.ts | 154 ++++++++++++++++++- 6 files changed, 268 insertions(+), 10 deletions(-) diff --git a/packages/github/discovery/github/.adapter.md b/packages/github/discovery/github/.adapter.md index d73b9ae2..f7788701 100644 --- a/packages/github/discovery/github/.adapter.md +++ b/packages/github/discovery/github/.adapter.md @@ -1,6 +1,6 @@ # GitHub adapter -The GitHub adapter exposes repository pull requests, issues, reviews, comments, commits, files, and checks under `/github`, with writeback support for creating and updating issues, creating and updating issue comments, and submitting pull request reviews. +The GitHub adapter exposes repository pull requests, issues, reviews, comments, commits, files, and checks under `/github`, with writeback support for creating and updating issues, creating and updating issue comments, submitting pull request reviews, and merging pull requests. Read-only mounts: - `/github/repos///pulls//meta.json` - Pull request metadata. @@ -15,6 +15,7 @@ Resources: | `/github/repos/{owner}/{repo}/issues/.json` | `/github/repos/{owner}/{repo}/issues/.schema.json` | `/github/repos/{owner}/{repo}/issues/.create.example.json` | `^[1-9]\d*$` | Creates a GitHub issue. | | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.json` | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.schema.json` | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.create.example.json` | `^\d+$` | Creates or updates a GitHub issue comment. | | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.schema.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.create.example.json` | `^\d+$` | Submits a pull request review with optional inline comments. | +| `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json` | inline merge payload | n/a | exact filename | Merges a pull request. | ## Operations @@ -30,6 +31,7 @@ Resources: - `/github/repos/{owner}/{repo}/issues/.json`: `^[1-9]\d*$`. Filenames that do not match this pattern are treated as create drafts. - `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.json`: `^\d+$`. Filenames that do not match this pattern are treated as create drafts. - `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.json`: `^\d+$`. Filenames that do not match this pattern are treated as create drafts. +- `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json`: exact action file. ## Write field contracts @@ -94,5 +96,20 @@ Fields: - `metadata.connectionId` (optional, string) - Relayfile connection id override. - `metadata.providerConfigKey` (optional, string) - GitHub provider config key override. +### Merge GitHub pull request + +Resource: `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json` +Required fields: none. +Optional fields: `method`, `commitTitle`, `commitMessage`, `metadata`. + +Fields: + +- `method` (optional, string) - Merge strategy. Defaults to `squash`. Allowed values: `merge`, `squash`, `rebase`. +- `commitTitle` (optional, string) - Commit title for merge and squash commits. +- `commitMessage` (optional, string) - Commit message for merge and squash commits. +- `metadata` (optional, object) - Optional submission metadata. +- `metadata.connectionId` (optional, string) - Relayfile connection id override. +- `metadata.providerConfigKey` (optional, string) - GitHub provider config key override. + ## 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. diff --git a/packages/github/github.mapping.yaml b/packages/github/github.mapping.yaml index 1b2c18f5..81412aa0 100644 --- a/packages/github/github.mapping.yaml +++ b/packages/github/github.mapping.yaml @@ -30,6 +30,9 @@ writebacks: review: match: /github/repos/*/*/pulls/*/reviews/*.json endpoint: POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews + merge: + match: /github/repos/*/*/pulls/*/merge.json + endpoint: PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge comment: match: /github/repos/*/*/pulls/*/comments/*.json endpoint: POST /repos/{owner}/{repo}/pulls/{pull_number}/comments diff --git a/packages/github/src/layout-prompt.ts b/packages/github/src/layout-prompt.ts index 9bf75b84..56238fd7 100644 --- a/packages/github/src/layout-prompt.ts +++ b/packages/github/src/layout-prompt.ts @@ -10,7 +10,7 @@ Always run \`ls\` before constructing a path. PR 0 standardizes issue and pull r \`pulls/__/diff.patch\`, \`pulls/__/files/**\`, and \`pulls/__/base/**\` are nested artifacts and should not be treated as canonical records. Issue and pull request aliases are materialized under \`/github/repos/__//...\`, distinct from the canonical \`/github/repos///...\` tree. Alias views include \`by-id/.json\`, \`by-title/__.json\`, \`by-state//.json\`, \`by-assignee//.json\`, \`by-creator//.json\`, \`by-priority//.json\`, and \`by-edited/YYYY-MM-DD/.json\`. The edited-date bucket uses the provider update timestamp, or a merge/close timestamp when that is the most recent activity-summary fallback date. -Writable resources advertise sibling schemas and create examples at \`discovery/github/repos/{owner}/{repo}/issues/.schema.json\`, \`discovery/github/repos/{owner}/{repo}/issues/.create.example.json\`, \`discovery/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.schema.json\`, \`discovery/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.create.example.json\`, \`discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.schema.json\`, and \`discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.create.example.json\`. +Writable resources advertise sibling schemas and create examples at \`discovery/github/repos/{owner}/{repo}/issues/.schema.json\`, \`discovery/github/repos/{owner}/{repo}/issues/.create.example.json\`, \`discovery/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.schema.json\`, \`discovery/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.create.example.json\`, \`discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.schema.json\`, and \`discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.create.example.json\`. To merge a pull request, write \`{"method":"squash"}\` or another valid merge payload to \`/github/repos///pulls//merge.json\`. ## Indexes diff --git a/packages/github/src/types.ts b/packages/github/src/types.ts index d2517bd8..b66d062d 100644 --- a/packages/github/src/types.ts +++ b/packages/github/src/types.ts @@ -75,6 +75,15 @@ export interface GitHubIssueCommentWritebackInput { body: string; } +export type GitHubMergeMethod = 'merge' | 'squash' | 'rebase'; + +export interface GitHubMergePullRequestWritebackInput { + method?: GitHubMergeMethod; + commitTitle?: string; + commitMessage?: string; + metadata?: AgentReviewMetadata; +} + export type ProxyRequest = SharedProxyRequest; export type ProxyResponse = SharedProxyResponse; diff --git a/packages/github/src/writeback.test.ts b/packages/github/src/writeback.test.ts index 244b7334..2a4f4426 100644 --- a/packages/github/src/writeback.test.ts +++ b/packages/github/src/writeback.test.ts @@ -151,6 +151,76 @@ describe('writeback', () => { assert.strictEqual(provider.requests[0]?.endpoint, '/repos/acme/widgets/pulls/12/reviews'); }); + it('handleWriteback merges pull requests through the GitHub merge endpoint', async () => { + const { handler, provider } = createHandler(() => ({ + status: 200, + headers: {}, + data: { merged: true, sha: 'abc123' } satisfies JsonObject, + })); + + const result = await handler.handleWriteback( + 'workspace-1', + '/github/repos/acme/widgets/pulls/7/merge.json', + JSON.stringify({ + method: 'rebase', + commitTitle: 'Merge PR #7', + commitMessage: 'Ship it.', + metadata: { + connectionId: 'conn-merge', + providerConfigKey: 'github-custom', + }, + }), + ); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.externalId, 'abc123'); + assert.strictEqual(provider.requests.length, 1); + assert.strictEqual(provider.requests[0]?.method, 'PUT'); + assert.strictEqual(provider.requests[0]?.endpoint, '/repos/acme/widgets/pulls/7/merge'); + assert.strictEqual(provider.requests[0]?.connectionId, 'conn-merge'); + assert.strictEqual(provider.requests[0]?.headers?.['Provider-Config-Key'], 'github-custom'); + assert.deepStrictEqual(provider.requests[0]?.body, { + merge_method: 'rebase', + commit_title: 'Merge PR #7', + commit_message: 'Ship it.', + }); + }); + + it('handleWriteback defaults pull request merge method to squash', async () => { + const { handler, provider } = createHandler(() => ({ + status: 200, + headers: {}, + data: { merged: true, sha: 'def456' } satisfies JsonObject, + })); + + const result = await handler.handleWriteback( + 'workspace-1', + '/github/repos/acme/widgets/pulls/7__finish-feature/merge.json', + JSON.stringify({}), + ); + + assert.strictEqual(result.success, true); + assert.strictEqual(provider.requests[0]?.endpoint, '/repos/acme/widgets/pulls/7/merge'); + assert.deepStrictEqual(provider.requests[0]?.body, { merge_method: 'squash' }); + }); + + it('handleWriteback treats a 2xx merge response without a JSON object body as success', async () => { + const { handler } = createHandler(() => ({ + status: 204, + headers: {}, + data: null, + })); + + const result = await handler.handleWriteback( + 'workspace-1', + '/github/repos/acme/widgets/pulls/7/merge.json', + JSON.stringify({ method: 'squash' }), + ); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.externalId, undefined); + }); + it('handleWriteback returns a typed error for invalid JSON', async () => { const { handler } = createHandler(); const result = await handler.handleWriteback( @@ -296,6 +366,27 @@ describe('writeback', () => { assert.deepStrictEqual(update.body, { body: 'Updated comment body.' }); }); + it('resolves pull request merge writebacks to GitHub merge requests', () => { + const request = resolveWritebackRequest( + '/github/repos/acme/widgets/pulls/42/merge.json', + JSON.stringify({ + method: 'merge', + commitTitle: 'Merge pull request #42', + commitMessage: 'Approved by automation.', + }), + ); + + assert.strictEqual(request.method, 'PUT'); + assert.strictEqual(request.baseUrl, 'https://api.github.com'); + assert.strictEqual(request.endpoint, '/repos/acme/widgets/pulls/42/merge'); + assert.strictEqual(request.connectionId, ''); + assert.deepStrictEqual(request.body, { + merge_method: 'merge', + commit_title: 'Merge pull request #42', + commit_message: 'Approved by automation.', + }); + }); + it('rejects read-only fields in issue writebacks', () => { assert.throws( () => diff --git a/packages/github/src/writeback.ts b/packages/github/src/writeback.ts index 3179cd76..2ec2478d 100644 --- a/packages/github/src/writeback.ts +++ b/packages/github/src/writeback.ts @@ -9,6 +9,8 @@ import { type AgentReviewMetadata, type GitHubIssueCommentWritebackInput, type GitHubIssueWritebackInput, + type GitHubMergeMethod, + type GitHubMergePullRequestWritebackInput, type GitHubRequestProvider, type GitHubCreateReviewInput, type JsonObject, @@ -27,6 +29,8 @@ const DEFAULT_PROVIDER_CONFIG_KEY = 'github-app-oauth'; // and tolerate an optional `__` suffix on the same segment. const REVIEW_WRITEBACK_PATH = /^\/github\/repos\/([^/]+)\/([^/]+)\/pulls\/([1-9]\d*)(?:__[^/]+)?\/reviews\/([^/]+?)(?:\.json)?$/; +const MERGE_WRITEBACK_PATH = + /^\/github\/repos\/([^/]+)\/([^/]+)\/pulls\/([1-9]\d*)(?:__[^/]+)?\/merge\.json$/; const ISSUE_WRITEBACK_PATH = /^\/github\/repos\/([^/]+)\/([^/]+)\/issues\/([^/]+?)(?:\.json)?$/; const ISSUE_COMMENT_WRITEBACK_PATH = @@ -170,6 +174,23 @@ export class GitHubWritebackHandler { content: string, ): Promise { try { + const mergeTarget = extractMergeTarget(path); + if (mergeTarget) { + const payload = parseMergePayload(content); + const response = await this.mergePullRequest(mergeTarget, payload, workspaceId); + if (response.status >= 400) { + return { + success: false, + error: formatProviderError(response, 'GitHub pull request merge failed'), + }; + } + const externalId = extractMergeSha(response.data); + return { + success: true, + ...(externalId ? { externalId } : {}), + }; + } + const target = this.extractWritebackTarget(path); const reviewId = target.reviewId; if (reviewId) { @@ -236,7 +257,14 @@ export class GitHubWritebackHandler { workspaceId: string, review: AgentReview, ): Promise { - const metadataConnectionId = review.metadata?.connectionId?.trim(); + return this.resolveConnectionIdFromMetadata(workspaceId, review.metadata); + } + + private async resolveConnectionIdFromMetadata( + workspaceId: string, + metadata?: AgentReviewMetadata, + ): Promise { + const metadataConnectionId = metadata?.connectionId?.trim(); if (metadataConnectionId) { return metadataConnectionId; } @@ -278,6 +306,23 @@ export class GitHubWritebackHandler { body: { body: payload.body }, }); } + + private async mergePullRequest( + target: { owner: string; repo: string; prNumber: number }, + payload: GitHubMergePullRequestWritebackInput, + workspaceId: string, + ): Promise { + const request = buildMergePullRequest(target, payload); + const connectionId = await this.resolveConnectionIdFromMetadata(workspaceId, payload.metadata); + return this.provider.proxy({ + ...request, + connectionId, + headers: { + ...request.headers, + 'Provider-Config-Key': payload.metadata?.providerConfigKey ?? this.defaultProviderConfigKey, + }, + }); + } } /** @@ -311,6 +356,11 @@ export function resolveDeleteRequest(path: string): ProxyRequest { } export function resolveWritebackRequest(path: string, content: string): ProxyRequest { + const mergeTarget = extractMergeTarget(path); + if (mergeTarget) { + return buildMergePullRequest(mergeTarget, parseMergePayload(content)); + } + const route = classifyWrite(path, resources); if (route?.resource.name === 'issues') { @@ -347,10 +397,41 @@ export function resolveWritebackRequest(path: string, content: string): ProxyReq } throw new Error( - `Unsupported GitHub writeback path: ${path}. Expected an issue, issue comment, or pull request review file.`, + `Unsupported GitHub writeback path: ${path}. Expected an issue, issue comment, pull request review, or pull request merge file.`, ); } +function extractMergeTarget(path: string): { owner: string; repo: string; prNumber: number } | undefined { + const match = path.match(MERGE_WRITEBACK_PATH); + if (!match?.[1] || !match[2] || !match[3]) { + return undefined; + } + return { + owner: decodeGitHubPathSegment(match[1], 'owner'), + repo: decodeGitHubPathSegment(match[2], 'repo'), + prNumber: Number.parseInt(match[3], 10), + }; +} + +function buildMergePullRequest( + target: { owner: string; repo: string; prNumber: number }, + payload: GitHubMergePullRequestWritebackInput, +): ProxyRequest { + const body: JsonObject = { + merge_method: payload.method ?? 'squash', + }; + if (payload.commitTitle !== undefined) body.commit_title = payload.commitTitle; + if (payload.commitMessage !== undefined) body.commit_message = payload.commitMessage; + return { + method: 'PUT', + baseUrl: GITHUB_API_BASE_URL, + endpoint: `/repos/${target.owner}/${target.repo}/pulls/${target.prNumber}/merge`, + connectionId: '', + headers: githubJsonHeaders(), + body, + }; +} + function buildIssueCreateRequest(owner: string, repo: string, content: string): ProxyRequest { const payload = parseIssuePayload(content, 'GitHub issue create payload'); if (!payload.title) { @@ -470,6 +551,38 @@ function parseIssueCommentPayload( return { body: readString(object, 'body', context) }; } +function parseMergePayload(content: string): GitHubMergePullRequestWritebackInput { + let parsed: JsonValue; + try { + parsed = JSON.parse(content) as JsonValue; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown JSON parse failure'; + throw new Error(`Invalid pull request merge JSON: ${message}`); + } + + const object = expectObject(parsed, 'Pull request merge payload'); + rejectReadOnlyFields(object); + const method = optionalMergeMethod( + object.method ?? object.merge_method, + 'Pull request merge payload.method', + ); + const commitTitle = + optionalTrimmedString(object.commitTitle, 'Pull request merge payload.commitTitle') ?? + optionalTrimmedString(object.commit_title, 'Pull request merge payload.commit_title'); + const commitMessage = + optionalTrimmedString(object.commitMessage, 'Pull request merge payload.commitMessage') ?? + optionalTrimmedString(object.commit_message, 'Pull request merge payload.commit_message'); + const metadataValue = object.metadata; + const metadata = metadataValue === undefined ? undefined : parseReviewMetadata(metadataValue); + + return { + ...(method ? { method } : {}), + ...(commitTitle ? { commitTitle } : {}), + ...(commitMessage ? { commitMessage } : {}), + ...(metadata ? { metadata } : {}), + }; +} + function compactIssuePayload(payload: GitHubIssueWritebackInput): JsonObject { const body: JsonObject = {}; if (payload.title !== undefined) body.title = payload.title; @@ -657,6 +770,16 @@ function optionalIssueState(value: JsonValue | undefined, fieldName: string): 'o throw new Error(`${fieldName} must be either open or closed when provided`); } +function optionalMergeMethod(value: JsonValue | undefined, fieldName: string): GitHubMergeMethod | undefined { + if (value === undefined) { + return undefined; + } + if (value === 'merge' || value === 'squash' || value === 'rebase') { + return value; + } + throw new Error(`${fieldName} must be one of merge, squash, rebase when provided`); +} + function readPositiveInteger(source: JsonObject, key: string, context: string): number { const value = source[key]; if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { @@ -737,26 +860,41 @@ function extractReviewId(value: JsonValue | null): string | undefined { return String(typedId); } -function formatProviderError(response: ProxyResponse): string { - const baseMessage = `GitHub review submission failed with status ${response.status}`; +function extractMergeSha(value: JsonValue | null): string | undefined { + if (value === null || Array.isArray(value) || typeof value !== 'object') { + return undefined; + } + + const responseObject = value as JsonObject; + const sha = responseObject.sha; + return typeof sha === 'string' && sha.trim().length > 0 ? sha : undefined; +} + +function formatProviderError( + response: ProxyResponse, + baseMessage = `GitHub review submission failed with status ${response.status}`, +): string { + const failureMessage = baseMessage.includes(String(response.status)) + ? baseMessage + : `${baseMessage} with status ${response.status}`; const responseData = response.data; if (responseData === null) { - return baseMessage; + return failureMessage; } if (typeof responseData === 'string' && responseData.trim().length > 0) { - return `${baseMessage}: ${responseData}`; + return `${failureMessage}: ${responseData}`; } if (!Array.isArray(responseData) && typeof responseData === 'object') { const message = responseData.message; if (typeof message === 'string' && message.trim().length > 0) { - return `${baseMessage}: ${message}`; + return `${failureMessage}: ${message}`; } } - return baseMessage; + return failureMessage; } function formatThrownError(error: unknown): string { From bfd1549a8bc88405c2baa983d8a7d1dfaa30f3af Mon Sep 17 00:00:00 2001 From: Miya Date: Tue, 26 May 2026 19:31:07 +0200 Subject: [PATCH 2/4] fix(github): avoid forcing squash merge method --- packages/github/src/writeback.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/github/src/writeback.ts b/packages/github/src/writeback.ts index 2ec2478d..b1bfa0a5 100644 --- a/packages/github/src/writeback.ts +++ b/packages/github/src/writeback.ts @@ -417,9 +417,8 @@ function buildMergePullRequest( target: { owner: string; repo: string; prNumber: number }, payload: GitHubMergePullRequestWritebackInput, ): ProxyRequest { - const body: JsonObject = { - merge_method: payload.method ?? 'squash', - }; + const body: JsonObject = {}; + if (payload.method !== undefined) body.merge_method = payload.method; if (payload.commitTitle !== undefined) body.commit_title = payload.commitTitle; if (payload.commitMessage !== undefined) body.commit_message = payload.commitMessage; return { From e684d960ab15eb3dd428d8c946c9beff369fe039 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Tue, 26 May 2026 19:33:21 +0200 Subject: [PATCH 3/4] fix(github): complete PR merge writeback surface --- packages/core/src/runtime/schema-adapter.ts | 16 ++- .../core/tests/runtime/schema-adapter.test.ts | 13 ++ packages/github/discovery/github/.adapter.md | 17 +-- .../merge.json/.create.example.json | 3 + .../{pullNumber}/merge.json/.schema.json | 117 ++++++++++++++++++ packages/github/src/layout.ts | 1 + packages/github/src/resources.ts | 8 ++ packages/github/src/types.ts | 1 + packages/github/src/writeback.test.ts | 16 +++ packages/github/src/writeback.ts | 3 + .../github/writeback.openapi.json | 5 + scripts/writeback-discovery-data.mjs | 17 ++- scripts/writeback-discovery-normalizer.mjs | 3 + .../writeback-discovery-normalizer.test.mjs | 2 +- 14 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 packages/github/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json create mode 100644 packages/github/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json diff --git a/packages/core/src/runtime/schema-adapter.ts b/packages/core/src/runtime/schema-adapter.ts index 582a309f..b1b3331e 100644 --- a/packages/core/src/runtime/schema-adapter.ts +++ b/packages/core/src/runtime/schema-adapter.ts @@ -501,7 +501,10 @@ export class SchemaAdapter { const parsed = parseEndpointDescriptor(mapping.endpoint); const placeholders = extractEndpointParams(parsed.path); const params = Object.fromEntries( - placeholders.map((placeholder, index) => [placeholder, wildcardValues[index] ?? ""]) + placeholders.map((placeholder, index) => [ + placeholder, + normalizeWritebackParam(this.name, placeholder, wildcardValues[index] ?? ""), + ]) ); return { @@ -1356,6 +1359,17 @@ function readString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; } +function normalizeWritebackParam( + adapterName: string, + placeholder: string, + value: string +): string { + if (adapterName === "github" && (placeholder === "pull_number" || placeholder === "issue_number")) { + return value.split("__", 1)[0] ?? value; + } + return value; +} + const SYNC_OPTION_KEYS = new Set([ "connectionId", "cursor", diff --git a/packages/core/tests/runtime/schema-adapter.test.ts b/packages/core/tests/runtime/schema-adapter.test.ts index 6e93811f..72bc2a3e 100644 --- a/packages/core/tests/runtime/schema-adapter.test.ts +++ b/packages/core/tests/runtime/schema-adapter.test.ts @@ -93,6 +93,10 @@ test("SchemaAdapter matches writebacks and proxies them to the provider", async match: "/github/repos/*/*/pulls/*/reviews/*.json", endpoint: "POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews", }, + merge: { + match: "/github/repos/*/*/pulls/*/merge.json", + endpoint: "PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge", + }, }, }, defaultConnectionId: "conn_default", @@ -107,4 +111,13 @@ test("SchemaAdapter matches writebacks and proxies them to the provider", async assert.equal(providerCalls.length, 1); assert.equal(providerCalls[0]?.endpoint, "/repos/acme/demo/pulls/42/reviews"); assert.equal(providerCalls[0]?.connectionId, "conn_default"); + + await adapter.writeBack( + "ws_123", + "/github/repos/acme/demo/pulls/43__finish-feature/merge.json", + JSON.stringify({ merge_method: "squash" }) + ); + + assert.equal(providerCalls.length, 2); + assert.equal(providerCalls[1]?.endpoint, "/repos/acme/demo/pulls/43/merge"); }); diff --git a/packages/github/discovery/github/.adapter.md b/packages/github/discovery/github/.adapter.md index f7788701..e2e4c9fd 100644 --- a/packages/github/discovery/github/.adapter.md +++ b/packages/github/discovery/github/.adapter.md @@ -15,7 +15,7 @@ Resources: | `/github/repos/{owner}/{repo}/issues/.json` | `/github/repos/{owner}/{repo}/issues/.schema.json` | `/github/repos/{owner}/{repo}/issues/.create.example.json` | `^[1-9]\d*$` | Creates a GitHub issue. | | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.json` | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.schema.json` | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.create.example.json` | `^\d+$` | Creates or updates a GitHub issue comment. | | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.schema.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.create.example.json` | `^\d+$` | Submits a pull request review with optional inline comments. | -| `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json` | inline merge payload | n/a | exact filename | Merges a pull request. | +| `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json` | `^\d+$` | Merges a pull request. Defaults to squash when no merge method is supplied. | ## Operations @@ -31,7 +31,7 @@ Resources: - `/github/repos/{owner}/{repo}/issues/.json`: `^[1-9]\d*$`. Filenames that do not match this pattern are treated as create drafts. - `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.json`: `^\d+$`. Filenames that do not match this pattern are treated as create drafts. - `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.json`: `^\d+$`. Filenames that do not match this pattern are treated as create drafts. -- `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json`: exact action file. +- `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json`: exact file path. ## Write field contracts @@ -99,14 +99,17 @@ Fields: ### Merge GitHub pull request Resource: `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json` -Required fields: none. -Optional fields: `method`, `commitTitle`, `commitMessage`, `metadata`. +Schema: `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json` +Create example: `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json` +Required fields: none at the top level. +Optional fields: `commit_title`, `commit_message`, `sha`, `merge_method`, `metadata`. Fields: -- `method` (optional, string) - Merge strategy. Defaults to `squash`. Allowed values: `merge`, `squash`, `rebase`. -- `commitTitle` (optional, string) - Commit title for merge and squash commits. -- `commitMessage` (optional, string) - Commit message for merge and squash commits. +- `commit_title` (optional, string) - Title for the automatic commit message. +- `commit_message` (optional, string) - Extra detail to append to automatic commit message. +- `sha` (optional, string) - Expected head SHA. GitHub refuses the merge if the pull request head has moved. +- `merge_method` (optional, string) - Merge strategy. Defaults to squash. Allowed values: `merge`, `squash`, `rebase`. - `metadata` (optional, object) - Optional submission metadata. - `metadata.connectionId` (optional, string) - Relayfile connection id override. - `metadata.providerConfigKey` (optional, string) - GitHub provider config key override. diff --git a/packages/github/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json b/packages/github/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json new file mode 100644 index 00000000..2443166f --- /dev/null +++ b/packages/github/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json @@ -0,0 +1,3 @@ +{ + "merge_method": "squash" +} diff --git a/packages/github/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json b/packages/github/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json new file mode 100644 index 00000000..5ab8c407 --- /dev/null +++ b/packages/github/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Merge GitHub pull request", + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents.", + "type": "object", + "required": [], + "properties": { + "commit_title": { + "type": "string", + "description": "Title for the automatic commit message." + }, + "commit_message": { + "type": "string", + "description": "Extra detail to append to automatic commit message." + }, + "sha": { + "type": "string", + "description": "Expected head SHA. GitHub refuses the merge if the pull request head has moved." + }, + "merge_method": { + "type": "string", + "description": "Merge strategy. Defaults to squash.", + "enum": [ + "merge", + "squash", + "rebase" + ] + }, + "metadata": { + "type": "object", + "description": "Optional submission metadata.", + "properties": { + "connectionId": { + "type": "string", + "description": "Relayfile connection id override." + }, + "providerConfigKey": { + "type": "string", + "description": "GitHub provider config key override." + } + }, + "additionalProperties": true + }, + "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 + } + }, + "additionalProperties": false, + "x-relayfile-source": { + "operationId": "pulls/merge", + "sourceKind": "openapi", + "sourcePath": "scripts/integration-contracts/github/source/openapi.yaml", + "sourceUrl": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml" + } +} diff --git a/packages/github/src/layout.ts b/packages/github/src/layout.ts index a89081ad..391318ff 100644 --- a/packages/github/src/layout.ts +++ b/packages/github/src/layout.ts @@ -40,6 +40,7 @@ export const layoutManifest: CoreLayoutManifestProvider = () => ({ aliasSegments: ['by-id', 'by-title', 'by-state', 'by-assignee', 'by-creator', 'by-priority', 'by-edited'], writebackResources: [ { path: 'github/repos/*/*/pulls/reviews', schemaId: 'github/pull-request-review' }, + { path: 'github/repos/*/*/pulls/*/merge.json', schemaId: 'github/pull-request-merge' }, ], }, ], diff --git a/packages/github/src/resources.ts b/packages/github/src/resources.ts index 0d3edc9b..81ceab03 100644 --- a/packages/github/src/resources.ts +++ b/packages/github/src/resources.ts @@ -32,6 +32,14 @@ export const resources = [ schema: "discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.schema.json", createExample: "discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.create.example.json", }, + { + name: "merge", + path: "/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json", + pathPattern: /^\/github\/repos\/[^\/]+\/[^\/]+\/pulls\/[1-9]\d*(?:__[^\/]+)?\/merge\.json$/, + idPattern: /^\d+$/, + schema: "discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json", + createExample: "discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json", + }, ] as const satisfies readonly AdapterResourceConfig[]; export function findResourceByPath(path: string): AdapterResourceConfig | undefined { diff --git a/packages/github/src/types.ts b/packages/github/src/types.ts index b66d062d..18733059 100644 --- a/packages/github/src/types.ts +++ b/packages/github/src/types.ts @@ -81,6 +81,7 @@ export interface GitHubMergePullRequestWritebackInput { method?: GitHubMergeMethod; commitTitle?: string; commitMessage?: string; + sha?: string; metadata?: AgentReviewMetadata; } diff --git a/packages/github/src/writeback.test.ts b/packages/github/src/writeback.test.ts index 2a4f4426..4bc0ab4d 100644 --- a/packages/github/src/writeback.test.ts +++ b/packages/github/src/writeback.test.ts @@ -165,6 +165,7 @@ describe('writeback', () => { method: 'rebase', commitTitle: 'Merge PR #7', commitMessage: 'Ship it.', + sha: 'reviewed-head', metadata: { connectionId: 'conn-merge', providerConfigKey: 'github-custom', @@ -183,6 +184,7 @@ describe('writeback', () => { merge_method: 'rebase', commit_title: 'Merge PR #7', commit_message: 'Ship it.', + sha: 'reviewed-head', }); }); @@ -221,6 +223,20 @@ describe('writeback', () => { assert.strictEqual(result.externalId, undefined); }); + it('handleWriteback rejects invalid pull request merge payloads', async () => { + const { handler, provider } = createHandler(); + + const result = await handler.handleWriteback( + 'workspace-1', + '/github/repos/acme/widgets/pulls/7/merge.json', + JSON.stringify({ merge_method: null, commit_title: 42 }), + ); + + assert.strictEqual(result.success, false); + assert.match(result.error ?? '', /merge payload\.method must be one of merge, squash, rebase/); + assert.strictEqual(provider.requests.length, 0); + }); + it('handleWriteback returns a typed error for invalid JSON', async () => { const { handler } = createHandler(); const result = await handler.handleWriteback( diff --git a/packages/github/src/writeback.ts b/packages/github/src/writeback.ts index b1bfa0a5..1c685d72 100644 --- a/packages/github/src/writeback.ts +++ b/packages/github/src/writeback.ts @@ -421,6 +421,7 @@ function buildMergePullRequest( if (payload.method !== undefined) body.merge_method = payload.method; if (payload.commitTitle !== undefined) body.commit_title = payload.commitTitle; if (payload.commitMessage !== undefined) body.commit_message = payload.commitMessage; + if (payload.sha !== undefined) body.sha = payload.sha; return { method: 'PUT', baseUrl: GITHUB_API_BASE_URL, @@ -571,6 +572,7 @@ function parseMergePayload(content: string): GitHubMergePullRequestWritebackInpu const commitMessage = optionalTrimmedString(object.commitMessage, 'Pull request merge payload.commitMessage') ?? optionalTrimmedString(object.commit_message, 'Pull request merge payload.commit_message'); + const sha = optionalTrimmedString(object.sha, 'Pull request merge payload.sha'); const metadataValue = object.metadata; const metadata = metadataValue === undefined ? undefined : parseReviewMetadata(metadataValue); @@ -578,6 +580,7 @@ function parseMergePayload(content: string): GitHubMergePullRequestWritebackInpu ...(method ? { method } : {}), ...(commitTitle ? { commitTitle } : {}), ...(commitMessage ? { commitMessage } : {}), + ...(sha ? { sha } : {}), ...(metadata ? { metadata } : {}), }; } diff --git a/scripts/integration-contracts/github/writeback.openapi.json b/scripts/integration-contracts/github/writeback.openapi.json index ef355755..ed534a93 100644 --- a/scripts/integration-contracts/github/writeback.openapi.json +++ b/scripts/integration-contracts/github/writeback.openapi.json @@ -18,6 +18,11 @@ "operationId": "pulls/create-review", "summary": "Create a review for a pull request", "description": "Submits a GitHub pull request review with optional inline comments." + }, + { + "operationId": "pulls/merge", + "summary": "Merge a pull request", + "description": "Merges a GitHub pull request with an optional method, commit text, and expected head SHA." } ] } diff --git a/scripts/writeback-discovery-data.mjs b/scripts/writeback-discovery-data.mjs index f20f8e7c..f74d7306 100644 --- a/scripts/writeback-discovery-data.mjs +++ b/scripts/writeback-discovery-data.mjs @@ -93,7 +93,7 @@ export const adapters = [ slug: 'github', title: 'GitHub adapter', overview: - 'The GitHub adapter exposes repository pull requests, issues, reviews, comments, commits, files, and checks under `/github`, with writeback support for creating and updating issues, creating and updating issue comments, and submitting pull request reviews.', + 'The GitHub adapter exposes repository pull requests, issues, reviews, comments, commits, files, and checks under `/github`, with writeback support for creating and updating issues, creating and updating issue comments, submitting pull request reviews, and merging pull requests.', readPaths: [ ['/github/repos///pulls//meta.json', 'Pull request metadata.'], ['/github/repos///pulls//files/', 'Pull request file records.'], @@ -145,6 +145,21 @@ export const adapters = [ }, }, }), + contractEndpoint('/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json', 'pulls/merge', { merge_method: 'squash' }, { + title: 'Merge GitHub pull request', + description: 'Merges a pull request. Defaults to squash when no merge method is supplied.', + schemaOverrides: { + type: 'object', + properties: { + merge_method: en(['merge', 'squash', 'rebase'], 'Merge strategy. Defaults to squash.'), + sha: str('Expected head SHA. GitHub refuses the merge if the pull request head has moved.'), + metadata: obj('Optional submission metadata.', { + connectionId: str('Relayfile connection id override.'), + providerConfigKey: str('GitHub provider config key override.'), + }), + }, + }, + }), ], }, { diff --git a/scripts/writeback-discovery-normalizer.mjs b/scripts/writeback-discovery-normalizer.mjs index d9283bdc..6e8e9ef7 100644 --- a/scripts/writeback-discovery-normalizer.mjs +++ b/scripts/writeback-discovery-normalizer.mjs @@ -228,6 +228,9 @@ function pathPatternSourceFor(adapterSlug, resourcePath) { if (adapterSlug === 'gitlab' && resourcePath.includes('/issues/{issueIid}__{slug}/comments')) { return '^/gitlab/projects/.+?/issues/[^/]+(?:__[^/]+)?/comments(?:/[^/]+(?:\\.json)?)?$'; } + if (adapterSlug === 'github' && resourcePath === '/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json') { + return '^/github/repos/[^/]+/[^/]+/pulls/[1-9]\\d*(?:__[^/]+)?/merge\\.json$'; + } const resourceSegments = resourcePath.split('/').filter(Boolean).map((segment) => { if (segment === '{projectPath}') { diff --git a/scripts/writeback-discovery-normalizer.test.mjs b/scripts/writeback-discovery-normalizer.test.mjs index 80fe6904..ca90b9c0 100644 --- a/scripts/writeback-discovery-normalizer.test.mjs +++ b/scripts/writeback-discovery-normalizer.test.mjs @@ -19,7 +19,7 @@ test('loads GitHub writeback operations from a local OpenAPI contract', () => { const github = contracts.get('github'); assert.ok(github); - assert.deepEqual([...github.operations.keys()], ['issues/create', 'issues/create-comment', 'pulls/create-review']); + assert.deepEqual([...github.operations.keys()], ['issues/create', 'issues/create-comment', 'pulls/create-review', 'pulls/merge']); assert.equal(github.operations.get('issues/create').requestSchema.required[0], 'title'); assert.deepEqual(github.operations.get('pulls/create-review').requestSchema.properties.event.enum, ['APPROVE', 'REQUEST_CHANGES', 'COMMENT']); }); From 05b72067008a78cdb6431bd375b84b92b2768591 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Tue, 26 May 2026 19:57:53 +0200 Subject: [PATCH 4/4] fix(github): correct merge writeback metadata --- .../src/runtime/file-native-router.test.ts | 23 +++++++++++++++++++ packages/github/discovery/github/.adapter.md | 4 ++-- .../{pullNumber}/merge.json/.schema.json | 2 +- packages/github/src/resources.ts | 2 +- packages/github/src/writeback.test.ts | 4 ++-- packages/notion/discovery/notion/.adapter.md | 16 ++++++------- scripts/generate-writeback-discovery.mjs | 8 ++++++- scripts/writeback-discovery-data.mjs | 4 ++-- scripts/writeback-discovery-normalizer.mjs | 3 +++ 9 files changed, 49 insertions(+), 17 deletions(-) diff --git a/packages/core/src/runtime/file-native-router.test.ts b/packages/core/src/runtime/file-native-router.test.ts index b10c765e..22fe0dc4 100644 --- a/packages/core/src/runtime/file-native-router.test.ts +++ b/packages/core/src/runtime/file-native-router.test.ts @@ -34,6 +34,17 @@ const resources: readonly AdapterResourceConfig[] = [ createExample: "discovery/linear/issues/{issueId}/comments/.create.example.json", }, + { + name: "merge", + path: "/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json", + pathPattern: + /^\/github\/repos\/[^/]+\/[^/]+\/pulls\/[1-9]\d*(?:__[^/]+)?\/merge\.json$/, + idPattern: /^[1-9]\d*(?:__.*)?$/, + schema: + "discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json", + createExample: + "discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json", + }, ]; const issueId = "11111111-1111-1111-1111-111111111111"; @@ -77,6 +88,18 @@ test("classifyWrite chooses the most specific matching resource", () => { assert.equal(route?.resource.name, "comments"); }); +test("classifyWrite maps slugged exact-file resources to patch", () => { + const route = classifyWrite( + "/github/repos/acme/widgets/pulls/7__finish-feature/merge.json", + resources + ); + + assert.equal(route?.kind, "patch"); + assert.equal(route?.canonical, true); + assert.equal(route?.id, "7__finish-feature"); + assert.equal(route?.resource.name, "merge"); +}); + test("classifyWrite ignores temporary and partial writeback filenames", () => { for (const path of [ "/linear/issues/.tmp.json", diff --git a/packages/github/discovery/github/.adapter.md b/packages/github/discovery/github/.adapter.md index e2e4c9fd..dfb25e63 100644 --- a/packages/github/discovery/github/.adapter.md +++ b/packages/github/discovery/github/.adapter.md @@ -15,7 +15,7 @@ Resources: | `/github/repos/{owner}/{repo}/issues/.json` | `/github/repos/{owner}/{repo}/issues/.schema.json` | `/github/repos/{owner}/{repo}/issues/.create.example.json` | `^[1-9]\d*$` | Creates a GitHub issue. | | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.json` | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.schema.json` | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.create.example.json` | `^\d+$` | Creates or updates a GitHub issue comment. | | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.schema.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.create.example.json` | `^\d+$` | Submits a pull request review with optional inline comments. | -| `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json` | `^\d+$` | Merges a pull request. Defaults to squash when no merge method is supplied. | +| `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json` | exact file path | Merges a pull request. Uses the repository default merge strategy when no merge method is supplied. | ## Operations @@ -109,7 +109,7 @@ Fields: - `commit_title` (optional, string) - Title for the automatic commit message. - `commit_message` (optional, string) - Extra detail to append to automatic commit message. - `sha` (optional, string) - Expected head SHA. GitHub refuses the merge if the pull request head has moved. -- `merge_method` (optional, string) - Merge strategy. Defaults to squash. Allowed values: `merge`, `squash`, `rebase`. +- `merge_method` (optional, string) - Merge strategy. When omitted, GitHub uses the repository default. Allowed values: `merge`, `squash`, `rebase`. - `metadata` (optional, object) - Optional submission metadata. - `metadata.connectionId` (optional, string) - Relayfile connection id override. - `metadata.providerConfigKey` (optional, string) - GitHub provider config key override. diff --git a/packages/github/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json b/packages/github/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json index 5ab8c407..f6591f5c 100644 --- a/packages/github/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json +++ b/packages/github/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json @@ -19,7 +19,7 @@ }, "merge_method": { "type": "string", - "description": "Merge strategy. Defaults to squash.", + "description": "Merge strategy. When omitted, GitHub uses the repository default.", "enum": [ "merge", "squash", diff --git a/packages/github/src/resources.ts b/packages/github/src/resources.ts index 81ceab03..86e74d69 100644 --- a/packages/github/src/resources.ts +++ b/packages/github/src/resources.ts @@ -36,7 +36,7 @@ export const resources = [ name: "merge", path: "/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json", pathPattern: /^\/github\/repos\/[^\/]+\/[^\/]+\/pulls\/[1-9]\d*(?:__[^\/]+)?\/merge\.json$/, - idPattern: /^\d+$/, + idPattern: /^[1-9]\d*(?:__.*)?$/, schema: "discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json", createExample: "discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json", }, diff --git a/packages/github/src/writeback.test.ts b/packages/github/src/writeback.test.ts index 4bc0ab4d..63c59355 100644 --- a/packages/github/src/writeback.test.ts +++ b/packages/github/src/writeback.test.ts @@ -188,7 +188,7 @@ describe('writeback', () => { }); }); - it('handleWriteback defaults pull request merge method to squash', async () => { + it('handleWriteback omits merge method when the payload does not specify one', async () => { const { handler, provider } = createHandler(() => ({ status: 200, headers: {}, @@ -203,7 +203,7 @@ describe('writeback', () => { assert.strictEqual(result.success, true); assert.strictEqual(provider.requests[0]?.endpoint, '/repos/acme/widgets/pulls/7/merge'); - assert.deepStrictEqual(provider.requests[0]?.body, { merge_method: 'squash' }); + assert.deepStrictEqual(provider.requests[0]?.body, {}); }); it('handleWriteback treats a 2xx merge response without a JSON object body as success', async () => { diff --git a/packages/notion/discovery/notion/.adapter.md b/packages/notion/discovery/notion/.adapter.md index 9cfb9dd9..d1eadd03 100644 --- a/packages/notion/discovery/notion/.adapter.md +++ b/packages/notion/discovery/notion/.adapter.md @@ -13,14 +13,14 @@ Resources: | Resource | Schema | Create example | ID pattern | What it does | |---|---|---|---|---| | `/notion/databases/{databaseId}/pages/.json` | `/notion/databases/{databaseId}/pages/.schema.json` | `/notion/databases/{databaseId}/pages/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Creates a page inside a Notion database. | -| `/notion/databases/{databaseId}/pages/{pageId}.json` | `/notion/databases/{databaseId}/pages/{pageId}.json/.schema.json` | `/notion/databases/{databaseId}/pages/{pageId}.json/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Updates properties, archive state, icon, or cover for a page inside a Notion database. | -| `/notion/databases/{databaseId}/pages/{pageId}/properties.json` | `/notion/databases/{databaseId}/pages/{pageId}/properties.json/.schema.json` | `/notion/databases/{databaseId}/pages/{pageId}/properties.json/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Updates properties, archive state, icon, or cover through a database page properties sidecar. | -| `/notion/databases/{databaseId}/pages/{pageId}/content.md` | `/notion/databases/{databaseId}/pages/{pageId}/content.md/.schema.json` | `/notion/databases/{databaseId}/pages/{pageId}/content.md/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Replaces the rendered markdown body for a page inside a Notion database. | -| `/notion/databases/{databaseId}/pages/{pageId}/comments.json` | `/notion/databases/{databaseId}/pages/{pageId}/comments.json/.schema.json` | `/notion/databases/{databaseId}/pages/{pageId}/comments.json/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Creates a Notion comment on a page inside a Notion database from comments.json. | -| `/notion/pages/{pageId}.json` | `/notion/pages/{pageId}.json/.schema.json` | `/notion/pages/{pageId}.json/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Updates properties, archive state, icon, or cover for a standalone page. | -| `/notion/pages/{pageId}/properties.json` | `/notion/pages/{pageId}/properties.json/.schema.json` | `/notion/pages/{pageId}/properties.json/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Updates properties, archive state, icon, or cover through a standalone page properties sidecar. | -| `/notion/pages/{pageId}/content.md` | `/notion/pages/{pageId}/content.md/.schema.json` | `/notion/pages/{pageId}/content.md/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Replaces the rendered markdown body for a standalone page. | -| `/notion/pages/{pageId}/comments.json` | `/notion/pages/{pageId}/comments.json/.schema.json` | `/notion/pages/{pageId}/comments.json/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Creates a Notion comment on a standalone page from comments.json. | +| `/notion/databases/{databaseId}/pages/{pageId}.json` | `/notion/databases/{databaseId}/pages/{pageId}.json/.schema.json` | `/notion/databases/{databaseId}/pages/{pageId}.json/.create.example.json` | exact file path | Updates properties, archive state, icon, or cover for a page inside a Notion database. | +| `/notion/databases/{databaseId}/pages/{pageId}/properties.json` | `/notion/databases/{databaseId}/pages/{pageId}/properties.json/.schema.json` | `/notion/databases/{databaseId}/pages/{pageId}/properties.json/.create.example.json` | exact file path | Updates properties, archive state, icon, or cover through a database page properties sidecar. | +| `/notion/databases/{databaseId}/pages/{pageId}/content.md` | `/notion/databases/{databaseId}/pages/{pageId}/content.md/.schema.json` | `/notion/databases/{databaseId}/pages/{pageId}/content.md/.create.example.json` | exact file path | Replaces the rendered markdown body for a page inside a Notion database. | +| `/notion/databases/{databaseId}/pages/{pageId}/comments.json` | `/notion/databases/{databaseId}/pages/{pageId}/comments.json/.schema.json` | `/notion/databases/{databaseId}/pages/{pageId}/comments.json/.create.example.json` | exact file path | Creates a Notion comment on a page inside a Notion database from comments.json. | +| `/notion/pages/{pageId}.json` | `/notion/pages/{pageId}.json/.schema.json` | `/notion/pages/{pageId}.json/.create.example.json` | exact file path | Updates properties, archive state, icon, or cover for a standalone page. | +| `/notion/pages/{pageId}/properties.json` | `/notion/pages/{pageId}/properties.json/.schema.json` | `/notion/pages/{pageId}/properties.json/.create.example.json` | exact file path | Updates properties, archive state, icon, or cover through a standalone page properties sidecar. | +| `/notion/pages/{pageId}/content.md` | `/notion/pages/{pageId}/content.md/.schema.json` | `/notion/pages/{pageId}/content.md/.create.example.json` | exact file path | Replaces the rendered markdown body for a standalone page. | +| `/notion/pages/{pageId}/comments.json` | `/notion/pages/{pageId}/comments.json/.schema.json` | `/notion/pages/{pageId}/comments.json/.create.example.json` | exact file path | Creates a Notion comment on a standalone page from comments.json. | ## Operations diff --git a/scripts/generate-writeback-discovery.mjs b/scripts/generate-writeback-discovery.mjs index c50e6bf7..38229eab 100644 --- a/scripts/generate-writeback-discovery.mjs +++ b/scripts/generate-writeback-discovery.mjs @@ -49,7 +49,7 @@ function renderAdapterReadme(adapter) { '', '| Resource | Schema | Create example | ID pattern | What it does |', '|---|---|---|---|---|', - ...resources.map((resource) => `| \`${resourceWritePath(resource)}\` | \`${resource.schemaPath}\` | \`${resource.examplePath}\` | \`${escapeMarkdownTableCell(resource.idPatternSource)}\` | ${resource.description} |`), + ...resources.map((resource) => `| \`${resourceWritePath(resource)}\` | \`${resource.schemaPath}\` | \`${resource.examplePath}\` | ${resourceIdPatternCell(resource)} | ${resource.description} |`), '', '## Operations', '', @@ -113,6 +113,12 @@ function renderIdPattern(resource) { return `- \`${writePath}\`: \`${resource.idPatternSource}\`. Filenames that do not match this pattern are treated as create drafts.`; } +function resourceIdPatternCell(resource) { + return resourceWritePath(resource) === resource.resourcePath + ? 'exact file path' + : `\`${escapeMarkdownTableCell(resource.idPatternSource)}\``; +} + function renderValidationNotes(schema) { const notes = []; const anyOfFields = describeRequiredBranches(schema.anyOf); diff --git a/scripts/writeback-discovery-data.mjs b/scripts/writeback-discovery-data.mjs index f74d7306..06134e84 100644 --- a/scripts/writeback-discovery-data.mjs +++ b/scripts/writeback-discovery-data.mjs @@ -147,11 +147,11 @@ export const adapters = [ }), contractEndpoint('/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json', 'pulls/merge', { merge_method: 'squash' }, { title: 'Merge GitHub pull request', - description: 'Merges a pull request. Defaults to squash when no merge method is supplied.', + description: 'Merges a pull request. Uses the repository default merge strategy when no merge method is supplied.', schemaOverrides: { type: 'object', properties: { - merge_method: en(['merge', 'squash', 'rebase'], 'Merge strategy. Defaults to squash.'), + merge_method: en(['merge', 'squash', 'rebase'], 'Merge strategy. When omitted, GitHub uses the repository default.'), sha: str('Expected head SHA. GitHub refuses the merge if the pull request head has moved.'), metadata: obj('Optional submission metadata.', { connectionId: str('Relayfile connection id override.'), diff --git a/scripts/writeback-discovery-normalizer.mjs b/scripts/writeback-discovery-normalizer.mjs index 6e8e9ef7..b284d858 100644 --- a/scripts/writeback-discovery-normalizer.mjs +++ b/scripts/writeback-discovery-normalizer.mjs @@ -273,6 +273,9 @@ function idPatternFor(adapterSlug, resourcePath) { return pattern('^[A-Za-z0-9_.:-]+$'); } if (adapterSlug === 'github') { + if (resourcePath === '/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json') { + return pattern('^[1-9]\\d*(?:__.*)?$'); + } if (resourcePath.endsWith('/issues')) { return pattern('^[1-9]\\d*$'); }