From a3b83e52f1916e8b1d6f65a76c1db7ca9fe68316 Mon Sep 17 00:00:00 2001 From: 4 Bytes Robby Date: Fri, 4 Sep 2026 15:25:27 +0200 Subject: [PATCH] fix: migrate JQL search to /rest/api/3/search/jql #6 --- README.md | 2 +- package.json | 2 +- src/jira-client.ts | 52 +++++++++++--- src/tools/jira-search-issues.ts | 23 ++++--- src/types.ts | 13 ++++ tests/jira-client.test.ts | 117 +++++++++++++++++++++++++++++++- 6 files changed, 189 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 2b82ccd..0e691df 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Credentials priority: JSON file > environment variables. | Tool | Description | |------|-------------| | `jira_get_issue` | Read issue fields (status, summary, assignee, labels) | -| `jira_search_issues` | Search issues with JQL (text, status, assignee filters) | +| `jira_search_issues` | Search issues with JQL (text, status, assignee filters), cursor-paged | | `jira_search_users` | Find users by name/email — returns account IDs for assignment | | `jira_assign_issue` | Assign/unassign issue to user | | `jira_add_comment` | Add ADF-formatted comment (supports Markdown → ADF conversion) | diff --git a/package.json b/package.json index e3c791a..99f04b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@four-bytes/four-opencode-jira", - "version": "0.3.0", + "version": "0.4.0", "description": "Jira REST API integration tools for opencode agents — 6 custom tools (get_issue, add_comment, transition, extract_key, sync_progress, validate_config), project-local .opencode/jira.json config, optional hook automation. Source: Perplexity P49.", "license": "Apache-2.0", "type": "module", diff --git a/src/jira-client.ts b/src/jira-client.ts index 1038f8c..6e6782a 100644 --- a/src/jira-client.ts +++ b/src/jira-client.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) 2025-2026 Four Bytes -import type { JiraConfig, JiraIssue, CommentResult, Transition, JiraError, CreatedIssue } from './types'; +import type { JiraConfig, JiraIssue, CommentResult, Transition, JiraError, CreatedIssue, SearchResult } from './types'; import { getCredential } from './config'; import { logDebugEvent } from './debug-logger'; @@ -9,6 +9,15 @@ import { logDebugEvent } from './debug-logger'; // JiraClient — Jira REST API v3 wrapper // ──────────────────────────────────────────────────────────────── +/** Hard cap the enhanced-search endpoint enforces on `maxResults`. */ +const MAX_SEARCH_RESULTS = 5000; + +/** + * Fields requested by default in a JQL search. + * `/rest/api/3/search/jql` returns issue ids only unless fields are named. + */ +const DEFAULT_SEARCH_FIELDS = ['summary', 'status', 'assignee']; + export class JiraClient { private baseUrl: string; private authHeader: string; @@ -303,18 +312,41 @@ export class JiraClient { /** * Search Jira issues with JQL. - * GET /rest/api/3/search?jql={jql}&fields=summary,status,assignee + * POST /rest/api/3/search/jql + * + * Replaces GET /rest/api/3/search, which Atlassian removed from Jira Cloud. + * The enhanced-search endpoint differs in three ways that matter here: + * - paging is a cursor (`nextPageToken`), not `startAt` + * - the response carries no `total` + * - fields must be named explicitly, or only issue ids come back */ - async searchIssues(jql: string, maxResults: number = 10): Promise { - const url = `${this.baseUrl}/rest/api/3/search?jql=${encodeURIComponent(jql)}&fields=summary,status,assignee&maxResults=${maxResults}`; + async searchIssues( + jql: string, + maxResults: number = 10, + options: { fields?: string[]; nextPageToken?: string } = {}, + ): Promise { + const url = `${this.baseUrl}/rest/api/3/search/jql`; + + // The endpoint rejects maxResults outside 1..5000. + const limit = Math.min(Math.max(Math.trunc(maxResults) || 1, 1), MAX_SEARCH_RESULTS); + + const payload: Record = { + jql, + maxResults: limit, + fields: options.fields ?? DEFAULT_SEARCH_FIELDS, + }; + if (options.nextPageToken) payload.nextPageToken = options.nextPageToken; try { + // POST rather than GET — long JQL expressions blow past URL length limits. const response = await fetch(url, { - method: 'GET', + method: 'POST', headers: { 'Authorization': this.authHeader, 'Accept': 'application/json', + 'Content-Type': 'application/json', }, + body: JSON.stringify(payload), }); if (!response.ok) { @@ -327,9 +359,13 @@ export class JiraClient { }; } - const data = await response.json() as { issues: JiraIssue[] }; - logDebugEvent('jira_client.searchIssues.success', { jql, count: data.issues?.length || 0 }); - return data.issues || []; + const data = await response.json() as { issues?: JiraIssue[]; nextPageToken?: string; isLast?: boolean }; + const issues = data.issues || []; + // `isLast` is not always present — absence of a token means no more pages. + const isLast = data.isLast ?? !data.nextPageToken; + + logDebugEvent('jira_client.searchIssues.success', { jql, count: issues.length, isLast }); + return { issues, nextPageToken: data.nextPageToken, isLast }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); logDebugEvent('jira_client.searchIssues.exception', { jql, error: msg }); diff --git a/src/tools/jira-search-issues.ts b/src/tools/jira-search-issues.ts index 1ad7b87..14633a4 100644 --- a/src/tools/jira-search-issues.ts +++ b/src/tools/jira-search-issues.ts @@ -7,17 +7,18 @@ import { createJiraClient } from '../jira-client'; import { logDebugEvent } from '../debug-logger'; export const jiraSearchIssuesTool = tool({ - description: 'Search Jira issues using JQL (Jira Query Language). Use text ~ "keyword" for text search, status = "In Progress" for status filter, project = "PROJ" for project filter. Combine with AND/OR.', + description: 'Search Jira issues using JQL (Jira Query Language). Use text ~ "keyword" for text search, status = "In Progress" for status filter, project = "PROJ" for project filter. Combine with AND/OR. Results are paged — pass the returned nextPageToken to fetch the following page.', args: { jql: tool.schema.string().describe('JQL query (e.g. \'text ~ "footer" AND status = "Code Review" ORDER BY updated DESC\')'), - maxResults: tool.schema.number().optional().describe('Max results (default: 10)'), + maxResults: tool.schema.number().optional().describe('Max results per page (default: 10, max: 5000)'), + nextPageToken: tool.schema.string().optional().describe('Page token returned by a previous search, to fetch the next page'), }, async execute(args, ctx) { - const { jql, maxResults } = args; + const { jql, maxResults, nextPageToken } = args; - logDebugEvent('jira_search_issues.start', { jql, maxResults }); + logDebugEvent('jira_search_issues.start', { jql, maxResults, paged: Boolean(nextPageToken) }); try { const config = loadConfig(ctx.directory); @@ -26,14 +27,14 @@ export const jiraSearchIssuesTool = tool({ const client = createJiraClient(config); if (!client) return 'Jira client not configured.'; - const result = await client.searchIssues(jql, maxResults || 10); + const result = await client.searchIssues(jql, maxResults || 10, { nextPageToken }); - if (typeof result === 'object' && 'error' in result && result.error) { + if ('error' in result) { return `Search failed: ${result.message}`; } - const issues = result as Array; - if (!issues || issues.length === 0) { + const { issues, isLast, nextPageToken: nextToken } = result; + if (issues.length === 0) { return `No issues found for JQL: ${jql}`; } @@ -43,8 +44,12 @@ export const jiraSearchIssuesTool = tool({ const assignee = issue.fields?.assignee?.displayName || 'unassigned'; lines.push(` - ${issue.key}: ${issue.fields?.summary || '(no summary)'} [${status}] (${assignee})`); } + // The API reports no total — only whether another page exists. + if (!isLast && nextToken) { + lines.push(`\nMore results available — nextPageToken: ${nextToken}`); + } - logDebugEvent('jira_search_issues.success', { jql, count: issues.length }); + logDebugEvent('jira_search_issues.success', { jql, count: issues.length, isLast }); return lines.join('\n'); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/types.ts b/src/types.ts index 9265e4f..27d7594 100644 --- a/src/types.ts +++ b/src/types.ts @@ -96,6 +96,19 @@ export interface Transition { name: string; } +/** + * Result of a JQL search via POST /rest/api/3/search/jql. + * + * The enhanced-search endpoint pages with an opaque cursor instead of + * `startAt`, and returns no `total` — use the approximate-count endpoint + * if a count is needed. + */ +export interface SearchResult { + issues: JiraIssue[]; + nextPageToken?: string; + isLast: boolean; +} + export interface CreatedIssue { id: string; key: string; diff --git a/tests/jira-client.test.ts b/tests/jira-client.test.ts index 0c5bb47..aab722a 100644 --- a/tests/jira-client.test.ts +++ b/tests/jira-client.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) 2025-2026 Four Bytes -import { describe, it, expect } from 'bun:test'; +import { describe, it, expect, afterEach } from 'bun:test'; import { JiraClient, createJiraClient } from '../src/jira-client'; import { DEFAULT_CONFIG } from '../src/types'; @@ -150,3 +150,118 @@ describe('createJiraClient', () => { else delete process.env.JIRA_API_TOKEN; }); }); + +// ──────────────────────────────────────────────────────────────── +// JQL search — POST /rest/api/3/search/jql (replaces removed /search) +// ──────────────────────────────────────────────────────────────── + +describe('JiraClient.searchIssues', () => { + const client = new JiraClient('https://jira.example.com', 'user@example.com', 'secret'); + const realFetch = globalThis.fetch; + + /** Stub global fetch, capture the request, reply with `body`. */ + function stubFetch(body: unknown, status = 200) { + const calls: Array<{ url: string; init: RequestInit }> = []; + globalThis.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as typeof fetch; + return calls; + } + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + it('posts to /rest/api/3/search/jql with jql, maxResults and explicit fields', async () => { + const calls = stubFetch({ issues: [], isLast: true }); + + await client.searchIssues('project = TEST', 25); + + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('https://jira.example.com/rest/api/3/search/jql'); + expect(calls[0]!.init.method).toBe('POST'); + const payload = JSON.parse(String(calls[0]!.init.body)); + expect(payload.jql).toBe('project = TEST'); + expect(payload.maxResults).toBe(25); + // Without an explicit field list the endpoint returns ids only. + expect(payload.fields).toEqual(['summary', 'status', 'assignee']); + expect(payload.nextPageToken).toBeUndefined(); + }); + + it('clamps maxResults to the 1..5000 range the endpoint accepts', async () => { + let calls = stubFetch({ issues: [] }); + await client.searchIssues('project = TEST', 99999); + expect(JSON.parse(String(calls[0]!.init.body)).maxResults).toBe(5000); + + calls = stubFetch({ issues: [] }); + await client.searchIssues('project = TEST', 0); + expect(JSON.parse(String(calls[0]!.init.body)).maxResults).toBe(1); + }); + + it('sends nextPageToken when paging', async () => { + const calls = stubFetch({ issues: [], isLast: true }); + + await client.searchIssues('project = TEST', 10, { nextPageToken: 'CAEaAggD' }); + + expect(JSON.parse(String(calls[0]!.init.body)).nextPageToken).toBe('CAEaAggD'); + }); + + it('honours a custom field list', async () => { + const calls = stubFetch({ issues: [] }); + + await client.searchIssues('project = TEST', 10, { fields: ['summary', 'labels'] }); + + expect(JSON.parse(String(calls[0]!.init.body)).fields).toEqual(['summary', 'labels']); + }); + + it('returns issues plus the paging cursor', async () => { + stubFetch({ + issues: [{ id: '1', key: 'TEST-1', fields: { summary: 'One', status: { name: 'Open', id: '1' }, labels: [] } }], + nextPageToken: 'tok-2', + isLast: false, + }); + + const result = await client.searchIssues('project = TEST'); + + expect('error' in result).toBe(false); + if ('error' in result) return; + expect(result.issues).toHaveLength(1); + expect(result.issues[0]!.key).toBe('TEST-1'); + expect(result.nextPageToken).toBe('tok-2'); + expect(result.isLast).toBe(false); + }); + + it('treats a missing isLast as the final page when no token is returned', async () => { + stubFetch({ issues: [] }); + + const result = await client.searchIssues('project = TEST'); + + expect('error' in result).toBe(false); + if ('error' in result) return; + expect(result.isLast).toBe(true); + expect(result.issues).toEqual([]); + }); + + it('returns a structured error on a non-OK response', async () => { + stubFetch({ errorMessages: ['bad jql'] }, 400); + + const result = await client.searchIssues('nonsense'); + + expect('error' in result).toBe(true); + if (!('error' in result)) return; + expect(result.status).toBe(400); + }); + + it('returns a structured error for a bad URL instead of throwing', async () => { + const broken = new JiraClient('https://does-not-exist.invalid', 'u@e.com', 'p'); + const result = await broken.searchIssues('project = TEST'); + + expect('error' in result).toBe(true); + if (!('error' in result)) return; + expect(result.message).toBeTruthy(); + }); +});