Skip to content

Commit 0678a3f

Browse files
Merge branch 'main' into removing-redundant-safejaonparse
2 parents 9829c29 + 7661a1f commit 0678a3f

17 files changed

+390
-179
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22

33
## What's new in 4.0.27
44

5+
### Improvements
6+
7+
- **RovoDev**: Generalized MCP tool parsing in chat UI to support any MCP toolset via regex matching (`mcp__<name>__invoke_tool` / `mcp__<name>__get_tool_schema`) instead of hardcoded tool names
8+
59
### Bug Fixes
610

711
- Fixed "Please log in again" error message for disabled products so Jira-only and Bitbucket-only users do not see the other product's connection error on startup
812
- Fixed duplicate remote creation when checking out PR branches from forked repositories - the extension now reuses existing remotes that point to the same repository
13+
- **Bitbucket (and Jira) Cloud OAuth**: Fixed repeated disconnections after one or two operations. OAuth API clients were not using the auth interceptor
914

1015
## What's new in 4.0.25
1116

@@ -61,7 +66,7 @@
6166
- **RovoDev**: Fixed chat message not appearing when clicking "Fix with Rovo Dev" before the chat view is fully initialized - now waits for the webview to be ready before executing the chat command
6267
- Fixed "Cannot read properties of undefined (reading 'initiateApiTokenAuth')" error
6368
- Fixed the bug that prevented users from editing selected values in the landing page for Rovo Dev.
64-
- **RovoDev**: Hide chat action buttons during plan workflows and remove the Generate Code button when a plan is scrapped
69+
**RovoDev**: Hide chat action buttons during plan workflows and remove the Generate Code button when a plan is scrapped
6570

6671
## What's new in 4.0.22
6772

scripts/release.sh

Lines changed: 10 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,14 @@
11
#!/bin/bash
22
set -e
33

4-
# Check for dry flag (must be first argument)
5-
DRY_RUN=false
6-
if [ "$1" = "dry" ]; then
7-
DRY_RUN=true
8-
echo "========================================="
9-
echo "DRY RUN MODE - No remote push"
10-
echo "========================================="
11-
fi
12-
13-
echo "========================================="
14-
echo "Automated Stable Release Script"
15-
echo "========================================="
4+
git checkout main
5+
git pull origin main
166

177
# If no version provided, calculate it automatically
188
if [ -z "$VERSION" ]; then
19-
echo ""
209
echo "No version provided. Calculating next stable version..."
2110

22-
# Step 1: Always start from main
23-
echo ""
24-
echo "Step 1: Switching to main branch..."
25-
git checkout main
26-
27-
# Step 2: Fetch and update from remote
28-
echo ""
29-
echo "Step 2: Fetching and updating from remote..."
30-
git fetch origin
31-
git pull origin main
32-
git fetch --tags --force
33-
34-
# Step 3: Calculate the latest version number
35-
echo ""
36-
echo "Step 3: Calculating next stable version..."
11+
# Calculate the latest version number
3712
latest_stable_version=$(./scripts/version/get-latest-stable.sh)
3813
echo "Latest stable version: $latest_stable_version"
3914

@@ -47,111 +22,21 @@ if [ -z "$VERSION" ]; then
4722
VERSION="$major.$minor.$next_patch"
4823

4924
echo "Next stable version: $VERSION"
50-
else
51-
echo ""
52-
echo "Using provided version: $VERSION"
53-
54-
# Ensure we're on main and up to date
55-
echo ""
56-
echo "Switching to main branch and updating..."
57-
git checkout main
58-
git fetch origin
59-
git pull origin main
60-
git fetch --tags --force
6125
fi
6226

63-
# Validate the version is stable
64-
echo ""
65-
echo "Validating version is stable..."
27+
# call assert-stable.sh to check if the version is stable
6628
./scripts/version/assert-stable.sh $VERSION
6729

