@stubbedev/atlassian-mcp 0.1.19 → 0.2.2

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 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 inside a git repo with a Bitbucket remote');
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 pr = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
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/${args.prId}/commits?limit=${commitsLimit}&start=${commitsStart}`);
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/${args.prId}/blocker-comments?${qs}`);
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/${args.prId}/activities?limit=${commentsLimit}&start=${commentsStart}`);
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/${args.prId}/diff`);
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 missing = [];
38
- if (!jiraUrl)
39
- missing.push('jira.url (or JIRA_URL)');
40
- if (!jiraToken)
41
- missing.push('jira.token (or JIRA_ACCESS_TOKEN)');
42
- if (!bitbucketUrl)
43
- missing.push('bitbucket.url (or BITBUCKET_URL)');
44
- if (!bitbucketToken)
45
- missing.push('bitbucket.token (or BITBUCKET_ACCESS_TOKEN)');
46
- if (missing.length > 0) {
47
- throw new Error(`Missing required configuration: ${missing.join(', ')}.\n` +
48
- 'Provide a config file (~/.atlassian-mcp.json or --config <path>) or set environment variables.');
49
- }
50
- return {
51
- jira: { url: jiraUrl, token: jiraToken },
52
- bitbucket: { url: bitbucketUrl, token: bitbucketToken },
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.getIssue({ issueKey: key });
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 || 'None'}`,
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
- sections.push(`── Bitbucket (${parsed.projectKey}/${parsed.repoSlug}) ── No open PR for branch "${branch}"`);
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 (jiraKeys.length > 0) {
32
- lines.push(`Jira issue(s) detected in branch: ${jiraKeys.join(', ')}`);
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
+ }