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>

Reply via email to