@stubbedev/atlassian-mcp 0.1.19 → 0.2.1
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/dist/bitbucket.js +171 -6
- package/dist/config.js +24 -17
- package/dist/context.js +35 -7
- package/dist/git.js +106 -3
- package/dist/index.js +631 -741
- package/dist/jira.js +72 -1
- package/package.json +12 -7
package/dist/bitbucket.js
CHANGED
|
@@ -197,11 +197,29 @@ export class BitbucketClient {
|
|
|
197
197
|
}
|
|
198
198
|
return `${this.baseUrl}/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}/pull-requests/${prId}`;
|
|
199
199
|
}
|
|
200
|
+
configuredHostname() {
|
|
201
|
+
try {
|
|
202
|
+
return new URL(this.baseUrl).hostname.toLowerCase();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return '';
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
remoteMatchesInstance(remote) {
|
|
209
|
+
const host = this.configuredHostname();
|
|
210
|
+
if (!host)
|
|
211
|
+
return true; // can't validate, allow
|
|
212
|
+
return remote.toLowerCase().includes(host);
|
|
213
|
+
}
|
|
200
214
|
resolveProjectAndRepo(projectKey, repoSlug) {
|
|
201
215
|
if (projectKey && repoSlug)
|
|
202
216
|
return { projectKey, repoSlug };
|
|
203
217
|
const remote = safeExec('git remote get-url origin');
|
|
204
218
|
if (remote) {
|
|
219
|
+
if (!this.remoteMatchesInstance(remote)) {
|
|
220
|
+
throw new Error(`This repo's remote does not point to your configured Bitbucket instance (${this.baseUrl}). ` +
|
|
221
|
+
`Bitbucket tools only work with repos hosted on that instance.`);
|
|
222
|
+
}
|
|
205
223
|
const parsed = parseBitbucketRemote(remote);
|
|
206
224
|
if (parsed) {
|
|
207
225
|
return {
|
|
@@ -210,7 +228,7 @@ export class BitbucketClient {
|
|
|
210
228
|
};
|
|
211
229
|
}
|
|
212
230
|
}
|
|
213
|
-
throw new Error('Could not determine projectKey/repoSlug — provide them explicitly or run from a directory
|
|
231
|
+
throw new Error('Could not determine projectKey/repoSlug — provide them explicitly or run from a directory with a Bitbucket remote');
|
|
214
232
|
}
|
|
215
233
|
async request(method, path, body) {
|
|
216
234
|
const url = `${this.baseUrl}/rest/api/1.0${path}`;
|
|
@@ -238,6 +256,21 @@ export class BitbucketClient {
|
|
|
238
256
|
}
|
|
239
257
|
return res.text();
|
|
240
258
|
}
|
|
259
|
+
async requestBuildStatus(method, path, body) {
|
|
260
|
+
const url = `${this.baseUrl}/rest/build-status/1.0${path}`;
|
|
261
|
+
const opts = { method, headers: this.headers };
|
|
262
|
+
if (body !== undefined)
|
|
263
|
+
opts.body = JSON.stringify(body);
|
|
264
|
+
const res = await fetch(url, opts);
|
|
265
|
+
if (res.status === 404)
|
|
266
|
+
return null; // no build status yet
|
|
267
|
+
if (!res.ok) {
|
|
268
|
+
const errText = await res.text();
|
|
269
|
+
const details = parseBitbucketErrorDetails(errText);
|
|
270
|
+
throw new Error(formatBitbucketError(res.status, method, path, details));
|
|
271
|
+
}
|
|
272
|
+
return res.status === 204 ? null : res.json();
|
|
273
|
+
}
|
|
241
274
|
normalizeIdentity(value) {
|
|
242
275
|
return (value ?? '').trim().toLowerCase();
|
|
243
276
|
}
|
|
@@ -270,6 +303,10 @@ export class BitbucketClient {
|
|
|
270
303
|
}
|
|
271
304
|
throw new Error(`You can only edit your own Bitbucket comments. Comment #${comment.id} is authored by ${comment.author?.displayName ?? comment.author?.name ?? 'another user'}.`);
|
|
272
305
|
}
|
|
306
|
+
/** Returns true if the given remote URL belongs to this Bitbucket instance. */
|
|
307
|
+
isRemoteForThisInstance(remoteUrl) {
|
|
308
|
+
return this.remoteMatchesInstance(remoteUrl);
|
|
309
|
+
}
|
|
273
310
|
// Used internally by context tools — finds the open PR for a given source branch
|
|
274
311
|
async findOpenPrForBranch(projectKey, repoSlug, branch) {
|
|
275
312
|
const targetBranch = branchDisplayId(branch);
|
|
@@ -352,12 +389,40 @@ export class BitbucketClient {
|
|
|
352
389
|
const includeCommits = args.includeCommits ?? true;
|
|
353
390
|
const includeComments = args.includeComments ?? true;
|
|
354
391
|
const includeDiff = args.includeDiff ?? false;
|
|
355
|
-
const
|
|
392
|
+
const includeBuildStatus = args.includeBuildStatus ?? true;
|
|
393
|
+
let prId = args.prId;
|
|
394
|
+
if (prId === undefined) {
|
|
395
|
+
const branch = args.fromBranch ?? safeExec('git rev-parse --abbrev-ref HEAD');
|
|
396
|
+
if (!branch || branch === 'HEAD') {
|
|
397
|
+
throw new Error('Provide prId or fromBranch, or run from a checked-out branch.');
|
|
398
|
+
}
|
|
399
|
+
const found = await this.findOpenPrForBranch(projectKey, repoSlug, branch);
|
|
400
|
+
if (!found)
|
|
401
|
+
throw new Error(`No open PR found for branch "${branchDisplayId(branch)}".`);
|
|
402
|
+
prId = found.id;
|
|
403
|
+
}
|
|
404
|
+
const [pr, me] = await Promise.all([
|
|
405
|
+
this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}`),
|
|
406
|
+
this.getCurrentUser().catch(() => null),
|
|
407
|
+
]);
|
|
356
408
|
if (!pr)
|
|
357
409
|
return text('Pull request not found.');
|
|
358
410
|
const sections = [];
|
|
359
411
|
const reviewers = pr.reviewers.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`).join(', ');
|
|
360
412
|
const url = pr.links?.self?.[0]?.href;
|
|
413
|
+
// Prefer slug (canonical unique identifier), fall back to username (name field).
|
|
414
|
+
// Display name is deliberately excluded — it is not unique.
|
|
415
|
+
const meSlug = this.normalizeIdentity(me?.slug);
|
|
416
|
+
const meName = this.normalizeIdentity(me?.name);
|
|
417
|
+
const authorSlug = this.normalizeIdentity(pr.author.user.slug);
|
|
418
|
+
const authorName = this.normalizeIdentity(pr.author.user.name);
|
|
419
|
+
const isAuthor = me
|
|
420
|
+
? (meSlug && authorSlug ? meSlug === authorSlug : false) ||
|
|
421
|
+
(meName && authorName ? meName === authorName : false)
|
|
422
|
+
: false;
|
|
423
|
+
const viewingAs = me
|
|
424
|
+
? `Viewing as: ${me.displayName} (${isAuthor ? 'you are the author' : 'you are a reviewer'})`
|
|
425
|
+
: '';
|
|
361
426
|
const header = [
|
|
362
427
|
`PR #${pr.id}: ${pr.title}`,
|
|
363
428
|
`State: ${pr.state}`,
|
|
@@ -365,15 +430,29 @@ export class BitbucketClient {
|
|
|
365
430
|
`Branch: ${pr.fromRef.displayId} → ${pr.toRef.displayId}`,
|
|
366
431
|
`Reviewers: ${reviewers || 'None'}`,
|
|
367
432
|
url ? `URL: ${url}` : '',
|
|
433
|
+
viewingAs,
|
|
368
434
|
'',
|
|
369
435
|
'Description:',
|
|
370
436
|
pr.description ?? '(no description)',
|
|
371
437
|
].filter((line) => line !== '');
|
|
372
438
|
sections.push(header.join('\n'));
|
|
439
|
+
if (includeBuildStatus && pr.fromRef.latestCommit) {
|
|
440
|
+
const statuses = await this.requestBuildStatus('GET', `/commits/${pr.fromRef.latestCommit}`).catch(() => null);
|
|
441
|
+
if (statuses?.values?.length) {
|
|
442
|
+
const statusLines = statuses.values.map((s) => {
|
|
443
|
+
const icon = s.state === 'SUCCESSFUL' ? '✓' : s.state === 'FAILED' ? '✗' : '…';
|
|
444
|
+
return `${icon} [${s.state}] ${s.name ?? s.key}${s.description ? ` — ${s.description}` : ''}`;
|
|
445
|
+
});
|
|
446
|
+
sections.push(`Build status (${pr.fromRef.latestCommit.slice(0, 8)}):\n${statusLines.join('\n')}`);
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
sections.push(`Build status: none reported for ${pr.fromRef.latestCommit.slice(0, 8)}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
373
452
|
if (includeCommits) {
|
|
374
453
|
const commitsLimit = args.commitsLimit ?? 25;
|
|
375
454
|
const commitsStart = args.commitsStart ?? 0;
|
|
376
|
-
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${
|
|
455
|
+
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/commits?limit=${commitsLimit}&start=${commitsStart}`);
|
|
377
456
|
if (!data || data.values.length === 0) {
|
|
378
457
|
sections.push('Commits:\n(no commits found)');
|
|
379
458
|
}
|
|
@@ -392,7 +471,7 @@ export class BitbucketClient {
|
|
|
392
471
|
}
|
|
393
472
|
if (commentsSeverity === 'BLOCKER') {
|
|
394
473
|
const qs = new URLSearchParams({ limit: String(commentsLimit), start: String(commentsStart), state: commentsState });
|
|
395
|
-
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${
|
|
474
|
+
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/blocker-comments?${qs}`);
|
|
396
475
|
if (!data || data.values.length === 0) {
|
|
397
476
|
sections.push(`Comments:\n(no ${commentsState} BLOCKER comments)`);
|
|
398
477
|
}
|
|
@@ -402,7 +481,7 @@ export class BitbucketClient {
|
|
|
402
481
|
}
|
|
403
482
|
}
|
|
404
483
|
else {
|
|
405
|
-
const activityData = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${
|
|
484
|
+
const activityData = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/activities?limit=${commentsLimit}&start=${commentsStart}`);
|
|
406
485
|
const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
|
|
407
486
|
const matchesState = commentMatchesState(comment, commentsState);
|
|
408
487
|
return matchesState && commentMatchesSeverity(comment, commentsSeverity);
|
|
@@ -418,7 +497,7 @@ export class BitbucketClient {
|
|
|
418
497
|
}
|
|
419
498
|
}
|
|
420
499
|
if (includeDiff) {
|
|
421
|
-
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${
|
|
500
|
+
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/diff`);
|
|
422
501
|
sections.push(`Diff:\n${data ? formatDiff(data, args.diffMaxChars ?? 8000) : '(no diff found)'}`);
|
|
423
502
|
}
|
|
424
503
|
return text(sections.join('\n\n'));
|
|
@@ -612,6 +691,24 @@ export class BitbucketClient {
|
|
|
612
691
|
return text(`Merged PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
|
|
613
692
|
return text(`Merged PR #${data.id}: "${data.title}" (${data.fromRef.displayId} → ${data.toRef.displayId}).\n${this.pullRequestUrl(projectKey, repoSlug, data.id, data)}`);
|
|
614
693
|
}
|
|
694
|
+
async getDefaultBranchRef(projectKey, repoSlug) {
|
|
695
|
+
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/default-branch`);
|
|
696
|
+
if (data?.displayId)
|
|
697
|
+
return `refs/heads/${data.displayId}`;
|
|
698
|
+
// Fallback: detect from local git
|
|
699
|
+
const head = safeExec('git rev-parse --abbrev-ref origin/HEAD');
|
|
700
|
+
if (head.startsWith('origin/'))
|
|
701
|
+
return `refs/heads/${head.slice('origin/'.length)}`;
|
|
702
|
+
return 'refs/heads/master';
|
|
703
|
+
}
|
|
704
|
+
async createBranch(args) {
|
|
705
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
706
|
+
const startPoint = args.startPoint ?? await this.getDefaultBranchRef(projectKey, repoSlug);
|
|
707
|
+
const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/branches`, { name: args.branchName, startPoint });
|
|
708
|
+
if (!data)
|
|
709
|
+
return text(`Branch "${args.branchName}" created.`);
|
|
710
|
+
return text(`Created branch "${data.displayId}" at ${data.latestCommit.slice(0, 8)} in ${projectKey}/${repoSlug}.`);
|
|
711
|
+
}
|
|
615
712
|
async getBranches(args) {
|
|
616
713
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
617
714
|
const qs = new URLSearchParams({ limit: String(args.limit ?? 25), start: String(args.start ?? 0) });
|
|
@@ -856,4 +953,72 @@ export class BitbucketClient {
|
|
|
856
953
|
await this.request('DELETE', path);
|
|
857
954
|
return text(`Comment #${args.commentId} deleted from PR #${args.prId}.`);
|
|
858
955
|
}
|
|
956
|
+
async getPrTasks(args) {
|
|
957
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
958
|
+
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/tasks`);
|
|
959
|
+
if (!data || data.values.length === 0)
|
|
960
|
+
return text(`No tasks on PR #${args.prId}.`);
|
|
961
|
+
const lines = data.values.map((t) => {
|
|
962
|
+
const author = t.author?.displayName ?? t.author?.name ?? 'Unknown';
|
|
963
|
+
const date = t.createdDate ? ` (${formatDate(t.createdDate)})` : '';
|
|
964
|
+
const anchor = t.anchor?.id ? ` [on comment #${t.anchor.id}]` : '';
|
|
965
|
+
return `#${t.id} [${t.state}] ${author}${date}${anchor}: ${t.text}`;
|
|
966
|
+
});
|
|
967
|
+
const open = data.values.filter((t) => t.state === 'OPEN').length;
|
|
968
|
+
return text(`${data.values.length} task(s) on PR #${args.prId} (${open} open)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
969
|
+
}
|
|
970
|
+
async mutatePrTask(args) {
|
|
971
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
972
|
+
if (args.action === 'create') {
|
|
973
|
+
if (!args.text)
|
|
974
|
+
throw new Error('text is required to create a task.');
|
|
975
|
+
const body = { text: args.text };
|
|
976
|
+
if (args.commentId !== undefined) {
|
|
977
|
+
body.anchor = { id: args.commentId, type: 'COMMENT' };
|
|
978
|
+
}
|
|
979
|
+
else if (args.prId !== undefined) {
|
|
980
|
+
body.anchor = { id: args.prId, type: 'PULL_REQUEST' };
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
throw new Error('Provide prId or commentId to anchor the task.');
|
|
984
|
+
}
|
|
985
|
+
const created = await this.request('POST', '/tasks', body);
|
|
986
|
+
if (!created)
|
|
987
|
+
return text('Task created.');
|
|
988
|
+
return text(`Task #${created.id} created: "${created.text}"`);
|
|
989
|
+
}
|
|
990
|
+
if (!args.taskId)
|
|
991
|
+
throw new Error('taskId is required for resolve/reopen/delete.');
|
|
992
|
+
if (args.action === 'delete') {
|
|
993
|
+
const task = await this.request('GET', `/tasks/${args.taskId}`);
|
|
994
|
+
if (!task)
|
|
995
|
+
throw new Error(`Task #${args.taskId} not found.`);
|
|
996
|
+
await this.request('DELETE', `/tasks/${args.taskId}?version=${task.id}`);
|
|
997
|
+
return text(`Task #${args.taskId} deleted.`);
|
|
998
|
+
}
|
|
999
|
+
// resolve or reopen
|
|
1000
|
+
const task = await this.request('GET', `/tasks/${args.taskId}`);
|
|
1001
|
+
if (!task)
|
|
1002
|
+
throw new Error(`Task #${args.taskId} not found.`);
|
|
1003
|
+
const newState = args.action === 'resolve' ? 'RESOLVED' : 'OPEN';
|
|
1004
|
+
const updated = await this.request('PUT', `/tasks/${args.taskId}`, {
|
|
1005
|
+
id: task.id,
|
|
1006
|
+
state: newState,
|
|
1007
|
+
text: task.text,
|
|
1008
|
+
});
|
|
1009
|
+
if (!updated)
|
|
1010
|
+
return text(`Task #${args.taskId} ${newState}.`);
|
|
1011
|
+
return text(`Task #${updated.id} is now ${updated.state}: "${updated.text}"`);
|
|
1012
|
+
}
|
|
1013
|
+
async getBuildStatuses(args) {
|
|
1014
|
+
const data = await this.requestBuildStatus('GET', `/commits/${args.commitSha}`);
|
|
1015
|
+
if (!data?.values?.length)
|
|
1016
|
+
return text(`No build statuses reported for ${args.commitSha}.`);
|
|
1017
|
+
const lines = data.values.map((s) => {
|
|
1018
|
+
const icon = s.state === 'SUCCESSFUL' ? '✓' : s.state === 'FAILED' ? '✗' : '…';
|
|
1019
|
+
const date = s.dateAdded ? ` (${new Date(s.dateAdded).toISOString().slice(0, 10)})` : '';
|
|
1020
|
+
return `${icon} [${s.state}] ${s.name ?? s.key}${date}${s.description ? `\n ${s.description}` : ''}${s.url ? `\n ${s.url}` : ''}`;
|
|
1021
|
+
});
|
|
1022
|
+
return text(`${data.values.length} build status(es) for ${args.commitSha.slice(0, 8)}:\n${lines.join('\n')}`);
|
|
1023
|
+
}
|
|
859
1024
|
}
|
package/dist/config.js
CHANGED
|
@@ -34,21 +34,28 @@ export function loadConfig() {
|
|
|
34
34
|
const jiraToken = file?.jira?.token ?? process.env.JIRA_ACCESS_TOKEN ?? '';
|
|
35
35
|
const bitbucketUrl = file?.bitbucket?.url ?? process.env.BITBUCKET_URL ?? '';
|
|
36
36
|
const bitbucketToken = file?.bitbucket?.token ?? process.env.BITBUCKET_ACCESS_TOKEN ?? '';
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
37
|
+
const config = {};
|
|
38
|
+
if (jiraUrl && jiraToken) {
|
|
39
|
+
config.jira = { url: jiraUrl, token: jiraToken };
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const missing = [];
|
|
43
|
+
if (!jiraUrl)
|
|
44
|
+
missing.push('jira.url (or JIRA_URL)');
|
|
45
|
+
if (!jiraToken)
|
|
46
|
+
missing.push('jira.token (or JIRA_ACCESS_TOKEN)');
|
|
47
|
+
console.error(`[atlassian-mcp] Jira disabled: missing ${missing.join(', ')}`);
|
|
48
|
+
}
|
|
49
|
+
if (bitbucketUrl && bitbucketToken) {
|
|
50
|
+
config.bitbucket = { url: bitbucketUrl, token: bitbucketToken };
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const missing = [];
|
|
54
|
+
if (!bitbucketUrl)
|
|
55
|
+
missing.push('bitbucket.url (or BITBUCKET_URL)');
|
|
56
|
+
if (!bitbucketToken)
|
|
57
|
+
missing.push('bitbucket.token (or BITBUCKET_ACCESS_TOKEN)');
|
|
58
|
+
console.error(`[atlassian-mcp] Bitbucket disabled: missing ${missing.join(', ')}`);
|
|
59
|
+
}
|
|
60
|
+
return config;
|
|
54
61
|
}
|
package/dist/context.js
CHANGED
|
@@ -11,6 +11,7 @@ function safeExec(cmd, cwd) {
|
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
13
|
* Unified developer context: git state + linked Jira issues + open PR for current branch.
|
|
14
|
+
* Either jira or bitbucket may be null when only one product is configured.
|
|
14
15
|
*/
|
|
15
16
|
export async function getDevContext(args, jira, bitbucket) {
|
|
16
17
|
const repoPath = args.repoPath ?? process.cwd();
|
|
@@ -30,38 +31,65 @@ export async function getDevContext(args, jira, bitbucket) {
|
|
|
30
31
|
'Working tree:',
|
|
31
32
|
status,
|
|
32
33
|
].join('\n'));
|
|
33
|
-
// Jira — fetch any tickets referenced in the branch name
|
|
34
|
-
const jiraKeys = [...new Set(branch.match(JIRA_KEY_RE) ?? [])];
|
|
34
|
+
// Jira — fetch overview for any tickets referenced in the branch name
|
|
35
|
+
const jiraKeys = jira ? [...new Set(branch.match(JIRA_KEY_RE) ?? [])] : [];
|
|
35
36
|
for (const key of jiraKeys) {
|
|
36
37
|
try {
|
|
37
|
-
const result = await jira.
|
|
38
|
+
const result = await jira.issueOverview({
|
|
39
|
+
issueKey: key,
|
|
40
|
+
includeComments: true,
|
|
41
|
+
commentsMaxResults: 5,
|
|
42
|
+
includeTransitions: true,
|
|
43
|
+
includeSprint: true,
|
|
44
|
+
});
|
|
38
45
|
sections.push(`── Jira ${key} ──\n${result.content[0].text}`);
|
|
39
46
|
}
|
|
40
47
|
catch {
|
|
41
48
|
sections.push(`── Jira ${key} ── (could not fetch)`);
|
|
42
49
|
}
|
|
43
50
|
}
|
|
44
|
-
// Bitbucket — find the open PR for this branch
|
|
45
|
-
const parsed = parseBitbucketRemote(remote);
|
|
51
|
+
// Bitbucket — find the open PR for this branch (only if remote points to this instance)
|
|
52
|
+
const parsed = bitbucket?.isRemoteForThisInstance(remote) ? parseBitbucketRemote(remote) : null;
|
|
46
53
|
if (parsed) {
|
|
47
54
|
try {
|
|
48
55
|
const pr = await bitbucket.findOpenPrForBranch(parsed.projectKey, parsed.repoSlug, branch);
|
|
49
56
|
if (pr) {
|
|
57
|
+
const approved = pr.reviewers.filter((r) => r.approved).length;
|
|
58
|
+
const total = pr.reviewers.length;
|
|
50
59
|
const reviewers = pr.reviewers.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`).join(', ');
|
|
51
60
|
const url = pr.links?.self?.[0]?.href ?? '';
|
|
61
|
+
const descSnippet = pr.description
|
|
62
|
+
? pr.description.slice(0, 200) + (pr.description.length > 200 ? '…' : '')
|
|
63
|
+
: '';
|
|
52
64
|
const prLines = [
|
|
53
65
|
`── PR #${pr.id}: ${pr.title} ──`,
|
|
54
66
|
`State: ${pr.state}`,
|
|
55
67
|
`Author: ${pr.author.user.displayName}`,
|
|
56
68
|
`Branch: ${pr.fromRef.displayId} → ${pr.toRef.displayId}`,
|
|
57
|
-
`Reviewers: ${reviewers || '
|
|
69
|
+
`Reviewers: ${reviewers || 'none'} (${approved}/${total} approved)`,
|
|
58
70
|
];
|
|
59
71
|
if (url)
|
|
60
72
|
prLines.push(`URL: ${url}`);
|
|
73
|
+
if (descSnippet)
|
|
74
|
+
prLines.push(``, descSnippet);
|
|
75
|
+
// Workflow hint
|
|
76
|
+
if (total > 0 && approved === total) {
|
|
77
|
+
prLines.push(``, `✓ All reviewers approved — ready to merge: bitbucket_mutate {prId: ${pr.id}, action: "merge"}`);
|
|
78
|
+
}
|
|
79
|
+
else if (total > 0) {
|
|
80
|
+
prLines.push(``, `→ ${total - approved} reviewer(s) pending — use bitbucket_get_pr {prId: ${pr.id}} to see open comments`);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
prLines.push(``, `→ No reviewers assigned — use bitbucket_mutate {prId: ${pr.id}, update: {reviewers: [...]}} to add them`);
|
|
84
|
+
}
|
|
61
85
|
sections.push(prLines.join('\n'));
|
|
62
86
|
}
|
|
63
87
|
else {
|
|
64
|
-
|
|
88
|
+
const noPrHint = [
|
|
89
|
+
`── Bitbucket (${parsed.projectKey}/${parsed.repoSlug}) ── No open PR for branch "${branch}"`,
|
|
90
|
+
`→ Create one: bitbucket_mutate {create: {title: "...", fromBranch: "${branch}"}}`,
|
|
91
|
+
];
|
|
92
|
+
sections.push(noPrHint.join('\n'));
|
|
65
93
|
}
|
|
66
94
|
}
|
|
67
95
|
catch {
|
package/dist/git.js
CHANGED
|
@@ -22,16 +22,52 @@ export function getContext(args) {
|
|
|
22
22
|
const remote = safeGit('remote get-url origin', repoPath, '(no remote)');
|
|
23
23
|
const commits = safeGit(`log --oneline -${limit}`, repoPath, '(no commits)');
|
|
24
24
|
const status = safeGit('status --short', repoPath, '');
|
|
25
|
+
// Upstream tracking
|
|
26
|
+
const upstream = safeGit('rev-parse --abbrev-ref @{u}', repoPath, '');
|
|
27
|
+
let upstreamLine = '';
|
|
28
|
+
if (upstream) {
|
|
29
|
+
const ab = safeGit(`rev-list --left-right --count ${upstream}...HEAD`, repoPath, '');
|
|
30
|
+
if (ab.includes('\t')) {
|
|
31
|
+
const [behind, ahead] = ab.split('\t').map(Number);
|
|
32
|
+
const parts = [];
|
|
33
|
+
if (ahead)
|
|
34
|
+
parts.push(`${ahead} ahead`);
|
|
35
|
+
if (behind)
|
|
36
|
+
parts.push(`${behind} behind`);
|
|
37
|
+
upstreamLine = `${upstream}${parts.length ? ` (${parts.join(', ')})` : ' (up to date)'}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Diff stat summary
|
|
41
|
+
const diffStatLines = safeGit('diff HEAD --stat', repoPath, '').split('\n').filter(Boolean);
|
|
42
|
+
const diffStat = diffStatLines[diffStatLines.length - 1]?.trim() ?? '';
|
|
25
43
|
const jiraKeys = [...new Set(branch.match(JIRA_KEY_RE) ?? [])];
|
|
26
44
|
const lines = [
|
|
27
45
|
`Repository: ${repoPath}`,
|
|
28
46
|
`Branch: ${branch}`,
|
|
47
|
+
...(upstreamLine ? [`Upstream: ${upstreamLine}`] : []),
|
|
29
48
|
`Remote: ${remote}`,
|
|
49
|
+
...(jiraKeys.length ? [`Jira: ${jiraKeys.join(', ')}`] : []),
|
|
50
|
+
'',
|
|
51
|
+
`Recent commits (last ${limit}):`,
|
|
52
|
+
commits || '(none)',
|
|
53
|
+
'',
|
|
54
|
+
'Working tree:',
|
|
30
55
|
];
|
|
31
|
-
if (
|
|
32
|
-
lines.push(
|
|
56
|
+
if (status) {
|
|
57
|
+
lines.push(status);
|
|
58
|
+
if (diffStat)
|
|
59
|
+
lines.push('', `Diff stat: ${diffStat}`);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
lines.push('(clean)');
|
|
63
|
+
}
|
|
64
|
+
if (args.includeDiff && status) {
|
|
65
|
+
const diff = safeGit('diff HEAD', repoPath, '');
|
|
66
|
+
if (diff) {
|
|
67
|
+
const MAX = 6000;
|
|
68
|
+
lines.push('', '── Uncommitted diff ──', diff.length > MAX ? diff.slice(0, MAX) + `\n\n... (truncated, ${diff.length - MAX} more chars)` : diff);
|
|
69
|
+
}
|
|
33
70
|
}
|
|
34
|
-
lines.push('', `Recent commits (last ${limit}):`, commits || '(none)', '', 'Working tree status:', status || '(clean)');
|
|
35
71
|
return text(lines.join('\n'));
|
|
36
72
|
}
|
|
37
73
|
catch (err) {
|
|
@@ -84,3 +120,70 @@ export function getDiff(args) {
|
|
|
84
120
|
return text(`Error reading diff: ${err.message}`);
|
|
85
121
|
}
|
|
86
122
|
}
|
|
123
|
+
export function checkRemoteBranch(branchName, repoPath) {
|
|
124
|
+
const lsRemote = safeGit(`ls-remote --heads origin refs/heads/${branchName}`, repoPath);
|
|
125
|
+
if (!lsRemote)
|
|
126
|
+
return { exists: false };
|
|
127
|
+
const sha = lsRemote.split(/\s+/)[0]?.trim();
|
|
128
|
+
// Fetch so we can read the log
|
|
129
|
+
safeGit(`fetch origin ${branchName}`, repoPath);
|
|
130
|
+
const log = safeGit(`log origin/${branchName} -1 --format=%an%x09%ae%x09%ad%x09%s`, repoPath);
|
|
131
|
+
if (!log)
|
|
132
|
+
return { exists: true, sha: sha?.slice(0, 8) };
|
|
133
|
+
const [author, email, date, ...msgParts] = log.split('\t');
|
|
134
|
+
return {
|
|
135
|
+
exists: true,
|
|
136
|
+
sha: sha?.slice(0, 8),
|
|
137
|
+
author: email ? `${author} <${email}>` : author,
|
|
138
|
+
date,
|
|
139
|
+
message: msgParts.join('\t'),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function getDefaultBranch(repoPath) {
|
|
143
|
+
const head = safeGit('rev-parse --abbrev-ref origin/HEAD', repoPath);
|
|
144
|
+
if (head && head.startsWith('origin/'))
|
|
145
|
+
return head.slice('origin/'.length);
|
|
146
|
+
// origin/HEAD not set — probe common defaults
|
|
147
|
+
if (safeGit('rev-parse --verify origin/main', repoPath))
|
|
148
|
+
return 'main';
|
|
149
|
+
return 'master';
|
|
150
|
+
}
|
|
151
|
+
export function checkoutRemoteBranch(branchName, repoPath) {
|
|
152
|
+
try {
|
|
153
|
+
const existing = safeGit(`branch --list ${branchName}`, repoPath);
|
|
154
|
+
if (existing.trim()) {
|
|
155
|
+
git(`checkout ${branchName}`, repoPath);
|
|
156
|
+
return text(`Switched to existing local branch "${branchName}".`);
|
|
157
|
+
}
|
|
158
|
+
git(`checkout --track origin/${branchName}`, repoPath);
|
|
159
|
+
return text(`Checked out "${branchName}" tracking origin/${branchName}.`);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
return text(`Error checking out branch: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export function createBranch(args) {
|
|
166
|
+
const repoPath = args.repoPath ?? process.cwd();
|
|
167
|
+
const { branchName, push = false } = args;
|
|
168
|
+
const baseBranch = args.baseBranch ?? getDefaultBranch(repoPath);
|
|
169
|
+
try {
|
|
170
|
+
if (!/^[a-zA-Z0-9/_.\-]+$/.test(branchName)) {
|
|
171
|
+
return text(`Invalid branch name "${branchName}". Use only letters, numbers, /, _, ., -`);
|
|
172
|
+
}
|
|
173
|
+
const existing = safeGit(`branch --list ${branchName}`, repoPath);
|
|
174
|
+
if (existing.trim()) {
|
|
175
|
+
return text(`Branch "${branchName}" already exists locally. Switch with: git checkout ${branchName}`);
|
|
176
|
+
}
|
|
177
|
+
safeGit(`fetch origin ${baseBranch}`, repoPath);
|
|
178
|
+
git(`checkout -b ${branchName} origin/${baseBranch}`, repoPath);
|
|
179
|
+
const lines = [`Created and switched to branch "${branchName}" from origin/${baseBranch}.`];
|
|
180
|
+
if (push) {
|
|
181
|
+
git(`push -u origin ${branchName}`, repoPath);
|
|
182
|
+
lines.push(`Pushed to origin/${branchName} and set upstream.`);
|
|
183
|
+
}
|
|
184
|
+
return text(lines.join('\n'));
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
return text(`Error creating branch: ${err.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|