Jira Archived Issues Report


Intro video is not displayed because you have disallowed functional cookies.
Get Started

Not the template you're looking for? Browse more.

About the template


Password-protected, paginated HTML report of archived Jira Cloud issues, served from a Generic event listener. Get started to learn more.

About ScriptRunner Connect


What is ScriptRunner Connect?

ScriptRunner Connect is an AI assisted code-first (JavaScript/TypeScript) integration platform (iPaaS) for building complex integrations and automations.

Can I try it out for free?

Yes. ScriptRunner Connect comes with a forever free tier.

Can I customize the integration logic?

Absolutely. The main value proposition of ScriptRunner Connect is that you'll get full access to the code that is powering the integration, which means you can make any changes to the the integration logic yourself.

Can I change the integration to communicate with additional apps?

Yes. Since ScriptRunner Connect specializes in enabling complex integrations, you can easily change the integration logic to connect to as many additional apps as you need, no limitations.

What if I don't feel comfortable making changes to the code?

First you can try out our AI assistant which can help you understand what the code does, and also help you make changes to the code. Alternatively you can hire our professionals to make the changes you need or build new integrations from scratch.

Do I have to host it myself?

No. ScriptRunner Connect is a fully managed SaaS (Software-as-a-Service) product.

What about security?

ScriptRunner Connect is ISO 27001 and SOC 2 certified. Learn more about our security.

Template Content


README

Scripts

TypeScriptArchivedIssuesReport
Sync HTTP Event

README


Jira Archived Issues Report

Overview

Jira Cloud has no search or filter for archived issues, and its REST API has no endpoint that lists them. This workspace serves a web page that does. Open its URL in a browser, sign in with the report password, and you get a table of every archived issue on the Jira site, 50 per page by default.

The page shows key (linked to the issue), project, summary, issue type, status, priority, assignee, reporter, created, updated, archived date and who archived it. You can narrow the list to one project and search for words in the summary.

The data is read live from Jira on every page load, using the same GraphQL query Jira's own "Archived work items" admin page uses.

Setup

Connectors

  • Jira Elrond 5 (Generic). Base URL https://<your-site>.atlassian.net, Basic authentication with an Atlassian account email as the username and a classic API token as the password. Create the token at https://id.atlassian.com/manage-profile/security/api-tokens. A scoped API token does not work against the site URL. The account needs permission to see archived issues, so use a Jira admin, or a project admin when only their projects matter.

API connections

  • jira: uses the Generic connector above.

Event listeners

  • Sync HTTP Event (Generic) runs ArchivedIssuesReport. Its URL is the report's address; share it only with people who should see the report. Nothing has to be registered in another app.

Parameters

ParameterTypeDescriptionExample
REPORT_PASSWORDText (masked)Password typed on the login page. Required. Changing it signs everyone out at once.a long passphrase
PAGE_SIZENumberArchived issues per page, 1 to 100. Default 50.50
SESSION_HOURSNumberHow long a login lasts before the page asks for the password again. Default 8.8
MAX_LOGIN_ATTEMPTSNumberFailed logins allowed from one IP address within 15 minutes before that address is locked out. Default 5.5

Usage

  1. Open the event listener's URL in a browser.
  2. Enter the report password.
  3. The table lists archived issues from all projects. Pick a project or type words from a summary and press Show to narrow it. Use Previous and Next to move between pages.
  4. Sign out from the top right, or close the browser. The login expires after SESSION_HOURS either way.

To check it is working, archive an issue in Jira and reload the page; it appears at the top of the list.

Considerations

  • Beta Jira API. Atlassian marks the archived issues GraphQL query as beta. It can change or disappear without notice. If it does, the page shows the error Jira returned instead of the table, and the script needs updating.
  • Search. The search box matches words in the summary. It does not match issue keys.
  • Security. The password is checked by the script and a signed, HttpOnly session cookie limited to this listener's path keeps people signed in. Repeated wrong passwords lock that IP address out for 15 minutes. Anyone holding both the URL and the password sees every archived issue the connector's account can see, so treat the password like an admin credential and rotate it when people leave.
  • Permissions. The report shows what the connector's account can see, not what the person viewing it can see in Jira.
  • Projects. The project list is cached for 10 minutes, so a newly created project can take that long to appear in the picker.

