Skip to content

Commit c427fab

Browse files
committed
TT-17162: support scoped tokens for branch suggestion
1 parent 1570a18 commit c427fab

7 files changed

Lines changed: 154 additions & 22 deletions

File tree

.github/workflows/branch-suggestion.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
secrets:
88
JIRA_TOKEN:
99
required: true
10-
description: 'Base64 encoded JIRA API token'
10+
description: 'Base64 encoded Jira email and scoped API token'
1111

1212
permissions:
1313
pull-requests: write

.github/workflows/example-usage.yml.template

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ jobs:
1515
branch-suggestions:
1616
uses: TykTechnologies/REFINE/.github/workflows/branch-suggestion.yml@b469f63c328ddc43e222dac10487f0a19cd0add1 # main
1717
secrets:
18-
JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }}
18+
# JIRA_TOKEN should be base64-encoded from "your-email@example.com:your-scoped-jira-api-token".
19+
JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }}

branch-suggestion/README.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Add this workflow to your repository to enable automatic branch suggestions:
1919
1. Copy `.github/workflows/example-usage.yml.template` to your repository as `.github/workflows/branch-suggestion.yml`
2020

2121
2. Add required secrets to your repository (Settings → Secrets and variables → Actions):
22-
- `JIRA_TOKEN`: Base64 encoded JIRA API token
22+
- `JIRA_TOKEN`: Base64 encoded Jira account email and scoped API token, generated from `email:api_token`
2323
3. That's it! The workflow will automatically analyze PRs and post branch suggestions.
2424

2525
### Example Workflow Configuration
@@ -95,7 +95,7 @@ npm install -g @probelabs/visor
9595

9696
3. Set up environment variables:
9797
```bash
98-
export JIRA_TOKEN="your-jira-api-token"
98+
export JIRA_TOKEN="$(printf '%s' 'your-email@example.com:your-scoped-jira-api-token' | base64)"
9999
```
100100

101101
### Test Individual Scripts
@@ -138,8 +138,9 @@ node scripts/jira/get-fixedversion.js "feature/TT-12345-fix-auth"
138138

139139
**Exit codes:**
140140
- `0`: Success (fix versions found)
141-
- `1`: Error (ticket found but no fix versions set)
141+
- `1`: Error (JIRA API or configuration error)
142142
- `2`: No JIRA ticket found
143+
- `3`: Ticket found but no fix versions set
143144

144145
#### Test Branch Matcher
145146

@@ -183,13 +184,13 @@ node scripts/github/add-pr-comment.js \
183184