68-
# Create release branch first (before CHANGELOG update)
69-
echo ""
70-
echo "Creating release branch..."
71-
RELEASE_BRANCH="release/v$VERSION"
72-
git checkout -b $RELEASE_BRANCH
73-
30+
# Confirm that the CHANGELOG.md has been updated
7431
if ! grep -q "## What's new in $VERSION" CHANGELOG.md; then
7532
echo "CHANGELOG.md has not been updated. Please update CHANGELOG.md with the changes in this release."
7633
exit 1
7734
fi
78-
79-
# Update CHANGELOG.md if needed
80-
# echo ""
81-
# echo "Checking CHANGELOG.md..."
8235

83-
# Check if the third line is "## What's new in $VERSION"
84-
# Line 1: ### [Report an Issue](...)
85-
# Line 2: (blank)
86-
# Line 3: ## What's new in $VERSION
87-
# third_line=$(sed -n '3p' CHANGELOG.md)
88-
# expected_header="## What's new in $VERSION"
36+
# add v to the beginning of the version number
37+
VERSION="v$VERSION"
8938

90-
# if [ "$third_line" = "$expected_header" ]; then
91-
# echo "CHANGELOG.md already has latest version entry ✓"
92-
# else
93-
# echo "CHANGELOG.md needs update. Adding version entry..."
94-
95-
# # Add the new version entry after line 2 (after the blank line)
96-
# {
97-
# head -2 CHANGELOG.md
98-
# echo "## What's new in $VERSION"
99-
# echo ""
100-
# tail -n +3 CHANGELOG.md
101-
# } > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
102-
103-
# echo "Added '## What's new in $VERSION' to CHANGELOG.md ✓"
104-
105-
# # Commit the changelog update
106-
# git add CHANGELOG.md
107-
# git commit -m "chore: update CHANGELOG for v$VERSION"
108-
# fi
39+
MESSAGE=${2:-"Release $VERSION"}
10940

110-
# Add v to the beginning of the version number for tag
111-
VERSION_TAG="v$VERSION"
112-
MESSAGE=${3:-"Release $VERSION_TAG"}
113-
114-
echo ""
115-
echo "Creating tag $VERSION_TAG..."
116-
git tag $VERSION_TAG -m "$MESSAGE"
117-
118-
# Push to remote or show dry run message
119-
if [ "$DRY_RUN" = true ]; then
120-
echo ""
121-
echo "========================================="
122-
echo "DRY RUN - Branch and tag created locally"
123-
echo "========================================="
124-
echo ""
125-
echo "Created locally:"
126-
echo " - Branch: $RELEASE_BRANCH"
127-
echo " - Tag: $VERSION_TAG"
128-
echo ""
129-
echo "To push to remote, run without 'dry' flag:"
130-
echo " npm run release:stable"
131-
echo " or"
132-
echo " ./scripts/release.sh"
133-
echo ""
134-
echo "To clean up local artifacts, run:"
135-
echo " git checkout main && git branch -D $RELEASE_BRANCH && git tag -d $VERSION_TAG && git checkout CHANGELOG.md"
136-
else
137-
echo ""
138-
echo "Pushing to remote..."
139-
git push origin $RELEASE_BRANCH
140-
git push origin $VERSION_TAG
141-
142-
echo ""
143-
echo "========================================="
144-
echo "✓ Release $VERSION_TAG completed!"
145-
echo "========================================="
146-
echo ""
147-
echo "Release branch: $RELEASE_BRANCH"
148-
echo "Tag: $VERSION_TAG"
149-
echo ""
150-
echo "Next steps:"
151-
echo "1. Create a PR from $RELEASE_BRANCH to main"
152-
echo "2. Review and merge the PR"
153-
echo "3. The GitHub release workflow will trigger automatically"
154-
echo ""
155-
echo "To create a draft PR, visit:"
156-
echo "https://github.com/atlassian/atlascode/pull/new/$RELEASE_BRANCH"
157-
fi
41+
git tag $VERSION -m "$MESSAGE"
42+
git push origin $VERSION

src/atlclients/authStore.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,45 @@ describe('CredentialManager', () => {
798798
});
799799
});
800800

