diff --git a/README.md b/README.md index 7b3dd69..12a26b9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ gh2 pat create --account --name --owner \ --expires-in [--yes --token-output ] gh2 support login gh2 support create --subject --body [--account ] [--yes] +gh2 support view [--scope personal/0] [--output json] +gh2 support reply --body [--scope personal/0] [--yes] ``` ### Administration boundary @@ -206,11 +208,15 @@ organization-owned token into pending approval after creation. ### GitHub Support tickets -`gh2 support create` signs in to `support.github.com` through GitHub OAuth using the session captured by `gh2 support login`. It does not open a browser. The default is a live-authenticated dry run; add `--yes` only after reviewing the exact ticket. +The Support commands sign in to `support.github.com` through GitHub OAuth using the session captured by `gh2 support login`. They do not open a browser. Ticket creation and replies default to a live-authenticated dry run; add `--yes` only after reviewing the exact write. ```bash gh2 support login +# Read the original body and every reply in chronological order +gh2 support view 1234567 +gh2 support view 1234567 --scope personal/0 --output json + gh2 support create \ --account "Circles Inc." \ --subject "Remove sensitive data from repository history" \ @@ -222,9 +228,15 @@ gh2 support create \ --subject "Remove sensitive data from repository history" \ --body-file ./ticket.md \ --yes + +# Preview a reply; omit --yes unless posting was explicitly requested +gh2 support reply 1234567 --body-file ./reply.md +gh2 support reply 1234567 --body-file ./reply.md --yes ``` -The command uses GitHub Support's authenticated web endpoint because GitHub does not publish a Support ticket REST or GraphQL API. Portal changes can therefore require a `gh2` update. If GitHub requires a captcha, the command refuses to submit and directs the operator to the portal. +`support view --output json` returns `ticket`, `subject`, `status`, `account`, `scope`, `created_at`, `author`, `body`, and chronological `comments` containing `id`, `author`, `created_at`, and `body`. Authentication cookies and form tokens are never part of the output. + +The commands use GitHub Support's authenticated web pages because GitHub does not publish a Support ticket REST or GraphQL API. Portal changes can therefore require a `gh2` update. If GitHub requires a captcha, ticket creation refuses to submit and directs the operator to the portal. ## Webhook lifecycle diff --git a/skills/guide/SKILL.md b/skills/guide/SKILL.md index b39f56f..59b35cd 100644 --- a/skills/guide/SKILL.md +++ b/skills/guide/SKILL.md @@ -1,7 +1,6 @@ --- name: gh2 description: Guide for GitHub Apps, installation approvals, deleted-repository restoration, organization PAT policies, fine-grained personal access tokens, and Support via the gh2 CLI -user-invocable: false --- # gh2 CLI @@ -188,11 +187,15 @@ copy the token into logs, table output, issue bodies, or pull request text. ## GitHub Support -Create a ticket without opening the Support portal in a browser: +Read or create a ticket without opening the Support portal in a browser: ```bash gh2 support login +# Read the original body and every reply in chronological order +gh2 support view 1234567 +gh2 support view 1234567 --scope personal/0 --output json + # Dry run: authenticates and prints the exact ticket without creating it gh2 support create \ --account "Circles Inc." \ @@ -205,9 +208,19 @@ gh2 support create \ --subject "Remove sensitive data from repository history" \ --body-file ./ticket.md \ --yes + +# Preview a reply; omit --yes unless posting was explicitly requested +gh2 support reply 1234567 --body-file ./reply.md +gh2 support reply 1234567 --body-file ./reply.md --yes ``` -Use `--body -` to read the body from stdin. The command refuses submission if GitHub requires a captcha. Treat this as a web integration that may need updating when the Support portal changes. +Keep support tickets and replies minimal and action-oriented: state the exact action requested and include only the identifiers and evidence GitHub needs to perform it. Do not volunteer internal migration history, business context, or exhaustive verification logs unless GitHub asks. + +Before running `gh2 support create ... --yes` or `gh2 support reply ... --yes`, always run the dry run, show the exact subject and body to the user, and obtain explicit approval for that text. A general instruction to handle the ticket is not approval to submit unseen wording. + +`support view` is read-only. Its JSON output contains ticket metadata, the original `body`, and chronological `comments` with `id`, `author`, `created_at`, and `body`; it must never include authentication cookies or form tokens. Use `--scope personal/0` (or an organization scope) to skip account discovery when the scope is already known. + +Use `--body -` to read a create/reply body from stdin. Write commands remain dry runs without `--yes`, and ticket creation refuses submission if GitHub requires a captcha. Treat Support commands as a web integration that may need updating when the portal changes. ## Default config path diff --git a/src/commands/support/index.ts b/src/commands/support/index.ts index 0c257a9..420f28a 100644 --- a/src/commands/support/index.ts +++ b/src/commands/support/index.ts @@ -2,6 +2,7 @@ import { defineCommand } from "citty"; import { loginCommand } from "../app/login.ts"; import { supportCreateCommand } from "./create.ts"; import { supportReplyCommand } from "./reply.ts"; +import { supportViewCommand } from "./view.ts"; export const supportCommand = defineCommand({ meta: { @@ -11,6 +12,7 @@ export const supportCommand = defineCommand({ subCommands: { login: loginCommand, create: supportCreateCommand, + view: supportViewCommand, reply: supportReplyCommand, }, }); diff --git a/src/commands/support/view.ts b/src/commands/support/view.ts new file mode 100644 index 0000000..861081c --- /dev/null +++ b/src/commands/support/view.ts @@ -0,0 +1,103 @@ +import { defineCommand } from "citty"; +import { loadAuth } from "../../lib/auth.ts"; +import { + SupportSession, + type SupportTicketDetails, +} from "../../lib/support.ts"; +import { + getOutputFormat, + printOutput, + type OutputFormat, +} from "../../lib/output.ts"; + +function ticketOutput(ticket: SupportTicketDetails, account: string | undefined) { + return { + ticket: ticket.ticketId, + subject: ticket.subject, + status: ticket.status, + account: account ?? ticket.author, + scope: ticket.scope, + created_at: ticket.createdAt, + author: ticket.author, + body: ticket.body, + comments: ticket.comments.map((comment) => ({ + id: comment.id, + author: comment.author, + created_at: comment.createdAt, + body: comment.body, + })), + }; +} + +function printTicket( + ticket: SupportTicketDetails, + account: string | undefined, + format: OutputFormat, +): void { + const output = ticketOutput(ticket, account); + if (format === "json") { + printOutput(output, format); + return; + } + + printOutput( + { + ticket: output.ticket, + subject: output.subject, + status: output.status, + account: output.account, + scope: output.scope, + created_at: output.created_at, + comments: output.comments.length, + }, + format, + ); + console.log(`\nBody — ${output.author}\n\n${output.body}`); + if (output.comments.length === 0) return; + + console.log("\nComments"); + for (const [index, comment] of output.comments.entries()) { + console.log( + `\n${index + 1}. ${comment.author} · ${comment.created_at} · ${comment.id}\n\n${comment.body}`, + ); + } +} + +export const supportViewCommand = defineCommand({ + meta: { + name: "view", + description: "Show a GitHub Support ticket and its comments", + }, + args: { + ticket: { + type: "positional", + description: "Ticket number, for example 1234567", + required: true, + }, + scope: { + type: "string", + description: + "Ticket scope such as 'personal/0'; defaults to searching every accessible scope", + }, + output: { + type: "string", + description: "Output format: json | table (default: table)", + default: "table", + }, + }, + async run({ args }) { + const ticket = String(args.ticket).replace(/^#/, ""); + if (!/^\d+$/.test(ticket)) { + throw new Error(`"${args.ticket}" is not a ticket number.`); + } + + const auth = await loadAuth(); + const session = new SupportSession(auth); + await session.login(); + const scopes = args.scope + ? [args.scope.replace(/^\/?(tickets\/)?/, "")] + : ["personal/0", ...(await session.ticketScopes())]; + const details = await session.viewTicket(ticket, [...new Set(scopes)]); + printTicket(details, auth.account, getOutputFormat(args.output)); + }, +}); diff --git a/src/lib/support.ts b/src/lib/support.ts index 3cae44d..042deaf 100644 --- a/src/lib/support.ts +++ b/src/lib/support.ts @@ -47,6 +47,8 @@ function decodeHtmlEntities(value: string): string { .replace(/&#(\d+);/g, (_, decimal) => String.fromCodePoint(Number(decimal))) .replace(/"/g, '"') .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/ /g, " ") .replace(/</g, "<") .replace(/>/g, ">") .replace(/&/g, "&"); @@ -203,6 +205,24 @@ export interface SupportTicketPage { subject?: string; } +export interface SupportTicketComment { + id: string; + author: string; + createdAt: string; + body: string; +} + +export interface SupportTicketDetails { + ticketId: string; + scope: string; + subject?: string; + status: "open" | "closed"; + author: string; + createdAt: string; + body: string; + comments: SupportTicketComment[]; +} + function attribute(tag: string, name: string): string | undefined { // The leading boundary matters: the comment form carries both `action` and // `data-action`, and an unanchored match returns the Catalyst binding. @@ -210,6 +230,141 @@ function attribute(tag: string, name: string): string | undefined { return value === undefined ? undefined : decodeHtmlEntities(value); } +function htmlToText(html: string): string { + return decodeHtmlEntities( + html + .replace(//gi, "\n") + .replace(/]*>/gi, "\n---\n") + .replace(/]*>/gi, "- ") + .replace( + /<\/(?:blockquote|div|h[1-6]|li|ol|p|pre|table|tbody|td|th|thead|tr|ul)>/gi, + "\n", + ) + .replace(/<[^>]+>/g, ""), + ) + .replace(/\r/g, "") + .replace(/\u00a0/g, " ") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n[ \t]+/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function firstClassBlock( + html: string, + tagName: string, + className: string, +): string | undefined { + const openingTags = new RegExp(`<${tagName}\\b[^>]*>`, "gi"); + let opening: RegExpExecArray | null; + while ((opening = openingTags.exec(html))) { + const classes = attribute(opening[0], "class")?.split(/\s+/) ?? []; + if (!classes.includes(className)) continue; + + const tags = new RegExp(`<\\/?${tagName}\\b[^>]*>`, "gi"); + tags.lastIndex = opening.index; + let depth = 0; + let openingEnd = -1; + let tag: RegExpExecArray | null; + while ((tag = tags.exec(html))) { + if (tag[0].startsWith("]*\bid="tc-[^"]+"[^>]*>/i)?.[0]; + const authorHtml = html.match( + /]*class="[^"]*\bauthor\b[^"]*"[^>]*>([\s\S]*?)<\/span>/i, + )?.[1]; + const timeTag = html.match(/]*>/i)?.[0]; + const bodyHtml = firstClassBlock(html, "div", "zd-comment"); + const id = timelineTag + ? attribute(timelineTag, "id")?.replace(/^tc-/, "") + : undefined; + const createdAt = timeTag ? attribute(timeTag, "datetime") : undefined; + const author = authorHtml ? htmlToText(authorHtml) : undefined; + if (!id || !createdAt || !author || bodyHtml === undefined) { + throw new Error( + "GitHub Support returned an unexpected ticket-comment shape.", + ); + } + return { id, author, createdAt, body: htmlToText(bodyHtml) }; +} + +/** Parse the read-only ticket timeline. Portal order is newest-first. */ +export function parseSupportTicketDetails(html: string): SupportTicketDetails { + const ticketTag = html.match(/<[^>]*id="ticket"[^>]*>/i)?.[0]; + if (!ticketTag) { + if (/Ticket not found/i.test(html)) { + throw new Error( + "GitHub Support says the ticket does not exist or is not accessible to this session. Re-run `gh2 support login` if your browser is signed in as a different account.", + ); + } + throw new Error( + "GitHub Support ticket data was not found. The portal markup may have changed.", + ); + } + + const ticketId = attribute(ticketTag, "data-ticket-id"); + const orgType = attribute(ticketTag, "data-org-type"); + const orgId = attribute(ticketTag, "data-org-id"); + const status = html + .match(/class="[^"]*\bState--(open|closed)\b/i)?.[1] + ?.toLowerCase(); + if ( + !ticketId || + !orgType || + orgId === undefined || + (status !== "open" && status !== "closed") + ) { + throw new Error("GitHub Support returned an unexpected ticket-data shape."); + } + + const entries = [ + ...html.matchAll( + /]*>([\s\S]*?)<\/ticket-comment>/gi, + ), + ] + .map((match) => parseSupportComment(match[1] ?? "")) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + const initial = entries[0]; + if (!initial) { + throw new Error( + "GitHub Support returned a ticket without a readable timeline.", + ); + } + + const title = html.match(/([^<]*)<\/title>/i)?.[1]; + const subjectWithNumber = title + ? decodeHtmlEntities(title).replace(/\s*-\s*GitHub Support\s*$/i, "").trim() + : undefined; + const subject = subjectWithNumber + ?.replace(new RegExp(`\\s+#${ticketId}$`), "") + .trim(); + + return { + ticketId, + scope: `${orgType}/${orgId}`, + subject: subject || undefined, + status, + author: initial.author, + createdAt: initial.createdAt, + body: initial.body, + comments: entries.slice(1), + }; +} + /** * The ticket page is server-rendered: the comment box is a plain form posting * to `/ticket/<scope>/<id>/comment`. The bundled JS only drives websocket @@ -418,15 +573,11 @@ export class SupportSession { return parseTicketScopes(await response.text()); } - /** - * Load a ticket, trying each scope until one renders it. The portal answers - * HTTP 200 with a "Ticket not found" body for the wrong scope, so the status - * code alone cannot decide this. - */ - async openTicket( + private async findTicket<T>( ticketId: string, scopes: string[], - ): Promise<SupportTicketPage> { + parser: (html: string) => T, + ): Promise<T> { let lastError: Error | undefined; for (const scope of scopes) { const response = await this.request( @@ -434,7 +585,7 @@ export class SupportSession { ); if (!response.ok) continue; try { - return parseSupportTicketPage(await response.text()); + return parser(await response.text()); } catch (error) { lastError = error as Error; } @@ -447,6 +598,25 @@ export class SupportSession { ); } + /** + * Load a ticket, trying each scope until one renders it. The portal answers + * HTTP 200 with a "Ticket not found" body for the wrong scope, so the status + * code alone cannot decide this. + */ + async openTicket( + ticketId: string, + scopes: string[], + ): Promise<SupportTicketPage> { + return this.findTicket(ticketId, scopes, parseSupportTicketPage); + } + + async viewTicket( + ticketId: string, + scopes: string[], + ): Promise<SupportTicketDetails> { + return this.findTicket(ticketId, scopes, parseSupportTicketDetails); + } + async commentOnTicket( page: SupportTicketPage, message: string, diff --git a/tests/fixtures/support-ticket.html b/tests/fixtures/support-ticket.html new file mode 100644 index 0000000..47df4ea --- /dev/null +++ b/tests/fixtures/support-ticket.html @@ -0,0 +1,48 @@ +<!DOCTYPE html> +<html> + <head> + <title>Remove old references & confirm cleanup #1234567 - GitHub Support + + + Open +
+ + +
+ monalisa + Aug 7 +
+

