diff --git a/.github/workflows/branch-suggestion.yml b/.github/workflows/branch-suggestion.yml index 1abe937..ae3148e 100644 --- a/.github/workflows/branch-suggestion.yml +++ b/.github/workflows/branch-suggestion.yml @@ -7,7 +7,10 @@ on: secrets: JIRA_TOKEN: required: true - description: 'Pre-encoded JIRA token (base64(email:api_token))' + description: 'Raw JIRA API token' + JIRA_USER_EMAIL: + required: true + description: 'JIRA account email' permissions: pull-requests: write @@ -22,7 +25,7 @@ jobs: uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: TykTechnologies/github-actions - ref: main + ref: ${{ github.repository == 'TykTechnologies/github-actions' && github.ref || 'main' }} - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -39,7 +42,9 @@ jobs: - name: Analyze PR and suggest branches working-directory: branch-suggestion env: + VISOR_WORKSPACE_ENABLED: "false" JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_TITLE: ${{ github.event.pull_request.title }} PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/example-usage.yml.template b/.github/workflows/example-usage.yml.template index 7ddb719..ecb37dd 100644 --- a/.github/workflows/example-usage.yml.template +++ b/.github/workflows/example-usage.yml.template @@ -15,4 +15,5 @@ jobs: branch-suggestions: uses: TykTechnologies/REFINE/.github/workflows/branch-suggestion.yml@b469f63c328ddc43e222dac10487f0a19cd0add1 # main secrets: - JIRA_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} \ No newline at end of file diff --git a/branch-suggestion/README.md b/branch-suggestion/README.md index 6101e49..a898a82 100644 --- a/branch-suggestion/README.md +++ b/branch-suggestion/README.md @@ -19,15 +19,16 @@ Add this workflow to your repository to enable automatic branch suggestions: 1. Copy `.github/workflows/example-usage.yml.template` to your repository as `.github/workflows/branch-suggestion.yml` 2. Add required secrets to your repository (Settings → Secrets and variables → Actions): - - `JIRA_API_TOKEN`: JIRA API token (generate at https://id.atlassian.com/manage-profile/security/api-tokens) + - `JIRA_USER_EMAIL`: JIRA account email + - `JIRA_TOKEN`: JIRA API token (generate at https://id.atlassian.com/manage-profile/security/api-tokens) 3. That's it! The workflow will automatically analyze PRs and post branch suggestions. ### Example Workflow Configuration -```yaml -# .github/workflows/branch-suggestion.yml -name: PR Branch Suggestions +2. Add required secrets to your repository (Settings → Secrets and variables → Actions): + - `JIRA_USER_EMAIL`: JIRA account email + - `JIRA_TOKEN`: JIRA API token (generate at https://id.atlassian.com/manage-profile/security/api-tokens) on: pull_request: @@ -41,8 +42,8 @@ jobs: branch-suggestions: uses: TykTechnologies/REFINE/.github/workflows/branch-suggestion.yml@main secrets: - JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} - JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }} ``` ## How It Works @@ -100,8 +101,8 @@ npm install -g @probelabs/visor 3. Set up environment variables: ```bash -export JIRA_EMAIL="your-email@example.com" -export JIRA_API_TOKEN="your-jira-api-token" +export JIRA_USER_EMAIL="your-email@example.com" +export JIRA_TOKEN="your-jira-api-token" ``` ### Test Individual Scripts @@ -189,15 +190,15 @@ node scripts/github/add-pr-comment.js \ ```bash # Test with a real ticket -env JIRA_EMAIL="your-email@example.com" \ - JIRA_API_TOKEN="your-token" \ +env JIRA_USER_EMAIL="your-email@example.com" \ + JIRA_TOKEN="your-token" \ PR_TITLE="TT-12345" \ REPOSITORY="TykTechnologies/tyk" \ visor --config branch_suggestion.yml # Test with TIB ticket (different version format) -env JIRA_EMAIL="your-email@example.com" \ - JIRA_API_TOKEN="your-token" \ +env JIRA_USER_EMAIL="your-email@example.com" \ + JIRA_TOKEN="your-token" \ PR_TITLE="TT-5433" \ REPOSITORY="TykTechnologies/tyk-identity-broker" \ visor --config branch_suggestion.yml @@ -269,8 +270,8 @@ BRANCH SUGGESTION ANALYSIS ### Environment Variables #### Required -- `JIRA_EMAIL`: JIRA account email -- `JIRA_API_TOKEN`: JIRA API token +- `JIRA_USER_EMAIL`: JIRA account email +- `JIRA_TOKEN`: JIRA API token #### Optional (for PR comment posting) - `GITHUB_TOKEN`: GitHub token (automatically provided in GitHub Actions) @@ -322,7 +323,7 @@ The tool automatically adapts to different branching strategies: **Symptom:** Error message about authentication **Solution:** -1. Verify `JIRA_EMAIL` matches your JIRA account email +1. Verify `JIRA_USER_EMAIL` matches your JIRA account email 2. Generate a new API token at https://id.atlassian.com/manage-profile/security/api-tokens 3. Ensure the token has not expired diff --git a/branch-suggestion/branch_suggestion.yml b/branch-suggestion/branch_suggestion.yml index 5044bef..b6f8262 100644 --- a/branch-suggestion/branch_suggestion.yml +++ b/branch-suggestion/branch_suggestion.yml @@ -13,10 +13,10 @@ checks: # 2. Fetch fix versions from JIRA # 3. Fetch repository branches # 4. Match and suggest branches - # ============================================================================ analyze-and-suggest: type: command - timeout: 90 + tags: ["remote"] + timeout: 90000 exec: | set -e @@ -35,10 +35,13 @@ checks: echo "ℹ️ No JIRA ticket found. Skipping branch suggestions." >&2 exit 0 fi - if [ $JIRA_EXIT_CODE -eq 1 ]; then - echo "❌ JIRA ticket found but no fix versions set." >&2 - exit 1 + echo '[{"file": "branch-suggestion", "line": 0, "message": "JIRA API or configuration error (e.g. invalid token).", "severity": "critical", "ruleId": "command/execution_error"}]' + exit 0 + fi + if [ $JIRA_EXIT_CODE -eq 3 ]; then + echo '[{"file": "branch-suggestion", "line": 0, "message": "JIRA ticket found but no fix versions set.", "severity": "info", "ruleId": "command/execution_info"}]' + exit 0 fi # Fetch repository branches @@ -77,7 +80,7 @@ checks: type: command depends_on: [analyze-and-suggest] tags: ["remote"] - timeout: 30 + timeout: 30000 exec: | set -e @@ -87,8 +90,8 @@ checks: # Check if markdown file exists from previous step if [ ! -f /tmp/branch_suggestion_markdown.txt ]; then - echo "❌ Error: Markdown file not found. analyze-and-suggest step may have failed." >&2 - exit 1 + echo "ℹ️ Markdown file not found. analyze-and-suggest step may have exited early with info severity." >&2 + exit 0 fi # Post or update the comment diff --git a/branch-suggestion/scripts/jira/__tests__/get-fixedversion.test.js b/branch-suggestion/scripts/jira/__tests__/get-fixedversion.test.js index 71b1564..1ab380a 100644 --- a/branch-suggestion/scripts/jira/__tests__/get-fixedversion.test.js +++ b/branch-suggestion/scripts/jira/__tests__/get-fixedversion.test.js @@ -1,5 +1,10 @@ -import { describe, it, expect } from 'vitest'; -import { extractJiraTicket, parseVersion, detectComponent } from '../get-fixedversion.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { extractJiraTicket, parseVersion, detectComponent, getFixVersions, main } from '../get-fixedversion.js'; +import { getIssue } from '../jira-api.js'; + +vi.mock('../jira-api.js', () => ({ + getIssue: vi.fn() +})); describe('extractJiraTicket', () => { it('should extract ticket from PR title', () => { @@ -145,3 +150,197 @@ describe('parseVersion', () => { expect(result.original).toBe('TIB 1.7.0'); }); }); + +describe('getFixVersions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return parsed fix versions on success', async () => { + getIssue.mockResolvedValue({ + fields: { + summary: 'Test issue', + priority: { name: 'High' }, + issuetype: { name: 'Bug' }, + fixVersions: [ + { name: 'Tyk 5.0.0', id: '10000', released: true } + ] + } + }); + + const result = await getFixVersions('TT-123'); + expect(result).toEqual({ + ticket: 'TT-123', + summary: 'Test issue', + priority: 'High', + issueType: 'Bug', + fixVersions: [ + { + name: 'Tyk 5.0.0', + id: '10000', + released: true, + parsed: { + major: 5, + minor: 0, + patch: 0, + original: 'Tyk 5.0.0', + component: ['tyk', 'tyk-analytics', 'tyk-analytics-ui'] + } + } + ] + }); + expect(getIssue).toHaveBeenCalledWith('TT-123'); + }); + + it('should handle missing fix versions', async () => { + getIssue.mockResolvedValue({ + fields: { + summary: 'Test issue', + priority: { name: 'High' }, + issuetype: { name: 'Bug' }, + // no fixVersions + } + }); + + const result = await getFixVersions('TT-123'); + expect(result.fixVersions).toEqual([]); + }); + + it('should throw error when getIssue fails', async () => { + getIssue.mockRejectedValue(new Error('API Error')); + + await expect(getFixVersions('TT-123')).rejects.toThrow('Failed to fetch JIRA ticket TT-123: API Error'); + }); +}); + +describe('main execution', () => { + let originalArgv; + let exitMock; + let consoleLogMock; + let consoleErrorMock; + + beforeEach(() => { + originalArgv = process.argv; + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => {}); + consoleLogMock = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.clearAllMocks(); + }); + + afterEach(() => { + process.argv = originalArgv; + vi.restoreAllMocks(); + }); + + it('should exit with code 3 if no fix versions found', async () => { + process.argv = ['node', 'script.js', 'TT-123']; + getIssue.mockResolvedValue({ + fields: { + summary: 'Test', + fixVersions: [] + } + }); + + await main(); + + expect(consoleErrorMock).toHaveBeenCalled(); + const errorCall = consoleErrorMock.mock.calls[0][0]; + expect(errorCall).toContain('No fix versions found in JIRA ticket'); + expect(exitMock).toHaveBeenCalledWith(3); + }); + + it('should exit with code 1 on API error', async () => { + process.argv = ['node', 'script.js', 'TT-123']; + getIssue.mockRejectedValue(new Error('API Error')); + + await main(); + + expect(consoleErrorMock).toHaveBeenCalled(); + const errorCall = consoleErrorMock.mock.calls[0][0]; + expect(errorCall).toContain('API Error'); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + it('should exit with code 2 if no ticket found', async () => { + process.argv = ['node', 'script.js', 'No ticket here']; + + await main(); + + expect(consoleErrorMock).toHaveBeenCalled(); + const errorCall = consoleErrorMock.mock.calls[0][0]; + expect(errorCall).toContain('No JIRA ticket found'); + expect(exitMock).toHaveBeenCalledWith(2); + }); + + it('should log result and not exit on success', async () => { + process.argv = ['node', 'script.js', 'TT-123']; + getIssue.mockResolvedValue({ + fields: { + summary: 'Test', + fixVersions: [{ name: '1.0.0' }] + } + }); + + await main(); + + expect(consoleLogMock).toHaveBeenCalled(); + expect(exitMock).not.toHaveBeenCalled(); + }); + + it('should exit with code 1 if no arguments provided', async () => { + process.argv = ['node', 'script.js']; + + await main(); + + expect(consoleLogMock).toHaveBeenCalledWith(expect.stringContaining('Usage:')); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + it('should extract ticket from branchName (Priority 1)', async () => { + process.argv = ['node', 'script.js', 'No ticket here', 'feature/TT-999-fix']; + getIssue.mockResolvedValue({ + fields: { + summary: 'Test', + fixVersions: [{ name: '1.0.0' }] + } + }); + + await main(); + + expect(getIssue).toHaveBeenCalledWith('TT-999'); + expect(consoleLogMock).toHaveBeenCalled(); + expect(exitMock).not.toHaveBeenCalled(); + }); + + it('should extract ticket from prTitle (Priority 2)', async () => { + process.argv = ['node', 'script.js', 'TT-888: Fix bug', 'no-ticket-branch']; + getIssue.mockResolvedValue({ + fields: { + summary: 'Test', + fixVersions: [{ name: '1.0.0' }] + } + }); + + await main(); + + expect(getIssue).toHaveBeenCalledWith('TT-888'); + expect(consoleLogMock).toHaveBeenCalled(); + expect(exitMock).not.toHaveBeenCalled(); + }); + + it('should handle prTitle as direct ticket key', async () => { + process.argv = ['node', 'script.js', 'TT-777']; + getIssue.mockResolvedValue({ + fields: { + summary: 'Test', + fixVersions: [{ name: '1.0.0' }] + } + }); + + await main(); + + expect(getIssue).toHaveBeenCalledWith('TT-777'); + expect(consoleLogMock).toHaveBeenCalled(); + expect(exitMock).not.toHaveBeenCalled(); + }); +}); diff --git a/branch-suggestion/scripts/jira/__tests__/jira-api.test.js b/branch-suggestion/scripts/jira/__tests__/jira-api.test.js new file mode 100644 index 0000000..6199369 --- /dev/null +++ b/branch-suggestion/scripts/jira/__tests__/jira-api.test.js @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Set env var before importing +process.env.JIRA_TOKEN = 'test-token'; +process.env.JIRA_USER_EMAIL = 'test@example.com'; + +import { extractJQL, jiraAPI, searchIssues, getIssue, formatIssue, main } from '../jira-api.js'; +import readline from 'readline'; + +// Mock fetch +global.fetch = vi.fn(); + +vi.mock('readline', () => ({ + default: { + createInterface: vi.fn() + } +})); + +describe('extractJQL', () => { + it('should extract JQL from a valid JIRA URL', () => { + const url = 'https://tyktech.atlassian.net/jira/software/c/projects/TT/issues/?jql=project%20%3D%20TT%20AND%20status%20!%3D%20closed'; + expect(extractJQL(url)).toBe('project = TT AND status != closed'); + }); + + it('should return input if it is not a URL but contains jql=', () => { + const input = 'jql=project = TT'; + // URL parsing will fail, so it returns input + expect(extractJQL(input)).toBe(input); + }); + + it('should return input if it is a direct JQL query', () => { + const input = 'project = TT AND status != closed'; + expect(extractJQL(input)).toBe(input); + }); + + it('should return input if URL does not contain jql parameter', () => { + const url = 'https://tyktech.atlassian.net/browse/TT-123'; + expect(extractJQL(url)).toBe(url); + }); +}); + +describe('jiraAPI', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + global.fetch.mockClear(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should throw error if JIRA_TOKEN or JIRA_USER_EMAIL is not set', async () => { + delete process.env.JIRA_TOKEN; + await expect(jiraAPI('/test')).rejects.toThrow('JIRA_TOKEN and JIRA_USER_EMAIL must be set'); + + process.env.JIRA_TOKEN = 'test-token'; + delete process.env.JIRA_USER_EMAIL; + await expect(jiraAPI('/test')).rejects.toThrow('JIRA_TOKEN and JIRA_USER_EMAIL must be set'); + }); + + it('should handle 401 Unauthorized error', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 401, + text: vi.fn().mockResolvedValue('Unauthorized') + }); + + await expect(jiraAPI('/test')).rejects.toThrow('JIRA API Error (401): Unauthorized'); + }); + + it('should handle 404 Not Found error', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 404, + text: vi.fn().mockResolvedValue('Issue does not exist') + }); + + await expect(jiraAPI('/test')).rejects.toThrow('JIRA API Error (404): Issue does not exist'); + }); + + it('should return JSON on success', async () => { + const mockData = { key: 'TT-123' }; + global.fetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockData) + }); + + const result = await jiraAPI('/test'); + expect(result).toEqual(mockData); + }); +}); + +describe('searchIssues', () => { + beforeEach(() => { + global.fetch.mockClear(); + }); + + it('should call jiraAPI with correct parameters', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ issues: [] }) + }); + + await searchIssues('project = TT', 10, 20); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const url = global.fetch.mock.calls[0][0]; + expect(url).toContain('/search/jql?'); + expect(url).toContain('jql=project+%3D+TT'); + expect(url).toContain('startAt=10'); + expect(url).toContain('maxResults=20'); + }); +}); + +describe('formatIssue', () => { + it('should format issue correctly', () => { + const issue = { + key: 'TT-123', + fields: { + summary: 'Test issue', + status: { name: 'In Progress' }, + issuetype: { name: 'Bug' }, + priority: { name: 'High' }, + created: '2023-01-01T00:00:00.000Z', + assignee: { displayName: 'John Doe' }, + reporter: { displayName: 'Jane Doe' }, + labels: ['test', 'bug'], + components: [{ name: 'API' }] + } + }; + + const formatted = formatIssue(issue, 0); + expect(formatted).toContain('1. [TT-123] Test issue'); + expect(formatted).toContain('Status: In Progress'); + expect(formatted).toContain('Type: Bug'); + expect(formatted).toContain('Priority: High'); + expect(formatted).toContain('Assignee: John Doe'); + expect(formatted).toContain('Reporter: Jane Doe'); + expect(formatted).toContain('Labels: test, bug'); + expect(formatted).toContain('Components: API'); + expect(formatted).toContain('Link: https://tyktech.atlassian.net/browse/TT-123'); + }); + + it('should handle missing fields', () => { + const issue = { + key: 'TT-123', + fields: {} + }; + + const formatted = formatIssue(issue, 0); + expect(formatted).toContain('1. [TT-123] No summary'); + expect(formatted).toContain('Status: Unknown'); + expect(formatted).toContain('Type: Unknown'); + expect(formatted).toContain('Priority: None'); + expect(formatted).toContain('Created: Unknown'); + }); +}); + +describe('main execution', () => { + let originalArgv; + let exitMock; + let consoleLogMock; + let consoleErrorMock; + let stdoutWriteMock; + + beforeEach(() => { + originalArgv = process.argv; + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => {}); + consoleLogMock = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {}); + stdoutWriteMock = vi.spyOn(process.stdout, 'write').mockImplementation(() => {}); + vi.clearAllMocks(); + global.fetch.mockClear(); + }); + + afterEach(() => { + process.argv = originalArgv; + vi.restoreAllMocks(); + }); + + it('should exit with code 1 if no arguments provided', async () => { + process.argv = ['node', 'script.js']; + + await main(); + + expect(consoleLogMock).toHaveBeenCalledWith(expect.stringContaining('Usage:')); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + it('should handle pagination when total > pageSize and user answers yes', async () => { + process.argv = ['node', 'script.js', 'project = TT']; + + // Mock first page + global.fetch.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ + total: 100, + issues: Array(50).fill({ key: 'TT-1', fields: { summary: 'Test' } }) + }) + }); + + // Mock second page + global.fetch.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ + total: 100, + issues: Array(50).fill({ key: 'TT-2', fields: { summary: 'Test 2' } }) + }) + }); + + // Mock readline to answer 'yes' + const questionMock = vi.fn((query, cb) => cb('yes')); + readline.createInterface.mockReturnValue({ + question: questionMock, + close: vi.fn() + }); + + await main(); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(consoleLogMock).toHaveBeenCalledWith(expect.stringContaining('Displayed 100 of 100 total issues')); + }); + + it('should not fetch remaining pages if user answers no', async () => { + process.argv = ['node', 'script.js', 'project = TT']; + + // Mock first page + global.fetch.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ + total: 100, + issues: Array(50).fill({ key: 'TT-1', fields: { summary: 'Test' } }) + }) + }); + + // Mock readline to answer 'no' + const questionMock = vi.fn((query, cb) => cb('no')); + readline.createInterface.mockReturnValue({ + question: questionMock, + close: vi.fn() + }); + + await main(); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(consoleLogMock).toHaveBeenCalledWith(expect.stringContaining('Displayed 50 of 100 total issues')); + }); +}); \ No newline at end of file diff --git a/branch-suggestion/scripts/jira/get-fixedversion.js b/branch-suggestion/scripts/jira/get-fixedversion.js index 57dfba7..bb94617 100644 --- a/branch-suggestion/scripts/jira/get-fixedversion.js +++ b/branch-suggestion/scripts/jira/get-fixedversion.js @@ -8,7 +8,6 @@ if (!process.env.JIRA_TOKEN) { process.env.DOTENV_LOG_LEVEL = 'error'; dotenv.config(); } - /** * Extract JIRA ticket key from text (e.g., PR title, branch name) * @param {string} text - Text to search @@ -140,8 +139,9 @@ async function main() { console.log('\nOutput: JSON object with ticket info and fix versions'); console.log('\nExit codes:'); console.log(' 0 - Success (fix versions found)'); - console.log(' 1 - Error (ticket found but no fix versions set)'); + console.log(' 1 - Error (JIRA API or configuration error)'); console.log(' 2 - No JIRA ticket found'); + console.log(' 3 - Info (ticket found but no fix versions set)'); process.exit(1); } @@ -186,7 +186,7 @@ async function main() { priority: result.priority, issueType: result.issueType })); - process.exit(1); + process.exit(3); } console.log(JSON.stringify(result, null, 2)); @@ -200,13 +200,13 @@ async function main() { } } - // Export functions for use in other scripts export { extractJiraTicket, getFixVersions, parseVersion, - detectComponent + detectComponent, + main }; diff --git a/branch-suggestion/scripts/jira/jira-api.js b/branch-suggestion/scripts/jira/jira-api.js index 364f540..a749a0d 100755 --- a/branch-suggestion/scripts/jira/jira-api.js +++ b/branch-suggestion/scripts/jira/jira-api.js @@ -7,14 +7,13 @@ import readline from 'readline'; if (!process.env.JIRA_TOKEN) { dotenv.config(); } - // JIRA configuration const JIRA_BASE_URL = 'https://tyktech.atlassian.net'; -const JIRA_TOKEN = process.env.JIRA_TOKEN; // Pre-encoded base64(email:api_token) +// We read JIRA_TOKEN dynamically inside jiraAPI to allow testing // Debug logging (without exposing sensitive data) console.error('DEBUG: Environment check:'); -console.error(` JIRA_TOKEN: ${JIRA_TOKEN ? 'SET' : 'EMPTY'}`); +console.error(` JIRA_TOKEN: ${process.env.JIRA_TOKEN ? 'SET' : 'EMPTY'}`); console.error(` All JIRA env vars: ${Object.keys(process.env).filter(k => k.includes('JIRA')).join(', ')}`); // Extract JQL from URL or use directly @@ -37,13 +36,14 @@ function extractJQL(input) { // Make JIRA API request async function jiraAPI(endpoint, options = {}) { - if (!JIRA_TOKEN) { - throw new Error('JIRA_TOKEN must be set in .env file (pre-encoded base64(email:api_token))'); + const token = process.env.JIRA_TOKEN; + const email = process.env.JIRA_USER_EMAIL; + if (!token || !email) { + throw new Error('JIRA_TOKEN and JIRA_USER_EMAIL must be set in environment variables'); } - // Token is already base64 encoded, use directly - const auth = JIRA_TOKEN; - + // Token is raw API token, encode it with email + const auth = Buffer.from(`${email}:${token}`).toString('base64'); const response = await fetch(`${JIRA_BASE_URL}/rest/api/3${endpoint}`, { ...options, headers: { @@ -135,8 +135,8 @@ async function main() { console.log(' node jira-api.js "project = TT AND status != closed"'); console.log(' node jira-api.js "https://tyktech.atlassian.net/jira/software/c/projects/TT/issues/?jql=..."'); console.log('\nMake sure to set in .env:'); - console.log(' JIRA_TOKEN='); - console.log('\nNote: JIRA_TOKEN should be pre-encoded as base64(email:api_token)'); + console.log(' JIRA_TOKEN='); + console.log(' JIRA_USER_EMAIL='); process.exit(1); } @@ -225,7 +225,7 @@ async function main() { } catch (error) { console.error('\n❌ Error:', error.message); - console.error('\nMake sure you have set JIRA_TOKEN in your .env file (pre-encoded as base64(email:api_token))'); + console.error('\nMake sure you have set JIRA_TOKEN and JIRA_USER_EMAIL in your environment variables'); process.exit(1); } } @@ -260,7 +260,8 @@ export { searchIssues, getIssue, formatIssue, - exportToCSV + exportToCSV, + main }; // Run main if executed directly