801+
describe('handleApiUnauthorized', () => {
802+
it('should return isOAuth true, not persist Invalid, and invoke callback when credentials are OAuth', async () => {
803+
const oauthInfo: OAuthInfo = {
804+
user: { id: 'user-id', displayName: 'User Name', email: 'user@example.com', avatarUrl: '' },
805+
state: AuthInfoState.Valid,
806+
access: 'access-token',
807+
refresh: 'refresh-token',
808+
expirationDate: Date.now() + Time.HOURS,
809+
recievedAt: Date.now(),
810+
};
811+
(Container.context.secrets.get as jest.Mock).mockResolvedValue(JSON.stringify(oauthInfo));
812+
const memStore = (credentialManager as any)._memStore;
813+
memStore.get(ProductJira.key).set(mockJiraSite.credentialId, oauthInfo);
814+
815+
const saveAuthInfoSpy = jest.spyOn(credentialManager as any, 'saveAuthInfo');
816+
const onOAuthApiUnauthorized = jest.fn();
817+
credentialManager.setOnOAuthApiUnauthorized(onOAuthApiUnauthorized);
818+
819+
const result = await credentialManager.handleApiUnauthorized(mockJiraSite);
820+
821+
expect(result).toEqual({ isOAuth: true });
822+
expect(saveAuthInfoSpy).not.toHaveBeenCalled();
823+
expect(onOAuthApiUnauthorized).toHaveBeenCalledWith(mockJiraSite);
824+
});
825+
826+
it('should persist Invalid and return isOAuth false when credentials are basic/API token', async () => {
827+
(Container.context.secrets.get as jest.Mock).mockResolvedValue(JSON.stringify(mockAuthInfo));
828+
const memStore = (credentialManager as any)._memStore;
829+
memStore.get(ProductJira.key).set(mockJiraSite.credentialId, mockAuthInfo);
830+
831+
const result = await credentialManager.handleApiUnauthorized(mockJiraSite);
832+
833+
expect(result).toEqual({ isOAuth: false });
834+
expect(Container.context.secrets.store).toHaveBeenCalled();
835+
const storedJson = (Container.context.secrets.store as jest.Mock).mock.calls[0][1];
836+
expect(JSON.parse(storedJson).state).toBe(AuthInfoState.Invalid);
837+
});
838+
});
839+
801840
describe('removeAuthInfo', () => {
802841
it('should remove auth info from memory and secret storage', async () => {
803842
// Setup memory store with auth info

src/atlclients/authStore.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export class CredentialManager implements Disposable {
6262
private negotiator: Negotiator;
6363
private _refreshInFlight = new Map<string, Promise<void>>();
6464
private _failedRefreshCache = new Map<string, FailedRefreshEntry>();
65+
private _onOAuthApiUnauthorized?: (site: DetailedSiteInfo) => void;
6566

6667
constructor(
6768
context: ExtensionContext,
@@ -72,6 +73,14 @@ export class CredentialManager implements Disposable {
7273
this.negotiator = new Negotiator(context.globalState);
7374
}
7475

76+
/**
77+
* Register a callback to run when an API 401/403 is handled for OAuth credentials. Used by ClientManager
78+
* to evict the cached client so the next request creates a new one and triggers token refresh.
79+
*/
80+
public setOnOAuthApiUnauthorized(callback: (site: DetailedSiteInfo) => void): void {
81+
this._onOAuthApiUnauthorized = callback;
82+
}
83+
7584
private _onDidAuthChange = new EventEmitter<AuthInfoEvent>();
7685
public get onDidAuthChange(): Event<AuthInfoEvent> {
7786
return this._onDidAuthChange.event;
@@ -639,6 +648,25 @@ export class CredentialManager implements Disposable {
639648
}
640649
}
641650

651+
/**
652+
* Handles 401/403 from an API call (not the token endpoint). For basic/API token auth, marks credentials
653+
* Invalid and persists. For OAuth, does not persist and invokes the registered callback so cached clients
654+
* can be evicted and the next request triggers token refresh.
655+
*/
656+
public async handleApiUnauthorized(site: DetailedSiteInfo): Promise<{ isOAuth: boolean }> {
657+
const authInfo = await this.getAuthInfoForProductAndCredentialId(site, true);
658+
if (!authInfo) {
659+
return { isOAuth: false };
660+
}
661+
if (isOAuthInfo(authInfo)) {
662+
this._onOAuthApiUnauthorized?.(site);
663+
return { isOAuth: true };
664+
}
665+
authInfo.state = AuthInfoState.Invalid;
666+
await this.saveAuthInfo(site, authInfo);
667+
return { isOAuth: false };
668+
}
669+
642670
/**
643671
* Removes an auth item from both the in-memory store and the secretstorage.
644672
*/

src/atlclients/basicInterceptor.test.ts

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ jest.mock('../logger');
1313
jest.mock('../constants', () => ({
1414
Commands: {
1515
ShowJiraAuth: 'atlascode.showJiraAuth',
16+
ShowBitbucketAuth: 'atlascode.showBitbucketAuth',
1617
},
1718
}));
1819

@@ -41,6 +42,7 @@ describe('BasicInterceptor', () => {
4142
mockAuthStore = {
4243
getAuthInfo: jest.fn(),
4344
saveAuthInfo: jest.fn(),
45+
handleApiUnauthorized: jest.fn(),
4446
} as any;
4547

4648
mockRequestInterceptorUse = jest.fn();
@@ -99,21 +101,14 @@ describe('BasicInterceptor', () => {
99101
});
100102

101103
describe('error interceptor', () => {
102-
it('should handle 401 error and mark credentials as invalid', async () => {
103-
const authInfo = expansionCastTo<any>({
104-
state: AuthInfoState.Valid,
105-
});
106-
mockAuthStore.getAuthInfo.mockResolvedValue(authInfo);
104+
it('should call handleApiUnauthorized on 401', async () => {
105+
mockAuthStore.handleApiUnauthorized.mockResolvedValue({ isOAuth: false });
107106

108107
const interceptor = new BasicInterceptor(mockSite, mockAuthStore);
109108
await interceptor.attachToAxios(mockAxiosInstance);
110109

111110
const errorInterceptor = mockResponseInterceptorUse.mock.calls[0][1];
112-
const error401 = {
113-
response: {
114-
status: 401,
115-
},
116-
};
111+
const error401 = { response: { status: 401 } };
117112

118113
await expect(errorInterceptor(error401)).rejects.toBe(error401);
119114

@@ -123,33 +118,22 @@ describe('BasicInterceptor', () => {
123118
{ modal: false },
124119
'Update Credentials',
125120
);
126-
expect(mockAuthStore.getAuthInfo).toHaveBeenCalledWith(mockSite);
121+
expect(mockAuthStore.handleApiUnauthorized).toHaveBeenCalledWith(mockSite);
127122
});
128123

129-
it('should handle 403 error and mark credentials as invalid', async () => {
130-
const authInfo = expansionCastTo<any>({
131-
state: AuthInfoState.Valid,
132-
});
133-
mockAuthStore.getAuthInfo.mockResolvedValue(authInfo);
124+
it('should call handleApiUnauthorized on 403', async () => {
125+
mockAuthStore.handleApiUnauthorized.mockResolvedValue({ isOAuth: false });
134126

135127
const interceptor = new BasicInterceptor(mockSite, mockAuthStore);
136128
await interceptor.attachToAxios(mockAxiosInstance);
137129

138130
const errorInterceptor = mockResponseInterceptorUse.mock.calls[0][1];
139-
const error403 = {
140-
response: {
141-
status: 403,
142-
},
143-
};
131+
const error403 = { response: { status: 403 } };
144132

145133
await expect(errorInterceptor(error403)).rejects.toBe(error403);
146134

147135
expect(mockedLogger.debug).toHaveBeenCalledWith('Received 403 - marking credentials as invalid');
148-
expect(mockedWindow.showErrorMessage).toHaveBeenCalledWith(
149-
`Credentials refused for ${mockSite.baseApiUrl}`,
150-
{ modal: false },
151-
'Update Credentials',
152-
);
136+
expect(mockAuthStore.handleApiUnauthorized).toHaveBeenCalledWith(mockSite);
153137
});
154138
});
155139

0 commit comments

Comments
 (0)