From f4306b7409322ed18277249379c07abd577b647c Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 20 Apr 2026 12:34:12 -0700 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=A7=B9=20Remove=20unused=20CTAPopover?= =?UTF-8?q?=20context=20and=20related=20code=20(#60847)?= 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/fixtures/helpers/turn-off-experiments.ts | 15 --- .../tests/playwright-rendering.spec.ts | 20 +--- src/frame/components/context/CTAContext.tsx | 108 ------------------ src/frame/components/page-header/Header.tsx | 5 - src/frame/pages/app.tsx | 7 +- src/search/components/input/AskAIResults.tsx | 8 -- 6 files changed, 3 insertions(+), 160 deletions(-) delete mode 100644 src/frame/components/context/CTAContext.tsx diff --git a/src/fixtures/helpers/turn-off-experiments.ts b/src/fixtures/helpers/turn-off-experiments.ts index cd4065eb7968..cfb9547b4e2e 100644 --- a/src/fixtures/helpers/turn-off-experiments.ts +++ b/src/fixtures/helpers/turn-off-experiments.ts @@ -44,18 +44,3 @@ export function turnOffExperimentsBeforeEach(test: typeof Test) { await turnOffExperimentsInPage(page) }) } - -export async function dismissCTAPopover(page: Page) { - // Set the CTA popover to permanently dismissed in localStorage - await page.evaluate(() => { - localStorage.setItem( - 'ctaPopoverState', - JSON.stringify({ - dismissedCount: 0, - lastDismissedAt: null, - permanentlyDismissed: true, - }), - ) - }) - await page.reload() -} diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index 6ae060d97614..b99559b46009 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -1,6 +1,6 @@ import dotenv from 'dotenv' import { test, expect } from '@playwright/test' -import { turnOffExperimentsInPage, dismissCTAPopover } from '../helpers/turn-off-experiments' +import { turnOffExperimentsInPage } from '../helpers/turn-off-experiments' import { HOVERCARDS_ENABLED, ANALYTICS_ENABLED } from '../../frame/lib/constants' // This exists for the benefit of local testing. @@ -22,7 +22,6 @@ test('view home page', async ({ page }) => { test('logo link keeps current version', async ({ page }) => { await page.goto('/enterprise-cloud@latest') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) // Basically clicking into any page that isn't the home page for this version. await page.getByTestId('product').getByRole('link', { name: 'Get started' }).click() await expect(page).toHaveURL(/\/en\/enterprise-cloud@latest\/get-started/) @@ -58,7 +57,6 @@ test('do a search from home page and click on "Foo" page', async ({ page }) => { await page.goto('/') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) // Use the search overlay await page.locator('[data-testid="search"]:visible').click() @@ -84,7 +82,6 @@ test('open search, and perform a general search', async ({ page }) => { await page.goto('/') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) await page.locator('[data-testid="search"]:visible').click() await page.getByTestId('overlay-search-input').fill('serve playwright') @@ -182,7 +179,6 @@ test('search from enterprise-cloud and filter by top-level Fooing', async ({ pag await page.goto('/enterprise-cloud@latest') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) // Use the search overlay await page.locator('[data-testid="search"]:visible').click() @@ -213,7 +209,6 @@ test.describe('platform picker', () => { test('switch operating systems', async ({ page }) => { await page.goto('/get-started/liquid/platform-specific') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) await page.getByTestId('platform-picker').getByRole('link', { name: 'Mac' }).click() await expect(page).toHaveURL(/\?platform=mac/) @@ -230,7 +225,6 @@ test.describe('platform picker', () => { // default platform set to windows in fixture fronmatter await page.goto('/get-started/liquid/platform-specific') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) await expect( page.getByTestId('minitoc').getByRole('link', { name: 'Macintosh until 1999' }), ).not.toBeVisible() @@ -249,7 +243,6 @@ test.describe('platform picker', () => { test('remember last clicked OS', async ({ page }) => { await page.goto('/get-started/liquid/platform-specific') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) await page.getByTestId('platform-picker').getByRole('link', { name: 'Windows' }).click() // Return and now the cookie should start us off on Windows again @@ -263,7 +256,6 @@ test.describe('tool picker', () => { test('switch tools', async ({ page }) => { await page.goto('/get-started/liquid/tool-specific') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) await page.getByTestId('tool-picker').getByRole('link', { name: 'GitHub CLI' }).click() await expect(page).toHaveURL(/\?tool=cli/) @@ -289,7 +281,6 @@ test.describe('tool picker', () => { test('remember last clicked tool', async ({ page }) => { await page.goto('/get-started/liquid/tool-specific') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) await page.getByTestId('tool-picker').getByRole('link', { name: 'Web browser' }).click() // Return and now the cookie should start us off with Web UI content again @@ -303,7 +294,6 @@ test.describe('tool picker', () => { // default tool set to webui in fixture frontmatter await page.goto('/get-started/liquid/tool-specific') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) await expect( page.getByTestId('minitoc').getByRole('link', { name: 'Webui section' }), ).toBeVisible() @@ -353,7 +343,6 @@ test.describe('hover cards', () => { test('hover over link', async ({ page }) => { await page.goto('/pages/quickstart') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) // hover over a link and check for intro content from hovercard await page @@ -416,7 +405,6 @@ test.describe('hover cards', () => { test('use keyboard shortcut to open hover card', async ({ page }) => { await page.goto('/pages/quickstart') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) // Simply putting focus on the link should not open the hovercard await page @@ -449,7 +437,6 @@ test.describe('hover cards', () => { test('able to use Esc to close hovercard', async ({ page }) => { await page.goto('/pages/quickstart') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) // hover over a link and check for intro content from hovercard await page @@ -598,7 +585,6 @@ test.describe('test nav at different viewports', () => { }) await page.goto('/get-started/foo/bar') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) // header sign-up button is not visible await expect(page.getByTestId('header-signup')).not.toBeVisible() @@ -635,7 +621,6 @@ test.describe('test nav at different viewports', () => { }) await page.goto('/get-started/foo/bar') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) // Use the search overlay await page.locator('[data-testid="mobile-search-button"]:visible').click() @@ -660,7 +645,6 @@ test.describe('test nav at different viewports', () => { }) await page.goto('/get-started/foo/bar') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) // Use the search overlay await page.locator('[data-testid="mobile-search-button"]:visible').click() @@ -921,7 +905,6 @@ test('open search, and ask Copilot (Ask AI) a question', async ({ page }) => { await page.goto('/') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) await page.locator('[data-testid="search"]:visible').click() await page.getByTestId('overlay-search-input').fill('How do I create a Repository?') @@ -964,7 +947,6 @@ test('open search, Ask AI returns 400 error and shows general search results', a await page.goto('/') await turnOffExperimentsInPage(page) - await dismissCTAPopover(page) await page.locator('[data-testid="search"]:visible').click() await page.getByTestId('overlay-search-input').fill('foo') diff --git a/src/frame/components/context/CTAContext.tsx b/src/frame/components/context/CTAContext.tsx deleted file mode 100644 index 8f033c0a5cf8..000000000000 --- a/src/frame/components/context/CTAContext.tsx +++ /dev/null @@ -1,108 +0,0 @@ -// Context to keep track of a call to action (e.g. popover introducing a new feature) -// The state of the CTA will be stored in local storage, so it will persist across page reloads -// If `dismiss` is called, the CTA will not be shown again -import { - createContext, - useCallback, - useContext, - useEffect, - useState, - PropsWithChildren, -} from 'react' - -type CTAPopoverState = { - isOpen: boolean - initializeCTA: () => void // Call to "open" the CTA if it's not already been dismissed by the user - dismiss: () => void // Call to "close" the CTA and store the dismissal in local storage. Will be shown again after 24 hours for a max of 3 times - permanentDismiss: () => void // Call to permanently dismiss the CTA and store the dismissal in local storage -} - -type StoredValue = { - dismissedCount: number - lastDismissedAt: number | null - permanentlyDismissed: boolean -} - -const CTAPopoverContext = createContext(undefined) - -const STORAGE_KEY = 'ctaPopoverState' -const MAX_DISMISSES = 3 -const HIDE_CTA_FOR_MS = 24 * 60 * 60 * 1000 // Every 24 hours we show the CTA again, unless permanently dismissed - -const shouldHide = (): boolean => { - if (typeof window === 'undefined') return false // SSR guard - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return false - const parsed = JSON.parse(raw) as StoredValue - if (parsed.permanentlyDismissed) return true - if (parsed.dismissedCount >= MAX_DISMISSES) return true - if (parsed.lastDismissedAt && Date.now() - parsed.lastDismissedAt < HIDE_CTA_FOR_MS) return true - return false - } catch { - return false // corruption / quota / disabled storage - } -} - -const readStored = (): StoredValue => { - const emptyValue = { dismissedCount: 0, lastDismissedAt: null, permanentlyDismissed: false } - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) { - return emptyValue - } - return JSON.parse(raw) as StoredValue - } catch { - return emptyValue // corruption / quota / disabled storage - } -} - -const writeStored = (v: StoredValue) => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(v)) - } catch { - /* ignore */ - } -} - -export function CTAPopoverProvider({ children }: PropsWithChildren) { - // We start closed because we might only want to "turn on" the CTA if an experiment is active - const [isOpen, setIsOpen] = useState(false) - - const dismiss = useCallback(() => { - const stored = readStored() - writeStored({ - ...stored, - dismissedCount: stored.dismissedCount + 1, - lastDismissedAt: Date.now(), - }) - setIsOpen(false) - }, []) - - const permanentDismiss = useCallback(() => { - const stored = readStored() - writeStored({ ...stored, permanentlyDismissed: true }) - setIsOpen(false) - }, []) - - const initializeCTA = useCallback(() => { - setIsOpen(!shouldHide()) - }, []) - - // Wrap in a useEffect to avoid a hydration mismatch (SSR guard) - useEffect(() => { - setIsOpen(!shouldHide()) - }, []) - - return ( - - {children} - - ) -} - -export const useCTAPopoverContext = () => { - const ctx = useContext(CTAPopoverContext) - if (!ctx) throw new Error('useCTAPopoverContext must be used inside ') - return ctx -} diff --git a/src/frame/components/page-header/Header.tsx b/src/frame/components/page-header/Header.tsx index 92b69ac35fda..b4ef9d70faac 100644 --- a/src/frame/components/page-header/Header.tsx +++ b/src/frame/components/page-header/Header.tsx @@ -19,7 +19,6 @@ import { HeaderSearchAndWidgets } from './HeaderSearchAndWidgets' import { useInnerWindowWidth } from './hooks/useInnerWindowWidth' import { useMultiQueryParams } from '@/search/components/hooks/useMultiQueryParams' import { SearchOverlayContainer } from '@/search/components/input/SearchOverlayContainer' -import { useCTAPopoverContext } from '@/frame/components/context/CTAContext' import { useSearchOverlayContext } from '@/search/components/context/SearchOverlayContext' import styles from './Header.module.scss' @@ -45,7 +44,6 @@ export const Header = () => { const returnFocusRef = useRef(null) const searchButtonRefLarge = useRef(null) const searchButtonRefSmall = useRef(null) - const { initializeCTA } = useCTAPopoverContext() const { isSearchOpen, setIsSearchOpen } = useSearchOverlayContext() // The lg breakpoint (1012px) determines which search button is visible. @@ -74,9 +72,6 @@ export const Header = () => { /> ) - // Initialize the CTA(s) - initializeCTA() - useEffect(() => { function onScroll() { setScroll(window.scrollY > 10) diff --git a/src/frame/pages/app.tsx b/src/frame/pages/app.tsx index 5286bdc70310..4fd98004f740 100644 --- a/src/frame/pages/app.tsx +++ b/src/frame/pages/app.tsx @@ -17,7 +17,6 @@ import { } from '@/languages/components/LanguagesContext' import { useTheme } from '@/color-schemes/components/useTheme' import { SharedUIContextProvider } from '@/frame/components/context/SharedUIContext' -import { CTAPopoverProvider } from '@/frame/components/context/CTAContext' import { ClientSideHashFocus } from '@/frame/components/ClientSideHashFocus' import type { ExtendedRequest } from '@/types' @@ -146,10 +145,8 @@ const MyApp = ({ Component, pageProps, languagesContext, stagingName }: MyAppPro > - - - - + + 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) From dd57bc1e7e56313596f8b0590d3f67167d107d4b Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 20 Apr 2026 12:34:18 -0700 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Split=20GraphQL=20chan?= =?UTF-8?q?gelog=20by=20year=20(#60819)?= 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> --- content/graphql/overview/changelog/2017.md | 8 ++ content/graphql/overview/changelog/2018.md | 8 ++ content/graphql/overview/changelog/2019.md | 8 ++ content/graphql/overview/changelog/2020.md | 8 ++ content/graphql/overview/changelog/2021.md | 8 ++ content/graphql/overview/changelog/2022.md | 8 ++ content/graphql/overview/changelog/2023.md | 8 ++ content/graphql/overview/changelog/2024.md | 8 ++ content/graphql/overview/changelog/2025.md | 8 ++ content/graphql/overview/changelog/2026.md | 8 ++ .../{changelog.md => changelog/index.md} | 11 +++ .../templates/graphql-changelog.template.md | 2 + src/article-api/tests/graphql-transformer.ts | 32 ++++++-- .../graphql-changelog-transformer.ts | 25 ++++++- .../graphql/overview/changelog/2024.md | 10 +++ .../graphql/overview/changelog/2025.md | 10 +++ .../graphql/overview/changelog/2026.md | 10 +++ .../{changelog.md => changelog/index.md} | 6 +- src/graphql/components/Changelog.tsx | 37 ++++++++-- src/graphql/lib/index.ts | 20 +++++ src/graphql/pages/changelog-year.tsx | 74 +++++++++++++++++++ src/graphql/pages/changelog.tsx | 61 ++++++++------- src/graphql/scripts/build-changelog.ts | 46 +++++++++++- src/graphql/tests/build-changelog.ts | 43 +++++++++++ .../graphql/overview/changelog/[year].tsx | 1 + .../{changelog.tsx => changelog/index.tsx} | 0 26 files changed, 427 insertions(+), 41 deletions(-) create mode 100644 content/graphql/overview/changelog/2017.md create mode 100644 content/graphql/overview/changelog/2018.md create mode 100644 content/graphql/overview/changelog/2019.md create mode 100644 content/graphql/overview/changelog/2020.md create mode 100644 content/graphql/overview/changelog/2021.md create mode 100644 content/graphql/overview/changelog/2022.md create mode 100644 content/graphql/overview/changelog/2023.md create mode 100644 content/graphql/overview/changelog/2024.md create mode 100644 content/graphql/overview/changelog/2025.md create mode 100644 content/graphql/overview/changelog/2026.md rename content/graphql/overview/{changelog.md => changelog/index.md} (85%) create mode 100644 src/fixtures/fixtures/content/graphql/overview/changelog/2024.md create mode 100644 src/fixtures/fixtures/content/graphql/overview/changelog/2025.md create mode 100644 src/fixtures/fixtures/content/graphql/overview/changelog/2026.md rename src/fixtures/fixtures/content/graphql/overview/{changelog.md => changelog/index.md} (79%) create mode 100644 src/graphql/pages/changelog-year.tsx create mode 100644 src/pages/[versionId]/graphql/overview/changelog/[year].tsx rename src/pages/[versionId]/graphql/overview/{changelog.tsx => changelog/index.tsx} (100%) diff --git a/content/graphql/overview/changelog/2017.md b/content/graphql/overview/changelog/2017.md new file mode 100644 index 000000000000..051944633796 --- /dev/null +++ b/content/graphql/overview/changelog/2017.md @@ -0,0 +1,8 @@ +--- +title: "GraphQL changelog for 2017" +shortTitle: "2017" +intro: 'GraphQL schema changes from 2017.' +versions: + fpt: '*' +autogenerated: graphql +--- diff --git a/content/graphql/overview/changelog/2018.md b/content/graphql/overview/changelog/2018.md new file mode 100644 index 000000000000..5721027b0a7b --- /dev/null +++ b/content/graphql/overview/changelog/2018.md @@ -0,0 +1,8 @@ +--- +title: "GraphQL changelog for 2018" +shortTitle: "2018" +intro: 'GraphQL schema changes from 2018.' +versions: + fpt: '*' +autogenerated: graphql +--- diff --git a/content/graphql/overview/changelog/2019.md b/content/graphql/overview/changelog/2019.md new file mode 100644 index 000000000000..a250cfcef489 --- /dev/null +++ b/content/graphql/overview/changelog/2019.md @@ -0,0 +1,8 @@ +--- +title: "GraphQL changelog for 2019" +shortTitle: "2019" +intro: 'GraphQL schema changes from 2019.' +versions: + fpt: '*' +autogenerated: graphql +--- diff --git a/content/graphql/overview/changelog/2020.md b/content/graphql/overview/changelog/2020.md new file mode 100644 index 000000000000..3faaadc2f372 --- /dev/null +++ b/content/graphql/overview/changelog/2020.md @@ -0,0 +1,8 @@ +--- +title: "GraphQL changelog for 2020" +shortTitle: "2020" +intro: 'GraphQL schema changes from 2020.' +versions: + fpt: '*' +autogenerated: graphql +--- diff --git a/content/graphql/overview/changelog/2021.md b/content/graphql/overview/changelog/2021.md new file mode 100644 index 000000000000..af29ea40ab20 --- /dev/null +++ b/content/graphql/overview/changelog/2021.md @@ -0,0 +1,8 @@ +--- +title: "GraphQL changelog for 2021" +shortTitle: "2021" +intro: 'GraphQL schema changes from 2021.' +versions: + fpt: '*' +autogenerated: graphql +--- diff --git a/content/graphql/overview/changelog/2022.md b/content/graphql/overview/changelog/2022.md new file mode 100644 index 000000000000..dd936ddd9473 --- /dev/null +++ b/content/graphql/overview/changelog/2022.md @@ -0,0 +1,8 @@ +--- +title: "GraphQL changelog for 2022" +shortTitle: "2022" +intro: 'GraphQL schema changes from 2022.' +versions: + fpt: '*' +autogenerated: graphql +--- diff --git a/content/graphql/overview/changelog/2023.md b/content/graphql/overview/changelog/2023.md new file mode 100644 index 000000000000..6b11d2b2d463 --- /dev/null +++ b/content/graphql/overview/changelog/2023.md @@ -0,0 +1,8 @@ +--- +title: "GraphQL changelog for 2023" +shortTitle: "2023" +intro: 'GraphQL schema changes from 2023.' +versions: + fpt: '*' +autogenerated: graphql +--- diff --git a/content/graphql/overview/changelog/2024.md b/content/graphql/overview/changelog/2024.md new file mode 100644 index 000000000000..9d56b809ddbc --- /dev/null +++ b/content/graphql/overview/changelog/2024.md @@ -0,0 +1,8 @@ +--- +title: "GraphQL changelog for 2024" +shortTitle: "2024" +intro: 'GraphQL schema changes from 2024.' +versions: + fpt: '*' +autogenerated: graphql +--- diff --git a/content/graphql/overview/changelog/2025.md b/content/graphql/overview/changelog/2025.md new file mode 100644 index 000000000000..afc659fbc082 --- /dev/null +++ b/content/graphql/overview/changelog/2025.md @@ -0,0 +1,8 @@ +--- +title: "GraphQL changelog for 2025" +shortTitle: "2025" +intro: 'GraphQL schema changes from 2025.' +versions: + fpt: '*' +autogenerated: graphql +--- diff --git a/content/graphql/overview/changelog/2026.md b/content/graphql/overview/changelog/2026.md new file mode 100644 index 000000000000..5f0ba0a8d496 --- /dev/null +++ b/content/graphql/overview/changelog/2026.md @@ -0,0 +1,8 @@ +--- +title: "GraphQL changelog for 2026" +shortTitle: "2026" +intro: 'GraphQL schema changes from 2026.' +versions: + fpt: '*' +autogenerated: graphql +--- diff --git a/content/graphql/overview/changelog.md b/content/graphql/overview/changelog/index.md similarity index 85% rename from content/graphql/overview/changelog.md rename to content/graphql/overview/changelog/index.md index 11f8acc59920..12470b307848 100644 --- a/content/graphql/overview/changelog.md +++ b/content/graphql/overview/changelog/index.md @@ -6,6 +6,17 @@ redirect_from: versions: fpt: '*' autogenerated: graphql +children: + - /2026 + - /2025 + - /2024 + - /2023 + - /2022 + - /2021 + - /2020 + - /2019 + - /2018 + - /2017 category: - Understand API changes and limits --- diff --git a/src/article-api/templates/graphql-changelog.template.md b/src/article-api/templates/graphql-changelog.template.md index b1461930710f..3ecffbd95e33 100644 --- a/src/article-api/templates/graphql-changelog.template.md +++ b/src/article-api/templates/graphql-changelog.template.md @@ -4,6 +4,8 @@ {{ manualContent }} +{% for navItem in yearNavItems %}{% if navItem.isCurrent %}**{{ navItem.year }}**{% else %}[{{ navItem.year }}]({{ navItem.year }}){% endif %}{% unless forloop.last %} · {% endunless %}{% endfor %} + {% for item in changelogItems %} ## Schema changes for {{ item.date }} diff --git a/src/article-api/tests/graphql-transformer.ts b/src/article-api/tests/graphql-transformer.ts index d418bec102cd..fc0a49502bf2 100644 --- a/src/article-api/tests/graphql-transformer.ts +++ b/src/article-api/tests/graphql-transformer.ts @@ -233,7 +233,7 @@ describe('GraphQL transformer', { timeout: 10000 }, () => { }) describe('Overview pages', () => { - test('changelog page renders with changes', async () => { + test('changelog index page renders with latest year changes', async () => { const res = await getCached('/en/graphql/overview/changelog') expect(res.statusCode).toBe(200) @@ -250,16 +250,38 @@ describe('GraphQL transformer', { timeout: 10000 }, () => { 'Breaking changes include changes that will break existing queries', ) - // Check for date-based changelog sections - expect(res.body).toContain('## Schema changes for 2025-11-30') + // Index page shows latest year (2026) entries only + expect(res.body).toContain('## Schema changes for 2026-') // Check for change items expect(res.body).toContain('### The GraphQL schema includes these changes:') - expect(res.body).toContain('Type SuggestedReviewerActor was added') + + // Should NOT contain entries from other years + expect(res.body).not.toContain('## Schema changes for 2025-') + + // Check for year navigation + expect(res.body).toContain('2026') + expect(res.body).toContain('2025') + }) + + test('changelog year page renders with that year only', async () => { + const res = await getCached('/en/graphql/overview/changelog/2025') + expect(res.statusCode).toBe(200) + + // Check for year-specific heading + expect(res.body).toContain('# GraphQL changelog for 2025') + + // Check for date-based changelog sections from 2025 + expect(res.body).toContain('## Schema changes for 2025-') + + // Should NOT contain entries from other years + expect(res.body).not.toContain('## Schema changes for 2026-') + expect(res.body).not.toContain('## Schema changes for 2024-') }) test('changelog removes HTML tags from changes', async () => { - const res = await getCached('/en/graphql/overview/changelog') + // Use a year page that has the specific test data + const res = await getCached('/en/graphql/overview/changelog/2025') expect(res.statusCode).toBe(200) // Check that HTML tags are removed diff --git a/src/article-api/transformers/graphql-changelog-transformer.ts b/src/article-api/transformers/graphql-changelog-transformer.ts index 0e3b504cbce0..53d764cbdb09 100644 --- a/src/article-api/transformers/graphql-changelog-transformer.ts +++ b/src/article-api/transformers/graphql-changelog-transformer.ts @@ -22,9 +22,22 @@ export class GraphQLChangelogTransformer implements PageTransformer { async transform(page: Page, _pathname: string, context: Context): Promise { const currentVersion = context.currentVersion! - const { getGraphqlChangelog } = await import('@/graphql/lib/index') + const { getGraphqlChangelogByYear, getGraphqlChangelogYears } = + await import('@/graphql/lib/index') - const schema = getGraphqlChangelog(currentVersion) as ChangelogItemT[] + // Determine if this is a year-specific page + const yearMatch = page.relativePath.match(/changelog\/(\d{4})\.md$/) + const year = yearMatch ? Number(yearMatch[1]) : null + const years = getGraphqlChangelogYears(currentVersion) + + let schema: ChangelogItemT[] + if (year) { + schema = getGraphqlChangelogByYear(currentVersion, year) as ChangelogItemT[] + } else { + // Index page: show only the latest year + const latestYear = years[0] + schema = getGraphqlChangelogByYear(currentVersion, latestYear) as ChangelogItemT[] + } const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' const manualContent = await extractManualContent(page, context) @@ -51,11 +64,19 @@ export class GraphQLChangelogTransformer implements PageTransformer { } }) + // Build year navigation links + const displayYear = year || years[0] + const yearNavItems = years.map((y) => ({ + year: y, + isCurrent: y === displayYear, + })) + const templateData: Record = { pageTitle: page.title, pageIntro: intro, manualContent, changelogItems, + yearNavItems, } const templateContent = loadTemplate(this.templateName) diff --git a/src/fixtures/fixtures/content/graphql/overview/changelog/2024.md b/src/fixtures/fixtures/content/graphql/overview/changelog/2024.md new file mode 100644 index 000000000000..260addef2fe3 --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/overview/changelog/2024.md @@ -0,0 +1,10 @@ +--- +title: "GraphQL changelog for 2024" +shortTitle: "2024" +intro: 'GraphQL schema changes from 2024.' +versions: + fpt: '*' + ghec: '*' + ghes: '*' +autogenerated: graphql +--- diff --git a/src/fixtures/fixtures/content/graphql/overview/changelog/2025.md b/src/fixtures/fixtures/content/graphql/overview/changelog/2025.md new file mode 100644 index 000000000000..7352eb823c78 --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/overview/changelog/2025.md @@ -0,0 +1,10 @@ +--- +title: "GraphQL changelog for 2025" +shortTitle: "2025" +intro: 'GraphQL schema changes from 2025.' +versions: + fpt: '*' + ghec: '*' + ghes: '*' +autogenerated: graphql +--- diff --git a/src/fixtures/fixtures/content/graphql/overview/changelog/2026.md b/src/fixtures/fixtures/content/graphql/overview/changelog/2026.md new file mode 100644 index 000000000000..d663bd9fcdc2 --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/overview/changelog/2026.md @@ -0,0 +1,10 @@ +--- +title: "GraphQL changelog for 2026" +shortTitle: "2026" +intro: 'GraphQL schema changes from 2026.' +versions: + fpt: '*' + ghec: '*' + ghes: '*' +autogenerated: graphql +--- diff --git a/src/fixtures/fixtures/content/graphql/overview/changelog.md b/src/fixtures/fixtures/content/graphql/overview/changelog/index.md similarity index 79% rename from src/fixtures/fixtures/content/graphql/overview/changelog.md rename to src/fixtures/fixtures/content/graphql/overview/changelog/index.md index 248d15f70c0f..d93fa0f07b86 100644 --- a/src/fixtures/fixtures/content/graphql/overview/changelog.md +++ b/src/fixtures/fixtures/content/graphql/overview/changelog/index.md @@ -6,8 +6,12 @@ versions: ghec: '*' ghes: '*' autogenerated: graphql +children: + - /2026 + - /2025 + - /2024 --- Breaking changes include changes that will break existing queries or could affect the runtime behavior of clients. For a list of breaking changes and when they will occur, see our breaking changes log. - \ No newline at end of file + diff --git a/src/graphql/components/Changelog.tsx b/src/graphql/components/Changelog.tsx index cc1d06777803..a6f1d13a6ecb 100644 --- a/src/graphql/components/Changelog.tsx +++ b/src/graphql/components/Changelog.tsx @@ -8,9 +8,29 @@ import styles from '@/frame/components/ui/MarkdownContent/MarkdownContent.module type Props = { changelogItems: ChangelogItemT[] + years?: number[] + currentYear?: number } -export function Changelog({ changelogItems }: Props) { +function YearNav({ years, currentYear }: { years: number[]; currentYear: number }) { + return ( + + ) +} + +export function Changelog({ changelogItems, years, currentYear }: Props) { const slugger = new GithubSlugger() const changes = changelogItems.map((item, index) => { @@ -45,14 +65,21 @@ export function Changelog({ changelogItems }: Props) { {(item.upcomingChanges || []).map((change, changeIndex) => (

{change.title}

- {change.changes.map((changeItem) => ( -
  • - ))} +
      + {change.changes.map((changeItem) => ( +
    • + ))} +
    ))} ) }) - return
    {changes}
    + return ( +
    + {years && currentYear && } + {changes} +
    + ) } diff --git a/src/graphql/lib/index.ts b/src/graphql/lib/index.ts index 572be9573c54..e6ebf5cc056b 100644 --- a/src/graphql/lib/index.ts +++ b/src/graphql/lib/index.ts @@ -50,6 +50,26 @@ export function getGraphqlChangelog(version: string): any { return changelog.get(graphqlVersion) } +/** + * Return changelog entries filtered by year. + */ +export function getGraphqlChangelogByYear(version: string, year: number): any[] { + const all = getGraphqlChangelog(version) as Array<{ date: string }> + return all.filter((entry) => entry.date.startsWith(String(year))) +} + +/** + * Return the distinct years present in the changelog, sorted descending (newest first). + */ +export function getGraphqlChangelogYears(version: string): number[] { + const all = getGraphqlChangelog(version) as Array<{ date: string }> + const years = new Set() + for (const entry of all) { + years.add(Number(entry.date.slice(0, 4))) + } + return [...years].sort((a, b) => b - a) +} + // Using any for return type as the breaking changes structure is dynamically loaded from JSON export function getGraphqlBreakingChanges(version: string): any { const graphqlVersion: string = getGraphqlVersion(version) diff --git a/src/graphql/pages/changelog-year.tsx b/src/graphql/pages/changelog-year.tsx new file mode 100644 index 000000000000..79df6419c61b --- /dev/null +++ b/src/graphql/pages/changelog-year.tsx @@ -0,0 +1,74 @@ +import { GetServerSideProps } from 'next' +import type { ExtendedRequest } from '@/types' +import type { ServerResponse } from 'http' + +import { MainContextT, MainContext, getMainContext } from '@/frame/components/context/MainContext' +import { AutomatedPage } from '@/automated-pipelines/components/AutomatedPage' +import { + AutomatedPageContext, + AutomatedPageContextT, + getAutomatedPageContextFromRequest, +} from '@/automated-pipelines/components/AutomatedPageContext' +import { Changelog } from '@/graphql/components/Changelog' +import { ChangelogItemT } from '@/graphql/components/types' +import { stripParagraphWrappers } from '@/graphql/pages/changelog' + +type Props = { + mainContext: MainContextT + schema: ChangelogItemT[] + automatedPageContext: AutomatedPageContextT + years: number[] + currentYear: number +} + +export default function GraphqlChangelogYear({ + mainContext, + schema, + automatedPageContext, + years, + currentYear, +}: Props) { + const content = + return ( + + + {content} + + + ) +} + +export const getServerSideProps: GetServerSideProps = async (context) => { + 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 year = Number(context.query.year) + + const years = getGraphqlChangelogYears(currentVersion) + if (!years.includes(year)) { + return { notFound: true } + } + + const schema = getGraphqlChangelogByYear(currentVersion, year) as ChangelogItemT[] + + const automatedPageContext = getAutomatedPageContextFromRequest(req) + const titles = schema.map((item) => `Schema changes for ${item.date}`) + const changelogMiniTocItems = await getAutomatedPageMiniTocItems(titles, req.context!, 2) + automatedPageContext.miniTocItems.push(...changelogMiniTocItems) + + stripParagraphWrappers(schema) + + return { + props: { + mainContext: await getMainContext(req, res), + automatedPageContext, + schema, + years, + currentYear: year, + }, + } +} diff --git a/src/graphql/pages/changelog.tsx b/src/graphql/pages/changelog.tsx index 8dd1ce67fa7d..46bc884988a1 100644 --- a/src/graphql/pages/changelog.tsx +++ b/src/graphql/pages/changelog.tsx @@ -16,10 +16,18 @@ type Props = { mainContext: MainContextT schema: ChangelogItemT[] automatedPageContext: AutomatedPageContextT + years: number[] + currentYear: number } -export default function GraphqlChangelog({ mainContext, schema, automatedPageContext }: Props) { - const content = +export default function GraphqlChangelog({ + mainContext, + schema, + automatedPageContext, + years, + currentYear, +}: Props) { + const content = return ( @@ -30,34 +38,41 @@ export default function GraphqlChangelog({ mainContext, schema, automatedPageCon } export const getServerSideProps: GetServerSideProps = 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/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 From 5e9dcbc0e46c02256114faaa3f33ec361e2b2b19 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 20 Apr 2026 14:51:29 -0700 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20Remove=20explicit=20an?= =?UTF-8?q?y=20types=20from=2016=20TypeScript=20files=20(#60746)?= 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> --- eslint.config.ts | 18 +- src/content-linter/lib/init-test.ts | 4 +- .../lib/linting-rules/code-annotations.ts | 2 +- src/content-linter/lib/linting-rules/index.ts | 7 +- .../linting-rules/journey-tracks-liquid.ts | 17 +- .../lib/linting-rules/liquid-versioning.ts | 7 +- .../third-party-action-pinning.ts | 6 +- .../scripts/pretty-print-results.ts | 4 +- src/content-linter/style/base.ts | 2 +- .../tests/integration/lint-cli.ts | 7 +- src/content-linter/tests/lint-files.ts | 314 +++++++++--------- .../tests/lint-frontmatter-links.ts | 18 +- .../unit/table-column-integrity-simple.ts | 12 +- .../tests/link-error-line-numbers.ts | 28 +- .../scripts/migrate-early-access-product.ts | 15 +- .../tests/categories-and-subcategory.ts | 11 +- src/fixtures/tests/translations.ts | 25 +- src/workflows/projects.ts | 90 ++++- src/workflows/ready-for-docs-review.ts | 29 +- 19 files changed, 330 insertions(+), 286 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index 5010779df4fe..961aaa8bcde3 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -204,32 +204,17 @@ export default [ 'src/article-api/transformers/audit-logs-transformer.ts', 'src/article-api/transformers/rest-transformer.ts', 'src/codeql-cli/scripts/convert-markdown-for-docs.ts', - 'src/content-linter/lib/init-test.ts', - 'src/content-linter/lib/linting-rules/code-annotations.ts', - 'src/content-linter/lib/linting-rules/index.ts', - 'src/content-linter/lib/linting-rules/journey-tracks-liquid.ts', 'src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts', - 'src/content-linter/lib/linting-rules/liquid-versioning.ts', - 'src/content-linter/lib/linting-rules/third-party-action-pinning.ts', 'src/content-linter/scripts/lint-content.ts', - 'src/content-linter/scripts/pretty-print-results.ts', - 'src/content-linter/style/base.ts', - 'src/content-linter/tests/integration/lint-cli.ts', - 'src/content-linter/tests/lint-files.ts', - 'src/content-linter/tests/lint-frontmatter-links.ts', - 'src/content-linter/tests/unit/table-column-integrity-simple.ts', + 'src/content-render/liquid/engine.ts', 'src/content-render/liquid/index.ts', 'src/content-render/scripts/liquid-tags.ts', 'src/content-render/scripts/move-content.ts', - 'src/content-render/tests/link-error-line-numbers.ts', 'src/content-render/unified/annotate.ts', 'src/content-render/unified/index.ts', 'src/data-directory/lib/get-data.ts', - 'src/early-access/scripts/migrate-early-access-product.ts', - 'src/fixtures/tests/categories-and-subcategory.ts', 'src/fixtures/tests/guides.ts', - 'src/fixtures/tests/translations.ts', 'src/frame/components/context/MainContext.tsx', 'src/frame/lib/create-tree.ts', 'src/frame/lib/frontmatter.ts', @@ -290,7 +275,6 @@ export default [ 'src/types/markdownlint-rule-helpers.d.ts', 'src/types/markdownlint-rule-search-replace.d.ts', 'src/types/primer__octicons.d.ts', - 'src/workflows/projects.ts', ], rules: { '@typescript-eslint/no-explicit-any': 'off', diff --git a/src/content-linter/lib/init-test.ts b/src/content-linter/lib/init-test.ts index f5eb91e05d45..6fcd819f5634 100644 --- a/src/content-linter/lib/init-test.ts +++ b/src/content-linter/lib/init-test.ts @@ -23,7 +23,9 @@ export async function runRule( } const testOptions: Partial = { - customRules: [module as any], + customRules: [ + module as unknown as NonNullable>[number], + ], config: { ...defaultConfig, ...testConfig }, } if (strings) testOptions.strings = strings diff --git a/src/content-linter/lib/linting-rules/code-annotations.ts b/src/content-linter/lib/linting-rules/code-annotations.ts index 4226d8ed2070..df9f090c0a5c 100644 --- a/src/content-linter/lib/linting-rules/code-annotations.ts +++ b/src/content-linter/lib/linting-rules/code-annotations.ts @@ -5,7 +5,7 @@ import type { RuleParams, RuleErrorCallback, MarkdownToken } from '@/content-lin interface Frontmatter { layout?: string - [key: string]: any + [key: string]: unknown } export const codeAnnotations = { diff --git a/src/content-linter/lib/linting-rules/index.ts b/src/content-linter/lib/linting-rules/index.ts index 56b641849e14..a9232fae65c2 100644 --- a/src/content-linter/lib/linting-rules/index.ts +++ b/src/content-linter/lib/linting-rules/index.ts @@ -58,13 +58,10 @@ import { raiAppCardStructure } from '@/content-linter/lib/linting-rules/rai-app- import { frontmatterContentType } from '@/content-linter/lib/linting-rules/frontmatter-content-type' import { frontmatterDocsTeamMetrics } from '@/content-linter/lib/linting-rules/frontmatter-docs-team-metrics' -// Using any type because @github/markdownlint-github doesn't provide TypeScript declarations -// The elements in the array have a 'names' property that contains rule identifiers -const noDefaultAltText = markdownlintGitHub.find((elem: any) => +const noDefaultAltText = markdownlintGitHub.find((elem: { names: string[] }) => elem.names.includes('no-default-alt-text'), ) -// Using any type because @github/markdownlint-github doesn't provide TypeScript declarations -const noGenericLinkText = markdownlintGitHub.find((elem: any) => +const noGenericLinkText = markdownlintGitHub.find((elem: { names: string[] }) => elem.names.includes('no-generic-link-text'), ) diff --git a/src/content-linter/lib/linting-rules/journey-tracks-liquid.ts b/src/content-linter/lib/linting-rules/journey-tracks-liquid.ts index bb602fc1c14d..f3a3dfd547c9 100644 --- a/src/content-linter/lib/linting-rules/journey-tracks-liquid.ts +++ b/src/content-linter/lib/linting-rules/journey-tracks-liquid.ts @@ -9,8 +9,7 @@ export const journeyTracksLiquid = { description: 'Journey track properties must use valid Liquid syntax', tags: ['frontmatter', 'journey-tracks', 'liquid'], function: (params: RuleParams, onError: RuleErrorCallback) => { - // Using any for frontmatter as it's a dynamic YAML object with varying properties - const fm: any = getFrontmatter(params.lines) + const fm: Record = getFrontmatter(params.lines) as Record if (!fm || !fm.journeyTracks || !Array.isArray(fm.journeyTracks)) return if (!fm.layout || fm.layout !== 'journey-landing') return @@ -23,7 +22,7 @@ export const journeyTracksLiquid = { : 1 for (let trackIndex = 0; trackIndex < fm.journeyTracks.length; trackIndex++) { - const track: any = fm.journeyTracks[trackIndex] + const track = (fm.journeyTracks as Array>)[trackIndex] // Try to find the line number for this specific journey track so we can use that for the error // line number. Getting the exact line number is probably more work than it's worth for this // particular rule. @@ -62,11 +61,11 @@ export const journeyTracksLiquid = { if (prop.value && typeof prop.value === 'string') { try { liquid.parse(prop.value) - } catch (error: any) { + } catch (error: unknown) { addError( onError, trackLineNumber, - `Invalid Liquid syntax in journey track ${prop.name} (track ${trackIndex + 1}): ${error.message}`, + `Invalid Liquid syntax in journey track ${prop.name} (track ${trackIndex + 1}): ${error instanceof Error ? error.message : String(error)}`, prop.value, ) } @@ -84,11 +83,11 @@ export const journeyTracksLiquid = { if ('href' in guideObj && typeof guideObj.href === 'string') { try { liquid.parse(guideObj.href) - } catch (error: any) { + } catch (error: unknown) { addError( onError, trackLineNumber, - `Invalid Liquid syntax in journey track guide href (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error.message}`, + `Invalid Liquid syntax in journey track guide href (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error instanceof Error ? error.message : String(error)}`, guideObj.href, ) } @@ -101,11 +100,11 @@ export const journeyTracksLiquid = { ) { try { liquid.parse(guideObj.alternativeNextStep) - } catch (error: any) { + } catch (error: unknown) { addError( onError, trackLineNumber, - `Invalid Liquid syntax in journey track guide alternativeNextStep (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error.message}`, + `Invalid Liquid syntax in journey track guide alternativeNextStep (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error instanceof Error ? error.message : String(error)}`, guideObj.alternativeNextStep, ) } diff --git a/src/content-linter/lib/linting-rules/liquid-versioning.ts b/src/content-linter/lib/linting-rules/liquid-versioning.ts index a9062aa04243..bebbba7bdfa8 100644 --- a/src/content-linter/lib/linting-rules/liquid-versioning.ts +++ b/src/content-linter/lib/linting-rules/liquid-versioning.ts @@ -169,9 +169,8 @@ function validateIfversionConditionals(cond: string, possibleVersionNames: Set = {} if (part in allFeatures) { for (const [shortName, version] of Object.entries(allFeatures[part].versions)) { - // Using 'as any' for recursive getVersionsObject call because it can return either - // a string or a nested Record, but we flatten it to string for this context const versionOperator: string = version in allFeatures - ? (getVersionsObject(version, allFeatures) as any) + ? Object.values(getVersionsObject(version, allFeatures))[0] || '*' : (version as string) if (shortName in versions) { versions[shortName] = lowestVersion(versionOperator, versions[shortName]) diff --git a/src/content-linter/lib/linting-rules/third-party-action-pinning.ts b/src/content-linter/lib/linting-rules/third-party-action-pinning.ts index 45cb3424099c..cc8d927dcbb9 100644 --- a/src/content-linter/lib/linting-rules/third-party-action-pinning.ts +++ b/src/content-linter/lib/linting-rules/third-party-action-pinning.ts @@ -14,18 +14,18 @@ const firstPartyPrefixes = ['actions/', './.github/actions/', 'github/', 'octo-o interface WorkflowStep { uses?: string - [key: string]: any + [key: string]: unknown } interface WorkflowJob { steps?: WorkflowStep[] - [key: string]: any + [key: string]: unknown } interface WorkflowYaml { jobs?: Record steps?: WorkflowStep[] - [key: string]: any + [key: string]: unknown } export const thirdPartyActionPinning: Rule = { diff --git a/src/content-linter/scripts/pretty-print-results.ts b/src/content-linter/scripts/pretty-print-results.ts index 3c0a8124a095..bd758d0d949f 100644 --- a/src/content-linter/scripts/pretty-print-results.ts +++ b/src/content-linter/scripts/pretty-print-results.ts @@ -152,8 +152,8 @@ function chalkFunColors(text: string): string { .map((char) => { const color = shuffledColors[colorIndex] colorIndex = (colorIndex + 1) % shuffledColors.length - // Chalk's TypeScript types don't support dynamic property access, but these are valid color methods - return (chalk as any)[color](char) + const colorFn = chalk[color] as (text: string) => string + return colorFn(char) }) .join('') } diff --git a/src/content-linter/style/base.ts b/src/content-linter/style/base.ts index 600a65017754..bdb7dc725869 100644 --- a/src/content-linter/style/base.ts +++ b/src/content-linter/style/base.ts @@ -9,7 +9,7 @@ type RuleConfig = { severity: 'error' | 'warning' 'partial-markdown-files': boolean 'yml-files': boolean - [key: string]: any + [key: string]: unknown } type BaseConfig = { diff --git a/src/content-linter/tests/integration/lint-cli.ts b/src/content-linter/tests/integration/lint-cli.ts index 4e464014ba49..c089f08df799 100644 --- a/src/content-linter/tests/integration/lint-cli.ts +++ b/src/content-linter/tests/integration/lint-cli.ts @@ -52,9 +52,10 @@ describe('Content Linter CLI Integration Tests', { timeout: 30000 }, () => { stdio: 'pipe', timeout: 10000, // 10 second timeout }) - } catch (error: any) { - output = error.stdout + error.stderr - exitCode = error.status || 1 + } catch (error: unknown) { + const execError = error as { stdout?: string; stderr?: string; status?: number } + output = (execError.stdout || '') + (execError.stderr || '') + exitCode = execError.status || 1 } return { output, exitCode } diff --git a/src/content-linter/tests/lint-files.ts b/src/content-linter/tests/lint-files.ts index c700fc7cb08b..cc3509a3e722 100755 --- a/src/content-linter/tests/lint-files.ts +++ b/src/content-linter/tests/lint-files.ts @@ -200,9 +200,15 @@ function formatLinkError(message: string, links: string[]) { // Returns `content` if its a string, or `content.description` if it can. // Used for getting the nested `description` key in glossary files. // Using any because content can be string | { description: string } | other YAML structures -function getContent(content: any) { +function getContent(content: unknown) { if (typeof content === 'string') return content - if (typeof content.description === 'string') return content.description + if ( + content && + typeof content === 'object' && + 'description' in content && + typeof (content as { description: unknown }).description === 'string' + ) + return (content as { description: string }).description return null } @@ -240,198 +246,200 @@ if (ymlToLint.length === 0) { } else { describe('lint yaml content', () => { if (ymlToLint.length < 1) return - describe.each(ymlToLint)('%s', (yamlRelPath: any, yamlAbsPath: any) => { - // Using any because Vitest's describe.each doesn't properly infer tuple types - let dictionary: any // YAML structure varies by file type (variables, glossaries, features) - let isEarlyAccess: boolean - let fileContents: string - // This variable is used to determine if the file was parsed successfully. - // When `yaml.load()` fails to parse the file, it is overwritten with the error message. - // `false` is intentionally chosen since `null` and `undefined` are valid return values. - let dictionaryError: any = false // Can be false, Error, or other error types - - beforeAll(async () => { - fileContents = await fs.readFile(yamlAbsPath, 'utf8') - try { - dictionary = yaml.load(fileContents, { filename: yamlRelPath }) - } catch (error) { - dictionaryError = error - } - - isEarlyAccess = yamlRelPath.split('/').includes('early-access') - }) - - test('it can be parsed as a single yaml document', () => { - expect(dictionaryError).toBe(false) - }) - - test('placeholder string is not present in any yaml files', () => { - const matches = fileContents.match(placeholderRegex) || [] - const errorMessage = ` - Found ${matches.length} placeholder string '${placeholder}'! Please update all placeholders. - ` - expect(matches.length, errorMessage).toBe(0) - }) - - test('relative URLs must start with "/"', async () => { - const matches = [] - - for (const [key, content] of Object.entries(dictionary)) { - const contentStr = getContent(content) - if (!contentStr) continue - const valMatches = contentStr.match(relativeArticleLinkRegex) || [] - if (valMatches.length > 0) { - matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) + describe.each(ymlToLint)( + '%s', + (yamlRelPath: string | undefined, yamlAbsPath: string | undefined) => { + let dictionary: unknown // YAML structure varies by file type (variables, glossaries, features) + let isEarlyAccess: boolean + let fileContents: string + // This variable is used to determine if the file was parsed successfully. + // When `yaml.load()` fails to parse the file, it is overwritten with the error message. + // `false` is intentionally chosen since `null` and `undefined` are valid return values. + let dictionaryError: unknown = false + + beforeAll(async () => { + fileContents = await fs.readFile(yamlAbsPath!, 'utf8') + try { + dictionary = yaml.load(fileContents, { filename: yamlRelPath }) + } catch (error) { + dictionaryError = error } - } - const errorMessage = formatLinkError(relativeArticleLinkErrorText, matches) - expect(matches.length, errorMessage).toBe(0) - }) + isEarlyAccess = yamlRelPath!.split('/').includes('early-access') + }) + + test('it can be parsed as a single yaml document', () => { + expect(dictionaryError).toBe(false) + }) - test('must not leak Early Access doc URLs', async () => { - // Only execute for docs that are NOT Early Access - if (!isEarlyAccess) { + test('placeholder string is not present in any yaml files', () => { + const matches = fileContents.match(placeholderRegex) || [] + const errorMessage = ` + Found ${matches.length} placeholder string '${placeholder}'! Please update all placeholders. + ` + expect(matches.length, errorMessage).toBe(0) + }) + + test('relative URLs must start with "/"', async () => { const matches = [] - for (const [key, content] of Object.entries(dictionary)) { + for (const [key, content] of Object.entries(dictionary as Record)) { const contentStr = getContent(content) if (!contentStr) continue - const valMatches = contentStr.match(earlyAccessLinkRegex) || [] + const valMatches = contentStr.match(relativeArticleLinkRegex) || [] if (valMatches.length > 0) { matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) } } - const errorMessage = formatLinkError(earlyAccessLinkErrorText, matches) + const errorMessage = formatLinkError(relativeArticleLinkErrorText, matches) expect(matches.length, errorMessage).toBe(0) - } - }) + }) + + test('must not leak Early Access doc URLs', async () => { + // Only execute for docs that are NOT Early Access + if (!isEarlyAccess) { + const matches = [] + + for (const [key, content] of Object.entries(dictionary as Record)) { + const contentStr = getContent(content) + if (!contentStr) continue + const valMatches = contentStr.match(earlyAccessLinkRegex) || [] + if (valMatches.length > 0) { + matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) + } + } + + const errorMessage = formatLinkError(earlyAccessLinkErrorText, matches) + expect(matches.length, errorMessage).toBe(0) + } + }) + + test('must not leak Early Access image URLs', async () => { + // Only execute for docs that are NOT Early Access + if (!isEarlyAccess) { + const matches = [] + + for (const [key, content] of Object.entries(dictionary as Record)) { + const contentStr = getContent(content) + if (!contentStr) continue + const valMatches = contentStr.match(earlyAccessImageRegex) || [] + if (valMatches.length > 0) { + matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) + } + } + + const errorMessage = formatLinkError(earlyAccessImageErrorText, matches) + expect(matches.length, errorMessage).toBe(0) + } + }) - test('must not leak Early Access image URLs', async () => { - // Only execute for docs that are NOT Early Access - if (!isEarlyAccess) { + test('must have correctly formatted Early Access image URLs', async () => { + // Execute for ALL docs (not just Early Access) to ensure non-EA docs + // are not leaking incorrectly formatted EA image URLs const matches = [] - for (const [key, content] of Object.entries(dictionary)) { + for (const [key, content] of Object.entries(dictionary as Record)) { const contentStr = getContent(content) if (!contentStr) continue - const valMatches = contentStr.match(earlyAccessImageRegex) || [] + const valMatches = contentStr.match(badEarlyAccessImageRegex) || [] if (valMatches.length > 0) { matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) } } - const errorMessage = formatLinkError(earlyAccessImageErrorText, matches) + const errorMessage = formatLinkError(badEarlyAccessImageErrorText, matches) expect(matches.length, errorMessage).toBe(0) - } - }) - - test('must have correctly formatted Early Access image URLs', async () => { - // Execute for ALL docs (not just Early Access) to ensure non-EA docs - // are not leaking incorrectly formatted EA image URLs - const matches = [] - - for (const [key, content] of Object.entries(dictionary)) { - const contentStr = getContent(content) - if (!contentStr) continue - const valMatches = contentStr.match(badEarlyAccessImageRegex) || [] - if (valMatches.length > 0) { - matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) + }) + + test('URLs must not contain a hard-coded language code', async () => { + const matches = [] + + for (const [key, content] of Object.entries(dictionary as Record)) { + const contentStr = getContent(content) + if (!contentStr) continue + const valMatches = contentStr.match(languageLinkRegex) || [] + if (valMatches.length > 0) { + matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) + } } - } - const errorMessage = formatLinkError(badEarlyAccessImageErrorText, matches) - expect(matches.length, errorMessage).toBe(0) - }) + const errorMessage = formatLinkError(languageLinkErrorText, matches) + expect(matches.length, errorMessage).toBe(0) + }) - test('URLs must not contain a hard-coded language code', async () => { - const matches = [] + test('URLs must not contain a hard-coded version number', async () => { + const matches = [] - for (const [key, content] of Object.entries(dictionary)) { - const contentStr = getContent(content) - if (!contentStr) continue - const valMatches = contentStr.match(languageLinkRegex) || [] - if (valMatches.length > 0) { - matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) + for (const [key, content] of Object.entries(dictionary as Record)) { + const contentStr = getContent(content) + if (!contentStr) continue + const valMatches = contentStr.match(versionLinkRegEx) || [] + if (valMatches.length > 0) { + matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) + } } - } - const errorMessage = formatLinkError(languageLinkErrorText, matches) - expect(matches.length, errorMessage).toBe(0) - }) + const errorMessage = formatLinkError(versionLinkErrorText, matches) + expect(matches.length, errorMessage).toBe(0) + }) - test('URLs must not contain a hard-coded version number', async () => { - const matches = [] + test('URLs must not contain a hard-coded domain name', async () => { + const matches = [] - for (const [key, content] of Object.entries(dictionary)) { - const contentStr = getContent(content) - if (!contentStr) continue - const valMatches = contentStr.match(versionLinkRegEx) || [] - if (valMatches.length > 0) { - matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) + for (const [key, content] of Object.entries(dictionary as Record)) { + const contentStr = getContent(content) + if (!contentStr) continue + const valMatches = contentStr.match(domainLinkRegex) || [] + if (valMatches.length > 0) { + matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) + } } - } - const errorMessage = formatLinkError(versionLinkErrorText, matches) - expect(matches.length, errorMessage).toBe(0) - }) + const errorMessage = formatLinkError(domainLinkErrorText, matches) + expect(matches.length, errorMessage).toBe(0) + }) - test('URLs must not contain a hard-coded domain name', async () => { - const matches = [] + test('does not use old site.data variable syntax', async () => { + const matches = [] - for (const [key, content] of Object.entries(dictionary)) { - const contentStr = getContent(content) - if (!contentStr) continue - const valMatches = contentStr.match(domainLinkRegex) || [] - if (valMatches.length > 0) { - matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) - } - } - - const errorMessage = formatLinkError(domainLinkErrorText, matches) - expect(matches.length, errorMessage).toBe(0) - }) - - test('does not use old site.data variable syntax', async () => { - const matches = [] - - for (const [key, content] of Object.entries(dictionary)) { - const contentStr = getContent(content) - if (!contentStr) continue - const valMatches = contentStr.match(oldVariableRegex) || [] - if (valMatches.length > 0) { - matches.push( - ...valMatches.map((match: string) => { - const example = match.replace( - /{{\s*?site\.data\.([a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]+)+)\s*?}}/g, - '{% data $1 %}', - ) - return `Key "${key}": ${match} => ${example}` - }), - ) + for (const [key, content] of Object.entries(dictionary as Record)) { + const contentStr = getContent(content) + if (!contentStr) continue + const valMatches = contentStr.match(oldVariableRegex) || [] + if (valMatches.length > 0) { + matches.push( + ...valMatches.map((match: string) => { + const example = match.replace( + /{{\s*?site\.data\.([a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]+)+)\s*?}}/g, + '{% data $1 %}', + ) + return `Key "${key}": ${match} => ${example}` + }), + ) + } } - } - const errorMessage = formatLinkError(oldVariableErrorText, matches) - expect(matches.length, errorMessage).toBe(0) - }) + const errorMessage = formatLinkError(oldVariableErrorText, matches) + expect(matches.length, errorMessage).toBe(0) + }) - test('does not use old octicon variable syntax', async () => { - const matches = [] + test('does not use old octicon variable syntax', async () => { + const matches = [] - for (const [key, content] of Object.entries(dictionary)) { - const contentStr = getContent(content) - if (!contentStr) continue - const valMatches = contentStr.match(oldOcticonRegex) || [] - if (valMatches.length > 0) { - matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) + for (const [key, content] of Object.entries(dictionary as Record)) { + const contentStr = getContent(content) + if (!contentStr) continue + const valMatches = contentStr.match(oldOcticonRegex) || [] + if (valMatches.length > 0) { + matches.push(...valMatches.map((match: string) => `Key "${key}": ${match}`)) + } } - } - const errorMessage = formatLinkError(oldOcticonErrorText, matches) - expect(matches.length, errorMessage).toBe(0) - }) - }) + const errorMessage = formatLinkError(oldOcticonErrorText, matches) + expect(matches.length, errorMessage).toBe(0) + }) + }, + ) }) } diff --git a/src/content-linter/tests/lint-frontmatter-links.ts b/src/content-linter/tests/lint-frontmatter-links.ts index 07b4b1e352af..f13643ecad07 100644 --- a/src/content-linter/tests/lint-frontmatter-links.ts +++ b/src/content-linter/tests/lint-frontmatter-links.ts @@ -12,9 +12,11 @@ const liquidElsif = /{%\s*elsif/ const containsLiquidElseIf = (text: string) => liquidElsif.test(text) describe('front matter', () => { - // Using any type for page because it comes from loadPages which returns dynamic page objects with varying properties - // Using any[] for trouble because the error objects have different shapes depending on the validation that failed - function makeCustomErrorMessage(page: any, trouble: any[], key: string) { + function makeCustomErrorMessage( + page: { relativePath: string }, + trouble: Array<{ warning?: boolean; uri?: string; index?: number; redirects?: string }>, + key: string, + ) { let customErrorMessage = `In the front matter of ${page.relativePath} ` if (trouble.length > 0) { if (trouble.length === 1) { @@ -22,8 +24,7 @@ describe('front matter', () => { } else { customErrorMessage += `there are ${trouble.length} .${key} front matter entries that are not correct.` } - // Using any type because trouble array contains objects with varying error properties - const nonWarnings = trouble.filter((t: any) => !t.warning) + const nonWarnings = trouble.filter((t) => !t.warning) for (const { uri, index, redirects: redirectTo } of nonWarnings) { customErrorMessage += `\nindex: ${index} URI: ${uri}` if (redirectTo) { @@ -32,8 +33,7 @@ describe('front matter', () => { customErrorMessage += '\tPage not found' } } - // Using any type because trouble array contains objects with varying error properties - if (trouble.find((t: any) => t.redirects)) { + if (trouble.find((t) => t.redirects)) { customErrorMessage += `\n\nNOTE! To automatically fix the redirects run this command:\n` customErrorMessage += `\n\t./src/links/scripts/update-internal-links.ts content/${page.relativePath}\n\n` } @@ -59,7 +59,7 @@ describe('front matter', () => { ...links .filter((link) => link.href) .map((link, i) => checkURL(link.href, i, redirectsContext)) - .filter(Boolean), + .filter((item): item is NonNullable => Boolean(item)), ) } @@ -89,7 +89,7 @@ describe('front matter', () => { // Ignore those too. .filter((uri) => !uri.includes('https://')) .map((uri, i) => checkURL(uri, i, redirectsContext)) - .filter(Boolean), + .filter((item): item is NonNullable => Boolean(item)), ) } const customErrorMessage = makeCustomErrorMessage(page, trouble, 'introLinks') diff --git a/src/content-linter/tests/unit/table-column-integrity-simple.ts b/src/content-linter/tests/unit/table-column-integrity-simple.ts index 4980fce6f999..dcbb9947a0d7 100644 --- a/src/content-linter/tests/unit/table-column-integrity-simple.ts +++ b/src/content-linter/tests/unit/table-column-integrity-simple.ts @@ -22,8 +22,10 @@ describe(tableColumnIntegrity.names.join(' - '), () => { const errors = result.markdown expect(errors.length).toBe(1) expect(errors[0].lineNumber).toBe(3) - if ((errors[0] as any).detail) { - expect((errors[0] as any).detail).toContain('Table row has 3 columns but header has 2') + if ((errors[0] as unknown as { detail?: string }).detail) { + expect((errors[0] as unknown as { detail?: string }).detail).toContain( + 'Table row has 3 columns but header has 2', + ) } else if (errors[0].errorDetail) { expect(errors[0].errorDetail).toContain('Table row has 3 columns but header has 2') } else { @@ -38,8 +40,10 @@ describe(tableColumnIntegrity.names.join(' - '), () => { const errors = result.markdown expect(errors.length).toBe(1) expect(errors[0].lineNumber).toBe(3) - if ((errors[0] as any).detail) { - expect((errors[0] as any).detail).toContain('Table row has 2 columns but header has 3') + if ((errors[0] as unknown as { detail?: string }).detail) { + expect((errors[0] as unknown as { detail?: string }).detail).toContain( + 'Table row has 2 columns but header has 3', + ) } else if (errors[0].errorDetail) { expect(errors[0].errorDetail).toContain('Table row has 2 columns but header has 3') } else { diff --git a/src/content-render/tests/link-error-line-numbers.ts b/src/content-render/tests/link-error-line-numbers.ts index 5ee9a839b842..4c1d9255e421 100644 --- a/src/content-render/tests/link-error-line-numbers.ts +++ b/src/content-render/tests/link-error-line-numbers.ts @@ -1,12 +1,12 @@ import { describe, expect, test, beforeEach, afterEach } from 'vitest' import { renderContent } from '@/content-render/index' import { TitleFromAutotitleError } from '@/content-render/unified/rewrite-local-links' -import type { Context } from '@/types' +import type { Context, Page } from '@/types' describe('link error line numbers', () => { - let fs: any // Dynamic import of fs module for mocking in tests - let originalReadFileSync: any // Storing original fs.readFileSync for restoration after test - let originalExistsSync: any // Storing original fs.existsSync for restoration after test + let fs: { default: typeof import('fs') } + let originalReadFileSync: typeof import('fs').readFileSync + let originalExistsSync: typeof import('fs').existsSync let mockContext: Context beforeEach(async () => { @@ -21,11 +21,11 @@ describe('link error line numbers', () => { mockContext = { currentLanguage: 'en', currentVersion: 'free-pro-team@latest', - pages: {} as any, - redirects: {} as any, + pages: {} as unknown as Record, + redirects: {} as Record, page: { fullPath: '/fake/test-file.md', - } as any, + } as unknown as Context['page'], } }) @@ -50,7 +50,7 @@ Here is a broken link: [AUTOTITLE](/nonexistent/page). More content here.` - fs.default.readFileSync = () => template + fs.default.readFileSync = (() => template) as unknown as typeof fs.default.readFileSync try { await renderContent(template, mockContext) @@ -70,7 +70,7 @@ More content here.` test('reports correct line numbers with different frontmatter sizes', async () => { mockContext.page = { fullPath: '/fake/test-file-2.md', - } as any + } as unknown as Context['page'] // Test with more extensive frontmatter const template = `--- @@ -92,7 +92,7 @@ Some introductory text here. Content with a [AUTOTITLE](/another/nonexistent/page) link.` - fs.default.readFileSync = () => template + fs.default.readFileSync = (() => template) as unknown as typeof fs.default.readFileSync try { await renderContent(template, mockContext) @@ -107,7 +107,7 @@ Content with a [AUTOTITLE](/another/nonexistent/page) link.` test('handles files without frontmatter correctly', async () => { mockContext.page = { fullPath: '/fake/no-frontmatter.md', - } as any + } as unknown as Context['page'] // Test content without frontmatter const template = `# Simple Title @@ -116,7 +116,7 @@ This is content without frontmatter. Here is a broken link: [AUTOTITLE](/missing/page).` - fs.default.readFileSync = () => template + fs.default.readFileSync = (() => template) as unknown as typeof fs.default.readFileSync try { await renderContent(template, mockContext) @@ -131,7 +131,7 @@ Here is a broken link: [AUTOTITLE](/missing/page).` test('error message format is improved', async () => { mockContext.page = { fullPath: '/fake/message-test.md', - } as any + } as unknown as Context['page'] const template = `--- title: Message Test @@ -140,7 +140,7 @@ title: Message Test [AUTOTITLE](/test/broken/link) ` - fs.default.readFileSync = () => template + fs.default.readFileSync = (() => template) as unknown as typeof fs.default.readFileSync try { await renderContent(template, mockContext) diff --git a/src/early-access/scripts/migrate-early-access-product.ts b/src/early-access/scripts/migrate-early-access-product.ts index d4117215b190..07102aa21611 100644 --- a/src/early-access/scripts/migrate-early-access-product.ts +++ b/src/early-access/scripts/migrate-early-access-product.ts @@ -198,19 +198,18 @@ function moveVariable(dataRef: string): void { } } - const variableFileContent: Record = yaml.load( + const variableFileContent: Record = yaml.load( fs.readFileSync(oldVariableFinalPath, 'utf8'), - ) as Record - const value: any = variableFileContent[variableKey] + ) as Record + const value: unknown = variableFileContent[variableKey] // If the variable file already exists, add the key/value pair. if (fs.existsSync(nonAltPath)) { - const content: Record = yaml.load(fs.readFileSync(nonAltPath, 'utf8')) as Record< - string, - any - > + const content: Record = yaml.load( + fs.readFileSync(nonAltPath, 'utf8'), + ) as Record if (!content[variableKey]) { - const newString = `\n\n${variableKey}: ${value}` + const newString = `\n\n${variableKey}: ${String(value)}` fs.appendFileSync(nonAltPath, newString) } } else { diff --git a/src/fixtures/tests/categories-and-subcategory.ts b/src/fixtures/tests/categories-and-subcategory.ts index 01fb0a629124..fcbf6375987d 100644 --- a/src/fixtures/tests/categories-and-subcategory.ts +++ b/src/fixtures/tests/categories-and-subcategory.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest' import type { CheerioAPI } from 'cheerio' +import type { Element } from 'domhandler' import { getDOM, head } from '@/tests/helpers/e2etest' @@ -12,13 +13,13 @@ describe('subcategories', () => { const links = $('[data-testid=table-of-contents] a[href]') expect(links.length).toBeGreaterThan(0) // They all have the same prefix - const hrefs = links.map((i: number, el: any) => $(el).attr('href')).get() + const hrefs = links.map((i: number, el: Element) => $(el).attr('href')).get() expect( hrefs.every((href: string) => href.startsWith('/en/get-started/start-your-journey/')), ).toBeTruthy() // The all resolve to a 200 OK without redirects const responses = await Promise.all(hrefs.map((href: string) => head(href))) - expect(responses.every((r: any) => r.statusCode === 200)).toBeTruthy() + expect(responses.every((r: { statusCode: number }) => r.statusCode === 200)).toBeTruthy() }) test('actions/category/subcategory subcategory has its articles intro', async () => { @@ -27,7 +28,7 @@ describe('subcategories', () => { expect(lead).toMatch("Here's the intro for HubGit Actions.") const links = $('[data-testid=table-of-contents] a[href]') - const hrefs = links.map((i: number, el: any) => $(el).attr('href')).get() + const hrefs = links.map((i: number, el: Element) => $(el).attr('href')).get() expect(hrefs.every((href: string) => href.startsWith('/en/actions/category/'))).toBeTruthy() const firstArticleH2 = $('[data-testid=table-of-contents] h2').first() @@ -50,10 +51,10 @@ describe('categories', () => { const links = $('[data-testid=table-of-contents] a[href]') expect(links.length).toBeGreaterThan(0) // They all have the same prefix - const hrefs = links.map((i: number, el: any) => $(el).attr('href')).get() + const hrefs = links.map((i: number, el: Element) => $(el).attr('href')).get() expect(hrefs.every((href: string) => href.startsWith('/en/actions/category/'))).toBeTruthy() // The all resolve to a 200 OK without redirects const responses = await Promise.all(hrefs.map((href: string) => head(href))) - expect(responses.every((r: any) => r.statusCode === 200)).toBeTruthy() + expect(responses.every((r: { statusCode: number }) => r.statusCode === 200)).toBeTruthy() }) }) diff --git a/src/fixtures/tests/translations.ts b/src/fixtures/tests/translations.ts index 0682eecfcd71..a35fa3e346c5 100644 --- a/src/fixtures/tests/translations.ts +++ b/src/fixtures/tests/translations.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest' -import type { CheerioAPI } from 'cheerio' +import type { Cheerio, CheerioAPI } from 'cheerio' +import type { Element } from 'domhandler' import { TRANSLATIONS_FIXTURE_ROOT } from '@/frame/lib/constants' import { getDOM, head } from '@/tests/helpers/e2etest' @@ -19,14 +20,14 @@ describe('translations', () => { const links = $('[data-testid=product] a[href]') const hrefs = links - .filter((i: number, link: any) => { + .filter((i: number, link: Element) => { const href = $(link).attr('href') return href !== undefined && href.startsWith('/') }) - .map((i: number, link: any) => $(link)) + .map((i: number, link: Element) => $(link)) .get() const linkTexts = Object.fromEntries( - hrefs.map(($link: any) => [$link.attr('href'), $link.text()]), + hrefs.map(($link: Cheerio) => [$link.attr('href'), $link.text()]), ) expect(linkTexts['/ja/get-started']).toBe('はじめに') }) @@ -40,11 +41,11 @@ describe('translations', () => { test('internal links get prefixed with /ja', async () => { const $: CheerioAPI = await getDOM('/ja/get-started/start-your-journey/link-rewriting') const links = $('#article-contents a[href]') - const jaLinks = links.filter((i: number, element: any) => { + const jaLinks = links.filter((i: number, element: Element) => { const href = $(element).attr('href') return href !== undefined && href.startsWith('/ja') }) - const enLinks = links.filter((i: number, element: any) => { + const enLinks = links.filter((i: number, element: Element) => { const href = $(element).attr('href') return href !== undefined && href.startsWith('/en') }) @@ -55,7 +56,7 @@ describe('translations', () => { test('internal links with AUTOTITLE resolves', async () => { const $: CheerioAPI = await getDOM('/ja/get-started/foo/autotitling') const links = $('#article-contents a[href]') - links.each((i: number, element: any) => { + links.each((i: number, element: Element) => { if ($(element).attr('href')?.includes('/ja/get-started/start-your-journey/hello-world')) { expect($(element).text()).toBe('こんにちは World') } @@ -73,7 +74,7 @@ describe('translations', () => { expect(paragraph).toMatch('mention of HubGit in Liquid') const tds = $('#article-contents td') - .map((i: number, element: any) => $(element).text()) + .map((i: number, element: Element) => $(element).text()) .get() expect(tds.length).toBe(2) expect(tds[1]).toBe('Not') @@ -88,7 +89,7 @@ describe('translations', () => { expect(paragraph).toMatch('mention of HubGit Enterprise Server in Liquid') const tds = $('#article-contents td') - .map((i: number, element: any) => $(element).text()) + .map((i: number, element: Element) => $(element).text()) .get() expect(tds.length).toBe(2) expect(tds[1]).toBe('Present') @@ -98,7 +99,7 @@ describe('translations', () => { test('automatic correction of bad AUTOTITLE in reusables', async () => { const $: CheerioAPI = await getDOM('/ja/get-started/start-your-journey/hello-world') const links = $('#article-contents a[href]') - const texts = links.map((i: number, element: any) => $(element).text()).get() + const texts = links.map((i: number, element: Element) => $(element).text()).get() // That Japanese page uses AUTOTITLE links. Both in the main `.md` file // but also inside a reusable. // E.g. `["AUTOTITLE](/get-started/start-your-journey/hello-world)."` @@ -129,11 +130,11 @@ describe('translations', () => { const $: CheerioAPI = await getDOM('/ja/get-started/start-your-journey/hello-world') const links = $('#article-contents a[href]') const texts = links - .filter((i: number, element: any) => { + .filter((i: number, element: Element) => { const href = $(element).attr('href') return href !== undefined && href.includes('get-started/foo/bar') }) - .map((i: number, element: any) => $(element).text()) + .map((i: number, element: Element) => $(element).text()) .get() // Check that the text contains the essential parts rather than exact spacing const foundBarLink = texts.find( 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) From 4a7827c596a6e1fa123959baa7b76fd6d862e6d5 Mon Sep 17 00:00:00 2001 From: Evan Bonsignori Date: Mon, 20 Apr 2026 15:33:45 -0700 Subject: [PATCH 4/7] Skip build-number check on manual Fastly purge (#60864) --- .github/workflows/purge-fastly.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/purge-fastly.yml b/.github/workflows/purge-fastly.yml index 558817cb55df..9f5bd1bca7b1 100644 --- a/.github/workflows/purge-fastly.yml +++ b/.github/workflows/purge-fastly.yml @@ -39,6 +39,7 @@ jobs: - uses: ./.github/actions/node-npm-setup - name: Wait for production to match build number + if: github.event_name != 'workflow_dispatch' run: | needs=$(git rev-parse HEAD) start_time=$(date +%s) From b7bcba181ba73eb73fcc2dc33bd11a3fe4689a65 Mon Sep 17 00:00:00 2001 From: Evan Bonsignori Date: Mon, 20 Apr 2026 15:34:00 -0700 Subject: [PATCH 5/7] Fix graceful shutdown to prevent deploy timeouts (#60862) --- src/frame/start-server.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/frame/start-server.ts b/src/frame/start-server.ts index fca1713301e4..68bf88385ad2 100644 --- a/src/frame/start-server.ts +++ b/src/frame/start-server.ts @@ -65,9 +65,33 @@ async function startServer() { process.once('SIGTERM', () => { logger.info('Received SIGTERM, beginning graceful shutdown', { pid: process.pid, port }) + + // Force-close idle keep-alive sockets so server.close() doesn't hang + // waiting for them to disconnect naturally. + try { + server.closeIdleConnections() + } catch (err) { + logger.warn('closeIdleConnections failed (server may not be running)', { error: err }) + } + server.close(() => { logger.info('HTTP server closed') }) + + // If in-flight requests haven't drained within 25s, force exit. + // Kubernetes sends SIGKILL at terminationGracePeriodSeconds (60s), + // but the deploy controller may time out before that if an old pod + // stays in "Terminating" state too long. The preStop hook sleeps 5s, + // so 25s here keeps total shutdown well under the 60s grace period. + setTimeout(() => { + logger.warn('Graceful shutdown timed out, forcing exit') + try { + server.closeAllConnections() + } catch (err) { + logger.warn('closeAllConnections failed (server may not be running)', { error: err }) + } + process.exit(0) + }, 25_000).unref() }) return server From ad87086744182bb7fc7a0d7c9b80f4a95db95a0b Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 20 Apr 2026 15:34:09 -0700 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Update=20production=20?= =?UTF-8?q?resource=20requests=20and=20memory=20limit=20based=20on=20Moda?= =?UTF-8?q?=20recommendations=20(#60796)?= 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> --- config/kubernetes/production/deployments/webapp.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/kubernetes/production/deployments/webapp.yaml b/config/kubernetes/production/deployments/webapp.yaml index 85c6dbba4a7c..7b366e61bfee 100644 --- a/config/kubernetes/production/deployments/webapp.yaml +++ b/config/kubernetes/production/deployments/webapp.yaml @@ -40,18 +40,18 @@ spec: image: docs-internal resources: requests: - cpu: 1250m + cpu: 1500m # Absolute minimum to start app is 1000m # Node is single-threaded but we want more CPUs # for OS and image resizing, and other binary executions # Better to increase replicas or memory than CPU - memory: 8.0Gi + memory: 5000Mi # Absolute minimum to start app is 4500Mi # Would increase with more pages, versions, or languages supported # The additional memory helps during traffic surges limits: cpu: 8000m - memory: 16.0Gi + memory: 14.0Gi ports: - name: http containerPort: 4000 From c1af8159d965a9abd2609e8a5242716e7d3f25c7 Mon Sep 17 00:00:00 2001 From: docs-bot <77750099+docs-bot@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:34:24 -0700 Subject: [PATCH 7/7] Add trailing newlines to trigger translation re-ingestion (#60839) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- content/billing/how-tos/set-up-budgets.md | 1 + .../codespaces/troubleshooting/troubleshooting-included-usage.md | 1 + .../managing-visibility-of-your-projects.md | 1 + .../using-query-parameters-to-create-a-pull-request.md | 1 + .../acceptable-use-policies/github-acceptable-use-policies.md | 1 + data/reusables/apps/deprecating_auth_with_query_parameters.md | 1 + data/reusables/enterprise-accounts/emu-cap-validates.md | 1 + data/reusables/user-settings/user-api.md | 1 + 8 files changed, 8 insertions(+) diff --git a/content/billing/how-tos/set-up-budgets.md b/content/billing/how-tos/set-up-budgets.md index 29c4a65e7a2c..950b8589665a 100644 --- a/content/billing/how-tos/set-up-budgets.md +++ b/content/billing/how-tos/set-up-budgets.md @@ -136,3 +136,4 @@ You can edit or delete a budget at any time, but you cannot change the scope of 1. Navigate to the "Budgets and alerts" view. See [Viewing budgets](#viewing-budgets). 1. In the list of budgets, click {% octicon "kebab-horizontal" aria-label="View actions" %} next to the budget you want to edit, and click **{% octicon "pencil" aria-hidden="true" aria-label="pencil" %} Edit** or **{% octicon "trash" aria-hidden="true" aria-label="trash" %} Delete**. 1. Follow the prompts. + diff --git a/content/codespaces/troubleshooting/troubleshooting-included-usage.md b/content/codespaces/troubleshooting/troubleshooting-included-usage.md index 0aa4ac320c34..2becdafc129b 100644 --- a/content/codespaces/troubleshooting/troubleshooting-included-usage.md +++ b/content/codespaces/troubleshooting/troubleshooting-included-usage.md @@ -106,3 +106,4 @@ If the dev container for the current codespace was built from the default image, Alternatively, you can check which repositories have prebuilds by reviewing a usage report. See [Understanding your {% data variables.product.prodname_codespaces %} usage](#understanding-your-codespaces-usage) above. * Storage of containers built from the default dev container image for codespaces is free of charge and does not reduce your included storage. You can therefore avoid your storage allowance being consumed by your dev container by using the default image in your dev container configuration, rather than specifying a more specialized image. See [AUTOTITLE](/codespaces/setting-up-your-project-for-codespaces/adding-a-dev-container-configuration/introduction-to-dev-containers#using-the-default-dev-container-configuration) and [Storage usage for your base dev container](#storage-usage-for-your-base-dev-container) above. + diff --git a/content/issues/planning-and-tracking-with-projects/managing-your-project/managing-visibility-of-your-projects.md b/content/issues/planning-and-tracking-with-projects/managing-your-project/managing-visibility-of-your-projects.md index 2e3c475bfa3e..73bacc82a493 100644 --- a/content/issues/planning-and-tracking-with-projects/managing-your-project/managing-visibility-of-your-projects.md +++ b/content/issues/planning-and-tracking-with-projects/managing-your-project/managing-visibility-of-your-projects.md @@ -39,3 +39,4 @@ Project admins can also manage write and admin access to their project and contr ## Further reading * [Allowing project visibility changes in your organization](/organizations/managing-organization-settings/allowing-project-visibility-changes-in-your-organization) + diff --git a/content/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/using-query-parameters-to-create-a-pull-request.md b/content/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/using-query-parameters-to-create-a-pull-request.md index 79a98e51c40b..c1aab59703a0 100644 --- a/content/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/using-query-parameters-to-create-a-pull-request.md +++ b/content/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/using-query-parameters-to-create-a-pull-request.md @@ -27,3 +27,4 @@ Query parameter | Example `assignees` | `https://github.com/octo-org/octo-repo/compare/main...my-branch?quick_pull=1&assignees=octocat` creates a pull request and assigns it to @octocat. `projects` | `https://github.com/octo-org/octo-repo/compare/main...my-branch?quick_pull=1&title=Bug+fix&projects=octo-org/1` creates a pull request with the title "Bug fix" and adds it to the organization's project 1. `template` | `https://github.com/octo-org/octo-repo/compare/main...my-branch?quick_pull=1&template=issue_template.md` creates a pull request with a template in the pull request body. The `template` query parameter works with templates stored in a `PULL_REQUEST_TEMPLATE` subdirectory within the root, `docs/` or `.github/` directory in a repository. For more information, see [AUTOTITLE](/communities/using-templates-to-encourage-useful-issues-and-pull-requests). + diff --git a/content/site-policy/acceptable-use-policies/github-acceptable-use-policies.md b/content/site-policy/acceptable-use-policies/github-acceptable-use-policies.md index f096c344b909..5100dcce69b9 100644 --- a/content/site-policy/acceptable-use-policies/github-acceptable-use-policies.md +++ b/content/site-policy/acceptable-use-policies/github-acceptable-use-policies.md @@ -126,3 +126,4 @@ We will interpret our policies and resolve disputes in favor of protecting users **Enforcement.** GitHub retains full discretion to [take action](/site-policy/github-terms/github-community-guidelines#what-happens-if-someone-violates-githubs-policies) in response to a violation of these policies, including account suspension, account [termination](/site-policy/github-terms/github-terms-of-service#3-github-may-terminate), or [removal](/site-policy/github-terms/github-terms-of-service#2-github-may-remove-content) of content. Please also see our [Community Guidelines](/site-policy/github-terms/github-community-guidelines) for actions you can take if something or someone offends you. **Reinstatement and appeal.** If your content or account has been disabled or restricted and you seek reinstatement or wish to appeal, please see our [Appeal and Reinstatement page](/site-policy/acceptable-use-policies/github-appeal-and-reinstatement) for information about the process and use our [Appeal and Reinstatement form](https://support.github.com/contact/reinstatement) to submit a request. + diff --git a/data/reusables/apps/deprecating_auth_with_query_parameters.md b/data/reusables/apps/deprecating_auth_with_query_parameters.md index d3c93c1aaac4..0cfe35b1b941 100644 --- a/data/reusables/apps/deprecating_auth_with_query_parameters.md +++ b/data/reusables/apps/deprecating_auth_with_query_parameters.md @@ -2,3 +2,4 @@ > **{% data variables.release-phases.retired_caps %} Notice:** Authenticating to the {% data variables.product.prodname_dotcom %} API is no longer accessible using query parameters. Authenticating to the API should be done with [HTTP basic authentication](/rest/overview/authenticating-to-the-rest-api#using-basic-authentication). For more information, including scheduled brownouts, see the [blog post](https://developer.github.com/changes/2020-02-10-deprecating-auth-through-query-param/). {% ifversion ghes %}> > Authentication to the API using query parameters while available is no longer supported due to security concerns. Instead we recommend integrators move their access token, `client_id`, or `client_secret` in the header. {% data variables.product.prodname_dotcom %} will announce the removal of authentication by query parameters with advanced notice. {% endif %} + diff --git a/data/reusables/enterprise-accounts/emu-cap-validates.md b/data/reusables/enterprise-accounts/emu-cap-validates.md index 3e778ec339f7..951acb4df252 100644 --- a/data/reusables/enterprise-accounts/emu-cap-validates.md +++ b/data/reusables/enterprise-accounts/emu-cap-validates.md @@ -1 +1,2 @@ When your enterprise uses OIDC SSO, {% data variables.product.prodname_dotcom %} will automatically use your IdP's conditional access policy (CAP) IP conditions to validate interactions with {% data variables.product.prodname_dotcom %} when members use the web UI or change IP addresses, and for each authentication with a {% data variables.product.pat_generic %} or SSH key associated with a user account. + diff --git a/data/reusables/user-settings/user-api.md b/data/reusables/user-settings/user-api.md index a3826508906d..359ab2db663c 100644 --- a/data/reusables/user-settings/user-api.md +++ b/data/reusables/user-settings/user-api.md @@ -1 +1,2 @@ If a request URL does not include a `{username}` parameter then the response will be for the signed-in user (and you must pass [authentication information](/rest/overview/authenticating-to-the-rest-api) with your request). Additional private information, such as whether a user has two-factor authentication enabled, is included when authenticated through{% ifversion ghes %} Basic Authentication or{% endif %} OAuth with the `user` scope. +