Troubleshooting

  • "Could not load archived issues: Jira answered 401": the API token on the connector expired or was revoked. Create a new one and update the connector.
  • The page asks for the password again and again: the browser is blocking cookies for event.scriptrunnerconnect.com. Allow them for that site.
  • "Too many failed attempts": wait 15 minutes, or raise MAX_LOGIN_ATTEMPTS.
  • Parameter REPORT_PASSWORD is not set: set it on the environment. Without it the page refuses every request.
  • For anything else, open the workspace's invocation logs and look at the ArchivedIssuesReport run that failed.

API Connections


TypeScriptArchivedIssuesReport

import { HttpEventRequest, HttpEventResponse, buildHTMLResponse } from '@sr-connect/generic-app/events/http';
import { attemptLogin, hasValidSession, logoutCookie, readAuthSettings, readFormBody } from './Utils/Auth';
import { errorPage, loginPage, ReportProject, reportPage } from './Utils/Html';
import { getArchivedIssues, getProjects, JiraRequestError } from './Utils/Jira';

/**
 * Serves a password-protected, paginated HTML report of archived Jira Cloud issues.
 *
 * GET shows the login form or, with a valid session, the report. POST handles the login and logout forms.
 * Report query parameters: project (key, empty for all), q (summary search), after (page cursor) and
 * cursors (the cursors of earlier pages, so Previous can step back without Jira supporting backwards paging).
 */
export default async function (event: HttpEventRequest, context: Context<EV>): Promise<HttpEventResponse> {
    const settings = readAuthSettings(context.environment.vars);
    const basePath = event.path;

    if (event.method === 'POST') {
        const action = readFormBody(event).get('action');
        if (action === 'logout') {
            return redirect(basePath, logoutCookie(event));
        }
        const login = await attemptLogin(event, settings);
        if (login.ok) {
            return redirect(basePath, login.cookie);
        }
        const message = login.lockedOut
            ? 'Too many failed attempts. Try again in 15 minutes.'
            : 'That password is not correct.';
        return html(loginPage(basePath, message), login.lockedOut ? 429 : 401);
    }

    if (event.method !== 'GET') {
        return html(errorPage(`Method ${event.method} is not supported.`), 405);
    }
    if (!(await hasValidSession(event, settings))) {
        return html(loginPage(basePath));
    }

    return html(await renderReport(event, basePath, pageSize(context.environment.vars.PAGE_SIZE)));
}

async function renderReport(event: HttpEventRequest, basePath: string, size: number): Promise<string> {
    const params = event.queryStringParams;
    const projectKey = params.project || undefined;
    const search = (params.q ?? '').trim();
    const after = params.after || undefined;
    // Page 1 is recorded as an empty cursor, so the list is never empty once you have moved past it.
    const cursors = params.cursors === undefined ? [] : params.cursors.split(',');

    let projects: ReportProject[] = [];
    const base = () => ({
        basePath,
        projects,
        project: projects.find((p) => p.key === projectKey),
        search,
        pageSize: size,
        pageNumber: cursors.length + 1,
    });

    try {
        // Listing projects is a Jira call too, so an expired token shows on the page instead of failing the run.
        projects = await getProjects();
        const page = await getArchivedIssues({ projectKey, search, pageSize: size, after });
        const link = (pageAfter: string | undefined, pageCursors: string[]): string => {
            const query = new URLSearchParams();
            if (projectKey) query.set('project', projectKey);
            if (search) query.set('q', search);
            if (pageAfter) query.set('after', pageAfter);
            if (pageCursors.length) query.set('cursors', pageCursors.join(','));
            const qs = query.toString();
            return qs ? `${basePath}?${qs}` : basePath;
        };

        return reportPage({
            ...base(),
            issues: page.issues,
            totalCount: page.totalCount,
            previousHref: cursors.length ? link(cursors[cursors.length - 1], cursors.slice(0, -1)) : undefined,
            nextHref: page.hasNextPage && page.endCursor ? link(page.endCursor, [...cursors, after ?? '']) : undefined,
        });
    } catch (e) {
        // Jira refusing the query is an expected case for a beta API: show it on the page rather than a blank error.
        if (!(e instanceof JiraRequestError)) throw e;
        return reportPage({ ...base(), issues: [], error: `Could not load archived issues: ${e.message}` });
    }
}

