This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git
The following commit(s) were added to refs/heads/main by this push:
new 14a4e5bcd feat(jira): add Atlassian API Gateway authentication mode
(#9024)
14a4e5bcd is described below
commit 14a4e5bcdfbf21e85b05c6386757b02d8e770fb1
Author: Kashif Shoukat <[email protected]>
AuthorDate: Thu Jul 30 16:19:02 2026 +0000
feat(jira): add Atlassian API Gateway authentication mode (#9024)
- Add JIRA_GATEWAY_REGEX and a third connection mode 'Jira Cloud (Atlassian
API Gateway)' in the connection UI
- Auto-build endpoint from Cloud ID input: the user types only the Cloud ID
and the full gateway URL is constructed automatically
- Force authMethod=AccessToken for gateway connections; existing BasicAuth
path for standard cloud and server connections is unchanged
- Detect gateway endpoints on edit/load and pre-fill the Cloud ID field
- Suppress misleading /rest/ hint in testConnection() for api.atlassian.com
endpoints (a 404 there means a bad Cloud ID, not a missing /rest/ path)
- Add unit tests for gateway and standard-cloud connection flows
- Add frontend test suite for regex patterns and Cloud ID builder
No migration script required. No new DB columns.
All collectors, extractors, converters and domain models unchanged.
---
backend/plugins/jira/api/connection_api.go | 5 +-
backend/plugins/jira/api/connection_api_test.go | 144 +++++++++++++++++++++
.../register/jira/connection-fields/auth.test.ts | 144 +++++++++++++++++++++
.../register/jira/connection-fields/auth.tsx | 111 +++++++++++-----
4 files changed, 373 insertions(+), 31 deletions(-)
diff --git a/backend/plugins/jira/api/connection_api.go
b/backend/plugins/jira/api/connection_api.go
index 9b6071b15..209ab6c5c 100644
--- a/backend/plugins/jira/api/connection_api.go
+++ b/backend/plugins/jira/api/connection_api.go
@@ -58,7 +58,10 @@ func testConnection(ctx context.Context, connection
models.JiraConn) (*JiraTestC
}
serverInfoFail := "Failed testing the serverInfo: [ " +
res.Request.URL.String() + " ]"
// check if `/rest/` was missing
- if res.StatusCode == http.StatusNotFound &&
!strings.HasSuffix(connection.Endpoint, "/rest/") {
+ // Skip this hint for Atlassian API Gateway endpoints — they already
include /rest/ in the path
+ // and a 404 on serverInfo there indicates a wrong Cloud ID, not a
missing /rest/ segment.
+ isGatewayEndpoint := strings.Contains(connection.Endpoint,
"api.atlassian.com")
+ if res.StatusCode == http.StatusNotFound &&
!strings.HasSuffix(connection.Endpoint, "/rest/") && !isGatewayEndpoint {
endpointUrl, err := url.Parse(connection.Endpoint)
if err != nil {
return nil, errors.Convert(err)
diff --git a/backend/plugins/jira/api/connection_api_test.go
b/backend/plugins/jira/api/connection_api_test.go
new file mode 100644
index 000000000..e8ce6c0ad
--- /dev/null
+++ b/backend/plugins/jira/api/connection_api_test.go
@@ -0,0 +1,144 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/apache/incubator-devlake/core/config"
+ implcontext "github.com/apache/incubator-devlake/impls/context"
+ "github.com/apache/incubator-devlake/impls/logruslog"
+ "github.com/apache/incubator-devlake/plugins/jira/models"
+ "github.com/go-playground/validator/v10"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// initTestDeps wires up the package-level basicRes and vld.
+// DefaultBasicRes satisfies context.BasicRes and only needs config + logger;
+// dal can be nil because NewApiClientFromConnection never calls GetDal.
+func initTestDeps(t *testing.T) {
+ t.Helper()
+ basicRes = implcontext.NewDefaultBasicRes(config.GetConfig(),
logruslog.Global, nil)
+ vld = validator.New()
+}
+
+// serverInfoResponse is the minimal Jira serverInfo payload used in tests.
+type serverInfoResponse struct {
+ DeploymentType string `json:"deploymentType"`
+ Version string `json:"version"`
+ VersionNumbers []int `json:"versionNumbers"`
+}
+
+// newJiraTestServer creates an httptest.Server that responds to Jira REST
paths.
+// It records the Authorization header sent by the client for later assertion.
+func newJiraTestServer(t *testing.T, authHeaderOut *string) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+ if authHeaderOut != nil {
+ *authHeaderOut = r.Header.Get("Authorization")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/api/2/serverInfo"):
+ json.NewEncoder(w).Encode(serverInfoResponse{
+ DeploymentType: "Cloud",
+ Version: "1001.0.0",
+ VersionNumbers: []int{1001, 0, 0},
+ })
+ case strings.Contains(r.URL.Path, "/agile/1.0/board"):
+
json.NewEncoder(w).Encode(map[string]interface{}{"values": []interface{}{}})
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+}
+
+// TestTestConnection_Gateway verifies that a gateway-style endpoint with
+// authMethod=AccessToken sends "Authorization: Bearer <token>" and succeeds.
+func TestTestConnection_Gateway(t *testing.T) {
+ initTestDeps(t)
+
+ var capturedAuth string
+ srv := newJiraTestServer(t, &capturedAuth)
+ defer srv.Close()
+
+ conn := models.JiraConn{}
+ conn.Endpoint = srv.URL + "/rest/"
+ conn.AuthMethod = "AccessToken"
+ conn.Token = "my-scoped-gateway-token"
+
+ resp, err := testConnection(context.Background(), conn)
+ require.Nil(t, err, "testConnection should succeed for gateway
endpoint")
+ require.NotNil(t, resp)
+ assert.True(t, resp.Success)
+ assert.Equal(t, "Bearer my-scoped-gateway-token", capturedAuth,
+ "gateway mode must send Bearer token, not Basic credentials")
+}
+
+// TestTestConnection_StandardCloud verifies backward compatibility:
+// a standard atlassian.net endpoint with BasicAuth still works.
+func TestTestConnection_StandardCloud(t *testing.T) {
+ initTestDeps(t)
+
+ var capturedAuth string
+ srv := newJiraTestServer(t, &capturedAuth)
+ defer srv.Close()
+
+ conn := models.JiraConn{}
+ conn.Endpoint = srv.URL + "/rest/"
+ conn.AuthMethod = "BasicAuth"
+ conn.Username = "[email protected]"
+ conn.Password = "api-token-value"
+
+ resp, err := testConnection(context.Background(), conn)
+ require.Nil(t, err, "testConnection should succeed for standard cloud
endpoint")
+ require.NotNil(t, resp)
+ assert.True(t, resp.Success)
+ assert.True(t, strings.HasPrefix(capturedAuth, "Basic "),
+ "standard cloud mode must send Basic auth header")
+}
+
+// TestTestConnection_NonGateway404ShowsHint verifies that when a non-gateway
endpoint
+// is missing /rest/ and returns 404, the helpful hint message is included in
the error.
+func TestTestConnection_NonGateway404ShowsHint(t *testing.T) {
+ initTestDeps(t)
+
+ // Server that always returns 404
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer srv.Close()
+
+ // URL intentionally missing /rest/ — should trigger the "please try"
hint
+ connMissingRest := models.JiraConn{}
+ connMissingRest.Endpoint = srv.URL + "/"
+ connMissingRest.AuthMethod = "BasicAuth"
+ connMissingRest.Username = "u"
+ connMissingRest.Password = "p"
+
+ _, errMissingRest := testConnection(context.Background(),
connMissingRest)
+ require.NotNil(t, errMissingRest)
+ assert.Contains(t, errMissingRest.Error(), "please try",
+ "non-gateway 404 should include the /rest/ hint")
+}
diff --git a/config-ui/src/plugins/register/jira/connection-fields/auth.test.ts
b/config-ui/src/plugins/register/jira/connection-fields/auth.test.ts
new file mode 100644
index 000000000..0c43ba459
--- /dev/null
+++ b/config-ui/src/plugins/register/jira/connection-fields/auth.test.ts
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { describe, it, expect } from 'vitest';
+
+// ---------------------------------------------------------------------------
+// Regex constants (mirror exactly what auth.tsx defines)
+// ---------------------------------------------------------------------------
+const JIRA_CLOUD_REGEX = /^https:\/\/\w+\.atlassian\.net\/rest\/$/;
+const JIRA_GATEWAY_REGEX =
/^https:\/\/api\.atlassian\.com\/ex\/jira\/[^/]+\/rest\/$/;
+
+// ---------------------------------------------------------------------------
+// Endpoint builder (mirrors handleChangeCloudId in auth.tsx)
+// ---------------------------------------------------------------------------
+function buildGatewayEndpoint(cloudId: string): string {
+ return cloudId ? `https://api.atlassian.com/ex/jira/${cloudId}/rest/` : '';
+}
+
+// ---------------------------------------------------------------------------
+// Cloud ID extractor (mirrors the load-time useEffect in auth.tsx)
+// ---------------------------------------------------------------------------
+function extractCloudId(endpoint: string): string | null {
+ const match = endpoint.match(/\/ex\/jira\/([^/]+)\//);
+ return match ? match[1] : null;
+}
+
+// ---------------------------------------------------------------------------
+// JIRA_GATEWAY_REGEX tests
+// ---------------------------------------------------------------------------
+describe('JIRA_GATEWAY_REGEX', () => {
+ it('accepts a valid gateway URL with UUID Cloud ID', () => {
+
expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/a1b2c3d4-e5f6-7890-abcd-ef1234567890/rest/')).toBe(true);
+ });
+
+ it('accepts a valid gateway URL with short Cloud ID', () => {
+
expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/mycloud123/rest/')).toBe(true);
+ });
+
+ it('rejects gateway URL missing trailing slash', () => {
+
expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/abc123/rest')).toBe(false);
+ });
+
+ it('rejects gateway URL missing /rest/', () => {
+
expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/abc123/')).toBe(false);
+ });
+
+ it('rejects standard Jira Cloud URL', () => {
+
expect(JIRA_GATEWAY_REGEX.test('https://mycompany.atlassian.net/rest/')).toBe(false);
+ });
+
+ it('rejects Jira Server URL', () => {
+
expect(JIRA_GATEWAY_REGEX.test('https://jira.mycompany.com/rest/')).toBe(false);
+ });
+
+ it('rejects gateway URL with extra path segment after /rest/', () => {
+
expect(JIRA_GATEWAY_REGEX.test('https://api.atlassian.com/ex/jira/abc123/rest/extra/')).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// JIRA_CLOUD_REGEX regression tests — must not break existing behaviour
+// ---------------------------------------------------------------------------
+describe('JIRA_CLOUD_REGEX (regression)', () => {
+ it('accepts a standard Jira Cloud URL', () => {
+
expect(JIRA_CLOUD_REGEX.test('https://mycompany.atlassian.net/rest/')).toBe(true);
+ });
+
+ it('rejects gateway URL', () => {
+
expect(JIRA_CLOUD_REGEX.test('https://api.atlassian.com/ex/jira/abc123/rest/')).toBe(false);
+ });
+
+ it('rejects Jira Server URL', () => {
+
expect(JIRA_CLOUD_REGEX.test('https://jira.mycompany.com/rest/')).toBe(false);
+ });
+
+ it('rejects cloud URL missing trailing slash', () => {
+
expect(JIRA_CLOUD_REGEX.test('https://mycompany.atlassian.net/rest')).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// buildGatewayEndpoint tests
+// ---------------------------------------------------------------------------
+describe('buildGatewayEndpoint', () => {
+ it('builds the correct URL from a UUID Cloud ID', () => {
+ expect(buildGatewayEndpoint('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).toBe(
+
'https://api.atlassian.com/ex/jira/a1b2c3d4-e5f6-7890-abcd-ef1234567890/rest/',
+ );
+ });
+
+ it('builds the correct URL from a short Cloud ID', () => {
+ expect(buildGatewayEndpoint('mycloud123')).toBe(
+ 'https://api.atlassian.com/ex/jira/mycloud123/rest/',
+ );
+ });
+
+ it('returns empty string for empty Cloud ID', () => {
+ expect(buildGatewayEndpoint('')).toBe('');
+ });
+
+ it('produced URL matches JIRA_GATEWAY_REGEX', () => {
+ const url = buildGatewayEndpoint('test-cloud-id');
+ expect(JIRA_GATEWAY_REGEX.test(url)).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// extractCloudId tests — verifies the edit-mode useEffect can pre-fill Cloud
ID
+// ---------------------------------------------------------------------------
+describe('extractCloudId', () => {
+ it('extracts UUID Cloud ID from a saved gateway endpoint', () => {
+
expect(extractCloudId('https://api.atlassian.com/ex/jira/a1b2c3d4-e5f6-7890-abcd-ef1234567890/rest/')).toBe(
+ 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
+ );
+ });
+
+ it('extracts short Cloud ID', () => {
+
expect(extractCloudId('https://api.atlassian.com/ex/jira/mycloud123/rest/')).toBe('mycloud123');
+ });
+
+ it('returns null for a standard cloud URL', () => {
+ expect(extractCloudId('https://mycompany.atlassian.net/rest/')).toBeNull();
+ });
+
+ it('round-trips: build then extract returns original Cloud ID', () => {
+ const id = 'round-trip-id-123';
+ expect(extractCloudId(buildGatewayEndpoint(id))).toBe(id);
+ });
+});
diff --git a/config-ui/src/plugins/register/jira/connection-fields/auth.tsx
b/config-ui/src/plugins/register/jira/connection-fields/auth.tsx
index bc43a6ace..d7f0d6251 100644
--- a/config-ui/src/plugins/register/jira/connection-fields/auth.tsx
+++ b/config-ui/src/plugins/register/jira/connection-fields/auth.tsx
@@ -23,8 +23,10 @@ import { Radio, Input } from 'antd';
import { Block, ExternalLink } from '@/components';
import { DOC_URL } from '@/release';
-const JIRA_CLOUD_REGEX = /^https:\/\/\w+.atlassian.net\/rest\/$/;
+const JIRA_CLOUD_REGEX = /^https:\/\/\w+\.atlassian\.net\/rest\/$/;
+const JIRA_GATEWAY_REGEX =
/^https:\/\/api\.atlassian\.com\/ex\/jira\/[^/]+\/rest\/$/;
+type JiraVersion = 'cloud' | 'gateway' | 'server';
type Method = 'BasicAuth' | 'AccessToken';
interface Props {
@@ -37,10 +39,17 @@ interface Props {
}
export const Auth = ({ type, initialValues, values, setValues, setErrors }:
Props) => {
- const [version, setVersion] = useState('cloud');
+ const [version, setVersion] = useState<JiraVersion>('cloud');
+ const [cloudId, setCloudId] = useState('');
useEffect(() => {
- if (initialValues.endpoint &&
!JIRA_CLOUD_REGEX.test(initialValues.endpoint)) {
+ if (!initialValues.endpoint) return;
+ if (JIRA_GATEWAY_REGEX.test(initialValues.endpoint)) {
+ setVersion('gateway');
+ // Extract the Cloud ID from the saved endpoint so the field pre-fills
on edit
+ const match = initialValues.endpoint.match(/\/ex\/jira\/([^/]+)\//);
+ if (match) setCloudId(match[1]);
+ } else if (!JIRA_CLOUD_REGEX.test(initialValues.endpoint)) {
setVersion('server');
}
}, [initialValues.endpoint]);
@@ -73,17 +82,17 @@ export const Auth = ({ type, initialValues, values,
setValues, setErrors }: Prop
}, [values]);
const handleChangeVersion = (e: RadioChangeEvent) => {
- const version = e.target.value;
-
+ const v = e.target.value as JiraVersion;
+ setCloudId('');
setValues({
endpoint: '',
- authMethod: 'BasicAuth',
+ // Gateway always uses AccessToken (scoped API token); others default to
BasicAuth
+ authMethod: v === 'gateway' ? 'AccessToken' : 'BasicAuth',
username: undefined,
password: undefined,
token: undefined,
});
-
- setVersion(version);
+ setVersion(v);
};
const handleChangeEndpoint = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -92,6 +101,16 @@ export const Auth = ({ type, initialValues, values,
setValues, setErrors }: Prop
});
};
+ const handleChangeCloudId = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const id = e.target.value.trim();
+ setCloudId(id);
+ // Auto-construct the gateway endpoint from the Cloud ID so the user never
+ // has to type the full URL manually.
+ setValues({
+ endpoint: id ? `https://api.atlassian.com/ex/jira/${id}/rest/` : '',
+ });
+ };
+
const handleChangeMethod = (e: RadioChangeEvent) => {
setValues({
authMethod: (e.target as HTMLInputElement).value as Method,
@@ -124,33 +143,65 @@ export const Auth = ({ type, initialValues, values,
setValues, setErrors }: Prop
<Block title="Jira Version" required>
<Radio.Group value={version} onChange={handleChangeVersion}>
<Radio value="cloud">Jira Cloud</Radio>
+ <Radio value="gateway">Jira Cloud (Atlassian API Gateway)</Radio>
<Radio value="server">Jira Server / Jira Data Center</Radio>
</Radio.Group>
- <Block
- style={{ marginTop: 8, marginBottom: 0 }}
- title="Endpoint URL"
- description={
- <>
- {version === 'cloud'
- ? 'Provide the Jira instance API endpoint. For Jira Cloud,
e.g. https://your-company.atlassian.net/rest/. Please note that the endpoint
URL should end with /.'
- : ''}
- {version === 'server'
- ? 'Provide the Jira instance API endpoint. For Jira Server /
Jira Data Center, e.g. https://jira.your-company.com/rest/. Please note that
the endpoint URL should end with /.'
- : ''}
- </>
- }
- required
- >
- <Input
- style={{ width: 386 }}
- placeholder="Your Endpoint URL"
- value={values.endpoint}
- onChange={handleChangeEndpoint}
- />
- </Block>
+ {version !== 'gateway' && (
+ <Block
+ style={{ marginTop: 8, marginBottom: 0 }}
+ title="Endpoint URL"
+ description={
+ <>
+ {version === 'cloud'
+ ? 'Provide the Jira instance API endpoint. For Jira Cloud,
e.g. https://your-company.atlassian.net/rest/. Please note that the endpoint
URL should end with /.'
+ : ''}
+ {version === 'server'
+ ? 'Provide the Jira instance API endpoint. For Jira Server /
Jira Data Center, e.g. https://jira.your-company.com/rest/. Please note that
the endpoint URL should end with /.'
+ : ''}
+ </>
+ }
+ required
+ >
+ <Input
+ style={{ width: 386 }}
+ placeholder="Your Endpoint URL"
+ value={values.endpoint}
+ onChange={handleChangeEndpoint}
+ />
+ </Block>
+ )}
</Block>
+ {version === 'gateway' && (
+ <>
+ <Block
+ title="Cloud ID"
+ description="Your Atlassian Cloud ID. Find it at
admin.atlassian.com → Settings → Organisation."
+ required
+ >
+ <Input
+ style={{ width: 386 }}
+ placeholder="e.g. a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+ value={cloudId}
+ onChange={handleChangeCloudId}
+ />
+ </Block>
+ <Block
+ title="Scoped API Token"
+ description="API token for your Atlassian Managed Service Account
with Jira read scopes."
+ required
+ >
+ <Input.Password
+ style={{ width: 386 }}
+ placeholder={type === 'update' ? '********' : 'Your Scoped API
Token'}
+ value={values.token}
+ onChange={handleChangeToken}
+ />
+ </Block>
+ </>
+ )}
+
{version === 'cloud' && (
<>
<Block title="E-Mail" required>