231 lines
8.1 KiB
TypeScript
231 lines
8.1 KiB
TypeScript
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<T>(
|
|
query: string,
|
|
variables: object,
|
|
missing = false,
|
|
): Promise<T | null> {
|
|
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<Issue | null> {
|
|
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<Issue> {
|
|
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
|
|
? ``
|
|
: "",
|
|
"",
|
|
"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;
|
|
}
|
|
}
|