Can you confirm the final cleanup?

+ +
+
+
+ + +
+ GitHub + Aug 6 +
+
We are checking with our internal team.
We will follow up.
+
+
+
+ + +
+ monalisa + Aug 4 +
+
+

Please remove the internal references.

+
  • Repository A
  • Repository B & C
+
+
+
+
+ +
+ +
+ + diff --git a/tests/support.test.ts b/tests/support.test.ts index a2a5db8..a34c60d 100644 --- a/tests/support.test.ts +++ b/tests/support.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; import { buildSupportTicketPayload, maskEmail, parseSupportBootstrap, parseSupportTicketPage, + parseSupportTicketDetails, parseTicketScopes, selectSupportAccount, selectSupportEmail, @@ -12,6 +14,11 @@ import { type SupportBootstrap, } from "../src/lib/support.ts"; +const TICKET_DETAILS_HTML = readFileSync( + new URL("./fixtures/support-ticket.html", import.meta.url), + "utf8", +); + const personal: SupportAccount = { id: "personal-id", identifier: "monalisa", @@ -179,3 +186,43 @@ describe("support ticket replies", () => { expect(parseTicketScopes(html)).toEqual(["personal/0", "organization/42"]); }); }); + +describe("support ticket view", () => { + it("parses the body and every comment in chronological order", () => { + expect(parseSupportTicketDetails(TICKET_DETAILS_HTML)).toEqual({ + ticketId: "1234567", + scope: "personal/0", + subject: "Remove old references & confirm cleanup", + status: "open", + author: "monalisa", + createdAt: "2026-08-04T15:36:01Z", + body: [ + "Please remove the internal references.", + "", + "- Repository A", + "- Repository B & C", + ].join("\n"), + comments: [ + { + id: "200", + author: "GitHub", + createdAt: "2026-08-06T09:29:33Z", + body: "We are checking with our internal team.\nWe will follow up.", + }, + { + id: "300", + author: "monalisa", + createdAt: "2026-08-07T02:35:52Z", + body: "Can you confirm the final cleanup?", + }, + ], + }); + }); + + it("reads a closed ticket without requiring a comment form", () => { + const closed = TICKET_DETAILS_HTML + .replace("State--open", "State--closed") + .replace(/
/, ""); + expect(parseSupportTicketDetails(closed).status).toBe("closed"); + }); +});