Skip to content

Commit 67bbea3

Browse files
authored
Merge pull request #17 from ThomasDepole/feature/bitbucket-pending-comments
Feature/bitbucket pending comments on pull request
2 parents 808770d + 49b7dab commit 67bbea3

File tree

3 files changed

+331
-5
lines changed

3 files changed

+331
-5
lines changed

packages/bitbucket/src/__tests__/bitbucket-service.test.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { BitbucketService } from '../bitbucket-service.js';
22
import { PullRequestsService } from '../bitbucket-client/index.js';
3+
import { request as mockRequest } from '../bitbucket-client/core/request.js';
34

45
// Mock the request function
56
jest.mock('../bitbucket-client/core/request.js', () => ({
@@ -14,6 +15,7 @@ jest.mock('../bitbucket-client/index.js', () => ({
1415
streamChanges1: jest.fn(),
1516
create: jest.fn(),
1617
update: jest.fn(),
18+
updateStatus: jest.fn(),
1719
getPage: jest.fn(),
1820
getReviewers: jest.fn(),
1921
get3: jest.fn()
@@ -1649,4 +1651,222 @@ describe('BitbucketService', () => {
16491651
expect(missingVars).toEqual([]);
16501652
});
16511653
});
1654+
1655+
describe('postPullRequestComment - pending flag', () => {
1656+
it('should include state: PENDING in the request body when pending is true', async () => {
1657+
const mockComment = { id: 99, text: 'Draft comment', author: { displayName: 'Test User' } };
1658+
(PullRequestsService.createComment2 as jest.Mock).mockResolvedValue(mockComment);
1659+
1660+
const result = await bitbucketService.postPullRequestComment(
1661+
mockProjectKey,
1662+
mockRepositorySlug,
1663+
mockPullRequestId,
1664+
'Draft comment',
1665+
undefined, // parentId
1666+
undefined, // filePath
1667+
undefined, // line
1668+
undefined, // lineType
1669+
true // pending
1670+
);
1671+
1672+
expect(result.success).toBe(true);
1673+
expect(PullRequestsService.createComment2).toHaveBeenCalledWith(
1674+
mockProjectKey,
1675+
mockPullRequestId,
1676+
mockRepositorySlug,
1677+
{ text: 'Draft comment', state: 'PENDING' }
1678+
);
1679+
});
1680+
1681+
it('should NOT include pending in the request body when pending is false or omitted', async () => {
1682+
const mockComment = { id: 100, text: 'Normal comment', author: { displayName: 'Test User' } };
1683+
(PullRequestsService.createComment2 as jest.Mock).mockResolvedValue(mockComment);
1684+
1685+
await bitbucketService.postPullRequestComment(
1686+
mockProjectKey,
1687+
mockRepositorySlug,
1688+
mockPullRequestId,
1689+
'Normal comment'
1690+
);
1691+
1692+
expect(PullRequestsService.createComment2).toHaveBeenCalledWith(
1693+
mockProjectKey,
1694+
mockPullRequestId,
1695+
mockRepositorySlug,
1696+
{ text: 'Normal comment' } // no pending field
1697+
);
1698+
});
1699+
1700+
it('should support state: PENDING combined with a file anchor', async () => {
1701+
const mockComment = { id: 101, text: 'Pending file comment', author: { displayName: 'Test User' } };
1702+
(PullRequestsService.createComment2 as jest.Mock).mockResolvedValue(mockComment);
1703+
1704+
await bitbucketService.postPullRequestComment(
1705+
mockProjectKey,
1706+
mockRepositorySlug,
1707+
mockPullRequestId,
1708+
'Pending file comment',
1709+
undefined, // parentId
1710+
'src/index.ts', // filePath
1711+
10, // line
1712+
'ADDED', // lineType
1713+
true // pending
1714+
);
1715+
1716+
expect(PullRequestsService.createComment2).toHaveBeenCalledWith(
1717+
mockProjectKey,
1718+
mockPullRequestId,
1719+
mockRepositorySlug,
1720+
{
1721+
text: 'Pending file comment',
1722+
state: 'PENDING',
1723+
anchor: {
1724+
path: 'src/index.ts',
1725+
diffType: 'EFFECTIVE',
1726+
line: 10,
1727+
lineType: 'ADDED',
1728+
fileType: 'TO'
1729+
}
1730+
}
1731+
);
1732+
});
1733+
});
1734+
1735+
describe('submitPullRequestReview', () => {
1736+
const mockUserSlug = 'test-user';
1737+
1738+
it('should submit a NEEDS_WORK review and call updateStatus with correct args', async () => {
1739+
const mockParticipant = {
1740+
user: { name: mockUserSlug },
1741+
role: 'REVIEWER',
1742+
status: 'NEEDS_WORK'
1743+
};
1744+
(PullRequestsService.updateStatus as jest.Mock).mockResolvedValue(mockParticipant);
1745+
1746+
const result = await bitbucketService.submitPullRequestReview(
1747+
mockProjectKey,
1748+
mockRepositorySlug,
1749+
mockPullRequestId,
1750+
mockUserSlug,
1751+
'NEEDS_WORK'
1752+
);
1753+
1754+
expect(result.success).toBe(true);
1755+
expect(result.data).toBe(mockParticipant);
1756+
expect(PullRequestsService.updateStatus).toHaveBeenCalledWith(
1757+
mockProjectKey,
1758+
mockUserSlug,
1759+
mockPullRequestId,
1760+
mockRepositorySlug,
1761+
{ status: 'NEEDS_WORK' }
1762+
);
1763+
});
1764+
1765+
it('should submit an APPROVED review', async () => {
1766+
const mockParticipant = { user: { name: mockUserSlug }, status: 'APPROVED' };
1767+
(PullRequestsService.updateStatus as jest.Mock).mockResolvedValue(mockParticipant);
1768+
1769+
const result = await bitbucketService.submitPullRequestReview(
1770+
mockProjectKey,
1771+
mockRepositorySlug,
1772+
mockPullRequestId,
1773+
mockUserSlug,
1774+
'APPROVED'
1775+
);
1776+
1777+
expect(result.success).toBe(true);
1778+
expect(PullRequestsService.updateStatus).toHaveBeenCalledWith(
1779+
mockProjectKey,
1780+
mockUserSlug,
1781+
mockPullRequestId,
1782+
mockRepositorySlug,
1783+
{ status: 'APPROVED' }
1784+
);
1785+
});
1786+
1787+
it('should include lastReviewedCommit when provided', async () => {
1788+
const mockParticipant = { user: { name: mockUserSlug }, status: 'NEEDS_WORK' };
1789+
(PullRequestsService.updateStatus as jest.Mock).mockResolvedValue(mockParticipant);
1790+
1791+
await bitbucketService.submitPullRequestReview(
1792+
mockProjectKey,
1793+
mockRepositorySlug,
1794+
mockPullRequestId,
1795+
mockUserSlug,
1796+
'NEEDS_WORK',
1797+
'abc123def456'
1798+
);
1799+
1800+
expect(PullRequestsService.updateStatus).toHaveBeenCalledWith(
1801+
mockProjectKey,
1802+
mockUserSlug,
1803+
mockPullRequestId,
1804+
mockRepositorySlug,
1805+
{ status: 'NEEDS_WORK', lastReviewedCommit: 'abc123def456' }
1806+
);
1807+
});
1808+
1809+
it('should handle API errors gracefully', async () => {
1810+
const mockError = new Error('Forbidden');
1811+
(PullRequestsService.updateStatus as jest.Mock).mockRejectedValue(mockError);
1812+
1813+
const result = await bitbucketService.submitPullRequestReview(
1814+
mockProjectKey,
1815+
mockRepositorySlug,
1816+
mockPullRequestId,
1817+
mockUserSlug,
1818+
'APPROVED'
1819+
);
1820+
1821+
expect(result.success).toBe(false);
1822+
expect(result.error).toBe('Forbidden');
1823+
});
1824+
});
1825+
1826+
describe('getUser', () => {
1827+
it('should fetch a user by exact slug', async () => {
1828+
const mockUser = { slug: 'jsmith', displayName: 'John Smith', emailAddress: 'jsmith@example.com' };
1829+
(mockRequest as jest.Mock).mockResolvedValue(mockUser);
1830+
1831+
const result = await bitbucketService.getUser('jsmith', undefined);
1832+
1833+
expect(result.success).toBe(true);
1834+
expect(result.data).toEqual(mockUser);
1835+
expect(mockRequest).toHaveBeenCalledWith(
1836+
expect.anything(),
1837+
expect.objectContaining({
1838+
method: 'GET',
1839+
url: '/api/latest/users/{userSlug}',
1840+
path: { userSlug: 'jsmith' }
1841+
})
1842+
);
1843+
});
1844+
1845+
it('should search for users by filter string', async () => {
1846+
const mockUsers = { values: [{ slug: 'jsmith', displayName: 'John Smith' }], size: 1, isLastPage: true };
1847+
(mockRequest as jest.Mock).mockResolvedValue(mockUsers);
1848+
1849+
const result = await bitbucketService.getUser(undefined, 'John');
1850+
1851+
expect(result.success).toBe(true);
1852+
expect(result.data).toEqual(mockUsers);
1853+
expect(mockRequest).toHaveBeenCalledWith(
1854+
expect.anything(),
1855+
expect.objectContaining({
1856+
method: 'GET',
1857+
url: '/api/latest/users',
1858+
query: { filter: 'John' }
1859+
})
1860+
);
1861+
});
1862+
1863+
it('should handle API errors gracefully', async () => {
1864+
(mockRequest as jest.Mock).mockRejectedValue(new Error('Not Found'));
1865+
1866+
const result = await bitbucketService.getUser('nonexistent', undefined);
1867+
1868+
expect(result.success).toBe(false);
1869+
expect(result.error).toBe('Not Found');
1870+
});
1871+
});
16521872
});

packages/bitbucket/src/bitbucket-service.ts

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,9 @@ export class BitbucketService {
256256
* @param filePath Optional file path for file-specific comments
257257
* @param line Optional line number for line-specific comments
258258
* @param lineType Optional line type ('ADDED', 'REMOVED', 'CONTEXT') for line comments
259+
* @param pending Optional flag to create a pending (draft) comment, not visible to others until a review is submitted.
260+
* Only works when filePath is provided (file-level or inline comments).
261+
* Top-level PR comments (no filePath) are always posted live regardless of this flag.
259262
* @returns Promise with created comment data
260263
*/
261264
async postPullRequestComment(
@@ -266,12 +269,19 @@ export class BitbucketService {
266269
parentId?: number,
267270
filePath?: string,
268271
line?: number,
269-
lineType?: 'ADDED' | 'REMOVED' | 'CONTEXT'
272+
lineType?: 'ADDED' | 'REMOVED' | 'CONTEXT',
273+
pending?: boolean
270274
) {
271275
const comment: any = {
272276
text
273277
};
274278

279+
// Mark comment as pending (draft) — not visible to others until review is submitted
280+
// Note: Bitbucket DC uses state:'PENDING' not pending:true
281+
if (pending) {
282+
comment.state = 'PENDING';
283+
}
284+
275285
// Add parent reference for replies
276286
if (parentId) {
277287
comment.parent = { id: parentId };
@@ -303,6 +313,69 @@ export class BitbucketService {
303313
);
304314
}
305315

316+
/**
317+
* Get a user by slug, or search for users by name/email filter
318+
* @param userSlug Optional exact slug to look up a specific user
319+
* @param filter Optional search string to find users by name or email
320+
* @returns Promise with user data
321+
*/
322+
async getUser(userSlug?: string, filter?: string) {
323+
if (userSlug) {
324+
return handleApiOperation(
325+
() => __request(OpenAPI, {
326+
method: 'GET',
327+
url: '/api/latest/users/{userSlug}',
328+
path: { userSlug },
329+
}),
330+
'Error fetching user'
331+
);
332+
}
333+
return handleApiOperation(
334+
() => __request(OpenAPI, {
335+
method: 'GET',
336+
url: '/api/latest/users',
337+
query: { filter },
338+
}),
339+
'Error fetching users'
340+
);
341+
}
342+
343+
/**
344+
* Submit a pull request review, publishing all pending (draft) comments and updating the reviewer's status.
345+
* This is the equivalent of clicking "Submit Review" in the Bitbucket UI.
346+
* @param projectKey The project key
347+
* @param repositorySlug The repository slug
348+
* @param pullRequestId The pull request ID
349+
* @param userSlug The username/slug of the reviewer submitting the review (the PAT token owner).
350+
* @param status The review verdict: 'APPROVED', 'NEEDS_WORK', or 'UNAPPROVED'
351+
* @param lastReviewedCommit Optional last reviewed commit hash (for tracking review progress)
352+
* @returns Promise with updated participant data
353+
*/
354+
async submitPullRequestReview(
355+
projectKey: string,
356+
repositorySlug: string,
357+
pullRequestId: string,
358+
userSlug: string,
359+
status: 'APPROVED' | 'NEEDS_WORK' | 'UNAPPROVED',
360+
lastReviewedCommit?: string
361+
) {
362+
const requestBody: any = {
363+
status,
364+
...(lastReviewedCommit ? { lastReviewedCommit } : {})
365+
};
366+
367+
return handleApiOperation(
368+
() => PullRequestsService.updateStatus(
369+
projectKey,
370+
userSlug,
371+
pullRequestId,
372+
repositorySlug,
373+
requestBody
374+
),
375+
'Error submitting pull request review'
376+
);
377+
}
378+
306379

307380
/**
308381
* Get text diff for a specific file in a pull request
@@ -615,7 +688,20 @@ export const bitbucketToolSchemas = {
615688
parentId: z.number().optional().describe("Parent comment ID for replies"),
616689
filePath: z.string().optional().describe("File path for file-specific comments"),
617690
line: z.number().optional().describe("Line number for line-specific comments"),
618-
lineType: z.enum(['ADDED', 'REMOVED', 'CONTEXT']).optional().describe("Line type for line comments")
691+
lineType: z.enum(['ADDED', 'REMOVED', 'CONTEXT']).optional().describe("Line type for line comments"),
692+
pending: z.boolean().optional().describe("If true, creates a pending (draft) comment not visible to others until the review is submitted via bitbucket_submitPullRequestReview. Only works when filePath is provided — top-level PR comments (no filePath) are always posted live.")
693+
},
694+
getUser: {
695+
userSlug: z.string().optional().describe("Exact slug of the user to look up (e.g. 'tdepole'). Use this to confirm a known slug or fetch a user's details."),
696+
filter: z.string().optional().describe("Search string to find users by name or email. Use this to discover a user's slug when it is not known.")
697+
},
698+
submitPullRequestReview: {
699+
projectKey: z.string().describe("The project key"),
700+
repositorySlug: z.string().describe("The repository slug"),
701+
pullRequestId: z.string().describe("The pull request ID"),
702+
userSlug: z.string().describe("The username/slug of the PAT token owner — the same user whose credentials are in BITBUCKET_API_TOKEN. Resolution order: (1) author.slug from any comment posted this session, (2) reviewers/participants array from getPullRequest, (3) bitbucket_getUser with a name/email filter."),
703+
status: z.enum(['APPROVED', 'NEEDS_WORK', 'UNAPPROVED']).describe("The review verdict: APPROVED, NEEDS_WORK, or UNAPPROVED"),
704+
lastReviewedCommit: z.string().optional().describe("Optional hash of the last commit reviewed, used to track review progress")
619705
},
620706
getPullRequestDiff: {
621707
projectKey: z.string().describe("The project key"),

packages/bitbucket/src/index.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,32 @@ server.tool(
112112
}
113113
);
114114

115+
server.tool(
116+
"bitbucket_getUser",
117+
"Get a Bitbucket user by their slug, or search for users by name/email to discover their slug. Use this to resolve userSlug for bitbucket_submitPullRequestReview when it is not already known from a comment response or PR participant list.",
118+
bitbucketToolSchemas.getUser,
119+
async ({ userSlug, filter }) => {
120+
const result = await bitbucketService.getUser(userSlug, filter);
121+
return formatToolResponse(result);
122+
}
123+
);
124+
115125
server.tool(
116126
"bitbucket_postPullRequestComment",
117-
"Post a comment to a Bitbucket pull request",
127+
"Post a comment to a Bitbucket pull request. Use pending: true to create a draft comment that is only visible to you until you call bitbucket_submitPullRequestReview. NOTE: pending only works when filePath is provided (file-level or inline comments). True top-level PR comments (no filePath) are always posted live and cannot be drafted.",
118128
bitbucketToolSchemas.postPullRequestComment,
119-
async ({ projectKey, repositorySlug, pullRequestId, text, parentId, filePath, line, lineType }) => {
120-
const result = await bitbucketService.postPullRequestComment(projectKey, repositorySlug, pullRequestId, text, parentId, filePath, line, lineType);
129+
async ({ projectKey, repositorySlug, pullRequestId, text, parentId, filePath, line, lineType, pending }) => {
130+
const result = await bitbucketService.postPullRequestComment(projectKey, repositorySlug, pullRequestId, text, parentId, filePath, line, lineType, pending);
131+
return formatToolResponse(result);
132+
}
133+
);
134+
135+
server.tool(
136+
"bitbucket_submitPullRequestReview",
137+
"Submit a pull request review, publishing all pending (draft) comments and setting the reviewer's verdict. This is equivalent to clicking 'Submit Review' in the Bitbucket UI. Use after posting comments with pending: true. To resolve userSlug: (1) check author.slug in any comment you posted this session, (2) check the reviewers/participants array from bitbucket_getPullRequest, or (3) call bitbucket_getUser with a name/email filter as a last resort.",
138+
bitbucketToolSchemas.submitPullRequestReview,
139+
async ({ projectKey, repositorySlug, pullRequestId, userSlug, status, lastReviewedCommit }) => {
140+
const result = await bitbucketService.submitPullRequestReview(projectKey, repositorySlug, pullRequestId, userSlug, status, lastReviewedCommit);
121141
return formatToolResponse(result);
122142
}
123143
);

0 commit comments

Comments
 (0)