From 338b0d66acfa237acb25366ae11846a8f0a81620 Mon Sep 17 00:00:00 2001 From: Sunbrye Ly <56200261+sunbrye@users.noreply.github.com> Date: Thu, 28 May 2026 10:17:49 -0700 Subject: [PATCH 1/5] Tented model (#61450) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../reference/ai-models/model-hosting.md | 1 + .../reference/ai-models/supported-models.md | 19 ++++++++++--------- .../annual-subscriber-model-multipliers.yml | 4 ++++ data/tables/copilot/model-comparison.yml | 5 +++++ data/tables/copilot/model-multipliers.yml | 4 ++++ data/tables/copilot/model-release-status.yml | 7 +++++++ .../copilot/model-supported-clients.yml | 9 +++++++++ data/tables/copilot/model-supported-plans.yml | 8 ++++++++ data/tables/copilot/models-and-pricing.yml | 9 +++++++++ data/variables/copilot.yml | 1 + 10 files changed, 58 insertions(+), 9 deletions(-) diff --git a/content/copilot/reference/ai-models/model-hosting.md b/content/copilot/reference/ai-models/model-hosting.md index 85ef1eaf39db..952afde4d49f 100644 --- a/content/copilot/reference/ai-models/model-hosting.md +++ b/content/copilot/reference/ai-models/model-hosting.md @@ -56,6 +56,7 @@ Used for: * {% data variables.copilot.copilot_claude_opus_46 %} * {% data variables.copilot.copilot_claude_opus_46_fast %} * {% data variables.copilot.copilot_claude_opus_47 %} +* {% data variables.copilot.copilot_claude_opus_48 %} * {% data variables.copilot.copilot_claude_sonnet_46 %} These models are hosted by Amazon Web Services, Anthropic PBC, and Google Cloud Platform. {% data variables.product.github %} has provider agreements in place to ensure data is not used for training. Additional details for each provider are included below: diff --git a/content/copilot/reference/ai-models/supported-models.md b/content/copilot/reference/ai-models/supported-models.md index a788d32fe842..87085fc213ba 100644 --- a/content/copilot/reference/ai-models/supported-models.md +++ b/content/copilot/reference/ai-models/supported-models.md @@ -94,15 +94,16 @@ Some {% data variables.product.prodname_copilot_short %} models require minimum {% rowheaders %} | Model | {% data variables.product.prodname_vscode %} | {% data variables.product.prodname_vs %} | JetBrains IDEs | Xcode | Eclipse | -|------------------------------------------------------| --- | --- | --- | --- | --- | -| {% data variables.copilot.copilot_gemini_3_flash %} | `v1.115.0` and later | `17.14.22` or `18.1.0` and later | `1.5.62` and later | `0.46.0` and later | `0.14.0` and later | -| {% data variables.copilot.copilot_gemini_31_pro %} | `v1.115.0` and later | `17.14.22` or `18.1.0` and later | `1.5.62` and later | `0.46.0` and later | `0.14.0` and later | -| {% data variables.copilot.copilot_gemini_35_flash %} | `v1.115.0` and later | `17.14.22` or `18.1.0` and later | `1.5.62` and later | `0.46.0` and later | `0.14.0` and later | -| {% data variables.copilot.copilot_gpt_52_codex %} | No minimum listed | `17.14.19` or `18.0.0` and later | `1.5.61` and later | `0.45.0` and later | `0.13.0` and later | -| {% data variables.copilot.copilot_gpt_53_codex %} | `v1.104.1` and later | `17.14.19` and later | `1.5.61` and later | `0.45.0` and later | `0.13.0` and later | -| {% data variables.copilot.copilot_gpt_54 %} | `v1.104.1` and later | `17.14.19` and later | `1.5.66` and later | `0.47.0` and later | `0.15.0` and later | -| {% data variables.copilot.copilot_gpt_54_mini %} | `v1.104.1` and later | `17.14.19` and later | `1.5.66` and later | `0.47.0` and later | `0.15.0` and later | -| {% data variables.copilot.copilot_gpt_55 %} | `v1.117` and later | `17.14.19` and later | `1.5.66` and later | `0.47.0` and later | `0.15.0` and later | +|------------------------------------------------------|----------------------------------------------|------------------------------------------| --- | --- | --- | +| {% data variables.copilot.copilot_gemini_3_flash %} | `v1.115.0` and later | `17.14.22` or `18.1.0` and later | `1.5.62` and later | `0.46.0` and later | `0.14.0` and later | +| {% data variables.copilot.copilot_gemini_31_pro %} | `v1.115.0` and later | `17.14.22` or `18.1.0` and later | `1.5.62` and later | `0.46.0` and later | `0.14.0` and later | +| {% data variables.copilot.copilot_gemini_35_flash %} | `v1.115.0` and later | `17.14.22` or `18.1.0` and later | `1.5.62` and later | `0.46.0` and later | `0.14.0` and later | +| {% data variables.copilot.copilot_gpt_52_codex %} | No minimum listed | `17.14.19` or `18.0.0` and later | `1.5.61` and later | `0.45.0` and later | `0.13.0` and later | +| {% data variables.copilot.copilot_gpt_53_codex %} | `v1.104.1` and later | `17.14.19` and later | `1.5.61` and later | `0.45.0` and later | `0.13.0` and later | +| {% data variables.copilot.copilot_gpt_54 %} | `v1.104.1` and later | `17.14.19` and later | `1.5.66` and later | `0.47.0` and later | `0.15.0` and later | +| {% data variables.copilot.copilot_gpt_54_mini %} | `v1.104.1` and later | `17.14.19` and later | `1.5.66` and later | `0.47.0` and later | `0.15.0` and later | +| {% data variables.copilot.copilot_gpt_55 %} | `v1.117` and later | `17.14.19` and later | `1.5.66` and later | `0.47.0` and later | `0.15.0` and later | +| {% data variables.copilot.copilot_claude_opus_48 %} | `v1.118` and later | `17.14.19` and later | TBD | TBD | TBD | {% endrowheaders %} diff --git a/data/tables/copilot/annual-subscriber-model-multipliers.yml b/data/tables/copilot/annual-subscriber-model-multipliers.yml index d0a36d734eff..848fb3621834 100644 --- a/data/tables/copilot/annual-subscriber-model-multipliers.yml +++ b/data/tables/copilot/annual-subscriber-model-multipliers.yml @@ -22,6 +22,10 @@ current_multiplier: '15' new_multiplier: '27' +- model: 'Claude Opus 4.8' + current_multiplier: '15' + new_multiplier: '27' + - model: 'Claude Sonnet 4.5' current_multiplier: '1' new_multiplier: '6' diff --git a/data/tables/copilot/model-comparison.yml b/data/tables/copilot/model-comparison.yml index e64b699f8085..5e7cc3df6206 100644 --- a/data/tables/copilot/model-comparison.yml +++ b/data/tables/copilot/model-comparison.yml @@ -63,6 +63,11 @@ excels_at: Complex problem-solving challenges, sophisticated reasoning further_reading: '[Claude Opus 4.7 model card](https://cdn.sanity.io/files/4zrzovbb/website/037f06850df7fbe871e206dad004c3db5fd50340.pdf)' +- name: Claude Opus 4.8 + task_area: Deep reasoning and debugging + excels_at: Complex problem-solving challenges, sophisticated reasoning + further_reading: 'Not available' + - name: Claude Sonnet 4.5 task_area: General-purpose coding and agent tasks excels_at: Complex problem-solving challenges, sophisticated reasoning diff --git a/data/tables/copilot/model-multipliers.yml b/data/tables/copilot/model-multipliers.yml index b64928774b26..99971bef01df 100644 --- a/data/tables/copilot/model-multipliers.yml +++ b/data/tables/copilot/model-multipliers.yml @@ -29,6 +29,10 @@ multiplier_paid: 15 multiplier_free: Not applicable +- name: Claude Opus 4.8 + multiplier_paid: 15 + multiplier_free: Not applicable + - name: Claude Sonnet 4.5 multiplier_paid: 1 multiplier_free: Not applicable diff --git a/data/tables/copilot/model-release-status.yml b/data/tables/copilot/model-release-status.yml index 8b23793f5440..aadf3d65ae43 100644 --- a/data/tables/copilot/model-release-status.yml +++ b/data/tables/copilot/model-release-status.yml @@ -117,6 +117,13 @@ ask_mode: true edit_mode: true +- name: 'Claude Opus 4.8' + provider: 'Anthropic' + release_status: 'GA' + agent_mode: true + ask_mode: true + edit_mode: true + - name: 'Claude Sonnet 4.5' provider: 'Anthropic' release_status: 'GA' diff --git a/data/tables/copilot/model-supported-clients.yml b/data/tables/copilot/model-supported-clients.yml index 947cd8d4b363..7bb47e1b0d9c 100644 --- a/data/tables/copilot/model-supported-clients.yml +++ b/data/tables/copilot/model-supported-clients.yml @@ -59,6 +59,15 @@ xcode: true jetbrains: true +- name: Claude Opus 4.8 + dotcom: true + cli: true + vscode: true + vs: true + eclipse: true + xcode: true + jetbrains: true + - name: Claude Sonnet 4.5 dotcom: true cli: true diff --git a/data/tables/copilot/model-supported-plans.yml b/data/tables/copilot/model-supported-plans.yml index d138b7209625..2f2fa8ed6738 100644 --- a/data/tables/copilot/model-supported-plans.yml +++ b/data/tables/copilot/model-supported-plans.yml @@ -53,6 +53,14 @@ business: true enterprise: true +- name: Claude Opus 4.8 + free: false + student: false + pro: false + pro_plus: true + business: true + enterprise: true + - name: Claude Sonnet 4.5 free: false student: false diff --git a/data/tables/copilot/models-and-pricing.yml b/data/tables/copilot/models-and-pricing.yml index 00152a3d3eb0..0831f0ab60f8 100644 --- a/data/tables/copilot/models-and-pricing.yml +++ b/data/tables/copilot/models-and-pricing.yml @@ -153,6 +153,15 @@ output: $25.00 cache_write: $6.25 +- model: Claude Opus 4.8 + provider: anthropic + release_status: GA + category: Powerful + input: $5.00 + cached_input: $0.50 + output: $25.00 + cache_write: $6.25 + # Google - model: 'Gemini 2.5 Pro[^5]' provider: google diff --git a/data/variables/copilot.yml b/data/variables/copilot.yml index 6356af1c87be..b718226e0efe 100644 --- a/data/variables/copilot.yml +++ b/data/variables/copilot.yml @@ -169,6 +169,7 @@ copilot_claude_opus_45: 'Claude Opus 4.5' copilot_claude_opus_46: 'Claude Opus 4.6' copilot_claude_opus_46_fast: 'Claude Opus 4.6 (fast mode) (preview)' copilot_claude_opus_47: 'Claude Opus 4.7' +copilot_claude_opus_48: 'Claude Opus 4.8' copilot_claude_sonnet: 'Claude Sonnet' copilot_claude_sonnet_35: 'Claude Sonnet 3.5' copilot_claude_sonnet_37: 'Claude Sonnet 3.7' From 93255fd4b4a3fe88b34683bac25fec66577e0988 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 17:15:58 +0000 Subject: [PATCH 2/5] [2026-05-28] Add Gemini 3.1 Pro and Gemini 3.5 Flash availability to Copilot CLI and cloud agent (#61451) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: timrogers <116134+timrogers@users.noreply.github.com> --- data/reusables/copilot/copilot-cloud-agent-non-auto-models.md | 2 ++ data/tables/copilot/model-supported-clients.yml | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md b/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md index c606728d9b8e..0ba4cb9b2128 100644 --- a/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md +++ b/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md @@ -1,4 +1,6 @@ * {% data variables.copilot.copilot_claude_opus_47 %} * {% data variables.copilot.copilot_claude_haiku_45 %} +* {% data variables.copilot.copilot_gemini_31_pro %} +* {% data variables.copilot.copilot_gemini_35_flash %} * {% data variables.copilot.copilot_gpt_52_codex %} * {% data variables.copilot.copilot_gpt_54_mini %} diff --git a/data/tables/copilot/model-supported-clients.yml b/data/tables/copilot/model-supported-clients.yml index 7bb47e1b0d9c..c1d29e2f5ea3 100644 --- a/data/tables/copilot/model-supported-clients.yml +++ b/data/tables/copilot/model-supported-clients.yml @@ -106,7 +106,7 @@ - name: Gemini 3.1 Pro dotcom: false - cli: false + cli: true vscode: true vs: true eclipse: true @@ -115,7 +115,7 @@ - name: Gemini 3.5 Flash dotcom: false - cli: false + cli: true vscode: true vs: true eclipse: true From 72fe4fb35b6a5ddbe9ad67d616b30e4680654ab7 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Thu, 28 May 2026 10:21:31 -0700 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=95=B8=EF=B8=8F=20Emit=20per-category?= =?UTF-8?q?=20GraphQL=20schema=20files=20alongside=20monolith=20(#61434)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/graphql/lib/categories.ts | 183 +++++++++++++ src/graphql/scripts/sync.ts | 46 +++- .../scripts/utils/bucket-by-category.ts | 152 +++++++++++ src/graphql/scripts/utils/process-schemas.ts | 246 ++++++++++++++++-- src/graphql/scripts/utils/schema-helpers.ts | 28 +- 5 files changed, 631 insertions(+), 24 deletions(-) create mode 100644 src/graphql/lib/categories.ts create mode 100644 src/graphql/scripts/utils/bucket-by-category.ts diff --git a/src/graphql/lib/categories.ts b/src/graphql/lib/categories.ts new file mode 100644 index 000000000000..0a729a5116a3 --- /dev/null +++ b/src/graphql/lib/categories.ts @@ -0,0 +1,183 @@ +// Canonical mapping of internal schema kinds to: +// - urlKind: the URL/folder segment used in href anchors before categorization +// (kept for backward-compat with `helpers.getFullLink` signature) +// - slugPrefix: the kind-disambiguating slug prefix used on category pages +// so two items sharing a case-insensitive name don't collide +// - label: human-readable label rendered as a Primer Label next to each item + +export type SchemaKindKey = + | 'queries' + | 'mutations' + | 'objects' + | 'interfaces' + | 'enums' + | 'unions' + | 'inputObjects' + | 'scalars' + +export const KIND_LABELS: Record = { + queries: 'Query', + mutations: 'Mutation', + objects: 'Object', + interfaces: 'Interface', + enums: 'Enum', + unions: 'Union', + inputObjects: 'Input object', + scalars: 'Scalar', +} + +// Slug prefix used to disambiguate items across kinds on a category page. +// For example, a `Repository` object and a `repository` query both have id +// `repository`; on a category page they become `object-repository` and +// `query-repository` respectively. +export const KIND_SLUG_PREFIX: Record = { + queries: 'query', + mutations: 'mutation', + objects: 'object', + interfaces: 'interface', + enums: 'enum', + unions: 'union', + inputObjects: 'input-object', + scalars: 'scalar', +} + +// The "URL kind" / `pageType` value used by `helpers.getTypeKind` and +// `helpers.getFullLink`. `inputObjects` (camelCase internal key) becomes +// `input-objects` in URLs. +export const KIND_URL_SEGMENT: Record = { + queries: 'queries', + mutations: 'mutations', + objects: 'objects', + interfaces: 'interfaces', + enums: 'enums', + unions: 'unions', + inputObjects: 'input-objects', + scalars: 'scalars', +} + +export const ALL_KIND_KEYS: SchemaKindKey[] = [ + 'queries', + 'mutations', + 'objects', + 'interfaces', + 'enums', + 'unions', + 'inputObjects', + 'scalars', +] + +// Reverse map from the URL-kind segment used in hrefs (e.g. `input-objects`) +// to the slug prefix used to disambiguate items in category page anchors +// (e.g. `input-object`). Derived from KIND_URL_SEGMENT + KIND_SLUG_PREFIX so +// the three tables stay in sync automatically. +export const SLUG_PREFIX_BY_URL_SEGMENT: Record = Object.fromEntries( + ALL_KIND_KEYS.map((k) => [KIND_URL_SEGMENT[k], KIND_SLUG_PREFIX[k]]), +) + +// Given a URL-kind segment (as returned by helpers.getTypeKind, e.g. +// `objects`, `input-objects`), return the slug prefix used to disambiguate +// items in category page anchors. Falls back to the input for unknown kinds. +export function slugPrefixForUrlKind(urlKind: string): string { + return SLUG_PREFIX_BY_URL_SEGMENT[urlKind] ?? urlKind +} + +// Bucket all items that don't have an upstream `@docsCategory` directive. +export const OTHER_CATEGORY = 'other' + +// Canonical list of categories emitted by the upstream `docs_category` DSL. +// Keep this list in sync with the allowlist in +// `github/github`'s `app/platform/objects/base/docs_category.rb`. +// `other` is a docs-internal bucket for un-annotated types. +export const CATEGORIES = [ + 'actions', + 'activity', + 'apps', + 'audit-log', + 'billing', + 'branches', + 'checks', + 'code-scanning', + 'code-security', + 'codespaces', + 'collaborators', + 'commits', + 'copilot', + 'dependabot', + 'dependency-graph', + 'deploy-keys', + 'deployments', + 'discussions', + 'enterprise-admin', + 'gists', + 'git', + 'interactions', + 'issues', + 'licenses', + 'meta', + 'migrations', + 'orgs', + 'packages', + 'pages', + 'projects', + 'projects-classic', + 'pulls', + 'reactions', + 'releases', + 'repos', + 'scim', + 'search', + 'secret-scanning', + 'security-advisories', + 'sponsors', + 'teams', + 'users', + OTHER_CATEGORY, +] as const + +export type CategorySlug = (typeof CATEGORIES)[number] + +export function isValidCategory(slug: string): slug is CategorySlug { + return (CATEGORIES as readonly string[]).includes(slug) +} + +// Human-readable display title for a category. Falls back to slug. +export function categoryTitle(slug: string): string { + switch (slug) { + case 'apps': + return 'GitHub Apps' + case 'audit-log': + return 'Audit log' + case 'code-scanning': + return 'Code scanning' + case 'code-security': + return 'Code security' + case 'codespaces': + return 'Codespaces' + case 'dependency-graph': + return 'Dependency graph' + case 'deploy-keys': + return 'Deploy keys' + case 'enterprise-admin': + return 'Enterprise administration' + case 'meta': + return 'Meta' + case 'orgs': + return 'Organizations' + case 'projects-classic': + return 'Projects (classic)' + case 'pulls': + return 'Pull requests' + case 'repos': + return 'Repositories' + case 'scim': + return 'SCIM' + case 'secret-scanning': + return 'Secret scanning' + case 'security-advisories': + return 'Security advisories' + case OTHER_CATEGORY: + return 'Other' + default: + return slug.charAt(0).toUpperCase() + slug.slice(1) + } +} diff --git a/src/graphql/scripts/sync.ts b/src/graphql/scripts/sync.ts index 386193b14d37..eacdc621c85d 100755 --- a/src/graphql/scripts/sync.ts +++ b/src/graphql/scripts/sync.ts @@ -9,6 +9,7 @@ import { allVersions } from '@/versions/lib/all-versions' import processPreviews from './utils/process-previews' import processUpcomingChanges from './utils/process-upcoming-changes' import processSchemas from './utils/process-schemas' +import { bucketSchemaByCategory, writeCategoryFiles } from './utils/bucket-by-category' import { prependDatedEntry, createChangelogEntry, @@ -110,12 +111,55 @@ async function main() { ...preview, toggled_by: [preview.toggled_by].flat(), })) - const schemaJsonPerVersion = await processSchemas(latestSchema, previewsForSchema) // This is slow! + // Fallback category source for GHES versions that pre-date the upstream + // `@docsCategory` DSL (DSL landed on master 2026-05-07; GHES 3.16-3.21 + // were cut at the 3.21 freeze 2026-03-19). Without this, every type on + // those versions gets bucketed as "other". GHES 3.22+ is expected to + // include the DSL natively so it's excluded from the fallback. + let fallbackCategoryMap: Record> | undefined + const ghesMatch = /^ghes-(\d+)\.(\d+)$/.exec(graphqlVersion) + if (ghesMatch) { + const major = Number(ghesMatch[1]) + const minor = Number(ghesMatch[2]) + if (major < 3 || (major === 3 && minor < 22)) { + try { + fallbackCategoryMap = JSON.parse( + await fs.readFile(path.join(graphqlStaticDir, 'fpt', 'category-map.json'), 'utf8'), + ) + console.log(`Using fpt/category-map.json as @docsCategory fallback for ${graphqlVersion}`) + } catch { + // fpt hasn't been processed yet (shouldn't happen given iteration + // order, but stay defensive). Without it, ghes types fall to "other", + // so warn loudly rather than silently mis-bucketing the schema. + console.warn( + `No fpt/category-map.json available as @docsCategory fallback for ${graphqlVersion}; ` + + `every type without an explicit @docsCategory directive will be bucketed as "other". ` + + `This usually means sync was run for a single GHES version, or the iteration order of ` + + `allVersions changed so that fpt is no longer processed first.`, + ) + } + } + } + const schemaJsonPerVersion = await processSchemas( + latestSchema, + previewsForSchema, + fallbackCategoryMap, + ) // This is slow! + + // Keep writing the monolithic `schema.json` so the existing runtime + // loader continues to work. The per-category files are emitted alongside + // it so a follow-up PR can flip the loader over without coordinating a + // sync run. await updateStaticFile( schemaJsonPerVersion, path.join(graphqlStaticDir, graphqlVersion, 'schema.json'), ) + // Split the schema by category so the runtime can lazily load only the + // bucket it needs for a given page request. + const perCategoryFiles = bucketSchemaByCategory(schemaJsonPerVersion) + await writeCategoryFiles(path.join(graphqlStaticDir, graphqlVersion), perCategoryFiles) + // 4. UPDATE CHANGELOG if (allVersions[version].nonEnterpriseDefault) { // The changelog is only built for free-pro-team@latest diff --git a/src/graphql/scripts/utils/bucket-by-category.ts b/src/graphql/scripts/utils/bucket-by-category.ts new file mode 100644 index 000000000000..6af2e3b3a709 --- /dev/null +++ b/src/graphql/scripts/utils/bucket-by-category.ts @@ -0,0 +1,152 @@ +import fs from 'fs/promises' +import path from 'path' +import { mkdirp } from 'mkdirp' +import { + ALL_KIND_KEYS, + CATEGORIES, + KIND_URL_SEGMENT, + OTHER_CATEGORY, + slugPrefixForUrlKind, + type SchemaKindKey, +} from '@/graphql/lib/categories' + +// Item shape from process-schemas; we only need the `category` field here so +// we keep this loose to avoid pulling all the precise interfaces. +type CategorizedItem = { category?: string; name?: string; id?: string } + +export type CategoryBuckets = Map>> + +// Matches the legacy href format that process-schemas emits, e.g. +// `/graphql/reference/objects#repository`. Captures the url-kind segment +// and the id so the bucketer can rewrite into the category-aware form. +const LEGACY_HREF_RE = /^\/graphql\/reference\/([a-z][a-z-]*)#([a-z0-9-]+)$/ + +type CategoryLookup = Map> + +function buildCategoryLookup(buckets: CategoryBuckets): CategoryLookup { + const lookup: CategoryLookup = new Map() + for (const kind of ALL_KIND_KEYS) { + const urlKind = KIND_URL_SEGMENT[kind] + const byId = new Map() + for (const [cat, bucket] of buckets.entries()) { + for (const item of bucket[kind] ?? []) { + const id = (item.id ?? item.name ?? '').toLowerCase() + if (id) byId.set(id, cat) + } + } + lookup.set(urlKind, byId) + } + return lookup +} + +function rewriteHref(href: string, lookup: CategoryLookup): string { + const match = LEGACY_HREF_RE.exec(href) + if (!match) return href + const [, urlKind, id] = match + const category = lookup.get(urlKind)?.get(id) ?? OTHER_CATEGORY + return `/graphql/reference/${category}#${slugPrefixForUrlKind(urlKind)}-${id}` +} + +// Walk a processed item recursively, rewriting any string value that looks +// like a legacy `/graphql/reference/#` href into the +// category-aware form. Mutates in place; the monolithic schema.json has +// already been written to disk before this runs. +function rewriteHrefsInPlace(value: unknown, lookup: CategoryLookup): void { + if (Array.isArray(value)) { + for (const v of value) rewriteHrefsInPlace(v, lookup) + return + } + if (value && typeof value === 'object') { + const obj = value as Record + for (const key of Object.keys(obj)) { + const v = obj[key] + if (typeof v === 'string' && v.startsWith('/graphql/reference/')) { + obj[key] = rewriteHref(v, lookup) + } else { + rewriteHrefsInPlace(v, lookup) + } + } + } +} + +// Group a processed schema (one big `{queries, mutations, ...}` object) into +// one bucket per category. Each bucket only contains the kinds that have +// items in that category. +export function bucketSchemaByCategory( + schema: Record, +): CategoryBuckets { + const buckets: CategoryBuckets = new Map() + for (const kind of ALL_KIND_KEYS) { + const items = schema[kind] || [] + for (const item of items) { + const cat = item.category ?? OTHER_CATEGORY + let bucket = buckets.get(cat) + if (!bucket) { + bucket = {} + buckets.set(cat, bucket) + } + if (!bucket[kind]) bucket[kind] = [] + bucket[kind]!.push(item) + } + } + + // After grouping, rewrite cross-reference hrefs from the legacy + // `/graphql/reference/#` form into the category-aware + // `/graphql/reference/#-` form so per-category + // files link to their sibling files. The monolithic `schema.json` is + // serialized to disk before this runs (see sync.ts), so it keeps the + // legacy hrefs the existing runtime expects. + const lookup = buildCategoryLookup(buckets) + for (const bucket of buckets.values()) { + rewriteHrefsInPlace(bucket, lookup) + } + + return buckets +} + +// Write `schema-.json` files into `dir`. Categories with no items +// for this version get an empty file so the loader has a deterministic file +// to consume (rather than relying on filesystem stat). +export async function writeCategoryFiles(dir: string, buckets: CategoryBuckets): Promise { + await mkdirp(dir) + // First, delete any stale schema-*.json files so a category that becomes + // empty in a new sync doesn't leave behind a stale file. + let existing: string[] = [] + try { + existing = await fs.readdir(dir) + } catch { + existing = [] + } + for (const file of existing) { + if (file.startsWith('schema-') && file.endsWith('.json')) { + try { + await fs.unlink(path.join(dir, file)) + } catch { + // ignore + } + } + } + for (const cat of CATEGORIES) { + const bucket = buckets.get(cat) ?? {} + const filepath = path.join(dir, `schema-${cat}.json`) + console.log(`Updating static file ${filepath}`) + await fs.writeFile(filepath, JSON.stringify(bucket, null, 2), 'utf8') + } + + // Also emit a small category-map.json used at runtime by the GraphQL + // category redirect middleware. Shape: { [kindKey]: { [id]: category } } + const categoryMap: Partial>> = {} + for (const kind of ALL_KIND_KEYS) { + const byId: Record = {} + for (const [cat, bucket] of buckets.entries()) { + for (const item of bucket[kind] ?? []) { + const key = (item.id ?? item.name ?? '').toLowerCase() + if (key) byId[key] = cat + } + } + if (Object.keys(byId).length > 0) categoryMap[kind] = byId + } + const mapPath = path.join(dir, 'category-map.json') + console.log(`Updating static file ${mapPath}`) + await fs.writeFile(mapPath, JSON.stringify(categoryMap, null, 2), 'utf8') +} diff --git a/src/graphql/scripts/utils/process-schemas.ts b/src/graphql/scripts/utils/process-schemas.ts index b0f4cf7e93b6..a4b92e1533fb 100755 --- a/src/graphql/scripts/utils/process-schemas.ts +++ b/src/graphql/scripts/utils/process-schemas.ts @@ -3,12 +3,14 @@ import { parse, buildASTSchema, GraphQLSchema } from 'graphql' import type { DocumentNode, ObjectTypeDefinitionNode, + InputObjectTypeDefinitionNode, FieldDefinitionNode, InputValueDefinitionNode, ConstDirectiveNode, DefinitionNode, } from 'graphql/language' import helpers from './schema-helpers' +import { OTHER_CATEGORY, isValidCategory } from '@/graphql/lib/categories' import fs from 'fs/promises' import path from 'path' @@ -199,6 +201,11 @@ interface ProcessedSchemaData { scalars: ScalarInfo[] } +// All processed items get an optional `category` field once the schema has +// been categorized. Using `& { category: string }` at the type level would +// require touching every interface, so we keep it loose here and rely on the +// runtime guarantee that every emitted item has a category. + const externalScalarsJSON: Array<{ name: string; description: string }> = JSON.parse( await fs.readFile(path.join(process.cwd(), './src/graphql/lib/non-schema-scalars.json'), 'utf-8'), ) @@ -206,21 +213,35 @@ const externalScalars: ScalarInfo[] = await Promise.all( externalScalarsJSON.map(async (scalar): Promise => { const description = await helpers.getDescription(scalar.description) const id = helpers.getId(scalar.name) + // External scalars (e.g. Date, URI) are not annotated upstream and live + // in the "other" bucket. Emit the legacy href; bucket-by-category will + // rewrite it to the category-aware form for per-category files. const href = helpers.getFullLink('scalars', id) return { name: scalar.name, description, id, href, - } + category: OTHER_CATEGORY, + } as ScalarInfo & { category: string } }), ) // select and format all the data from the schema that we need for the docs // used in the build step +// Shape of the per-version `category-map.json` used both at runtime by the +// redirect middleware and (here) at build time as a fallback source of +// categories when a schema lacks `@docsCategory` directives. +type CategoryMapFallback = Partial>> + export default async function processSchemas( idl: Buffer | string, previewsPerVersion: PreviewInfo[], + // Optional fallback used when the IDL itself has no `@docsCategory` + // directives (e.g. GHES branches cut before the upstream DSL existed). + // Lookups for type-level categories use the type id; mutations look up + // by mutation field name under the `mutations` key. + fallbackCategoryMap?: CategoryMapFallback, ): Promise { const schemaAST: DocumentNode = parse(idl.toString()) const schema: GraphQLSchema = buildASTSchema(schemaAST) @@ -230,6 +251,168 @@ export default async function processSchemas( (def): def is ObjectTypeDefinitionNode => def.kind === 'ObjectTypeDefinition', ) + // PASS 1: Build a typeId -> category map by reading the @docsCategory + // directive on every categorizable definition. Queries derive their + // category from the return type's category; mutations are annotated on + // each Mutation root field rather than on a type, so we collect those + // separately. + const typeCategoryMap = new Map() + const mutationFieldCategoryMap = new Map() + + for (const def of schemaAST.definitions) { + if (def.kind === 'ObjectTypeDefinition' && def.name.value === 'Mutation') { + for (const field of def.fields || []) { + const cat = helpers.getDocsCategory( + (field.directives || []) as readonly ConstDirectiveNode[], + ) + if (cat) mutationFieldCategoryMap.set(field.name.value, cat) + } + continue + } + if (def.kind === 'ObjectTypeDefinition' && def.name.value === 'Query') continue + + if ( + def.kind === 'ObjectTypeDefinition' || + def.kind === 'InterfaceTypeDefinition' || + def.kind === 'UnionTypeDefinition' || + def.kind === 'EnumTypeDefinition' || + def.kind === 'InputObjectTypeDefinition' || + def.kind === 'ScalarTypeDefinition' + ) { + const cat = helpers.getDocsCategory((def.directives || []) as readonly ConstDirectiveNode[]) + if (cat) typeCategoryMap.set(helpers.getId(def.name.value), cat) + } + } + + // Build a flat fallback id -> cat map across every type-level kind. (We + // exclude queries: query categories are derived from the return type. + // Mutations are kept separately since they're keyed by field name.) + const fallbackTypeMap: Record = {} + if (fallbackCategoryMap) { + for (const kind of Object.keys(fallbackCategoryMap)) { + if (kind === 'queries' || kind === 'mutations') continue + const sub = fallbackCategoryMap[kind] || {} + for (const id of Object.keys(sub)) { + // First write wins; in practice ids don't collide across kinds. + if (!(id in fallbackTypeMap)) fallbackTypeMap[id] = sub[id] + } + } + } + const fallbackMutationMap = fallbackCategoryMap?.mutations || {} + + // PASS 1.5: derive categories for types that github/github cannot annotate + // directly. Two rules apply, both run before fallback / OTHER assignment so + // they take effect for fpt and ghec (where the IDL has the annotations) and + // also propagate into the per-version category-map.json that GHES <3.22 + // consumes as its fallback. + // + // (a) Input objects inherit from their owning mutation. The DSL can mark + // a mutation field with @docsCategory but the generated *Input type + // isn't annotated; we copy the mutation's category onto each input + // argument's named type. + // (b) Connection / Edge types inherit from their underlying type. These + // are emitted by graphql-ruby's Relay pagination and never get a + // hand-written docs_category. We walk `node`/`nodes`/`edges` to the + // referenced object type and copy its category. + // + // Explicit annotations always win; derivation only fills gaps. + const lookupCat = (id: string): string | undefined => + typeCategoryMap.get(id) ?? fallbackTypeMap[id] + const getMutationCat = (mutFieldName: string): string | undefined => + mutationFieldCategoryMap.get(mutFieldName) ?? fallbackMutationMap[mutFieldName.toLowerCase()] + + // Walk through a TypeNode chain (NonNull/List wrappers) to the NamedType. + const namedTypeName = (typeNode: any): string | undefined => { + let t = typeNode + while (t && t.type) t = t.type + return t?.name?.value + } + + // (a) input objects from mutation field args + const mutationDef = schemaAST.definitions.find( + (def): def is ObjectTypeDefinitionNode => + def.kind === 'ObjectTypeDefinition' && def.name.value === 'Mutation', + ) + if (mutationDef) { + for (const field of mutationDef.fields || []) { + const mutCat = getMutationCat(field.name.value) + if (!mutCat) continue + for (const arg of field.arguments || []) { + const argTypeName = namedTypeName(arg.type) + if (!argTypeName) continue + const argDef = schemaAST.definitions.find( + (d): d is InputObjectTypeDefinitionNode => + d.kind === 'InputObjectTypeDefinition' && d.name.value === argTypeName, + ) + if (!argDef) continue + const argId = helpers.getId(argTypeName) + if (!typeCategoryMap.has(argId)) typeCategoryMap.set(argId, mutCat) + } + } + } + + // (b) Connection / Edge types from their underlying type. Run multiple + // passes so an XConnection that points at XEdge can still resolve after + // XEdge itself has been derived (Connection -> Edge -> object). + const objectDefs = schemaAST.definitions.filter( + (def): def is ObjectTypeDefinitionNode => def.kind === 'ObjectTypeDefinition', + ) + for (let pass = 0; pass < 5; pass++) { + let changed = false + for (const def of objectDefs) { + const name = def.name.value + if (name === 'Query' || name === 'Mutation') continue + const isEdge = name.endsWith('Edge') + const isConn = name.endsWith('Connection') + if (!isEdge && !isConn) continue + const id = helpers.getId(name) + if (lookupCat(id)) continue + // Edge: walk `node`. Connection: prefer `nodes` (direct), else `edges`. + const fields = def.fields || [] + let underlyingName: string | undefined + if (isEdge) { + const node = fields.find((f) => f.name.value === 'node') + if (node) underlyingName = namedTypeName(node.type) + } else { + const nodes = fields.find((f) => f.name.value === 'nodes') + if (nodes) underlyingName = namedTypeName(nodes.type) + if (!underlyingName) { + const edges = fields.find((f) => f.name.value === 'edges') + if (edges) underlyingName = namedTypeName(edges.type) + } + } + if (!underlyingName) continue + const underlyingCat = lookupCat(helpers.getId(underlyingName)) + if (underlyingCat) { + typeCategoryMap.set(id, underlyingCat) + changed = true + } + } + if (!changed) break + } + + // Normalize unknown categories (e.g. `:checks`, `:search`, `:packages`, + // `:security_advisories`) to `other`. The upstream gh/gh allowlist permits + // many categories that docs-internal hasn't yet built per-category landing + // pages for; without this fallback those types would be silently dropped + // by `writeCategoryFiles` (which only emits files for slugs in CATEGORIES) + // and their redirects would 404. Once a page exists for a category, add it + // to CATEGORIES in src/graphql/lib/categories.ts and types will move out + // of `other` on the next sync. + // Resolver used to populate the top-level `.category` field on every + // processed item. The bucketer reads `.category` to split the schema into + // per-category files and to rewrite cross-reference hrefs. + const resolveCategory = (typeId: string): string => { + const cat = typeCategoryMap.get(typeId) ?? fallbackTypeMap[typeId] ?? OTHER_CATEGORY + return isValidCategory(cat) ? cat : OTHER_CATEGORY + } + + // process-schemas emits legacy `/graphql/reference/#` hrefs + // throughout so the monolithic `schema.json` stays compatible with the + // existing runtime loader. The bucketer rewrites these to the + // category-aware form when emitting per-category schema files. + const linkTo = (urlKind: string, id: string): string => helpers.getFullLink(urlKind, id) + const data: ProcessedSchemaData = { queries: [], mutations: [], @@ -257,7 +440,7 @@ export default async function processSchemas( const fieldKind = helpers.getTypeKind(query.type, schema) if (!fieldKind) return query.id = helpers.getId(query.type) - query.href = helpers.getFullLink(fieldKind, query.id) + query.href = linkTo(fieldKind, query.id) query.description = await helpers.getDescription(field.description?.value || '') query.isDeprecated = helpers.getDeprecationStatus( (field.directives || []) as readonly ConstDirectiveNode[], @@ -287,7 +470,7 @@ export default async function processSchemas( queryArg.id = helpers.getId(queryArg.type) const argKind = helpers.getTypeKind(queryArg.type, schema) if (!argKind) return - queryArg.href = helpers.getFullLink(argKind, queryArg.id) + queryArg.href = linkTo(argKind, queryArg.id) queryArg.description = await helpers.getDescription(arg.description?.value || '') queryArg.isDeprecated = helpers.getDeprecationStatus( (arg.directives || []) as readonly ConstDirectiveNode[], @@ -306,6 +489,8 @@ export default async function processSchemas( ) query.args = sortBy(queryArgs, 'name') + // Queries inherit the category of their return type. + ;(query as QueryInfo & { category: string }).category = resolveCategory(query.id!) data.queries.push(query as QueryInfo) }), ) @@ -323,6 +508,17 @@ export default async function processSchemas( mutation.name = field.name.value mutation.id = helpers.getId(mutation.name) + // Mutation fields carry @docsCategory at the field level on the + // Mutation root, not on the payload type, so use the field map. + // Normalize via isValidCategory so an upstream-only category + // doesn't produce hrefs/buckets we don't ship pages for. + const rawMutationCategory = + mutationFieldCategoryMap.get(mutation.name) ?? + fallbackMutationMap[mutation.name.toLowerCase()] ?? + OTHER_CATEGORY + const mutationCategory = isValidCategory(rawMutationCategory) + ? rawMutationCategory + : OTHER_CATEGORY mutation.href = helpers.getFullLink('mutations', mutation.id) mutation.description = await helpers.getDescription(field.description?.value || '') mutation.isDeprecated = helpers.getDeprecationStatus( @@ -350,7 +546,7 @@ export default async function processSchemas( inputField.id = helpers.getId(inputField.type) const argKind = helpers.getTypeKind(inputField.type, schema) if (!argKind) return - inputField.href = helpers.getFullLink(argKind, inputField.id) + inputField.href = linkTo(argKind, inputField.id) inputFields.push(inputField as InputFieldInfo) }), ) @@ -380,7 +576,7 @@ export default async function processSchemas( returnField.id = helpers.getId(returnField.type) const fieldKind = helpers.getTypeKind(returnField.type, schema) if (!fieldKind) return - returnField.href = helpers.getFullLink(fieldKind, returnField.id) + returnField.href = linkTo(fieldKind, returnField.id) returnField.description = await helpers.getDescription( returnFieldDef.description?.value || '', ) @@ -401,7 +597,7 @@ export default async function processSchemas( ) mutation.returnFields = sortBy(returnFields, 'name') - + ;(mutation as MutationInfo & { category: string }).category = mutationCategory data.mutations.push(mutation as MutationInfo) }), ) @@ -420,7 +616,7 @@ export default async function processSchemas( object.name = def.name.value object.id = helpers.getId(object.name) - object.href = helpers.getFullLink('objects', object.id) + object.href = linkTo('objects', object.id) object.description = await helpers.getDescription(def.description?.value || '') object.isDeprecated = helpers.getDeprecationStatus( (def.directives || []) as readonly ConstDirectiveNode[], @@ -443,7 +639,7 @@ export default async function processSchemas( const objectInterface: InterfaceInfo = { name: graphqlInterface.name.value, id: helpers.getId(graphqlInterface.name.value), - href: helpers.getFullLink('interfaces', helpers.getId(graphqlInterface.name.value)), + href: linkTo('interfaces', helpers.getId(graphqlInterface.name.value)), } objectImplements.push(objectInterface) }), @@ -466,7 +662,7 @@ export default async function processSchemas( objectField.id = helpers.getId(objectField.type) const fieldKind = helpers.getTypeKind(objectField.type, schema) if (!fieldKind) return - objectField.href = helpers.getFullLink(fieldKind, objectField.id) + objectField.href = linkTo(fieldKind, objectField.id) // InputValueDefinitionNode structure is compatible with ArgumentNode expected by getArguments objectField.arguments = await helpers.getArguments( (field.arguments || []) as any, @@ -492,7 +688,7 @@ export default async function processSchemas( if (objectImplements.length) object.implements = sortBy(objectImplements, 'name') if (objectFields.length) object.fields = sortBy(objectFields, 'name') - + ;(object as ObjectInfo & { category: string }).category = resolveCategory(object.id!) data.objects.push(object as ObjectInfo) return } @@ -504,7 +700,7 @@ export default async function processSchemas( graphqlInterface.name = def.name.value graphqlInterface.id = helpers.getId(graphqlInterface.name) - graphqlInterface.href = helpers.getFullLink('interfaces', graphqlInterface.id) + graphqlInterface.href = linkTo('interfaces', graphqlInterface.id) graphqlInterface.description = await helpers.getDescription(def.description?.value || '') graphqlInterface.isDeprecated = helpers.getDeprecationStatus( (def.directives || []) as readonly ConstDirectiveNode[], @@ -535,7 +731,7 @@ export default async function processSchemas( interfaceField.id = helpers.getId(interfaceField.type) const fieldKind = helpers.getTypeKind(interfaceField.type, schema) if (!fieldKind) return - interfaceField.href = helpers.getFullLink(fieldKind, interfaceField.id) + interfaceField.href = linkTo(fieldKind, interfaceField.id) // InputValueDefinitionNode structure is compatible with ArgumentNode expected by getArguments interfaceField.arguments = await helpers.getArguments( (field.arguments || []) as any, @@ -560,7 +756,8 @@ export default async function processSchemas( } graphqlInterface.fields = sortBy(interfaceFields, 'name') - + ;(graphqlInterface as GraphQLInterfaceInfo & { category: string }).category = + resolveCategory(graphqlInterface.id!) data.interfaces.push(graphqlInterface as GraphQLInterfaceInfo) return } @@ -572,7 +769,7 @@ export default async function processSchemas( graphqlEnum.name = def.name.value graphqlEnum.id = helpers.getId(graphqlEnum.name) - graphqlEnum.href = helpers.getFullLink('enums', graphqlEnum.id) + graphqlEnum.href = linkTo('enums', graphqlEnum.id) graphqlEnum.description = await helpers.getDescription(def.description?.value || '') graphqlEnum.isDeprecated = helpers.getDeprecationStatus( (def.directives || []) as readonly ConstDirectiveNode[], @@ -598,7 +795,9 @@ export default async function processSchemas( ) graphqlEnum.values = sortBy(enumValues, 'name') - + ;(graphqlEnum as EnumInfo & { category: string }).category = resolveCategory( + graphqlEnum.id!, + ) data.enums.push(graphqlEnum as EnumInfo) return } @@ -610,7 +809,7 @@ export default async function processSchemas( union.name = def.name.value union.id = helpers.getId(union.name) - union.href = helpers.getFullLink('unions', union.id) + union.href = linkTo('unions', union.id) union.description = await helpers.getDescription(def.description?.value || '') union.isDeprecated = helpers.getDeprecationStatus( (def.directives || []) as readonly ConstDirectiveNode[], @@ -631,14 +830,14 @@ export default async function processSchemas( const possibleType: PossibleTypeInfo = { name: type.name.value, id: helpers.getId(type.name.value), - href: helpers.getFullLink('objects', helpers.getId(type.name.value)), + href: linkTo('objects', helpers.getId(type.name.value)), } possibleTypes.push(possibleType) }), ) union.possibleTypes = sortBy(possibleTypes, 'name') - + ;(union as UnionInfo & { category: string }).category = resolveCategory(union.id!) data.unions.push(union as UnionInfo) return } @@ -653,7 +852,7 @@ export default async function processSchemas( inputObject.name = def.name.value inputObject.id = helpers.getId(inputObject.name) - inputObject.href = helpers.getFullLink('input-objects', inputObject.id) + inputObject.href = linkTo('input-objects', inputObject.id) inputObject.description = await helpers.getDescription(def.description?.value || '') inputObject.isDeprecated = helpers.getDeprecationStatus( (def.directives || []) as readonly ConstDirectiveNode[], @@ -682,7 +881,7 @@ export default async function processSchemas( inputField.id = helpers.getId(inputField.type) const fieldKind = helpers.getTypeKind(inputField.type, schema) if (!fieldKind) return - inputField.href = helpers.getFullLink(fieldKind, inputField.id) + inputField.href = linkTo(fieldKind, inputField.id) inputField.isDeprecated = helpers.getDeprecationStatus( (field.directives || []) as readonly ConstDirectiveNode[], ) @@ -702,7 +901,9 @@ export default async function processSchemas( } inputObject.inputFields = sortBy(inputFields, 'name') - + ;(inputObject as InputObjectInfo & { category: string }).category = resolveCategory( + inputObject.id!, + ) data.inputObjects.push(inputObject as InputObjectInfo) return } @@ -712,7 +913,7 @@ export default async function processSchemas( const scalar: ScalarInfo = { name: def.name.value, id: helpers.getId(def.name.value), - href: helpers.getFullLink('scalars', helpers.getId(def.name.value)), + href: linkTo('scalars', helpers.getId(def.name.value)), description: await helpers.getDescription(def.description?.value || ''), isDeprecated: helpers.getDeprecationStatus( (def.directives || []) as readonly ConstDirectiveNode[], @@ -729,6 +930,7 @@ export default async function processSchemas( previewsPerVersion, ), } + ;(scalar as ScalarInfo & { category: string }).category = resolveCategory(scalar.id) data.scalars.push(scalar) } }), diff --git a/src/graphql/scripts/utils/schema-helpers.ts b/src/graphql/scripts/utils/schema-helpers.ts index 4f6b70234eac..5cea653153c3 100644 --- a/src/graphql/scripts/utils/schema-helpers.ts +++ b/src/graphql/scripts/utils/schema-helpers.ts @@ -12,6 +12,8 @@ import { import type { ConstDirectiveNode } from 'graphql/language' import path from 'path' +import { slugPrefixForUrlKind } from '@/graphql/lib/categories' + interface GraphQLTypeInfo { type: string kind: string @@ -81,7 +83,11 @@ async function getArguments( type.id = getId(typeName) const typeKind = getTypeKind(typeName, schema) if (!typeKind) continue // Skip if type kind cannot be determined - type.href = getFullLink(typeKind, type.id) + // process-schemas always emits legacy `/graphql/reference/#` + // hrefs. bucket-by-category rewrites them into the category-aware form + // when splitting into per-category files, so monolithic schema.json stays + // byte-stable with what the existing runtime expects. + type.href = getFullLink(typeKind, type.id!) newArg.type = type as TypeInfo newArgs.push(newArg as ArgumentInfo) } @@ -89,6 +95,13 @@ async function getArguments( return newArgs } +// Build a category-aware anchor link for a type, e.g. +// `/graphql/reference/repos#object-repository`. Exposed for the bucketer's +// href-rewrite pass; process-schemas itself uses the legacy `getFullLink`. +export function buildCategoryHref(category: string, urlKind: string, id: string): string { + return `/graphql/reference/${category}#${slugPrefixForUrlKind(urlKind)}-${id}` +} + async function getDeprecationReason( directives: readonly ConstDirectiveNode[], schemaMember: SchemaMember, @@ -126,6 +139,18 @@ function getFullLink(baseType: string, id: string): string { return `/graphql/reference/${baseType}#${id}` } +// Extract the `@docsCategory(name: "...")` value from a directive list. +// Returns undefined when the directive is absent. +function getDocsCategory(directives: readonly ConstDirectiveNode[]): string | undefined { + const directive = directives.find((dir) => dir.name.value === 'docsCategory') + if (!directive) return + const nameArg = directive.arguments?.find((arg) => arg.name.value === 'name') + if (!nameArg) return + const value = (nameArg as any).value + if (!value || value.kind !== 'StringValue') return + return value.value +} + function getId(typeName: string): string { return removeMarkers(typeName).toLowerCase() } @@ -256,6 +281,7 @@ export default { getDeprecationReason, getDeprecationStatus, getDescription, + getDocsCategory, getFullLink, getId, getKind, From 6346e7ac738ba0e3733df4a717a9cff8f2e86fd5 Mon Sep 17 00:00:00 2001 From: docs-bot <77750099+docs-bot@users.noreply.github.com> Date: Thu, 28 May 2026 10:43:16 -0700 Subject: [PATCH 4/5] feat: add ReleaseNotesTransformer for article API (#61436) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Kevin Heis --- .../tests/release-notes-transformer.ts | 187 ++++++++++++++++++ src/article-api/transformers/index.ts | 2 + .../transformers/release-notes-transformer.ts | 126 ++++++++++++ 3 files changed, 315 insertions(+) create mode 100644 src/article-api/tests/release-notes-transformer.ts create mode 100644 src/article-api/transformers/release-notes-transformer.ts diff --git a/src/article-api/tests/release-notes-transformer.ts b/src/article-api/tests/release-notes-transformer.ts new file mode 100644 index 000000000000..f930e609ad68 --- /dev/null +++ b/src/article-api/tests/release-notes-transformer.ts @@ -0,0 +1,187 @@ +import { describe, expect, test, vi } from 'vitest' + +import type { Context, GHESReleasePatch, Page } from '@/types' +import { + ReleaseNotesTransformer, + renderReleaseNotesMarkdown, +} from '@/article-api/transformers/release-notes-transformer' + +// Mock renderContent so the unit tests don't need fixtures and so we can +// assert that markdownRequested: true is always threaded through. The mock +// returns the input unchanged so we can verify the output shape. +vi.mock('@/content-render/index', () => ({ + renderContent: vi.fn(async (template: string, ctx: { markdownRequested?: boolean }) => { + if (!ctx?.markdownRequested) { + throw new Error('renderContent was called without markdownRequested: true') + } + return template + }), +})) + +// Mock the release notes loader so the unit tests don't depend on fixtures +// existing on disk. Returns empty data, which makes the "release not found" +// branch in transform() the deterministic outcome. +vi.mock('@/release-notes/middleware/get-release-notes', () => ({ + getReleaseNotes: vi.fn(() => ({})), +})) + +const makeContext = (overrides: Partial = {}): Context => + ({ + currentVersion: 'enterprise-server@3.21', + currentLanguage: 'en', + ...overrides, + }) as Context + +const makePage = (overrides: Partial = {}): Page => + ({ + layout: 'release-notes', + title: 'GitHub Enterprise Server 3.21 release notes', + intro: '', + renderProp: async () => '', + ...overrides, + }) as unknown as Page + +const samplePatch = (): GHESReleasePatch => ({ + version: '3.21.0', + patchVersion: '0', + downloadVersion: '3.21.0', + release: '3.21', + date: '2026-05-26', + intro: 'Intro paragraph for this patch.', + sections: { + features: ['A new feature note.'], + bugs: ['A bug was fixed.', 'Another bug was fixed.'], + known_issues: [ + { heading: 'Instance administration', notes: ['Sub note one.', 'Sub note two.'] }, + ], + security_fixes: ['**CRITICAL**: Something serious.'], + }, +}) + +describe('ReleaseNotesTransformer', () => { + describe('canTransform', () => { + test('matches pages with layout: release-notes', () => { + const transformer = new ReleaseNotesTransformer() + expect(transformer.canTransform(makePage({ layout: 'release-notes' }))).toBe(true) + }) + + test('does not match pages with other layouts', () => { + const transformer = new ReleaseNotesTransformer() + expect(transformer.canTransform(makePage({ layout: 'default' } as unknown as Page))).toBe( + false, + ) + expect(transformer.canTransform(makePage({ layout: undefined } as unknown as Page))).toBe( + false, + ) + }) + }) + + describe('transform error paths', () => { + test('throws when currentVersion is missing', async () => { + const transformer = new ReleaseNotesTransformer() + await expect( + transformer.transform( + makePage(), + '/x', + makeContext({ currentVersion: undefined as unknown as string }), + ), + ).rejects.toThrow(/No currentVersion/) + }) + + test('throws when plan is not enterprise-server', async () => { + const transformer = new ReleaseNotesTransformer() + await expect( + transformer.transform( + makePage(), + '/x', + makeContext({ currentVersion: 'free-pro-team@latest' }), + ), + ).rejects.toThrow(/only supports enterprise-server/) + }) + + test('throws when release is not found', async () => { + const transformer = new ReleaseNotesTransformer() + await expect( + transformer.transform( + makePage(), + '/x', + makeContext({ currentVersion: 'enterprise-server@0.0' }), + ), + ).rejects.toThrow(/No release notes found/) + }) + }) +}) + +describe('renderReleaseNotesMarkdown', () => { + test('builds H1 title and intro', async () => { + const out = await renderReleaseNotesMarkdown('My Title', 'Intro line.', [], makeContext()) + expect(out).toMatch(/^# My Title\n\nIntro line\.$/) + }) + + test('renders H2 per patch and H3 per section with bulleted notes', async () => { + const out = await renderReleaseNotesMarkdown('Title', '', [samplePatch()], makeContext()) + + expect(out).toContain('## 3.21.0') + expect(out).toContain('**Release date:** 2026-05-26') + expect(out).toContain('Intro paragraph for this patch.') + + expect(out).toContain('### Features') + expect(out).toContain('- A new feature note.') + + expect(out).toContain('### Bug fixes') + expect(out).toContain('- A bug was fixed.') + expect(out).toContain('- Another bug was fixed.') + + expect(out).toContain('### Security fixes') + expect(out).toContain('- **CRITICAL**: Something serious.') + }) + + test('renders { heading, notes } as a nested bulleted list', async () => { + const out = await renderReleaseNotesMarkdown('Title', '', [samplePatch()], makeContext()) + + expect(out).toContain('### Known issues') + // Heading is a top-level bullet, sub-notes are nested under it. + expect(out).toContain('- **Instance administration**') + expect(out).toMatch( + /- \*\*Instance administration\*\*\n {2}- Sub note one\.\n {2}- Sub note two\./, + ) + }) + + test('preserves multi-line notes with continuation indent', async () => { + const patch: GHESReleasePatch = { + ...samplePatch(), + sections: { features: ['Line one.\n\nLine two.'] }, + } + const out = await renderReleaseNotesMarkdown('Title', '', [patch], makeContext()) + expect(out).toMatch(/- Line one\.\n\n {2}Line two\./) + }) + + test('throws on unrecognized note shape', async () => { + const patch: GHESReleasePatch = { + ...samplePatch(), + sections: { features: [{ unknown: 'shape' } as unknown as string] }, + } + await expect(renderReleaseNotesMarkdown('Title', '', [patch], makeContext())).rejects.toThrow( + /Unrecognized release note shape/, + ) + }) + + test('uses raw section key when label is unknown', async () => { + const patch: GHESReleasePatch = { + ...samplePatch(), + sections: { custom_section: ['A note.'] } as unknown as GHESReleasePatch['sections'], + } + const out = await renderReleaseNotesMarkdown('Title', '', [patch], makeContext()) + expect(out).toContain('### custom_section') + }) + + test('skips empty sections', async () => { + const patch: GHESReleasePatch = { + ...samplePatch(), + sections: { features: [], bugs: ['A bug.'] }, + } + const out = await renderReleaseNotesMarkdown('Title', '', [patch], makeContext()) + expect(out).not.toContain('### Features') + expect(out).toContain('### Bug fixes') + }) +}) diff --git a/src/article-api/transformers/index.ts b/src/article-api/transformers/index.ts index 986f29f0cf84..d5efd9407235 100644 --- a/src/article-api/transformers/index.ts +++ b/src/article-api/transformers/index.ts @@ -15,6 +15,7 @@ import { JourneyLandingTransformer } from './journey-landing-transformer' import { CategoryLandingTransformer } from './category-landing-transformer' import { DiscoveryLandingTransformer } from './discovery-landing-transformer' import { SearchPageTransformer } from './search-page-transformer' +import { ReleaseNotesTransformer } from './release-notes-transformer' import { ArticleTransformer } from './article-transformer' /** @@ -39,6 +40,7 @@ transformerRegistry.register(new JourneyLandingTransformer()) transformerRegistry.register(new CategoryLandingTransformer()) transformerRegistry.register(new DiscoveryLandingTransformer()) transformerRegistry.register(new SearchPageTransformer()) +transformerRegistry.register(new ReleaseNotesTransformer()) // ArticleTransformer is the catch-all — must be registered last. transformerRegistry.register(new ArticleTransformer()) diff --git a/src/article-api/transformers/release-notes-transformer.ts b/src/article-api/transformers/release-notes-transformer.ts new file mode 100644 index 000000000000..804b26351ad8 --- /dev/null +++ b/src/article-api/transformers/release-notes-transformer.ts @@ -0,0 +1,126 @@ +import type { Context, Page, GHESReleasePatch } from '@/types' +import type { PageTransformer } from './types' +import { getReleaseNotes } from '@/release-notes/middleware/get-release-notes' +import { formatReleases } from '@/release-notes/lib/release-notes-utils' +import { renderContent } from '@/content-render/index' + +/** + * Transformer for GHES enterprise-server release notes pages. + * + * The release notes content comes from YAML data files (not the markdown body), + * so the generic ArticleTransformer would return an empty body. This transformer + * fetches the release notes data directly and renders it as markdown. + */ +export class ReleaseNotesTransformer implements PageTransformer { + canTransform(page: Page): boolean { + return page.layout === 'release-notes' + } + + async transform(page: Page, _pathname: string, context: Context): Promise { + const currentVersion = context.currentVersion + if (!currentVersion) { + throw new Error('No currentVersion in context for release notes transformer') + } + + const [plan, release] = currentVersion.split('@') + if (plan !== 'enterprise-server') { + throw new Error(`Release notes transformer only supports enterprise-server, got: ${plan}`) + } + + const releaseNotes = getReleaseNotes('enterprise-server', 'en') + const allReleases = formatReleases(releaseNotes) + + const matchedRelease = allReleases.find((r) => r.version === release) + if (!matchedRelease) { + throw new Error(`No release notes found for enterprise-server@${release}`) + } + + const title = page.title + const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' + + return await renderReleaseNotesMarkdown(title, intro, matchedRelease.patches, context) + } +} + +// Matches the labels used by the web renderer; keep in sync with +// src/release-notes/components/PatchNotes.tsx. +const SECTION_LABELS: Record = { + features: 'Features', + bugs: 'Bug fixes', + known_issues: 'Known issues', + security_fixes: 'Security fixes', + changes: 'Changes', + deprecations: 'Deprecations', + backups: 'Backups', + errata: 'Errata', + closing_down: 'Closing down', + retired: 'Retired', +} + +async function renderNoteMarkdown(raw: string, context: Context): Promise { + return await renderContent(raw, { ...context, markdownRequested: true }) +} + +// Format `text` as a list item at the given indent depth. The first line +// gets the `- ` bullet; continuation lines are indented to align under it, +// so multi-paragraph notes and fenced code blocks stay inside the list item +// per CommonMark/GFM rules. +function bulletize(text: string, depth = 0): string { + const trimmed = text.replace(/\s+$/, '') + if (!trimmed) return '' + const indent = ' '.repeat(depth) + const continuationIndent = `${indent} ` + const [first, ...rest] = trimmed.split('\n') + if (rest.length === 0) return `${indent}- ${first}` + const continuation = rest.map((line) => (line.length ? `${continuationIndent}${line}` : line)) + return `${indent}- ${first}\n${continuation.join('\n')}` +} + +export async function renderReleaseNotesMarkdown( + title: string, + intro: string, + patches: GHESReleasePatch[], + context: Context, +): Promise { + const lines: string[] = [`# ${title}`] + if (intro) lines.push('', intro) + + for (const patch of patches) { + lines.push('', `## ${patch.version}`) + if (patch.date) lines.push('', `**Release date:** ${patch.date}`) + if (patch.intro) { + lines.push('', await renderNoteMarkdown(patch.intro, context)) + } + + for (const [sectionKey, sectionArray] of Object.entries(patch.sections)) { + if (!Array.isArray(sectionArray) || sectionArray.length === 0) continue + const sectionLabel = SECTION_LABELS[sectionKey] || sectionKey + lines.push('', `### ${sectionLabel}`) + + for (const note of sectionArray) { + if (typeof note === 'string') { + const rendered = await renderNoteMarkdown(note, context) + lines.push('', bulletize(rendered)) + } else if ( + note && + typeof note === 'object' && + 'heading' in note && + 'notes' in note && + Array.isArray((note as { notes: unknown }).notes) + ) { + const heading = (note as { heading: string }).heading + const subNotes = (note as { notes: string[] }).notes + lines.push('', `- **${heading}**`) + for (const subNote of subNotes) { + const rendered = await renderNoteMarkdown(subNote, context) + lines.push(bulletize(rendered, 1)) + } + } else { + throw new Error(`Unrecognized release note shape in section ${sectionKey}`) + } + } + } + } + + return lines.join('\n') +} From 52e3e0d25f3bf0e16d8ffd6468995314a73d8be8 Mon Sep 17 00:00:00 2001 From: docs-bot <77750099+docs-bot@users.noreply.github.com> Date: Thu, 28 May 2026 10:43:27 -0700 Subject: [PATCH 5/5] GraphQL schema update (#61460) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- src/graphql/data/fpt/schema.docs.graphql | 10 +++++----- src/graphql/data/fpt/schema.json | 5 +++++ src/graphql/data/ghec/schema.docs.graphql | 10 +++++----- src/graphql/data/ghec/schema.json | 5 +++++ 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/graphql/data/fpt/schema.docs.graphql b/src/graphql/data/fpt/schema.docs.graphql index d75e967f11d2..d57c28fb35d1 100644 --- a/src/graphql/data/fpt/schema.docs.graphql +++ b/src/graphql/data/fpt/schema.docs.graphql @@ -4968,7 +4968,7 @@ Choose which tools must provide code scanning results before the reference is updated. When configured, code scanning must be enabled and have results for both the commit and the reference being updated. """ -type CodeScanningParameters { +type CodeScanningParameters @docsCategory(name: "repos") { """ Tools that must provide code scanning results for this rule to pass. """ @@ -4980,7 +4980,7 @@ Choose which tools must provide code scanning results before the reference is updated. When configured, code scanning must be enabled and have results for both the commit and the reference being updated. """ -input CodeScanningParametersInput { +input CodeScanningParametersInput @docsCategory(name: "repos") { """ Tools that must provide code scanning results for this rule to pass. """ @@ -4990,7 +4990,7 @@ input CodeScanningParametersInput { """ A tool that must provide code scanning results for this rule to pass. """ -type CodeScanningTool { +type CodeScanningTool @docsCategory(name: "repos") { """ The severity level at which code scanning results that raise alerts block a reference update. For more information on alert severity levels, see "[About code scanning alerts](${externalDocsUrl}/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts#about-alert-severity-and-security-severity-levels)." @@ -5013,7 +5013,7 @@ type CodeScanningTool { """ A tool that must provide code scanning results for this rule to pass. """ -input CodeScanningToolInput { +input CodeScanningToolInput @docsCategory(name: "repos") { """ The severity level at which code scanning results that raise alerts block a reference update. For more information on alert severity levels, see "[About code scanning alerts](${externalDocsUrl}/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts#about-alert-severity-and-security-severity-levels)." @@ -43698,7 +43698,7 @@ type PullRequest implements Assignable & Closable & Comment & Labelable & Lockab """ Array of allowed merge methods. Allowed values include `merge`, `squash`, and `rebase`. At least one option must be enabled. """ -enum PullRequestAllowedMergeMethods { +enum PullRequestAllowedMergeMethods @docsCategory(name: "pulls") { """ Add all commits from the head branch to the base branch with a merge commit. """ diff --git a/src/graphql/data/fpt/schema.json b/src/graphql/data/fpt/schema.json index 1e3881de37d0..d80f7d37f861 100644 --- a/src/graphql/data/fpt/schema.json +++ b/src/graphql/data/fpt/schema.json @@ -13305,6 +13305,7 @@ "id": "codescanningparameters", "href": "/graphql/reference/objects#codescanningparameters", "description": "

Choose which tools must provide code scanning results before the reference is\nupdated. When configured, code scanning must be enabled and have results for\nboth the commit and the reference being updated.

", + "isDeprecated": false, "fields": [ { "name": "codeScanningTools", @@ -13320,6 +13321,7 @@ "id": "codescanningtool", "href": "/graphql/reference/objects#codescanningtool", "description": "

A tool that must provide code scanning results for this rule to pass.

", + "isDeprecated": false, "fields": [ { "name": "alertsThreshold", @@ -85646,6 +85648,7 @@ "id": "pullrequestallowedmergemethods", "href": "/graphql/reference/enums#pullrequestallowedmergemethods", "description": "

Array of allowed merge methods. Allowed values include merge, squash, and rebase. At least one option must be enabled.

", + "isDeprecated": false, "values": [ { "name": "MERGE", @@ -93467,6 +93470,7 @@ "id": "codescanningparametersinput", "href": "/graphql/reference/input-objects#codescanningparametersinput", "description": "

Choose which tools must provide code scanning results before the reference is\nupdated. When configured, code scanning must be enabled and have results for\nboth the commit and the reference being updated.

", + "isDeprecated": false, "inputFields": [ { "name": "codeScanningTools", @@ -93482,6 +93486,7 @@ "id": "codescanningtoolinput", "href": "/graphql/reference/input-objects#codescanningtoolinput", "description": "

A tool that must provide code scanning results for this rule to pass.

", + "isDeprecated": false, "inputFields": [ { "name": "alertsThreshold", diff --git a/src/graphql/data/ghec/schema.docs.graphql b/src/graphql/data/ghec/schema.docs.graphql index d75e967f11d2..d57c28fb35d1 100644 --- a/src/graphql/data/ghec/schema.docs.graphql +++ b/src/graphql/data/ghec/schema.docs.graphql @@ -4968,7 +4968,7 @@ Choose which tools must provide code scanning results before the reference is updated. When configured, code scanning must be enabled and have results for both the commit and the reference being updated. """ -type CodeScanningParameters { +type CodeScanningParameters @docsCategory(name: "repos") { """ Tools that must provide code scanning results for this rule to pass. """ @@ -4980,7 +4980,7 @@ Choose which tools must provide code scanning results before the reference is updated. When configured, code scanning must be enabled and have results for both the commit and the reference being updated. """ -input CodeScanningParametersInput { +input CodeScanningParametersInput @docsCategory(name: "repos") { """ Tools that must provide code scanning results for this rule to pass. """ @@ -4990,7 +4990,7 @@ input CodeScanningParametersInput { """ A tool that must provide code scanning results for this rule to pass. """ -type CodeScanningTool { +type CodeScanningTool @docsCategory(name: "repos") { """ The severity level at which code scanning results that raise alerts block a reference update. For more information on alert severity levels, see "[About code scanning alerts](${externalDocsUrl}/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts#about-alert-severity-and-security-severity-levels)." @@ -5013,7 +5013,7 @@ type CodeScanningTool { """ A tool that must provide code scanning results for this rule to pass. """ -input CodeScanningToolInput { +input CodeScanningToolInput @docsCategory(name: "repos") { """ The severity level at which code scanning results that raise alerts block a reference update. For more information on alert severity levels, see "[About code scanning alerts](${externalDocsUrl}/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts#about-alert-severity-and-security-severity-levels)." @@ -43698,7 +43698,7 @@ type PullRequest implements Assignable & Closable & Comment & Labelable & Lockab """ Array of allowed merge methods. Allowed values include `merge`, `squash`, and `rebase`. At least one option must be enabled. """ -enum PullRequestAllowedMergeMethods { +enum PullRequestAllowedMergeMethods @docsCategory(name: "pulls") { """ Add all commits from the head branch to the base branch with a merge commit. """ diff --git a/src/graphql/data/ghec/schema.json b/src/graphql/data/ghec/schema.json index 1e3881de37d0..d80f7d37f861 100644 --- a/src/graphql/data/ghec/schema.json +++ b/src/graphql/data/ghec/schema.json @@ -13305,6 +13305,7 @@ "id": "codescanningparameters", "href": "/graphql/reference/objects#codescanningparameters", "description": "

Choose which tools must provide code scanning results before the reference is\nupdated. When configured, code scanning must be enabled and have results for\nboth the commit and the reference being updated.

", + "isDeprecated": false, "fields": [ { "name": "codeScanningTools", @@ -13320,6 +13321,7 @@ "id": "codescanningtool", "href": "/graphql/reference/objects#codescanningtool", "description": "

A tool that must provide code scanning results for this rule to pass.

", + "isDeprecated": false, "fields": [ { "name": "alertsThreshold", @@ -85646,6 +85648,7 @@ "id": "pullrequestallowedmergemethods", "href": "/graphql/reference/enums#pullrequestallowedmergemethods", "description": "

Array of allowed merge methods. Allowed values include merge, squash, and rebase. At least one option must be enabled.

", + "isDeprecated": false, "values": [ { "name": "MERGE", @@ -93467,6 +93470,7 @@ "id": "codescanningparametersinput", "href": "/graphql/reference/input-objects#codescanningparametersinput", "description": "

Choose which tools must provide code scanning results before the reference is\nupdated. When configured, code scanning must be enabled and have results for\nboth the commit and the reference being updated.

", + "isDeprecated": false, "inputFields": [ { "name": "codeScanningTools", @@ -93482,6 +93486,7 @@ "id": "codescanningtoolinput", "href": "/graphql/reference/input-objects#codescanningtoolinput", "description": "

A tool that must provide code scanning results for this rule to pass.

", + "isDeprecated": false, "inputFields": [ { "name": "alertsThreshold",