Ctrl K
ComunidadGuías…

packages/desktop/script/beta-notes.ts

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// The user-facing changes compiled into a beta build: the build's commit, the newest commits of its history, and the
// feat, fix and perf commits among them, with who made each and what its pull request says it does when GitHub can be
// asked. electron.vite.config.ts embeds them in the renderer as import.meta.env.OPENCODE_BETA_NOTES; the updater's
// "What's new in beta" dialog lists the ones after the commit of the build the user saw last.
// Usage: GITHUB_TOKEN=... bun script/beta-notes.ts [head]
export * as BetaNotes from "./beta-notes"

import { execFileSync } from "node:child_process"
import { Option, Schema } from "effect"
import { BetaVisual } from "./beta-visual"

export type BetaNote = {
  type: "feat" | "fix" | "perf"
  scope?: string
  summary: string
  breaking?: true
  pr?: number
  sha: string
  /** The commit's author, or the pull request's once GitHub has been asked. */
  author?: { name: string; login?: string; avatar?: string }
  /** A sentence or two from the pull request's description of what it does. */
  description?: string
  /** The pull request's "### Visual": a small looping SVG of the change, rebuilt, and its still for reduced motion. */
  visual?: { animated: string; still: string }
}

/** How many commits of history a build carries. A previous build further back shows the newest notes instead. */
const LIMIT = 200

/** Commits are identified by this many hex digits, enough to be unique in this repository. */
const SHORT = 12

const KEPT = new Set(["feat", "fix", "perf"])

const FIELD = "\x1f"

const RECORD = "\x1e"

const REPOSITORY = { owner: "anomalyco", name: "opencode" }

/** Pull requests per GitHub query; the newest 200 commits take at most two. */
const BATCH = 100

/** A description longer than this reads as a wall of text in the dialog, so it is cut to fit. */
const DESCRIPTION = 240

/**
 * Reads the commit `head` resolves to (GITHUB_SHA in CI) and its newest commits, newest first: every commit's short
 * sha, merges included so a previous build at a merge is found, and the notes of the non-merge feat, fix and perf ones.
 */
export function embed(input: { head?: string; cwd?: string } = {}) {
  // electron-vite loads its config, which calls this, in Node, so this cannot use Bun's shell.
  const git = (...args: string[]) => execFileSync("git", args, { cwd: input.cwd, encoding: "utf8" })

  const sha = git("rev-parse", input.head ?? process.env.GITHUB_SHA ?? "HEAD").trim()

  const log = git(
    "log",
    "--topo-order",
    `--max-count=${LIMIT}`,
    `--format=%H${FIELD}%P${FIELD}%an${FIELD}%s${RECORD}`,
    sha,
  )

  const records = log
    .split(RECORD)
    .map((record) => record.trim().split(FIELD))
    .filter((fields) => fields.length === 4 && fields[0])

  return {
    sha,
    commits: records.map(([commit]) => commit!.slice(0, SHORT)),
    notes: records.flatMap(([commit, parents, author, subject]): BetaNote[] => {
      // A merge commit repeats the commits it merges.
      if (parents!.trim().split(/\s+/).length > 1) return []

      const note = parseSubject(subject!)

      if (!note) return []

      return [{ ...note, sha: commit!.slice(0, SHORT), author: author!.trim() ? { name: author!.trim() } : undefined }]
    }),
  }
}

/**
 * Adds each note's pull request author (name, login and public avatar) and description, read from GitHub with one
 * GraphQL query per 100 pull requests. The token stays here: only public data reaches the build. Without a token, or
 * when GitHub fails or is slow, the notes keep their commit author and subject: a build never fails for this.
 */
