@atlassian-dc-mcp/bitbucket 0.34.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +55 -2
- package/build/__tests__/bitbucket-service.test.js +41 -2
- package/build/__tests__/bitbucket-service.test.js.map +1 -1
- package/build/__tests__/config.test.js +2 -1
- package/build/__tests__/config.test.js.map +1 -1
- package/build/__tests__/pr-builds.test.d.ts +2 -0
- package/build/__tests__/pr-builds.test.d.ts.map +1 -0
- package/build/__tests__/pr-builds.test.js +74 -0
- package/build/__tests__/pr-builds.test.js.map +1 -0
- package/build/__tests__/pr-merge.test.js +5 -4
- package/build/__tests__/pr-merge.test.js.map +1 -1
- package/build/__tests__/pr-state.test.d.ts +2 -0
- package/build/__tests__/pr-state.test.d.ts.map +1 -0
- package/build/__tests__/pr-state.test.js +118 -0
- package/build/__tests__/pr-state.test.js.map +1 -0
- package/build/__tests__/repo-gateway.test.d.ts +2 -0
- package/build/__tests__/repo-gateway.test.d.ts.map +1 -0
- package/build/__tests__/{merge-gateway.test.js → repo-gateway.test.js} +70 -10
- package/build/__tests__/repo-gateway.test.js.map +1 -0
- package/build/bitbucket-service.d.ts +44 -0
- package/build/bitbucket-service.d.ts.map +1 -1
- package/build/bitbucket-service.js +59 -0
- package/build/bitbucket-service.js.map +1 -1
- package/build/index.js +33 -1
- package/build/index.js.map +1 -1
- package/build/pr-builds.d.ts +36 -0
- package/build/pr-builds.d.ts.map +1 -0
- package/build/pr-builds.js +40 -0
- package/build/pr-builds.js.map +1 -0
- package/build/pr-merge.d.ts +2 -2
- package/build/pr-merge.d.ts.map +1 -1
- package/build/pr-merge.js +2 -2
- package/build/pr-merge.js.map +1 -1
- package/build/pr-state.d.ts +33 -0
- package/build/pr-state.d.ts.map +1 -0
- package/build/pr-state.js +37 -0
- package/build/pr-state.js.map +1 -0
- package/build/repo-gateway.d.ts +57 -0
- package/build/repo-gateway.d.ts.map +1 -0
- package/build/repo-gateway.js +122 -0
- package/build/repo-gateway.js.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/bitbucket-service.test.ts +50 -2
- package/src/__tests__/config.test.ts +2 -1
- package/src/__tests__/pr-builds.test.ts +93 -0
- package/src/__tests__/pr-merge.test.ts +5 -5
- package/src/__tests__/pr-state.test.ts +150 -0
- package/src/__tests__/{merge-gateway.test.ts → repo-gateway.test.ts} +83 -11
- package/src/bitbucket-service.ts +67 -0
- package/src/index.ts +52 -1
- package/src/pr-builds.ts +74 -0
- package/src/pr-merge.ts +3 -3
- package/src/pr-state.ts +68 -0
- package/src/repo-gateway.ts +183 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/build/__tests__/merge-gateway.test.d.ts +0 -2
- package/build/__tests__/merge-gateway.test.d.ts.map +0 -1
- package/build/__tests__/merge-gateway.test.js.map +0 -1
- package/build/merge-gateway.d.ts +0 -34
- package/build/merge-gateway.d.ts.map +0 -1
- package/build/merge-gateway.js +0 -86
- package/build/merge-gateway.js.map +0 -1
- package/src/merge-gateway.ts +0 -132
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
DECLINE_POLICY,
|
|
3
|
+
MERGE_POLICY,
|
|
4
|
+
assertRepoOperationAllowed,
|
|
3
5
|
assertTargetRefMergeAllowed,
|
|
6
|
+
resolveDeclineGateway,
|
|
4
7
|
resolveMergeGateway,
|
|
5
|
-
|
|
8
|
+
type RepoGateway,
|
|
9
|
+
} from '../repo-gateway.js';
|
|
6
10
|
|
|
7
11
|
const silentWarn = () => undefined;
|
|
8
12
|
|
|
13
|
+
const disabled = (policy = MERGE_POLICY): RepoGateway => ({
|
|
14
|
+
enabled: false,
|
|
15
|
+
repos: [],
|
|
16
|
+
targetRefs: [],
|
|
17
|
+
policy,
|
|
18
|
+
});
|
|
19
|
+
|
|
9
20
|
describe('resolveMergeGateway', () => {
|
|
10
21
|
it('is disabled by default', () => {
|
|
11
22
|
const gateway = resolveMergeGateway({ env: {}, warn: silentWarn });
|
|
@@ -63,40 +74,101 @@ describe('resolveMergeGateway', () => {
|
|
|
63
74
|
});
|
|
64
75
|
});
|
|
65
76
|
|
|
66
|
-
describe('
|
|
77
|
+
describe('resolveDeclineGateway', () => {
|
|
78
|
+
it('is disabled by default', () => {
|
|
79
|
+
const gateway = resolveDeclineGateway({ env: {}, warn: silentWarn });
|
|
80
|
+
expect(gateway.enabled).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('reads its own environment variables rather than the merge ones', () => {
|
|
84
|
+
const gateway = resolveDeclineGateway({
|
|
85
|
+
env: {
|
|
86
|
+
BITBUCKET_DECLINE_ENABLED: 'true',
|
|
87
|
+
BITBUCKET_DECLINE_ALLOWED_REPOS: 'proj/Demo, OTHER/*',
|
|
88
|
+
},
|
|
89
|
+
warn: silentWarn,
|
|
90
|
+
});
|
|
91
|
+
expect(gateway.enabled).toBe(true);
|
|
92
|
+
expect(gateway.repos).toEqual(['PROJ/demo', 'OTHER/*']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('stays disabled when only merging was enabled', () => {
|
|
96
|
+
const gateway = resolveDeclineGateway({
|
|
97
|
+
env: { BITBUCKET_MERGE_ENABLED: 'true', BITBUCKET_MERGE_ALLOWED_REPOS: 'PROJ/demo' },
|
|
98
|
+
warn: silentWarn,
|
|
99
|
+
});
|
|
100
|
+
expect(gateway.enabled).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('never restricts target refs, even when the merge target refs are configured', () => {
|
|
104
|
+
const gateway = resolveDeclineGateway({
|
|
105
|
+
env: {
|
|
106
|
+
BITBUCKET_DECLINE_ENABLED: 'true',
|
|
107
|
+
BITBUCKET_DECLINE_ALLOWED_REPOS: 'PROJ/demo',
|
|
108
|
+
BITBUCKET_MERGE_ALLOWED_TARGET_REFS: 'develop',
|
|
109
|
+
},
|
|
110
|
+
warn: silentWarn,
|
|
111
|
+
});
|
|
112
|
+
expect(gateway.targetRefs).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('names the decline environment variables when enabled with no valid repository', () => {
|
|
116
|
+
const warnings: string[] = [];
|
|
117
|
+
resolveDeclineGateway({ env: { BITBUCKET_DECLINE_ENABLED: 'true' }, warn: message => warnings.push(message) });
|
|
118
|
+
expect(warnings).toHaveLength(1);
|
|
119
|
+
expect(warnings[0]).toContain('BITBUCKET_DECLINE_ALLOWED_REPOS');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('assertRepoOperationAllowed', () => {
|
|
67
124
|
const gateway = resolveMergeGateway({
|
|
68
125
|
env: { BITBUCKET_MERGE_ENABLED: 'true', BITBUCKET_MERGE_ALLOWED_REPOS: 'PROJ/demo, OTHER/*' },
|
|
69
126
|
warn: silentWarn,
|
|
70
127
|
});
|
|
71
128
|
|
|
72
|
-
it('refuses everything when
|
|
73
|
-
expect(() =>
|
|
129
|
+
it('refuses everything when the operation is disabled', () => {
|
|
130
|
+
expect(() => assertRepoOperationAllowed(disabled(), 'PROJ', 'demo'))
|
|
74
131
|
.toThrow(/disabled on this server/);
|
|
75
132
|
});
|
|
76
133
|
|
|
134
|
+
it('names the environment variables of its own operation when disabled', () => {
|
|
135
|
+
expect(() => assertRepoOperationAllowed(disabled(DECLINE_POLICY), 'PROJ', 'demo'))
|
|
136
|
+
.toThrow(/Declining pull requests is disabled on this server\. Enable it with BITBUCKET_DECLINE_ENABLED and list the allowed repositories in BITBUCKET_DECLINE_ALLOWED_REPOS\./);
|
|
137
|
+
});
|
|
138
|
+
|
|
77
139
|
it('allows an exact repository regardless of the casing used by the caller', () => {
|
|
78
|
-
expect(() =>
|
|
140
|
+
expect(() => assertRepoOperationAllowed(gateway, 'proj', 'DEMO')).not.toThrow();
|
|
79
141
|
});
|
|
80
142
|
|
|
81
143
|
it('allows any repository in a wildcard project', () => {
|
|
82
|
-
expect(() =>
|
|
144
|
+
expect(() => assertRepoOperationAllowed(gateway, 'OTHER', 'anything')).not.toThrow();
|
|
83
145
|
});
|
|
84
146
|
|
|
85
147
|
it('refuses a repository outside the allowed list', () => {
|
|
86
|
-
expect(() =>
|
|
148
|
+
expect(() => assertRepoOperationAllowed(gateway, 'PROJ', 'other-repo')).toThrow(/Merging is not allowed in PROJ\/other-repo/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('names the refused operation in the message', () => {
|
|
152
|
+
const declineGateway = resolveDeclineGateway({
|
|
153
|
+
env: { BITBUCKET_DECLINE_ENABLED: 'true', BITBUCKET_DECLINE_ALLOWED_REPOS: 'PROJ/demo' },
|
|
154
|
+
warn: silentWarn,
|
|
155
|
+
});
|
|
156
|
+
expect(() => assertRepoOperationAllowed(declineGateway, 'PROJ', 'other-repo'))
|
|
157
|
+
.toThrow(/Declining is not allowed in PROJ\/other-repo/);
|
|
87
158
|
});
|
|
88
159
|
|
|
89
160
|
it('does not treat a wildcard project as a prefix of another project', () => {
|
|
90
|
-
expect(() =>
|
|
161
|
+
expect(() => assertRepoOperationAllowed(gateway, 'OTHERS', 'demo')).toThrow(/not allowed/);
|
|
91
162
|
});
|
|
92
163
|
});
|
|
93
164
|
|
|
94
165
|
describe('assertTargetRefMergeAllowed', () => {
|
|
95
|
-
const unrestricted = { enabled: true, repos: ['PROJ/demo'], targetRefs: [] };
|
|
96
|
-
const restricted = {
|
|
166
|
+
const unrestricted: RepoGateway = { enabled: true, repos: ['PROJ/demo'], targetRefs: [], policy: MERGE_POLICY };
|
|
167
|
+
const restricted: RepoGateway = {
|
|
97
168
|
enabled: true,
|
|
98
169
|
repos: ['PROJ/demo'],
|
|
99
170
|
targetRefs: ['refs/heads/develop', 'refs/heads/release/*'],
|
|
171
|
+
policy: MERGE_POLICY,
|
|
100
172
|
};
|
|
101
173
|
|
|
102
174
|
it('allows any ref when no target-ref restriction is configured', () => {
|
package/src/bitbucket-service.ts
CHANGED
|
@@ -5,7 +5,14 @@ import { handleApiOperation, resolveOpenApiBase } from '@atlassian-dc-mcp/common
|
|
|
5
5
|
import { simplifyInboxPullRequests } from './inbox-pr-mapper.js';
|
|
6
6
|
import { CompareDiffResponse, formatCompareDiffAsUnified } from './compare-diff-mapper.js';
|
|
7
7
|
import { BITBUCKET_PRODUCT, getDefaultPageSize, getMissingConfig } from './config.js';
|
|
8
|
+
import { fetchPullRequestBuilds } from './pr-builds.js';
|
|
8
9
|
import { fetchMergeability, mergePullRequest, type MergePullRequestParams } from './pr-merge.js';
|
|
10
|
+
import {
|
|
11
|
+
declinePullRequest,
|
|
12
|
+
reopenPullRequest,
|
|
13
|
+
type DeclinePullRequestParams,
|
|
14
|
+
type ReopenPullRequestParams,
|
|
15
|
+
} from './pr-state.js';
|
|
9
16
|
import {
|
|
10
17
|
BitbucketMutationOutputMode,
|
|
11
18
|
BitbucketOutputMode,
|
|
@@ -581,6 +588,17 @@ export class BitbucketService {
|
|
|
581
588
|
return fetchMergeability(projectKey.toUpperCase(), repositorySlug.toLowerCase(), pullRequestId);
|
|
582
589
|
}
|
|
583
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Get the CI build statuses reported on a pull request's latest source commit. Read-only.
|
|
593
|
+
* @param projectKey The project key
|
|
594
|
+
* @param repositorySlug The repository slug
|
|
595
|
+
* @param pullRequestId The pull request ID
|
|
596
|
+
* @returns Promise with the commit id, per-state counts and the build statuses
|
|
597
|
+
*/
|
|
598
|
+
async getPullRequestBuilds(projectKey: string, repositorySlug: string, pullRequestId: string) {
|
|
599
|
+
return fetchPullRequestBuilds(projectKey.toUpperCase(), repositorySlug.toLowerCase(), pullRequestId);
|
|
600
|
+
}
|
|
601
|
+
|
|
584
602
|
/**
|
|
585
603
|
* Merge a pull request. Only permitted for repositories (and target branches) the operator
|
|
586
604
|
* allowed through the merge gateway; see merge-gateway.ts. A conflicted or vetoed pull
|
|
@@ -599,6 +617,35 @@ export class BitbucketService {
|
|
|
599
617
|
});
|
|
600
618
|
}
|
|
601
619
|
|
|
620
|
+
/**
|
|
621
|
+
* Decline a pull request. Only permitted for repositories the operator allowed through the
|
|
622
|
+
* decline gateway; see repo-gateway.ts.
|
|
623
|
+
* @param params.version The current pull request version, required for optimistic locking
|
|
624
|
+
* @param params.comment Optional reason, posted as a pull request comment by the same request
|
|
625
|
+
* @param params.gateway The resolved operator decline policy
|
|
626
|
+
* @returns Promise with the declined pull request
|
|
627
|
+
*/
|
|
628
|
+
async declinePullRequest(params: DeclinePullRequestParams) {
|
|
629
|
+
return declinePullRequest({
|
|
630
|
+
...params,
|
|
631
|
+
projectKey: params.projectKey.toUpperCase(),
|
|
632
|
+
repositorySlug: params.repositorySlug.toLowerCase(),
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Reopen a declined pull request. Not gated: it restores the state a decline removed.
|
|
638
|
+
* @param params.version The current pull request version, required for optimistic locking
|
|
639
|
+
* @returns Promise with the reopened pull request
|
|
640
|
+
*/
|
|
641
|
+
async reopenPullRequest(params: ReopenPullRequestParams) {
|
|
642
|
+
return reopenPullRequest({
|
|
643
|
+
...params,
|
|
644
|
+
projectKey: params.projectKey.toUpperCase(),
|
|
645
|
+
repositorySlug: params.repositorySlug.toLowerCase(),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
602
649
|
/**
|
|
603
650
|
* Get a user by slug, or search for users by name/email filter
|
|
604
651
|
* @param userSlug Optional exact slug to look up a specific user
|
|
@@ -1259,6 +1306,11 @@ export const bitbucketToolSchemas = {
|
|
|
1259
1306
|
repositorySlug: z.string().describe("The repository slug"),
|
|
1260
1307
|
pullRequestId: z.string().describe("The pull request ID")
|
|
1261
1308
|
},
|
|
1309
|
+
getPullRequestBuilds: {
|
|
1310
|
+
projectKey: z.string().describe("The project key"),
|
|
1311
|
+
repositorySlug: z.string().describe("The repository slug"),
|
|
1312
|
+
pullRequestId: z.string().describe("The pull request ID")
|
|
1313
|
+
},
|
|
1262
1314
|
mergePullRequest: {
|
|
1263
1315
|
projectKey: z.string().describe("The project key"),
|
|
1264
1316
|
repositorySlug: z.string().describe("The repository slug"),
|
|
@@ -1268,6 +1320,21 @@ export const bitbucketToolSchemas = {
|
|
|
1268
1320
|
message: z.string().optional().describe("Commit message for the merge commit. Omit to let Bitbucket generate it."),
|
|
1269
1321
|
output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
|
|
1270
1322
|
},
|
|
1323
|
+
declinePullRequest: {
|
|
1324
|
+
projectKey: z.string().describe("The project key"),
|
|
1325
|
+
repositorySlug: z.string().describe("The repository slug"),
|
|
1326
|
+
pullRequestId: z.string().describe("The pull request ID"),
|
|
1327
|
+
version: z.number().describe("The current version of the pull request (required for optimistic locking). Fetch it with bitbucket_getPullRequest immediately before declining; a stale version is rejected with a 409, in which case refetch the version and retry once."),
|
|
1328
|
+
comment: z.string().optional().describe("Reason for declining, posted as a pull request comment by the same request. Prefer this over posting a separate comment first: sending both together means a rejected decline leaves no orphaned 'why this was abandoned' comment on a pull request that stayed open."),
|
|
1329
|
+
output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
|
|
1330
|
+
},
|
|
1331
|
+
reopenPullRequest: {
|
|
1332
|
+
projectKey: z.string().describe("The project key"),
|
|
1333
|
+
repositorySlug: z.string().describe("The repository slug"),
|
|
1334
|
+
pullRequestId: z.string().describe("The pull request ID"),
|
|
1335
|
+
version: z.number().describe("The current version of the pull request (required for optimistic locking). Fetch it with bitbucket_getPullRequest immediately before reopening; a stale version is rejected with a 409, in which case refetch the version and retry once."),
|
|
1336
|
+
output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
|
|
1337
|
+
},
|
|
1271
1338
|
getRequiredReviewers: {
|
|
1272
1339
|
projectKey: z.string().describe("The project key"),
|
|
1273
1340
|
repositorySlug: z.string().describe("The repository slug"),
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { connectServer, createMcpServer, formatToolResponse, initializeRuntimeConfig } from '@atlassian-dc-mcp/common';
|
|
2
2
|
import { BitbucketService, bitbucketToolSchemas } from './bitbucket-service.js';
|
|
3
3
|
import { getBitbucketRuntimeConfig, getDefaultPageSize } from './config.js';
|
|
4
|
-
import { resolveMergeGateway } from './
|
|
4
|
+
import { resolveDeclineGateway, resolveMergeGateway } from './repo-gateway.js';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
@@ -229,6 +229,16 @@ server.tool(
|
|
|
229
229
|
}
|
|
230
230
|
);
|
|
231
231
|
|
|
232
|
+
server.tool(
|
|
233
|
+
"bitbucket_getPullRequestBuilds",
|
|
234
|
+
"Get the CI/CD build statuses of a pull request — the builds, jobs or pipelines that any CI server integrated with Bitbucket (Jenkins, Bamboo, TeamCity or any other tool posting build statuses) reported on the latest commit of its source branch, as shown in the pull request's Builds panel. Use it to check whether a pull request's checks passed, failed or are still running, e.g. after a push. Read-only. Returns the commit id, counts per state, and for each build its state (SUCCESSFUL, FAILED, INPROGRESS, CANCELLED, UNKNOWN), name, url of the run in the CI server and description.",
|
|
235
|
+
bitbucketToolSchemas.getPullRequestBuilds,
|
|
236
|
+
async ({ projectKey, repositorySlug, pullRequestId }) => {
|
|
237
|
+
const result = await bitbucketService.getPullRequestBuilds(projectKey, repositorySlug, pullRequestId);
|
|
238
|
+
return formatToolResponse(result);
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
|
|
232
242
|
const mergeGateway = resolveMergeGateway();
|
|
233
243
|
|
|
234
244
|
// Merging lands code on a shared branch and cannot be undone through the API, so the tool is
|
|
@@ -257,6 +267,47 @@ if (mergeGateway.enabled) {
|
|
|
257
267
|
);
|
|
258
268
|
}
|
|
259
269
|
|
|
270
|
+
const declineGateway = resolveDeclineGateway();
|
|
271
|
+
|
|
272
|
+
// Declining is reviewer-visible the moment it lands and notifies everyone on the pull request, so
|
|
273
|
+
// it is registered only for the repositories the operator named. Reopening is the undo path and is
|
|
274
|
+
// always available.
|
|
275
|
+
if (declineGateway.enabled) {
|
|
276
|
+
server.tool(
|
|
277
|
+
"bitbucket_declinePullRequest",
|
|
278
|
+
`Decline (close without merging) a pull request. IMPORTANT: you MUST first call bitbucket_getPullRequest to get the current 'version' — it is required for optimistic locking and a stale version is rejected. Pass the reason as 'comment' so it is posted with the decline in a single request. Reviewers are notified; undo with bitbucket_reopenPullRequest. Allowed on this server: ${declineGateway.repos.join(', ')}.`,
|
|
279
|
+
bitbucketToolSchemas.declinePullRequest,
|
|
280
|
+
async ({ projectKey, repositorySlug, pullRequestId, version, comment, output }) => {
|
|
281
|
+
const result = await bitbucketService.declinePullRequest({
|
|
282
|
+
projectKey,
|
|
283
|
+
repositorySlug,
|
|
284
|
+
pullRequestId,
|
|
285
|
+
version,
|
|
286
|
+
comment,
|
|
287
|
+
output,
|
|
288
|
+
gateway: declineGateway,
|
|
289
|
+
});
|
|
290
|
+
return formatToolResponse(result);
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
server.tool(
|
|
296
|
+
"bitbucket_reopenPullRequest",
|
|
297
|
+
"Reopen a declined pull request, restoring it to OPEN. IMPORTANT: you MUST first call bitbucket_getPullRequest to get the current 'version' — it is required for optimistic locking and a stale version is rejected. The pull request must be in the DECLINED state; anything else is refused.",
|
|
298
|
+
bitbucketToolSchemas.reopenPullRequest,
|
|
299
|
+
async ({ projectKey, repositorySlug, pullRequestId, version, output }) => {
|
|
300
|
+
const result = await bitbucketService.reopenPullRequest({
|
|
301
|
+
projectKey,
|
|
302
|
+
repositorySlug,
|
|
303
|
+
pullRequestId,
|
|
304
|
+
version,
|
|
305
|
+
output,
|
|
306
|
+
});
|
|
307
|
+
return formatToolResponse(result);
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
|
|
260
311
|
server.tool(
|
|
261
312
|
"bitbucket_getRequiredReviewers",
|
|
262
313
|
"Get required reviewers for pull request creation. Returns a set of users who are required reviewers for pull requests created from the given source repository and ref to the given target ref in this repository.",
|
package/src/pr-builds.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { handleApiOperation } from '@atlassian-dc-mcp/common';
|
|
2
|
+
import { DeprecatedService, PullRequestsService } from './bitbucket-client/index.js';
|
|
3
|
+
|
|
4
|
+
/** The commit build-status resource keeps only the 100 most recent statuses per commit. */
|
|
5
|
+
const MAX_BUILD_STATUSES = 100;
|
|
6
|
+
|
|
7
|
+
type BuildState = 'SUCCESSFUL' | 'FAILED' | 'INPROGRESS' | 'CANCELLED' | 'UNKNOWN';
|
|
8
|
+
|
|
9
|
+
export interface BuildStatus {
|
|
10
|
+
key?: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
state?: BuildState;
|
|
13
|
+
url?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
dateAdded?: number;
|
|
16
|
+
duration?: number;
|
|
17
|
+
buildNumber?: string;
|
|
18
|
+
testResults?: { successful?: number; failed?: number; skipped?: number };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BuildCounts {
|
|
22
|
+
successful: number;
|
|
23
|
+
failed: number;
|
|
24
|
+
inProgress: number;
|
|
25
|
+
cancelled: number;
|
|
26
|
+
unknown: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PullRequestBuilds {
|
|
30
|
+
commitId: string;
|
|
31
|
+
counts: BuildCounts;
|
|
32
|
+
builds: BuildStatus[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const COUNT_FIELD: Record<BuildState, keyof BuildCounts> = {
|
|
36
|
+
SUCCESSFUL: 'successful',
|
|
37
|
+
FAILED: 'failed',
|
|
38
|
+
INPROGRESS: 'inProgress',
|
|
39
|
+
CANCELLED: 'cancelled',
|
|
40
|
+
UNKNOWN: 'unknown',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function countBuilds(builds: BuildStatus[]): BuildCounts {
|
|
44
|
+
const counts: BuildCounts = { successful: 0, failed: 0, inProgress: 0, cancelled: 0, unknown: 0 };
|
|
45
|
+
for (const build of builds) {
|
|
46
|
+
counts[COUNT_FIELD[build.state ?? 'UNKNOWN'] ?? 'unknown'] += 1;
|
|
47
|
+
}
|
|
48
|
+
return counts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function fetchLatestSourceCommit(projectKey: string, repositorySlug: string, pullRequestId: string): Promise<string> {
|
|
52
|
+
const pullRequest = await PullRequestsService.get3(projectKey, pullRequestId, repositorySlug) as {
|
|
53
|
+
fromRef?: { latestCommit?: string };
|
|
54
|
+
};
|
|
55
|
+
const latestCommit = pullRequest?.fromRef?.latestCommit;
|
|
56
|
+
if (!latestCommit) {
|
|
57
|
+
throw new Error(`Pull request ${pullRequestId} has no source commit`);
|
|
58
|
+
}
|
|
59
|
+
return latestCommit;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build statuses on the pull request's latest source commit — what the pull request's Builds
|
|
64
|
+
* panel shows. Uses the commit-level build-status resource because the repository-scoped one
|
|
65
|
+
* only returns a single status by key and cannot list them.
|
|
66
|
+
*/
|
|
67
|
+
export async function fetchPullRequestBuilds(projectKey: string, repositorySlug: string, pullRequestId: string) {
|
|
68
|
+
return handleApiOperation<PullRequestBuilds>(async () => {
|
|
69
|
+
const commitId = await fetchLatestSourceCommit(projectKey, repositorySlug, pullRequestId);
|
|
70
|
+
const page = await DeprecatedService.getBuildStatus(commitId, 'NEWEST', 0, MAX_BUILD_STATUSES);
|
|
71
|
+
const builds = (page?.values ?? []) as BuildStatus[];
|
|
72
|
+
return { commitId, counts: countBuilds(builds), builds };
|
|
73
|
+
}, 'Error fetching pull request builds');
|
|
74
|
+
}
|
package/src/pr-merge.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
shapeMergeability,
|
|
6
6
|
shapePullRequestAck,
|
|
7
7
|
} from './bitbucket-response-mapper.js';
|
|
8
|
-
import {
|
|
8
|
+
import { assertRepoOperationAllowed, assertTargetRefMergeAllowed, type RepoGateway } from './repo-gateway.js';
|
|
9
9
|
|
|
10
10
|
export interface MergePullRequestParams {
|
|
11
11
|
projectKey: string;
|
|
@@ -13,7 +13,7 @@ export interface MergePullRequestParams {
|
|
|
13
13
|
pullRequestId: string;
|
|
14
14
|
/** Current PR version, required for optimistic locking. */
|
|
15
15
|
version: number;
|
|
16
|
-
gateway:
|
|
16
|
+
gateway: RepoGateway;
|
|
17
17
|
strategyId?: string;
|
|
18
18
|
message?: string;
|
|
19
19
|
output?: BitbucketMutationOutputMode;
|
|
@@ -75,7 +75,7 @@ export async function mergePullRequest(params: MergePullRequestParams) {
|
|
|
75
75
|
const { projectKey, repositorySlug, pullRequestId, version, gateway } = params;
|
|
76
76
|
|
|
77
77
|
const result = await handleApiOperation(async () => {
|
|
78
|
-
|
|
78
|
+
assertRepoOperationAllowed(gateway, projectKey, repositorySlug);
|
|
79
79
|
await assertTargetRefAllowed(params);
|
|
80
80
|
await assertMergeable(projectKey, repositorySlug, pullRequestId);
|
|
81
81
|
|
package/src/pr-state.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { handleApiOperation } from '@atlassian-dc-mcp/common';
|
|
2
|
+
import { PullRequestsService } from './bitbucket-client/index.js';
|
|
3
|
+
import { BitbucketMutationOutputMode, shapePullRequestAck } from './bitbucket-response-mapper.js';
|
|
4
|
+
import { assertRepoOperationAllowed, type RepoGateway } from './repo-gateway.js';
|
|
5
|
+
|
|
6
|
+
interface PullRequestStateParams {
|
|
7
|
+
projectKey: string;
|
|
8
|
+
repositorySlug: string;
|
|
9
|
+
pullRequestId: string;
|
|
10
|
+
/** Current PR version, required for optimistic locking. */
|
|
11
|
+
version: number;
|
|
12
|
+
output?: BitbucketMutationOutputMode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DeclinePullRequestParams extends PullRequestStateParams {
|
|
16
|
+
gateway: RepoGateway;
|
|
17
|
+
/**
|
|
18
|
+
* Reason for declining, posted as a pull request comment by the same request. Sending it with
|
|
19
|
+
* the decline keeps the two atomic: a rejected decline leaves no orphaned "why this was
|
|
20
|
+
* abandoned" comment on a pull request that stayed open.
|
|
21
|
+
*/
|
|
22
|
+
comment?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ReopenPullRequestParams = PullRequestStateParams;
|
|
26
|
+
|
|
27
|
+
/** The ack carries the new version, which is what a later reopen needs as its optimistic lock. */
|
|
28
|
+
function shapeResult<T extends { success: boolean; data?: unknown }>(result: T, output?: BitbucketMutationOutputMode) {
|
|
29
|
+
if (result.success && result.data && output !== 'full') {
|
|
30
|
+
return { ...result, data: shapePullRequestAck(result.data) };
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Decline a pull request under the operator's decline policy. The repository is checked before
|
|
37
|
+
* any request is issued. Bitbucket rejects a stale version and a pull request that is not OPEN
|
|
38
|
+
* with a 409, so no pre-flight read is needed.
|
|
39
|
+
*/
|
|
40
|
+
export async function declinePullRequest(params: DeclinePullRequestParams) {
|
|
41
|
+
const { projectKey, repositorySlug, pullRequestId, version, gateway, comment } = params;
|
|
42
|
+
|
|
43
|
+
const result = await handleApiOperation(async () => {
|
|
44
|
+
assertRepoOperationAllowed(gateway, projectKey, repositorySlug);
|
|
45
|
+
|
|
46
|
+
return PullRequestsService.decline(projectKey, pullRequestId, repositorySlug, String(version), {
|
|
47
|
+
version,
|
|
48
|
+
...(comment ? { comment } : {}),
|
|
49
|
+
});
|
|
50
|
+
}, 'Error declining pull request');
|
|
51
|
+
|
|
52
|
+
return shapeResult(result, params.output);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reopen a declined pull request. Ungated: it restores the state a decline removed, so it is the
|
|
57
|
+
* undo path rather than a destructive one. A pull request that is not declined is rejected with a 409.
|
|
58
|
+
*/
|
|
59
|
+
export async function reopenPullRequest(params: ReopenPullRequestParams) {
|
|
60
|
+
const { projectKey, repositorySlug, pullRequestId, version } = params;
|
|
61
|
+
|
|
62
|
+
const result = await handleApiOperation(
|
|
63
|
+
() => PullRequestsService.reopen(projectKey, pullRequestId, repositorySlug, String(version), { version }),
|
|
64
|
+
'Error reopening pull request',
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return shapeResult(result, params.output);
|
|
68
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator-controlled gate for pull request operations that change state on a shared branch
|
|
3
|
+
* or in front of other people.
|
|
4
|
+
*
|
|
5
|
+
* A merge cannot be undone through the API, and a decline is visible to every reviewer the
|
|
6
|
+
* moment it lands, so both are disabled by default: the tool is not even registered unless
|
|
7
|
+
* the operator enables it and names the repositories it may act on. Each operation reads its
|
|
8
|
+
* own environment variables, so allowing declines never implies allowing merges. The scope is
|
|
9
|
+
* read once at startup, so the model can never select or widen it. Read-only checks are not
|
|
10
|
+
* gated.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Names an operation in refusal messages and points the operator at the variables that open it. */
|
|
14
|
+
export interface GatewayPolicy {
|
|
15
|
+
/** Short operation name, used to tag warnings. */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Gerund that starts a refusal message, e.g. "Merging". */
|
|
18
|
+
gerund: string;
|
|
19
|
+
flagVar: string;
|
|
20
|
+
reposVar: string;
|
|
21
|
+
/** Omitted for operations that have no meaningful target-branch restriction. */
|
|
22
|
+
targetRefsVar?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const MERGE_POLICY: GatewayPolicy = {
|
|
26
|
+
name: 'merge',
|
|
27
|
+
gerund: 'Merging',
|
|
28
|
+
flagVar: 'BITBUCKET_MERGE_ENABLED',
|
|
29
|
+
reposVar: 'BITBUCKET_MERGE_ALLOWED_REPOS',
|
|
30
|
+
targetRefsVar: 'BITBUCKET_MERGE_ALLOWED_TARGET_REFS',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Declining has no target-ref restriction: which branch a pull request would have landed on
|
|
35
|
+
* says nothing about whether abandoning it is safe.
|
|
36
|
+
*/
|
|
37
|
+
export const DECLINE_POLICY: GatewayPolicy = {
|
|
38
|
+
name: 'decline',
|
|
39
|
+
gerund: 'Declining',
|
|
40
|
+
flagVar: 'BITBUCKET_DECLINE_ENABLED',
|
|
41
|
+
reposVar: 'BITBUCKET_DECLINE_ALLOWED_REPOS',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface RepoGateway {
|
|
45
|
+
/** Whether the operation is enabled and at least one valid repository pattern resolved. */
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
/** Allowed `PROJECT/repository-slug` targets; a `PROJECT/*` entry allows the whole project. */
|
|
48
|
+
repos: string[];
|
|
49
|
+
/** Allowed target refs; a trailing `*` matches a prefix. Empty means any ref in an allowed repository. */
|
|
50
|
+
targetRefs: string[];
|
|
51
|
+
policy: GatewayPolicy;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Env = Record<string, string | undefined>;
|
|
55
|
+
type Warn = (message: string) => void;
|
|
56
|
+
|
|
57
|
+
export interface ResolveGatewayOptions {
|
|
58
|
+
env?: Env;
|
|
59
|
+
warn?: Warn;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const REPO_ENTRY_RE = /^[^\s/]+\/[^\s/]+$/;
|
|
63
|
+
|
|
64
|
+
function readBool(env: Env, name: string): boolean {
|
|
65
|
+
const value = env[name]?.trim().toLowerCase();
|
|
66
|
+
return value === 'true' || value === '1' || value === 'yes';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseList(raw: string | undefined): string[] {
|
|
70
|
+
return (raw ?? '').split(/[,;\s]+/).map(entry => entry.trim()).filter(Boolean);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function dedupe(values: string[]): string[] {
|
|
74
|
+
return [...new Set(values)];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Accepts `PROJECT/repository-slug` or `PROJECT/*`. The project key is upper-cased and
|
|
79
|
+
* the slug lower-cased to match the casing the REST API uses, so comparisons are exact.
|
|
80
|
+
*/
|
|
81
|
+
function normalizeRepoEntry(entry: string, warn: Warn): string | undefined {
|
|
82
|
+
if (!REPO_ENTRY_RE.test(entry)) {
|
|
83
|
+
warn(`Ignoring repository entry that is not "PROJECT/repository-slug" or "PROJECT/*": "${entry}"`);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const [projectKey, slug] = entry.split('/');
|
|
87
|
+
if (projectKey === '*') {
|
|
88
|
+
warn(`Ignoring repository entry that would allow every project: "${entry}"`);
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
return `${projectKey.toUpperCase()}/${slug.toLowerCase()}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Bare branch names are expanded so operators can write `develop` instead of `refs/heads/develop`. */
|
|
95
|
+
function normalizeRefEntry(entry: string): string {
|
|
96
|
+
return entry.startsWith('refs/') ? entry : `refs/heads/${entry}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function matchesPattern(value: string, pattern: string): boolean {
|
|
100
|
+
return pattern.endsWith('*') ? value.startsWith(pattern.slice(0, -1)) : value === pattern;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function disabledGateway(policy: GatewayPolicy): RepoGateway {
|
|
104
|
+
return { enabled: false, repos: [], targetRefs: [], policy };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reads one operation's gateway configuration from the environment. The operation only
|
|
109
|
+
* activates when its flag is set and at least one repository entry is valid; otherwise a
|
|
110
|
+
* warning is logged and the gateway stays disabled.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveGateway(policy: GatewayPolicy, options?: ResolveGatewayOptions): RepoGateway {
|
|
113
|
+
const env = options?.env ?? process.env;
|
|
114
|
+
const warn = options?.warn ?? ((message: string) => console.error(`[${policy.name}-gateway] ${message}`));
|
|
115
|
+
|
|
116
|
+
if (!readBool(env, policy.flagVar)) {
|
|
117
|
+
return disabledGateway(policy);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const repos = dedupe(
|
|
121
|
+
parseList(env[policy.reposVar])
|
|
122
|
+
.map(entry => normalizeRepoEntry(entry, warn))
|
|
123
|
+
.filter((entry): entry is string => Boolean(entry)),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (repos.length === 0) {
|
|
127
|
+
warn(
|
|
128
|
+
`${policy.gerund} was enabled but no valid repository is configured (set ${policy.reposVar} ` +
|
|
129
|
+
'to a list of "PROJECT/repository-slug" or "PROJECT/*" entries); the tool will stay disabled.',
|
|
130
|
+
);
|
|
131
|
+
return disabledGateway(policy);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const targetRefs = policy.targetRefsVar
|
|
135
|
+
? dedupe(parseList(env[policy.targetRefsVar]).map(normalizeRefEntry))
|
|
136
|
+
: [];
|
|
137
|
+
|
|
138
|
+
return { enabled: true, repos, targetRefs, policy };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function resolveMergeGateway(options?: ResolveGatewayOptions): RepoGateway {
|
|
142
|
+
return resolveGateway(MERGE_POLICY, options);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function resolveDeclineGateway(options?: ResolveGatewayOptions): RepoGateway {
|
|
146
|
+
return resolveGateway(DECLINE_POLICY, options);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Throws unless the gateway allows its operation in this repository. No network call. */
|
|
150
|
+
export function assertRepoOperationAllowed(gateway: RepoGateway, projectKey: string, repositorySlug: string): void {
|
|
151
|
+
const { policy } = gateway;
|
|
152
|
+
if (!gateway.enabled) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`${policy.gerund} pull requests is disabled on this server. Enable it with ${policy.flagVar} ` +
|
|
155
|
+
`and list the allowed repositories in ${policy.reposVar}.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const target = `${projectKey.toUpperCase()}/${repositorySlug.toLowerCase()}`;
|
|
159
|
+
if (!gateway.repos.some(pattern => matchesPattern(target, pattern))) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`${policy.gerund} is not allowed in ${target} on this server. Allowed: ${gateway.repos.join(', ')}.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Throws unless the gateway allows merging into this target ref. A gateway with no ref restriction allows all. */
|
|
167
|
+
export function assertTargetRefMergeAllowed(gateway: RepoGateway, targetRefId: string | undefined): void {
|
|
168
|
+
if (gateway.targetRefs.length === 0) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (!targetRefId) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
'Could not determine the target branch of the pull request, and this server restricts which ' +
|
|
174
|
+
`branches may be merged into (${gateway.policy.targetRefsVar}); refusing to merge.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (!gateway.targetRefs.some(pattern => matchesPattern(targetRefId, pattern))) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Merging into ${targetRefId} is not allowed on this server. ` +
|
|
180
|
+
`Allowed target refs: ${gateway.targetRefs.join(', ')}.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|