= async (context) => {
- const { getGraphqlChangelog } = await import('@/graphql/lib/index')
+ const { getGraphqlChangelogByYear, getGraphqlChangelogYears } =
+ await import('@/graphql/lib/index')
const { getAutomatedPageMiniTocItems } = await import('@/frame/lib/get-mini-toc-items')
const req = context.req as unknown as ExtendedRequest
const res = context.res as unknown as ServerResponse
const currentVersion = context.query.versionId as string
- const schema = getGraphqlChangelog(currentVersion) as ChangelogItemT[]
+ const years = getGraphqlChangelogYears(currentVersion)
+ const currentYear = years[0]
+ const schema = getGraphqlChangelogByYear(currentVersion, currentYear) as ChangelogItemT[]
if (!schema) throw new Error('No graphql free-pro-team changelog schema found.')
- // Gets the miniTocItems in the article context. At this point it will only
- // include miniTocItems that exist in Markdown pages in
- // content/graphql/reference/*
+
const automatedPageContext = getAutomatedPageContextFromRequest(req)
const titles = schema.map((item) => `Schema changes for ${item.date}`)
const changelogMiniTocItems = await getAutomatedPageMiniTocItems(titles, req.context!, 2)
- // Update the existing context to include the miniTocItems from GraphQL
automatedPageContext.miniTocItems.push(...changelogMiniTocItems)
- // All groups in the schema have a change.changes array of strings that are
- // all the HTML output from a Markdown conversion. E.g.
- // `Field filename was added to object type IssueTemplate
`
- // Change these to just be the inside of the tag.
- // `Field filename was added to object type IssueTemplate`
- // This makes the serialized state data smaller and it makes it possible
- // to render it as...
- //
- //
Field filename was added to object type IssueTemplate
- //
- // ...without the additional .
+ stripParagraphWrappers(schema)
+
+ return {
+ props: {
+ mainContext: await getMainContext(req, res),
+ automatedPageContext,
+ schema,
+ years,
+ currentYear,
+ },
+ }
+}
+
+/**
+ * Strip wrapping `
` tags from HTML change descriptions to allow
+ * rendering as `
` content without nested block elements.
+ */
+export function stripParagraphWrappers(schema: ChangelogItemT[]) {
for (const item of schema) {
for (const group of [item.schemaChanges, item.previewChanges, item.upcomingChanges]) {
for (const change of group) {
@@ -68,12 +83,4 @@ export const getServerSideProps: GetServerSideProps = async (context) =>
}
}
}
-
- return {
- props: {
- mainContext: await getMainContext(req, res),
- automatedPageContext,
- schema,
- },
- }
}
diff --git a/src/graphql/scripts/build-changelog.ts b/src/graphql/scripts/build-changelog.ts
index e594f77d64d2..11acb66f209b 100644
--- a/src/graphql/scripts/build-changelog.ts
+++ b/src/graphql/scripts/build-changelog.ts
@@ -1,6 +1,7 @@
import { diff, ChangeType, Change } from '@graphql-inspector/core'
import { loadSchema } from '@graphql-tools/load'
import fs from 'fs'
+import nodePath from 'path'
import { renderContent } from '@/content-render/index'
interface UpcomingChange {
@@ -75,6 +76,43 @@ export function prependDatedEntry(changelogEntry: ChangelogEntry, targetPath: st
previousChangelog.unshift(changelogEntry)
// rewrite the updated changelog
fs.writeFileSync(targetPath, JSON.stringify(previousChangelog, null, 2))
+
+ // Ensure a content page exists for this entry's year
+ const year = todayString.slice(0, 4)
+ ensureYearPage(year)
+}
+
+const DEFAULT_CHANGELOG_CONTENT_DIR = nodePath.join('content', 'graphql', 'overview', 'changelog')
+
+/**
+ * If a year-specific content page doesn't exist yet (e.g. 2027.md),
+ * create it and prepend it to the children list in index.md.
+ */
+export function ensureYearPage(
+ year: string,
+ contentDir: string = DEFAULT_CHANGELOG_CONTENT_DIR,
+): void {
+ const yearPagePath = nodePath.join(contentDir, `${year}.md`)
+ if (fs.existsSync(yearPagePath)) return
+
+ const yearPage = [
+ '---',
+ `title: "GraphQL changelog for ${year}"`,
+ `shortTitle: "${year}"`,
+ `intro: 'GraphQL schema changes from ${year}.'`,
+ 'versions:',
+ " fpt: '*'",
+ 'autogenerated: graphql',
+ '---',
+ '',
+ ].join('\n')
+ fs.writeFileSync(yearPagePath, yearPage)
+
+ // Prepend the new year to children in index.md
+ const indexPath = nodePath.join(contentDir, 'index.md')
+ const indexContent = fs.readFileSync(indexPath, 'utf8')
+ const updated = indexContent.replace(/^(children:\n)/m, `$1 - /${year}\n`)
+ fs.writeFileSync(indexPath, updated)
}
/**
@@ -359,4 +397,10 @@ export function getIgnoredChangesSummary(): IgnoredChangesSummary | null {
return summary
}
-export default { createChangelogEntry, cleanPreviewTitle, previewAnchor, prependDatedEntry }
+export default {
+ createChangelogEntry,
+ cleanPreviewTitle,
+ previewAnchor,
+ prependDatedEntry,
+ ensureYearPage,
+}
diff --git a/src/graphql/tests/build-changelog.ts b/src/graphql/tests/build-changelog.ts
index f5c131468bad..139520b572dc 100644
--- a/src/graphql/tests/build-changelog.ts
+++ b/src/graphql/tests/build-changelog.ts
@@ -9,6 +9,7 @@ import {
cleanPreviewTitle,
previewAnchor,
prependDatedEntry,
+ ensureYearPage,
getLastIgnoredChanges,
getIgnoredChangesSummary,
type ChangelogEntry,
@@ -265,6 +266,48 @@ describe('updating the changelog file', () => {
})
})
+describe('ensureYearPage', () => {
+ const tmpDir = 'src/graphql/tests/fixtures/tmp-changelog'
+
+ afterEach(async () => {
+ await fs.rm(tmpDir, { recursive: true, force: true })
+ })
+
+ test('creates a new year page and updates index.md children', async () => {
+ await fs.mkdir(tmpDir, { recursive: true })
+ await fs.writeFile(
+ `${tmpDir}/index.md`,
+ ['---', 'title: Changelog', 'children:', ' - /2026', ' - /2025', '---', ''].join('\n'),
+ )
+
+ ensureYearPage('2027', tmpDir)
+
+ const yearPage = await fs.readFile(`${tmpDir}/2027.md`, 'utf8')
+ expect(yearPage).toContain('title: "GraphQL changelog for 2027"')
+ expect(yearPage).toContain('shortTitle: "2027"')
+ expect(yearPage).toContain('autogenerated: graphql')
+
+ const indexContent = await fs.readFile(`${tmpDir}/index.md`, 'utf8')
+ expect(indexContent).toContain(' - /2027\n - /2026')
+ })
+
+ test('is a no-op when the year page already exists', async () => {
+ await fs.mkdir(tmpDir, { recursive: true })
+ const indexContent = ['---', 'children:', ' - /2026', '---', ''].join('\n')
+ await fs.writeFile(`${tmpDir}/index.md`, indexContent)
+ await fs.writeFile(`${tmpDir}/2026.md`, '---\ntitle: existing\n---\n')
+
+ ensureYearPage('2026', tmpDir)
+
+ // Should not modify the existing file
+ const yearPage = await fs.readFile(`${tmpDir}/2026.md`, 'utf8')
+ expect(yearPage).toContain('title: existing')
+ // index.md should be unchanged
+ const updatedIndex = await fs.readFile(`${tmpDir}/index.md`, 'utf8')
+ expect(updatedIndex).toBe(indexContent)
+ })
+})
+
describe('ignored changes tracking', () => {
test('tracks ignored change types', async () => {
const oldSchemaString = `
diff --git a/src/languages/lib/correct-translation-content.ts b/src/languages/lib/correct-translation-content.ts
index fca1adefd0f9..2e1eb3fed434 100644
--- a/src/languages/lib/correct-translation-content.ts
+++ b/src/languages/lib/correct-translation-content.ts
@@ -26,6 +26,10 @@ export function correctTranslatedContentStrings(
// Remove colon prefix on Liquid tags: `{%:` → `{%`
content = content.replace(/\{%:/g, '{%')
+ // `{% siVersion X %}` — Spanish "si" (if) fused with "Version" = ifversion
+ content = content.replaceAll('{% siVersion ', '{% ifversion ')
+ content = content.replaceAll('{%- siVersion ', '{%- ifversion ')
+
content = content.replaceAll('{% vulnerables variables.', '{% data variables.')
content = content.replaceAll('{% datos variables', '{% data variables')
content = content.replaceAll('{% de datos variables', '{% data variables')
@@ -235,9 +239,17 @@ export function correctTranslatedContentStrings(
}
if (context.code === 'pt') {
+ // `{%–` — en-dash (U+2013) used instead of hyphen in `{%-` trim modifier
+ content = content.replaceAll('{%–', '{%-')
+
content = content.replaceAll('{% dados variables', '{% data variables')
content = content.replaceAll('{% de dados variables', '{% data variables')
content = content.replaceAll('{% dados reusables', '{% data reusables')
+ // `{% dadosvariables` / `{% datavariables` — no space between "dados"/"data" and "variables"
+ content = content.replaceAll('{% dadosvariables', '{% data variables')
+ content = content.replaceAll('{%- dadosvariables', '{%- data variables')
+ content = content.replaceAll('{% datavariables', '{% data variables')
+ content = content.replaceAll('{%- datavariables', '{%- data variables')
// Fully translated reusables path: `{% dados reutilizáveis.X.Y %}` → `{% data reusables.X.Y %}`
content = content.replaceAll('{% dados reutilizáveis.', '{% data reusables.')
// Translated path segment inside reusables path: `repositórios` → `repositories`
@@ -292,6 +304,9 @@ export function correctTranslatedContentStrings(
if (context.code === 'zh') {
content = content.replaceAll('{% 数据variables', '{% data variables')
content = content.replaceAll('{% 数据 variables', '{% data variables')
+ // `{%数据variables` — no space between `{%` and 数据 (data)
+ content = content.replaceAll('{%数据variables', '{% data variables')
+ content = content.replaceAll('{%数据 variables', '{% data variables')
// Order matters: the more specific `s.` variant must run first to
// avoid the broader rule producing a double-s (`reusabless`).
content = content.replaceAll('{% 数据可重用s.', '{% data reusables.')
@@ -467,6 +482,12 @@ export function correctTranslatedContentStrings(
content = content.replaceAll('{% données variables', '{% data variables')
content = content.replaceAll('{% données réutilisables.', '{% data reusables.')
content = content.replaceAll('{% variables de données.', '{% data variables.')
+ // `{% de données variables.` — preposition "de" prepended to "données variables"
+ content = content.replaceAll('{% de données variables.', '{% data variables.')
+ content = content.replaceAll('{%- de données variables.', '{%- data variables.')
+ // `{% de data variables.` — partially-corrected form (données already fixed to data)
+ content = content.replaceAll('{% de data variables.', '{% data variables.')
+ content = content.replaceAll('{%- de data variables.', '{%- data variables.')
content = content.replaceAll('{% autre %}', '{% else %}')
content = content.replaceAll('{%- autre %}', '{%- else %}')
content = content.replaceAll('{% brut %}', '{% raw %}')
@@ -571,6 +592,10 @@ export function correctTranslatedContentStrings(
content = content.replaceAll('{% 옥티콘 ', '{% octicon ')
content = content.replaceAll('{%- 옥티콘 ', '{%- octicon ')
+ // `{% data Variables.` — capital V in "Variables" (Korean translator capitalised the word)
+ content = content.replaceAll('{% data Variables.', '{% data variables.')
+ content = content.replaceAll('{%- data Variables.', '{%- data variables.')
+
// Korean translation of github-glossary.md
content = content.replaceAll('{{ 용어집.term }}', '{{ glossary.term }}')
// `{% 데이터 재사용.` — Korean translation of "data reusables" path
@@ -662,8 +687,23 @@ export function correctTranslatedContentStrings(
// `{% Endnotiz %}` — "end note" = endnote
content = content.replaceAll('{% Endnotiz %}', '{% endnote %}')
content = content.replaceAll('{%- Endnotiz %}', '{%- endnote %}')
+ // `{% data-variables.` — hyphen used instead of space between "data" and "variables"
+ content = content.replaceAll('{% data-variables.', '{% data variables.')
+ content = content.replaceAll('{%- data-variables.', '{%- data variables.')
+ // `{%- Datenworkflow variables.` — compound "Datenworkflow" (data workflow) = data
+ content = content.replaceAll('{%- Datenworkflow variables.', '{%- data variables.')
+ content = content.replaceAll('{% Datenworkflow variables.', '{% data variables.')
+ // `{% ifec ` — truncated/corrupted form of "ifversion"
+ content = content.replaceAll('{% ifec ', '{% ifversion ')
+ content = content.replaceAll('{%- ifec ', '{%- ifversion ')
+ // `{% andere %}` / `{%- andere %}` — German "andere" (other) = else
+ content = content.replaceAll('{% andere %}', '{% else %}')
+ content = content.replaceAll('{%- andere %}', '{%- else %}')
// `{% Dateninstanz` — "data instance" = data
content = content.replaceAll('{% Dateninstanz ', '{% data ')
+ // `{% Datenauflistung ` — "data listing" (compound) = data
+ content = content.replaceAll('{% Datenauflistung ', '{% data ')
+ content = content.replaceAll('{%- Datenauflistung ', '{%- data ')
// `{% ifversion-Sicherheitskonfigurationen %}` — hyphenated compound
content = content.replaceAll(
'{% ifversion-Sicherheitskonfigurationen %}',
@@ -731,6 +771,20 @@ export function correctTranslatedContentStrings(
content = content.replaceAll('{% dada variables', '{% data variables')
content = content.replaceAll('{% % data', '{% data')
+ // Leading dot in `{% data` paths: `{% data .variables.X %}` / `{% data .reusables.X %}`
+ // — translator inserted a stray dot. Affects ja, pt, zh.
+ content = content.replaceAll('{% data .variables.', '{% data variables.')
+ content = content.replaceAll('{%- data .variables.', '{%- data variables.')
+ content = content.replaceAll('{% data .reusables.', '{% data reusables.')
+ content = content.replaceAll('{%- data .reusables.', '{%- data reusables.')
+
+ // Singular "variable" / "reusable" in `{% data` paths:
+ // `{% data variable.product.X %}` → `{% data variables.product.X %}` (es, zh)
+ content = content.replaceAll('{% data variable.', '{% data variables.')
+ content = content.replaceAll('{%- data variable.', '{%- data variables.')
+ content = content.replaceAll('{% data reusable.', '{% data reusables.')
+ content = content.replaceAll('{%- data reusable.', '{%- data reusables.')
+
// Double-quote corruption in href attributes
content = content.replace(/href=""https:\/\//g, 'href="https://')
diff --git a/src/languages/tests/correct-translation-content.ts b/src/languages/tests/correct-translation-content.ts
index 308391c5bb5b..26f5382ff528 100644
--- a/src/languages/tests/correct-translation-content.ts
+++ b/src/languages/tests/correct-translation-content.ts
@@ -131,6 +131,11 @@ describe('correctTranslatedContentStrings', () => {
'{% data reusables.profile.access_org %}',
)
})
+
+ test('fixes siVersion → ifversion', () => {
+ expect(fix('{% siVersion productos-ghas %}', 'es')).toBe('{% ifversion productos-ghas %}')
+ expect(fix('{%- siVersion productos-ghas %}', 'es')).toBe('{%- ifversion productos-ghas %}')
+ })
})
// ─── JAPANESE (ja) ──────────────────────────────────────────────────
@@ -318,6 +323,26 @@ describe('correctTranslatedContentStrings', () => {
)
})
+ test('fixes en-dash in trim modifier', () => {
+ // `{%–` — en-dash (U+2013) used instead of hyphen in `{%-` trim modifier
+ expect(fix('{%– ifversion projects-v1 %}', 'pt')).toBe('{%- ifversion projects-v1 %}')
+ expect(fix('{%– endif %}', 'pt')).toBe('{%- endif %}')
+ })
+
+ test('fixes datavariables / dadosvariables (no space)', () => {
+ // `{% datavariables` — no space between "data" and "variables" (post-translation)
+ expect(fix('{% datavariables.product.github %}', 'pt')).toBe(
+ '{% data variables.product.github %}',
+ )
+ expect(fix('{%- datavariables.product.github %}', 'pt')).toBe(
+ '{%- data variables.product.github %}',
+ )
+ // `{% dadosvariables` — Portuguese "dados" fused with "variables"
+ expect(fix('{% dadosvariables.product.github %}', 'pt')).toBe(
+ '{% data variables.product.github %}',
+ )
+ })
+
test('fixes translated else variants', () => {
expect(fix('{% senão %}', 'pt')).toBe('{% else %}')
expect(fix('{%- senão %}', 'pt')).toBe('{%- else %}')
@@ -434,6 +459,13 @@ describe('correctTranslatedContentStrings', () => {
'{% data variables.product.github %}',
)
expect(fix('{% 数据可重用s.foo %}', 'zh')).toBe('{% data reusables.foo %}')
+ // No space between `{%` and 数据
+ expect(fix('{%数据variables.product.github%}', 'zh')).toBe(
+ '{% data variables.product.github%}',
+ )
+ expect(fix('{%数据 variables.product.github%}', 'zh')).toBe(
+ '{% data variables.product.github%}',
+ )
})
test('fixes translated else and raw', () => {
@@ -687,6 +719,14 @@ describe('correctTranslatedContentStrings', () => {
'{% data variables.product.github %}',
)
expect(fix('{% données reusables.foo %}', 'fr')).toBe('{% data reusables.foo %}')
+ // `{% de données variables.` — preposition "de" prepended
+ expect(fix('{% de données variables.product.github %}', 'fr')).toBe(
+ '{% data variables.product.github %}',
+ )
+ // `{% de data variables.` — partially-corrected form
+ expect(fix('{% de data variables.product.github %}', 'fr')).toBe(
+ '{% data variables.product.github %}',
+ )
})
test('fixes translated else', () => {
@@ -889,6 +929,15 @@ describe('correctTranslatedContentStrings', () => {
expect(fix('{% 주석 끝 %}', 'ko')).toBe('{% endnote %}')
expect(fix('{%- 주석 끝 %}', 'ko')).toBe('{%- endnote %}')
})
+
+ test('fixes capitalized Variables → data variables', () => {
+ expect(fix('{% data Variables.product.github %}', 'ko')).toBe(
+ '{% data variables.product.github %}',
+ )
+ expect(fix('{%- data Variables.product.github %}', 'ko')).toBe(
+ '{%- data variables.product.github %}',
+ )
+ })
})
// ─── GERMAN (de) ──────────────────────────────────────────────────
@@ -1088,6 +1137,44 @@ describe('correctTranslatedContentStrings', () => {
'{% ifversion enterprise-installed-apps %}',
)
})
+
+ test('fixes data-variables (hyphen instead of space)', () => {
+ expect(fix('{% data-variables.product.github %}', 'de')).toBe(
+ '{% data variables.product.github %}',
+ )
+ expect(fix('{%- data-variables.product.github %}', 'de')).toBe(
+ '{%- data variables.product.github %}',
+ )
+ })
+
+ test('fixes Datenworkflow variables → data variables', () => {
+ expect(fix('{%- Datenworkflow variables.product.prodname_actions %}', 'de')).toBe(
+ '{%- data variables.product.prodname_actions %}',
+ )
+ expect(fix('{% Datenworkflow variables.product.prodname_actions %}', 'de')).toBe(
+ '{% data variables.product.prodname_actions %}',
+ )
+ })
+
+ test('fixes ifec → ifversion', () => {
+ expect(fix('{% ifec ghec %}', 'de')).toBe('{% ifversion ghec %}')
+ expect(fix('{%- ifec ghec %}', 'de')).toBe('{%- ifversion ghec %}')
+ })
+
+ test('fixes andere → else', () => {
+ expect(fix('{% andere %}', 'de')).toBe('{% else %}')
+ expect(fix('{%- andere %}', 'de')).toBe('{%- else %}')
+ })
+
+ test('fixes Datenauflistung → data', () => {
+ // `{% Datenauflistung variables.X %}` — "data listing" compound = data
+ expect(fix('{% Datenauflistung variables.product.github %}', 'de')).toBe(
+ '{% data variables.product.github %}',
+ )
+ expect(fix('{%- Datenauflistung variables.product.github %}', 'de')).toBe(
+ '{%- data variables.product.github %}',
+ )
+ })
})
describe('Generic fixes (all languages)', () => {
@@ -1108,6 +1195,28 @@ describe('correctTranslatedContentStrings', () => {
)
})
+ test('fixes leading dot in {% data paths', () => {
+ // `{% data .variables.X %}` — translator inserted a stray dot
+ expect(fix('{% data .variables.product.prodname_ghe_server %}', 'ja')).toBe(
+ '{% data variables.product.prodname_ghe_server %}',
+ )
+ expect(fix('{%- data .variables.product.github %}', 'pt')).toBe(
+ '{%- data variables.product.github %}',
+ )
+ expect(fix('{% data .reusables.foo.bar %}', 'zh')).toBe('{% data reusables.foo.bar %}')
+ })
+
+ test('fixes singular variable / reusable in {% data paths', () => {
+ // `{% data variable.product.X %}` (singular) → `{% data variables.product.X %}`
+ expect(fix('{% data variable.product.prodname_container_registry %}', 'zh')).toBe(
+ '{% data variables.product.prodname_container_registry %}',
+ )
+ expect(fix('{%- data variable.product.github %}', 'es')).toBe(
+ '{%- data variables.product.github %}',
+ )
+ expect(fix('{% data reusable.foo.bar %}', 'fr')).toBe('{% data reusables.foo.bar %}')
+ })
+
test('fixes capitalized platform tags across all languages', () => {
expect(fix('{% Windows %}', 'zh')).toBe('{% windows %}')
expect(fix('{% Eclipse %}', 'zh')).toBe('{% eclipse %}')
diff --git a/src/pages/[versionId]/graphql/overview/changelog/[year].tsx b/src/pages/[versionId]/graphql/overview/changelog/[year].tsx
new file mode 100644
index 000000000000..b3bfdb624f0e
--- /dev/null
+++ b/src/pages/[versionId]/graphql/overview/changelog/[year].tsx
@@ -0,0 +1 @@
+export { default, getServerSideProps } from '@/graphql/pages/changelog-year'
diff --git a/src/pages/[versionId]/graphql/overview/changelog.tsx b/src/pages/[versionId]/graphql/overview/changelog/index.tsx
similarity index 100%
rename from src/pages/[versionId]/graphql/overview/changelog.tsx
rename to src/pages/[versionId]/graphql/overview/changelog/index.tsx
diff --git a/src/search/components/input/AskAIResults.tsx b/src/search/components/input/AskAIResults.tsx
index ec248e622264..4df1b247c23d 100644
--- a/src/search/components/input/AskAIResults.tsx
+++ b/src/search/components/input/AskAIResults.tsx
@@ -22,7 +22,6 @@ import { sendEvent, uuidv4 } from '@/events/components/events'
import { EventType } from '@/events/types'
import { generateAISearchLinksJson } from '../helpers/ai-search-links-json'
import { ASK_AI_EVENT_GROUP } from '@/events/components/event-groups'
-import { useCTAPopoverContext } from '@/frame/components/context/CTAContext'
import type { AIReference } from '../types'
@@ -83,7 +82,6 @@ export function AskAIResults({
aiCouldNotAnswer: boolean
connectedEventId?: string
}>('ai-query-cache', 1000, 7)
- const { isOpen: isCTAOpen, permanentDismiss: permanentlyDismissCTA } = useCTAPopoverContext()
let copyUrl = ``
if (window?.location?.href) {
@@ -145,12 +143,6 @@ export function AskAIResults({
setResponseLoading(true)
disclaimerRef.current?.focus()
- // We permanently dismiss the CTA after performing an AI Search because the
- // user has tried it and doesn't require additional CTA prompting to try it
- if (isCTAOpen) {
- permanentlyDismissCTA()
- }
-
const cachedData = getItem(query, version, router.locale || 'en')
if (cachedData) {
setMessage(cachedData.message)
diff --git a/src/workflows/projects.ts b/src/workflows/projects.ts
index 398b9e1924b8..88c24b987539 100644
--- a/src/workflows/projects.ts
+++ b/src/workflows/projects.ts
@@ -2,10 +2,68 @@ import { graphql } from '@octokit/graphql'
// Shared functions for managing projects (memex)
+export interface ProjectV2FieldNode {
+ name: string
+ id: string
+ options?: Array<{ name: string; id: string }>
+}
+
+export interface ProjectV2Data {
+ organization: {
+ projectV2: {
+ id: string
+ fields: {
+ nodes: ProjectV2FieldNode[]
+ }
+ }
+ }
+}
+
+interface TeamMemberData {
+ organization: {
+ team: {
+ members: {
+ nodes: Array<{ login: string }>
+ }
+ }
+ }
+}
+
+interface OrgMemberData {
+ user: {
+ organization: { name: string } | null
+ }
+}
+
+interface MutationResult {
+ [key: string]: { item: { id: string } }
+}
+
+export interface FileNode {
+ path: string
+ additions: number
+ deletions: number
+}
+
+export interface ItemData {
+ item: {
+ __typename: string
+ files: {
+ nodes: FileNode[]
+ }
+ author?: {
+ login: string
+ }
+ assignees?: {
+ nodes: Array<{ login: string }>
+ }
+ }
+}
+
// Pull out the node ID of a project field
-export function findFieldID(fieldName: string, data: Record) {
+export function findFieldID(fieldName: string, data: ProjectV2Data) {
const field = data.organization.projectV2.fields.nodes.find(
- (fieldNode: Record) => fieldNode.name === fieldName,
+ (fieldNode) => fieldNode.name === fieldName,
)
if (field && field.id) {
@@ -19,18 +77,16 @@ export function findFieldID(fieldName: string, data: Record) {
export function findSingleSelectID(
singleSelectName: string,
fieldName: string,
- data: Record,
+ data: ProjectV2Data,
) {
const field = data.organization.projectV2.fields.nodes.find(
- (fieldData: Record) => fieldData.name === fieldName,
+ (fieldData) => fieldData.name === fieldName,
)
if (!field) {
throw new Error(`A field called "${fieldName}" was not found. Check if the field was renamed.`)
}
- const singleSelect = field.options.find(
- (option: Record) => option.name === singleSelectName,
- )
+ const singleSelect = field.options?.find((option) => option.name === singleSelectName)
if (singleSelect && singleSelect.id) {
return singleSelect.id
@@ -66,7 +122,7 @@ export async function addItemsToProject(items: string[], project: string) {
}
`
- const newItems: Record = await graphql(mutation, {
+ const newItems: MutationResult = await graphql(mutation, {
project,
headers: {
authorization: `token ${process.env.TOKEN}`,
@@ -98,7 +154,7 @@ export async function isDocsTeamMember(login: string) {
return true
}
// Get all members of the docs team
- const data: Record = await graphql(
+ const data: TeamMemberData = await graphql(
`
query {
organization(login: "github") {
@@ -119,9 +175,7 @@ export async function isDocsTeamMember(login: string) {
},
)
- const teamMembers = data.organization.team.members.nodes.map(
- (entry: Record) => entry.login,
- )
+ const teamMembers = data.organization.team.members.nodes.map((entry) => entry.login)
return teamMembers.includes(login)
}
@@ -129,7 +183,7 @@ export async function isDocsTeamMember(login: string) {
// Given a GitHub login, returns a bool indicating
// whether the login is part of the GitHub org
export async function isGitHubOrgMember(login: string) {
- const data: Record = await graphql(
+ const data: OrgMemberData = await graphql(
`
query {
user(login: "${login}") {
@@ -302,13 +356,13 @@ export function generateUpdateProjectV2ItemFieldMutation({
}
// Guess the affected docs sets based on the files that the PR changed
-export function getFeature(data: Record) {
+export function getFeature(data: ItemData) {
// For issues, just use an empty string
if (data.item.__typename !== 'PullRequest') {
return ''
}
- const paths = data.item.files.nodes.map((node: Record) => node.path)
+ const paths = data.item.files.nodes.map((node) => node.path)
// For docs and docs-internal and docs-early-access PRs,
// determine the affected docs sets by looking at which
@@ -364,7 +418,7 @@ export function getFeature(data: Record) {
}
// Guess the size of an item
-export function getSize(data: Record) {
+export function getSize(data: ItemData) {
// We need to set something in case this is an issue, so just guesstimate small
if (data.item.__typename !== 'PullRequest') {
return 'S'
@@ -374,7 +428,7 @@ export function getSize(data: Record) {
if (process.env.REPO === 'github/github') {
let numFiles = 0
let numChanges = 0
- for (const node of data.item.files.nodes as Record[]) {
+ for (const node of data.item.files.nodes) {
if (node.path.startsWith('app/api/description')) {
numFiles += 1
numChanges += node.additions
@@ -394,7 +448,7 @@ export function getSize(data: Record) {
// Otherwise, estimated the size based on all files
let numFiles = 0
let numChanges = 0
- for (const node of data.item.files.nodes as Record[]) {
+ for (const node of data.item.files.nodes) {
numFiles += 1
numChanges += node.additions
numChanges += node.deletions
diff --git a/src/workflows/ready-for-docs-review.ts b/src/workflows/ready-for-docs-review.ts
index c032261fc94a..06757373302a 100644
--- a/src/workflows/ready-for-docs-review.ts
+++ b/src/workflows/ready-for-docs-review.ts
@@ -9,6 +9,8 @@ import {
generateUpdateProjectV2ItemFieldMutation,
getFeature,
getSize,
+ type ProjectV2Data,
+ type ItemData,
} from './projects'
/**
@@ -16,28 +18,25 @@ import {
* @param data GraphQL response data containing PR information
* @returns Object with isCopilotAuthor boolean and copilotAssignee string
*/
-function getCopilotAuthorInfo(data: Record): {
+function getCopilotAuthorInfo(data: ItemData): {
isCopilotAuthor: boolean
copilotAssignee: string
} {
- const item = data.item as Record
- const author = item.author as Record | undefined
- const assigneesObj = item.assignees as Record | undefined
+ const item = data.item
// Check if this is a Copilot-authored PR
const isCopilotAuthor = !!(
item.__typename === 'PullRequest' &&
- author &&
- author.login === 'copilot-swe-agent'
+ item.author &&
+ item.author.login === 'copilot-swe-agent'
)
// For Copilot PRs, find the appropriate assignee (excluding Copilot itself)
let copilotAssignee = ''
- if (isCopilotAuthor && assigneesObj && assigneesObj.nodes) {
- const nodes = assigneesObj.nodes as Array>
- const assigneeLogins = nodes
- .map((assignee: Record) => assignee.login as string)
- .filter((login: string) => login !== 'copilot-swe-agent')
+ if (isCopilotAuthor && item.assignees && item.assignees.nodes) {
+ const assigneeLogins = item.assignees.nodes
+ .map((assignee) => assignee.login)
+ .filter((login) => login !== 'copilot-swe-agent')
// Use the first non-Copilot assignee
copilotAssignee = assigneeLogins.length > 0 ? assigneeLogins[0] : ''
@@ -71,7 +70,7 @@ function getAuthorFieldValue(
async function run() {
// Get info about the docs-content review board project
- const data: Record = await graphql(
+ const data = (await graphql(
`
query ($organization: String!, $projectNumber: Int!, $id: ID!) {
organization(login: $organization) {
@@ -125,12 +124,10 @@ async function run() {
authorization: `token ${process.env.TOKEN}`,
},
},
- )
+ )) as ProjectV2Data & ItemData
// Get the project ID
- const organization = data.organization as Record
- const projectV2 = organization.projectV2 as Record
- const projectID = projectV2.id as string
+ const projectID = data.organization.projectV2.id
// Get the ID of the fields that we want to populate
const datePostedID = findFieldID('Date posted', data)