function redirect(location: string, cookie: string): HttpEventResponse {
    return { status: 303, headers: { Location: location, 'Set-Cookie': cookie, 'Cache-Control': 'no-store' } };
}

function html(body: string, status = 200): HttpEventResponse {
    const response = buildHTMLResponse(body);
    return {
        ...response,
        status,
        headers: {
            ...response.headers,
            // Archived issue data must not linger in shared caches or be framed by another site.
            'Cache-Control': 'no-store',
            'X-Frame-Options': 'DENY',
            'Referrer-Policy': 'no-referrer',
        },
    };
}

function pageSize(value: number | undefined): number {
    if (value === undefined || !Number.isFinite(value)) return 50;
    return Math.min(100, Math.max(1, Math.round(value)));
}
TypeScriptUtils/Auth

import { HttpEventRequest } from '@sr-connect/generic-app/events/http';
import { convertBase64ToText } from '@sr-connect/convert';
import { createRecordStorage } from '@sr-connect/record-storage';

const COOKIE_NAME = 'src_archived_issues_session';
const SIGNING_KEY_RECORD = 'session-signing-key';
const LOCKOUT_WINDOW_SECONDS = 15 * 60;

// The signing key never leaves record storage, so a session cookie cannot be forged even by someone who knows the password.
const secureStorage = createRecordStorage({ scope: 'workspace', secure: true });
const storage = createRecordStorage({ retryOn429: { enabled: true, verbose: false } });

export interface AuthSettings {
    password: string;
    sessionHours: number;
    maxLoginAttempts: number;
}

export function readAuthSettings(vars: EV): AuthSettings {
    const password = vars.REPORT_PASSWORD;
    if (!password) throw new Error('Parameter REPORT_PASSWORD is not set in this environment.');
    return {
        password,
        sessionHours: positiveOr(vars.SESSION_HOURS, 8),
        maxLoginAttempts: positiveOr(vars.MAX_LOGIN_ATTEMPTS, 5),
    };
}

/**
 * True when the request carries an unexpired session cookie signed for the current password.
 */
export async function hasValidSession(event: HttpEventRequest, settings: AuthSettings): Promise<boolean> {
    const cookie = readCookie(event, COOKIE_NAME);
    if (!cookie) return false;
    const [expiresAt, signature] = cookie.split('.');
    if (!expiresAt || !signature || Number(expiresAt) < Date.now()) return false;
    const expected = await sessionSignature(await signingKey(), expiresAt, settings.password);
    return constantTimeEquals(signature, expected);
}

export type LoginResult = { ok: true; cookie: string } | { ok: false; lockedOut: boolean };

/**
 * Checks the submitted password, counting failures per source IP so the form cannot be brute forced.
 */
export async function attemptLogin(event: HttpEventRequest, settings: AuthSettings): Promise<LoginResult> {
    const failureKey = `login-failures-${event.sourceIp.replace(/[^A-Za-z0-9]/g, '-')}`;
    const failures = (await storage.getValue<number>(failureKey)) ?? 0;
    if (failures >= settings.maxLoginAttempts) return { ok: false, lockedOut: true };

    const key = await signingKey();
    const submitted = readFormBody(event).get('password') ?? '';
    // Comparing fixed-length MACs keeps the comparison time independent of where the strings differ.
    const matches = constantTimeEquals(await hmac(key, submitted), await hmac(key, settings.password));
    if (!matches) {
        await storage.setValue(failureKey, failures + 1, { ttl: LOCKOUT_WINDOW_SECONDS });
        console.warn('Failed login from', event.sourceIp, 'attempt', failures + 1);
        return { ok: false, lockedOut: failures + 1 >= settings.maxLoginAttempts };
    }

    await storage.deleteValue(failureKey);
    const maxAgeSeconds = Math.round(settings.sessionHours * 3600);
    const expiresAt = String(Date.now() + maxAgeSeconds * 1000);
    const value = `${expiresAt}.${await sessionSignature(key, expiresAt, settings.password)}`;
    return { ok: true, cookie: sessionCookie(event, value, maxAgeSeconds) };
}

export function logoutCookie(event: HttpEventRequest): string {
    return sessionCookie(event, '', 0);
}

