feat(jira): add OAuth 2.1 with PKCE via Atlassian MCP server (#2805)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -43,10 +44,33 @@ type CloudClient struct {
|
||||
siteURL string
|
||||
email string
|
||||
secret string
|
||||
secretMu sync.RWMutex // guards secret for concurrent read/write
|
||||
authMethod string
|
||||
instanceType string
|
||||
apiBase string // "/rest/api/3" for cloud, "/rest/api/2" for server.
|
||||
maxBodySize int64
|
||||
// refresher enables on-demand token refresh for OAuth. When non-nil and a
|
||||
// request gets 401, the client refreshes the access token and retries once.
|
||||
refresher TokenRefresher
|
||||
// workspaceID identifies the workspace for token refresh.
|
||||
workspaceID string
|
||||
}
|
||||
|
||||
// TokenRefresher is implemented by the Service to allow CloudClient to refresh
|
||||
// OAuth access tokens on-demand when a request hits a 401.
|
||||
type TokenRefresher interface {
|
||||
// staleToken is the access token that just hit a 401; the refresher skips
|
||||
// rotation if the stored token already changed (another goroutine won the
|
||||
// race). Pass "" to force a refresh.
|
||||
RefreshOAuthToken(ctx context.Context, workspaceID, staleToken string) error
|
||||
RevealAccessToken(ctx context.Context, workspaceID string) (string, error)
|
||||
}
|
||||
|
||||
// SetRefresher wires the OAuth token refresher. Called by the service after
|
||||
// constructing the client so the client can trigger a refresh on 401.
|
||||
func (c *CloudClient) SetRefresher(workspaceID string, r TokenRefresher) {
|
||||
c.workspaceID = workspaceID
|
||||
c.refresher = r
|
||||
}
|
||||
|
||||
// NewCloudClient builds a client from a JiraConfig + secret. siteURL is
|
||||
@@ -97,14 +121,19 @@ const userAgent = "kandev/1.0 (+https://github.com/kdlbs/kandev)"
|
||||
func (c *CloudClient) authorize(req *http.Request) {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
c.secretMu.RLock()
|
||||
secret := c.secret
|
||||
c.secretMu.RUnlock()
|
||||
switch c.authMethod {
|
||||
case AuthMethodAPIToken:
|
||||
basic := base64.StdEncoding.EncodeToString([]byte(c.email + ":" + c.secret))
|
||||
basic := base64.StdEncoding.EncodeToString([]byte(c.email + ":" + secret))
|
||||
req.Header.Set("Authorization", "Basic "+basic)
|
||||
case AuthMethodPAT:
|
||||
req.Header.Set("Authorization", "Bearer "+c.secret)
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
case AuthMethodSessionCookie:
|
||||
req.Header.Set("Cookie", buildSessionCookieHeader(c.secret))
|
||||
req.Header.Set("Cookie", buildSessionCookieHeader(secret))
|
||||
case AuthMethodOAuth:
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +147,46 @@ func buildSessionCookieHeader(secret string) string {
|
||||
|
||||
// do executes a request and decodes a 2xx JSON body into out (may be nil).
|
||||
// Non-2xx responses are returned as *APIError so callers can switch on status.
|
||||
// For OAuth auth, a 401 triggers an automatic token refresh and single retry.
|
||||
func (c *CloudClient) do(ctx context.Context, method, path string, body interface{}, out interface{}) error {
|
||||
err := c.doOnce(ctx, method, path, body, out)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if c.tryRefreshOn401(ctx, err) {
|
||||
return c.doOnce(ctx, method, path, body, out)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// tryRefreshOn401 checks if the error is a 401 and refreshes the OAuth token.
|
||||
// Returns true if the token was refreshed and the caller should retry.
|
||||
func (c *CloudClient) tryRefreshOn401(ctx context.Context, err error) bool {
|
||||
if c.authMethod != AuthMethodOAuth || c.refresher == nil {
|
||||
return false
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnauthorized {
|
||||
return false
|
||||
}
|
||||
c.secretMu.RLock()
|
||||
stale := c.secret
|
||||
c.secretMu.RUnlock()
|
||||
if refreshErr := c.refresher.RefreshOAuthToken(ctx, c.workspaceID, stale); refreshErr != nil {
|
||||
return false
|
||||
}
|
||||
newToken, revealErr := c.refresher.RevealAccessToken(ctx, c.workspaceID)
|
||||
if revealErr != nil {
|
||||
return false
|
||||
}
|
||||
c.secretMu.Lock()
|
||||
c.secret = newToken
|
||||
c.secretMu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// doOnce executes a single request attempt without retry logic.
|
||||
func (c *CloudClient) doOnce(ctx context.Context, method, path string, body interface{}, out interface{}) error {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
|
||||
@@ -14,6 +14,10 @@ var ErrSameWorkspace = errors.New("jira: source and target workspaces are the sa
|
||||
// ErrNothingToCopy is returned when the source workspace has no Jira config.
|
||||
var ErrNothingToCopy = errors.New("jira: source workspace has no configuration to copy")
|
||||
|
||||
// ErrOAuthConfigCopyUnsupported prevents two workspaces from sharing one
|
||||
// rotating refresh-token lineage. The target must complete its own OAuth flow.
|
||||
var ErrOAuthConfigCopyUnsupported = errors.New("jira: OAuth connections cannot be copied; reconnect the target workspace")
|
||||
|
||||
// CopyConfigToWorkspace copies the Jira provider config and credential (token)
|
||||
// from sourceWorkspaceID to targetWorkspaceID. Watchers are intentionally out
|
||||
// of scope — only the connection settings and secret are duplicated.
|
||||
@@ -41,6 +45,9 @@ func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID,
|
||||
if cfg == nil {
|
||||
return nil, ErrNothingToCopy
|
||||
}
|
||||
if cfg.AuthMethod == AuthMethodOAuth {
|
||||
return nil, ErrOAuthConfigCopyUnsupported
|
||||
}
|
||||
secret, err := s.revealSecret(ctx, sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source jira secret: %w", err)
|
||||
|
||||
@@ -3,6 +3,7 @@ package jira
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -67,3 +68,24 @@ func TestCopyConfigToWorkspace_NothingToCopy(t *testing.T) {
|
||||
t.Fatalf("expected ErrNothingToCopy, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_RejectsOAuthConnection(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
ctx := context.Background()
|
||||
const src = "ws-oauth"
|
||||
if err := f.store.UpsertConfigForWorkspace(ctx, src, &JiraConfig{
|
||||
WorkspaceID: src,
|
||||
SiteURL: "https://acme.atlassian.net", AuthMethod: AuthMethodOAuth,
|
||||
InstanceType: InstanceTypeCloud, ClientID: "client-1", CloudID: "cloud-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed OAuth config: %v", err)
|
||||
}
|
||||
if err := f.secrets.Set(ctx, OAuthAccessTokenKeyForWorkspace(src), "access", "token-a"); err != nil {
|
||||
t.Fatalf("seed OAuth token: %v", err)
|
||||
}
|
||||
|
||||
_, err := f.svc.CopyConfigToWorkspace(ctx, src, "ws-target")
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "oauth") {
|
||||
t.Fatalf("expected an explicit OAuth copy rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
||||
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch)
|
||||
api.GET("/watches/issue/:id/reset/preview", c.httpPreviewResetIssueWatch)
|
||||
api.POST("/watches/issue/:id/reset", c.httpResetIssueWatch)
|
||||
|
||||
// OAuth 2.0 (3LO) flow — start, callback, manual refresh.
|
||||
api.GET("/oauth/start", c.httpOAuthStart)
|
||||
api.GET("/oauth/callback", c.httpOAuthCallback)
|
||||
api.POST("/oauth/refresh", c.httpOAuthRefresh)
|
||||
}
|
||||
|
||||
// --- HTTP handlers ---
|
||||
@@ -235,7 +240,8 @@ func (c *Controller) httpCopyConfig(ctx *gin.Context) {
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy), errors.Is(err, ErrInvalidConfig):
|
||||
case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy),
|
||||
errors.Is(err, ErrOAuthConfigCopyUnsupported), errors.Is(err, ErrInvalidConfig):
|
||||
status = http.StatusBadRequest
|
||||
case workspaceDenied(err):
|
||||
status = http.StatusNotFound
|
||||
@@ -586,3 +592,102 @@ func wsListProjects(svc *Service) func(ctx context.Context, msg *ws.Message) (*w
|
||||
return wsReply(msg, gin.H{"projects": projects})
|
||||
}
|
||||
}
|
||||
|
||||
// --- OAuth handlers ---
|
||||
|
||||
// httpOAuthStart initiates the OAuth 2.0 (3LO) flow. Returns the Atlassian
|
||||
// authorization URL for the frontend to open in a popup/redirect.
|
||||
func (c *Controller) httpOAuthStart(ctx *gin.Context) {
|
||||
workspaceID := c.workspaceID(ctx)
|
||||
if workspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"})
|
||||
return
|
||||
}
|
||||
if err := c.service.authorizeWorkspaceAccess(ctx.Request.Context(), workspaceID); err != nil {
|
||||
if workspaceDenied(err) {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
} else {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
siteURL := ctx.Query("siteUrl")
|
||||
if siteURL == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "siteUrl required"})
|
||||
return
|
||||
}
|
||||
redirectURI := c.service.oauthRedirectURI
|
||||
if redirectURI == "" {
|
||||
redirectURI = "http://localhost:38429/api/v1/jira/oauth/callback"
|
||||
}
|
||||
authURL, err := c.service.StartOAuthFlow(ctx.Request.Context(), workspaceID, siteURL, redirectURI)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"authUrl": authURL})
|
||||
}
|
||||
|
||||
// httpOAuthCallback handles the redirect from Atlassian after user consent.
|
||||
// When called via browser redirect (popup), returns an HTML page that uses
|
||||
// postMessage to send the code back to the opener window, then auto-closes.
|
||||
// When called via fetch (paste-URL flow), returns JSON.
|
||||
func (c *Controller) httpOAuthCallback(ctx *gin.Context) {
|
||||
code := ctx.Query("code")
|
||||
state := ctx.Query("state")
|
||||
if code == "" || state == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "code and state required"})
|
||||
return
|
||||
}
|
||||
// If this is a fetch/API request (paste-URL flow), return JSON.
|
||||
if ctx.Query("api") == "1" {
|
||||
_, err := c.service.CompleteOAuthFlow(ctx.Request.Context(), code, state)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
// Browser redirect (popup flow): return an HTML page that postMessages
|
||||
// the code+state back to the opener window, then closes itself.
|
||||
// The opener (Kandev settings page) listens for the message and completes
|
||||
// the OAuth flow via the API endpoint.
|
||||
ctx.Header("Content-Type", "text/html; charset=utf-8")
|
||||
ctx.String(http.StatusOK, `<!doctype html>
|
||||
<html><body>
|
||||
<p>Authorization complete. You can close this window.</p>
|
||||
<script>
|
||||
(function() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var data = { type: "jira-oauth-callback", code: params.get("code"), state: params.get("state") };
|
||||
if (window.opener) {
|
||||
window.opener.postMessage(data, window.location.origin);
|
||||
setTimeout(function() { window.close(); }, 100);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body></html>`)
|
||||
}
|
||||
|
||||
// httpOAuthRefresh manually triggers a token refresh for testing/debugging.
|
||||
func (c *Controller) httpOAuthRefresh(ctx *gin.Context) {
|
||||
workspaceID := c.workspaceID(ctx)
|
||||
if workspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"})
|
||||
return
|
||||
}
|
||||
if err := c.service.authorizeWorkspaceAccess(ctx.Request.Context(), workspaceID); err != nil {
|
||||
if workspaceDenied(err) {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
} else {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := c.service.RefreshOAuthToken(ctx.Request.Context(), workspaceID, ""); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
package jira
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mcpclient "github.com/mark3labs/mcp-go/client"
|
||||
mcptransport "github.com/mark3labs/mcp-go/client/transport"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// MCPClient implements the Client interface by calling Jira APIs through
|
||||
// the Atlassian MCP server (mcp.atlassian.com/v1/mcp/authv2) using JSON-RPC
|
||||
// over the MCP Streamable HTTP transport. The OAuth token from the MCP
|
||||
// OAuth 2.1 flow authenticates the session — it does NOT work for direct
|
||||
// Jira REST API calls, so we route through the MCP server.
|
||||
type MCPClient struct {
|
||||
http *http.Client
|
||||
accessToken string
|
||||
tokenMu sync.RWMutex // guards accessToken
|
||||
workspaceID string
|
||||
siteURL string
|
||||
sessionMu sync.Mutex // guards SDK client initialization and replacement
|
||||
session *mcpclient.Client
|
||||
cloudID string
|
||||
refresher TokenRefresher
|
||||
}
|
||||
|
||||
const (
|
||||
mcpEndpoint = "https://mcp.atlassian.com/v1/mcp/authv2"
|
||||
mcpProtocol = "2025-06-18"
|
||||
)
|
||||
|
||||
// NewMCPClient builds an MCP-backed Jira client.
|
||||
func NewMCPClient(accessToken, cloudID, workspaceID, siteURL string) *MCPClient {
|
||||
site := strings.TrimRight(siteURL, "/")
|
||||
return &MCPClient{
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &mcpStatusRoundTripper{base: http.DefaultTransport},
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
accessToken: accessToken,
|
||||
workspaceID: workspaceID,
|
||||
siteURL: site,
|
||||
cloudID: cloudID,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPClient) getToken() string {
|
||||
c.tokenMu.RLock()
|
||||
defer c.tokenMu.RUnlock()
|
||||
return c.accessToken
|
||||
}
|
||||
|
||||
func (c *MCPClient) setToken(t string) {
|
||||
c.tokenMu.Lock()
|
||||
defer c.tokenMu.Unlock()
|
||||
c.accessToken = t
|
||||
}
|
||||
|
||||
// SetRefresher wires the OAuth token refresher for 401 auto-refresh.
|
||||
func (c *MCPClient) SetRefresher(workspaceID string, r TokenRefresher) {
|
||||
c.workspaceID = workspaceID
|
||||
c.refresher = r
|
||||
}
|
||||
|
||||
type mcpAccessTokenContextKey struct{}
|
||||
|
||||
type mcpStatusRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *mcpStatusRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
base := t.base
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
resp, err := base.RoundTrip(req)
|
||||
if err != nil || resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
return resp, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return nil, &APIError{StatusCode: http.StatusUnauthorized, Message: string(body)}
|
||||
}
|
||||
|
||||
func withMCPAccessToken(ctx context.Context, token string) context.Context {
|
||||
return context.WithValue(ctx, mcpAccessTokenContextKey{}, token)
|
||||
}
|
||||
|
||||
// call sends a JSON-RPC request. On 401, refreshes token and retries once.
|
||||
func (c *MCPClient) call(ctx context.Context, method string, params interface{}) (string, error) {
|
||||
staleToken := c.getToken()
|
||||
requestCtx := withMCPAccessToken(ctx, staleToken)
|
||||
result, err := c.callOnce(requestCtx, method, params)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if errors.Is(err, mcptransport.ErrSessionTerminated) {
|
||||
c.resetSession()
|
||||
return c.callOnce(requestCtx, method, params)
|
||||
}
|
||||
if c.tryRefreshOn401(ctx, err, staleToken) {
|
||||
return c.callOnce(withMCPAccessToken(ctx, c.getToken()), method, params)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// tryRefreshOn401 refreshes the OAuth token on 401 and resets the session.
|
||||
// Returns true if the caller should retry.
|
||||
func (c *MCPClient) tryRefreshOn401(ctx context.Context, err error, staleToken string) bool {
|
||||
if c.refresher == nil {
|
||||
return false
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.StatusCode != 401 {
|
||||
return false
|
||||
}
|
||||
if refreshErr := c.refresher.RefreshOAuthToken(ctx, c.workspaceID, staleToken); refreshErr != nil {
|
||||
return false
|
||||
}
|
||||
newToken, revealErr := c.refresher.RevealAccessToken(ctx, c.workspaceID)
|
||||
if revealErr != nil {
|
||||
return false
|
||||
}
|
||||
c.setToken(newToken)
|
||||
c.resetSession()
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *MCPClient) callOnce(ctx context.Context, method string, params interface{}) (string, error) {
|
||||
client, err := c.ensureSession(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if method != "tools/call" {
|
||||
return "", fmt.Errorf("unsupported MCP method %q", method)
|
||||
}
|
||||
requestParams, ok := params.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", errors.New("MCP tools/call params must be an object")
|
||||
}
|
||||
toolName, _ := requestParams["name"].(string)
|
||||
if toolName == "" {
|
||||
return "", errors.New("MCP tool name is required")
|
||||
}
|
||||
result, err := client.CallTool(ctx, mcp.CallToolRequest{Params: mcp.CallToolParams{
|
||||
Name: toolName, Arguments: requestParams["arguments"],
|
||||
}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := firstMCPText(result)
|
||||
if result.IsError {
|
||||
if text != "" {
|
||||
return "", fmt.Errorf("MCP tool error: %s", text)
|
||||
}
|
||||
return "", errors.New("MCP tool error (no details)")
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func firstMCPText(result *mcp.CallToolResult) string {
|
||||
for _, content := range result.Content {
|
||||
switch text := content.(type) {
|
||||
case mcp.TextContent:
|
||||
return text.Text
|
||||
case *mcp.TextContent:
|
||||
return text.Text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ensureSession creates and initializes the SDK client once. The SDK owns
|
||||
// protocol negotiation, initialized notification, response demultiplexing,
|
||||
// and the Streamable HTTP session identifier.
|
||||
func (c *MCPClient) ensureSession(ctx context.Context) (*mcpclient.Client, error) {
|
||||
c.sessionMu.Lock()
|
||||
defer c.sessionMu.Unlock()
|
||||
if c.session != nil {
|
||||
return c.session, nil
|
||||
}
|
||||
client, err := mcpclient.NewStreamableHttpClient(
|
||||
mcpEndpoint,
|
||||
mcptransport.WithHTTPBasicClient(c.http),
|
||||
mcptransport.WithHTTPHeaderFunc(func(requestCtx context.Context) map[string]string {
|
||||
token, _ := requestCtx.Value(mcpAccessTokenContextKey{}).(string)
|
||||
if token == "" {
|
||||
token = c.getToken()
|
||||
}
|
||||
return map[string]string{"Authorization": "Bearer " + token}
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := client.Start(ctx); err != nil {
|
||||
return nil, fmt.Errorf("MCP start: %w", err)
|
||||
}
|
||||
if _, err := client.Initialize(ctx, mcp.InitializeRequest{Params: mcp.InitializeParams{
|
||||
ProtocolVersion: mcpProtocol,
|
||||
Capabilities: mcp.ClientCapabilities{},
|
||||
ClientInfo: mcp.Implementation{Name: "kandev", Version: "1.0"},
|
||||
}}); err != nil {
|
||||
return nil, fmt.Errorf("MCP initialize: %w", err)
|
||||
}
|
||||
c.session = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *MCPClient) resetSession() {
|
||||
c.sessionMu.Lock()
|
||||
previous := c.session
|
||||
c.session = nil
|
||||
c.sessionMu.Unlock()
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client interface ---
|
||||
|
||||
func (c *MCPClient) TestAuth(ctx context.Context) (*TestConnectionResult, error) {
|
||||
result, err := c.call(ctx, "tools/call", map[string]interface{}{
|
||||
"name": "atlassianUserInfo",
|
||||
"arguments": map[string]interface{}{},
|
||||
})
|
||||
if err != nil {
|
||||
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
|
||||
}
|
||||
var user struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AccountID string `json:"accountId"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result), &user); err != nil {
|
||||
return &TestConnectionResult{OK: true}, nil
|
||||
}
|
||||
return &TestConnectionResult{OK: true, DisplayName: user.Name, Email: user.Email, AccountID: user.AccountID}, nil
|
||||
}
|
||||
|
||||
func (c *MCPClient) GetTicket(ctx context.Context, ticketKey string) (*JiraTicket, error) {
|
||||
type issueResult struct {
|
||||
ticket *JiraTicket
|
||||
err error
|
||||
}
|
||||
type transResult struct {
|
||||
transitions []JiraTransition
|
||||
err error
|
||||
}
|
||||
issueCh := make(chan issueResult, 1)
|
||||
transCh := make(chan transResult, 1)
|
||||
go func() {
|
||||
result, err := c.call(ctx, "tools/call", map[string]interface{}{
|
||||
"name": "getJiraIssue",
|
||||
"arguments": map[string]interface{}{
|
||||
"cloudId": c.cloudID,
|
||||
"issueIdOrKey": ticketKey,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
issueCh <- issueResult{nil, err}
|
||||
return
|
||||
}
|
||||
t, err := parseMCPIssue(result, c.siteURL)
|
||||
issueCh <- issueResult{t, err}
|
||||
}()
|
||||
go func() {
|
||||
trans, err := c.listTransitions(ctx, ticketKey)
|
||||
transCh <- transResult{trans, err}
|
||||
}()
|
||||
ir := <-issueCh
|
||||
if ir.err != nil {
|
||||
return nil, ir.err
|
||||
}
|
||||
tr := <-transCh
|
||||
if tr.err == nil {
|
||||
ir.ticket.Transitions = tr.transitions
|
||||
}
|
||||
return ir.ticket, nil
|
||||
}
|
||||
|
||||
func (c *MCPClient) listTransitions(ctx context.Context, ticketKey string) ([]JiraTransition, error) {
|
||||
result, err := c.call(ctx, "tools/call", map[string]interface{}{
|
||||
"name": "getTransitionsForJiraIssue",
|
||||
"arguments": map[string]interface{}{
|
||||
"cloudId": c.cloudID,
|
||||
"issueIdOrKey": ticketKey,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Transitions []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
To struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"to"`
|
||||
} `json:"transitions"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result), &resp); err != nil {
|
||||
return nil, fmt.Errorf("decode transitions: %w", err)
|
||||
}
|
||||
out := make([]JiraTransition, 0, len(resp.Transitions))
|
||||
for _, t := range resp.Transitions {
|
||||
out = append(out, JiraTransition{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
ToStatusID: t.To.ID,
|
||||
ToStatusName: t.To.Name,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *MCPClient) ListTransitions(ctx context.Context, ticketKey string) ([]JiraTransition, error) {
|
||||
return c.listTransitions(ctx, ticketKey)
|
||||
}
|
||||
|
||||
func (c *MCPClient) DoTransition(ctx context.Context, ticketKey, transitionID string) error {
|
||||
_, err := c.call(ctx, "tools/call", map[string]interface{}{
|
||||
"name": "transitionJiraIssue",
|
||||
"arguments": map[string]interface{}{
|
||||
"cloudId": c.cloudID,
|
||||
"issueIdOrKey": ticketKey,
|
||||
"transition": map[string]string{"id": transitionID},
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *MCPClient) ListProjects(ctx context.Context) ([]JiraProject, error) {
|
||||
result, err := c.call(ctx, "tools/call", map[string]interface{}{
|
||||
"name": "getVisibleJiraProjects",
|
||||
"arguments": map[string]interface{}{
|
||||
"cloudId": c.cloudID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Values []struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
} `json:"values"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result), &resp); err != nil {
|
||||
return nil, fmt.Errorf("decode projects: %w", err)
|
||||
}
|
||||
out := make([]JiraProject, 0, len(resp.Values))
|
||||
for _, p := range resp.Values {
|
||||
out = append(out, JiraProject{Key: p.Key, Name: p.Name, ID: p.ID})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListProjectStatuses fetches project workflow statuses. The MCP server
|
||||
// doesn't expose a direct /project/{key}/statuses endpoint, so we use
|
||||
// getJiraProjectIssueTypesMetadata which returns issue types with their
|
||||
// available statuses. We flatten and dedupe like CloudClient does.
|
||||
func (c *MCPClient) ListProjectStatuses(ctx context.Context, projectKey string) ([]JiraStatus, error) {
|
||||
result, err := c.call(ctx, "tools/call", map[string]interface{}{
|
||||
"name": "getJiraProjectIssueTypesMetadata",
|
||||
"arguments": map[string]interface{}{
|
||||
"cloudId": c.cloudID,
|
||||
"projectIdOrKey": projectKey,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The response is an array of issue types, each with a "statuses" array.
|
||||
var body []struct {
|
||||
Statuses []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
StatusCategory struct {
|
||||
Key string `json:"key"`
|
||||
} `json:"statusCategory"`
|
||||
} `json:"statuses"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result), &body); err != nil {
|
||||
return nil, fmt.Errorf("decode project statuses: %w", err)
|
||||
}
|
||||
out := make([]JiraStatus, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, it := range body {
|
||||
for _, s := range it.Statuses {
|
||||
if _, dup := seen[s.ID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[s.ID] = struct{}{}
|
||||
out = append(out, JiraStatus{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
StatusCategory: s.StatusCategory.Key,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *MCPClient) SearchTickets(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) {
|
||||
args := map[string]interface{}{
|
||||
"cloudId": c.cloudID,
|
||||
"jql": jql,
|
||||
"maxResults": maxResults,
|
||||
}
|
||||
if pageToken != "" {
|
||||
args["nextPageToken"] = pageToken
|
||||
}
|
||||
result, err := c.call(ctx, "tools/call", map[string]interface{}{
|
||||
"name": "searchJiraIssuesUsingJql",
|
||||
"arguments": args,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseMCPSearchResult(result, c.siteURL)
|
||||
}
|
||||
|
||||
// mcpIssue mirrors the subset of the Jira issue payload from the MCP tool.
|
||||
type mcpIssue struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Fields struct {
|
||||
Summary string `json:"summary"`
|
||||
Description interface{} `json:"description"` // ADF or string
|
||||
Updated string `json:"updated"`
|
||||
Status struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
StatusCategory struct {
|
||||
Key string `json:"key"`
|
||||
} `json:"statusCategory"`
|
||||
} `json:"status"`
|
||||
Project struct {
|
||||
Key string `json:"key"`
|
||||
} `json:"project"`
|
||||
Issuetype struct {
|
||||
Name string `json:"name"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
} `json:"issuetype"`
|
||||
Priority struct {
|
||||
Name string `json:"name"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
} `json:"priority"`
|
||||
Assignee *mcpUser `json:"assignee"`
|
||||
Reporter *mcpUser `json:"reporter"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
|
||||
type mcpUser struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
AvatarURLs struct {
|
||||
Size24 string `json:"24x24"`
|
||||
Size32 string `json:"32x32"`
|
||||
} `json:"avatarUrls"`
|
||||
}
|
||||
|
||||
func (u *mcpUser) avatar() string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
if u.AvatarURLs.Size24 != "" {
|
||||
return u.AvatarURLs.Size24
|
||||
}
|
||||
return u.AvatarURLs.Size32
|
||||
}
|
||||
|
||||
func (u *mcpUser) name() string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
return u.DisplayName
|
||||
}
|
||||
|
||||
// parseMCPIssue converts the MCP tool response into a JiraTicket.
|
||||
func parseMCPIssue(raw, siteURL string) (*JiraTicket, error) {
|
||||
var issue mcpIssue
|
||||
if err := json.Unmarshal([]byte(raw), &issue); err != nil {
|
||||
return nil, fmt.Errorf("decode issue: %w", err)
|
||||
}
|
||||
return &JiraTicket{
|
||||
ID: issue.ID,
|
||||
Key: issue.Key,
|
||||
Summary: issue.Fields.Summary,
|
||||
Description: extractDescription(issue.Fields.Description),
|
||||
StatusID: issue.Fields.Status.ID,
|
||||
StatusName: issue.Fields.Status.Name,
|
||||
StatusCategory: issue.Fields.Status.StatusCategory.Key,
|
||||
ProjectKey: issue.Fields.Project.Key,
|
||||
IssueType: issue.Fields.Issuetype.Name,
|
||||
IssueTypeIcon: issue.Fields.Issuetype.IconURL,
|
||||
Priority: issue.Fields.Priority.Name,
|
||||
PriorityIcon: issue.Fields.Priority.IconURL,
|
||||
AssigneeName: issue.Fields.Assignee.name(),
|
||||
AssigneeAvatar: issue.Fields.Assignee.avatar(),
|
||||
ReporterName: issue.Fields.Reporter.name(),
|
||||
ReporterAvatar: issue.Fields.Reporter.avatar(),
|
||||
Updated: issue.Fields.Updated,
|
||||
URL: siteURL + "/browse/" + issue.Key,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseMCPSearchResult(raw, siteURL string) (*SearchResult, error) {
|
||||
var resp struct {
|
||||
Issues []json.RawMessage `json:"issues"`
|
||||
IsLast bool `json:"isLast"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
MaxResults int `json:"maxResults"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
|
||||
return nil, fmt.Errorf("decode search result: %w", err)
|
||||
}
|
||||
tickets := make([]JiraTicket, 0, len(resp.Issues))
|
||||
for _, raw := range resp.Issues {
|
||||
ticket, err := parseMCPIssue(string(raw), siteURL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tickets = append(tickets, *ticket)
|
||||
}
|
||||
return &SearchResult{
|
||||
Tickets: tickets,
|
||||
MaxResults: resp.MaxResults,
|
||||
IsLast: resp.IsLast,
|
||||
NextPageToken: resp.NextPageToken,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package jira
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
mcpclient "github.com/mark3labs/mcp-go/client"
|
||||
mcptransport "github.com/mark3labs/mcp-go/client/transport"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
type mcpRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f mcpRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func mcpHTTPResponse(status int, contentType, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{contentType}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func setMCPTestTransport(c *MCPClient, transport http.RoundTripper) {
|
||||
c.http.Transport = &mcpStatusRoundTripper{base: transport}
|
||||
}
|
||||
|
||||
func mcpMethod(t *testing.T, req *http.Request) string {
|
||||
t.Helper()
|
||||
if req.Body == nil {
|
||||
return req.Method
|
||||
}
|
||||
var body struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode MCP request: %v", err)
|
||||
}
|
||||
return body.Method
|
||||
}
|
||||
|
||||
const (
|
||||
initializeResult = `{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"fake","version":"1"}}}`
|
||||
toolResult = `{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"ok"}]}}`
|
||||
)
|
||||
|
||||
func TestMCPClientSendsInitializedNotification(t *testing.T) {
|
||||
c := NewMCPClient("token-a", "cloud", "ws", "https://x.atlassian.net")
|
||||
initialized := false
|
||||
setMCPTestTransport(c, mcpRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch mcpMethod(t, req) {
|
||||
case "initialize":
|
||||
resp := mcpHTTPResponse(http.StatusOK, "application/json", initializeResult)
|
||||
resp.Header.Set("Mcp-Session-Id", "session-1")
|
||||
return resp, nil
|
||||
case "notifications/initialized":
|
||||
initialized = true
|
||||
return mcpHTTPResponse(http.StatusAccepted, "application/json", ""), nil
|
||||
case "tools/call":
|
||||
return mcpHTTPResponse(http.StatusOK, "text/event-stream", "data: "+toolResult+"\n\n"), nil
|
||||
default:
|
||||
return mcpHTTPResponse(http.StatusBadRequest, "text/plain", "unexpected method"), nil
|
||||
}
|
||||
}))
|
||||
|
||||
if _, err := c.call(context.Background(), "tools/call", map[string]interface{}{"name": "test", "arguments": map[string]interface{}{}}); err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if !initialized {
|
||||
t.Fatal("client did not send notifications/initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPClientAcceptsJSONToolResponse(t *testing.T) {
|
||||
c := NewMCPClient("token-a", "cloud", "ws", "https://x.atlassian.net")
|
||||
setMCPTestTransport(c, mcpRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch mcpMethod(t, req) {
|
||||
case "initialize":
|
||||
resp := mcpHTTPResponse(http.StatusOK, "application/json", initializeResult)
|
||||
resp.Header.Set("Mcp-Session-Id", "session-1")
|
||||
return resp, nil
|
||||
case "notifications/initialized":
|
||||
return mcpHTTPResponse(http.StatusAccepted, "application/json", ""), nil
|
||||
default:
|
||||
return mcpHTTPResponse(http.StatusOK, "application/json", toolResult), nil
|
||||
}
|
||||
}))
|
||||
|
||||
got, err := c.call(context.Background(), "tools/call", map[string]interface{}{"name": "test", "arguments": map[string]interface{}{}})
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if got != "ok" {
|
||||
t.Fatalf("result = %q, want ok", got)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingRefresher struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
stale []string
|
||||
rotated chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (r *recordingRefresher) RefreshOAuthToken(_ context.Context, _ string, stale string) error {
|
||||
r.mu.Lock()
|
||||
r.stale = append(r.stale, stale)
|
||||
if stale == "token-a" {
|
||||
r.token = "token-b"
|
||||
} else {
|
||||
r.token = "token-c"
|
||||
}
|
||||
r.mu.Unlock()
|
||||
r.once.Do(func() { close(r.rotated) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingRefresher) RevealAccessToken(context.Context, string) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.token, nil
|
||||
}
|
||||
|
||||
func TestMCPClientRefreshUsesTokenSentByFailedRequest(t *testing.T) {
|
||||
refresher := &recordingRefresher{token: "token-a", rotated: make(chan struct{})}
|
||||
c := NewMCPClient("token-a", "cloud", "ws", "https://x.atlassian.net")
|
||||
c.SetRefresher("ws", refresher)
|
||||
bothInitial := make(chan struct{})
|
||||
var initialMu sync.Mutex
|
||||
initialCount := 0
|
||||
setMCPTestTransport(c, mcpRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch mcpMethod(t, req) {
|
||||
case "initialize":
|
||||
resp := mcpHTTPResponse(http.StatusOK, "application/json", initializeResult)
|
||||
resp.Header.Set("Mcp-Session-Id", "session-1")
|
||||
return resp, nil
|
||||
case "notifications/initialized":
|
||||
return mcpHTTPResponse(http.StatusAccepted, "application/json", ""), nil
|
||||
}
|
||||
if req.Header.Get("Authorization") != "Bearer token-a" {
|
||||
return mcpHTTPResponse(http.StatusOK, "application/json", toolResult), nil
|
||||
}
|
||||
initialMu.Lock()
|
||||
initialCount++
|
||||
current := initialCount
|
||||
if current == 2 {
|
||||
close(bothInitial)
|
||||
}
|
||||
initialMu.Unlock()
|
||||
<-bothInitial
|
||||
if current == 2 {
|
||||
<-refresher.rotated
|
||||
}
|
||||
return mcpHTTPResponse(http.StatusUnauthorized, "text/plain", "expired"), nil
|
||||
}))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
for range 2 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := c.call(ctx, "tools/call", map[string]interface{}{"name": "test", "arguments": map[string]interface{}{}})
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("call failed: %v", err)
|
||||
}
|
||||
}
|
||||
refresher.mu.Lock()
|
||||
defer refresher.mu.Unlock()
|
||||
if len(refresher.stale) != 2 || refresher.stale[0] != "token-a" || refresher.stale[1] != "token-a" {
|
||||
t.Fatalf("stale tokens = %v, want [token-a token-a]", refresher.stale)
|
||||
}
|
||||
}
|
||||
|
||||
type resetSessionTransport struct {
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (t *resetSessionTransport) Start(context.Context) error { return nil }
|
||||
|
||||
func (t *resetSessionTransport) SendRequest(context.Context, mcptransport.JSONRPCRequest) (*mcptransport.JSONRPCResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (t *resetSessionTransport) SendNotification(context.Context, mcp.JSONRPCNotification) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *resetSessionTransport) SetNotificationHandler(func(mcp.JSONRPCNotification)) {}
|
||||
|
||||
func (t *resetSessionTransport) Close() error {
|
||||
t.once.Do(func() { close(t.closed) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *resetSessionTransport) GetSessionId() string { return "test-session" }
|
||||
|
||||
func TestMCPClientResetSessionClosesPreviousClient(t *testing.T) {
|
||||
transport := &resetSessionTransport{closed: make(chan struct{})}
|
||||
c := NewMCPClient("token", "cloud", "ws", "https://x.atlassian.net")
|
||||
c.session = mcpclient.NewClient(transport)
|
||||
|
||||
c.resetSession()
|
||||
|
||||
if c.session != nil {
|
||||
t.Fatal("session was not cleared")
|
||||
}
|
||||
select {
|
||||
case <-transport.closed:
|
||||
default:
|
||||
t.Fatal("previous session was not closed")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@ const (
|
||||
// Created from the user's profile on the Jira instance. Server/DC-only —
|
||||
// Cloud doesn't accept Bearer for the Jira REST API.
|
||||
AuthMethodPAT = "pat"
|
||||
// AuthMethodOAuth is Atlassian's OAuth 2.0 (3LO) authorization code flow.
|
||||
// Cloud-only. A rotating refresh token keeps the session alive. API calls
|
||||
// use the Atlassian MCP server because its OAuth token does not work with
|
||||
// the direct Jira REST API.
|
||||
AuthMethodOAuth = "oauth"
|
||||
)
|
||||
|
||||
// Instance type identifiers persisted in JiraConfig.InstanceType. Cloud sites
|
||||
@@ -49,6 +54,17 @@ type JiraConfig struct {
|
||||
// a JWT (cloud.session.token / tenant.session.token). Nil for api_token or
|
||||
// opaque session cookies.
|
||||
SecretExpiresAt *time.Time `json:"secretExpiresAt,omitempty" db:"-"`
|
||||
// ClientID is the client_id from OAuth Dynamic Client Registration.
|
||||
// Stored in the config row, not the secret store; it is not sensitive.
|
||||
// Only set when AuthMethod == AuthMethodOAuth.
|
||||
ClientID string `json:"clientId,omitempty" db:"client_id"`
|
||||
// CloudID is the Atlassian Cloud site ID resolved through the MCP server.
|
||||
// MCP tool requests use it to select the Jira site. Only set when
|
||||
// AuthMethod == AuthMethodOAuth.
|
||||
CloudID string `json:"cloudId,omitempty" db:"cloud_id"`
|
||||
// TokenExpiresAt is the OAuth access token expiry. The client refreshes
|
||||
// on-demand when a request hits this deadline. Only for AuthMethodOAuth.
|
||||
TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty" db:"token_expires_at"`
|
||||
// LastCheckedAt / LastOk / LastError are written by the background auth
|
||||
// poller. They let the UI render a "connected/disconnected + checked Xs ago"
|
||||
// indicator without doing its own probing.
|
||||
@@ -69,6 +85,10 @@ type SetConfigRequest struct {
|
||||
InstanceType string `json:"instanceType"`
|
||||
DefaultProjectKey string `json:"defaultProjectKey"`
|
||||
Secret string `json:"secret"`
|
||||
// ClientID is the OAuth app client_id (dynamically registered via MCP).
|
||||
// Only used when authMethod == "oauth". Stored in the config row, not
|
||||
// the secret store — it is not sensitive.
|
||||
ClientID string `json:"clientId"`
|
||||
}
|
||||
|
||||
// TestConnectionResult reports what the backend learned when pinging Jira with
|
||||
@@ -157,6 +177,19 @@ func SecretKeyForWorkspace(workspaceID string) string {
|
||||
return "jira:" + workspaceID + ":token"
|
||||
}
|
||||
|
||||
// OAuthAccessTokenKeyForWorkspace returns the secret-store key for the OAuth
|
||||
// access token. The access token has a 15-minute TTL and is refreshed on-demand.
|
||||
func OAuthAccessTokenKeyForWorkspace(workspaceID string) string {
|
||||
return "jira:" + workspaceID + ":oauth_access_token"
|
||||
}
|
||||
|
||||
// OAuthRefreshTokenKeyForWorkspace returns the secret-store key for the OAuth
|
||||
// refresh token. The refresh token is rotating — every refresh invalidates the
|
||||
// old one and returns a new one.
|
||||
func OAuthRefreshTokenKeyForWorkspace(workspaceID string) string {
|
||||
return "jira:" + workspaceID + ":oauth_refresh_token"
|
||||
}
|
||||
|
||||
// LegacySecretKeyForWorkspace is kept for older tests/callers.
|
||||
func LegacySecretKeyForWorkspace(workspaceID string) string {
|
||||
return SecretKeyForWorkspace(workspaceID)
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
package jira
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mcptransport "github.com/mark3labs/mcp-go/client/transport"
|
||||
)
|
||||
|
||||
// Atlassian MCP OAuth 2.1 endpoints. The MCP server acts as both
|
||||
// authorization server and resource server, proxying to auth.atlassian.com.
|
||||
// Dynamic Client Registration (RFC 7591) + PKCE (S256) replaces the need for
|
||||
// a pre-created app — no client_id/client_secret from the developer console.
|
||||
const (
|
||||
mcpResourceURL = "https://mcp.atlassian.com"
|
||||
mcpScopes = "offline_access read:me read:account read:jira-user read:jira-work write:jira-work search:jira-work"
|
||||
mcpStateTTL = 10 * time.Minute
|
||||
oauthPersistenceTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// mcpTokenResponse is the response from the token endpoint.
|
||||
type mcpTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"` // seconds
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// mcpRegisterResponse is the response from dynamic client registration.
|
||||
type mcpRegisterResponse struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientIDIssuedAt int64 `json:"client_id_issued_at"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
||||
}
|
||||
|
||||
// mcpPendingState holds the CSRF state + PKCE verifier during the OAuth flow.
|
||||
type mcpPendingState struct {
|
||||
State string
|
||||
WorkspaceID string
|
||||
SiteURL string
|
||||
ClientID string // from dynamic registration
|
||||
CodeVerifier string // PKCE verifier (kept server-side)
|
||||
RedirectURI string
|
||||
TokenEndpoint string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type mcpOAuthEndpoints struct {
|
||||
Authorization string
|
||||
Token string
|
||||
Registration string
|
||||
}
|
||||
|
||||
// mcpStateManager holds pending OAuth states in memory.
|
||||
type mcpStateManager struct {
|
||||
mu sync.Mutex
|
||||
states map[string]mcpPendingState
|
||||
}
|
||||
|
||||
func newMCPStateManager() *mcpStateManager {
|
||||
return &mcpStateManager{states: make(map[string]mcpPendingState)}
|
||||
}
|
||||
|
||||
func (m *mcpStateManager) store(state mcpPendingState) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.states[state.State] = state
|
||||
m.cleanupLocked()
|
||||
}
|
||||
|
||||
func (m *mcpStateManager) loadAndDelete(state string) (mcpPendingState, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
s, ok := m.states[state]
|
||||
if ok {
|
||||
delete(m.states, state)
|
||||
}
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func (m *mcpStateManager) cleanupLocked() {
|
||||
cutoff := time.Now().Add(-mcpStateTTL)
|
||||
for k, s := range m.states {
|
||||
if s.CreatedAt.Before(cutoff) {
|
||||
delete(m.states, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateRandomString(nBytes int) (string, error) {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// generatePKCEPair creates a code_verifier and code_challenge (S256).
|
||||
func generatePKCEPair() (verifier, challenge string, err error) {
|
||||
verifier, err = generateRandomString(32)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
challenge = base64.RawURLEncoding.EncodeToString(h[:])
|
||||
return verifier, challenge, nil
|
||||
}
|
||||
|
||||
// registerMCPClient performs Dynamic Client Registration (RFC 7591) against
|
||||
// the MCP server. Returns a client_id that is used for the authorization flow.
|
||||
// No client_secret is returned — the flow uses PKCE with
|
||||
// token_endpoint_auth_method="none".
|
||||
func registerMCPClient(ctx context.Context, client *http.Client, registrationEndpoint, redirectURI string) (string, error) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"client_name": "Kandev",
|
||||
"redirect_uris": []string{redirectURI},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"token_endpoint_auth_method": "none",
|
||||
"scope": mcpScopes,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, registrationEndpoint, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("dynamic registration failed (%d): %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var reg mcpRegisterResponse
|
||||
if err := json.Unmarshal(raw, ®); err != nil {
|
||||
return "", fmt.Errorf("decode registration response: %w", err)
|
||||
}
|
||||
if reg.ClientID == "" {
|
||||
return "", errors.New("registration returned empty client_id")
|
||||
}
|
||||
return reg.ClientID, nil
|
||||
}
|
||||
|
||||
func discoverMCPOAuthEndpoints(ctx context.Context, client *http.Client) (*mcpOAuthEndpoints, error) {
|
||||
handler := mcptransport.NewOAuthHandler(mcptransport.OAuthConfig{HTTPClient: client})
|
||||
handler.SetBaseURL(mcpResourceURL)
|
||||
metadata, err := handler.GetServerMetadata(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover OAuth server: %w", err)
|
||||
}
|
||||
if metadata.AuthorizationEndpoint == "" || metadata.TokenEndpoint == "" || metadata.RegistrationEndpoint == "" {
|
||||
return nil, errors.New("OAuth server metadata is missing required endpoints")
|
||||
}
|
||||
return &mcpOAuthEndpoints{
|
||||
Authorization: metadata.AuthorizationEndpoint,
|
||||
Token: metadata.TokenEndpoint,
|
||||
Registration: metadata.RegistrationEndpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newMCPOAuthHTTPClient() *http.Client {
|
||||
return &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
// oauthPersistenceContext keeps credential writes alive after the request
|
||||
// ends. The token exchange invalidates the previous refresh token, so losing
|
||||
// the new token between the exchange and persistence would force re-auth.
|
||||
func oauthPersistenceContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), oauthPersistenceTimeout)
|
||||
}
|
||||
|
||||
// StartOAuthFlow registers a client dynamically, generates PKCE, and builds the
|
||||
// authorization URL. The user never needs to create an Atlassian app.
|
||||
func (s *Service) StartOAuthFlow(ctx context.Context, workspaceID, siteURL, redirectURI string) (string, error) {
|
||||
if redirectURI == "" {
|
||||
return "", errors.New("redirect URI is required")
|
||||
}
|
||||
resolvedWorkspaceID, err := s.resolveWorkspaceID(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
client := newMCPOAuthHTTPClient()
|
||||
endpoints, err := discoverMCPOAuthEndpoints(ctx, client)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
clientID, err := registerMCPClient(ctx, client, endpoints.Registration, redirectURI)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dynamic client registration: %w", err)
|
||||
}
|
||||
verifier, challenge, err := generatePKCEPair()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate PKCE: %w", err)
|
||||
}
|
||||
state, err := generateRandomString(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.oauthStates.store(mcpPendingState{
|
||||
State: state,
|
||||
WorkspaceID: resolvedWorkspaceID,
|
||||
SiteURL: siteURL,
|
||||
ClientID: clientID,
|
||||
CodeVerifier: verifier,
|
||||
RedirectURI: redirectURI,
|
||||
TokenEndpoint: endpoints.Token,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
params := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
"code_challenge": {challenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"state": {state},
|
||||
"resource": {mcpResourceURL},
|
||||
"scope": {mcpScopes},
|
||||
}
|
||||
return endpoints.Authorization + "?" + params.Encode(), nil
|
||||
}
|
||||
|
||||
// CompleteOAuthFlow exchanges the authorization code for tokens (using PKCE,
|
||||
// no client_secret), resolves the cloudId, and persists config + secrets.
|
||||
func (s *Service) CompleteOAuthFlow(ctx context.Context, code, state string) (*JiraConfig, error) {
|
||||
st, ok := s.oauthStates.loadAndDelete(state)
|
||||
if !ok || time.Since(st.CreatedAt) > mcpStateTTL {
|
||||
return nil, errors.New("invalid or expired OAuth state")
|
||||
}
|
||||
// Bind the completing user to the workspace the flow was started for, so a
|
||||
// leaked state cannot persist a victim's tokens into someone else's workspace.
|
||||
if err := s.authorizeWorkspaceAccess(ctx, st.WorkspaceID); err != nil {
|
||||
return nil, fmt.Errorf("authorize workspace: %w", err)
|
||||
}
|
||||
tok, err := exchangeMCPCode(ctx, newMCPOAuthHTTPClient(), st.TokenEndpoint, st.ClientID, code, st.RedirectURI, st.CodeVerifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token exchange: %w", err)
|
||||
}
|
||||
// cloudId is required for every MCP tool call; a workspace saved without it
|
||||
// looks connected but fails on every request, so fail the flow loudly here.
|
||||
cloudID, err := resolveCloudIDViaMCP(ctx, tok.AccessToken, st.SiteURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve Atlassian cloud ID for %q: %w", st.SiteURL, err)
|
||||
}
|
||||
expiresAt := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
|
||||
cfg := &JiraConfig{
|
||||
WorkspaceID: st.WorkspaceID,
|
||||
SiteURL: st.SiteURL,
|
||||
AuthMethod: AuthMethodOAuth,
|
||||
InstanceType: InstanceTypeCloud,
|
||||
ClientID: st.ClientID,
|
||||
CloudID: cloudID,
|
||||
TokenExpiresAt: &expiresAt,
|
||||
}
|
||||
// UpsertConfigForWorkspace is a full-row write; carry over user-owned fields
|
||||
// so connecting OAuth doesn't wipe an existing email / default project key.
|
||||
if existing, err := s.store.GetConfigForWorkspace(ctx, st.WorkspaceID); err == nil && existing != nil {
|
||||
cfg.Email = existing.Email
|
||||
cfg.DefaultProjectKey = existing.DefaultProjectKey
|
||||
}
|
||||
if tok.AccessToken == "" || tok.RefreshToken == "" {
|
||||
return nil, errors.New("token endpoint did not return the required access and refresh tokens")
|
||||
}
|
||||
persistCtx, cancel := oauthPersistenceContext(ctx)
|
||||
defer cancel()
|
||||
// Persist the rotating refresh token first. A later failure then leaves a
|
||||
// credential that can recover, instead of retaining an invalidated token.
|
||||
if err := s.secrets.Set(persistCtx, OAuthRefreshTokenKeyForWorkspace(st.WorkspaceID), "oauth_refresh_token", tok.RefreshToken); err != nil {
|
||||
return nil, fmt.Errorf("store refresh token: %w", err)
|
||||
}
|
||||
if err := s.secrets.Set(persistCtx, OAuthAccessTokenKeyForWorkspace(st.WorkspaceID), "oauth_access_token", tok.AccessToken); err != nil {
|
||||
return nil, fmt.Errorf("store access token: %w", err)
|
||||
}
|
||||
if err := s.store.UpsertConfigForWorkspace(persistCtx, st.WorkspaceID, cfg); err != nil {
|
||||
return nil, fmt.Errorf("persist config: %w", err)
|
||||
}
|
||||
s.invalidateClient(st.WorkspaceID)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RefreshOAuthToken uses the stored refresh token to get a new access token.
|
||||
// PKCE-based flow: only client_id is sent (no client_secret). Refresh tokens
|
||||
// are rotating — the old one is invalidated and a new one is returned.
|
||||
func (s *Service) RefreshOAuthToken(ctx context.Context, workspaceID, staleToken string) error {
|
||||
resolvedWorkspaceID, err := s.resolveWorkspaceID(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspaceID = resolvedWorkspaceID
|
||||
s.oauthMu.Lock(workspaceID)
|
||||
defer s.oauthMu.Unlock(workspaceID)
|
||||
return s.refreshOAuthTokenLocked(ctx, workspaceID, staleToken)
|
||||
}
|
||||
|
||||
func (s *Service) refreshOAuthTokenLocked(ctx context.Context, workspaceID, staleToken string) error {
|
||||
// If another goroutine already rotated the token while we waited for the
|
||||
// lock, skip: rotating again with the now-invalid refresh token would fail.
|
||||
if s.oauthRefreshAlreadyCompleted(ctx, workspaceID, staleToken) {
|
||||
return nil
|
||||
}
|
||||
refreshToken, err := s.secrets.Reveal(ctx, OAuthRefreshTokenKeyForWorkspace(workspaceID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read refresh token: %w", err)
|
||||
}
|
||||
if refreshToken == "" {
|
||||
return errors.New("no refresh token — re-authentication required")
|
||||
}
|
||||
cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return errors.New("no config for workspace")
|
||||
}
|
||||
endpoints, err := discoverMCPOAuthEndpoints(ctx, newMCPOAuthHTTPClient())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tok, err := refreshMCPToken(ctx, newMCPOAuthHTTPClient(), endpoints.Token, cfg.ClientID, refreshToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh: %w", err)
|
||||
}
|
||||
if tok.AccessToken == "" {
|
||||
return errors.New("token endpoint returned an empty access token")
|
||||
}
|
||||
return s.persistRefreshedOAuthToken(ctx, workspaceID, refreshToken, tok)
|
||||
}
|
||||
|
||||
func (s *Service) oauthRefreshAlreadyCompleted(ctx context.Context, workspaceID, staleToken string) bool {
|
||||
if staleToken == "" {
|
||||
return false
|
||||
}
|
||||
current, err := s.secrets.Reveal(ctx, OAuthAccessTokenKeyForWorkspace(workspaceID))
|
||||
return err == nil && current != "" && current != staleToken
|
||||
}
|
||||
|
||||
func (s *Service) persistRefreshedOAuthToken(
|
||||
ctx context.Context,
|
||||
workspaceID, previousRefreshToken string,
|
||||
tok *mcpTokenResponse,
|
||||
) error {
|
||||
persistCtx, cancel := oauthPersistenceContext(ctx)
|
||||
defer cancel()
|
||||
newRefreshToken := tok.RefreshToken
|
||||
if newRefreshToken == "" {
|
||||
newRefreshToken = previousRefreshToken
|
||||
}
|
||||
if err := s.secrets.Set(persistCtx, OAuthRefreshTokenKeyForWorkspace(workspaceID), "oauth_refresh_token", newRefreshToken); err != nil {
|
||||
return fmt.Errorf("store refresh token: %w", err)
|
||||
}
|
||||
if err := s.secrets.Set(persistCtx, OAuthAccessTokenKeyForWorkspace(workspaceID), "oauth_access_token", tok.AccessToken); err != nil {
|
||||
return fmt.Errorf("store access token: %w", err)
|
||||
}
|
||||
expiresAt := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
|
||||
if err := s.store.UpdateTokenExpiryForWorkspace(persistCtx, workspaceID, expiresAt); err != nil {
|
||||
return fmt.Errorf("update token expiry: %w", err)
|
||||
}
|
||||
s.invalidateClient(workspaceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// exchangeMCPCode swaps the authorization code for tokens using PKCE.
|
||||
// No client_secret is sent — token_endpoint_auth_method="none".
|
||||
func exchangeMCPCode(ctx context.Context, client *http.Client, tokenEndpoint, clientID, code, redirectURI, codeVerifier string) (*mcpTokenResponse, error) {
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {redirectURI},
|
||||
"client_id": {clientID},
|
||||
"code_verifier": {codeVerifier},
|
||||
"resource": {mcpResourceURL},
|
||||
}
|
||||
return postMCPTokenRequest(ctx, client, tokenEndpoint, form.Encode())
|
||||
}
|
||||
|
||||
// refreshMCPToken uses a refresh token to get a new access token. Only
|
||||
// client_id is sent (no client_secret — PKCE public client).
|
||||
func refreshMCPToken(ctx context.Context, client *http.Client, tokenEndpoint, clientID, refreshToken string) (*mcpTokenResponse, error) {
|
||||
form := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {refreshToken},
|
||||
"client_id": {clientID},
|
||||
"resource": {mcpResourceURL},
|
||||
}
|
||||
return postMCPTokenRequest(ctx, client, tokenEndpoint, form.Encode())
|
||||
}
|
||||
|
||||
func postMCPTokenRequest(ctx context.Context, client *http.Client, tokenEndpoint, body string) (*mcpTokenResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
var errResp struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &errResp)
|
||||
if errResp.Error != "" {
|
||||
return nil, fmt.Errorf("%s: %s", errResp.Error, errResp.ErrorDescription)
|
||||
}
|
||||
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var tok mcpTokenResponse
|
||||
if err := json.Unmarshal(raw, &tok); err != nil {
|
||||
return nil, fmt.Errorf("decode token response: %w", err)
|
||||
}
|
||||
return &tok, nil
|
||||
}
|
||||
|
||||
// resolveCloudIDViaMCP uses the MCP getAccessibleAtlassianResources tool to
|
||||
// resolve the cloudId. The MCP OAuth token doesn't work for direct
|
||||
// api.atlassian.com calls, but it works through the MCP server.
|
||||
func resolveCloudIDViaMCP(ctx context.Context, accessToken, siteURL string) (string, error) {
|
||||
client := NewMCPClient(accessToken, "", "", siteURL)
|
||||
result, err := client.call(ctx, "tools/call", map[string]interface{}{
|
||||
"name": "getAccessibleAtlassianResources", "arguments": map[string]interface{}{},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return matchAccessibleResource(result, siteURL)
|
||||
}
|
||||
|
||||
// matchAccessibleResource parses accessible-resources JSON and matches by URL.
|
||||
func matchAccessibleResource(raw, siteURL string) (string, error) {
|
||||
var resources []accessibleResource
|
||||
if err := json.Unmarshal([]byte(raw), &resources); err != nil {
|
||||
return "", fmt.Errorf("decode accessible-resources: %w", err)
|
||||
}
|
||||
normalized := strings.TrimRight(siteURL, "/")
|
||||
if normalized != "" && !strings.Contains(normalized, "://") {
|
||||
normalized = "https://" + normalized
|
||||
}
|
||||
for _, r := range resources {
|
||||
if strings.TrimRight(r.URL, "/") == normalized {
|
||||
return r.ID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("site URL %q not found in accessible resources", siteURL)
|
||||
}
|
||||
|
||||
// (resolveCloudID removed — the MCP OAuth token doesn't work for direct
|
||||
// api.atlassian.com calls, so resolveCloudIDViaMCP is used instead.)
|
||||
|
||||
// accessibleResource is one entry from the accessible-resources response.
|
||||
type accessibleResource struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
// perWorkspaceMutex provides a lock per workspace ID for OAuth token refresh.
|
||||
type perWorkspaceMutex struct {
|
||||
mu sync.Mutex
|
||||
locks map[string]*workspaceLock
|
||||
}
|
||||
|
||||
type workspaceLock struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
func newPerWorkspaceMutex() *perWorkspaceMutex {
|
||||
return &perWorkspaceMutex{locks: make(map[string]*workspaceLock)}
|
||||
}
|
||||
|
||||
func (p *perWorkspaceMutex) Lock(workspaceID string) {
|
||||
p.mu.Lock()
|
||||
entry, ok := p.locks[workspaceID]
|
||||
if !ok {
|
||||
entry = &workspaceLock{}
|
||||
p.locks[workspaceID] = entry
|
||||
}
|
||||
entry.refs++
|
||||
p.mu.Unlock()
|
||||
entry.mu.Lock()
|
||||
}
|
||||
|
||||
func (p *perWorkspaceMutex) Unlock(workspaceID string) {
|
||||
p.mu.Lock()
|
||||
entry, ok := p.locks[workspaceID]
|
||||
p.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
p.mu.Lock()
|
||||
entry.refs--
|
||||
if entry.refs == 0 {
|
||||
delete(p.locks, workspaceID)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package jira
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
||||
)
|
||||
|
||||
type oauthRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f oauthRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func oauthResponse(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func withDefaultOAuthTransport(t *testing.T, transport http.RoundTripper) {
|
||||
t.Helper()
|
||||
previous := http.DefaultTransport
|
||||
http.DefaultTransport = transport
|
||||
t.Cleanup(func() { http.DefaultTransport = previous })
|
||||
}
|
||||
|
||||
type contextAwareSecretStore struct {
|
||||
*fakeSecretStore
|
||||
}
|
||||
|
||||
func (s *contextAwareSecretStore) Set(ctx context.Context, id, name, value string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.fakeSecretStore.Set(ctx, id, name, value)
|
||||
}
|
||||
|
||||
func TestGeneratePKCEPairUsesS256(t *testing.T) {
|
||||
verifier, challenge, err := generatePKCEPair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate pair: %v", err)
|
||||
}
|
||||
wantHash := sha256.Sum256([]byte(verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(wantHash[:])
|
||||
if challenge != want {
|
||||
t.Fatalf("challenge = %q, want %q", challenge, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPStateIsSingleUseAndExpires(t *testing.T) {
|
||||
states := newMCPStateManager()
|
||||
states.store(mcpPendingState{State: "fresh", CreatedAt: time.Now()})
|
||||
if _, ok := states.loadAndDelete("fresh"); !ok {
|
||||
t.Fatal("fresh state was not found")
|
||||
}
|
||||
if _, ok := states.loadAndDelete("fresh"); ok {
|
||||
t.Fatal("state was reusable")
|
||||
}
|
||||
|
||||
states.store(mcpPendingState{State: "expired", CreatedAt: time.Now().Add(-mcpStateTTL - time.Second)})
|
||||
if _, ok := states.loadAndDelete("expired"); ok {
|
||||
t.Fatal("expired state remained available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPScopesCoverEveryUsedTool(t *testing.T) {
|
||||
requested := make(map[string]bool)
|
||||
for _, scope := range strings.Fields(mcpScopes) {
|
||||
requested[scope] = true
|
||||
}
|
||||
for _, required := range []string{"offline_access", "read:me", "read:account", "read:jira-work", "write:jira-work", "search:jira-work"} {
|
||||
if !requested[required] {
|
||||
t.Errorf("required scope %q is not requested", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartOAuthFlowAuthorizesBeforeNetwork(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
f.svc.SetWorkspaceAuthorizer(denyOnly("ws-foreign"))
|
||||
called := false
|
||||
withDefaultOAuthTransport(t, oauthRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
called = true
|
||||
return nil, errors.New("network must not be called")
|
||||
}))
|
||||
|
||||
_, err := f.svc.StartOAuthFlow(context.Background(), "ws-foreign", "https://x.atlassian.net", "http://localhost/callback")
|
||||
if !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
|
||||
t.Fatalf("expected workspace denial, got %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("OAuth registration ran before authorization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartOAuthFlowPropagatesCancellation(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
withDefaultOAuthTransport(t, oauthRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if err := req.Context().Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return oauthResponse(http.StatusCreated, `{"client_id":"client-1"}`), nil
|
||||
}))
|
||||
|
||||
_, err := f.svc.StartOAuthFlow(ctx, "ws-1", "https://x.atlassian.net", "http://localhost/callback")
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context cancellation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartOAuthFlowDiscoversAuthorizationServer(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
var paths []string
|
||||
withDefaultOAuthTransport(t, oauthRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
paths = append(paths, req.URL.Path)
|
||||
switch req.URL.Path {
|
||||
case "/.well-known/oauth-protected-resource":
|
||||
return oauthResponse(http.StatusOK, `{"authorization_servers":["https://auth.example"]}`), nil
|
||||
case "/.well-known/openid-configuration":
|
||||
return oauthResponse(http.StatusOK, `{"issuer":"https://auth.example","authorization_endpoint":"https://auth.example/authorize","token_endpoint":"https://auth.example/token","registration_endpoint":"https://auth.example/register","response_types_supported":["code"]}`), nil
|
||||
case "/register":
|
||||
return oauthResponse(http.StatusCreated, `{"client_id":"client-1"}`), nil
|
||||
default:
|
||||
return oauthResponse(http.StatusNotFound, "not found"), nil
|
||||
}
|
||||
}))
|
||||
|
||||
authURL, err := f.svc.StartOAuthFlow(context.Background(), "ws-1", "https://x.atlassian.net", "http://localhost/callback")
|
||||
if err != nil {
|
||||
t.Fatalf("start OAuth: %v (paths=%v)", err, paths)
|
||||
}
|
||||
if !strings.HasPrefix(authURL, "https://auth.example/authorize?") {
|
||||
t.Fatalf("authorization URL = %q", authURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshOAuthTokenAuthorizesBeforeReadingSecrets(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
f.svc.SetWorkspaceAuthorizer(denyOnly("ws-foreign"))
|
||||
err := f.svc.RefreshOAuthToken(context.Background(), "ws-foreign", "stale")
|
||||
if !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
|
||||
t.Fatalf("expected workspace denial, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistRefreshedOAuthTokenOutlivesCanceledRequest(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
f.svc.secrets = &contextAwareSecretStore{fakeSecretStore: f.secrets}
|
||||
if err := f.store.UpsertConfigForWorkspace(context.Background(), "ws-1", &JiraConfig{
|
||||
WorkspaceID: "ws-1",
|
||||
SiteURL: "https://x.atlassian.net",
|
||||
AuthMethod: AuthMethodOAuth,
|
||||
ClientID: "client-1",
|
||||
CloudID: "cloud-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := f.svc.persistRefreshedOAuthToken(ctx, "ws-1", "old-refresh", &mcpTokenResponse{
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
ExpiresIn: 300,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("persist refreshed token: %v", err)
|
||||
}
|
||||
if got, _ := f.secrets.Reveal(context.Background(), OAuthRefreshTokenKeyForWorkspace("ws-1")); got != "new-refresh" {
|
||||
t.Fatalf("refresh token = %q, want new-refresh", got)
|
||||
}
|
||||
if got, _ := f.secrets.Reveal(context.Background(), OAuthAccessTokenKeyForWorkspace("ws-1")); got != "new-access" {
|
||||
t.Fatalf("access token = %q, want new-access", got)
|
||||
}
|
||||
cfg, err := f.store.GetConfigForWorkspace(context.Background(), "ws-1")
|
||||
if err != nil {
|
||||
t.Fatalf("read persisted config: %v", err)
|
||||
}
|
||||
if cfg.TokenExpiresAt == nil {
|
||||
t.Fatal("token expiry was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerWorkspaceMutexReleasesUnusedEntry(t *testing.T) {
|
||||
locks := newPerWorkspaceMutex()
|
||||
locks.Lock("ws-1")
|
||||
locks.Unlock("ws-1")
|
||||
|
||||
locks.mu.Lock()
|
||||
defer locks.mu.Unlock()
|
||||
if len(locks.locks) != 0 {
|
||||
t.Fatalf("unused lock count = %d, want 0", len(locks.locks))
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,40 @@ func TestUpdateIssueWatch_DeniesForeignWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueWatchIDOperationsDenyForeignWorkspace(t *testing.T) {
|
||||
operations := []struct {
|
||||
name string
|
||||
run func(*Service, string) error
|
||||
}{
|
||||
{name: "get", run: func(s *Service, id string) error { _, err := s.GetIssueWatch(context.Background(), id); return err }},
|
||||
{name: "delete", run: func(s *Service, id string) error { return s.DeleteIssueWatch(context.Background(), id) }},
|
||||
{name: "preview reset", run: func(s *Service, id string) error {
|
||||
_, err := s.PreviewResetIssueWatch(context.Background(), id)
|
||||
return err
|
||||
}},
|
||||
{name: "reset", run: func(s *Service, id string) error { _, err := s.ResetIssueWatch(context.Background(), id); return err }},
|
||||
}
|
||||
for _, operation := range operations {
|
||||
t.Run(operation.name, func(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
w := &IssueWatch{
|
||||
WorkspaceID: "ws-victim", WorkflowID: "wf", WorkflowStepID: "step",
|
||||
JQL: "project = X", AgentProfileID: "ap", Enabled: true,
|
||||
}
|
||||
if err := f.store.CreateIssueWatch(context.Background(), w); err != nil {
|
||||
t.Fatalf("seed watch: %v", err)
|
||||
}
|
||||
f.svc.SetWorkspaceAuthorizer(denyOnly("ws-victim"))
|
||||
if err := operation.run(f.svc, w.ID); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
|
||||
t.Fatalf("expected ErrWorkspaceNotFound, got %v", err)
|
||||
}
|
||||
if got, err := f.store.GetIssueWatch(context.Background(), w.ID); err != nil || got == nil {
|
||||
t.Fatalf("denied operation removed watch: watch=%+v err=%v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestListIssueWatches_DeniesForeignWorkspace covers the single-workspace list
|
||||
// path (the scoped form the settings page uses with an explicit workspace_id).
|
||||
func TestListIssueWatches_DeniesForeignWorkspace(t *testing.T) {
|
||||
|
||||
@@ -12,9 +12,7 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/common/securityutil"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
||||
"github.com/kandev/kandev/internal/watchreset"
|
||||
)
|
||||
|
||||
@@ -64,6 +62,13 @@ type Service struct {
|
||||
// post-construction via SetWorkspaceAuthorizer. Nil (unit tests, auth
|
||||
// disabled) means unscoped — every workspace is visible, as before auth.
|
||||
workspaceAuthorizer func(context.Context, string) error
|
||||
// oauthStates holds pending OAuth CSRF states in memory.
|
||||
oauthStates *mcpStateManager
|
||||
// oauthMu provides per-workspace locks for token refresh to prevent
|
||||
// concurrent refreshes from racing on the rotating refresh token.
|
||||
oauthMu *perWorkspaceMutex
|
||||
// oauthRedirectURI is the fully-qualified callback URL. Set at Provide time.
|
||||
oauthRedirectURI string
|
||||
}
|
||||
|
||||
// SetWorkspaceAuthorizer installs the per-user workspace access boundary applied
|
||||
@@ -133,8 +138,12 @@ func (s *Service) MockClient() *MockClient {
|
||||
// tests can inject fakes without touching HTTP.
|
||||
type ClientFactory func(cfg *JiraConfig, secret string) Client
|
||||
|
||||
// DefaultClientFactory returns a real CloudClient.
|
||||
// DefaultClientFactory returns a real CloudClient for token-based auth, or
|
||||
// MCPClient for OAuth auth (which routes through the MCP server).
|
||||
func DefaultClientFactory(cfg *JiraConfig, secret string) Client {
|
||||
if cfg.AuthMethod == AuthMethodOAuth {
|
||||
return NewMCPClient(secret, cfg.CloudID, cfg.WorkspaceID, cfg.SiteURL)
|
||||
}
|
||||
return NewCloudClient(cfg, secret)
|
||||
}
|
||||
|
||||
@@ -145,11 +154,13 @@ func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log *
|
||||
clientFn = DefaultClientFactory
|
||||
}
|
||||
return &Service{
|
||||
store: store,
|
||||
secrets: secrets,
|
||||
log: log,
|
||||
clientFn: clientFn,
|
||||
clients: make(map[string]Client),
|
||||
store: store,
|
||||
secrets: secrets,
|
||||
log: log,
|
||||
clientFn: clientFn,
|
||||
clients: make(map[string]Client),
|
||||
oauthStates: newMCPStateManager(),
|
||||
oauthMu: newPerWorkspaceMutex(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +204,13 @@ func (s *Service) GetConfigForWorkspace(ctx context.Context, workspaceID string)
|
||||
cfg.SecretExpiresAt = parseSessionCookieExpiry(secret)
|
||||
}
|
||||
}
|
||||
// For OAuth, HasSecret should reflect whether the OAuth access token exists.
|
||||
if cfg.AuthMethod == AuthMethodOAuth && s.secrets != nil {
|
||||
oauthExists, oauthErr := s.secrets.Exists(ctx, OAuthAccessTokenKeyForWorkspace(workspaceID))
|
||||
if oauthErr == nil {
|
||||
cfg.HasSecret = oauthExists
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -227,6 +245,19 @@ func (s *Service) SetConfigForWorkspace(ctx context.Context, workspaceID string,
|
||||
AuthMethod: req.AuthMethod,
|
||||
InstanceType: req.InstanceType,
|
||||
DefaultProjectKey: req.DefaultProjectKey,
|
||||
ClientID: req.ClientID,
|
||||
}
|
||||
// Preserve cloudId for OAuth — it's resolved during the OAuth flow and
|
||||
// shouldn't be overwritten by a regular config save.
|
||||
if req.AuthMethod == AuthMethodOAuth {
|
||||
existing, err := s.store.GetConfigForWorkspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read existing config: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
cfg.CloudID = existing.CloudID
|
||||
cfg.TokenExpiresAt = existing.TokenExpiresAt
|
||||
}
|
||||
}
|
||||
if err := s.store.UpsertConfigForWorkspace(ctx, workspaceID, cfg); err != nil {
|
||||
return nil, fmt.Errorf("upsert jira config: %w", err)
|
||||
@@ -236,6 +267,9 @@ func (s *Service) SetConfigForWorkspace(ctx context.Context, workspaceID string,
|
||||
return nil, fmt.Errorf("store jira secret: %w", err)
|
||||
}
|
||||
}
|
||||
// OAuth: client_id is stored in the config row (UpsertConfigForWorkspace
|
||||
// above). No client_secret — the flow uses PKCE with
|
||||
// token_endpoint_auth_method="none".
|
||||
s.invalidateClient(workspaceID)
|
||||
// Probe asynchronously so a slow Atlassian doesn't stall the save response.
|
||||
// RecordAuthHealth manages its own probe timeout, so a fresh
|
||||
@@ -272,6 +306,14 @@ func (s *Service) DeleteConfigForWorkspace(ctx context.Context, workspaceID stri
|
||||
if err := s.secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)); err != nil {
|
||||
s.log.Warn("jira: secret delete failed", zap.Error(err))
|
||||
}
|
||||
// Clean up OAuth secrets if they exist. Log failures so a stale
|
||||
// refresh token left in the store after config removal is visible.
|
||||
if err := s.secrets.Delete(ctx, OAuthAccessTokenKeyForWorkspace(workspaceID)); err != nil {
|
||||
s.log.Warn("jira: oauth access token delete failed", zap.Error(err))
|
||||
}
|
||||
if err := s.secrets.Delete(ctx, OAuthRefreshTokenKeyForWorkspace(workspaceID)); err != nil {
|
||||
s.log.Warn("jira: oauth refresh token delete failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
s.invalidateClient(workspaceID)
|
||||
return nil
|
||||
@@ -307,7 +349,16 @@ func (s *Service) TestConnectionForWorkspace(ctx context.Context, workspaceID st
|
||||
if err != nil {
|
||||
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
|
||||
}
|
||||
// Set WorkspaceID on the config so MCPClient/SetRefresher get the right ID.
|
||||
cfg.WorkspaceID = workspaceID
|
||||
client := s.clientFn(cfg, secret)
|
||||
// Wire the OAuth token refresher for test-connection clients too,
|
||||
// so an expired access token is auto-refreshed during testing.
|
||||
if cfg.AuthMethod == AuthMethodOAuth {
|
||||
if mc, ok := client.(*MCPClient); ok {
|
||||
mc.SetRefresher(workspaceID, s)
|
||||
}
|
||||
}
|
||||
return client.TestAuth(ctx)
|
||||
}
|
||||
|
||||
@@ -538,7 +589,11 @@ func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, er
|
||||
}
|
||||
secret := ""
|
||||
if s.secrets != nil {
|
||||
secret, err = s.revealSecret(ctx, workspaceID)
|
||||
if cfg.AuthMethod == AuthMethodOAuth {
|
||||
secret, err = s.secrets.Reveal(ctx, OAuthAccessTokenKeyForWorkspace(workspaceID))
|
||||
} else {
|
||||
secret, err = s.revealSecret(ctx, workspaceID)
|
||||
}
|
||||
if err != nil {
|
||||
// Don't conflate a transient secret-store failure with "user never
|
||||
// configured Jira". The UI gates on ErrNotConfigured to render a
|
||||
@@ -551,6 +606,18 @@ func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, er
|
||||
}
|
||||
}
|
||||
client := s.clientFn(cfg, secret)
|
||||
// Wire the OAuth token refresher so clients can auto-refresh on 401.
|
||||
// This covers both the cached path (clientFor) and the test-connection
|
||||
// path (resolveCredentials → clientFn) — both create a fresh client that
|
||||
// needs the refresher wired.
|
||||
if cfg.AuthMethod == AuthMethodOAuth {
|
||||
if cc, ok := client.(*CloudClient); ok {
|
||||
cc.SetRefresher(workspaceID, s)
|
||||
}
|
||||
if mc, ok := client.(*MCPClient); ok {
|
||||
mc.SetRefresher(workspaceID, s)
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Re-check: another caller may have populated the cache while we were
|
||||
@@ -576,91 +643,6 @@ func (s *Service) invalidateClient(workspaceID string) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// resolveCredentials picks the credentials to test: if the request carries a
|
||||
// secret, use it inline (pre-save); otherwise fall back to the stored secret
|
||||
// (post-save re-test).
|
||||
func (s *Service) resolveCredentials(ctx context.Context, workspaceID string, req *SetConfigRequest) (*JiraConfig, string, error) {
|
||||
cfg := &JiraConfig{
|
||||
SiteURL: normalizeSiteURL(req.SiteURL),
|
||||
Email: req.Email,
|
||||
AuthMethod: req.AuthMethod,
|
||||
InstanceType: req.InstanceType,
|
||||
}
|
||||
var secret string
|
||||
if req.Secret != "" {
|
||||
secret = req.Secret
|
||||
} else {
|
||||
if s.secrets == nil {
|
||||
return nil, "", errors.New("no secret store configured")
|
||||
}
|
||||
var err error
|
||||
secret, err = s.revealSecret(ctx, workspaceID)
|
||||
if err != nil {
|
||||
// Don't conflate a transient secret-store failure with "no token
|
||||
// stored". Surfacing the real error gives the user a path to retry
|
||||
// instead of telling them to paste a token they already have.
|
||||
s.log.Warn("jira: secret reveal failed", zap.Error(err))
|
||||
return nil, "", fmt.Errorf("read jira secret: %w", err)
|
||||
}
|
||||
if secret == "" {
|
||||
return nil, "", errors.New("no token stored — paste one to test")
|
||||
}
|
||||
}
|
||||
// Merge with persisted config only when the caller didn't pass enough on
|
||||
// its own. A fully-specified pre-save TestConnection request needs no DB
|
||||
// read — keeping the call gated avoids a transient DB error blocking auth
|
||||
// testing for a request that doesn't depend on persisted state. When a
|
||||
// read does happen, surface its error so a real DB failure isn't masked
|
||||
// as an opaque "missing field" validation message later on.
|
||||
needsStoredConfig := cfg.SiteURL == "" ||
|
||||
cfg.AuthMethod == "" ||
|
||||
cfg.InstanceType == "" ||
|
||||
(cfg.AuthMethod == AuthMethodAPIToken && cfg.Email == "")
|
||||
if needsStoredConfig {
|
||||
if err := s.fillConfigFromStored(ctx, workspaceID, cfg); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if cfg.InstanceType == "" {
|
||||
cfg.InstanceType = InstanceTypeCloud
|
||||
}
|
||||
// Run the same auth/instance check the save path runs, so TestConnection
|
||||
// reports invalid combos (e.g. pat+cloud, api_token+server) with a clear
|
||||
// validation error instead of letting Jira return an opaque 401.
|
||||
if err := validateAuthInstance(cfg.AuthMethod, cfg.InstanceType, cfg.Email); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return cfg, secret, nil
|
||||
}
|
||||
|
||||
// fillConfigFromStored reads the persisted singleton config and copies any
|
||||
// fields the caller left blank onto cfg. A real read failure is returned so
|
||||
// callers don't mask DB errors as opaque "missing field" validation errors;
|
||||
// a missing row (stored == nil) is treated as "nothing to fill" rather than
|
||||
// an error so first-time TestConnection requests still go through.
|
||||
func (s *Service) fillConfigFromStored(ctx context.Context, workspaceID string, cfg *JiraConfig) error {
|
||||
stored, err := s.store.GetConfigForWorkspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read jira config: %w", err)
|
||||
}
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
if cfg.SiteURL == "" {
|
||||
cfg.SiteURL = stored.SiteURL
|
||||
}
|
||||
if cfg.Email == "" {
|
||||
cfg.Email = stored.Email
|
||||
}
|
||||
if cfg.AuthMethod == "" {
|
||||
cfg.AuthMethod = stored.AuthMethod
|
||||
}
|
||||
if cfg.InstanceType == "" {
|
||||
cfg.InstanceType = stored.InstanceType
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) defaultWorkspaceID() (string, error) {
|
||||
return s.store.defaultWorkspaceID()
|
||||
}
|
||||
@@ -726,7 +708,17 @@ func validateConfigRequest(req *SetConfigRequest) error {
|
||||
default:
|
||||
return fmt.Errorf("unknown instance type: %q", req.InstanceType)
|
||||
}
|
||||
return validateAuthInstance(req.AuthMethod, req.InstanceType, req.Email)
|
||||
if err := validateAuthInstance(req.AuthMethod, req.InstanceType, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
// OAuth requires a client_id at config-save time. The client_secret is
|
||||
// stored during the OAuth connect flow, not here.
|
||||
if req.AuthMethod == AuthMethodOAuth && req.ClientID == "" {
|
||||
return errors.New("clientId required for oauth auth")
|
||||
}
|
||||
// OAuth client_id is populated by the dynamic registration flow, not
|
||||
// by the user — so we don't validate it here for non-oauth methods.
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAuthInstance enforces the supported (auth method, instance type)
|
||||
@@ -759,6 +751,12 @@ func validateAuthInstance(authMethod, instanceType, email string) error {
|
||||
if instanceType != InstanceTypeCloud {
|
||||
return errors.New("session_cookie auth is supported only on Atlassian Cloud — use pat for Server/DC")
|
||||
}
|
||||
case AuthMethodOAuth:
|
||||
// OAuth 2.0 (3LO) is an Atlassian Cloud feature. The authorization,
|
||||
// token, and accessible-resources endpoints are Cloud-only.
|
||||
if instanceType != InstanceTypeCloud {
|
||||
return errors.New("oauth auth is supported only on Atlassian Cloud — use pat for Server/DC")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown auth method: %q", authMethod)
|
||||
}
|
||||
@@ -793,286 +791,6 @@ func (s *Service) SetEventBus(eb bus.EventBus) {
|
||||
|
||||
// ErrIssueWatchNotFound is returned when GetIssueWatch's caller looks up an ID
|
||||
// that doesn't exist. Callers map this to HTTP 404.
|
||||
var ErrIssueWatchNotFound = errors.New("jira: issue watch not found")
|
||||
|
||||
// CreateIssueWatch validates the request and persists a new watch row.
|
||||
func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchRequest) (*IssueWatch, error) {
|
||||
if err := validateIssueWatchCreate(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// WorkspaceID comes from the request body, which the query-only integration
|
||||
// middleware never authorizes. Without this a caller could plant a watch in
|
||||
// another user's workspace that repeatedly spawns tasks with that user's
|
||||
// credentials and an attacker-supplied JQL/prompt.
|
||||
if err := s.authorizeWorkspaceAccess(ctx, req.WorkspaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repositoryID, baseBranch, err := s.resolveRepositoryBinding(ctx, req.WorkspaceID, req.RepositoryID, req.BaseBranch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w := &IssueWatch{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
WorkflowID: req.WorkflowID,
|
||||
WorkflowStepID: req.WorkflowStepID,
|
||||
RepositoryID: repositoryID,
|
||||
BaseBranch: baseBranch,
|
||||
JQL: strings.TrimSpace(req.JQL),
|
||||
AgentProfileID: req.AgentProfileID,
|
||||
ExecutorProfileID: req.ExecutorProfileID,
|
||||
Prompt: req.Prompt,
|
||||
PollIntervalSeconds: req.PollIntervalSeconds,
|
||||
MaxInflightTasks: req.MaxInflightTasks,
|
||||
Enabled: true,
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
w.Enabled = *req.Enabled
|
||||
}
|
||||
if err := s.store.CreateIssueWatch(ctx, w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// ListIssueWatches returns the watches configured for a workspace.
|
||||
func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) {
|
||||
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.ListIssueWatches(ctx, workspaceID)
|
||||
}
|
||||
|
||||
// ListAllIssueWatches returns every watch the caller may see. For a scoped
|
||||
// caller that is only their own workspaces' watches; for an identity-less
|
||||
// internal caller (unscoped) it is every watch, as before auth. The unscoped
|
||||
// list form is only reachable when workspace_id is omitted, which the query-only
|
||||
// integration middleware does not authorize — without this filter it leaked
|
||||
// every workspace's watch config (JQL, repo/agent/profile IDs, spawn prompt).
|
||||
func (s *Service) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) {
|
||||
watches, err := s.store.ListAllIssueWatches(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.filterIssueWatchesByAccess(ctx, watches)
|
||||
}
|
||||
|
||||
// filterIssueWatchesByAccess keeps only watches whose workspace the caller may
|
||||
// access. Access decisions are memoized per workspace so a long list costs one
|
||||
// authorize call per distinct workspace, not one per watch. Only an
|
||||
// ErrWorkspaceNotFound denial drops a watch; any other authorizer error (a
|
||||
// transient DB failure, say) is propagated so the caller sees the failure
|
||||
// rather than a silently truncated 200.
|
||||
func (s *Service) filterIssueWatchesByAccess(ctx context.Context, watches []*IssueWatch) ([]*IssueWatch, error) {
|
||||
decision := make(map[string]bool)
|
||||
visible := make([]*IssueWatch, 0, len(watches))
|
||||
for _, w := range watches {
|
||||
allowed, seen := decision[w.WorkspaceID]
|
||||
if !seen {
|
||||
switch err := s.authorizeWorkspaceAccess(ctx, w.WorkspaceID); {
|
||||
case err == nil:
|
||||
allowed = true
|
||||
case errors.Is(err, repoerrors.ErrWorkspaceNotFound):
|
||||
allowed = false
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
decision[w.WorkspaceID] = allowed
|
||||
}
|
||||
if allowed {
|
||||
visible = append(visible, w)
|
||||
}
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
// GetIssueWatch returns a single watch by ID or ErrIssueWatchNotFound.
|
||||
func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) {
|
||||
w, err := s.store.GetIssueWatch(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w == nil {
|
||||
return nil, ErrIssueWatchNotFound
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// UpdateIssueWatch applies a partial update by patching only the fields the
|
||||
// caller explicitly set, then persists the result.
|
||||
func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIssueWatchRequest) (*IssueWatch, error) {
|
||||
w, err := s.GetIssueWatch(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Authorize off the loaded row's workspace — the watch ID alone reveals
|
||||
// nothing about ownership, so a caller must not mutate another user's watch.
|
||||
if err := s.authorizeWorkspaceAccess(ctx, w.WorkspaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prevRepositoryID, prevBaseBranch := w.RepositoryID, w.BaseBranch
|
||||
applyIssueWatchPatch(w, req)
|
||||
// Empty-string PATCH writes (`{"workflowId": ""}` etc.) bypass the nil
|
||||
// guard in applyIssueWatchPatch — Go's JSON decoder returns a non-nil
|
||||
// pointer to "". Guard the post-patch state so a PATCH can't strip a row
|
||||
// of the fields the orchestrator needs to create tasks.
|
||||
if w.JQL == "" {
|
||||
return nil, fmt.Errorf("%w: jql cannot be empty", ErrInvalidConfig)
|
||||
}
|
||||
if w.WorkflowID == "" || w.WorkflowStepID == "" {
|
||||
return nil, fmt.Errorf("%w: workflowId and workflowStepId cannot be empty", ErrInvalidConfig)
|
||||
}
|
||||
if err := validatePollInterval(w.PollIntervalSeconds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateMaxInflightTasks(w.MaxInflightTasks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Only validate/resolve the binding when its value actually changed. The
|
||||
// dialog re-sends repositoryId/baseBranch on every PATCH, and an unchanged
|
||||
// binding whose repo was since soft-deleted must not block edits to other
|
||||
// fields (prompt, JQL, …).
|
||||
if w.RepositoryID != prevRepositoryID || w.BaseBranch != prevBaseBranch {
|
||||
repositoryID, baseBranch, err := s.resolveRepositoryBinding(ctx, w.WorkspaceID, w.RepositoryID, w.BaseBranch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.RepositoryID = repositoryID
|
||||
w.BaseBranch = baseBranch
|
||||
}
|
||||
if err := s.store.UpdateIssueWatch(ctx, w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// DeleteIssueWatch removes the watch and its dedup rows. Idempotent: deleting
|
||||
// a missing ID is a silent success.
|
||||
func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error {
|
||||
return s.store.DeleteIssueWatch(ctx, id)
|
||||
}
|
||||
|
||||
// jiraIssueWatchResetter is the watchreset.Resetter adapter for a single
|
||||
// Jira issue watch. Closes over the store and watch ID so the shared
|
||||
// watchreset.Run helper stays integration-agnostic.
|
||||
type jiraIssueWatchResetter struct {
|
||||
store *Store
|
||||
watchID string
|
||||
}
|
||||
|
||||
func (r *jiraIssueWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) {
|
||||
return r.store.ListIssueWatchTaskIDs(ctx, r.watchID)
|
||||
}
|
||||
|
||||
func (r *jiraIssueWatchResetter) Clear(ctx context.Context) error {
|
||||
return r.store.ResetIssueWatchState(ctx, r.watchID)
|
||||
}
|
||||
|
||||
// PreviewResetIssueWatch returns how many tasks ResetIssueWatch would
|
||||
// cascade-delete. Used by the frontend to populate the confirmation dialog.
|
||||
func (s *Service) PreviewResetIssueWatch(ctx context.Context, watchID string) (int, error) {
|
||||
return watchreset.Preview(ctx, &jiraIssueWatchResetter{store: s.store, watchID: watchID})
|
||||
}
|
||||
|
||||
// ResetIssueWatch is destructive: cascade-deletes every task previously
|
||||
// created by the watch (including archived), wipes the per-watch dedup
|
||||
// rows, and nulls last_polled_at so the next poll re-imports every
|
||||
// currently-matching ticket. Returns the count of tasks deleted.
|
||||
func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, error) {
|
||||
s.mu.Lock()
|
||||
td := s.taskDeleter
|
||||
s.mu.Unlock()
|
||||
if td == nil {
|
||||
return 0, errors.New("jira: task deleter not wired; reset unavailable")
|
||||
}
|
||||
res, err := watchreset.Run(ctx,
|
||||
&jiraIssueWatchResetter{store: s.store, watchID: watchID},
|
||||
td, s.log)
|
||||
return res.TasksDeleted, err
|
||||
}
|
||||
|
||||
// CheckIssueWatch runs the watch's JQL once and returns the tickets that
|
||||
// haven't been turned into tasks yet. last_polled_at is stamped regardless of
|
||||
// whether the search succeeded — a failing search still counts as "we tried"
|
||||
// so the UI can show liveness.
|
||||
func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*JiraTicket, error) {
|
||||
defer s.stampLastPolled(w.ID)
|
||||
client, err := s.clientFor(ctx, w.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Single page is enough per tick: the next poll picks up anything that
|
||||
// overflowed maxResults. Pagination would let one chatty workspace starve
|
||||
// the others without any real freshness benefit.
|
||||
res, err := client.SearchTickets(ctx, w.JQL, "", issueWatchSearchPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Bulk-fetch the dedup set once per call instead of one query per ticket —
|
||||
// a broad JQL can return up to issueWatchSearchPageSize tickets, so a
|
||||
// per-ticket round-trip would multiply the per-tick cost.
|
||||
seen, err := s.store.ListSeenIssueKeys(ctx, w.ID)
|
||||
if err != nil {
|
||||
s.log.Warn("jira: dedup set fetch failed",
|
||||
zap.String("watch_id", w.ID), zap.Error(err))
|
||||
seen = nil // fall through: a missing dedup set is safer than dropping all tickets
|
||||
}
|
||||
out := make([]*JiraTicket, 0, len(res.Tickets))
|
||||
for i := range res.Tickets {
|
||||
t := res.Tickets[i]
|
||||
if _, ok := seen[t.Key]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, &t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stampLastPolled writes the current timestamp onto the watch row using a
|
||||
// fresh background context with a short write deadline. The caller's ctx may
|
||||
// already be cancelled (typical at poller shutdown), which would cause the
|
||||
// DB write to fail silently and the watch would look "never polled" on the
|
||||
// next backend restart. Mirrors the pattern in RecordAuthHealth.
|
||||
func (s *Service) stampLastPolled(watchID string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), authHealthWriteTimeout)
|
||||
defer cancel()
|
||||
if err := s.store.UpdateIssueWatchLastPolled(ctx, watchID, time.Now().UTC()); err != nil {
|
||||
s.log.Warn("jira: update last_polled_at failed",
|
||||
zap.String("watch_id", watchID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// publishNewJiraIssueEvent emits the orchestrator-facing event for one freshly
|
||||
// observed ticket. No-op when the event bus is not wired (tests, early boot).
|
||||
func (s *Service) publishNewJiraIssueEvent(ctx context.Context, w *IssueWatch, ticket *JiraTicket) {
|
||||
s.mu.Lock()
|
||||
eb := s.eventBus
|
||||
s.mu.Unlock()
|
||||
if eb == nil {
|
||||
return
|
||||
}
|
||||
evt := bus.NewEvent(events.JiraNewIssue, "jira", &NewJiraIssueEvent{
|
||||
IssueWatchID: w.ID,
|
||||
WorkspaceID: w.WorkspaceID,
|
||||
WorkflowID: w.WorkflowID,
|
||||
WorkflowStepID: w.WorkflowStepID,
|
||||
RepositoryID: w.RepositoryID,
|
||||
BaseBranch: w.BaseBranch,
|
||||
AgentProfileID: w.AgentProfileID,
|
||||
ExecutorProfileID: w.ExecutorProfileID,
|
||||
Prompt: w.Prompt,
|
||||
MaxInflightTasks: w.MaxInflightTasks,
|
||||
Issue: ticket,
|
||||
})
|
||||
if err := eb.Publish(ctx, events.JiraNewIssue, evt); err != nil {
|
||||
s.log.Debug("jira: publish new issue event failed",
|
||||
zap.String("watch_id", w.ID), zap.String("issue_key", ticket.Key), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// issueWatchSearchPageSize caps how many tickets a single CheckIssueWatch call
|
||||
// pulls from JIRA. 50 is well under the API's 100-result limit and keeps the
|
||||
// per-tick cost bounded even for very broad JQL queries.
|
||||
const issueWatchSearchPageSize = 50
|
||||
|
||||
// MinIssueWatchPollInterval / MaxIssueWatchPollInterval bound the per-watch
|
||||
// JQL re-run cadence. The lower limit protects Atlassian from rapid-fire
|
||||
|
||||
@@ -313,3 +313,34 @@ func TestService_CheckIssueWatch_StampsLastPolledOnError(t *testing.T) {
|
||||
t.Error("expected last_polled_at stamped even on search failure (liveness signal)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_CheckIssueWatchReturnsDedupLookupError(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
ctx := context.Background()
|
||||
if err := f.store.UpsertConfigForWorkspace(ctx, "ws-1", &JiraConfig{
|
||||
WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e",
|
||||
AuthMethod: AuthMethodAPIToken, InstanceType: InstanceTypeCloud,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
if err := f.secrets.Set(ctx, SecretKeyForWorkspace("ws-1"), "token", "tok"); err != nil {
|
||||
t.Fatalf("seed secret: %v", err)
|
||||
}
|
||||
if _, err := f.svc.clientFor(ctx, "ws-1"); err != nil {
|
||||
t.Fatalf("prime client: %v", err)
|
||||
}
|
||||
f.client.withSearchResults([]JiraTicket{{Key: "PROJ-1"}})
|
||||
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
|
||||
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", JQL: "project = PROJ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create watch: %v", err)
|
||||
}
|
||||
if _, err := f.store.db.ExecContext(ctx, "DROP TABLE jira_issue_watch_tasks"); err != nil {
|
||||
t.Fatalf("break dedup lookup: %v", err)
|
||||
}
|
||||
|
||||
if _, err := f.svc.CheckIssueWatch(ctx, w); err == nil {
|
||||
t.Fatal("expected dedup lookup failure to stop ticket delivery")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package jira
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RevealAccessToken reads the current OAuth access token from the secret store.
|
||||
// Implements the TokenRefresher interface for CloudClient/MCPClient auto-refresh.
|
||||
func (s *Service) RevealAccessToken(ctx context.Context, workspaceID string) (string, error) {
|
||||
if s.secrets == nil {
|
||||
return "", nil
|
||||
}
|
||||
return s.secrets.Reveal(ctx, OAuthAccessTokenKeyForWorkspace(workspaceID))
|
||||
}
|
||||
|
||||
// SetOAuthRedirectURI sets the OAuth callback URL. Called at Provide time.
|
||||
func (s *Service) SetOAuthRedirectURI(uri string) {
|
||||
if s != nil {
|
||||
s.oauthRedirectURI = uri
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSecret picks the secret to test: inline from the request if present,
|
||||
// or from the stored secret/OAuth access token otherwise.
|
||||
func (s *Service) resolveSecret(ctx context.Context, workspaceID string, req *SetConfigRequest) (string, error) {
|
||||
if req.Secret != "" {
|
||||
return req.Secret, nil
|
||||
}
|
||||
if s.secrets == nil {
|
||||
return "", errors.New("no secret store configured")
|
||||
}
|
||||
var secret string
|
||||
var err error
|
||||
if req.AuthMethod == AuthMethodOAuth {
|
||||
secret, err = s.secrets.Reveal(ctx, OAuthAccessTokenKeyForWorkspace(workspaceID))
|
||||
} else {
|
||||
secret, err = s.revealSecret(ctx, workspaceID)
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Warn("jira: secret reveal failed", zap.Error(err))
|
||||
return "", fmt.Errorf("read jira secret: %w", err)
|
||||
}
|
||||
if secret == "" {
|
||||
return "", errors.New("no token stored - paste one to test")
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// resolveCredentials picks the credentials to test: if the request carries a
|
||||
// secret, use it inline (pre-save); otherwise fall back to the stored secret
|
||||
// (post-save re-test). For OAuth, reads the OAuth access token instead of
|
||||
// the legacy secret key.
|
||||
func (s *Service) resolveCredentials(ctx context.Context, workspaceID string, req *SetConfigRequest) (*JiraConfig, string, error) {
|
||||
cfg := &JiraConfig{
|
||||
SiteURL: normalizeSiteURL(req.SiteURL),
|
||||
Email: req.Email,
|
||||
AuthMethod: req.AuthMethod,
|
||||
InstanceType: req.InstanceType,
|
||||
}
|
||||
secret, err := s.resolveSecret(ctx, workspaceID, req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
needsStoredConfig := cfg.SiteURL == "" ||
|
||||
cfg.AuthMethod == "" ||
|
||||
cfg.InstanceType == "" ||
|
||||
cfg.AuthMethod == AuthMethodOAuth ||
|
||||
(cfg.AuthMethod == AuthMethodAPIToken && cfg.Email == "")
|
||||
if needsStoredConfig {
|
||||
if err := s.fillConfigFromStored(ctx, workspaceID, cfg); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if cfg.InstanceType == "" {
|
||||
cfg.InstanceType = InstanceTypeCloud
|
||||
}
|
||||
if err := validateAuthInstance(cfg.AuthMethod, cfg.InstanceType, cfg.Email); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return cfg, secret, nil
|
||||
}
|
||||
|
||||
// fillConfigFromStored reads the persisted config and copies any fields the
|
||||
// caller left blank onto cfg. Includes OAuth fields (ClientID, CloudID).
|
||||
func (s *Service) fillConfigFromStored(ctx context.Context, workspaceID string, cfg *JiraConfig) error {
|
||||
stored, err := s.store.GetConfigForWorkspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read jira config: %w", err)
|
||||
}
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
if cfg.SiteURL == "" {
|
||||
cfg.SiteURL = stored.SiteURL
|
||||
}
|
||||
if cfg.Email == "" {
|
||||
cfg.Email = stored.Email
|
||||
}
|
||||
if cfg.AuthMethod == "" {
|
||||
cfg.AuthMethod = stored.AuthMethod
|
||||
}
|
||||
if cfg.InstanceType == "" {
|
||||
cfg.InstanceType = stored.InstanceType
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
cfg.ClientID = stored.ClientID
|
||||
}
|
||||
if cfg.CloudID == "" {
|
||||
cfg.CloudID = stored.CloudID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package jira
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
||||
"github.com/kandev/kandev/internal/watchreset"
|
||||
)
|
||||
|
||||
var ErrIssueWatchNotFound = errors.New("jira: issue watch not found")
|
||||
|
||||
// CreateIssueWatch validates the request and persists a new watch row.
|
||||
func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchRequest) (*IssueWatch, error) {
|
||||
if err := validateIssueWatchCreate(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// WorkspaceID comes from the request body, which the query-only integration
|
||||
// middleware never authorizes. Without this a caller could plant a watch in
|
||||
// another user's workspace that repeatedly spawns tasks with that user's
|
||||
// credentials and an attacker-supplied JQL/prompt.
|
||||
if err := s.authorizeWorkspaceAccess(ctx, req.WorkspaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repositoryID, baseBranch, err := s.resolveRepositoryBinding(ctx, req.WorkspaceID, req.RepositoryID, req.BaseBranch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w := &IssueWatch{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
WorkflowID: req.WorkflowID,
|
||||
WorkflowStepID: req.WorkflowStepID,
|
||||
RepositoryID: repositoryID,
|
||||
BaseBranch: baseBranch,
|
||||
JQL: strings.TrimSpace(req.JQL),
|
||||
AgentProfileID: req.AgentProfileID,
|
||||
ExecutorProfileID: req.ExecutorProfileID,
|
||||
Prompt: req.Prompt,
|
||||
PollIntervalSeconds: req.PollIntervalSeconds,
|
||||
MaxInflightTasks: req.MaxInflightTasks,
|
||||
Enabled: true,
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
w.Enabled = *req.Enabled
|
||||
}
|
||||
if err := s.store.CreateIssueWatch(ctx, w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// ListIssueWatches returns the watches configured for a workspace.
|
||||
func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) {
|
||||
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.ListIssueWatches(ctx, workspaceID)
|
||||
}
|
||||
|
||||
// ListAllIssueWatches returns every watch the caller may see. For a scoped
|
||||
// caller that is only their own workspaces' watches; for an identity-less
|
||||
// internal caller (unscoped) it is every watch, as before auth. The unscoped
|
||||
// list form is only reachable when workspace_id is omitted, which the query-only
|
||||
// integration middleware does not authorize — without this filter it leaked
|
||||
// every workspace's watch config (JQL, repo/agent/profile IDs, spawn prompt).
|
||||
func (s *Service) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) {
|
||||
watches, err := s.store.ListAllIssueWatches(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.filterIssueWatchesByAccess(ctx, watches)
|
||||
}
|
||||
|
||||
// filterIssueWatchesByAccess keeps only watches whose workspace the caller may
|
||||
// access. Access decisions are memoized per workspace so a long list costs one
|
||||
// authorize call per distinct workspace, not one per watch. Only an
|
||||
// ErrWorkspaceNotFound denial drops a watch; any other authorizer error (a
|
||||
// transient DB failure, say) is propagated so the caller sees the failure
|
||||
// rather than a silently truncated 200.
|
||||
func (s *Service) filterIssueWatchesByAccess(ctx context.Context, watches []*IssueWatch) ([]*IssueWatch, error) {
|
||||
decision := make(map[string]bool)
|
||||
visible := make([]*IssueWatch, 0, len(watches))
|
||||
for _, w := range watches {
|
||||
allowed, seen := decision[w.WorkspaceID]
|
||||
if !seen {
|
||||
switch err := s.authorizeWorkspaceAccess(ctx, w.WorkspaceID); {
|
||||
case err == nil:
|
||||
allowed = true
|
||||
case errors.Is(err, repoerrors.ErrWorkspaceNotFound):
|
||||
allowed = false
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
decision[w.WorkspaceID] = allowed
|
||||
}
|
||||
if allowed {
|
||||
visible = append(visible, w)
|
||||
}
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
// GetIssueWatch returns a single watch by ID or ErrIssueWatchNotFound.
|
||||
func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) {
|
||||
w, err := s.store.GetIssueWatch(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w == nil {
|
||||
return nil, ErrIssueWatchNotFound
|
||||
}
|
||||
if err := s.authorizeWorkspaceAccess(ctx, w.WorkspaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// UpdateIssueWatch applies a partial update by patching only the fields the
|
||||
// caller explicitly set, then persists the result.
|
||||
func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIssueWatchRequest) (*IssueWatch, error) {
|
||||
w, err := s.GetIssueWatch(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prevRepositoryID, prevBaseBranch := w.RepositoryID, w.BaseBranch
|
||||
applyIssueWatchPatch(w, req)
|
||||
// Empty-string PATCH writes (`{"workflowId": ""}` etc.) bypass the nil
|
||||
// guard in applyIssueWatchPatch — Go's JSON decoder returns a non-nil
|
||||
// pointer to "". Guard the post-patch state so a PATCH can't strip a row
|
||||
// of the fields the orchestrator needs to create tasks.
|
||||
if w.JQL == "" {
|
||||
return nil, fmt.Errorf("%w: jql cannot be empty", ErrInvalidConfig)
|
||||
}
|
||||
if w.WorkflowID == "" || w.WorkflowStepID == "" {
|
||||
return nil, fmt.Errorf("%w: workflowId and workflowStepId cannot be empty", ErrInvalidConfig)
|
||||
}
|
||||
if err := validatePollInterval(w.PollIntervalSeconds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateMaxInflightTasks(w.MaxInflightTasks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Only validate/resolve the binding when its value actually changed. The
|
||||
// dialog re-sends repositoryId/baseBranch on every PATCH, and an unchanged
|
||||
// binding whose repo was since soft-deleted must not block edits to other
|
||||
// fields (prompt, JQL, …).
|
||||
if w.RepositoryID != prevRepositoryID || w.BaseBranch != prevBaseBranch {
|
||||
repositoryID, baseBranch, err := s.resolveRepositoryBinding(ctx, w.WorkspaceID, w.RepositoryID, w.BaseBranch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.RepositoryID = repositoryID
|
||||
w.BaseBranch = baseBranch
|
||||
}
|
||||
if err := s.store.UpdateIssueWatch(ctx, w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// DeleteIssueWatch removes the watch and its dedup rows. Idempotent: deleting
|
||||
// a missing ID is a silent success.
|
||||
func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error {
|
||||
w, err := s.store.GetIssueWatch(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.authorizeWorkspaceAccess(ctx, w.WorkspaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.DeleteIssueWatch(ctx, id)
|
||||
}
|
||||
|
||||
// jiraIssueWatchResetter is the watchreset.Resetter adapter for a single
|
||||
// Jira issue watch. Closes over the store and watch ID so the shared
|
||||
// watchreset.Run helper stays integration-agnostic.
|
||||
type jiraIssueWatchResetter struct {
|
||||
store *Store
|
||||
watchID string
|
||||
}
|
||||
|
||||
func (r *jiraIssueWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) {
|
||||
return r.store.ListIssueWatchTaskIDs(ctx, r.watchID)
|
||||
}
|
||||
|
||||
func (r *jiraIssueWatchResetter) Clear(ctx context.Context) error {
|
||||
return r.store.ResetIssueWatchState(ctx, r.watchID)
|
||||
}
|
||||
|
||||
// PreviewResetIssueWatch returns how many tasks ResetIssueWatch would
|
||||
// cascade-delete. Used by the frontend to populate the confirmation dialog.
|
||||
func (s *Service) PreviewResetIssueWatch(ctx context.Context, watchID string) (int, error) {
|
||||
if _, err := s.GetIssueWatch(ctx, watchID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return watchreset.Preview(ctx, &jiraIssueWatchResetter{store: s.store, watchID: watchID})
|
||||
}
|
||||
|
||||
// ResetIssueWatch is destructive: cascade-deletes every task previously
|
||||
// created by the watch (including archived), wipes the per-watch dedup
|
||||
// rows, and nulls last_polled_at so the next poll re-imports every
|
||||
// currently-matching ticket. Returns the count of tasks deleted.
|
||||
func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, error) {
|
||||
if _, err := s.GetIssueWatch(ctx, watchID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
td := s.taskDeleter
|
||||
s.mu.Unlock()
|
||||
if td == nil {
|
||||
return 0, errors.New("jira: task deleter not wired; reset unavailable")
|
||||
}
|
||||
res, err := watchreset.Run(ctx,
|
||||
&jiraIssueWatchResetter{store: s.store, watchID: watchID},
|
||||
td, s.log)
|
||||
return res.TasksDeleted, err
|
||||
}
|
||||
|
||||
// CheckIssueWatch runs the watch's JQL once and returns the tickets that
|
||||
// haven't been turned into tasks yet. last_polled_at is stamped regardless of
|
||||
// whether the search succeeded — a failing search still counts as "we tried"
|
||||
// so the UI can show liveness.
|
||||
func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*JiraTicket, error) {
|
||||
defer s.stampLastPolled(w.ID)
|
||||
client, err := s.clientFor(ctx, w.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Single page is enough per tick: the next poll picks up anything that
|
||||
// overflowed maxResults. Pagination would let one chatty workspace starve
|
||||
// the others without any real freshness benefit.
|
||||
res, err := client.SearchTickets(ctx, w.JQL, "", issueWatchSearchPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Bulk-fetch the dedup set once per call instead of one query per ticket —
|
||||
// a broad JQL can return up to issueWatchSearchPageSize tickets, so a
|
||||
// per-ticket round-trip would multiply the per-tick cost.
|
||||
seen, err := s.store.ListSeenIssueKeys(ctx, w.ID)
|
||||
if err != nil {
|
||||
s.log.Warn("jira: dedup set fetch failed",
|
||||
zap.String("watch_id", w.ID), zap.Error(err))
|
||||
return nil, fmt.Errorf("load Jira issue-watch dedup set: %w", err)
|
||||
}
|
||||
out := make([]*JiraTicket, 0, len(res.Tickets))
|
||||
for i := range res.Tickets {
|
||||
t := res.Tickets[i]
|
||||
if _, ok := seen[t.Key]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, &t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stampLastPolled writes the current timestamp onto the watch row using a
|
||||
// fresh background context with a short write deadline. The caller's ctx may
|
||||
// already be cancelled (typical at poller shutdown), which would cause the
|
||||
// DB write to fail silently and the watch would look "never polled" on the
|
||||
// next backend restart. Mirrors the pattern in RecordAuthHealth.
|
||||
func (s *Service) stampLastPolled(watchID string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), authHealthWriteTimeout)
|
||||
defer cancel()
|
||||
if err := s.store.UpdateIssueWatchLastPolled(ctx, watchID, time.Now().UTC()); err != nil {
|
||||
s.log.Warn("jira: update last_polled_at failed",
|
||||
zap.String("watch_id", watchID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// publishNewJiraIssueEvent emits the orchestrator-facing event for one freshly
|
||||
// observed ticket. No-op when the event bus is not wired (tests, early boot).
|
||||
func (s *Service) publishNewJiraIssueEvent(ctx context.Context, w *IssueWatch, ticket *JiraTicket) {
|
||||
s.mu.Lock()
|
||||
eb := s.eventBus
|
||||
s.mu.Unlock()
|
||||
if eb == nil {
|
||||
return
|
||||
}
|
||||
evt := bus.NewEvent(events.JiraNewIssue, "jira", &NewJiraIssueEvent{
|
||||
IssueWatchID: w.ID,
|
||||
WorkspaceID: w.WorkspaceID,
|
||||
WorkflowID: w.WorkflowID,
|
||||
WorkflowStepID: w.WorkflowStepID,
|
||||
RepositoryID: w.RepositoryID,
|
||||
BaseBranch: w.BaseBranch,
|
||||
AgentProfileID: w.AgentProfileID,
|
||||
ExecutorProfileID: w.ExecutorProfileID,
|
||||
Prompt: w.Prompt,
|
||||
MaxInflightTasks: w.MaxInflightTasks,
|
||||
Issue: ticket,
|
||||
})
|
||||
if err := eb.Publish(ctx, events.JiraNewIssue, evt); err != nil {
|
||||
s.log.Debug("jira: publish new issue event failed",
|
||||
zap.String("watch_id", w.ID), zap.String("issue_key", ticket.Key), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// issueWatchSearchPageSize caps how many tickets a single CheckIssueWatch call
|
||||
// pulls from JIRA. 50 is well under the API's 100-result limit and keeps the
|
||||
// per-tick cost bounded even for very broad JQL queries.
|
||||
const issueWatchSearchPageSize = 50
|
||||
@@ -124,6 +124,9 @@ func (s *Store) initSchema() error {
|
||||
if err := s.addIssueWatchRepositoryColumns(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.addOAuthColumns(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -241,6 +244,32 @@ func (s *Store) addInstanceTypeColumn() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// addOAuthColumns brings older databases up to the current schema by adding
|
||||
// client_id, cloud_id, and token_expires_at to jira_configs when missing.
|
||||
// All three backfill to empty/NULL and are only populated for OAuth configs.
|
||||
func (s *Store) addOAuthColumns() error {
|
||||
cols, err := s.tableColumns("jira_configs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := cols["client_id"]; !ok {
|
||||
if _, err := s.db.Exec(`ALTER TABLE jira_configs ADD COLUMN client_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return fmt.Errorf("add client_id column: %w", err)
|
||||
}
|
||||
}
|
||||
if _, ok := cols["cloud_id"]; !ok {
|
||||
if _, err := s.db.Exec(`ALTER TABLE jira_configs ADD COLUMN cloud_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return fmt.Errorf("add cloud_id column: %w", err)
|
||||
}
|
||||
}
|
||||
if _, ok := cols["token_expires_at"]; !ok {
|
||||
if _, err := s.db.Exec(`ALTER TABLE jira_configs ADD COLUMN token_expires_at DATETIME`); err != nil {
|
||||
return fmt.Errorf("add token_expires_at column: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateLegacySingletonTable detects the install-wide singleton schema and
|
||||
// rewrites it into the workspace-scoped shape. Picks the active/default
|
||||
// workspace as the target so startup is deterministic.
|
||||
@@ -372,7 +401,8 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) {
|
||||
}
|
||||
|
||||
const selectConfigColumns = `workspace_id, site_url, email, auth_method, instance_type, default_project_key,
|
||||
last_checked_at, last_ok, last_error, created_at, updated_at`
|
||||
last_checked_at, last_ok, last_error, created_at, updated_at,
|
||||
client_id, cloud_id, token_expires_at`
|
||||
|
||||
// GetConfig returns the default workspace Jira config, or nil when no row
|
||||
// exists. New code should call GetConfigForWorkspace.
|
||||
@@ -433,16 +463,21 @@ func (s *Store) UpsertConfigForWorkspace(ctx context.Context, workspaceID string
|
||||
cfg.InstanceType = InstanceTypeCloud
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO jira_configs (workspace_id, site_url, email, auth_method, instance_type, default_project_key, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO jira_configs (workspace_id, site_url, email, auth_method, instance_type, default_project_key,
|
||||
client_id, cloud_id, token_expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET
|
||||
site_url = excluded.site_url,
|
||||
email = excluded.email,
|
||||
auth_method = excluded.auth_method,
|
||||
instance_type = excluded.instance_type,
|
||||
default_project_key = excluded.default_project_key,
|
||||
client_id = excluded.client_id,
|
||||
cloud_id = excluded.cloud_id,
|
||||
token_expires_at = excluded.token_expires_at,
|
||||
updated_at = excluded.updated_at`,
|
||||
workspaceID, cfg.SiteURL, cfg.Email, cfg.AuthMethod, cfg.InstanceType, cfg.DefaultProjectKey, cfg.CreatedAt, cfg.UpdatedAt)
|
||||
workspaceID, cfg.SiteURL, cfg.Email, cfg.AuthMethod, cfg.InstanceType, cfg.DefaultProjectKey,
|
||||
cfg.ClientID, cfg.CloudID, cfg.TokenExpiresAt, cfg.CreatedAt, cfg.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -514,6 +549,19 @@ func (s *Store) UpdateAuthHealthForWorkspace(ctx context.Context, workspaceID st
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateTokenExpiryForWorkspace updates the OAuth access token expiry for a
|
||||
// workspace config row. Called after a successful token refresh.
|
||||
func (s *Store) UpdateTokenExpiryForWorkspace(ctx context.Context, workspaceID string, expiresAt time.Time) error {
|
||||
workspaceID, err := s.resolveWorkspaceID(workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE jira_configs SET token_expires_at = ?, updated_at = ? WHERE workspace_id = ?`,
|
||||
expiresAt, time.Now().UTC(), workspaceID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) defaultWorkspaceID() (string, error) {
|
||||
return s.defaultWorkspace.Resolve(s.db)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { activateLocale, i18n } from "@/lib/i18n";
|
||||
import type { JiraConfig } from "@/lib/types/jira";
|
||||
import { configToForm, deriveFormState, type FormState } from "./jira-form-state";
|
||||
|
||||
const t = i18n.t;
|
||||
|
||||
beforeAll(async () => {
|
||||
await activateLocale("en");
|
||||
});
|
||||
|
||||
function config(overrides: Partial<JiraConfig> = {}): JiraConfig {
|
||||
return {
|
||||
siteUrl: "https://acme.atlassian.net",
|
||||
email: "user@example.com",
|
||||
authMethod: "oauth",
|
||||
instanceType: "cloud",
|
||||
defaultProjectKey: "ENG",
|
||||
hasSecret: true,
|
||||
clientId: "client-1",
|
||||
lastOk: true,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function oauthForm(overrides: Partial<FormState> = {}): FormState {
|
||||
return {
|
||||
...configToForm(config()),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function derive(saved: JiraConfig | null, form: FormState) {
|
||||
return deriveFormState({ config: saved, form, saving: false, loading: false }, t);
|
||||
}
|
||||
|
||||
describe("deriveFormState OAuth credential identity", () => {
|
||||
it("requires a connection before a fresh OAuth form can be saved or tested", () => {
|
||||
const state = derive(null, oauthForm());
|
||||
expect(state.disableSave).toBe(true);
|
||||
expect(state.disableTest).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reuse an API-token secret after switching to OAuth", () => {
|
||||
const saved = config({ authMethod: "api_token", clientId: undefined });
|
||||
const state = derive(saved, oauthForm());
|
||||
expect(state.disableSave).toBe(true);
|
||||
expect(state.disableTest).toBe(true);
|
||||
});
|
||||
|
||||
it("requires reconnecting after the OAuth site changes", () => {
|
||||
const saved = config();
|
||||
const state = derive(saved, oauthForm({ siteUrl: "https://other.atlassian.net" }));
|
||||
expect(state.savedSecretMatchesMode).toBe(false);
|
||||
expect(state.disableSave).toBe(true);
|
||||
expect(state.disableTest).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a saved OAuth connection whose full identity still matches", () => {
|
||||
const saved = config();
|
||||
const state = derive(saved, oauthForm());
|
||||
expect(state.savedSecretMatchesMode).toBe(true);
|
||||
expect(state.disableSave).toBe(false);
|
||||
expect(state.disableTest).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { JiraAuthMethod, JiraConfig, JiraInstanceType } from "@/lib/types/jira";
|
||||
|
||||
export type FormState = {
|
||||
siteUrl: string;
|
||||
email: string;
|
||||
authMethod: JiraAuthMethod;
|
||||
instanceType: JiraInstanceType;
|
||||
defaultProjectKey: string;
|
||||
secret: string;
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
export const emptyForm: FormState = {
|
||||
siteUrl: "",
|
||||
email: "",
|
||||
authMethod: "api_token",
|
||||
instanceType: "cloud",
|
||||
defaultProjectKey: "",
|
||||
secret: "",
|
||||
clientId: "",
|
||||
};
|
||||
|
||||
export function configToForm(cfg: JiraConfig | null): FormState {
|
||||
if (!cfg) return emptyForm;
|
||||
return {
|
||||
siteUrl: cfg.siteUrl,
|
||||
email: cfg.email,
|
||||
authMethod: cfg.authMethod,
|
||||
// Legacy rows written before Server/DC support carry an empty instanceType;
|
||||
// default to cloud so the dropdown has a valid selection.
|
||||
instanceType: cfg.instanceType || "cloud",
|
||||
defaultProjectKey: cfg.defaultProjectKey,
|
||||
secret: "",
|
||||
clientId: cfg.clientId || "",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeComparableSiteUrl(value: string): string {
|
||||
const trimmed = value.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) return "";
|
||||
return trimmed.includes("://") ? trimmed : `https://${trimmed}`;
|
||||
}
|
||||
|
||||
// savedSecretMatches reports whether the saved secret can be reused against
|
||||
// the current form values. Reuse is only safe when every identity component
|
||||
// of the saved credential still matches: same auth method, same instance
|
||||
// type, same Jira host, and — for Cloud api_token where the basic pair is
|
||||
// email:token — the same email (case-insensitive). Otherwise the user could
|
||||
// change the site URL or Cloud account and silently submit the previous
|
||||
// token to a different host/account.
|
||||
export function savedSecretMatches(config: JiraConfig | null, form: FormState): boolean {
|
||||
if (!config?.hasSecret) return false;
|
||||
if (config.authMethod !== form.authMethod) return false;
|
||||
if ((config.instanceType || "cloud") !== form.instanceType) return false;
|
||||
if (normalizeComparableSiteUrl(config.siteUrl) !== normalizeComparableSiteUrl(form.siteUrl)) {
|
||||
return false;
|
||||
}
|
||||
if (form.authMethod !== "api_token") return true;
|
||||
return (config.email ?? "").toLowerCase() === form.email.toLowerCase();
|
||||
}
|
||||
|
||||
export type DerivedFormState = {
|
||||
isOAuth: boolean;
|
||||
savedSecretMatchesMode: boolean;
|
||||
disableSave: boolean;
|
||||
disableTest: boolean;
|
||||
revision: string;
|
||||
dirty: boolean;
|
||||
invalidReason: string | undefined;
|
||||
};
|
||||
|
||||
// The subset of the settings hook's state deriveFormState reads. Structural, so
|
||||
// this module stays independent of the component's hook return type.
|
||||
type FormStateInputs = {
|
||||
config: JiraConfig | null;
|
||||
form: FormState;
|
||||
saving: boolean;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
function jiraDisableSave(
|
||||
s: FormStateInputs,
|
||||
emailRequired: boolean,
|
||||
missingSecret: boolean,
|
||||
oauthNeedsConnect: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
s.saving ||
|
||||
!s.form.siteUrl ||
|
||||
(emailRequired && !s.form.email) ||
|
||||
missingSecret ||
|
||||
oauthNeedsConnect
|
||||
);
|
||||
}
|
||||
|
||||
function jiraInvalidReason(
|
||||
form: FormState,
|
||||
emailRequired: boolean,
|
||||
missingSecret: boolean,
|
||||
t: TFunction,
|
||||
): string | undefined {
|
||||
if (!form.siteUrl) return t("jira:aJiraSiteUrlIsRequired");
|
||||
if (emailRequired && !form.email) return t("jira:anEmailAddressIsRequired");
|
||||
if (missingSecret) return t("jira:aCredentialIsRequired");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Derives save/test gating and the validation message from the form + stored
|
||||
// config. Extracted so JiraConnectionSection stays under the complexity limit.
|
||||
export function deriveFormState(s: FormStateInputs, t: TFunction): DerivedFormState {
|
||||
const savedSecretMatchesMode = savedSecretMatches(s.config, s.form);
|
||||
const isOAuth = s.form.authMethod === "oauth";
|
||||
const missingSecret = !isOAuth && !savedSecretMatchesMode && !s.form.secret;
|
||||
const oauthNeedsConnect = isOAuth && !savedSecretMatchesMode;
|
||||
const emailRequired = s.form.instanceType === "cloud" && s.form.authMethod === "api_token";
|
||||
const disableSave = jiraDisableSave(s, emailRequired, missingSecret, oauthNeedsConnect);
|
||||
const disableTest = missingSecret || (isOAuth && oauthNeedsConnect);
|
||||
const revision = JSON.stringify(s.form);
|
||||
const dirty = !s.loading && revision !== JSON.stringify(configToForm(s.config));
|
||||
const invalidReason = jiraInvalidReason(
|
||||
s.form,
|
||||
emailRequired,
|
||||
missingSecret || oauthNeedsConnect,
|
||||
t,
|
||||
);
|
||||
return {
|
||||
isOAuth,
|
||||
savedSecretMatchesMode,
|
||||
disableSave,
|
||||
disableTest,
|
||||
revision,
|
||||
dirty,
|
||||
invalidReason,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { activateLocale } from "@/lib/i18n";
|
||||
import { OAuthFields } from "./jira-oauth-fields";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
start: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
}));
|
||||
const toast = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api/domains/jira-api", () => ({
|
||||
startJiraOAuth: api.start,
|
||||
completeJiraOAuth: api.complete,
|
||||
}));
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast }),
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
await activateLocale("en");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
api.start.mockReset();
|
||||
api.complete.mockReset();
|
||||
toast.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderOAuth(onConnected = vi.fn()) {
|
||||
render(
|
||||
<OAuthFields
|
||||
form={{ siteUrl: "https://acme.atlassian.net" }}
|
||||
loading={false}
|
||||
workspaceId="ws-1"
|
||||
connected={false}
|
||||
onConnected={onConnected}
|
||||
/>,
|
||||
);
|
||||
return onConnected;
|
||||
}
|
||||
|
||||
describe("OAuthFields callback handoff", () => {
|
||||
it("completes an origin-checked message from the authorization popup", async () => {
|
||||
api.start.mockResolvedValue({
|
||||
authUrl: "https://auth.example/authorize?client_id=client-1&state=state-1",
|
||||
});
|
||||
api.complete.mockResolvedValue({ ok: true });
|
||||
const popup = { location: { assign: vi.fn() }, close: vi.fn() };
|
||||
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
|
||||
const onConnected = renderOAuth();
|
||||
|
||||
fireEvent.click(screen.getByTestId("jira-oauth-connect"));
|
||||
await waitFor(() => expect(api.start).toHaveBeenCalled());
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
origin: window.location.origin,
|
||||
source: popup as unknown as WindowProxy,
|
||||
data: { type: "jira-oauth-callback", code: "code-1", state: "state-1" },
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(api.complete).toHaveBeenCalledWith("code-1", "state-1"));
|
||||
expect(onConnected).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not start OAuth when the browser blocks the popup", async () => {
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
renderOAuth();
|
||||
|
||||
fireEvent.click(screen.getByTestId("jira-oauth-connect"));
|
||||
|
||||
await waitFor(() => expect(toast).toHaveBeenCalled());
|
||||
expect(api.start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@kandev/ui/alert";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useJiraOAuthConnect } from "./use-jira-oauth-connect";
|
||||
|
||||
type OAuthFieldsProps = {
|
||||
form: { siteUrl: string };
|
||||
loading: boolean;
|
||||
workspaceId: string;
|
||||
connected: boolean;
|
||||
tokenExpiresAt?: string | null;
|
||||
onConnected: () => void;
|
||||
};
|
||||
|
||||
// Fallback for remote servers: the OAuth redirect targets localhost, so the user
|
||||
// pastes the full callback URL back for the code+state exchange.
|
||||
function OAuthPasteDialog({
|
||||
pasteUrl,
|
||||
setPasteUrl,
|
||||
completing,
|
||||
onComplete,
|
||||
onCancel,
|
||||
}: {
|
||||
pasteUrl: string;
|
||||
setPasteUrl: (value: string) => void;
|
||||
completing: boolean;
|
||||
onComplete: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription className="space-y-3">
|
||||
<p className="font-medium">{t("jira:oauthPasteInstructions")}</p>
|
||||
<p className="text-xs">{t("jira:oauthPasteHelp")}</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="http://localhost:38429/api/v1/jira/oauth/callback?code=...&state=..."
|
||||
value={pasteUrl}
|
||||
onChange={(e) => setPasteUrl(e.target.value)}
|
||||
disabled={completing}
|
||||
data-testid="jira-oauth-paste-url"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onComplete}
|
||||
disabled={completing || !pasteUrl}
|
||||
className="h-11 w-full cursor-pointer sm:w-auto"
|
||||
>
|
||||
{completing ? t("jira:connecting") : t("jira:oauthComplete")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="h-11 w-full cursor-pointer sm:w-auto"
|
||||
>
|
||||
{t("jira:oauthCancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function OAuthFields({
|
||||
form,
|
||||
loading,
|
||||
workspaceId,
|
||||
connected,
|
||||
tokenExpiresAt,
|
||||
onConnected,
|
||||
}: OAuthFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const oauth = useJiraOAuthConnect(workspaceId, form.siteUrl, onConnected);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{connected && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("jira:oauthConnected")}
|
||||
{tokenExpiresAt && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t("jira:oauthTokenExpires", { date: new Date(tokenExpiresAt).toLocaleString() })}
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={oauth.handleConnect}
|
||||
disabled={oauth.connecting || loading || !form.siteUrl}
|
||||
className="h-11 w-full cursor-pointer sm:w-auto"
|
||||
data-testid="jira-oauth-connect"
|
||||
>
|
||||
{oauth.connecting ? t("jira:connecting") : t("jira:connectWithAtlassian")}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t("jira:oauthDescription")}</p>
|
||||
{oauth.showPasteDialog && (
|
||||
<OAuthPasteDialog
|
||||
pasteUrl={oauth.pasteUrl}
|
||||
setPasteUrl={oauth.setPasteUrl}
|
||||
completing={oauth.completing}
|
||||
onComplete={oauth.handlePasteComplete}
|
||||
onCancel={oauth.cancelPaste}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,9 @@ export function secretCopy(
|
||||
label: t("jira:sessionTokenValue"),
|
||||
placeholder: t("jira:pasteSessionTokenValue", { cookieName: SESSION_COOKIE_NAME }),
|
||||
},
|
||||
// OAuth has no paste field (OAuthFields renders the connect button instead);
|
||||
// this entry only satisfies the exhaustive Record<JiraAuthMethod, ...> type.
|
||||
oauth: { label: t("jira:oauth2"), placeholder: "" },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -36,50 +36,25 @@ import type {
|
||||
TestJiraConnectionResult,
|
||||
} from "@/lib/types/jira";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
configToForm,
|
||||
deriveFormState,
|
||||
emptyForm,
|
||||
type FormState,
|
||||
} from "@/components/jira/jira-form-state";
|
||||
import {
|
||||
CookieExpiry,
|
||||
SecretHelp,
|
||||
secretCopy,
|
||||
secretPlaceholder,
|
||||
} from "@/components/jira/jira-secret-help";
|
||||
import { OAuthFields } from "@/components/jira/jira-oauth-fields";
|
||||
import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations";
|
||||
|
||||
// Cloud sites live under this domain; shown as an example, not as prose, so it
|
||||
// is interpolated rather than written into the catalog.
|
||||
const ATLASSIAN_CLOUD_DOMAIN = "*.atlassian.net";
|
||||
|
||||
type FormState = {
|
||||
siteUrl: string;
|
||||
email: string;
|
||||
authMethod: JiraAuthMethod;
|
||||
instanceType: JiraInstanceType;
|
||||
defaultProjectKey: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
siteUrl: "",
|
||||
email: "",
|
||||
authMethod: "api_token",
|
||||
instanceType: "cloud",
|
||||
defaultProjectKey: "",
|
||||
secret: "",
|
||||
};
|
||||
|
||||
function configToForm(cfg: JiraConfig | null): FormState {
|
||||
if (!cfg) return emptyForm;
|
||||
return {
|
||||
siteUrl: cfg.siteUrl,
|
||||
email: cfg.email,
|
||||
authMethod: cfg.authMethod,
|
||||
// Legacy rows written before Server/DC support carry an empty instanceType;
|
||||
// default to cloud so the dropdown has a valid selection.
|
||||
instanceType: cfg.instanceType || "cloud",
|
||||
defaultProjectKey: cfg.defaultProjectKey,
|
||||
secret: "",
|
||||
};
|
||||
}
|
||||
|
||||
// defaultAuthForInstance returns the canonical auth method for an instance
|
||||
// type. Used when the user switches Instance type and the current auth method
|
||||
// is no longer valid for the new type (e.g. PAT picked for Cloud).
|
||||
@@ -97,6 +72,7 @@ function authAllowedForInstance(auth: JiraAuthMethod, instance: JiraInstanceType
|
||||
if (auth === "api_token") return instance === "cloud";
|
||||
if (auth === "pat") return instance === "server";
|
||||
if (auth === "session_cookie") return instance === "cloud";
|
||||
if (auth === "oauth") return instance === "cloud";
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -212,6 +188,7 @@ function AuthFields({ form, baseline, loading, update }: FieldsRowProps) {
|
||||
{form.instanceType === "cloud" ? (
|
||||
<>
|
||||
<SelectItem value="api_token">{t("jira:apiTokenRecommended")}</SelectItem>
|
||||
<SelectItem value="oauth">{t("jira:oauth2")}</SelectItem>
|
||||
<SelectItem value="session_cookie">{t("jira:browserSessionCookie")}</SelectItem>
|
||||
</>
|
||||
) : (
|
||||
@@ -402,6 +379,7 @@ function useJiraConfigMutations({
|
||||
instanceType: form.instanceType,
|
||||
defaultProjectKey: form.defaultProjectKey,
|
||||
secret: form.secret || undefined,
|
||||
clientId: form.clientId || undefined,
|
||||
},
|
||||
{ workspaceId },
|
||||
);
|
||||
@@ -513,51 +491,23 @@ function useJiraSettings(workspaceId: string) {
|
||||
handleSave,
|
||||
handleDelete,
|
||||
discard,
|
||||
load,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeComparableSiteUrl(value: string): string {
|
||||
const trimmed = value.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) return "";
|
||||
return trimmed.includes("://") ? trimmed : `https://${trimmed}`;
|
||||
}
|
||||
|
||||
// savedSecretMatches reports whether the saved secret can be reused against
|
||||
// the current form values. Reuse is only safe when every identity component
|
||||
// of the saved credential still matches: same auth method, same instance
|
||||
// type, same Jira host, and — for Cloud api_token where the basic pair is
|
||||
// email:token — the same email (case-insensitive). Otherwise the user could
|
||||
// change the site URL or Cloud account and silently submit the previous
|
||||
// token to a different host/account.
|
||||
function savedSecretMatches(config: JiraConfig | null, form: FormState): boolean {
|
||||
if (!config?.hasSecret) return false;
|
||||
if (config.authMethod !== form.authMethod) return false;
|
||||
if ((config.instanceType || "cloud") !== form.instanceType) return false;
|
||||
if (normalizeComparableSiteUrl(config.siteUrl) !== normalizeComparableSiteUrl(form.siteUrl)) {
|
||||
return false;
|
||||
}
|
||||
if (form.authMethod !== "api_token") return true;
|
||||
return (config.email ?? "").toLowerCase() === form.email.toLowerCase();
|
||||
}
|
||||
|
||||
export function JiraConnectionSection({ workspaceId }: { workspaceId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const s = useJiraSettings(workspaceId);
|
||||
const baseline = configToForm(s.config);
|
||||
const savedSecretMatchesMode = savedSecretMatches(s.config, s.form);
|
||||
const missingSecret = !savedSecretMatchesMode && !s.form.secret;
|
||||
const emailRequired = s.form.instanceType === "cloud" && s.form.authMethod === "api_token";
|
||||
const disableSave =
|
||||
s.saving || !s.form.siteUrl || (emailRequired && !s.form.email) || missingSecret;
|
||||
const disableTest = missingSecret;
|
||||
const revision = JSON.stringify(s.form);
|
||||
const dirty = !s.loading && revision !== JSON.stringify(configToForm(s.config));
|
||||
// Assigned rather than returned from a helper, but the guard would not see it
|
||||
// either way — `invalidReason` is a plain string, never a JSX literal.
|
||||
let invalidReason: string | undefined;
|
||||
if (!s.form.siteUrl) invalidReason = t("jira:aJiraSiteUrlIsRequired");
|
||||
else if (emailRequired && !s.form.email) invalidReason = t("jira:anEmailAddressIsRequired");
|
||||
else if (missingSecret) invalidReason = t("jira:aCredentialIsRequired");
|
||||
const {
|
||||
isOAuth,
|
||||
savedSecretMatchesMode,
|
||||
disableSave,
|
||||
disableTest,
|
||||
revision,
|
||||
dirty,
|
||||
invalidReason,
|
||||
} = deriveFormState(s, t);
|
||||
|
||||
useSettingsSaveContributor({
|
||||
id: `jira-config:${workspaceId}`,
|
||||
@@ -589,14 +539,25 @@ export function JiraConnectionSection({ workspaceId }: { workspaceId: string })
|
||||
/>
|
||||
<SiteFields form={s.form} baseline={baseline} loading={s.loading} update={s.update} />
|
||||
<AuthFields form={s.form} baseline={baseline} loading={s.loading} update={s.update} />
|
||||
<SecretField
|
||||
form={s.form}
|
||||
baseline={baseline}
|
||||
loading={s.loading}
|
||||
update={s.update}
|
||||
hasSavedSecret={savedSecretMatchesMode}
|
||||
secretExpiresAt={s.config?.secretExpiresAt ?? null}
|
||||
/>
|
||||
{isOAuth ? (
|
||||
<OAuthFields
|
||||
form={s.form}
|
||||
loading={s.loading}
|
||||
workspaceId={workspaceId}
|
||||
connected={savedSecretMatchesMode}
|
||||
tokenExpiresAt={s.config?.tokenExpiresAt ?? null}
|
||||
onConnected={() => void s.load()}
|
||||
/>
|
||||
) : (
|
||||
<SecretField
|
||||
form={s.form}
|
||||
baseline={baseline}
|
||||
loading={s.loading}
|
||||
update={s.update}
|
||||
hasSavedSecret={savedSecretMatchesMode}
|
||||
secretExpiresAt={s.config?.secretExpiresAt ?? null}
|
||||
/>
|
||||
)}
|
||||
<TestResultAlert result={s.testResult} />
|
||||
<Separator />
|
||||
<ActionBar
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { completeJiraOAuth, startJiraOAuth } from "@/lib/api/domains/jira-api";
|
||||
|
||||
export type OAuthConnectState = {
|
||||
connecting: boolean;
|
||||
completing: boolean;
|
||||
showPasteDialog: boolean;
|
||||
pasteUrl: string;
|
||||
setPasteUrl: (value: string) => void;
|
||||
handleConnect: () => void;
|
||||
handlePasteComplete: () => void;
|
||||
cancelPaste: () => void;
|
||||
};
|
||||
|
||||
type JiraOAuthCallbackMessage = {
|
||||
type: "jira-oauth-callback";
|
||||
code: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
function isJiraOAuthCallbackMessage(value: unknown): value is JiraOAuthCallbackMessage {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const message = value as Partial<JiraOAuthCallbackMessage>;
|
||||
return (
|
||||
message.type === "jira-oauth-callback" &&
|
||||
typeof message.code === "string" &&
|
||||
message.code.length > 0 &&
|
||||
typeof message.state === "string" &&
|
||||
message.state.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function useOAuthPopupMessage(
|
||||
popupRef: RefObject<Window | null>,
|
||||
pendingStateRef: RefObject<string | null>,
|
||||
completeOAuth: (code: string, state: string) => Promise<void>,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== window.location.origin || event.source !== popupRef.current) return;
|
||||
if (!isJiraOAuthCallbackMessage(event.data)) return;
|
||||
if (event.data.state !== pendingStateRef.current) return;
|
||||
void completeOAuth(event.data.code, event.data.state);
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
popupRef.current?.close();
|
||||
};
|
||||
}, [completeOAuth, pendingStateRef, popupRef]);
|
||||
}
|
||||
|
||||
function useLatestRef<T>(value: T) {
|
||||
const ref = useRef(value);
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function useJiraOAuthConnect(
|
||||
workspaceId: string,
|
||||
siteUrl: string,
|
||||
onConnected: () => void,
|
||||
): OAuthConnectState {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [completing, setCompleting] = useState(false);
|
||||
const [showPasteDialog, setShowPasteDialog] = useState(false);
|
||||
const [pasteUrl, setPasteUrl] = useState("");
|
||||
const popupRef = useRef<Window | null>(null);
|
||||
const pendingStateRef = useRef<string | null>(null);
|
||||
const onConnectedRef = useLatestRef(onConnected);
|
||||
|
||||
const showError = useCallback(
|
||||
(error: string) => {
|
||||
toast({ description: t("jira:oauthStartFailed", { error }), variant: "error" });
|
||||
},
|
||||
[t, toast],
|
||||
);
|
||||
|
||||
const completeOAuth = useCallback(
|
||||
async (code: string, state: string) => {
|
||||
setCompleting(true);
|
||||
try {
|
||||
await completeJiraOAuth(code, state);
|
||||
toast({ description: t("jira:oauthConnected"), variant: "success" });
|
||||
setShowPasteDialog(false);
|
||||
setPasteUrl("");
|
||||
pendingStateRef.current = null;
|
||||
popupRef.current?.close();
|
||||
popupRef.current = null;
|
||||
onConnectedRef.current();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : String(err));
|
||||
setShowPasteDialog(true);
|
||||
} finally {
|
||||
setCompleting(false);
|
||||
}
|
||||
},
|
||||
[t, toast, showError],
|
||||
);
|
||||
|
||||
useOAuthPopupMessage(popupRef, pendingStateRef, completeOAuth);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
const popup = window.open("", "_blank");
|
||||
if (!popup) {
|
||||
showError(t("jira:oauthPopupBlocked"));
|
||||
return;
|
||||
}
|
||||
popupRef.current = popup;
|
||||
setConnecting(true);
|
||||
try {
|
||||
const { authUrl } = await startJiraOAuth(siteUrl, { workspaceId });
|
||||
const state = new URL(authUrl).searchParams.get("state");
|
||||
if (!state) throw new Error(t("jira:oauthPasteInvalid"));
|
||||
pendingStateRef.current = state;
|
||||
popup.location.assign(authUrl);
|
||||
setShowPasteDialog(true);
|
||||
} catch (err) {
|
||||
popup.close();
|
||||
popupRef.current = null;
|
||||
pendingStateRef.current = null;
|
||||
showError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}, [workspaceId, siteUrl, showError, t]);
|
||||
|
||||
const handlePasteComplete = useCallback(async () => {
|
||||
try {
|
||||
const url = new URL(pasteUrl);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
if (!code || !state) {
|
||||
toast({ description: t("jira:oauthPasteInvalid"), variant: "error" });
|
||||
return;
|
||||
}
|
||||
await completeOAuth(code, state);
|
||||
} catch {
|
||||
toast({ description: t("jira:oauthPasteInvalid"), variant: "error" });
|
||||
}
|
||||
}, [pasteUrl, t, toast, completeOAuth]);
|
||||
|
||||
const cancelPaste = useCallback(() => {
|
||||
popupRef.current?.close();
|
||||
popupRef.current = null;
|
||||
pendingStateRef.current = null;
|
||||
setShowPasteDialog(false);
|
||||
setPasteUrl("");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
connecting,
|
||||
completing,
|
||||
showPasteDialog,
|
||||
pasteUrl,
|
||||
setPasteUrl,
|
||||
handleConnect: () => void handleConnect(),
|
||||
handlePasteComplete: () => void handlePasteComplete(),
|
||||
cancelPaste,
|
||||
};
|
||||
}
|
||||
@@ -53,7 +53,11 @@ export class JiraSettingsPage {
|
||||
|
||||
/** Open the Auth-method dropdown and pick by visible label. */
|
||||
async selectAuth(
|
||||
label: "API token (recommended)" | "Browser session cookie" | "Personal Access Token",
|
||||
label:
|
||||
| "API token (recommended)"
|
||||
| "Browser session cookie"
|
||||
| "Personal Access Token"
|
||||
| "OAuth 2.0",
|
||||
) {
|
||||
await this.authSelect.click();
|
||||
await this.page.getByRole("option", { name: label, exact: true }).click();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { JiraSettingsPage } from "../../pages/jira-settings-page";
|
||||
|
||||
test("Jira OAuth connect is reachable with a touch-sized action", async ({
|
||||
testPage,
|
||||
seedData,
|
||||
}) => {
|
||||
const settings = new JiraSettingsPage(testPage);
|
||||
await settings.gotoWorkspace(seedData.workspaceId);
|
||||
|
||||
await settings.siteInput.fill("https://acme.atlassian.net");
|
||||
await settings.selectAuth("OAuth 2.0");
|
||||
|
||||
const connect = testPage.getByTestId("jira-oauth-connect");
|
||||
await expect(connect).toBeEnabled();
|
||||
const box = await connect.boundingBox();
|
||||
expect(box?.height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
@@ -55,6 +55,29 @@ export async function deleteJiraConfig(options?: WorkspaceApiOptions) {
|
||||
);
|
||||
}
|
||||
|
||||
// startJiraOAuth registers a client + PKCE and returns the Atlassian
|
||||
// authorization URL. Uses withWorkspace so the backend gets `workspace_id`.
|
||||
export async function startJiraOAuth(
|
||||
siteUrl: string,
|
||||
options?: WorkspaceApiOptions,
|
||||
): Promise<{ authUrl: string }> {
|
||||
const path = withWorkspace(
|
||||
`/api/v1/jira/oauth/start?siteUrl=${encodeURIComponent(siteUrl)}`,
|
||||
options,
|
||||
);
|
||||
return fetchJson<{ authUrl: string }>(path, requestOptions(options));
|
||||
}
|
||||
|
||||
// completeJiraOAuth exchanges the pasted redirect (code + state) for tokens.
|
||||
export async function completeJiraOAuth(
|
||||
code: string,
|
||||
state: string,
|
||||
options?: WorkspaceApiOptions,
|
||||
): Promise<{ ok: boolean }> {
|
||||
const path = `/api/v1/jira/oauth/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}&api=1`;
|
||||
return fetchJson<{ ok: boolean }>(path, requestOptions(options));
|
||||
}
|
||||
|
||||
export async function testJiraConnection(
|
||||
payload: SetJiraConfigRequest,
|
||||
options?: WorkspaceApiOptions,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* - `pat` — Jira Server / Data Center only (Personal Access Token, sent as Bearer).
|
||||
* - `session_cookie` — works on both Cloud and Server (wraps the session JWT cookie).
|
||||
*/
|
||||
export type JiraAuthMethod = "api_token" | "pat" | "session_cookie";
|
||||
export type JiraAuthMethod = "api_token" | "pat" | "session_cookie" | "oauth";
|
||||
|
||||
/**
|
||||
* Jira deployment kind. Cloud uses REST v3 and the token-paginated search
|
||||
@@ -21,6 +21,12 @@ export interface JiraConfig {
|
||||
instanceType: JiraInstanceType;
|
||||
defaultProjectKey: string;
|
||||
hasSecret: boolean;
|
||||
/** OAuth client_id (only for authMethod === "oauth"). */
|
||||
clientId?: string;
|
||||
/** Atlassian cloudId (only for authMethod === "oauth"). */
|
||||
cloudId?: string;
|
||||
/** OAuth access token expiry ISO timestamp (only for authMethod === "oauth"). */
|
||||
tokenExpiresAt?: string | null;
|
||||
/** ISO timestamp when the session cookie's JWT expires, or null for api_token / opaque cookies. */
|
||||
secretExpiresAt?: string | null;
|
||||
/** Last time the backend probed credentials, or null if never probed. */
|
||||
@@ -40,6 +46,8 @@ export interface SetJiraConfigRequest {
|
||||
instanceType: JiraInstanceType;
|
||||
defaultProjectKey?: string;
|
||||
secret?: string;
|
||||
/** OAuth client_id (only for authMethod === "oauth", dynamically registered). */
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export interface TestJiraConnectionResult {
|
||||
|
||||
@@ -18,6 +18,21 @@
|
||||
"authenticationMethod": "Authentication method",
|
||||
"availablePlaceholders": "Available placeholders:",
|
||||
"browserSessionCookie": "Browser session cookie",
|
||||
"builtinViewAssignedToMe": "Assigned to me",
|
||||
"builtinViewUnassigned": "Unassigned",
|
||||
"connectWithAtlassian": "Connect with Atlassian",
|
||||
"connecting": "Connecting...",
|
||||
"oauth2": "OAuth 2.0",
|
||||
"oauthConnected": "Connected via OAuth. Tokens refresh automatically.",
|
||||
"oauthDescription": "Authenticate via Atlassian OAuth 2.1 with PKCE. No app creation or credentials needed. Click to authorize through your browser. Tokens auto-refresh on expiry.",
|
||||
"oauthStartFailed": "Failed to start OAuth: {{error}}",
|
||||
"oauthTokenExpires": "Token expires at: {{date}}",
|
||||
"oauthPasteInstructions": "Complete authorization in the new tab",
|
||||
"oauthPasteHelp": "After approving on Atlassian, you'll be redirected to a localhost URL that won't load. Copy the full URL from your browser's address bar and paste it below.",
|
||||
"oauthPopupBlocked": "Your browser blocked the authorization window. Allow pop-ups and try again.",
|
||||
"oauthComplete": "Complete",
|
||||
"oauthCancel": "Cancel",
|
||||
"oauthPasteInvalid": "Invalid URL. Make sure you copied the full redirect URL including code and state parameters.",
|
||||
"builtIn": "Built-in",
|
||||
"capOnOpenTasksCreatedBy": "Cap on open tasks created by this watcher. Leave blank for no cap. New matches are deferred to the next poll when the cap is reached.",
|
||||
"checkFailed": "Check failed: {{error}}",
|
||||
|
||||
@@ -18,6 +18,21 @@
|
||||
"authenticationMethod": "Àũţĥēńţĩćàţĩōń ḿēţĥōď",
|
||||
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
|
||||
"browserSessionCookie": "Ɓŕōŵśēŕ śēśśĩōń ćōōķĩē",
|
||||
"builtinViewAssignedToMe": "Àśśĩĝńēď ţō ḿē",
|
||||
"builtinViewUnassigned": "Ũńàśśĩĝńēď",
|
||||
"connectWithAtlassian": "Ćōńńēćţ ŵĩţĥ Àţĺàśśĩàń",
|
||||
"connecting": "Ćōńńēćţĩńĝ...",
|
||||
"oauth2": "ŌÀũţĥ 2.0",
|
||||
"oauthConnected": "Ćōńńēćţēď vĩà ŌÀũţĥ. Ţōķēńś ŕēƒŕēśĥ àũţōḿàţĩćàĺĺŷ.",
|
||||
"oauthDescription": "Àũţĥēńţĩćàţē vĩà Àţĺàśśĩàń ŌÀũţĥ 2.1 ŵĩţĥ ƤĶĆĒ. Ńō àƥƥ ćŕēàţĩōń ōŕ ćŕēďēńţĩàĺś ńēēďēď. Ćĺĩćķ ţō àũţĥōŕĩźē ţĥŕōũĝĥ ŷōũŕ ƀŕōŵśēŕ. Ţōķēńś àũţō-ŕēƒŕēśĥ ōń ēxƥĩŕŷ.",
|
||||
"oauthStartFailed": "Ƒàĩĺēď ţō śţàŕţ ŌÀũţĥ: {{error}}",
|
||||
"oauthTokenExpires": "Ţōķēń ēxƥĩŕēś àţ: {{date}}",
|
||||
"oauthPasteInstructions": "Ćōḿƥĺēţē àũţĥōŕĩźàţĩōń ĩń ţĥē ńēŵ ţàƀ",
|
||||
"oauthPasteHelp": "Àƒţēŕ àƥƥŕōvĩńĝ ōń Àţĺàśśĩàń, ŷōũ'ĺĺ ƀē ŕēďĩŕēćţēď ţō à ĺōćàĺĥōśţ ŨŔĹ ţĥàţ ŵōń'ţ ĺōàď. Ćōƥŷ ţĥē ƒũĺĺ ŨŔĹ ƒŕōḿ ŷōũŕ ƀŕōŵśēŕ'ś àďďŕēśś ƀàŕ àńď ƥàśţē ĩţ ƀēĺōŵ.",
|
||||
"oauthPopupBlocked": "Ŷōũŕ ƀŕōŵśēŕ ƀĺōćķēď ţĥē àũţĥōŕĩźàţĩōń ŵĩńďōŵ. Àĺĺōŵ ƥōƥ-ũƥś àńď ţŕŷ àĝàĩń.",
|
||||
"oauthComplete": "Ćōḿƥĺēţē",
|
||||
"oauthCancel": "Ćàńćēĺ",
|
||||
"oauthPasteInvalid": "Ĩńvàĺĩď ŨŔĹ. Ḿàķē śũŕē ŷōũ ćōƥĩēď ţĥē ƒũĺĺ ŕēďĩŕēćţ ŨŔĹ ĩńćĺũďĩńĝ ćōďē àńď śţàţē ƥàŕàḿēţēŕś.",
|
||||
"builtIn": "Ɓũĩĺţ-ĩń",
|
||||
"capOnOpenTasksCreatedBy": "Ćàƥ ōń ōƥēń ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ. Ĺēàvē ƀĺàńķ ƒōŕ ńō ćàƥ. Ńēŵ ḿàţćĥēś àŕē ďēƒēŕŕēď ţō ţĥē ńēxţ ƥōĺĺ ŵĥēń ţĥē ćàƥ ĩś ŕēàćĥēď.",
|
||||
"checkFailed": "Ćĥēćķ ƒàĩĺēď: {{error}}",
|
||||
@@ -208,7 +223,5 @@
|
||||
"watcherUpdated": "Ŵàţćĥēŕ ũƥďàţēď",
|
||||
"workflow": "Ŵōŕķƒĺōŵ",
|
||||
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ",
|
||||
"yourJiraSessionExpiredOrNeeds": "Ŷōũŕ Ĵĩŕà śēśśĩōń ēxƥĩŕēď ōŕ ńēēďś śţēƥ-ũƥ àũţĥēńţĩćàţĩōń. Ŕēćōńńēćţ ţō vĩēŵ ţĥĩś ţĩćķēţ.",
|
||||
"builtinViewAssignedToMe": "Àśśĩĝńēď ţō ḿē",
|
||||
"builtinViewUnassigned": "Ũńàśśĩĝńēď"
|
||||
"yourJiraSessionExpiredOrNeeds": "Ŷōũŕ Ĵĩŕà śēśśĩōń ēxƥĩŕēď ōŕ ńēēďś śţēƥ-ũƥ àũţĥēńţĩćàţĩōń. Ŕēćōńńēćţ ţō vĩēŵ ţĥĩś ţĩćķēţ."
|
||||
}
|
||||
|
||||
@@ -210,5 +210,18 @@
|
||||
"workflowStep": "Passo do fluxo de trabalho",
|
||||
"yourJiraSessionExpiredOrNeeds": "A sua sessão do Jira expirou ou precisa de autenticação reforçada. Volte a ligar para ver este ticket.",
|
||||
"builtinViewAssignedToMe": "Atribuídos a mim",
|
||||
"builtinViewUnassigned": "Sem responsável"
|
||||
"builtinViewUnassigned": "Sem responsável",
|
||||
"connectWithAtlassian": "Ligar à Atlassian",
|
||||
"connecting": "A ligar...",
|
||||
"oauth2": "OAuth 2.0",
|
||||
"oauthConnected": "Ligado através de OAuth. Os tokens são renovados automaticamente.",
|
||||
"oauthDescription": "Autenticação através do OAuth 2.1 da Atlassian com PKCE. Não é necessário criar uma aplicação nem fornecer credenciais. Clique para autorizar através do seu navegador. Os tokens são renovados automaticamente ao expirar.",
|
||||
"oauthStartFailed": "Não foi possível iniciar o OAuth: {{error}}",
|
||||
"oauthTokenExpires": "O token expira a: {{date}}",
|
||||
"oauthPasteInstructions": "Conclua a autorização no novo separador",
|
||||
"oauthPasteHelp": "Depois de aprovar na Atlassian, será redirecionado para um URL de localhost que não irá carregar. Copie o URL completo da barra de endereço do navegador e cole-o abaixo.",
|
||||
"oauthPopupBlocked": "O navegador bloqueou a janela de autorização. Permita pop-ups e tente novamente.",
|
||||
"oauthComplete": "Concluir",
|
||||
"oauthCancel": "Cancelar",
|
||||
"oauthPasteInvalid": "URL inválido. Certifique-se de que copiou o URL de redirecionamento completo, incluindo os parâmetros code e state."
|
||||
}
|
||||
|
||||
@@ -210,5 +210,18 @@
|
||||
"workflowStep": "工作流步骤",
|
||||
"yourJiraSessionExpiredOrNeeds": "你的 Jira 会话已过期或需要二次验证。请重新连接以查看此工单。",
|
||||
"builtinViewAssignedToMe": "指派给我",
|
||||
"builtinViewUnassigned": "未指派"
|
||||
"builtinViewUnassigned": "未指派",
|
||||
"connectWithAtlassian": "连接 Atlassian",
|
||||
"connecting": "正在连接...",
|
||||
"oauth2": "OAuth 2.0",
|
||||
"oauthConnected": "已通过 OAuth 连接。令牌会自动刷新。",
|
||||
"oauthDescription": "通过 Atlassian OAuth 2.1(含 PKCE)进行身份验证。无需创建应用或提供凭据。点击即可在浏览器中授权。令牌过期后会自动刷新。",
|
||||
"oauthStartFailed": "无法启动 OAuth:{{error}}",
|
||||
"oauthTokenExpires": "令牌过期时间:{{date}}",
|
||||
"oauthPasteInstructions": "在新标签页中完成授权",
|
||||
"oauthPasteHelp": "在 Atlassian 上批准后,你将被重定向到一个无法加载的 localhost 网址。请从浏览器地址栏复制完整网址并粘贴到下方。",
|
||||
"oauthPopupBlocked": "浏览器已阻止授权窗口。请允许弹出窗口,然后重试。",
|
||||
"oauthComplete": "完成",
|
||||
"oauthCancel": "取消",
|
||||
"oauthPasteInvalid": "网址无效。请确保复制了包含 code 和 state 参数的完整重定向网址。"
|
||||
}
|
||||
|
||||
@@ -210,5 +210,18 @@
|
||||
"workflowStep": "工作流程步驟",
|
||||
"yourJiraSessionExpiredOrNeeds": "你的 Jira 工作階段已過期或需要二次驗證。請重新連接以查看此工單。",
|
||||
"builtinViewAssignedToMe": "指派給我",
|
||||
"builtinViewUnassigned": "未指派"
|
||||
"builtinViewUnassigned": "未指派",
|
||||
"connectWithAtlassian": "連接 Atlassian",
|
||||
"connecting": "正在連線...",
|
||||
"oauth2": "OAuth 2.0",
|
||||
"oauthConnected": "已通過 OAuth 連接。權杖會自動刷新。",
|
||||
"oauthDescription": "通過 Atlassian OAuth 2.1(含 PKCE)進行身份驗證。無需建立應用或提供認證資料。點擊即可在瀏覽器中授權。權杖過期後會自動刷新。",
|
||||
"oauthStartFailed": "無法啓動 OAuth:{{error}}",
|
||||
"oauthTokenExpires": "權杖過期時間:{{date}}",
|
||||
"oauthPasteInstructions": "在新標籤頁中完成授權",
|
||||
"oauthPasteHelp": "在 Atlassian 上批准後,你將被重定向到一個無法載入的 localhost 網址。請從瀏覽器地址欄複製完整網址並貼上到下方。",
|
||||
"oauthPopupBlocked": "瀏覽器已封鎖授權視窗。請允許彈出式視窗,然後再試。",
|
||||
"oauthComplete": "完成",
|
||||
"oauthCancel": "取消",
|
||||
"oauthPasteInvalid": "網址無效。請確保複製了包含 code 和 state 參數的完整重定向網址。"
|
||||
}
|
||||
|
||||
@@ -210,5 +210,18 @@
|
||||
"workflowStep": "工作流程步驟",
|
||||
"yourJiraSessionExpiredOrNeeds": "你的 Jira 工作階段已過期或需要二次驗證。請重新連線以檢視此工單。",
|
||||
"builtinViewAssignedToMe": "指派給我",
|
||||
"builtinViewUnassigned": "未指派"
|
||||
"builtinViewUnassigned": "未指派",
|
||||
"connectWithAtlassian": "連線 Atlassian",
|
||||
"connecting": "正在連線...",
|
||||
"oauth2": "OAuth 2.0",
|
||||
"oauthConnected": "已通過 OAuth 連線。權杖會自動重新整理。",
|
||||
"oauthDescription": "通過 Atlassian OAuth 2.1(含 PKCE)進行身分驗證。無需建立應用或提供認證資訊。點選即可在瀏覽器中授權。權杖過期後會自動重新整理。",
|
||||
"oauthStartFailed": "無法啟動 OAuth:{{error}}",
|
||||
"oauthTokenExpires": "權杖過期時間:{{date}}",
|
||||
"oauthPasteInstructions": "在新標籤頁中完成授權",
|
||||
"oauthPasteHelp": "在 Atlassian 上批准後,你將被重定向到一個無法載入的 localhost 網址。請從瀏覽器位址列複製完整網址並貼上到下方。",
|
||||
"oauthPopupBlocked": "瀏覽器已封鎖授權視窗。請允許彈出式視窗,然後再試。",
|
||||
"oauthComplete": "完成",
|
||||
"oauthCancel": "取消",
|
||||
"oauthPasteInvalid": "網址無效。請確保複製了包含 code 和 state 引數的完整重定向網址。"
|
||||
}
|
||||
|
||||
@@ -549,11 +549,14 @@ Enter the site URL (a missing scheme is normalized to HTTPS), choose **Cloud** o
|
||||
| Deployment | Method | Required values |
|
||||
|---|---|---|
|
||||
| Jira Cloud | API token (recommended) | Atlassian account email and API token. |
|
||||
| Jira Cloud | OAuth 2.0 | Approval from the Atlassian account in a browser window. |
|
||||
| Jira Cloud | Browser session | Only the value of the `cloud.session.token` or `tenant.session.token` cookie. Do not include the cookie name or `=`. |
|
||||
| Server/Data Center | Personal access token | Bearer personal access token with the required read/write access. |
|
||||
|
||||
Cloud API tokens are not accepted for Server/Data Center, and Server/Data Center PATs are not the Cloud token flow. Browser-session JWTs expire and are less reliable than an API token; Kandev surfaces the decoded expiry and warns as it approaches.
|
||||
|
||||
For OAuth, select **Connect with Atlassian**. Then approve the connection in the browser window. Kandev completes the connection when Atlassian returns the result. If the automatic return fails, paste the full callback URL into Kandev.
|
||||
|
||||
When editing, a blank secret preserves the saved credential only if the URL, account identity, and authentication method still match. Supply a new secret when changing those identity fields. Save, select **Test connection**, and check the background health result.
|
||||
|
||||
### Jira issue watches
|
||||
|
||||
Reference in New Issue
Block a user