export async function enrich(
  embedded: ReturnType<typeof embed>,
  input: { token: string | undefined; fetch?: Request },
): Promise<ReturnType<typeof embed>> {
  const numbers = [...new Set(embedded.notes.flatMap((note) => note.pr ?? []))]
  const token = input.token

  if (!token || numbers.length === 0) return embedded

  const batches = Array.from({ length: Math.ceil(numbers.length / BATCH) }, (_, i) =>
    numbers.slice(i * BATCH, (i + 1) * BATCH),
  )

  const found = new Map(
    (await Promise.all(batches.map((batch) => pullRequests(batch, token, input.fetch ?? fetch)))).flat(),
  )

  return {
    ...embedded,
    notes: embedded.notes.map((note) => {
      const pr = note.pr === undefined ? undefined : found.get(note.pr)

      if (!pr) return note

      return {
        ...note,
        author: pr.author ? author(pr.author) : note.author,
        description: describe(pr.body, note.summary),
        visual: visual(pr.body),
      }
    }),
  }
}

/**
 * The first sentence or two of what a pull request says it does, as plain text: from the PR template's "What does this
 * PR do?" or a "Summary" section, else the text before the first heading, else a "Changes" or "Solution" section. The
 * template's own lines (issue links, "N/A", the type-of-change checklist) never count. Text over the cap ends at its
 * last whole sentence that fits, or is cut at a word with an ellipsis. None when it only repeats the subject.
 */