export function readFormBody(event: HttpEventRequest): URLSearchParams {
    if (event.body === undefined) return new URLSearchParams();
    if (event.bodyType === 'base64') return new URLSearchParams(convertBase64ToText(event.body as string));
    if (event.bodyType === 'text') return new URLSearchParams(event.body as string);
    return new URLSearchParams();
}

export function header(event: HttpEventRequest, name: string): string | undefined {
    const wanted = name.toLowerCase();
    const key = Object.keys(event.headers).find((k) => k.toLowerCase() === wanted);
    return key === undefined ? undefined : event.headers[key];
}

// Scoped to this listener's own path: the host is shared by every ScriptRunner Connect generic listener.
function sessionCookie(event: HttpEventRequest, value: string, maxAgeSeconds: number): string {
    return `${COOKIE_NAME}=${value}; Path=${event.path}; Max-Age=${maxAgeSeconds}; HttpOnly; Secure; SameSite=Lax`;
}

function readCookie(event: HttpEventRequest, name: string): string | undefined {
    const raw = header(event, 'cookie');
    if (!raw) return undefined;
    for (const part of raw.split(';')) {
        const [key, ...rest] = part.trim().split('=');
        if (key === name) return rest.join('=');
    }
    return undefined;
}

// Folding the password's fingerprint into the signature means changing REPORT_PASSWORD signs everyone out.
async function sessionSignature(key: string, expiresAt: string, password: string): Promise<string> {
    return hmac(key, `${expiresAt}.${await hmac(key, password)}`);
}

async function hmac(signingKey: string, text: string): Promise<string> {
    const key = await crypto.subtle.importKey(
        'raw',
        new TextEncoder().encode(signingKey),
        { name: 'HMAC', hash: 'SHA-256' },
        false,
        ['sign'],
    );
    const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text));
    return Array.from(new Uint8Array(mac), (b) => b.toString(16).padStart(2, '0')).join('');
}

async function signingKey(): Promise<string> {
    const stored = await secureStorage.getValue<string>(SIGNING_KEY_RECORD);
    if (stored) return stored;

    const fresh = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, '0')).join(
        '',
    );
    try {
        await secureStorage.setValue(SIGNING_KEY_RECORD, fresh, { denyUpdateOverwrite: true });
        return fresh;
    } catch (e) {
        // Another request created the key first; use theirs so both sessions verify.
        if (e instanceof Error && e.message.startsWith('Record already exists in given scope')) {
            const winner = await secureStorage.getValue<string>(SIGNING_KEY_RECORD);
            if (winner) return winner;
        }
        throw e;
    }
}

function constantTimeEquals(a: string, b: string): boolean {
    if (a.length !== b.length) return false;
    let diff = 0;
    for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
    return diff === 0;
}

function positiveOr(value: number | undefined, fallback: number): number {
    return value !== undefined && value > 0 ? value : fallback;
}
TypeScriptUtils/Html

export interface ReportIssue {
    key: string;
    url?: string;
    project?: string;
    summary?: string;
    issueType?: string;
    status?: string;
    statusCategory?: string;
    priority?: string;
    assignee?: string;
    reporter?: string;
    created?: string;
    updated?: string;
    archivedOn?: string;
    archivedBy?: string;
}

export interface ReportProject {
    id: string;
    key: string;
    name: string;
}

export interface ReportPage {
    basePath: string;
    projects: ReportProject[];
    project?: ReportProject;
    search: string;
    issues: ReportIssue[];
    totalCount?: number;
    pageNumber: number;
    pageSize: number;
    previousHref?: string;
    nextHref?: string;
    error?: string;
}

export function loginPage(basePath: string, message?: string): string {
    return layout(
        'Sign in',
        `<main class="login">
    <h1>Archived Jira issues</h1>
    <p class="muted">This report is password protected.</p>
    ${message ? `<p class="error" role="alert">${escape(message)}</p>` : ''}
    <form method="post" action="${escape(basePath)}">
        <input type="hidden" name="action" value="login">
        <label for="password">Password</label>
        <input id="password" name="password" type="password" autocomplete="current-password" required autofocus>
        <button type="submit">Sign in</button>
    </form>
</main>`,
    );
}