184185
```bash
185186
# Test with a real ticket
186-
env JIRA_TOKEN="your-token" \
187+
env JIRA_TOKEN="$(printf '%s' 'your-email@example.com:your-scoped-jira-api-token' | base64)" \
187188
PR_TITLE="TT-12345" \
188189
REPOSITORY="TykTechnologies/tyk" \
189190
visor --config branch_suggestion.yml
190191
191192
# Test with TIB ticket (different version format)
192-
env JIRA_TOKEN="your-token" \
193+
env JIRA_TOKEN="$(printf '%s' 'your-email@example.com:your-scoped-jira-api-token' | base64)" \
193194
PR_TITLE="TT-5433" \
194195
REPOSITORY="TykTechnologies/tyk-identity-broker" \
195196
visor --config branch_suggestion.yml
@@ -261,7 +262,7 @@ BRANCH SUGGESTION ANALYSIS
261262
### Environment Variables
262263
263264
#### Required
264-
- `JIRA_TOKEN`: Base64 encoded JIRA API token
265+
- `JIRA_TOKEN`: Base64 encoded Jira account email and scoped API token, generated from `email:api_token`
265266
266267
#### Optional (for PR comment posting)
267268
- `GITHUB_TOKEN`: GitHub token (automatically provided in GitHub Actions)
@@ -313,9 +314,10 @@ The tool automatically adapts to different branching strategies:
313314
**Symptom:** Error message about authentication
314315
315316
**Solution:**
316-
1. Generate a new API token at https://id.atlassian.com/manage-profile/security/api-tokens
317-
2. Base64 encode the token with your email (e.g., `echo -n "email:token" | base64`)
318-
3. Ensure the token has not expired
317+
1. Generate a scoped API token at https://id.atlassian.com/manage-profile/security/api-tokens
318+
2. Ensure the token has Jira read scope, such as `read:jira-work`
319+
3. Base64 encode the token with your email (for example, `printf '%s' 'email:token' | base64`)
320+
4. Ensure the token owner can browse the Jira project
319321
320322
### GitHub API rate limiting
321323

branch-suggestion/branch_suggestion.yml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,18 @@ checks:
2525
BRANCH_NAME="${BRANCH_NAME:-feature/TT-12345-test}"
2626
REPO="${REPOSITORY:-TykTechnologies/tyk}"
2727
28+
# Visor can execute this check from a fresh temporary worktree.
29+
# Install dependencies there when node_modules is not present.
30+
if [ ! -d node_modules ]; then
31+
npm ci --ignore-scripts
32+
fi
33+
2834
set +e
29-
JIRA_RESULT=$(node scripts/jira/get-fixedversion.js "$PR_TITLE" "$BRANCH_NAME")
35+
JIRA_ERROR_FILE=$(mktemp)
36+
JIRA_RESULT=$(node scripts/jira/get-fixedversion.js "$PR_TITLE" "$BRANCH_NAME" 2>"$JIRA_ERROR_FILE")
3037
JIRA_EXIT_CODE=$?
38+
JIRA_ERROR=$(cat "$JIRA_ERROR_FILE")
39+
rm -f "$JIRA_ERROR_FILE"
3140
set -e
3241
3342
# Handle exit codes
@@ -36,7 +45,14 @@ checks:
3645
exit 0
3746
fi
3847
if [ $JIRA_EXIT_CODE -eq 1 ]; then
39-
echo '[{"file": "branch-suggestion", "line": 0, "message": "JIRA API or configuration error (e.g. invalid token).", "severity": "critical", "ruleId": "command/execution_error"}]'
48+
MESSAGE=$(printf 'JIRA API or configuration error: %s' "${JIRA_ERROR:-Unknown error}")
49+
jq -n --arg message "$MESSAGE" '[{
50+
file: "branch-suggestion",
51+
line: 0,
52+
message: $message,
53+
severity: "critical",
54+
ruleId: "command/execution_error"
55+
}]'
4056
exit 0
4157
fi
4258
if [ $JIRA_EXIT_CODE -eq 3 ]; then

branch-suggestion/scripts/jira/__tests__/jira-api.test.js

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
33
// Set env var before importing
44
process.env.JIRA_TOKEN = 'test-token';
55

6-
import { extractJQL, jiraAPI, searchIssues, getIssue, formatIssue, main } from '../jira-api.js';
6+
import { extractJQL, jiraAPI, searchIssues, getIssue, formatIssue, getJiraApiBaseUrl, getJiraCloudId, resetJiraCloudIdCache, main } from '../jira-api.js';
77
import readline from 'readline';
88

99
// Mock fetch
@@ -45,6 +45,7 @@ describe('jiraAPI', () => {
4545
vi.resetModules();
4646
process.env = { ...originalEnv };
4747
global.fetch.mockClear();
48+
resetJiraCloudIdCache();
4849
});
4950

5051
afterEach(() => {
@@ -57,6 +58,7 @@ describe('jiraAPI', () => {
5758
});
5859

5960
it('should handle 401 Unauthorized error', async () => {
61+
process.env.JIRA_CLOUD_ID = 'cloud-123';
6062
global.fetch.mockResolvedValue({
6163
ok: false,
6264
status: 401,
@@ -67,6 +69,7 @@ describe('jiraAPI', () => {
6769
});
6870

6971
it('should handle 404 Not Found error', async () => {
72+
process.env.JIRA_CLOUD_ID = 'cloud-123';
7073
global.fetch.mockResolvedValue({
7174
ok: false,
7275
status: 404,
@@ -77,6 +80,7 @@ describe('jiraAPI', () => {
7780
});
7881

7982
it('should return JSON on success', async () => {
83+
process.env.JIRA_CLOUD_ID = 'cloud-123';
8084
const mockData = { key: 'TT-123' };
8185
global.fetch.mockResolvedValue({
8286
ok: true,
@@ -86,11 +90,57 @@ describe('jiraAPI', () => {
8690
const result = await jiraAPI('/test');
8791
expect(result).toEqual(mockData);
8892
});
93+
94+
it('should fetch JIRA_CLOUD_ID from tenant_info when it is not set', async () => {
95+
delete process.env.JIRA_CLOUD_ID;
96+
global.fetch
97+
.mockResolvedValueOnce({
98+
ok: true,
99+
json: vi.fn().mockResolvedValue({ cloudId: 'cloud-123' })
100+
})
101+
.mockResolvedValueOnce({
102+
ok: true,
103+
json: vi.fn().mockResolvedValue({})
104+
});
105+
106+
await jiraAPI('/test');
107+
108+
expect(global.fetch.mock.calls[0][0]).toBe('https://tyktech.atlassian.net/_edge/tenant_info');
109+
expect(global.fetch.mock.calls[1][0]).toBe('https://api.atlassian.com/ex/jira/cloud-123/rest/api/3/test');
110+
});
111+
112+
it('should use Atlassian scoped API token URL when JIRA_CLOUD_ID is set', async () => {
113+
process.env.JIRA_CLOUD_ID = 'cloud-123';
114+
global.fetch.mockResolvedValue({
115+
ok: true,
116+
json: vi.fn().mockResolvedValue({})
117+
});
118+
119+
await jiraAPI('/test');
120+
121+
expect(global.fetch.mock.calls[0][0]).toBe('https://api.atlassian.com/ex/jira/cloud-123/rest/api/3/test');
122+
});
123+
124+
it('should build Jira API base URL from JIRA_CLOUD_ID when present', async () => {
125+
process.env.JIRA_CLOUD_ID = 'cloud-123';
126+
await expect(getJiraApiBaseUrl()).resolves.toBe('https://api.atlassian.com/ex/jira/cloud-123');
127+
});
128+
129+
it('should throw if tenant_info does not return a cloud ID', async () => {
130+
delete process.env.JIRA_CLOUD_ID;
131+
global.fetch.mockResolvedValue({
132+
ok: true,
133+
json: vi.fn().mockResolvedValue({})
134+
});
135+
136+
await expect(getJiraCloudId()).rejects.toThrow('JIRA cloud ID was not found');
137+
});
89138
});
90139

91140
describe('searchIssues', () => {
92141
beforeEach(() => {
93142
global.fetch.mockClear();
143+
process.env.JIRA_CLOUD_ID = 'cloud-123';
94144
});
95145

96146
it('should call jiraAPI with correct parameters', async () => {
@@ -110,6 +160,26 @@ describe('searchIssues', () => {
110160
});
111161
});
112162

163+
describe('getIssue', () => {
164+
beforeEach(() => {
165+
global.fetch.mockClear();
166+
process.env.JIRA_CLOUD_ID = 'cloud-123';
167+
});
168+
169+
it('should request only fields needed for branch suggestions', async () => {
170+
global.fetch.mockResolvedValue({
171+
ok: true,
172+
json: vi.fn().mockResolvedValue({ key: 'TT-123' })
173+
});
174+
175+
await getIssue('TT-123');
176+
177+
const url = global.fetch.mock.calls[0][0];
178+
expect(url).toContain('/issue/TT-123?');
179+
expect(url).toContain('fields=summary,priority,issuetype,fixVersions');
180+
});
181+
});
182+
113183
describe('formatIssue', () => {
114184
it('should format issue correctly', () => {
115185
const issue = {
@@ -243,4 +313,4 @@ describe('main execution', () => {
243313
expect(global.fetch).toHaveBeenCalledTimes(1);
244314
expect(consoleLogMock).toHaveBeenCalledWith(expect.stringContaining('Displayed 50 of 100 total issues'));
245315
});
246-
});
316+
});

branch-suggestion/scripts/jira/get-fixedversion.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,3 @@ export {
214214
if (import.meta.url === `file://${process.argv[1]}`) {
215215
main();
216216
}
217-