export function describe(body: string, summary: string) {
  const heading = (line: string) => /^#{1,6}\s/.test(line) || /^\*\*[^*]+\*\*:?$/.test(line)

  // Template headings stay, as they end the section before them.
  const lines = body
    .replace(/<!--[\s\S]*?-->|```[\s\S]*?```/g, "")
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter((line) => heading(line) || !BOILERPLATE.some((pattern) => pattern.test(line)))

  const until = (rest: string[]) => {
    const end = rest.findIndex(heading)

    return end === -1 ? rest : rest.slice(0, end)
  }

  const section = (title: RegExp) => {
    const start = lines.findIndex((line) => heading(line) && title.test(line))

    return start === -1 ? [] : until(lines.slice(start + 1))
  }

  const words = (value: string) =>
    value
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, " ")
      .trim()

  return [section(/what does this pr do|summary/i), until(lines), section(/^\W*(what )?chang|solution/i)]
    .map((candidate) => {
      const paragraph = candidate
        .join("\n")
        .split(/\n\s*\n/)
        // A list stands for its first item.
        .map((block) => plain(block.split("\n").find((line) => /^([-*+]|\d+\.)\s/.test(line)) ?? block))
        .find(
          (text) => text && !/^(\[.\]|(closes|fixes|resolves|stacked on|depends on|n\/a|please provide)\b)/i.test(text),
        )

      // A sentence that introduces a code block or a list reads as cut off.
      const [first, second] = (paragraph ?? "")
        .split(/(?<=[.!?])\s+(?=[A-Z0-9"'`(])/)
        .filter((sentence) => sentence && !sentence.endsWith(":"))

      const text = first && second && `${first} ${second}`.length <= DESCRIPTION ? `${first} ${second}` : first

      // Fragments of a list, or text that starts with code, don't read as a sentence on their own.
      return text && /^[A-Z0-9"“]/.test(text) && words(text) !== words(summary) ? cap(text) : undefined
    })
    .find((text) => text !== undefined)
}

/**
 * The ```svg block under a pull request's "### Visual" heading, rebuilt from an allowlist (beta-visual.ts) as an
 * animated and a still copy. The dialog only ever shows it as an image, where scripts never run.
 */
export function visual(body: string) {
  const lines = body.split(/\r?\n/)
  const start = lines.findIndex((line) => /^#{1,6}\s+visual\b/i.test(line.trim()))

  if (start === -1) return undefined

  const rest = lines.slice(start + 1)
  const end = rest.findIndex((line) => /^#{1,6}\s/.test(line.trim()))
  const block = (end === -1 ? rest : rest.slice(0, end)).join("\n").match(/```svg[^\n]*\n([\s\S]*?)\n\s*```/)?.[1]

  return block ? BetaVisual.sanitize(block) : undefined
}

/** Lines of the PR template that say nothing about the change, whether a heading, a label or a filler answer. */
const BOILERPLATE = [
  /^\W*(issue for this pr|type of change|how did you verify|screenshots|checklist)\b/i,
  /^[-*+]\s*\[[ x]\]/i,
  /^\W*(n\/a|none|tbd)\W*$/i,
  /^\W*((closes|fixes|resolves)\s*)?([\w.-]+\/[\w.-]+)?#\d*\W*$/i,
]

/** A sentence over the cap, cut at its last word that fits, with an ellipsis. */
function cap(text: string) {
  if (text.length <= DESCRIPTION) return text

  const cut = text.slice(0, DESCRIPTION - 1).replace(/\s+\S*$/, "")

  return `${cut.replace(/[\s,;:—–-]+$/, "")}…`
}

/** Markdown as the plain text it reads as. */
function plain(markdown: string) {
  return (
    markdown
      .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
      // Parenthesized file paths and identifiers are for reviewers.
      .replace(/\s*\([^()]*`[^()]*\)/g, "")
      .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
      .replace(/<[^>]+>/g, "")
      .replace(/^\s*(?:[-*+]|\d+\.|>)\s+/gm, "")
      .replace(/(\*\*|__|`)/g, "")
      .replace(/\s+/g, " ")
      .trim()
  )
}

const Author = Schema.Struct({
  login: Schema.String,
  avatarUrl: Schema.optional(Schema.String),
  name: Schema.optional(Schema.NullOr(Schema.String)),
})

const PullRequest = Schema.Struct({ number: Schema.Number, body: Schema.String, author: Schema.NullOr(Author) })

const Response = Schema.Struct({
  data: Schema.Struct({ repository: Schema.Record(Schema.String, Schema.NullOr(PullRequest)) }),
})

const decodeResponse = Schema.decodeUnknownOption(Response)

type Request = (url: string, init: RequestInit) => Promise<globalThis.Response>

/** The pull requests GitHub returns for `numbers`; none when the request fails. Missing numbers are left out. */
async function pullRequests(numbers: number[], token: string, request: Request) {
  const fields = numbers
    .map(
      (n) =>
        `pr${n}: pullRequest(number: ${n}) { number body author { login avatarUrl(size: 48) ... on User { name } } }`,
    )
    .join("\n")

  const query = `query { repository(owner: "${REPOSITORY.owner}", name: "${REPOSITORY.name}") { ${fields} } }`

  const response = await request("https://api.github.com/graphql", {
    method: "POST",
    headers: { authorization: `bearer ${token}`, "content-type": "application/json", "user-agent": "opencode-desktop" },
    body: JSON.stringify({ query }),
    signal: AbortSignal.timeout(10_000),
  })
    .then((result) => (result.ok ? result.json() : undefined))
    .catch(() => undefined)

  return Option.match(decodeResponse(response), {
    onNone: () => [],
    onSome: (decoded) => Object.values(decoded.data.repository).flatMap((pr) => (pr ? [[pr.number, pr] as const] : [])),
  })
}

/** A pull request author as the dialog shows them; only GitHub's public avatar host is kept. */
function author(input: typeof Author.Type): NonNullable<BetaNote["author"]> {
  const avatar = input.avatarUrl?.startsWith("https://avatars.githubusercontent.com/") ? input.avatarUrl : undefined

  return { name: input.name?.trim() || input.login, login: input.login, avatar }
}

/** Parses a conventional commit header `type(scope)!: summary (#123)`; anything else is not a note. */
export function parseSubject(subject: string) {
  const match = subject.trim().match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/)
  const type = match?.[1]?.toLowerCase()

  if (!match || !type || !isKept(type)) return undefined

  const pr = match[4]!.match(/\s*\(#(\d+)\)\s*$/)
  const summary = (pr ? match[4]!.slice(0, pr.index) : match[4]!).trim()

  if (!summary) return undefined

  const note: Omit<BetaNote, "sha"> = { type, summary }
  const scope = match[2]?.trim()

  if (scope) note.scope = scope

  if (match[3]) note.breaking = true

  if (pr) note.pr = Number(pr[1])

  return note
}

function isKept(type: string): type is BetaNote["type"] {
  return KEPT.has(type)
}

if (import.meta.main)
  console.log(
    JSON.stringify(await enrich(embed({ head: process.argv[2] }), { token: process.env.GITHUB_TOKEN }), null, 2),
  )