Skip to content

Commit 549d820

Browse files
authored
Merge pull request #2236 from atomantic/jira-cloud-basic-auth
jira: support Jira Cloud (API token via Basic auth) alongside Server PAT
2 parents 8e48e54 + 5c71714 commit 549d820

4 files changed

Lines changed: 66 additions & 6 deletions

File tree

.changelog/NEXT.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@
9999

100100
- **Creative Director now evaluates rendered scenes on a local vision model instead of Opus.** Judging each freshly-rendered scene ("does it match the style spec and deliver the intent?") used to spawn a full Chief-of-Staff coding agent that landed on the active provider's *heavy* model — Opus for a Claude provider — purely to look at a couple of thumbnails and record a verdict. That decision now runs server-side through PortOS's vision-API path: it attaches the sampled frames to a **local, vision-capable model** (Ollama/LM Studio VLM) and parses a structured accept/retry/fail verdict, applying the exact same scene transitions (accept → add the clip to the collection; miss with retries left → re-render with a refined prompt; miss when exhausted → drop the scene). Pick the model under **Settings → AI Assignments → "Scene evaluation model"**; leave it blank to auto-select an installed local vision model. Installs with no local vision model configured transparently fall back to the original coding-agent path, so nothing breaks — but if you have a VLM installed, scene evaluation is now local and free. (`server/services/creativeDirector/sceneEvaluator.js`)
101101

102+
## Integrations
103+
104+
- **JIRA instances now support Jira Cloud (`*.atlassian.net`), not just Server / Data Center.** The JIRA client authenticated every instance with `Authorization: Bearer <token>` — correct for a Server/DC Personal Access Token, but Jira Cloud rejects that: a Cloud personal API token must be sent as HTTP Basic (`base64("email:token")`). PortOS now picks the scheme per instance by host — a `*.atlassian.net` base URL authenticates as Basic using the instance's email + API token (both already stored and required by the instance form), and any other host keeps the Bearer PAT — so Cloud and Server instances work side by side with no new fields or migration. Create a Cloud API token at id.atlassian.com → Security → API tokens and paste it into the instance's token field. (`server/services/jira.js`)
105+
102106
## Internal
103107

