Skip to content
9,004 changes: 9,004 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/jira/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@locusai/locus-jira",
"version": "0.26.2",
"version": "0.26.3",
"description": "Fetch and execute Jira issues with Locus",
"type": "module",
"bin": {
Expand Down
4 changes: 2 additions & 2 deletions packages/jira/src/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
* Also provides token refresh with rotating refresh token support.
*/

import { randomBytes } from "node:crypto";
import {
createServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { randomBytes } from "node:crypto";
import axios from "axios";
import open from "open";
import type { JiraOAuthCredentials } from "../types.js";
Expand Down Expand Up @@ -115,7 +115,7 @@ export async function startOAuthFlow(
process.stderr.write(" Opening browser for authorization...\n");
await open(authUrl);
process.stderr.write(
" If the browser did not open, visit:\n" + ` ${authUrl}\n\n`
` If the browser did not open, visit:\n ${authUrl}\n\n`
);

const code = await waitForCallback(config.callbackPort, state);
Expand Down
75 changes: 68 additions & 7 deletions packages/jira/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* and error handling. Supports OAuth, API Token (Cloud), and PAT (Server/DC).
*/

import axios, { type AxiosInstance, type AxiosError } from "axios";
import axios, { type AxiosError, type AxiosInstance } from "axios";
import {
loadJiraConfig,
saveCredentials,
Expand Down Expand Up @@ -63,6 +63,45 @@ async function fetchAllPages<T>(fetcher: PageFetcher<T>): Promise<T[]> {
return all;
}

// ─── Token-based Pagination ─────────────────────────────────────────────────

interface TokenPaginatedResponse<T> {
issues?: T[];
nextPageToken?: string;
isLast?: boolean;
}

type TokenPageFetcher<T> = (
nextPageToken: string | undefined,
maxResults: number
) => Promise<TokenPaginatedResponse<T>>;

/**
* Generic token-based pagination helper for endpoints like /search/jql.
* Fetches all pages until `nextPageToken` is absent or `isLast` is true.
*/
async function fetchAllPagesTokenBased<T>(
fetcher: TokenPageFetcher<T>
): Promise<T[]> {
const all: T[] = [];
let nextPageToken: string | undefined;

let hasMore = true;
while (hasMore) {
const page = await fetcher(nextPageToken, PAGE_SIZE);
const items = page.issues ?? [];
all.push(...items);

if (!page.nextPageToken || page.isLast || items.length === 0) {
hasMore = false;
} else {
nextPageToken = page.nextPageToken;
}
}

return all;
}

// ─── Base URL Resolution ────────────────────────────────────────────────────

function resolveBaseUrl(credentials: JiraCredentials): string {
Expand Down Expand Up @@ -230,14 +269,30 @@ export class JiraClient {
}

/**
* GET /rest/api/3/search?jql={jql}&startAt={}&maxResults={}
* Search issues via JQL.
* Cloud (OAuth / API Token): GET /rest/api/3/search/jql with token-based pagination.
* Server/DC (PAT): GET /rest/api/2/search with offset-based pagination.
* When fetchAll is true, paginates through all results.
*/
async searchIssues(
jql: string,
opts?: { startAt?: number; maxResults?: number; fetchAll?: boolean }
): Promise<JiraSearchResult> {
const isCloud = this.credentials.method !== "pat";

if (opts?.fetchAll) {
if (isCloud) {
const issues = await fetchAllPagesTokenBased<JiraIssue>(
(nextPageToken, maxResults) =>
this.api
.get("/search/jql", {
params: { jql, maxResults, nextPageToken },
})
.then((r) => r.data as TokenPaginatedResponse<JiraIssue>)
);
return { issues };
}

const issues = await fetchAllPages<JiraIssue>((startAt, maxResults) =>
this.api
.get("/search", { params: { jql, startAt, maxResults } })
Expand All @@ -251,6 +306,16 @@ export class JiraClient {
};
}

if (isCloud) {
const response = await this.api.get("/search/jql", {
params: {
jql,
maxResults: opts?.maxResults ?? PAGE_SIZE,
},
});
return response.data as JiraSearchResult;
}

const response = await this.api.get("/search", {
params: {
jql,
Expand Down Expand Up @@ -327,11 +392,7 @@ export class JiraClient {
/**
* POST /rest/api/3/issue/{key}/remotelink
*/
async addRemoteLink(
key: string,
title: string,
url: string
): Promise<void> {
async addRemoteLink(key: string, title: string, url: string): Promise<void> {
await this.api.post(`/issue/${encodeURIComponent(key)}/remotelink`, {
object: {
url,
Expand Down
8 changes: 5 additions & 3 deletions packages/jira/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,11 @@ export interface JiraIssue {

export interface JiraSearchResult {
issues: JiraIssue[];
total: number;
startAt: number;
maxResults: number;
nextPageToken?: string;
isLast?: boolean;
total?: number;
startAt?: number;
maxResults?: number;
}

// ─── Transitions ────────────────────────────────────────────────────────────
Expand Down
4 changes: 1 addition & 3 deletions packages/jira/src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,7 @@ function buildComment(pr: GitHubPR): string {
// ─── Dry Run ────────────────────────────────────────────────────────────────

function printDryRun(actions: SyncAction[]): void {
process.stderr.write(
`\n Dry run — ${actions.length} issue(s) analyzed:\n`
);
process.stderr.write(`\n Dry run — ${actions.length} issue(s) analyzed:\n`);
process.stderr.write(` ${"═".repeat(70)}\n`);

for (const action of actions) {
Expand Down
6 changes: 1 addition & 5 deletions packages/jira/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { readLocusConfig } from "@locusai/sdk";
import type {
JiraConfig,
JiraCredentials,
TransitionOnPR,
} from "./types.js";
import type { JiraConfig, JiraCredentials, TransitionOnPR } from "./types.js";

const DEFAULT_JIRA_CONFIG: JiraConfig = {
auth: null,
Expand Down
23 changes: 23 additions & 0 deletions packages/jira/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ export class JiraRateLimitError extends Error {
}
}

export class JiraApiGoneError extends Error {
constructor(
message = "Jira API endpoint has been removed (410 Gone). The endpoint you are using has been deprecated and is no longer available."
) {
super(message);
this.name = "JiraApiGoneError";
}
}

// ─── Error Handler ──────────────────────────────────────────────────────────

/**
Expand All @@ -65,6 +74,10 @@ export function handleJiraError(error: AxiosError): never {
throw new JiraPermissionError(`Jira permission denied (403): ${detail}`);
case 404:
throw new JiraNotFoundError(`Jira resource not found (404): ${detail}`);
case 410:
throw new JiraApiGoneError(
`Jira API endpoint has been removed (410 Gone): ${detail}`
);
case 429:
throw new JiraRateLimitError(
`Jira API rate limit exceeded (429): ${detail}`
Expand Down Expand Up @@ -119,6 +132,16 @@ export function handleCommandError(err: unknown): never {
process.exit(1);
}

if (err instanceof JiraApiGoneError) {
process.stderr.write(
"\n A Jira API endpoint has been removed (410 Gone).\n" +
" This usually means the endpoint was deprecated by Atlassian.\n" +
" Update your package to get the latest API support:\n" +
" npm update @locusai/locus-jira\n\n"
);
process.exit(1);
}

if (
msg.includes("ECONNREFUSED") ||
msg.includes("ENOTFOUND") ||
Expand Down
1 change: 1 addition & 0 deletions packages/jira/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
export {
handleCommandError,
handleJiraError,
JiraApiGoneError,
JiraAuthError,
JiraNotFoundError,
JiraPermissionError,
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp/src/commands/add-custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
*/

import { createInterface } from "node:readline";
import { McpConfigStore } from "../config/store.js";
import { McpTestClient } from "../client/test-client.js";
import { syncAll } from "../bridges/sync.js";
import { McpTestClient } from "../client/test-client.js";
import { McpConfigStore } from "../config/store.js";
import type {
McpHttpServerConfig,
McpServerConfig,
Expand Down
5 changes: 3 additions & 2 deletions packages/mcp/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@
*/

import { createInterface } from "node:readline";
import { McpConfigStore } from "../config/store.js";
import { McpTestClient } from "../client/test-client.js";
import { syncAll } from "../bridges/sync.js";
import { McpTestClient } from "../client/test-client.js";
import { McpConfigStore } from "../config/store.js";
import {
getTemplate,
listTemplates,
resolveTemplate,
} from "../registry/templates.js";

// ─── Arg parsing ────────────────────────────────────────────────────────────

interface AddFlags {
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* the corresponding `locus-<name>` entries from provider configs.
*/

import { McpConfigStore } from "../config/store.js";
import { syncAll } from "../bridges/sync.js";
import { McpConfigStore } from "../config/store.js";

// ─── Command ────────────────────────────────────────────────────────────────

Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import { join } from "node:path";
import { fromLocusName } from "../bridges/bridge.js";
import { ClaudeBridge } from "../bridges/claude.js";
import { CodexBridge } from "../bridges/codex.js";
import type { ProviderBridge } from "../types.js";
import { filterServersForProvider } from "../bridges/sync.js";
import { McpConfigStore } from "../config/store.js";
import type { ProviderBridge } from "../types.js";

// ─── Command ────────────────────────────────────────────────────────────────

Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
* changes, and `--force` to overwrite even if no changes are detected.
*/

import type { ProviderBridge } from "../types.js";
import { ClaudeBridge } from "../bridges/claude.js";
import { CodexBridge } from "../bridges/codex.js";
import type { ProviderName } from "../bridges/sync.js";
import { filterServersForProvider, syncProvider } from "../bridges/sync.js";
import { McpConfigStore } from "../config/store.js";
import type { ProviderBridge } from "../types.js";

// ─── Arg parsing ────────────────────────────────────────────────────────────

Expand Down
19 changes: 9 additions & 10 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ export type {
McpTestClientOptions,
} from "./client/test-client.js";
export { McpTestClient } from "./client/test-client.js";
// Re-export command handlers for use by locus CLI
export { addCommand } from "./commands/add.js";
export { addCustomCommand } from "./commands/add-custom.js";
export { listCommand } from "./commands/list.js";
export { removeCommand } from "./commands/remove.js";
export { statusCommand } from "./commands/status.js";
export { syncCommand } from "./commands/sync.js";
export { testCommand } from "./commands/test.js";
export { disableCommand, enableCommand } from "./commands/toggle.js";
export {
McpConfigSchema,
McpHttpServerSchema,
Expand Down Expand Up @@ -79,16 +88,6 @@ export type {
SyncResult,
} from "./types.js";

// Re-export command handlers for use by locus CLI
export { addCommand } from "./commands/add.js";
export { addCustomCommand } from "./commands/add-custom.js";
export { listCommand } from "./commands/list.js";
export { removeCommand } from "./commands/remove.js";
export { statusCommand } from "./commands/status.js";
export { syncCommand } from "./commands/sync.js";
export { testCommand } from "./commands/test.js";
export { disableCommand, enableCommand } from "./commands/toggle.js";

export async function main(args: string[]): Promise<void> {
const command = args[0] ?? "help";
const subArgs = args.slice(1);
Expand Down