Template Content
Scripts
Not the template you're looking for? Browse more.
About the template
About ScriptRunner Connect
What is ScriptRunner Connect?
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
Scripts
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.
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.jira: uses the Generic connector above.| Parameter | Type | Description | Example |
|---|---|---|---|
| REPORT_PASSWORD | Text (masked) | Password typed on the login page. Required. Changing it signs everyone out at once. | a long passphrase |
| PAGE_SIZE | Number | Archived issues per page, 1 to 100. Default 50. | 50 |
| SESSION_HOURS | Number | How long a login lasts before the page asks for the password again. Default 8. | 8 |
| MAX_LOGIN_ATTEMPTS | Number | Failed logins allowed from one IP address within 15 minutes before that address is locked out. Default 5. | 5 |
To check it is working, archive an issue in Jira and reload the page; it appears at the top of the list.
event.scriptrunnerconnect.com. Allow them for that site.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)));
}
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;
}
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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>`;
}
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