104108
- **[issue-2183] Creative Tool Registry + gated dispatch (CDO Phase 1).** Foundation for the Creative Director orchestrator (epic #2182): a single registry (`server/services/creative/toolRegistry.js` + per-domain modules under `creative/tools/`) of creative-suite tools an orchestrating agent can call — universe, story builder, writers room, pipeline, media, catalog — each a thin conductor wrapper over an existing service entry point (`createUniverse`, `generateStep`, `startSeriesAutopilot`, `enqueueJob`, …), never a re-implementation. All orchestrator calls flow through one governed chokepoint, `dispatchCreativeTool(name, args, ctx)`, which validates args (Zod), enforces a new `creative` autonomy mode, charges the daily action budget for `llm`/`render` tools, and appends a `{tool, argsDigest, outcome, timing}` entry to the calling project's run ledger. The `creative` autonomy domain (`server/lib/domainAutonomy.js` `getCreativeAutonomyMode`) is kept separate from the four core domains and defaults to mirror the `cos` mode, so existing autopilot users get consistent behavior with no migration, while an explicit `domainAutonomy.creative` value can override it later. Direct user actions through the existing routes are unchanged — the gate lives only on the orchestrator path. No user-facing surface yet; the directive composer and plan board arrive in later CDO phases.

.changelog/v0.14.21.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ This release adds Ghostty terminal theme integration and a project update workfl
6363
- **`scripts/jira-create-ticket.js` CLI** — standalone script that imports JIRA services directly, reads app config, auto-resolves the active sprint, and creates tickets with all fields (project, assignee, sprint, epic, story points, labels) in one call; used by the `/ticket` Claude Code skill
6464

6565
### JIRA Epic Search & Token Expiry Detection
66-
- **`GET /api/jira/instances/:id/projects/:key/epics?q=`** — new endpoint to search epics by name in a project; the CLI ticket creator uses this to resolve fuzzy epic names (e.g., "grace support") to JIRA keys
67-
- **`--epic` accepts search terms** — the CLI script now accepts either a JIRA key (`CONTECH-1553`) or a name search (`"grace support"`) and auto-resolves to the best-matching epic
66+
- **`GET /api/jira/instances/:id/projects/:key/epics?q=`** — new endpoint to search epics by name in a project; the CLI ticket creator uses this to resolve fuzzy epic names (e.g., "payments platform") to JIRA keys
67+
- **`--epic` accepts search terms** — the CLI script now accepts either a JIRA key (`PROJ-1553`) or a name search (`"payments platform"`) and auto-resolves to the best-matching epic
6868
- **Token expiry detection** — JIRA client interceptor detects when the API returns an HTML login page instead of JSON and throws a clear "token expired" error instead of crashing
6969

7070
### JIRA Integration in Task & Agent UI

server/services/jira.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import fs from 'fs/promises';
77
import { createHttpClient } from '../lib/httpClient.js';
88
import path from 'path';
99
import { ensureDir, PATHS } from '../lib/fileUtils.js';
10+
import { hostFromOriginUrl } from '../lib/workTracker.js';
1011

1112
const JIRA_CONFIG_FILE = path.join(PATHS.data, 'jira.json');
1213

@@ -55,7 +56,7 @@ export async function upsertInstance(instanceId, instanceData) {
5556
name: instanceData.name,
5657
baseUrl: instanceData.baseUrl,
5758
email: instanceData.email,
58-
apiToken: instanceData.apiToken, // PAT (Personal Access Token)
59+
apiToken: instanceData.apiToken, // Server/DC PAT (sent as Bearer) or Cloud API token (sent as Basic email:token)
5960
tokenUpdatedAt: (instanceData.apiToken !== existing?.apiToken) ? new Date().toISOString() : (existing?.tokenUpdatedAt || new Date().toISOString()),
6061
createdAt: existing?.createdAt || new Date().toISOString(),
6162
updatedAt: new Date().toISOString()
@@ -74,6 +75,29 @@ export async function deleteInstance(instanceId) {
7475
await saveInstances(config);
7576
}
7677

78+
/**
79+
* Whether a JIRA instance is Jira Cloud (*.atlassian.net) vs Server / Data Center.
80+
* Uses the shared no-throw host extractor (returns null on unparseable input) so a
81+
* hand-edited jira.json can't throw here.
82+
*/
83+
export function isCloudInstance(baseUrl) {
84+
const host = hostFromOriginUrl(baseUrl);
85+
return !!host && /(^|\.)atlassian\.net$/i.test(host);
86+
}
87+
88+
/**
89+
* Build the Authorization header for a JIRA instance.
90+
* - Jira Cloud authenticates a personal API token via HTTP Basic (base64 "email:token").
91+
* - Jira Server / Data Center authenticates a Personal Access Token (PAT) via Bearer.
92+
* Detected by host so Server and Cloud instances can coexist during a migration.
93+
*/
94+
export function jiraAuthHeader(instance) {
95+
if (isCloudInstance(instance.baseUrl)) {
96+
return `Basic ${Buffer.from(`${instance.email}:${instance.apiToken}`).toString('base64')}`;
97+
}
98+
return `Bearer ${instance.apiToken}`;
99+
}
100+
77101
/**
78102
* Create HTTP client for JIRA instance
79103
*/
@@ -85,7 +109,7 @@ export function createJiraClient(instance) {
85109
const base = createHttpClient({
86110
baseURL: instance.baseUrl,
87111
headers: {
88-
'Authorization': `Bearer ${instance.apiToken}`,
112+
'Authorization': jiraAuthHeader(instance),
89113
'Content-Type': 'application/json',
90114
'Accept': 'application/json'
91115
},
@@ -96,7 +120,7 @@ export function createJiraClient(instance) {
96120
// Detect expired token (JIRA returns HTML login page instead of JSON)
97121
const checkToken = res => {
98122
if (typeof res.data === 'string' && res.data.includes('<!DOCTYPE')) {
99-
const err = new Error('JIRA token expired — received login page instead of JSON. Regenerate your PAT.');
123+
const err = new Error('JIRA token expired — received login page instead of JSON. Regenerate your token (Server: PAT; Cloud: API token).');
100124
err.status = 401;
101125
throw err;
102126
}

server/services/jira.test.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
11
import { describe, it, expect } from 'vitest';
2-
import { buildColumnsFromBoardConfig, buildColumnsFromStatuses } from './jira.js';
2+
import { buildColumnsFromBoardConfig, buildColumnsFromStatuses, isCloudInstance, jiraAuthHeader } from './jira.js';
3+
4+
describe('isCloudInstance', () => {
5+
it('treats *.atlassian.net hosts as Cloud', () => {
6+
expect(isCloudInstance('https://example.atlassian.net')).toBe(true);
7+
expect(isCloudInstance('https://example.atlassian.net/jira/software/c/projects/PROJ')).toBe(true);
8+
expect(isCloudInstance('https://ATLASSIAN.NET')).toBe(true);
9+
});
10+
11+
it('treats Server / Data Center hosts as not Cloud', () => {
12+
expect(isCloudInstance('https://jira.example.com')).toBe(false);
13+
expect(isCloudInstance('https://jira.example.com:8443')).toBe(false);
14+
// Guard against a lookalike host that merely contains the string.
15+
expect(isCloudInstance('https://atlassian.net.evil.com')).toBe(false);
16+
});
17+
18+
it('does not throw on a malformed baseUrl', () => {
19+
expect(isCloudInstance('not a url')).toBe(false);
20+
expect(isCloudInstance(undefined)).toBe(false);
21+
});
22+
});
23+
24+
describe('jiraAuthHeader', () => {
25+
it('uses Basic base64(email:token) for Cloud instances', () => {
26+
const header = jiraAuthHeader({ baseUrl: 'https://example.atlassian.net', email: 'me@x.com', apiToken: 'tok' });
27+
expect(header).toBe(`Basic ${Buffer.from('me@x.com:tok').toString('base64')}`);
28+
});
29+
30+
it('uses Bearer PAT for Server / Data Center instances', () => {
31+
const header = jiraAuthHeader({ baseUrl: 'https://jira.example.com', email: 'me@x.com', apiToken: 'pat' });
32+
expect(header).toBe('Bearer pat');
33+
});
34+
});
335

436
describe('buildColumnsFromBoardConfig', () => {
537
const statusById = new Map([

0 commit comments

Comments
 (0)