branch-suggestion/scripts/jira/jira-api.js

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,54 @@ if (!process.env.JIRA_TOKEN) {
88
dotenv.config();
99
}
1010
// JIRA configuration
11-
const JIRA_BASE_URL = 'https://tyktech.atlassian.net';
11+
const JIRA_SITE_URL = 'https://tyktech.atlassian.net';
1212
// We read JIRA_TOKEN dynamically inside jiraAPI to allow testing
13+
let cachedCloudId = null;
1314

1415
// Debug logging (without exposing sensitive data)
1516
console.error('DEBUG: Environment check:');
1617
console.error(` JIRA_TOKEN: ${process.env.JIRA_TOKEN ? 'SET' : 'EMPTY'}`);
18+
console.error(` JIRA_CLOUD_ID: ${process.env.JIRA_CLOUD_ID ? 'SET' : 'EMPTY'}`);
1719
console.error(` All JIRA env vars: ${Object.keys(process.env).filter(k => k.includes('JIRA')).join(', ')}`);
1820

21+
async function getJiraCloudId() {
22+
if (process.env.JIRA_CLOUD_ID) {
23+
return process.env.JIRA_CLOUD_ID;
24+
}
25+
26+
if (cachedCloudId) {
27+
return cachedCloudId;
28+
}
29+
30+
const response = await fetch(`${JIRA_SITE_URL}/_edge/tenant_info`, {
31+
headers: {
32+
'Accept': 'application/json'
33+
}
34+
});
35+
36+
if (!response.ok) {
37+
const error = await response.text();
38+
throw new Error(`Failed to fetch JIRA cloud ID (${response.status}): ${error}`);
39+
}
40+
41+
const tenantInfo = await response.json();
42+
if (!tenantInfo.cloudId) {
43+
throw new Error('JIRA cloud ID was not found in tenant_info response');
44+
}
45+
46+
cachedCloudId = tenantInfo.cloudId;
47+
return cachedCloudId;
48+
}
49+
50+
function resetJiraCloudIdCache() {
51+
cachedCloudId = null;
52+
}
53+
54+
async function getJiraApiBaseUrl() {
55+
const cloudId = await getJiraCloudId();
56+
return `https://api.atlassian.com/ex/jira/${cloudId}`;
57+
}
58+
1959
// Extract JQL from URL or use directly
2060
function extractJQL(input) {
2161
// Check if input is a URL
@@ -41,7 +81,8 @@ async function jiraAPI(endpoint, options = {}) {
4181
throw new Error('JIRA_TOKEN must be set in environment variables');
4282
}
4383

44-
const response = await fetch(`${JIRA_BASE_URL}/rest/api/3${endpoint}`, {
84+
const baseUrl = await getJiraApiBaseUrl();
85+
const response = await fetch(`${baseUrl}/rest/api/3${endpoint}`, {
4586
...options,
4687
headers: {
4788
'Authorization': `Basic ${token}`,
@@ -76,7 +117,7 @@ async function searchIssues(jql, startAt = 0, maxResults = 50) {
76117

77118
// Get issue details
78119
async function getIssue(issueKey) {
79-
return jiraAPI(`/issue/${issueKey}`);
120+
return jiraAPI(`/issue/${issueKey}?fields=summary,priority,issuetype,fixVersions`);
80121
}
81122

82123
// Format issue for display
@@ -118,7 +159,7 @@ function formatIssue(issue, index) {
118159
lines.push(` Components: ${issue.fields.components.map(c => c.name).join(', ')}`);
119160
}
120161

121-
lines.push(` Link: ${JIRA_BASE_URL}/browse/${issue.key}`);
162+
lines.push(` Link: ${JIRA_SITE_URL}/browse/${issue.key}`);
122163

123164
return lines.join('\n');
124165
}
@@ -241,7 +282,7 @@ function exportToCSV(issues) {
241282
new Date(issue.fields.created).toLocaleDateString(),
242283
issue.fields.assignee?.displayName || '',
243284
issue.fields.reporter?.displayName || '',
244-
`${JIRA_BASE_URL}/browse/${issue.key}`
285+
`${JIRA_SITE_URL}/browse/${issue.key}`
245286
];
246287
rows.push(row.join(','));
247288
}
@@ -257,10 +298,13 @@ export {
257298
getIssue,
258299
formatIssue,
259300
exportToCSV,
301+
getJiraCloudId,
302+
getJiraApiBaseUrl,
303+
resetJiraCloudIdCache,
260304
main
261305
};
262306

263307
// Run main if executed directly
264308
if (import.meta.url === `file://${process.argv[1]}`) {
265309
main().catch(console.error);
266-
}
310+
}

0 commit comments

Comments
 (0)