@stubbedev/atlassian-mcp 0.0.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/README.md +300 -0
- package/atlassian-mcp.schema.json +48 -0
- package/dist/bitbucket.js +337 -0
- package/dist/config.js +54 -0
- package/dist/context.js +98 -0
- package/dist/git.js +86 -0
- package/dist/index.js +568 -0
- package/dist/jira.js +189 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# atlassian-mcp
|
|
2
|
+
|
|
3
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **self-hosted Jira** (Server / Data Center) and **self-hosted Bitbucket** (Server / Data Center). Exposes 34 tools to Claude for reading and managing issues, pull requests, comments, and git context.
|
|
4
|
+
|
|
5
|
+
> **Note:** This server only supports self-hosted instances. Jira Cloud and Bitbucket Cloud use different APIs and are not supported.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Tools
|
|
10
|
+
|
|
11
|
+
### Context
|
|
12
|
+
|
|
13
|
+
| Tool | Description |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `get_dev_context` | Unified snapshot: git state + linked Jira tickets (from branch name) + open PR for the current branch |
|
|
16
|
+
|
|
17
|
+
### Jira
|
|
18
|
+
|
|
19
|
+
| Tool | Description |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `jira_search_issues` | Search issues by text, JQL, project, status, assignee, or issue type |
|
|
22
|
+
| `jira_my_issues` | List issues assigned to you, ordered by last updated |
|
|
23
|
+
| `jira_get_projects` | List all accessible projects |
|
|
24
|
+
| `jira_get_issue_types` | List issue types and their available statuses for a project |
|
|
25
|
+
| `jira_get_issue` | Get issue details by key |
|
|
26
|
+
| `jira_create_issue` | Create a new issue |
|
|
27
|
+
| `jira_update_issue` | Update summary, description, assignee, or priority |
|
|
28
|
+
| `jira_search_users` | Search for users by name or email |
|
|
29
|
+
| `jira_get_comments` | List comments on an issue |
|
|
30
|
+
| `jira_add_comment` | Add a comment to an issue |
|
|
31
|
+
| `jira_get_transitions` | List available status transitions |
|
|
32
|
+
| `jira_transition_issue` | Change issue status via transition ID |
|
|
33
|
+
|
|
34
|
+
### Bitbucket
|
|
35
|
+
|
|
36
|
+
| Tool | Description |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `bitbucket_list_repos` | List repositories (optionally by project) |
|
|
39
|
+
| `bitbucket_list_pull_requests` | List pull requests for a repository |
|
|
40
|
+
| `bitbucket_my_prs` | List PRs in your inbox (authored by you or awaiting review) |
|
|
41
|
+
| `bitbucket_get_pull_request` | Get pull request details |
|
|
42
|
+
| `bitbucket_get_pr_diff` | Get the code diff for a pull request |
|
|
43
|
+
| `bitbucket_create_pull_request` | Create a new pull request |
|
|
44
|
+
| `bitbucket_create_pr_from_context` | Create a PR auto-detecting project, repo, and branch from the current git repo |
|
|
45
|
+
| `bitbucket_approve_pr` | Approve a pull request |
|
|
46
|
+
| `bitbucket_unapprove_pr` | Remove your approval from a pull request |
|
|
47
|
+
| `bitbucket_merge_pr` | Merge a pull request |
|
|
48
|
+
| `bitbucket_decline_pr` | Decline a pull request |
|
|
49
|
+
| `bitbucket_get_pr_comments` | Get comment threads with IDs and states for a pull request |
|
|
50
|
+
| `bitbucket_add_pr_comment` | Add a top-level PR comment or reply to an existing comment |
|
|
51
|
+
| `bitbucket_update_pr_comment` | Update comment text, state, or severity (`NORMAL` / `BLOCKER`) |
|
|
52
|
+
| `bitbucket_delete_pr_comment` | Delete a PR comment by comment ID |
|
|
53
|
+
| `bitbucket_get_pr_commits` | List commits included in a pull request |
|
|
54
|
+
| `bitbucket_get_branches` | List branches in a repository |
|
|
55
|
+
| `bitbucket_get_file` | Get raw file content at a given ref |
|
|
56
|
+
|
|
57
|
+
### Git
|
|
58
|
+
|
|
59
|
+
| Tool | Description |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `git_get_context` | Branch, remote, recent commits, working tree status, and any Jira keys detected in the branch name |
|
|
62
|
+
| `git_get_commits` | Commit history for a branch with author and message |
|
|
63
|
+
| `git_get_diff` | Diff of uncommitted changes or between two refs |
|
|
64
|
+
|
|
65
|
+
All list tools support `limit` and `start`/`startAt` for pagination.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Setup
|
|
70
|
+
|
|
71
|
+
### 1. Create a config file
|
|
72
|
+
|
|
73
|
+
Create `~/.atlassian-mcp.json`:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"$schema": "https://raw.githubusercontent.com/stubbedev/atlassian-mcp/master/atlassian-mcp.schema.json",
|
|
78
|
+
"jira": {
|
|
79
|
+
"url": "https://jira.example.com",
|
|
80
|
+
"token": "your-jira-personal-access-token"
|
|
81
|
+
},
|
|
82
|
+
"bitbucket": {
|
|
83
|
+
"url": "https://bitbucket.example.com",
|
|
84
|
+
"token": "your-bitbucket-personal-access-token"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The `$schema` field is optional but enables editor autocomplete and validation. For Bitbucket tools, `projectKey` and `repoSlug` are auto-detected from your local `origin` remote when omitted. Jira project-specific tools still require `projectKey` in the tool call.
|
|
90
|
+
|
|
91
|
+
Alternatively, use environment variables (or a `.env` file in this directory):
|
|
92
|
+
|
|
93
|
+
```env
|
|
94
|
+
JIRA_URL=https://jira.example.com
|
|
95
|
+
JIRA_ACCESS_TOKEN=your-jira-personal-access-token
|
|
96
|
+
BITBUCKET_URL=https://bitbucket.example.com
|
|
97
|
+
BITBUCKET_ACCESS_TOKEN=your-bitbucket-personal-access-token
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Config is resolved in this order: `--config <path>` CLI arg → `ATLASSIAN_MCP_CONFIG` env var → `~/.atlassian-mcp.json` → `.atlassian-mcp.json` in cwd → environment variables.
|
|
101
|
+
|
|
102
|
+
### 2. Connect to your AI tool
|
|
103
|
+
|
|
104
|
+
No cloning or building required — just point your tool at `npx @stubbedev/atlassian-mcp@latest` and it will install and run automatically. Add `--prefer-online` to make `npx` check npm for updates on each run.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
#### Claude Code
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
claude mcp add atlassian -- npx --prefer-online -y @stubbedev/atlassian-mcp@latest --config ~/.atlassian-mcp.json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
#### Cursor
|
|
117
|
+
|
|
118
|
+
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project-only):
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"mcpServers": {
|
|
123
|
+
"atlassian": {
|
|
124
|
+
"command": "npx",
|
|
125
|
+
"args": ["--prefer-online", "-y", "@stubbedev/atlassian-mcp@latest", "--config", "/Users/you/.atlassian-mcp.json"]
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
#### Windsurf
|
|
134
|
+
|
|
135
|
+
Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"mcpServers": {
|
|
140
|
+
"atlassian": {
|
|
141
|
+
"command": "npx",
|
|
142
|
+
"args": ["--prefer-online", "-y", "@stubbedev/atlassian-mcp@latest", "--config", "/Users/you/.atlassian-mcp.json"]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
#### Zed
|
|
151
|
+
|
|
152
|
+
Add to `~/.config/zed/settings.json`:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"context_servers": {
|
|
157
|
+
"atlassian": {
|
|
158
|
+
"command": {
|
|
159
|
+
"path": "npx",
|
|
160
|
+
"args": ["--prefer-online", "-y", "@stubbedev/atlassian-mcp@latest", "--config", "/home/you/.atlassian-mcp.json"]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
#### OpenCode
|
|
170
|
+
|
|
171
|
+
Add to `opencode.json` in your project root (or `~/.config/opencode/opencode.json` for global):
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"$schema": "https://opencode.ai/config.json",
|
|
176
|
+
"mcp": {
|
|
177
|
+
"atlassian": {
|
|
178
|
+
"type": "local",
|
|
179
|
+
"command": ["npx", "--prefer-online", "-y", "@stubbedev/atlassian-mcp@latest", "--config", "/home/you/.atlassian-mcp.json"]
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
#### Codex CLI
|
|
188
|
+
|
|
189
|
+
Add to `~/.codex/config.yaml`:
|
|
190
|
+
|
|
191
|
+
```yaml
|
|
192
|
+
mcpServers:
|
|
193
|
+
atlassian:
|
|
194
|
+
command: npx
|
|
195
|
+
args:
|
|
196
|
+
- --prefer-online
|
|
197
|
+
- -y
|
|
198
|
+
- @stubbedev/atlassian-mcp@latest
|
|
199
|
+
- --config
|
|
200
|
+
- /home/you/.atlassian-mcp.json
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
#### Any other MCP-compatible tool
|
|
206
|
+
|
|
207
|
+
Most tools that support MCP accept the same JSON format. Use `npx` as the command with `["--prefer-online", "-y", "@stubbedev/atlassian-mcp@latest", "--config", "/path/to/config.json"]` as the args.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
### Manual install (optional)
|
|
212
|
+
|
|
213
|
+
If you prefer to clone and run locally:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
git clone git@github.com:stubbedev/atlassian-mcp.git
|
|
217
|
+
cd atlassian-mcp
|
|
218
|
+
npm install
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Then use `node /path/to/atlassian-mcp/dist/index.js` instead of the `npx` command in the configs above.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Releases (Maintainers)
|
|
226
|
+
|
|
227
|
+
This package is published to npm as `@stubbedev/atlassian-mcp`.
|
|
228
|
+
|
|
229
|
+
Automatic publish is configured in `.github/workflows/publish.yml`:
|
|
230
|
+
|
|
231
|
+
- Push a tag like `v1.0.1` to publish from CI
|
|
232
|
+
- Or run the workflow manually via **Actions → Publish Package**
|
|
233
|
+
|
|
234
|
+
Required repository secret:
|
|
235
|
+
|
|
236
|
+
- `NPM_TOKEN` (npm automation token with publish rights for `@stubbedev`)
|
|
237
|
+
|
|
238
|
+
Manual publish from local machine:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
npm run build
|
|
242
|
+
npm publish --access public
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Creating Personal Access Tokens
|
|
248
|
+
|
|
249
|
+
### Jira Server / Data Center
|
|
250
|
+
|
|
251
|
+
Personal Access Tokens are supported from **Jira 8.14** onwards.
|
|
252
|
+
|
|
253
|
+
1. Log in to your Jira instance.
|
|
254
|
+
2. Click your profile avatar in the top-right corner and select **Profile**.
|
|
255
|
+
3. In the left sidebar, click **Personal Access Tokens**.
|
|
256
|
+
4. Click **Create token**.
|
|
257
|
+
5. Give the token a name (e.g. `atlassian-mcp`) and optionally set an expiry date.
|
|
258
|
+
6. Click **Create** and copy the token — it will only be shown once.
|
|
259
|
+
|
|
260
|
+
Paste the token as the `token` value under `jira` in your config file.
|
|
261
|
+
|
|
262
|
+
> If your Jira version is older than 8.14, you can use HTTP Basic Auth instead — but this server only supports Bearer token (PAT) authentication.
|
|
263
|
+
|
|
264
|
+
### Bitbucket Server / Data Center
|
|
265
|
+
|
|
266
|
+
Personal Access Tokens are supported from **Bitbucket Server 5.5** onwards.
|
|
267
|
+
|
|
268
|
+
1. Log in to your Bitbucket instance.
|
|
269
|
+
2. Click your profile avatar in the top-right corner and select **Manage account**.
|
|
270
|
+
3. In the left sidebar, under **Security**, click **Personal access tokens**.
|
|
271
|
+
4. Click **Create a token**.
|
|
272
|
+
5. Give the token a name (e.g. `atlassian-mcp`).
|
|
273
|
+
6. Set the permissions:
|
|
274
|
+
- **Projects**: Read
|
|
275
|
+
- **Repositories**: Read + Write (Write is needed to create pull requests and add comments)
|
|
276
|
+
7. Optionally set an expiry date.
|
|
277
|
+
8. Click **Create** and copy the token — it will only be shown once.
|
|
278
|
+
|
|
279
|
+
Paste the token as the `token` value under `bitbucket` in your config file.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Development
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
# Watch mode — recompiles on file changes
|
|
287
|
+
npm run dev
|
|
288
|
+
|
|
289
|
+
# Run the built server directly
|
|
290
|
+
node dist/index.js
|
|
291
|
+
|
|
292
|
+
# Test the tool list
|
|
293
|
+
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node dist/index.js
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
To use a specific config file:
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
node dist/index.js --config /path/to/config.json
|
|
300
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "atlassian-mcp-config",
|
|
4
|
+
"title": "Atlassian MCP Config",
|
|
5
|
+
"description": "Configuration file for the atlassian-mcp MCP server",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["jira", "bitbucket"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"$schema": {
|
|
11
|
+
"type": "string"
|
|
12
|
+
},
|
|
13
|
+
"jira": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"description": "Self-hosted Jira Server / Data Center connection settings",
|
|
16
|
+
"required": ["url", "token"],
|
|
17
|
+
"additionalProperties": false,
|
|
18
|
+
"properties": {
|
|
19
|
+
"url": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"format": "uri",
|
|
22
|
+
"description": "Base URL of your Jira instance, e.g. https://jira.example.com"
|
|
23
|
+
},
|
|
24
|
+
"token": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "Personal Access Token (PAT) for Jira Server authentication"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"bitbucket": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"description": "Self-hosted Bitbucket Server / Data Center connection settings",
|
|
33
|
+
"required": ["url", "token"],
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"properties": {
|
|
36
|
+
"url": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"format": "uri",
|
|
39
|
+
"description": "Base URL of your Bitbucket instance, e.g. https://bitbucket.example.com"
|
|
40
|
+
},
|
|
41
|
+
"token": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "Personal Access Token (PAT) for Bitbucket Server authentication"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
function safeExec(cmd) {
|
|
3
|
+
try {
|
|
4
|
+
return execSync(cmd, { encoding: 'utf-8' }).trim();
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return '';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Parses a Bitbucket Server remote URL into projectKey + repoSlug.
|
|
12
|
+
* Handles SSH (ssh://git@host/PROJ/repo.git), SCP-like (git@host:PROJ/repo.git),
|
|
13
|
+
* and HTTP (https://host/scm/PROJ/repo.git) formats.
|
|
14
|
+
*/
|
|
15
|
+
export function parseBitbucketRemote(remoteUrl) {
|
|
16
|
+
const sshUrl = remoteUrl.match(/ssh:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
17
|
+
if (sshUrl)
|
|
18
|
+
return { projectKey: sshUrl[1], repoSlug: sshUrl[2] };
|
|
19
|
+
const scpUrl = remoteUrl.match(/^[^@]+@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
20
|
+
if (scpUrl)
|
|
21
|
+
return { projectKey: scpUrl[1], repoSlug: scpUrl[2] };
|
|
22
|
+
const httpUrl = remoteUrl.match(/\/scm\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
23
|
+
if (httpUrl)
|
|
24
|
+
return { projectKey: httpUrl[1], repoSlug: httpUrl[2] };
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
function text(t) {
|
|
28
|
+
return { content: [{ type: 'text', text: t }] };
|
|
29
|
+
}
|
|
30
|
+
function toBranchRef(branch) {
|
|
31
|
+
return branch.startsWith('refs/') ? branch : `refs/heads/${branch}`;
|
|
32
|
+
}
|
|
33
|
+
function formatDate(ms) {
|
|
34
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
35
|
+
}
|
|
36
|
+
function formatCommentThread(comment, indent = '') {
|
|
37
|
+
const author = comment.author?.displayName ?? comment.author?.name ?? 'Unknown';
|
|
38
|
+
const date = comment.createdDate ? ` (${formatDate(comment.createdDate)})` : '';
|
|
39
|
+
const state = comment.state ?? 'OPEN';
|
|
40
|
+
const severity = comment.severity ?? 'NORMAL';
|
|
41
|
+
const lines = [
|
|
42
|
+
`${indent}#${comment.id} [${state}/${severity}] ${author}${date} (v${comment.version})`,
|
|
43
|
+
`${indent}${comment.text}`,
|
|
44
|
+
];
|
|
45
|
+
if (comment.comments && comment.comments.length > 0) {
|
|
46
|
+
for (const reply of comment.comments) {
|
|
47
|
+
lines.push(...formatCommentThread(reply, `${indent} `));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
52
|
+
function pageHint(data) {
|
|
53
|
+
return data.isLastPage ? '' : ` (use start=${data.nextPageStart} for next page)`;
|
|
54
|
+
}
|
|
55
|
+
function formatDiff(data, maxChars = 8000) {
|
|
56
|
+
const parts = [];
|
|
57
|
+
for (const diff of data.diffs) {
|
|
58
|
+
const from = diff.source?.toString ?? '/dev/null';
|
|
59
|
+
const to = diff.destination?.toString ?? '/dev/null';
|
|
60
|
+
parts.push(`--- a/${from}\n+++ b/${to}`);
|
|
61
|
+
for (const hunk of diff.hunks ?? []) {
|
|
62
|
+
for (const segment of hunk.segments ?? []) {
|
|
63
|
+
const prefix = segment.type === 'ADDED' ? '+' : segment.type === 'REMOVED' ? '-' : ' ';
|
|
64
|
+
for (const line of segment.lines ?? []) {
|
|
65
|
+
parts.push(`${prefix}${line.line}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const result = parts.join('\n');
|
|
71
|
+
if (!result)
|
|
72
|
+
return '(no diff)';
|
|
73
|
+
if (result.length > maxChars) {
|
|
74
|
+
return result.slice(0, maxChars) + `\n\n... (truncated, ${result.length - maxChars} more chars)`;
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
export class BitbucketClient {
|
|
79
|
+
baseUrl;
|
|
80
|
+
headers;
|
|
81
|
+
constructor(baseUrl, token) {
|
|
82
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
83
|
+
this.headers = {
|
|
84
|
+
Authorization: `Bearer ${token}`,
|
|
85
|
+
'Content-Type': 'application/json',
|
|
86
|
+
Accept: 'application/json',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
resolveProjectAndRepo(projectKey, repoSlug) {
|
|
90
|
+
if (projectKey && repoSlug)
|
|
91
|
+
return { projectKey, repoSlug };
|
|
92
|
+
const remote = safeExec('git remote get-url origin');
|
|
93
|
+
if (remote) {
|
|
94
|
+
const parsed = parseBitbucketRemote(remote);
|
|
95
|
+
if (parsed) {
|
|
96
|
+
return {
|
|
97
|
+
projectKey: projectKey ?? parsed.projectKey,
|
|
98
|
+
repoSlug: repoSlug ?? parsed.repoSlug,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw new Error('Could not determine projectKey/repoSlug — provide them explicitly or run from a directory inside a git repo with a Bitbucket remote');
|
|
103
|
+
}
|
|
104
|
+
async request(method, path, body) {
|
|
105
|
+
const url = `${this.baseUrl}/rest/api/1.0${path}`;
|
|
106
|
+
const opts = { method, headers: this.headers };
|
|
107
|
+
if (body !== undefined)
|
|
108
|
+
opts.body = JSON.stringify(body);
|
|
109
|
+
const res = await fetch(url, opts);
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const errText = await res.text();
|
|
112
|
+
throw new Error(`Bitbucket ${res.status} ${method} ${path}: ${errText}`);
|
|
113
|
+
}
|
|
114
|
+
return res.status === 204 ? null : res.json();
|
|
115
|
+
}
|
|
116
|
+
async requestText(path) {
|
|
117
|
+
const url = `${this.baseUrl}/rest/api/1.0${path}`;
|
|
118
|
+
const res = await fetch(url, {
|
|
119
|
+
method: 'GET',
|
|
120
|
+
headers: { Authorization: this.headers.Authorization },
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
const errText = await res.text();
|
|
124
|
+
throw new Error(`Bitbucket ${res.status} GET ${path}: ${errText}`);
|
|
125
|
+
}
|
|
126
|
+
return res.text();
|
|
127
|
+
}
|
|
128
|
+
// Used internally by context tools — finds the open PR for a given source branch
|
|
129
|
+
async findOpenPrForBranch(projectKey, repoSlug, branch) {
|
|
130
|
+
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests?state=OPEN&limit=50`);
|
|
131
|
+
return data?.values.find((pr) => pr.fromRef.displayId === branch) ?? null;
|
|
132
|
+
}
|
|
133
|
+
async listRepos(args) {
|
|
134
|
+
const { limit = 50, start = 0 } = args;
|
|
135
|
+
const qs = `?limit=${limit}&start=${start}`;
|
|
136
|
+
const path = args.projectKey
|
|
137
|
+
? `/projects/${args.projectKey}/repos${qs}`
|
|
138
|
+
: `/repos${qs}`;
|
|
139
|
+
const data = await this.request('GET', path);
|
|
140
|
+
if (!data || data.values.length === 0)
|
|
141
|
+
return text('No repositories found.');
|
|
142
|
+
const lines = data.values.map((r, i) => `${start + i + 1}. ${r.project.key}/${r.slug} — ${r.name}`);
|
|
143
|
+
return text(`${data.values.length} repo(s)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
144
|
+
}
|
|
145
|
+
async listPullRequests(args) {
|
|
146
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
147
|
+
const { state = 'OPEN', limit = 25, start = 0 } = args;
|
|
148
|
+
const path = `/projects/${projectKey}/repos/${repoSlug}/pull-requests?state=${state}&limit=${limit}&start=${start}`;
|
|
149
|
+
const data = await this.request('GET', path);
|
|
150
|
+
if (!data || data.values.length === 0)
|
|
151
|
+
return text(`No ${state} pull requests found.`);
|
|
152
|
+
const lines = data.values.map((pr) => `#${pr.id} [${pr.state}] ${pr.title} | ${pr.fromRef.displayId} → ${pr.toRef.displayId} | by ${pr.author.user.displayName}`);
|
|
153
|
+
return text(`${data.values.length} PR(s) (${state})${pageHint(data)}:\n${lines.join('\n')}`);
|
|
154
|
+
}
|
|
155
|
+
async myPrs(args) {
|
|
156
|
+
const { limit = 25, start = 0 } = args;
|
|
157
|
+
const data = await this.request('GET', `/inbox/pull-requests?limit=${limit}&start=${start}`);
|
|
158
|
+
if (!data || data.values.length === 0)
|
|
159
|
+
return text('No pull requests in your inbox.');
|
|
160
|
+
const lines = data.values.map((pr) => {
|
|
161
|
+
const repo = `${pr.toRef.repository.project.key}/${pr.toRef.repository.slug}`;
|
|
162
|
+
return `#${pr.id} [${pr.state}] ${pr.title} | ${repo} | ${pr.fromRef.displayId} → ${pr.toRef.displayId}`;
|
|
163
|
+
});
|
|
164
|
+
return text(`${data.values.length} PR(s) in your inbox${pageHint(data)}:\n${lines.join('\n')}`);
|
|
165
|
+
}
|
|
166
|
+
async getPullRequest(args) {
|
|
167
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
168
|
+
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
|
|
169
|
+
if (!data)
|
|
170
|
+
return text('Pull request not found.');
|
|
171
|
+
const reviewers = data.reviewers
|
|
172
|
+
.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`)
|
|
173
|
+
.join(', ');
|
|
174
|
+
const lines = [
|
|
175
|
+
`PR #${data.id}: ${data.title}`,
|
|
176
|
+
`State: ${data.state}`,
|
|
177
|
+
`Author: ${data.author.user.displayName}`,
|
|
178
|
+
`Branch: ${data.fromRef.displayId} → ${data.toRef.displayId}`,
|
|
179
|
+
`Reviewers: ${reviewers || 'None'}`,
|
|
180
|
+
'',
|
|
181
|
+
'Description:',
|
|
182
|
+
data.description ?? '(no description)',
|
|
183
|
+
];
|
|
184
|
+
return text(lines.join('\n'));
|
|
185
|
+
}
|
|
186
|
+
async getPrDiff(args) {
|
|
187
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
188
|
+
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/diff`);
|
|
189
|
+
if (!data)
|
|
190
|
+
return text('No diff found.');
|
|
191
|
+
return text(formatDiff(data));
|
|
192
|
+
}
|
|
193
|
+
async getPrCommits(args) {
|
|
194
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
195
|
+
const limit = args.limit ?? 25;
|
|
196
|
+
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/commits?limit=${limit}`);
|
|
197
|
+
if (!data || data.values.length === 0)
|
|
198
|
+
return text('No commits found.');
|
|
199
|
+
const lines = data.values.map((c) => `${c.displayId} ${formatDate(c.authorTimestamp)} ${c.author.name}: ${c.message.split('\n')[0]}`);
|
|
200
|
+
return text(`${data.values.length} commit(s)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
201
|
+
}
|
|
202
|
+
async createPullRequest(args) {
|
|
203
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
204
|
+
const { title, description, fromBranch, toBranch = 'master', reviewers = [] } = args;
|
|
205
|
+
const body = {
|
|
206
|
+
title,
|
|
207
|
+
description: description ?? '',
|
|
208
|
+
fromRef: { id: toBranchRef(fromBranch), repository: { slug: repoSlug, project: { key: projectKey } } },
|
|
209
|
+
toRef: { id: toBranchRef(toBranch), repository: { slug: repoSlug, project: { key: projectKey } } },
|
|
210
|
+
reviewers: reviewers.map((name) => ({ user: { name } })),
|
|
211
|
+
};
|
|
212
|
+
const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests`, body);
|
|
213
|
+
if (!data)
|
|
214
|
+
return text('Pull request created.');
|
|
215
|
+
const url = data.links?.self?.[0]?.href ?? '';
|
|
216
|
+
return text(`Created PR #${data.id}: "${data.title}"${url ? `\n${url}` : ''}`);
|
|
217
|
+
}
|
|
218
|
+
async approvePr(args) {
|
|
219
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
220
|
+
const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/approve`);
|
|
221
|
+
if (!data)
|
|
222
|
+
return text(`Approved PR #${args.prId}.`);
|
|
223
|
+
return text(`Approved PR #${args.prId} as ${data.user.displayName}.`);
|
|
224
|
+
}
|
|
225
|
+
async unapprovePr(args) {
|
|
226
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
227
|
+
await this.request('DELETE', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/approve`);
|
|
228
|
+
return text(`Approval removed from PR #${args.prId}.`);
|
|
229
|
+
}
|
|
230
|
+
async declinePr(args) {
|
|
231
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
232
|
+
const pr = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
|
|
233
|
+
if (!pr)
|
|
234
|
+
throw new Error(`PR #${args.prId} not found.`);
|
|
235
|
+
const body = { version: pr.version };
|
|
236
|
+
if (args.message)
|
|
237
|
+
body.message = args.message;
|
|
238
|
+
const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/decline`, body);
|
|
239
|
+
if (!data)
|
|
240
|
+
return text(`Declined PR #${args.prId}.`);
|
|
241
|
+
return text(`Declined PR #${data.id}: "${data.title}".`);
|
|
242
|
+
}
|
|
243
|
+
async mergePr(args) {
|
|
244
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
245
|
+
const pr = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
|
|
246
|
+
if (!pr)
|
|
247
|
+
throw new Error(`PR #${args.prId} not found.`);
|
|
248
|
+
const body = { version: pr.version };
|
|
249
|
+
if (args.mergeStrategy)
|
|
250
|
+
body.strategyId = args.mergeStrategy;
|
|
251
|
+
if (args.message)
|
|
252
|
+
body.message = args.message;
|
|
253
|
+
const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/merge`, body);
|
|
254
|
+
if (!data)
|
|
255
|
+
return text(`Merged PR #${args.prId}.`);
|
|
256
|
+
return text(`Merged PR #${data.id}: "${data.title}" (${data.fromRef.displayId} → ${data.toRef.displayId}).`);
|
|
257
|
+
}
|
|
258
|
+
async getBranches(args) {
|
|
259
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
260
|
+
const qs = new URLSearchParams({ limit: String(args.limit ?? 25), start: String(args.start ?? 0) });
|
|
261
|
+
if (args.filter)
|
|
262
|
+
qs.set('filterText', args.filter);
|
|
263
|
+
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/branches?${qs}`);
|
|
264
|
+
if (!data || data.values.length === 0)
|
|
265
|
+
return text('No branches found.');
|
|
266
|
+
const lines = data.values.map((b) => `${b.displayId}${b.isDefault ? ' (default)' : ''} — ${b.latestCommit.slice(0, 8)}`);
|
|
267
|
+
return text(`${data.values.length} branch(es)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
268
|
+
}
|
|
269
|
+
async getFile(args) {
|
|
270
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
271
|
+
const qs = args.ref ? `?at=${encodeURIComponent(args.ref)}` : '';
|
|
272
|
+
const encodedPath = args.path.split('/').map(encodeURIComponent).join('/');
|
|
273
|
+
const content = await this.requestText(`/projects/${projectKey}/repos/${repoSlug}/raw/${encodedPath}${qs}`);
|
|
274
|
+
const MAX_CHARS = 10000;
|
|
275
|
+
if (content.length > MAX_CHARS) {
|
|
276
|
+
return text(content.slice(0, MAX_CHARS) + `\n\n... (truncated, ${content.length - MAX_CHARS} more chars)`);
|
|
277
|
+
}
|
|
278
|
+
return text(content);
|
|
279
|
+
}
|
|
280
|
+
async getPrComments(args) {
|
|
281
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
282
|
+
const { limit = 50, start = 0, state = 'OPEN' } = args;
|
|
283
|
+
const qs = new URLSearchParams({ limit: String(limit), start: String(start) });
|
|
284
|
+
qs.set('state', state);
|
|
285
|
+
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments?${qs}`);
|
|
286
|
+
if (!data || data.values.length === 0) {
|
|
287
|
+
return text(`No ${state} comments on PR #${args.prId}.`);
|
|
288
|
+
}
|
|
289
|
+
const blocks = data.values.flatMap((comment) => formatCommentThread(comment));
|
|
290
|
+
return text(`${data.values.length} comment thread(s) on PR #${args.prId}${pageHint(data)}:\n\n${blocks.join('\n\n')}`);
|
|
291
|
+
}
|
|
292
|
+
async addPrComment(args) {
|
|
293
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
294
|
+
const body = { text: args.text };
|
|
295
|
+
if (args.parentCommentId)
|
|
296
|
+
body.parent = { id: args.parentCommentId };
|
|
297
|
+
const created = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments`, body);
|
|
298
|
+
if (!created)
|
|
299
|
+
return text(`Comment added to PR #${args.prId}.`);
|
|
300
|
+
if (args.parentCommentId) {
|
|
301
|
+
return text(`Reply #${created.id} added to comment #${args.parentCommentId} on PR #${args.prId}.`);
|
|
302
|
+
}
|
|
303
|
+
return text(`Comment #${created.id} added to PR #${args.prId}.`);
|
|
304
|
+
}
|
|
305
|
+
async updatePrComment(args) {
|
|
306
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
307
|
+
if (!args.text && !args.state && !args.severity) {
|
|
308
|
+
throw new Error('At least one field is required: text, state, or severity');
|
|
309
|
+
}
|
|
310
|
+
const current = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}`);
|
|
311
|
+
if (!current)
|
|
312
|
+
throw new Error(`Comment #${args.commentId} not found.`);
|
|
313
|
+
const body = {
|
|
314
|
+
version: current.version,
|
|
315
|
+
text: args.text ?? current.text,
|
|
316
|
+
};
|
|
317
|
+
if (args.state)
|
|
318
|
+
body.state = args.state;
|
|
319
|
+
if (args.severity)
|
|
320
|
+
body.severity = args.severity;
|
|
321
|
+
const updated = await this.request('PUT', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}`, body);
|
|
322
|
+
if (!updated)
|
|
323
|
+
return text(`Comment #${args.commentId} updated.`);
|
|
324
|
+
const state = updated.state ?? current.state ?? 'OPEN';
|
|
325
|
+
const severity = updated.severity ?? current.severity ?? 'NORMAL';
|
|
326
|
+
return text(`Comment #${updated.id} updated (${state}/${severity}).`);
|
|
327
|
+
}
|
|
328
|
+
async deletePrComment(args) {
|
|
329
|
+
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
330
|
+
const current = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}`);
|
|
331
|
+
if (!current)
|
|
332
|
+
throw new Error(`Comment #${args.commentId} not found.`);
|
|
333
|
+
const path = `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}?version=${current.version}`;
|
|
334
|
+
await this.request('DELETE', path);
|
|
335
|
+
return text(`Comment #${args.commentId} deleted from PR #${args.prId}.`);
|
|
336
|
+
}
|
|
337
|
+
}
|