export function reportPage(page: ReportPage): string {
    const options = page.projects
        .map(
            (p) =>
                `<option value="${escape(p.key)}"${p.key === page.project?.key ? ' selected' : ''}>${escape(p.name)} (${escape(p.key)})</option>`,
        )
        .join('');

    const first = (page.pageNumber - 1) * page.pageSize + 1;
    const last = first + page.issues.length - 1;
    const range =
        page.issues.length === 0
            ? ''
            : `Showing ${first}–${last}${page.totalCount !== undefined ? ` of ${page.totalCount}` : ''}`;

    return layout(
        page.project ? `Archived issues · ${page.project.key}` : 'Archived issues',
        `<header>
    <h1>Archived Jira issues</h1>
    <form method="post" action="${escape(page.basePath)}"><input type="hidden" name="action" value="logout"><button class="link" type="submit">Sign out</button></form>
</header>
<form class="filters" method="get" action="${escape(page.basePath)}">
    <label>Project
        <select name="project" onchange="this.form.submit()">
            <option value="">All projects</option>${options}
        </select>
    </label>
    <label>Search
        <input type="search" name="q" value="${escape(page.search)}" placeholder="Words in the summary">
    </label>
    <button type="submit">Show</button>
</form>
${page.error ? `<p class="error" role="alert">${escape(page.error)}</p>` : ''}
${results(page, range)}`,
    );
}

export function errorPage(message: string): string {
    return layout(
        'Error',
        `<main class="login"><h1>Something went wrong</h1><p class="error">${escape(message)}</p></main>`,
    );
}

function results(page: ReportPage, range: string): string {
    if (page.issues.length === 0 && !page.error) {
        const scope = page.project ? `in ${escape(page.project.name)}` : 'on this site';
        return `<p class="muted">No archived issues ${scope}${page.search ? ' match that search' : ''}.</p>`;
    }
    const rows = page.issues
        .map(
            (i) => `<tr>
    <td class="nowrap">${i.url ? `<a href="${escape(i.url)}" target="_blank" rel="noopener">${escape(i.key)}</a>` : escape(i.key)}</td>
    <td class="nowrap">${escape(i.project)}</td>
    <td>${escape(i.summary)}</td>
    <td>${escape(i.issueType)}</td>
    <td>${i.status ? `<span class="status ${statusClass(i.statusCategory)}">${escape(i.status)}</span>` : ''}</td>
    <td>${escape(i.priority)}</td>
    <td>${escape(i.assignee) || '<span class="muted">Unassigned</span>'}</td>
    <td>${escape(i.reporter)}</td>
    <td class="nowrap">${date(i.created)}</td>
    <td class="nowrap">${date(i.updated)}</td>
    <td class="nowrap">${date(i.archivedOn)}</td>
    <td>${escape(i.archivedBy)}</td>
</tr>`,
        )
        .join('');

    return `<div class="table-wrap"><table>
    <thead><tr><th>Key</th><th>Project</th><th>Summary</th><th>Type</th><th>Status</th><th>Priority</th><th>Assignee</th><th>Reporter</th><th>Created</th><th>Updated</th><th>Archived</th><th>Archived by</th></tr></thead>
    <tbody>${rows}</tbody>
</table></div>
<nav class="pager">
    ${page.previousHref ? `<a class="button" href="${escape(page.previousHref)}">← Previous</a>` : '<span class="button disabled">← Previous</span>'}
    <span>Page ${page.pageNumber}${range ? ` · ${range}` : ''}</span>
    ${page.nextHref ? `<a class="button" href="${escape(page.nextHref)}">Next →</a>` : '<span class="button disabled">Next →</span>'}
</nav>`;
}

function statusClass(category: string | undefined): string {
    if (category === 'done') return 'done';
    if (category === 'indeterminate') return 'progress';
    return 'todo';
}

function date(value: string | undefined): string {
    return value ? escape(value.slice(0, 10)) : '';
}

function escape(value: string | undefined): string {
    return (value ?? '')
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
}

