import { escapeSupportMarkdown, type BugReportRecord, } from "../shared/bug-reports"; type Issue = { id: string; identifier: string; team: { id: string }; state?: { id: string; name: string; type: string }; completedAt?: string | null; }; export class LinearPermissionError extends Error {} export class LinearSupportClient { constructor( private key: string, private team: string, private project?: string, private request: typeof fetch = fetch, ) {} async graphql( query: string, variables: object, missing = false, ): Promise { const response = await this.request("https://api.linear.app/graphql", { method: "POST", headers: { Authorization: this.key, "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(15_000), }); const result = await response.json(); if ( missing && response.ok && result.errors?.length && result.errors?.every( (error: { message?: string; extensions?: { code?: string } }) => error.extensions?.code === "ENTITY_NOT_FOUND" || /^Entity not found/i.test(error.message ?? ""), ) ) return null; if ( response.status === 401 || response.status === 403 || result.errors?.some( (error: { message?: string; extensions?: { code?: string } }) => /forbidden|permission|authentication|unauthorized/i.test( `${error.extensions?.code ?? ""} ${error.message ?? ""}`, ), ) ) throw new LinearPermissionError( "Support delivery is temporarily unavailable. Check Linear Write permission.", ); if (!response.ok || result.errors?.length || !result.data) throw new Error("Support delivery is temporarily unavailable"); return result.data; } async issue(id: string): Promise { const data = await this.graphql<{ issue: Issue }>( "query SupportIssue($id: String!) { issue(id: $id) { id identifier team { id } state { id name type } completedAt } }", { id }, true, ); const issue = data?.issue ?? null; if (issue && issue.team.id !== this.team) throw new Error("Support issue team mismatch"); return issue; } async done(id: string) { const issue = await this.issue(id); if (!issue) throw new Error("Support issue was not found"); if ( issue.state?.type === "completed" && issue.state.name.toLowerCase() === "done" ) return; const data = await this.graphql<{ team: { states: { nodes: { id: string; name: string; type: string }[] } }; }>( "query SupportDoneState($team: String!) { team(id:$team) { states { nodes { id name type } } } }", { team: this.team }, ); const state = data?.team.states.nodes.find( (s) => s.type === "completed" && s.name.toLowerCase() === "done", ); if (!state) throw new Error("The support team needs a Done workflow status"); const result = await this.graphql<{ issueUpdate: { success: boolean; issue: Issue }; }>( "mutation SupportDone($id: String!, $input: IssueUpdateInput!) { issueUpdate(id:$id,input:$input) { success issue { id state { id name type } } } }", { id, input: { stateId: state.id } }, ); if ( !result?.issueUpdate.success || result.issueUpdate.issue.id !== id || result.issueUpdate.issue.state?.id !== state.id ) throw new Error("Support issue status was not confirmed"); } async remove(id: string) { if (!(await this.issue(id))) return; const result = await this.graphql<{ issueDelete: { success: boolean } }>( "mutation SupportDelete($id: String!) { issueDelete(id:$id) { success } }", { id }, ); if (!result?.issueDelete.success) throw new Error("Support issue deletion was not confirmed"); } async createIssue(report: BugReportRecord): Promise { const existing = await this.issue(report.id); if (existing) return existing; const description = [ "#user-report-message", `Reporter: ${escapeSupportMarkdown(report.display_name)}`, `Page: ${report.page_path}`, "", escapeSupportMarkdown(report.description), "", report.linear_asset_url ? `![Player screenshot](${report.linear_asset_url})` : "", "", "Reply to the player with #player-report-send followed by your message.", "Grant or remove the username reward with #player-report-glow grant or #player-report-glow revoke.", "Close this report with #player-report-close or move the issue to Done. Closed reports and issues are deleted after three days.", `Report reference: ${report.id}`, ].join("\n"); try { const data = await this.graphql<{ issueCreate: { success: boolean; issue: Issue }; }>( "mutation SupportIssueCreate($input: IssueCreateInput!) { issueCreate(input:$input) { success issue { id identifier team { id } } } }", { input: { id: report.id, teamId: this.team, ...(this.project ? { projectId: this.project } : {}), title: `[Player bug] ${report.title}`, description, priority: 3, }, }, ); if ( !data?.issueCreate.success || data.issueCreate.issue?.id !== report.id ) throw new Error("Support issue creation failed"); return data.issueCreate.issue; } catch (error) { // A timed-out creation may have succeeded. A stable UUID prevents a second issue. const recovered = await this.issue(report.id); if (recovered) return recovered; throw error; } } async comment(id: string, reportId: string, name: string, text: string) { const lookup = () => this.graphql<{ comment: { id: string; issue: { id: string } } }>( "query SupportComment($id:String!) { comment(id:$id) { id issue { id } } }", { id }, true, ); const existing = (await lookup())?.comment; if (existing) { if (existing.issue.id !== reportId) throw new Error("Support comment mismatch"); return; } try { const data = await this.graphql<{ commentCreate: { success: boolean; comment: { id: string } }; }>( "mutation SupportCommentCreate($input:CommentCreateInput!) { commentCreate(input:$input) { success comment { id } } }", { input: { id, issueId: reportId, body: `#user-report-message\n${escapeSupportMarkdown(name)}\n\n${escapeSupportMarkdown(text)}`, }, }, ); if (!data?.commentCreate.success || data.commentCreate.comment?.id !== id) throw new Error("Support comment creation failed"); } catch (error) { const recovered = (await lookup())?.comment; if (!recovered || recovered.issue.id !== reportId) throw error; } } async upload(bytes: Uint8Array, filename: string) { const data = await this.graphql<{ fileUpload: { success: boolean; uploadFile: { uploadUrl: string; assetUrl: string; headers: { key: string; value: string }[]; }; }; }>( 'mutation SupportFile($size:Int!,$filename:String!){ fileUpload(contentType:"image/webp",filename:$filename,size:$size) { success uploadFile { uploadUrl assetUrl headers { key value } } } }', { filename, size: bytes.length }, ); const file = data?.fileUpload.uploadFile; if ( !data?.fileUpload.success || !file || new URL(file.uploadUrl).protocol !== "https:" || new URL(file.assetUrl).protocol !== "https:" ) throw new Error("Support screenshot upload failed"); const headers = new Headers({ "Content-Type": "image/webp", "Cache-Control": "private, max-age=31536000", }); file.headers.forEach(({ key, value }) => headers.set(key, value)); const result = await this.request(file.uploadUrl, { method: "PUT", headers, body: Buffer.from(bytes), signal: AbortSignal.timeout(15_000), }); if (!result.ok) throw new Error("Support screenshot upload failed"); return file.assetUrl; } }