function layout(title: string, body: string): string {
    return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>${escape(title)}</title>
<style>
:root { --bg: #fff; --fg: #172b4d; --muted: #626f86; --line: #dcdfe4; --head: #f7f8f9; --accent: #0c66e4; --error: #ae2e24;
    --todo: #dcdfe4; --todo-fg: #44546f; --progress: #cce0ff; --progress-fg: #0055cc; --done: #baf3db; --done-fg: #216e4e; }
@media (prefers-color-scheme: dark) { :root { --bg: #1d2125; --fg: #dee4ea; --muted: #9fadbc; --line: #38414a; --head: #22272b;
    --accent: #579dff; --error: #fd9891; --todo: #38414a; --todo-fg: #b6c2cf; --progress: #09326c; --progress-fg: #85b8ff; --done: #164b35; --done-fg: #7ee2b8; } }
* { box-sizing: border-box; }
body { margin: 0; padding: 24px 16px; background: var(--bg); color: var(--fg); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
h1 { font-size: 20px; margin: 0; }
a { color: var(--accent); }
header { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
.login { max-width: 340px; margin: 10vh auto; display: grid; gap: 12px; }
.login form { display: grid; gap: 8px; }
.filters { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; margin-bottom: 16px; }
.filters label { display: grid; gap: 4px; font-weight: 600; font-size: 12px; color: var(--muted); }
input, select { font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; min-width: 220px; }
button, .button { font: inherit; border: 0; border-radius: 4px; padding: 7px 14px; background: var(--accent); color: #fff; cursor: pointer; text-decoration: none; display: inline-block; }
button.link { background: none; color: var(--accent); padding: 0; }
.button.disabled { opacity: .4; cursor: default; }
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 6px; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { background: var(--head); font-size: 12px; color: var(--muted); position: sticky; top: 0; }
tr:last-child td { border-bottom: 0; }
.nowrap { white-space: nowrap; }
.status { font-size: 11px; font-weight: 700; text-transform: uppercase; padding: 2px 6px; border-radius: 3px; white-space: nowrap; }
.status.todo { background: var(--todo); color: var(--todo-fg); }
.status.progress { background: var(--progress); color: var(--progress-fg); }
.status.done { background: var(--done); color: var(--done-fg); }
.pager { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 16px; }
.muted { color: var(--muted); }
.error { color: var(--error); }
</style>
</head>
<body>
${body}
</body>
</html>`;
}
TypeScriptUtils/Jira

import Jira from '../api/jira';
import { createRecordStorage } from '@sr-connect/record-storage';
import { ReportIssue, ReportProject } from './Html';

const PROJECTS_RECORD = 'jira-projects';
const CLOUD_ID_RECORD = 'jira-cloud-id';
const PROJECTS_TTL_SECONDS = 10 * 60;

const storage = createRecordStorage({ retryOn429: { enabled: true, verbose: false } });

/**
 * Jira's REST API has no way to list archived issues. This is the GraphQL query Jira's own "Archived work items" page
 * uses; Atlassian marks it beta, so it needs the opt-in directive and may change without notice.
 * Leaving filterBy out returns archived issues from every project; searchBy matches summary text, not issue keys.
 */
const ARCHIVED_ISSUES_QUERY = `query ArchivedIssuesReport($cloudId: ID!, $filterBy: JiraArchivedIssuesFilterInput, $searchBy: String, $first: Int, $after: String) {
    jira {
        archivedIssuesSearch(cloudId: $cloudId, filterBy: $filterBy, searchBy: $searchBy, first: $first, after: $after) @optIn(to: "JiraArchivedIssuesSearchQuery") {
            totalCount
            pageInfo { hasNextPage endCursor }
            edges {
                node {
                    key webUrl summary created updated archivedOn
                    issueType { name }
                    status { name statusCategory { key } }
                    priority { name }
                    assignee { name }
                    reporter { name }
                    archivedBy { name }
                    project { key name }
                }
            }
        }
    }
}`;

export interface ArchivedIssuesPage {
    issues: ReportIssue[];
    totalCount?: number;
    endCursor?: string;
    hasNextPage: boolean;
}

export class JiraRequestError extends Error {}

export async function getArchivedIssues(options: {
    projectKey?: string;
    search?: string;
    pageSize: number;
    after?: string;
}): Promise<ArchivedIssuesPage> {
    const response = await Jira.fetch('/gateway/api/graphql', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            operationName: 'ArchivedIssuesReport',
            query: ARCHIVED_ISSUES_QUERY,
            variables: {
                cloudId: await cloudId(),
                filterBy: options.projectKey ? { byProject: options.projectKey } : undefined,
                searchBy: options.search || undefined,
                first: options.pageSize,
                after: options.after || undefined,
            },
        }),
    });
    if (!response.ok) {
        throw new JiraRequestError(`Jira answered ${response.status} to the archived issues query.`);
    }
    const body = await response.json<ArchivedIssuesResponse>();
    const connection = body.data?.jira?.archivedIssuesSearch;
    if (body.errors?.length || !connection) {
        console.error('Archived issues query failed:', body.errors);
        throw new JiraRequestError(
            body.errors?.map((e) => e.message).join(' ') || 'Jira returned no archived issues data.',
        );
    }

    return {
        totalCount: connection.totalCount ?? undefined,
        endCursor: connection.pageInfo.endCursor ?? undefined,
        hasNextPage: connection.pageInfo.hasNextPage,
        issues: (connection.edges ?? []).flatMap((edge) => (edge?.node ? [toReportIssue(edge.node)] : [])),
    };
}

/**
 * Every project the connector's account can browse, sorted by name, cached briefly so the picker does not cost a page
 * of REST calls on every request.
 */
export async function getProjects(): Promise<ReportProject[]> {
    const cached = await storage.getValue<ReportProject[]>(PROJECTS_RECORD);
    if (cached) return cached;

    const projects: ReportProject[] = [];
    let startAt = 0;
    let isLast = false;
    while (!isLast) {
        const response = await Jira.fetch(`/rest/api/3/project/search?orderBy=name&maxResults=100&startAt=${startAt}`);
        if (!response.ok) throw new JiraRequestError(`Jira answered ${response.status} when listing projects.`);
        const page = await response.json<ProjectSearchResponse>();
        projects.push(...page.values.map((p) => ({ id: p.id, key: p.key, name: p.name })));
        startAt += page.values.length;
        isLast = page.isLast || page.values.length === 0;
    }

    await storage.setValue(PROJECTS_RECORD, projects, { ttl: PROJECTS_TTL_SECONDS });
    return projects;
}

async function cloudId(): Promise<string> {
    const cached = await storage.getValue<string>(CLOUD_ID_RECORD);
    if (cached) return cached;
    const response = await Jira.fetch('/_edge/tenant_info');
    if (!response.ok) throw new JiraRequestError(`Jira answered ${response.status} when reading the site's cloud ID.`);
    const { cloudId } = await response.json<{ cloudId: string }>();
    // A day is plenty: the cloud ID only changes if the connector is pointed at another site.
    await storage.setValue(CLOUD_ID_RECORD, cloudId, { ttl: 24 * 3600 });
    return cloudId;
}

function toReportIssue(node: ArchivedIssueNode): ReportIssue {
    return {
        key: node.key,
        url: node.webUrl ?? undefined,
        project: node.project?.key ?? undefined,
        summary: node.summary ?? undefined,
        issueType: node.issueType?.name ?? undefined,
        status: node.status?.name ?? undefined,
        statusCategory: node.status?.statusCategory?.key ?? undefined,
        priority: node.priority?.name ?? undefined,
        assignee: node.assignee?.name ?? undefined,
        reporter: node.reporter?.name ?? undefined,
        created: node.created ?? undefined,
        updated: node.updated ?? undefined,
        archivedOn: node.archivedOn ?? undefined,
        archivedBy: node.archivedBy?.name ?? undefined,
    };
}

interface Named {
    name?: string | null;
}

interface ArchivedIssueNode {
    key: string;
    webUrl?: string | null;
    summary?: string | null;
    created?: string | null;
    updated?: string | null;
    archivedOn?: string | null;
    issueType?: Named | null;
    status?: (Named & { statusCategory?: { key?: string | null } | null }) | null;
    priority?: Named | null;
    assignee?: Named | null;
    reporter?: Named | null;
    archivedBy?: Named | null;
    project?: { key?: string | null; name?: string | null } | null;
}

interface ArchivedIssuesResponse {
    errors?: { message: string }[];
    data?: {
        jira?: {
            archivedIssuesSearch?: {
                totalCount?: number | null;
                pageInfo: { hasNextPage: boolean; endCursor?: string | null };
                edges?: ({ node?: ArchivedIssueNode | null } | null)[] | null;
            } | null;
        } | null;
    } | null;
}

interface ProjectSearchResponse {
    isLast: boolean;
    values: { id: string; key: string; name: string }[];
}

© 2025 ScriptRunner · Terms and Conditions · Privacy Policy · Legal Notice · Cookie Preferences