diff --git a/.github/workflows/link-check-external.yml b/.github/workflows/link-check-external.yml index 9e218dbbb2f1..b5c4915a3e24 100644 --- a/.github/workflows/link-check-external.yml +++ b/.github/workflows/link-check-external.yml @@ -18,15 +18,22 @@ jobs: if: github.repository == 'github/docs-internal' runs-on: ubuntu-latest timeout-minutes: 180 # 3 hours for external checks + # Serialize publishing so two overlapping runs can't both create a + # "rolling" issue, or write their results out of order. + concurrency: + group: broken-external-links-report + cancel-in-progress: false steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: ./.github/actions/node-npm-setup - name: Install dependencies run: npm ci - name: Check external links + id: check env: ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} CACHE_MAX_AGE_DAYS: '7' @@ -57,15 +64,158 @@ jobs: echo "No broken link report generated - all links valid!" fi - - name: Create issue if broken links found - if: always() && steps.check_report.outputs.has_report == 'true' - uses: peter-evans/create-issue-from-file@65115121ba9a3573cbaded4dc66b90ba1f9b69dc + - name: Create or update the rolling report issue + if: | + always() + && steps.check.outcome == 'success' + && steps.check_report.outputs.has_report == 'true' + && !inputs.max_urls + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 with: - token: ${{ secrets.DOCS_BOT_PAT_BASE }} - repository: github/docs-content - title: '🌐 Broken External Links Report' - content-filepath: artifacts/external-link-report.md - labels: broken link report + github-token: ${{ secrets.DOCS_BOT_PAT_BASE }} + script: | + const fs = require('fs') + const title = '🌐 Broken External Links Report' + const owner = 'github' + const repo = 'docs-content' + const label = 'broken link report' + + // GitHub rejects issue bodies over 65536 characters with a 422. + // Truncate and point at the run artifact for the full contents. + const MAX_BODY_SIZE = 60000 + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + let body = fs.readFileSync('artifacts/external-link-report.md', 'utf8') + if (body.length > MAX_BODY_SIZE) { + const notice = `\n\n---\n\n*Report truncated. Download the full report from the [workflow run artifacts](${runUrl}).*` + body = body.slice(0, MAX_BODY_SIZE - notice.length) + notice + core.warning(`Report exceeded ${MAX_BODY_SIZE} characters, so it was truncated.`) + } + + // Reuse a single rolling issue instead of opening a new one every + // week, which floods the first responders' board. Find the open + // report issues (newest first). + const open = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'open', + labels: label, + per_page: 100, + }) + const reportIssues = open + .filter((issue) => !issue.pull_request && issue.title === title) + .sort((a, b) => b.number - a.number) + + if (reportIssues.length === 0) { + const created = await github.rest.issues.create({ + owner, + repo, + title, + body, + labels: [label], + }) + core.info(`Created rolling report issue: ${created.data.html_url}`) + return + } + + // Refresh the newest open report in place and close any older + // duplicates so exactly one canonical issue remains. + const [canonical, ...superseded] = reportIssues + await github.rest.issues.update({ + owner, + repo, + issue_number: canonical.number, + title, + body, + }) + core.info(`Updated rolling report issue: ${canonical.html_url}`) + + // Attempt every duplicate even if one fails, so a single transient + // API error doesn't leave the rest open. + const results = await Promise.allSettled( + superseded.map(async (issue) => { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `Superseded by the current rolling report: #${canonical.number}.`, + }) + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }) + core.info(`Closed superseded report issue #${issue.number}`) + }), + ) + const failures = results.filter((result) => result.status === 'rejected') + if (failures.length > 0) { + throw new AggregateError( + failures.map((failure) => failure.reason), + `Failed to close ${failures.length} superseded report issue(s).`, + ) + } + + - name: Close the rolling report issue when all links are valid + if: | + always() + && steps.check.outcome == 'success' + && steps.check_report.outputs.has_report == 'false' + && !inputs.max_urls + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 + with: + github-token: ${{ secrets.DOCS_BOT_PAT_BASE }} + script: | + const title = '🌐 Broken External Links Report' + const owner = 'github' + const repo = 'docs-content' + const label = 'broken link report' + + // A clean run means the open report is stale. Leaving it open would + // keep fixed failures on the first responders' board. + const open = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'open', + labels: label, + per_page: 100, + }) + const reportIssues = open.filter( + (issue) => !issue.pull_request && issue.title === title, + ) + + if (reportIssues.length === 0) { + core.info('No open report issue to close.') + return + } + + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + const results = await Promise.allSettled( + reportIssues.map(async (issue) => { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `All external links are valid as of the [latest run](${runUrl}). Closing this report. A new one opens if links break again.`, + }) + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'completed', + }) + core.info(`Closed resolved report issue #${issue.number}`) + }), + ) + const failures = results.filter((result) => result.status === 'rejected') + if (failures.length > 0) { + throw new AggregateError( + failures.map((failure) => failure.reason), + `Failed to close ${failures.length} resolved report issue(s).`, + ) + } - uses: ./.github/actions/create-workflow-failure-issue id: create-failure-issue diff --git a/.github/workflows/link-check-internal.yml b/.github/workflows/link-check-internal.yml index 9b8994564d70..05d7ec35ab67 100644 --- a/.github/workflows/link-check-internal.yml +++ b/.github/workflows/link-check-internal.yml @@ -20,10 +20,10 @@ on: required: false default: false create_report: - description: 'Create the combined broken links report issue in docs-content' + description: "Publish the combined report to the rolling docs-content issue. A manual run only covers one version/language, so it will overwrite the scheduled run's fuller report." type: boolean required: false - default: true + default: false permissions: contents: read @@ -202,6 +202,11 @@ jobs: if: always() && github.repository == 'github/docs-internal' needs: [setup-matrix, check-internal-links] runs-on: ubuntu-latest + # Serialize publishing so two overlapping runs can't both create a + # "rolling" issue, or write their results out of order. + concurrency: + group: broken-internal-links-report + cancel-in-progress: false permissions: contents: read issues: write @@ -239,15 +244,157 @@ jobs: echo "No broken link reports generated - all links valid!" fi - - name: Create issue if broken links found - if: steps.combine.outputs.has_reports == 'true' && (github.event_name != 'workflow_dispatch' || inputs.create_report != false) - uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v5 + - name: Create or update the rolling report issue + if: | + steps.combine.outputs.has_reports == 'true' + && needs.check-internal-links.result == 'success' + && (github.event_name != 'workflow_dispatch' || inputs.create_report) + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 with: - token: ${{ secrets.DOCS_BOT_PAT_BASE }} - repository: github/docs-content - title: '🔗 Broken Internal Links Report' - content-filepath: combined-report.md - labels: broken link report + github-token: ${{ secrets.DOCS_BOT_PAT_BASE }} + script: | + const fs = require('fs') + const title = '🔗 Broken Internal Links Report' + const owner = 'github' + const repo = 'docs-content' + const label = 'broken link report' + + // GitHub rejects issue bodies over 65536 characters with a 422. The + // internal report routinely exceeds that, so truncate and point at + // the run artifact for the full contents. + const MAX_BODY_SIZE = 60000 + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + let body = fs.readFileSync('combined-report.md', 'utf8') + if (body.length > MAX_BODY_SIZE) { + const notice = `\n\n---\n\n*Report truncated. Download the full report from the [workflow run artifacts](${runUrl}).*` + body = body.slice(0, MAX_BODY_SIZE - notice.length) + notice + core.warning(`Report exceeded ${MAX_BODY_SIZE} characters, so it was truncated.`) + } + + // Reuse a single rolling issue instead of opening a new one every + // week, which floods the first responders' board. Find the open + // report issues (newest first). + const open = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'open', + labels: label, + per_page: 100, + }) + const reportIssues = open + .filter((issue) => !issue.pull_request && issue.title === title) + .sort((a, b) => b.number - a.number) + + if (reportIssues.length === 0) { + const created = await github.rest.issues.create({ + owner, + repo, + title, + body, + labels: [label], + }) + core.info(`Created rolling report issue: ${created.data.html_url}`) + return + } + + // Refresh the newest open report in place and close any older + // duplicates so exactly one canonical issue remains. + const [canonical, ...superseded] = reportIssues + await github.rest.issues.update({ + owner, + repo, + issue_number: canonical.number, + title, + body, + }) + core.info(`Updated rolling report issue: ${canonical.html_url}`) + + // Attempt every duplicate even if one fails, so a single transient + // API error doesn't leave the rest open. + const results = await Promise.allSettled( + superseded.map(async (issue) => { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `Superseded by the current rolling report: #${canonical.number}.`, + }) + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }) + core.info(`Closed superseded report issue #${issue.number}`) + }), + ) + const failures = results.filter((result) => result.status === 'rejected') + if (failures.length > 0) { + throw new AggregateError( + failures.map((failure) => failure.reason), + `Failed to close ${failures.length} superseded report issue(s).`, + ) + } + + - name: Close the rolling report issue when all links are valid + if: | + steps.combine.outputs.has_reports == 'false' + && needs.check-internal-links.result == 'success' + && github.event_name != 'workflow_dispatch' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 + with: + github-token: ${{ secrets.DOCS_BOT_PAT_BASE }} + script: | + const title = '🔗 Broken Internal Links Report' + const owner = 'github' + const repo = 'docs-content' + const label = 'broken link report' + + // A clean run means the open report is stale. Leaving it open would + // keep fixed failures on the first responders' board. + const open = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'open', + labels: label, + per_page: 100, + }) + const reportIssues = open.filter( + (issue) => !issue.pull_request && issue.title === title, + ) + + if (reportIssues.length === 0) { + core.info('No open report issue to close.') + return + } + + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + const results = await Promise.allSettled( + reportIssues.map(async (issue) => { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `All internal links are valid as of the [latest run](${runUrl}). Closing this report. A new one opens if links break again.`, + }) + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'completed', + }) + core.info(`Closed resolved report issue #${issue.number}`) + }), + ) + const failures = results.filter((result) => result.status === 'rejected') + if (failures.length > 0) { + throw new AggregateError( + failures.map((failure) => failure.reason), + `Failed to close ${failures.length} resolved report issue(s).`, + ) + } - uses: ./.github/actions/create-workflow-failure-issue id: create-failure-issue diff --git a/Dockerfile b/Dockerfile index 4c33770f81a8..70c347c3aef9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ # --------------------------------------------------------------- # To update the sha: # https://github.com/github/gh-base-image/pkgs/container/gh-base-image%2Fgh-base-noble -FROM ghcr.io/github/gh-base-image/gh-base-noble:20260722-151519-g000ce495e@sha256:f722b1fb6d02a18f85d45ab2e064a0736e64992f20371a063c4076bac57832c9 AS base +FROM ghcr.io/github/gh-base-image/gh-base-noble:20260727-152635-gd2e4a1fa6@sha256:22e3a406a3f0f9bf6d3544ce4189bb947e467330bbe7210af5ecc727232a569c AS base # Install curl for Node install and determining the early access branch # Install git for cloning docs-early-access & translations repos diff --git a/content/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/create-github-private-repo.md b/content/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/create-github-private-repo.md index 2de2c67d9076..62a9ec1c38b1 100644 --- a/content/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/create-github-private-repo.md +++ b/content/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/create-github-private-repo.md @@ -38,6 +38,10 @@ You can create a `.github-private` repository using a template or from scratch. The "Configuration summary" on the settings page will display the settings taken from this repository. +## Protecting your governance repository + +To control who can propose and merge changes to the governance settings stored here, you can create a repository ruleset that targets the branches and files in your `.github-private` repository. See [AUTOTITLE](/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository). + ## Next steps * [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/prepare-for-custom-agents) * [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/configure-enterprise-managed-settings) diff --git a/content/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/prepare-for-custom-agents.md b/content/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/prepare-for-custom-agents.md index dad8d7ce7148..8bdf3137443d 100644 --- a/content/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/prepare-for-custom-agents.md +++ b/content/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/prepare-for-custom-agents.md @@ -11,22 +11,35 @@ category: - Manage Copilot for a team --- -Enterprise-level {% data variables.copilot.custom_agents_short %} are defined in a specific repository within an organization in your enterprise. +Enterprise-level {% data variables.copilot.custom_agents_short %} are defined in a `.github-private` repository within an organization in your enterprise. Preparing your enterprise involves creating that repository, configuring it as your source of governance, protecting your agent files, and deciding who can manage {% data variables.copilot.custom_agents_short %}. -Before you can create and use {% data variables.copilot.custom_agents_short %}, you need to create this repository and configure the relevant enterprise settings. +Work through the following steps to set up your enterprise. -## Creating a repository for your enterprise governance +## Set up your governance repository -1. Create a `.github-private` repository for your enterprise governance. If you don't already have a `.github-private` repo, see [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/create-github-private-repo). +1. **Create a `.github-private` repository** to house your enterprise's {% data variables.copilot.agent_profiles %}, client permissions, and plugin settings. +1. **Select the repository as your source of governance** so that your enterprise reads its settings from the repository. -1. To automatically configure a ruleset that allows only enterprise owners to edit {% data variables.copilot.agent_profiles %}, in the "Protect agent files using rulesets" section, click **Create ruleset**. +For both steps, see [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/create-github-private-repo). - > [!NOTE] - > * Members of your enterprise with write access to the {% data variables.copilot.copilot_custom_agent_short %} repository can still create pull requests proposing changes to your {% data variables.copilot.agent_profiles %}. Enterprise members with bypass access to the ruleset can then merge those pull requests as they see fit. - > * Creating this ruleset will also block organization owners in your enterprise from creating or editing organization-level {% data variables.copilot.custom_agents_short %}. To prevent this, you can edit the ruleset to target only the organization containing your enterprise-level {% data variables.copilot.custom_agents_short %}. +## Protecting your agent files using rulesets -## Next steps +To automatically configure a ruleset that allows only enterprise owners to edit {% data variables.copilot.agent_profiles %} across your enterprise: + +{% data reusables.enterprise-accounts.access-enterprise %} +{% data reusables.enterprise-accounts.ai-controls-tab %} +1. On the "Agents" tab, in the "Protect agent files using rulesets" section, click **Create ruleset**. + +> [!NOTE] +> * Members of your enterprise with write access to the {% data variables.copilot.copilot_custom_agent_short %} repository can still create pull requests proposing changes to your {% data variables.copilot.agent_profiles %}. Enterprise members with bypass access to the ruleset can then merge those pull requests as they see fit. +> * Creating this ruleset will also block organization owners in your enterprise from creating or editing organization-level {% data variables.copilot.custom_agents_short %}. To prevent this, you can edit the ruleset to target only the organization containing your enterprise-level {% data variables.copilot.custom_agents_short %}. + +## Decide who manages your custom agents To reduce your administrative burden and empower your SMEs, you can delegate the creation and management of {% data variables.copilot.custom_agents_short %} in your enterprise by creating a team of AI managers. See [AUTOTITLE](/copilot/tutorials/roll-out-at-scale/govern-at-scale/establish-ai-managers). If you prefer to maintain full control over your enterprise's tooling to ensure security and compliance, you can create and manage {% data variables.copilot.custom_agents_short %} yourself. See [AUTOTITLE](/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/test-custom-agents). + +## Next steps + +To centrally control {% data variables.product.prodname_copilot_short %} client behavior across your enterprise, configure enterprise managed settings. See [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/configure-enterprise-managed-settings). diff --git a/src/graphql/data/fpt/category-map.json b/src/graphql/data/fpt/category-map.json index a36e17de6132..507462e7e30b 100644 --- a/src/graphql/data/fpt/category-map.json +++ b/src/graphql/data/fpt/category-map.json @@ -211,10 +211,12 @@ "createprojectv2field": "projects", "createprojectv2issuefield": "projects", "createprojectv2statusupdate": "projects", + "createprojectv2view": "projects", "deleteprojectv2": "projects", "deleteprojectv2field": "projects", "deleteprojectv2item": "projects", "deleteprojectv2statusupdate": "projects", + "deleteprojectv2view": "projects", "deleteprojectv2workflow": "projects", "linkprojectv2torepository": "projects", "linkprojectv2toteam": "projects", @@ -230,6 +232,7 @@ "updateprojectv2itemfieldvalue": "projects", "updateprojectv2itemposition": "projects", "updateprojectv2statusupdate": "projects", + "updateprojectv2view": "projects", "addpullrequestcreationcapbypassusers": "pulls", "addpullrequestreview": "pulls", "addpullrequestreviewcomment": "pulls", @@ -370,7 +373,6 @@ "usernamespacerepository": "enterprise-admin", "usernamespacerepositoryconnection": "enterprise-admin", "usernamespacerepositoryedge": "enterprise-admin", - "issueeventrationale": "other", "pageinfo": "other", "license": "licenses", "licenserule": "licenses", @@ -651,6 +653,7 @@ "issuecontributionsbyrepository": "issues", "issuedependenciessummary": "issues", "issueedge": "issues", + "issueeventrationale": "issues", "issuefieldaddedevent": "issues", "issuefieldchangedevent": "issues", "issuefielddate": "issues", @@ -1669,10 +1672,12 @@ "createprojectv2input": "projects", "createprojectv2issuefieldinput": "projects", "createprojectv2statusupdateinput": "projects", + "createprojectv2viewinput": "projects", "deleteprojectv2fieldinput": "projects", "deleteprojectv2input": "projects", "deleteprojectv2iteminput": "projects", "deleteprojectv2statusupdateinput": "projects", + "deleteprojectv2viewinput": "projects", "deleteprojectv2workflowinput": "projects", "linkprojectv2torepositoryinput": "projects", "linkprojectv2toteaminput": "projects", @@ -1702,6 +1707,7 @@ "updateprojectv2itemfieldvalueinput": "projects", "updateprojectv2itempositioninput": "projects", "updateprojectv2statusupdateinput": "projects", + "updateprojectv2viewinput": "projects", "addpullrequestcreationcapbypassusersinput": "pulls", "addpullrequestreviewcommentinput": "pulls", "addpullrequestreviewinput": "pulls", diff --git a/src/graphql/data/fpt/changelog.json b/src/graphql/data/fpt/changelog.json index 2a4107aab51d..40735454d1d1 100644 --- a/src/graphql/data/fpt/changelog.json +++ b/src/graphql/data/fpt/changelog.json @@ -1,4 +1,47 @@ [ + { + "schemaChanges": [ + { + "title": "The GraphQL schema includes these changes:", + "changes": [ + "

Type 'CreateProjectV2ViewInput' was added

", + "

Input field clientMutationId of type String was added to input object type 'CreateProjectV2ViewInput'

", + "

Input field layout of type 'ProjectV2ViewLayout!was added to input object typeCreateProjectV2ViewInput'

", + "

Input field name of type String! was added to input object type 'CreateProjectV2ViewInput'

", + "

Input field projectId of type ID! was added to input object type 'CreateProjectV2ViewInput'

", + "

Type 'CreateProjectV2ViewPayload' was added

", + "

Field clientMutationId was added to object type 'CreateProjectV2ViewPayload'

", + "

Field 'projectV2Viewwas added to object typeCreateProjectV2ViewPayload'

", + "

Type 'DeleteProjectV2ViewInput' was added

", + "

Input field clientMutationId of type String was added to input object type 'DeleteProjectV2ViewInput'

", + "

Input field viewId of type ID! was added to input object type 'DeleteProjectV2ViewInput'

", + "

Type 'DeleteProjectV2ViewPayload' was added

", + "

Field clientMutationId was added to object type 'DeleteProjectV2ViewPayload'

", + "

Field 'projectV2Viewwas added to object typeDeleteProjectV2ViewPayload'

", + "

Type 'UpdateProjectV2ViewInput' was added

", + "

Input field clientMutationId of type String was added to input object type 'UpdateProjectV2ViewInput'

", + "

Input field filter of type String was added to input object type 'UpdateProjectV2ViewInput'

", + "

Input field layout of type 'ProjectV2ViewLayoutwas added to input object typeUpdateProjectV2ViewInput'

", + "

Input field name of type String was added to input object type 'UpdateProjectV2ViewInput'

", + "

Input field viewId of type ID! was added to input object type 'UpdateProjectV2ViewInput'

", + "

Type 'UpdateProjectV2ViewPayload' was added

", + "

Field clientMutationId was added to object type 'UpdateProjectV2ViewPayload'

", + "

Field 'projectV2Viewwas added to object typeUpdateProjectV2ViewPayload'

", + "

Field exp was added to object type CopilotEndpoints

", + "

Field 'createProjectV2Viewwas added to object typeMutation'

", + "

Argument 'input: CreateProjectV2ViewInput!added to fieldMutation.createProjectV2View'

", + "

Field 'deleteProjectV2Viewwas added to object typeMutation'

", + "

Argument 'input: DeleteProjectV2ViewInput!added to fieldMutation.deleteProjectV2View'

", + "

Field 'updateProjectV2Viewwas added to object typeMutation'

", + "

Argument 'input: UpdateProjectV2ViewInput!added to fieldMutation.updateProjectV2View'

", + "

Enum value 'SECURITY_KEYwas added to enumProofOfPresenceRequirement'

" + ] + } + ], + "previewChanges": [], + "upcomingChanges": [], + "date": "2026-07-28" + }, { "schemaChanges": [ { diff --git a/src/graphql/data/fpt/schema-copilot.json b/src/graphql/data/fpt/schema-copilot.json index d8f086a9ac75..a7ca98c81be6 100644 --- a/src/graphql/data/fpt/schema-copilot.json +++ b/src/graphql/data/fpt/schema-copilot.json @@ -14,6 +14,13 @@ "id": "string", "href": "/graphql/reference/other#scalar-string" }, + { + "name": "exp", + "description": "

Copilot experimentation (edge TAS) endpoint.

", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, { "name": "originTracker", "description": "

Copilot origin tracker endpoint.

", diff --git a/src/graphql/data/fpt/schema-enterprise-admin.json b/src/graphql/data/fpt/schema-enterprise-admin.json index 39b46a83c620..301116f496a0 100644 --- a/src/graphql/data/fpt/schema-enterprise-admin.json +++ b/src/graphql/data/fpt/schema-enterprise-admin.json @@ -60,7 +60,7 @@ "type": "EnterpriseAdministratorInvitation", "id": "enterpriseadministratorinvitation", "href": "/graphql/reference/enterprise-admin#object-enterpriseadministratorinvitation", - "description": "

Look up a pending enterprise administrator invitation by invitation token.

", + "description": "

Look up a pending enterprise administrator invitation by invitation token. If\nthe invitation was sent to an email address, the viewer must have verified\nthat email address, or otherwise be an administrator of the enterprise.

", "args": [ { "name": "invitationToken", @@ -101,7 +101,7 @@ "type": "EnterpriseMemberInvitation", "id": "enterprisememberinvitation", "href": "/graphql/reference/enterprise-admin#object-enterprisememberinvitation", - "description": "

Look up a pending enterprise unaffiliated member invitation by invitation token.

", + "description": "

Look up a pending enterprise unaffiliated member invitation by invitation\ntoken. If the invitation was sent to an email address, the viewer must have\nverified that email address, or otherwise be an administrator of the enterprise.

", "args": [ { "name": "invitationToken", @@ -11744,6 +11744,10 @@ { "name": "REAUTH", "description": "

Members must complete a fresh re-authentication against the enterprise identity provider.

" + }, + { + "name": "SECURITY_KEY", + "description": "

Members must satisfy a phishing-resistant security key re-authentication (Microsoft Entra only).

" } ], "category": "enterprise-admin" diff --git a/src/graphql/data/fpt/schema-issues.json b/src/graphql/data/fpt/schema-issues.json index e8b922c029b0..f48da692fa8f 100644 --- a/src/graphql/data/fpt/schema-issues.json +++ b/src/graphql/data/fpt/schema-issues.json @@ -2944,10 +2944,12 @@ }, { "name": "eventRationales", - "description": "

A list of rationales associated with this issue's timeline events.

", + "description": "

A list of rationales associated with this issue's timeline events. Always\nreturns an empty list; use the intent field on individual timeline events instead.

", "type": "[IssueEventRationale!]!", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use the intent field on individual timeline events instead. This field is being removed and now always returns an empty list.

" }, { "name": "fullDatabaseId", @@ -4840,6 +4842,44 @@ ], "category": "issues" }, + { + "name": "IssueEventRationale", + "id": "issueeventrationale", + "href": "/graphql/reference/issues#object-issueeventrationale", + "description": "

Rationale text associated with an issue timeline event. Deprecated: the fields\nthat return this type are being removed and now return null/empty. Use the\nintent field on individual timeline events instead.

", + "isDeprecated": false, + "fields": [ + { + "name": "actor", + "description": "

The agent or user who produced the rationale.

", + "type": "Actor", + "id": "actor", + "href": "/graphql/reference/users#interface-actor" + }, + { + "name": "createdAt", + "description": "

Identifies the date and time when the rationale was created.

", + "type": "DateTime!", + "id": "datetime", + "href": "/graphql/reference/other#scalar-datetime" + }, + { + "name": "issueEvent", + "description": "

The issue timeline event this rationale is associated with.

", + "type": "IssueEventWithRationale", + "id": "issueeventwithrationale", + "href": "/graphql/reference/issues#union-issueeventwithrationale" + }, + { + "name": "rationale", + "description": "

The reasoning or explanation text for the event.

", + "type": "String!", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + } + ], + "category": "issues" + }, { "name": "IssueFieldAddedEvent", "id": "issuefieldaddedevent", @@ -4905,10 +4945,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" }, { "name": "value", @@ -5013,10 +5055,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -5435,10 +5479,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -6417,10 +6463,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -6483,10 +6531,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -6602,10 +6652,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -7024,10 +7076,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -8809,10 +8863,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -10464,7 +10520,7 @@ "name": "IssueEventWithRationale", "id": "issueeventwithrationale", "href": "/graphql/reference/issues#union-issueeventwithrationale", - "description": "

An issue timeline event that may have an associated rationale.

", + "description": "

An issue timeline event that may have an associated rationale. Deprecated: this\nunion is only reachable via the deprecated IssueEventRationale type, which is\nbeing removed. Use the intent field on individual timeline events instead.

", "isDeprecated": false, "possibleTypes": [ { diff --git a/src/graphql/data/fpt/schema-other.json b/src/graphql/data/fpt/schema-other.json index ad001ba78630..e0420f2f3c8c 100644 --- a/src/graphql/data/fpt/schema-other.json +++ b/src/graphql/data/fpt/schema-other.json @@ -20,43 +20,6 @@ } ], "objects": [ - { - "name": "IssueEventRationale", - "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale", - "description": "

Rationale text associated with an issue timeline event.

", - "fields": [ - { - "name": "actor", - "description": "

The agent or user who produced the rationale.

", - "type": "Actor", - "id": "actor", - "href": "/graphql/reference/users#interface-actor" - }, - { - "name": "createdAt", - "description": "

Identifies the date and time when the rationale was created.

", - "type": "DateTime!", - "id": "datetime", - "href": "/graphql/reference/other#scalar-datetime" - }, - { - "name": "issueEvent", - "description": "

The issue timeline event this rationale is associated with.

", - "type": "IssueEventWithRationale", - "id": "issueeventwithrationale", - "href": "/graphql/reference/issues#union-issueeventwithrationale" - }, - { - "name": "rationale", - "description": "

The reasoning or explanation text for the event.

", - "type": "String!", - "id": "string", - "href": "/graphql/reference/other#scalar-string" - } - ], - "category": "other" - }, { "name": "PageInfo", "id": "pageinfo", diff --git a/src/graphql/data/fpt/schema-projects.json b/src/graphql/data/fpt/schema-projects.json index 9c9faf05dc6b..88e3b3e1d02c 100644 --- a/src/graphql/data/fpt/schema-projects.json +++ b/src/graphql/data/fpt/schema-projects.json @@ -100,7 +100,7 @@ "name": "clearProjectV2ItemFieldValue", "id": "clearprojectv2itemfieldvalue", "href": "/graphql/reference/projects#mutation-clearprojectv2itemfieldvalue", - "description": "

This mutation clears the value of a field for an item in a Project. Currently\nonly text, number, date, assignees, labels, single-select, iteration and\nmilestone fields are supported.

", + "description": "

This mutation clears the value of a field for an item in a Project. Currently\nonly text, number, date, assignees, labels, single-select, multi-select,\niteration and milestone fields are supported.

", "isDeprecated": false, "inputFields": [ { @@ -320,6 +320,38 @@ ], "category": "projects" }, + { + "name": "createProjectV2View", + "id": "createprojectv2view", + "href": "/graphql/reference/projects#mutation-createprojectv2view", + "description": "

Creates a new view in a project.

", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "CreateProjectV2ViewInput!", + "id": "createprojectv2viewinput", + "href": "/graphql/reference/projects#input-object-createprojectv2viewinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "

A unique identifier for the client performing the mutation.

" + }, + { + "name": "projectV2View", + "type": "ProjectV2View", + "id": "projectv2view", + "href": "/graphql/reference/projects#object-projectv2view", + "description": "

The new view.

" + } + ], + "category": "projects" + }, { "name": "deleteProjectV2", "id": "deleteprojectv2", @@ -455,6 +487,38 @@ ], "category": "projects" }, + { + "name": "deleteProjectV2View", + "id": "deleteprojectv2view", + "href": "/graphql/reference/projects#mutation-deleteprojectv2view", + "description": "

Deletes a view from a project.

", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "DeleteProjectV2ViewInput!", + "id": "deleteprojectv2viewinput", + "href": "/graphql/reference/projects#input-object-deleteprojectv2viewinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "

A unique identifier for the client performing the mutation.

" + }, + { + "name": "projectV2View", + "type": "ProjectV2View", + "id": "projectv2view", + "href": "/graphql/reference/projects#object-projectv2view", + "description": "

The deleted view.

" + } + ], + "category": "projects" + }, { "name": "deleteProjectV2Workflow", "id": "deleteprojectv2workflow", @@ -941,6 +1005,38 @@ } ], "category": "projects" + }, + { + "name": "updateProjectV2View", + "id": "updateprojectv2view", + "href": "/graphql/reference/projects#mutation-updateprojectv2view", + "description": "

Updates an existing view in a project.

", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "UpdateProjectV2ViewInput!", + "id": "updateprojectv2viewinput", + "href": "/graphql/reference/projects#input-object-updateprojectv2viewinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "

A unique identifier for the client performing the mutation.

" + }, + { + "name": "projectV2View", + "type": "ProjectV2View", + "id": "projectv2view", + "href": "/graphql/reference/projects#object-projectv2view", + "description": "

The updated view.

" + } + ], + "category": "projects" } ], "objects": [ @@ -6309,6 +6405,44 @@ ], "category": "projects" }, + { + "name": "CreateProjectV2ViewInput", + "id": "createprojectv2viewinput", + "href": "/graphql/reference/projects#input-object-createprojectv2viewinput", + "description": "

Autogenerated input type of CreateProjectV2View.

", + "inputFields": [ + { + "name": "clientMutationId", + "description": "

A unique identifier for the client performing the mutation.

", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "layout", + "description": "

The layout of the view.

", + "type": "ProjectV2ViewLayout!", + "id": "projectv2viewlayout", + "href": "/graphql/reference/projects#enum-projectv2viewlayout" + }, + { + "name": "name", + "description": "

The name of the view.

", + "type": "String!", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "projectId", + "description": "

The ID of the project to create a view in.

", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "projects" + }, { "name": "DeleteProjectV2FieldInput", "id": "deleteprojectv2fieldinput", @@ -6413,6 +6547,30 @@ ], "category": "projects" }, + { + "name": "DeleteProjectV2ViewInput", + "id": "deleteprojectv2viewinput", + "href": "/graphql/reference/projects#input-object-deleteprojectv2viewinput", + "description": "

Autogenerated input type of DeleteProjectV2View.

", + "inputFields": [ + { + "name": "clientMutationId", + "description": "

A unique identifier for the client performing the mutation.

", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "viewId", + "description": "

The ID of the view to delete.

", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "projects" + }, { "name": "DeleteProjectV2WorkflowInput", "id": "deleteprojectv2workflowinput", @@ -7379,6 +7537,51 @@ } ], "category": "projects" + }, + { + "name": "UpdateProjectV2ViewInput", + "id": "updateprojectv2viewinput", + "href": "/graphql/reference/projects#input-object-updateprojectv2viewinput", + "description": "

Autogenerated input type of UpdateProjectV2View.

", + "inputFields": [ + { + "name": "clientMutationId", + "description": "

A unique identifier for the client performing the mutation.

", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "filter", + "description": "

The new filter for the view.

", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "layout", + "description": "

The new layout for the view.

", + "type": "ProjectV2ViewLayout", + "id": "projectv2viewlayout", + "href": "/graphql/reference/projects#enum-projectv2viewlayout" + }, + { + "name": "name", + "description": "

The new name for the view.

", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "viewId", + "description": "

The ID of the view to update.

", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "projects" } ] } \ No newline at end of file diff --git a/src/graphql/data/fpt/schema.docs.graphql b/src/graphql/data/fpt/schema.docs.graphql index 837261b5d359..ee5b13ba0a80 100644 --- a/src/graphql/data/fpt/schema.docs.graphql +++ b/src/graphql/data/fpt/schema.docs.graphql @@ -7650,6 +7650,11 @@ type CopilotEndpoints @docsCategory(name: "copilot") { """ api: String! + """ + Copilot experimentation (edge TAS) endpoint + """ + exp: String + """ Copilot origin tracker endpoint """ @@ -9044,6 +9049,46 @@ type CreateProjectV2StatusUpdatePayload { statusUpdate: ProjectV2StatusUpdate } +""" +Autogenerated input type of CreateProjectV2View +""" +input CreateProjectV2ViewInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The layout of the view. + """ + layout: ProjectV2ViewLayout! + + """ + The name of the view. + """ + name: String! + + """ + The ID of the project to create a view in. + """ + projectId: ID! @possibleTypes(concreteTypes: ["ProjectV2"]) +} + +""" +Autogenerated return type of CreateProjectV2View. +""" +type CreateProjectV2ViewPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The new view. + """ + projectV2View: ProjectV2View +} + """ Autogenerated input type of CreatePullRequest """ @@ -10991,6 +11036,36 @@ type DeleteProjectV2StatusUpdatePayload { projectV2: ProjectV2 } +""" +Autogenerated input type of DeleteProjectV2View +""" +input DeleteProjectV2ViewInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The ID of the view to delete. + """ + viewId: ID! @possibleTypes(concreteTypes: ["ProjectV2View"]) +} + +""" +Autogenerated return type of DeleteProjectV2View. +""" +type DeleteProjectV2ViewPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The deleted view. + """ + projectV2View: ProjectV2View +} + """ Autogenerated input type of DeleteProjectV2Workflow """ @@ -20078,9 +20153,13 @@ type Issue implements Assignable & Closable & Comment & Deletable & Labelable & editor: Actor """ - A list of rationales associated with this issue's timeline events. + A list of rationales associated with this issue's timeline events. Always + returns an empty list; use the `intent` field on individual timeline events instead. """ eventRationales: [IssueEventRationale!]! + @deprecated( + reason: "Use the `intent` field on individual timeline events instead. This field is being removed and now always returns an empty list." + ) """ Identifies the primary key from the database as a BigInt. @@ -21329,9 +21408,11 @@ enum IssueEventConfidenceLevel @docsCategory(name: "issues") { } """ -Rationale text associated with an issue timeline event. +Rationale text associated with an issue timeline event. Deprecated: the fields +that return this type are being removed and now return null/empty. Use the +`intent` field on individual timeline events instead. """ -type IssueEventRationale { +type IssueEventRationale @docsCategory(name: "issues") { """ The agent or user who produced the rationale. """ @@ -21354,7 +21435,9 @@ type IssueEventRationale { } """ -An issue timeline event that may have an associated rationale. +An issue timeline event that may have an associated rationale. Deprecated: this +union is only reachable via the deprecated `IssueEventRationale` type, which is +being removed. Use the `intent` field on individual timeline events instead. """ union IssueEventWithRationale @docsCategory(name: "issues") = | ClosedEvent @@ -21407,9 +21490,10 @@ type IssueFieldAddedEvent implements Node @docsCategory(name: "issues") { options: [IssueFieldTimelineOption!] """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") """ The value of the added field. @@ -21477,9 +21561,10 @@ type IssueFieldChangedEvent implements Node @docsCategory(name: "issues") { previousValue: String """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -21857,9 +21942,10 @@ type IssueFieldRemovedEvent implements Node @docsCategory(name: "issues") { options: [IssueFieldTimelineOption!] """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -23106,9 +23192,10 @@ type IssueTypeAddedEvent implements Node @docsCategory(name: "issues") { issueType: IssueType """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -23146,9 +23233,10 @@ type IssueTypeChangedEvent implements Node @docsCategory(name: "issues") { prevIssueType: IssueType """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -23296,9 +23384,10 @@ type IssueTypeRemovedEvent implements Node @docsCategory(name: "issues") { issueType: IssueType """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -23708,9 +23797,10 @@ type LabeledEvent implements Node @docsCategory(name: "issues") { labelable: Labelable! """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -27248,8 +27338,8 @@ type Mutation @docsCategory(name: "meta") { """ This mutation clears the value of a field for an item in a Project. Currently - only text, number, date, assignees, labels, single-select, iteration and - milestone fields are supported. + only text, number, date, assignees, labels, single-select, multi-select, + iteration and milestone fields are supported. """ clearProjectV2ItemFieldValue( """ @@ -27636,6 +27726,16 @@ type Mutation @docsCategory(name: "meta") { input: CreateProjectV2StatusUpdateInput! ): CreateProjectV2StatusUpdatePayload @docsCategory(name: "projects") + """ + Creates a new view in a project. + """ + createProjectV2View( + """ + Parameters for CreateProjectV2View + """ + input: CreateProjectV2ViewInput! + ): CreateProjectV2ViewPayload @docsCategory(name: "projects") + """ Create a new pull request """ @@ -27969,6 +28069,16 @@ type Mutation @docsCategory(name: "meta") { input: DeleteProjectV2StatusUpdateInput! ): DeleteProjectV2StatusUpdatePayload @docsCategory(name: "projects") + """ + Deletes a view from a project. + """ + deleteProjectV2View( + """ + Parameters for DeleteProjectV2View + """ + input: DeleteProjectV2ViewInput! + ): DeleteProjectV2ViewPayload @docsCategory(name: "projects") + """ Deletes a project workflow. """ @@ -29499,6 +29609,16 @@ type Mutation @docsCategory(name: "meta") { input: UpdateProjectV2StatusUpdateInput! ): UpdateProjectV2StatusUpdatePayload @docsCategory(name: "projects") + """ + Updates an existing view in a project. + """ + updateProjectV2View( + """ + Parameters for UpdateProjectV2View + """ + input: UpdateProjectV2ViewInput! + ): UpdateProjectV2ViewPayload @docsCategory(name: "projects") + """ Update a pull request """ @@ -43544,6 +43664,11 @@ enum ProofOfPresenceRequirement @docsCategory(name: "enterprise-admin") { Members must complete a fresh re-authentication against the enterprise identity provider. """ REAUTH + + """ + Members must satisfy a phishing-resistant security key re-authentication (Microsoft Entra only). + """ + SECURITY_KEY } """ @@ -47406,7 +47531,9 @@ type Query implements Node @docsCategory(name: "meta") { ): EnterpriseAdministratorInvitation """ - Look up a pending enterprise administrator invitation by invitation token. + Look up a pending enterprise administrator invitation by invitation token. If + the invitation was sent to an email address, the viewer must have verified + that email address, or otherwise be an administrator of the enterprise. """ enterpriseAdministratorInvitationByToken( """ @@ -47431,7 +47558,9 @@ type Query implements Node @docsCategory(name: "meta") { ): EnterpriseMemberInvitation """ - Look up a pending enterprise unaffiliated member invitation by invitation token. + Look up a pending enterprise unaffiliated member invitation by invitation + token. If the invitation was sent to an email address, the viewer must have + verified that email address, or otherwise be an administrator of the enterprise. """ enterpriseMemberInvitationByToken( """ @@ -67306,9 +67435,10 @@ type UnlabeledEvent implements Node @docsCategory(name: "issues") { labelable: Labelable! """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -70262,6 +70392,51 @@ type UpdateProjectV2StatusUpdatePayload { statusUpdate: ProjectV2StatusUpdate } +""" +Autogenerated input type of UpdateProjectV2View +""" +input UpdateProjectV2ViewInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The new filter for the view. + """ + filter: String + + """ + The new layout for the view. + """ + layout: ProjectV2ViewLayout + + """ + The new name for the view. + """ + name: String + + """ + The ID of the view to update. + """ + viewId: ID! @possibleTypes(concreteTypes: ["ProjectV2View"]) +} + +""" +Autogenerated return type of UpdateProjectV2View. +""" +type UpdateProjectV2ViewPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The updated view. + """ + projectV2View: ProjectV2View +} + """ Autogenerated input type of UpdatePullRequestBranch """ diff --git a/src/graphql/data/ghec/category-map.json b/src/graphql/data/ghec/category-map.json index a36e17de6132..507462e7e30b 100644 --- a/src/graphql/data/ghec/category-map.json +++ b/src/graphql/data/ghec/category-map.json @@ -211,10 +211,12 @@ "createprojectv2field": "projects", "createprojectv2issuefield": "projects", "createprojectv2statusupdate": "projects", + "createprojectv2view": "projects", "deleteprojectv2": "projects", "deleteprojectv2field": "projects", "deleteprojectv2item": "projects", "deleteprojectv2statusupdate": "projects", + "deleteprojectv2view": "projects", "deleteprojectv2workflow": "projects", "linkprojectv2torepository": "projects", "linkprojectv2toteam": "projects", @@ -230,6 +232,7 @@ "updateprojectv2itemfieldvalue": "projects", "updateprojectv2itemposition": "projects", "updateprojectv2statusupdate": "projects", + "updateprojectv2view": "projects", "addpullrequestcreationcapbypassusers": "pulls", "addpullrequestreview": "pulls", "addpullrequestreviewcomment": "pulls", @@ -370,7 +373,6 @@ "usernamespacerepository": "enterprise-admin", "usernamespacerepositoryconnection": "enterprise-admin", "usernamespacerepositoryedge": "enterprise-admin", - "issueeventrationale": "other", "pageinfo": "other", "license": "licenses", "licenserule": "licenses", @@ -651,6 +653,7 @@ "issuecontributionsbyrepository": "issues", "issuedependenciessummary": "issues", "issueedge": "issues", + "issueeventrationale": "issues", "issuefieldaddedevent": "issues", "issuefieldchangedevent": "issues", "issuefielddate": "issues", @@ -1669,10 +1672,12 @@ "createprojectv2input": "projects", "createprojectv2issuefieldinput": "projects", "createprojectv2statusupdateinput": "projects", + "createprojectv2viewinput": "projects", "deleteprojectv2fieldinput": "projects", "deleteprojectv2input": "projects", "deleteprojectv2iteminput": "projects", "deleteprojectv2statusupdateinput": "projects", + "deleteprojectv2viewinput": "projects", "deleteprojectv2workflowinput": "projects", "linkprojectv2torepositoryinput": "projects", "linkprojectv2toteaminput": "projects", @@ -1702,6 +1707,7 @@ "updateprojectv2itemfieldvalueinput": "projects", "updateprojectv2itempositioninput": "projects", "updateprojectv2statusupdateinput": "projects", + "updateprojectv2viewinput": "projects", "addpullrequestcreationcapbypassusersinput": "pulls", "addpullrequestreviewcommentinput": "pulls", "addpullrequestreviewinput": "pulls", diff --git a/src/graphql/data/ghec/schema-copilot.json b/src/graphql/data/ghec/schema-copilot.json index d8f086a9ac75..a7ca98c81be6 100644 --- a/src/graphql/data/ghec/schema-copilot.json +++ b/src/graphql/data/ghec/schema-copilot.json @@ -14,6 +14,13 @@ "id": "string", "href": "/graphql/reference/other#scalar-string" }, + { + "name": "exp", + "description": "

Copilot experimentation (edge TAS) endpoint.

", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, { "name": "originTracker", "description": "

Copilot origin tracker endpoint.

", diff --git a/src/graphql/data/ghec/schema-enterprise-admin.json b/src/graphql/data/ghec/schema-enterprise-admin.json index 39b46a83c620..301116f496a0 100644 --- a/src/graphql/data/ghec/schema-enterprise-admin.json +++ b/src/graphql/data/ghec/schema-enterprise-admin.json @@ -60,7 +60,7 @@ "type": "EnterpriseAdministratorInvitation", "id": "enterpriseadministratorinvitation", "href": "/graphql/reference/enterprise-admin#object-enterpriseadministratorinvitation", - "description": "

Look up a pending enterprise administrator invitation by invitation token.

", + "description": "

Look up a pending enterprise administrator invitation by invitation token. If\nthe invitation was sent to an email address, the viewer must have verified\nthat email address, or otherwise be an administrator of the enterprise.

", "args": [ { "name": "invitationToken", @@ -101,7 +101,7 @@ "type": "EnterpriseMemberInvitation", "id": "enterprisememberinvitation", "href": "/graphql/reference/enterprise-admin#object-enterprisememberinvitation", - "description": "

Look up a pending enterprise unaffiliated member invitation by invitation token.

", + "description": "

Look up a pending enterprise unaffiliated member invitation by invitation\ntoken. If the invitation was sent to an email address, the viewer must have\nverified that email address, or otherwise be an administrator of the enterprise.

", "args": [ { "name": "invitationToken", @@ -11744,6 +11744,10 @@ { "name": "REAUTH", "description": "

Members must complete a fresh re-authentication against the enterprise identity provider.

" + }, + { + "name": "SECURITY_KEY", + "description": "

Members must satisfy a phishing-resistant security key re-authentication (Microsoft Entra only).

" } ], "category": "enterprise-admin" diff --git a/src/graphql/data/ghec/schema-issues.json b/src/graphql/data/ghec/schema-issues.json index e8b922c029b0..f48da692fa8f 100644 --- a/src/graphql/data/ghec/schema-issues.json +++ b/src/graphql/data/ghec/schema-issues.json @@ -2944,10 +2944,12 @@ }, { "name": "eventRationales", - "description": "

A list of rationales associated with this issue's timeline events.

", + "description": "

A list of rationales associated with this issue's timeline events. Always\nreturns an empty list; use the intent field on individual timeline events instead.

", "type": "[IssueEventRationale!]!", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use the intent field on individual timeline events instead. This field is being removed and now always returns an empty list.

" }, { "name": "fullDatabaseId", @@ -4840,6 +4842,44 @@ ], "category": "issues" }, + { + "name": "IssueEventRationale", + "id": "issueeventrationale", + "href": "/graphql/reference/issues#object-issueeventrationale", + "description": "

Rationale text associated with an issue timeline event. Deprecated: the fields\nthat return this type are being removed and now return null/empty. Use the\nintent field on individual timeline events instead.

", + "isDeprecated": false, + "fields": [ + { + "name": "actor", + "description": "

The agent or user who produced the rationale.

", + "type": "Actor", + "id": "actor", + "href": "/graphql/reference/users#interface-actor" + }, + { + "name": "createdAt", + "description": "

Identifies the date and time when the rationale was created.

", + "type": "DateTime!", + "id": "datetime", + "href": "/graphql/reference/other#scalar-datetime" + }, + { + "name": "issueEvent", + "description": "

The issue timeline event this rationale is associated with.

", + "type": "IssueEventWithRationale", + "id": "issueeventwithrationale", + "href": "/graphql/reference/issues#union-issueeventwithrationale" + }, + { + "name": "rationale", + "description": "

The reasoning or explanation text for the event.

", + "type": "String!", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + } + ], + "category": "issues" + }, { "name": "IssueFieldAddedEvent", "id": "issuefieldaddedevent", @@ -4905,10 +4945,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" }, { "name": "value", @@ -5013,10 +5055,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -5435,10 +5479,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -6417,10 +6463,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -6483,10 +6531,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -6602,10 +6652,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -7024,10 +7076,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -8809,10 +8863,12 @@ }, { "name": "rationale", - "description": "

The rationale associated with this event.

", + "description": "

The rationale associated with this event. Always returns null; use intent instead.

", "type": "IssueEventRationale", "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale" + "href": "/graphql/reference/issues#object-issueeventrationale", + "isDeprecated": true, + "deprecationReason": "

Use intent instead. This field is being removed and now always returns null.

" } ], "category": "issues" @@ -10464,7 +10520,7 @@ "name": "IssueEventWithRationale", "id": "issueeventwithrationale", "href": "/graphql/reference/issues#union-issueeventwithrationale", - "description": "

An issue timeline event that may have an associated rationale.

", + "description": "

An issue timeline event that may have an associated rationale. Deprecated: this\nunion is only reachable via the deprecated IssueEventRationale type, which is\nbeing removed. Use the intent field on individual timeline events instead.

", "isDeprecated": false, "possibleTypes": [ { diff --git a/src/graphql/data/ghec/schema-other.json b/src/graphql/data/ghec/schema-other.json index ee4ed38c6d29..e54123f35069 100644 --- a/src/graphql/data/ghec/schema-other.json +++ b/src/graphql/data/ghec/schema-other.json @@ -20,43 +20,6 @@ } ], "objects": [ - { - "name": "IssueEventRationale", - "id": "issueeventrationale", - "href": "/graphql/reference/other#object-issueeventrationale", - "description": "

Rationale text associated with an issue timeline event.

", - "fields": [ - { - "name": "actor", - "description": "

The agent or user who produced the rationale.

", - "type": "Actor", - "id": "actor", - "href": "/graphql/reference/users#interface-actor" - }, - { - "name": "createdAt", - "description": "

Identifies the date and time when the rationale was created.

", - "type": "DateTime!", - "id": "datetime", - "href": "/graphql/reference/other#scalar-datetime" - }, - { - "name": "issueEvent", - "description": "

The issue timeline event this rationale is associated with.

", - "type": "IssueEventWithRationale", - "id": "issueeventwithrationale", - "href": "/graphql/reference/issues#union-issueeventwithrationale" - }, - { - "name": "rationale", - "description": "

The reasoning or explanation text for the event.

", - "type": "String!", - "id": "string", - "href": "/graphql/reference/other#scalar-string" - } - ], - "category": "other" - }, { "name": "PageInfo", "id": "pageinfo", diff --git a/src/graphql/data/ghec/schema-projects.json b/src/graphql/data/ghec/schema-projects.json index 9c9faf05dc6b..88e3b3e1d02c 100644 --- a/src/graphql/data/ghec/schema-projects.json +++ b/src/graphql/data/ghec/schema-projects.json @@ -100,7 +100,7 @@ "name": "clearProjectV2ItemFieldValue", "id": "clearprojectv2itemfieldvalue", "href": "/graphql/reference/projects#mutation-clearprojectv2itemfieldvalue", - "description": "

This mutation clears the value of a field for an item in a Project. Currently\nonly text, number, date, assignees, labels, single-select, iteration and\nmilestone fields are supported.

", + "description": "

This mutation clears the value of a field for an item in a Project. Currently\nonly text, number, date, assignees, labels, single-select, multi-select,\niteration and milestone fields are supported.

", "isDeprecated": false, "inputFields": [ { @@ -320,6 +320,38 @@ ], "category": "projects" }, + { + "name": "createProjectV2View", + "id": "createprojectv2view", + "href": "/graphql/reference/projects#mutation-createprojectv2view", + "description": "

Creates a new view in a project.

", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "CreateProjectV2ViewInput!", + "id": "createprojectv2viewinput", + "href": "/graphql/reference/projects#input-object-createprojectv2viewinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "

A unique identifier for the client performing the mutation.

" + }, + { + "name": "projectV2View", + "type": "ProjectV2View", + "id": "projectv2view", + "href": "/graphql/reference/projects#object-projectv2view", + "description": "

The new view.

" + } + ], + "category": "projects" + }, { "name": "deleteProjectV2", "id": "deleteprojectv2", @@ -455,6 +487,38 @@ ], "category": "projects" }, + { + "name": "deleteProjectV2View", + "id": "deleteprojectv2view", + "href": "/graphql/reference/projects#mutation-deleteprojectv2view", + "description": "

Deletes a view from a project.

", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "DeleteProjectV2ViewInput!", + "id": "deleteprojectv2viewinput", + "href": "/graphql/reference/projects#input-object-deleteprojectv2viewinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "

A unique identifier for the client performing the mutation.

" + }, + { + "name": "projectV2View", + "type": "ProjectV2View", + "id": "projectv2view", + "href": "/graphql/reference/projects#object-projectv2view", + "description": "

The deleted view.

" + } + ], + "category": "projects" + }, { "name": "deleteProjectV2Workflow", "id": "deleteprojectv2workflow", @@ -941,6 +1005,38 @@ } ], "category": "projects" + }, + { + "name": "updateProjectV2View", + "id": "updateprojectv2view", + "href": "/graphql/reference/projects#mutation-updateprojectv2view", + "description": "

Updates an existing view in a project.

", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "UpdateProjectV2ViewInput!", + "id": "updateprojectv2viewinput", + "href": "/graphql/reference/projects#input-object-updateprojectv2viewinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "

A unique identifier for the client performing the mutation.

" + }, + { + "name": "projectV2View", + "type": "ProjectV2View", + "id": "projectv2view", + "href": "/graphql/reference/projects#object-projectv2view", + "description": "

The updated view.

" + } + ], + "category": "projects" } ], "objects": [ @@ -6309,6 +6405,44 @@ ], "category": "projects" }, + { + "name": "CreateProjectV2ViewInput", + "id": "createprojectv2viewinput", + "href": "/graphql/reference/projects#input-object-createprojectv2viewinput", + "description": "

Autogenerated input type of CreateProjectV2View.

", + "inputFields": [ + { + "name": "clientMutationId", + "description": "

A unique identifier for the client performing the mutation.

", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "layout", + "description": "

The layout of the view.

", + "type": "ProjectV2ViewLayout!", + "id": "projectv2viewlayout", + "href": "/graphql/reference/projects#enum-projectv2viewlayout" + }, + { + "name": "name", + "description": "

The name of the view.

", + "type": "String!", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "projectId", + "description": "

The ID of the project to create a view in.

", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "projects" + }, { "name": "DeleteProjectV2FieldInput", "id": "deleteprojectv2fieldinput", @@ -6413,6 +6547,30 @@ ], "category": "projects" }, + { + "name": "DeleteProjectV2ViewInput", + "id": "deleteprojectv2viewinput", + "href": "/graphql/reference/projects#input-object-deleteprojectv2viewinput", + "description": "

Autogenerated input type of DeleteProjectV2View.

", + "inputFields": [ + { + "name": "clientMutationId", + "description": "

A unique identifier for the client performing the mutation.

", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "viewId", + "description": "

The ID of the view to delete.

", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "projects" + }, { "name": "DeleteProjectV2WorkflowInput", "id": "deleteprojectv2workflowinput", @@ -7379,6 +7537,51 @@ } ], "category": "projects" + }, + { + "name": "UpdateProjectV2ViewInput", + "id": "updateprojectv2viewinput", + "href": "/graphql/reference/projects#input-object-updateprojectv2viewinput", + "description": "

Autogenerated input type of UpdateProjectV2View.

", + "inputFields": [ + { + "name": "clientMutationId", + "description": "

A unique identifier for the client performing the mutation.

", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "filter", + "description": "

The new filter for the view.

", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "layout", + "description": "

The new layout for the view.

", + "type": "ProjectV2ViewLayout", + "id": "projectv2viewlayout", + "href": "/graphql/reference/projects#enum-projectv2viewlayout" + }, + { + "name": "name", + "description": "

The new name for the view.

", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "viewId", + "description": "

The ID of the view to update.

", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "projects" } ] } \ No newline at end of file diff --git a/src/graphql/data/ghec/schema.docs.graphql b/src/graphql/data/ghec/schema.docs.graphql index 837261b5d359..ee5b13ba0a80 100644 --- a/src/graphql/data/ghec/schema.docs.graphql +++ b/src/graphql/data/ghec/schema.docs.graphql @@ -7650,6 +7650,11 @@ type CopilotEndpoints @docsCategory(name: "copilot") { """ api: String! + """ + Copilot experimentation (edge TAS) endpoint + """ + exp: String + """ Copilot origin tracker endpoint """ @@ -9044,6 +9049,46 @@ type CreateProjectV2StatusUpdatePayload { statusUpdate: ProjectV2StatusUpdate } +""" +Autogenerated input type of CreateProjectV2View +""" +input CreateProjectV2ViewInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The layout of the view. + """ + layout: ProjectV2ViewLayout! + + """ + The name of the view. + """ + name: String! + + """ + The ID of the project to create a view in. + """ + projectId: ID! @possibleTypes(concreteTypes: ["ProjectV2"]) +} + +""" +Autogenerated return type of CreateProjectV2View. +""" +type CreateProjectV2ViewPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The new view. + """ + projectV2View: ProjectV2View +} + """ Autogenerated input type of CreatePullRequest """ @@ -10991,6 +11036,36 @@ type DeleteProjectV2StatusUpdatePayload { projectV2: ProjectV2 } +""" +Autogenerated input type of DeleteProjectV2View +""" +input DeleteProjectV2ViewInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The ID of the view to delete. + """ + viewId: ID! @possibleTypes(concreteTypes: ["ProjectV2View"]) +} + +""" +Autogenerated return type of DeleteProjectV2View. +""" +type DeleteProjectV2ViewPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The deleted view. + """ + projectV2View: ProjectV2View +} + """ Autogenerated input type of DeleteProjectV2Workflow """ @@ -20078,9 +20153,13 @@ type Issue implements Assignable & Closable & Comment & Deletable & Labelable & editor: Actor """ - A list of rationales associated with this issue's timeline events. + A list of rationales associated with this issue's timeline events. Always + returns an empty list; use the `intent` field on individual timeline events instead. """ eventRationales: [IssueEventRationale!]! + @deprecated( + reason: "Use the `intent` field on individual timeline events instead. This field is being removed and now always returns an empty list." + ) """ Identifies the primary key from the database as a BigInt. @@ -21329,9 +21408,11 @@ enum IssueEventConfidenceLevel @docsCategory(name: "issues") { } """ -Rationale text associated with an issue timeline event. +Rationale text associated with an issue timeline event. Deprecated: the fields +that return this type are being removed and now return null/empty. Use the +`intent` field on individual timeline events instead. """ -type IssueEventRationale { +type IssueEventRationale @docsCategory(name: "issues") { """ The agent or user who produced the rationale. """ @@ -21354,7 +21435,9 @@ type IssueEventRationale { } """ -An issue timeline event that may have an associated rationale. +An issue timeline event that may have an associated rationale. Deprecated: this +union is only reachable via the deprecated `IssueEventRationale` type, which is +being removed. Use the `intent` field on individual timeline events instead. """ union IssueEventWithRationale @docsCategory(name: "issues") = | ClosedEvent @@ -21407,9 +21490,10 @@ type IssueFieldAddedEvent implements Node @docsCategory(name: "issues") { options: [IssueFieldTimelineOption!] """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") """ The value of the added field. @@ -21477,9 +21561,10 @@ type IssueFieldChangedEvent implements Node @docsCategory(name: "issues") { previousValue: String """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -21857,9 +21942,10 @@ type IssueFieldRemovedEvent implements Node @docsCategory(name: "issues") { options: [IssueFieldTimelineOption!] """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -23106,9 +23192,10 @@ type IssueTypeAddedEvent implements Node @docsCategory(name: "issues") { issueType: IssueType """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -23146,9 +23233,10 @@ type IssueTypeChangedEvent implements Node @docsCategory(name: "issues") { prevIssueType: IssueType """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -23296,9 +23384,10 @@ type IssueTypeRemovedEvent implements Node @docsCategory(name: "issues") { issueType: IssueType """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -23708,9 +23797,10 @@ type LabeledEvent implements Node @docsCategory(name: "issues") { labelable: Labelable! """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -27248,8 +27338,8 @@ type Mutation @docsCategory(name: "meta") { """ This mutation clears the value of a field for an item in a Project. Currently - only text, number, date, assignees, labels, single-select, iteration and - milestone fields are supported. + only text, number, date, assignees, labels, single-select, multi-select, + iteration and milestone fields are supported. """ clearProjectV2ItemFieldValue( """ @@ -27636,6 +27726,16 @@ type Mutation @docsCategory(name: "meta") { input: CreateProjectV2StatusUpdateInput! ): CreateProjectV2StatusUpdatePayload @docsCategory(name: "projects") + """ + Creates a new view in a project. + """ + createProjectV2View( + """ + Parameters for CreateProjectV2View + """ + input: CreateProjectV2ViewInput! + ): CreateProjectV2ViewPayload @docsCategory(name: "projects") + """ Create a new pull request """ @@ -27969,6 +28069,16 @@ type Mutation @docsCategory(name: "meta") { input: DeleteProjectV2StatusUpdateInput! ): DeleteProjectV2StatusUpdatePayload @docsCategory(name: "projects") + """ + Deletes a view from a project. + """ + deleteProjectV2View( + """ + Parameters for DeleteProjectV2View + """ + input: DeleteProjectV2ViewInput! + ): DeleteProjectV2ViewPayload @docsCategory(name: "projects") + """ Deletes a project workflow. """ @@ -29499,6 +29609,16 @@ type Mutation @docsCategory(name: "meta") { input: UpdateProjectV2StatusUpdateInput! ): UpdateProjectV2StatusUpdatePayload @docsCategory(name: "projects") + """ + Updates an existing view in a project. + """ + updateProjectV2View( + """ + Parameters for UpdateProjectV2View + """ + input: UpdateProjectV2ViewInput! + ): UpdateProjectV2ViewPayload @docsCategory(name: "projects") + """ Update a pull request """ @@ -43544,6 +43664,11 @@ enum ProofOfPresenceRequirement @docsCategory(name: "enterprise-admin") { Members must complete a fresh re-authentication against the enterprise identity provider. """ REAUTH + + """ + Members must satisfy a phishing-resistant security key re-authentication (Microsoft Entra only). + """ + SECURITY_KEY } """ @@ -47406,7 +47531,9 @@ type Query implements Node @docsCategory(name: "meta") { ): EnterpriseAdministratorInvitation """ - Look up a pending enterprise administrator invitation by invitation token. + Look up a pending enterprise administrator invitation by invitation token. If + the invitation was sent to an email address, the viewer must have verified + that email address, or otherwise be an administrator of the enterprise. """ enterpriseAdministratorInvitationByToken( """ @@ -47431,7 +47558,9 @@ type Query implements Node @docsCategory(name: "meta") { ): EnterpriseMemberInvitation """ - Look up a pending enterprise unaffiliated member invitation by invitation token. + Look up a pending enterprise unaffiliated member invitation by invitation + token. If the invitation was sent to an email address, the viewer must have + verified that email address, or otherwise be an administrator of the enterprise. """ enterpriseMemberInvitationByToken( """ @@ -67306,9 +67435,10 @@ type UnlabeledEvent implements Node @docsCategory(name: "issues") { labelable: Labelable! """ - The rationale associated with this event. + The rationale associated with this event. Always returns null; use `intent` instead. """ rationale: IssueEventRationale + @deprecated(reason: "Use `intent` instead. This field is being removed and now always returns null.") } """ @@ -70262,6 +70392,51 @@ type UpdateProjectV2StatusUpdatePayload { statusUpdate: ProjectV2StatusUpdate } +""" +Autogenerated input type of UpdateProjectV2View +""" +input UpdateProjectV2ViewInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The new filter for the view. + """ + filter: String + + """ + The new layout for the view. + """ + layout: ProjectV2ViewLayout + + """ + The new name for the view. + """ + name: String + + """ + The ID of the view to update. + """ + viewId: ID! @possibleTypes(concreteTypes: ["ProjectV2View"]) +} + +""" +Autogenerated return type of UpdateProjectV2View. +""" +type UpdateProjectV2ViewPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The updated view. + """ + projectV2View: ProjectV2View +} + """ Autogenerated input type of UpdatePullRequestBranch """ diff --git a/src/links/lib/cross-page-anchors.ts b/src/links/lib/cross-page-anchors.ts new file mode 100644 index 000000000000..53b77f7eab72 --- /dev/null +++ b/src/links/lib/cross-page-anchors.ts @@ -0,0 +1,51 @@ +import type { BrokenLink } from '@/links/lib/link-report' + +/** + * A cross-page anchor link (`/some/path#fragment`) collected during pass 1 of the + * internal link checker, to be validated in pass 2 once every page's heading IDs are known. + */ +export interface PendingCrossPageAnchor { + targetKey: string + fragment: string + href: string + file: string + line: number + text?: string +} + +/** + * Pass 2 of cross-page anchor validation: given the collected cross-page anchor links + * and the fully-populated per-page heading ID cache, return the links whose fragment is + * missing from the target page. + * + * A target with no cache entry is skipped, not flagged. That happens when the target + * resolves to a version not covered by the current run, or the target is an autogenerated + * page whose anchors resolve at runtime. Note the scheduled matrix does not run every + * version, so an anchor whose target only resolves to an uncovered version is currently + * not validated anywhere: closing that gap is follow-up guardrail work. Kept as a pure + * function so the two-pass behavior can be tested without rendering the whole site. + * + * `#top` is exempt, mirroring the same-page anchor check. Per the HTML spec a browser + * scrolls to the top of the document for `#top` when nothing carries that ID, so it is + * always valid and never appears in a page's computed heading IDs. + */ +export function validateCrossPageAnchors( + pendingCrossPageAnchors: PendingCrossPageAnchor[], + headingIdsByPageKey: Map>, +): BrokenLink[] { + const broken: BrokenLink[] = [] + for (const anchor of pendingCrossPageAnchors) { + if (anchor.fragment === 'top') continue + const targetHeadingIds = headingIdsByPageKey.get(anchor.targetKey) + if (!targetHeadingIds) continue + if (!targetHeadingIds.has(anchor.fragment)) { + broken.push({ + href: anchor.href, + file: anchor.file, + lines: [anchor.line], + text: anchor.text, + }) + } + } + return broken +} diff --git a/src/links/lib/extract-links.ts b/src/links/lib/extract-links.ts index 28820181e5a7..1d0db58e4a4f 100644 --- a/src/links/lib/extract-links.ts +++ b/src/links/lib/extract-links.ts @@ -46,6 +46,13 @@ export interface ExtractedLink { isAutotitle?: boolean isImage?: boolean isAnchor?: boolean + /** + * The URL fragment (the part after `#`) for internal links that carry one, e.g. + * `some-heading` for `/foo/bar#some-heading`. `href` keeps the fragment stripped + * (so path resolution is unaffected); this field preserves it for cross-page anchor + * validation. Undefined when the link has no fragment. + */ + fragment?: string } export interface LinkExtractionResult { @@ -126,6 +133,17 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult const imageLinks: ExtractedLink[] = [] const liquidPrefixedLinks: ExtractedLink[] = [] + // Split an internal link destination into its path and optional fragment. + // href keeps the fragment stripped (so path resolution is unaffected); the + // fragment is returned separately for cross-page anchor validation. An empty + // fragment (a trailing bare `#`) is treated as no fragment. + const splitFragment = (raw: string): { href: string; fragment?: string } => { + const hashIndex = raw.indexOf('#') + if (hashIndex === -1) return { href: raw } + const fragment = raw.slice(hashIndex + 1) + return { href: raw.slice(0, hashIndex), fragment: fragment.length ? fragment : undefined } + } + // Strip fenced code blocks to avoid checking example/placeholder URLs // Replaces non-newline characters with spaces to preserve line numbers and positions const withoutFences = content.replace( @@ -156,7 +174,7 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult let match while ((match = AUTOTITLE_LINK_PATTERN.exec(strippedContent)) !== null) { const { line, column } = getLineAndColumn(lineOffsets, match.index) - const href = match[1].split('#')[0] // Remove anchor if present + const { href, fragment } = splitFragment(match[1]) // Split off anchor if present if (href.startsWith('/')) { internalLinks.push({ href, @@ -164,6 +182,7 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult column, text: 'AUTOTITLE', isAutotitle: true, + fragment, }) } } @@ -181,7 +200,7 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult const { line, column } = getLineAndColumn(lineOffsets, match.index) // Extract href from ](/path) format. The destination is captured in group 1, // which handles balanced parentheses (e.g. asset filenames like `(fr).pdf`). - const href = match[1].split('#')[0] + const { href, fragment } = splitFragment(match[1]) const text = extractLinkText(strippedContent, match.index) internalLinks.push({ @@ -190,6 +209,7 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult column, text, isAutotitle: false, + fragment, }) } @@ -252,12 +272,13 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult // These are distinct from inline links but point to the same targets that need validating. while ((match = LINK_DEFINITION_PATTERN.exec(strippedContent)) !== null) { const { line, column } = getLineAndColumn(lineOffsets, match.index) - const href = match[1].split('#')[0] + const { href, fragment } = splitFragment(match[1]) internalLinks.push({ href, line, column, isAutotitle: false, + fragment, }) } @@ -329,6 +350,20 @@ async function getCachedRenderLiquid(): Promise { return _renderLiquid } +/** + * Render Liquid templates in content and return the rendered markdown. + * + * Unlike `extractLinksWithLiquid` and `renderAndExtractLinks`, a render failure is NOT + * swallowed: it propagates to the caller. Use this when falling back to the raw, unrendered + * markdown would be worse than no result at all — for example when the rendered headings + * drive a destructive edit, where unrendered `{% data %}` in a heading would silently + * produce the wrong anchor IDs. + */ +export async function renderMarkdownLiquid(content: string, context: Context): Promise { + const renderLiquid = await getCachedRenderLiquid() + return renderLiquid(content, context) +} + /** * Render Liquid templates in content and extract links * @@ -416,6 +451,34 @@ export function normalizeLinkPath(href: string): string { return normalized } +/** + * Resolve an internal link href to the exact pageMap key of the page it lands on, + * but ONLY for direct (non-redirect) hits. Returns null when the link doesn't + * resolve directly to a page (redirect, archived version, or broken). + * + * This mirrors the two direct-hit branches of `checkInternalLink` (a bare path or a + * language-prefixed path). It's used by the cross-page anchor checker to look up the + * target page's precomputed heading IDs. Redirects are intentionally excluded: the + * link is already reported as a redirect-to-update, and its final anchor is ambiguous. + */ +export function resolveInternalLinkKey(href: string, pageMap: Record): string | null { + const normalized = normalizeLinkPath(href) + + const latestPrefix = '/enterprise-server@latest' + const stablePrefix = `/enterprise-server@${latestStable}` + const resolved = + normalized.startsWith(latestPrefix) || normalized.startsWith(`/en${latestPrefix}`) + ? normalized.replace(latestPrefix, stablePrefix) + : normalized + + if (pageMap[resolved]) return resolved + + const withLang = `/en${resolved}` + if (pageMap[withLang]) return withLang + + return null +} + /** * Check if a path exists in the pageMap or redirects */ diff --git a/src/links/lib/heading-anchors.ts b/src/links/lib/heading-anchors.ts new file mode 100644 index 000000000000..930cf2da33cc --- /dev/null +++ b/src/links/lib/heading-anchors.ts @@ -0,0 +1,118 @@ +import GithubSlugger from 'github-slugger' + +/** + * Strip inline Markdown markup from a heading to get plain text for slug computation. + * Matches what hast-util-to-string produces on a heading node after remark parsing. + * + * Key design decisions: + * - Inline code spans (backtick) are extracted verbatim so that `` inside them + * is not incorrectly stripped by the HTML-tag regex (which is needed for octicon SVGs). + * - HTML stripping only removes valid HTML element names (no underscores) to avoid stripping + * angle-bracket placeholders like that appear in code-span heading text. + * - No final .trim() — trailing whitespace from stripped SVGs becomes trailing hyphens via + * github-slugger, reproducing the live site's heading IDs (e.g. `allow--`). + */ +export function headingTextToPlain(text: string): string { + // Strip HTML tags using a state machine rather than a regex so that CodeQL can verify + // the stripping is complete. Tags like or tags with '>' in attribute values + // are handled correctly. Output is only used for slug computation, never rendered as HTML. + function stripHtmlTags(s: string): string { + let out = '' + let inTag = false + for (let i = 0; i < s.length; i++) { + if (!inTag && s[i] === '<') { + // Peek ahead: if this looks like an underscore-containing placeholder (e.g. ), + // emit the inner text instead of dropping it entirely so the slug stays correct. + const close = s.indexOf('>', i + 1) + if (close !== -1) { + const inner = s.slice(i + 1, close) + if (/^[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+$/.test(inner)) { + out += inner + i = close + continue + } + } + inTag = true + } else if (inTag && s[i] === '>') { + inTag = false + // Don't emit a replacement space — surrounding whitespace in the source markdown + // already provides the correct spacing for github-slugger (e.g. `allow ` from + // the space before an octicon tag). + } else if (!inTag) { + out += s[i] + } + } + return out + } + + // Process non-code portions: strip HTML and inline formatting markup. + function processNonCode(s: string): string { + return stripHtmlTags(s) + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images: ![alt](url) → alt + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links: [text](url) → text + .replace(/\*\*([^*]+)\*\*/g, '$1') // bold **text** + .replace(/\*([^*]+)\*/g, '$1') // italic *text* + .replace(/(? 0) { + const open = remaining.indexOf('`') + if (open === -1) { + parts.push(processNonCode(remaining)) + break + } + if (open > 0) parts.push(processNonCode(remaining.slice(0, open))) + const close = remaining.indexOf('`', open + 1) + if (close === -1) { + // Unclosed backtick — treat remainder as non-code + parts.push(processNonCode(remaining.slice(open))) + break + } + parts.push(remaining.slice(open + 1, close)) // code content verbatim + remaining = remaining.slice(close + 1) + } + return parts.join('') + // Note: no .trim() — see comment above. +} + +/** + * Compute the set of heading anchor IDs for a page from its Liquid-rendered markdown. + * + * Uses github-slugger (the same library as rehype-slug in the render pipeline) to compute + * heading anchor IDs in document order, producing results that match the live site, + * including the `-1`, `-2`, ... dedupe suffixes github-slugger adds for repeated headings. + * + * Handles ATX headings (`## Heading`), Setext headings (underlined with `===`/`---`), and + * explicit `` / `` anchors embedded in the markdown. + */ +export function computeHeadingIds(renderedMarkdown: string): Set { + const slugger = new GithubSlugger() + const headingIds = new Set() + + // ATX headings: ## Heading text (optional trailing ##) + const ATX_HEADING_RE = /^#{1,6}\s+(.+?)(?:\s+#+)?\s*$/gm + let m: RegExpExecArray | null + while ((m = ATX_HEADING_RE.exec(renderedMarkdown)) !== null) { + headingIds.add(slugger.slug(headingTextToPlain(m[1]))) + } + + // Setext headings: text line followed by === or --- underline + const SETEXT_HEADING_RE = /^([^\n]+)\n[=-]{2,}\s*$/gm + while ((m = SETEXT_HEADING_RE.exec(renderedMarkdown)) !== null) { + headingIds.add(slugger.slug(headingTextToPlain(m[1]))) + } + + // Explicit and anchors embedded in the markdown. + // Some pages (e.g. site-policy) use raw HTML anchors instead of headings. + const NAMED_ANCHOR_RE = /]*(?:name|id)="([^"]+)"[^>]*>/gi + while ((m = NAMED_ANCHOR_RE.exec(renderedMarkdown)) !== null) { + headingIds.add(m[1]) + } + + return headingIds +} diff --git a/src/links/lib/update-internal-links.ts b/src/links/lib/update-internal-links.ts index 96c85e48aa2c..27b63dd7caec 100644 --- a/src/links/lib/update-internal-links.ts +++ b/src/links/lib/update-internal-links.ts @@ -21,6 +21,7 @@ import { loadUnversionedTree, loadPages, loadPageMap } from '@/frame/lib/page-da import getRedirect, { splitPathByLanguage } from '@/redirects/lib/get-redirect' import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version' import { deprecated } from '@/versions/lib/enterprise-server-releases' +import { RedirectedFragmentValidator } from '@/links/lib/validate-redirected-fragment' const logger = createLogger(import.meta.url) @@ -33,6 +34,7 @@ type LinkContext = { redirects: NonNullable currentLanguage: string userLanguage: string + fragmentValidator: RedirectedFragmentValidator } type Replacement = { @@ -49,11 +51,40 @@ type Warning = { column?: number } +// A fragment (`#anchor`) that was carried over when a redirect rewrote the link's path. +// It needs validating against the destination page before we decide to keep or drop it. +type CarriedFragment = { + hash: string + destPage?: Page +} + +type NewHrefResult = { + // The rewritten href WITHOUT the fragment when `fragment` is set (the caller decides + // whether to re-append it); otherwise the full href including any fragment/search. + href: string + fragment?: CarriedFragment +} + +// A link/definition replacement collected during the synchronous AST walk. Applying it is +// deferred until after async fragment validation, so a carried-over anchor can be dropped. +type PendingReplacement = { + asMarkdown: string + line: number + column?: number + baseHref: string + makeMarkdown: (href: string) => string + fragment?: CarriedFragment +} + const Options = { setAutotitle: false, fixHref: false, verbose: false, strict: false, + // When a redirect rewrites a link's path, the carried-over `#anchor` is validated + // against the destination page's real headings. If it exists in no applicable version + // it is dropped by default. Set this to keep such fragments (warn only). + keepStaleFragments: false, } export async function updateInternalLinks(files: string[], options = {}) { @@ -71,6 +102,7 @@ export async function updateInternalLinks(files: string[], options = {}) { redirects, currentLanguage: 'en', userLanguage: 'en', + fragmentValidator: new RedirectedFragmentValidator(pageMap, redirects, 'en'), } for (const file of files) { @@ -167,31 +199,30 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio const lineOffset = rawContent.replace(content, '').split(/\n/g).length - 1 + // Replacements are collected here during the synchronous AST walk and applied after + // async fragment validation below, so a carried-over `#anchor` can be dropped when the + // redirected destination page doesn't actually have that heading. + const pending: PendingReplacement[] = [] + visit(ast, definitionMatcher as Test, (node: Nodes) => { const asMarkdown = toMarkdown(node).trim() // E.g. `[foo]: /bar` if (opts.fixHref && content.includes(asMarkdown) && isDefinition(node)) { - let newHref = node.url const { label } = node - const betterHref = getNewHref(newHref, context, opts, file) + const result = getNewHref(node.url, context, opts, file) // getNewHref() might return a deliberate `undefined` if the // new href value could not be computed for some reason. - if (betterHref !== undefined) { - newHref = betterHref - } - const newAsMarkdown = `[${label}]: ${newHref}` - if (asMarkdown !== newAsMarkdown) { - // Something can be improved! - const column = node.position?.start.column - const line = node.position?.start.line || 0 + lineOffset - replacements.push({ - asMarkdown, - newAsMarkdown, - line, - column, - }) - newContent = newContent.replace(asMarkdown, newAsMarkdown) - } + const baseHref = result === undefined ? node.url : result.href + const column = node.position?.start.column + const line = (node.position?.start.line ?? 0) + lineOffset + pending.push({ + asMarkdown, + line, + column, + baseHref, + makeMarkdown: (href) => `[${label}]: ${href}`, + fragment: result?.fragment, + }) } }) @@ -212,7 +243,8 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio const title = node.children.map((child: Nodes) => toMarkdown(child).slice(0, -1)).join('') let newTitle = title - let newHref = node.url + let baseHref = node.url + let fragment: CarriedFragment | undefined const hasQuotesAroundLink = content.includes(`"${asMarkdown}`) @@ -250,7 +282,7 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio if (xValue) { if (singleStartingQuote(xValue)) { const column = node.position?.start.column - const line = node.position?.start.line || 0 + lineOffset + const line = (node.position?.start.line ?? 0) + lineOffset warnings.push({ warning: 'Starts with a single " inside the text', asMarkdown, @@ -259,7 +291,7 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio }) } else if (isSimpleQuote(xValue)) { const column = node.position?.start.column - const line = node.position?.start.line || 0 + lineOffset + const line = (node.position?.start.line ?? 0) + lineOffset warnings.push({ warning: 'Starts and ends with a " inside the text', asMarkdown, @@ -271,26 +303,24 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio } } if (opts.fixHref) { - const betterHref = getNewHref(node.url, context, opts, file) + const result = getNewHref(node.url, context, opts, file) // getNewHref() might return a deliberate `undefined` if the // new href value could not be computed for some reason. - if (betterHref !== undefined) { - newHref = betterHref + if (result !== undefined) { + baseHref = result.href + fragment = result.fragment } } - const newAsMarkdown = `[${newTitle}](${newHref})` - if (asMarkdown !== newAsMarkdown) { - // Something can be improved! - const column = node.position?.start.column - const line = node.position?.start.line || 0 + lineOffset - replacements.push({ - asMarkdown, - newAsMarkdown, - line, - column, - }) - newContent = newContent.replace(asMarkdown, newAsMarkdown) - } + const column = node.position?.start.column + const line = (node.position?.start.line ?? 0) + lineOffset + pending.push({ + asMarkdown, + line, + column, + baseHref, + makeMarkdown: (href) => `[${newTitle}](${href})`, + fragment, + }) } else if (opts.verbose) { logger.warn('Unable to find link as Markdown in the source content', { asMarkdown, @@ -299,6 +329,64 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio } }) + // Resolve any carried-over fragments (async — renders the destination page), then apply + // every replacement to `newContent` in document order. + for (const item of pending) { + let finalHref = item.baseHref + if (item.fragment) { + const { hash, destPage } = item.fragment + const decision = await context.fragmentValidator.classify(destPage, hash) + if (decision === 'drop') { + if (opts.keepStaleFragments) { + finalHref = item.baseHref + hash + warnings.push({ + warning: `Stale anchor '${hash}' not found on the redirected destination page in any applicable version, but kept because keepStaleFragments is set`, + asMarkdown: item.asMarkdown, + line: item.line, + column: item.column, + }) + } else { + // Drop the fragment: leave `finalHref` as the fragment-less base href. + warnings.push({ + warning: `Removed stale anchor '${hash}' — not found on the redirected destination page in any applicable version`, + asMarkdown: item.asMarkdown, + line: item.line, + column: item.column, + }) + } + } else if (decision === 'mixed') { + finalHref = item.baseHref + hash + warnings.push({ + warning: `Anchor '${hash}' exists in some but not all versions of the redirected destination page; kept for manual review`, + asMarkdown: item.asMarkdown, + line: item.line, + column: item.column, + }) + } else if (decision === 'unvalidatable') { + finalHref = item.baseHref + hash + warnings.push({ + warning: `Could not validate anchor '${hash}' on the redirected destination page; kept unchanged`, + asMarkdown: item.asMarkdown, + line: item.line, + column: item.column, + }) + } else { + // 'keep' — the anchor exists on the destination in every applicable version. + finalHref = item.baseHref + hash + } + } + const newAsMarkdown = item.makeMarkdown(finalHref) + if (item.asMarkdown !== newAsMarkdown) { + replacements.push({ + asMarkdown: item.asMarkdown, + newAsMarkdown, + line: item.line, + column: item.column, + }) + newContent = newContent.replace(item.asMarkdown, newAsMarkdown) + } + } + return { data, content, @@ -490,7 +578,7 @@ function getNewHref( strict: boolean }, file: string, -) { +): NewHrefResult | undefined { const { currentLanguage } = context const parsed = new URL(href, 'https://docs.github.com') const hash = parsed.hash @@ -579,13 +667,31 @@ function getNewHref( } } - if (search) { - newHref += search + const base = search ? `${newHref}${search}` : newHref + + // No fragment → nothing to validate; return the (possibly rewritten) path as-is. + if (!hash) { + return { href: base } } - if (hash) { - newHref += hash + + // The path wasn't rewritten, so the original fragment still points at the same page and + // remains valid. Keep it appended, exactly as before. + if (!redirected) { + return { href: base + hash } } - return newHref + + // The path WAS rewritten by a redirect, so the carried-over fragment may be stale on the + // destination page. Surface it (with the destination Page, if resolvable) so the caller + // can validate it against the destination's real headings and decide keep vs. drop. + const destPage = resolveDestinationPage(context.pages, redirected) + return { href: base, fragment: { hash, destPage } } +} + +// Resolve the Page a redirect points at, so its headings can be validated. `redirected` is +// a full permalink-style URL from getRedirect() (e.g. `/en/get-started/foo` or +// `/en/enterprise-cloud@latest/get-started/foo`), which is how the page map is keyed. +function resolveDestinationPage(pages: Record, redirected: string): Page | undefined { + return pages[redirected] || pages[getPathWithLanguage(getPathWithoutLanguage(redirected), 'en')] } function singleStartingQuote(text: string) { diff --git a/src/links/lib/validate-redirected-fragment.ts b/src/links/lib/validate-redirected-fragment.ts new file mode 100644 index 000000000000..5682cf83e96d --- /dev/null +++ b/src/links/lib/validate-redirected-fragment.ts @@ -0,0 +1,135 @@ +/** + * When `update-internal-links` rewrites a link's path via a redirect, the original + * URL fragment (`#anchor`) is carried over from the old target. If the destination + * page doesn't have that heading, the anchor is silently stale and lands the reader + * at the top of the page. + * + * This validator renders the destination page through Liquid (once per version, cached) + * and checks whether the anchor exists in the page's real heading IDs, so the caller + * can decide whether to keep or drop the fragment. + * + * Correctness note: dropping a fragment is a destructive edit, so we only ever return + * `'drop'` when the anchor is absent from EVERY applicable version's render. If we can't + * render the page faithfully (render error, autogenerated page, unknown version), we + * return `'unvalidatable'` so the caller keeps the fragment rather than risk a false drop. + * + * Towards github/docs-engineering#6738. + */ +import warmServer from '@/frame/lib/warm-server' +import { allVersions } from '@/versions/lib/all-versions' +import { getFeaturesByVersion } from '@/versions/middleware/features' +import { renderMarkdownLiquid } from '@/links/lib/extract-links' +import { computeHeadingIds } from '@/links/lib/heading-anchors' +import { createLogger } from '@/observability/logger' +import type { Context, Page } from '@/types' + +const logger = createLogger(import.meta.url) + +// 'keep' → anchor exists in every applicable version → leave the fragment. +// 'drop' → anchor is absent from every applicable version → safe to remove. +// 'mixed' → anchor exists in some but not all versions → keep, but flag for review. +// 'unvalidatable' → couldn't faithfully render the destination → keep, don't risk a drop. +export type FragmentDecision = 'keep' | 'drop' | 'mixed' | 'unvalidatable' + +export class RedirectedFragmentValidator { + private headingIdCache = new Map | null>() + private warmPromise: Promise | null = null + + constructor( + private pages: Record, + private redirects: Record, + private language = 'en', + ) {} + + // Rendering a page's Liquid faithfully (e.g. `{% data variables.x %}` inside a heading) + // depends on the site data being loaded. Warm it lazily so callers that never hit a + // redirected fragment don't pay the cost. + private ensureWarmed(): Promise { + if (!this.warmPromise) { + this.warmPromise = warmServer([this.language]) + } + return this.warmPromise + } + + // Overridable in tests so the decision logic can be exercised without rendering. + protected async headingIdsFor(page: Page, version: string): Promise | null> { + const cacheKey = `${version}\u0000${page.relativePath}` + const cached = this.headingIdCache.get(cacheKey) + if (cached !== undefined) return cached + + let ids: Set | null = null + try { + const versionObj = allVersions[version] + if (!versionObj) { + this.headingIdCache.set(cacheKey, null) + return null + } + await this.ensureWarmed() + const context = { + currentVersion: version, + currentLanguage: this.language, + currentVersionObj: versionObj, + [versionObj.shortName]: true, + pages: this.pages, + redirects: this.redirects, + page, + ...getFeaturesByVersion(version), + } as unknown as Context + // Use renderMarkdownLiquid, NOT renderAndExtractLinks: the latter swallows Liquid + // render failures and falls back to the raw markdown, which would yield heading IDs + // computed from unrendered `{% data %}` expressions. Those never match a real anchor, + // so classify() would return 'drop' and destructively delete a valid fragment. Here a + // render failure throws, lands in the catch below, and becomes 'unvalidatable'. + const renderedMarkdown = await renderMarkdownLiquid(page.markdown, context) + ids = computeHeadingIds(renderedMarkdown) + } catch (error) { + logger.warn('Failed to render destination page for fragment validation', { + page: page.relativePath, + version, + error: error instanceof Error ? error.message : String(error), + }) + ids = null + } + this.headingIdCache.set(cacheKey, ids) + return ids + } + + /** + * Decide what to do with a fragment carried over onto a redirected destination page. + * `fragment` may include the leading `#`. + */ + async classify(destPage: Page | undefined, fragment: string): Promise { + // Can't find the destination page (redirect to an archived/external target, or a + // lookup miss) → we have nothing to validate against, so keep the fragment. + if (!destPage) return 'unvalidatable' + + // Autogenerated pages (REST, GraphQL, webhooks) build their headings from data files + // at render time in a way `computeHeadingIds` on the source markdown can't see, so we + // can't reliably tell whether the anchor exists. Keep it and let the human/checker decide. + if (destPage.autogenerated) return 'unvalidatable' + + const anchor = fragment.replace(/^#/, '') + if (!anchor) return 'keep' + + // `#top` is always valid: per the HTML spec a browser scrolls to the top of the + // document when nothing carries that ID, so it never appears in computed heading IDs + // and would otherwise always classify as 'drop'. Mirrors the same-page anchor check + // in check-links-internal.ts, which skips `#` and `#top`. + if (anchor === 'top') return 'keep' + + const versions = destPage.applicableVersions || [] + if (versions.length === 0) return 'unvalidatable' + + let presentCount = 0 + for (const version of versions) { + const ids = await this.headingIdsFor(destPage, version) + // If even one applicable version fails to render, don't risk a destructive drop. + if (ids === null) return 'unvalidatable' + if (ids.has(anchor)) presentCount++ + } + + if (presentCount === versions.length) return 'keep' + if (presentCount === 0) return 'drop' + return 'mixed' + } +} diff --git a/src/links/scripts/check-links-internal.ts b/src/links/scripts/check-links-internal.ts index a3d0c9cf3683..26993356a473 100644 --- a/src/links/scripts/check-links-internal.ts +++ b/src/links/scripts/check-links-internal.ts @@ -24,14 +24,13 @@ import os from 'os' import { program } from 'commander' import chalk from 'chalk' -import GithubSlugger from 'github-slugger' - import warmServer from '@/frame/lib/warm-server' import { allVersions, allVersionKeys } from '@/versions/lib/all-versions' import languages from '@/languages/lib/languages-server' import { normalizeLinkPath, checkInternalLink, + resolveInternalLinkKey, checkAssetLink, isAssetLink, extractLinksWithLiquid, @@ -48,6 +47,11 @@ import { uploadArtifact } from '@/links/scripts/upload-artifact' import { createReportIssue, linkReports } from '@/workflows/issue-report' import github from '@/workflows/github' import excludedLinks from '@/links/lib/excluded-links' +import { + validateCrossPageAnchors, + type PendingCrossPageAnchor, +} from '@/links/lib/cross-page-anchors' +import { computeHeadingIds } from '@/links/lib/heading-anchors' import { getFeaturesByVersion } from '@/versions/middleware/features' import type { Page, Permalink, Context } from '@/types' import * as coreLib from '@actions/core' @@ -120,7 +124,7 @@ async function getLinksFromMarkdown( context: Context, precomputedRawResult?: LinkExtractionResult, prerenderedResult?: LinkExtractionResult, -): Promise<{ href: string; text: string | undefined; line: number }[]> { +): Promise<{ href: string; text: string | undefined; line: number; fragment?: string }[]> { const fmOffset = getFrontmatterLineOffset(page.fullPath) // Build a map of raw-markdown line numbers per href, plus a parallel index @@ -190,140 +194,39 @@ async function getLinksFromMarkdown( // extractLinksWithLiquid already catches Liquid render failures internally and // falls back to raw extraction with a warning, so no outer try/catch is needed. const renderedResult = prerenderedResult ?? (await extractLinksWithLiquid(page.markdown, context)) - const renderedLinks = renderedResult.internalLinks.map((l) => ({ href: l.href, text: l.text })) + const renderedLinks = renderedResult.internalLinks.map((l) => ({ + href: l.href, + text: l.text, + fragment: l.fragment, + })) return renderedLinks.map((link) => { const lines = rawLinesByHref.get(link.href) const idx = rawLinesIndex.get(link.href) ?? 0 const line = lines && idx < lines.length ? lines[idx] : 0 rawLinesIndex.set(link.href, idx + 1) - return { href: link.href, text: link.text, line } + return { href: link.href, text: link.text, line, fragment: link.fragment } }) } -/** - * Strip inline Markdown markup from a heading to get plain text for slug computation. - * Matches what hast-util-to-string produces on a heading node after remark parsing. - * - * Key design decisions: - * - Inline code spans (backtick) are extracted verbatim so that `` inside them - * is not incorrectly stripped by the HTML-tag regex (which is needed for octicon SVGs). - * - HTML stripping only removes valid HTML element names (no underscores) to avoid stripping - * angle-bracket placeholders like that appear in code-span heading text. - * - No final .trim() — trailing whitespace from stripped SVGs becomes trailing hyphens via - * github-slugger, reproducing the live site's heading IDs (e.g. `allow--`). - */ -function headingTextToPlain(text: string): string { - // Strip HTML tags using a state machine rather than a regex so that CodeQL can verify - // the stripping is complete. Tags like or tags with '>' in attribute values - // are handled correctly. Output is only used for slug computation, never rendered as HTML. - function stripHtmlTags(s: string): string { - let out = '' - let inTag = false - for (let i = 0; i < s.length; i++) { - if (!inTag && s[i] === '<') { - // Peek ahead: if this looks like an underscore-containing placeholder (e.g. ), - // emit the inner text instead of dropping it entirely so the slug stays correct. - const close = s.indexOf('>', i + 1) - if (close !== -1) { - const inner = s.slice(i + 1, close) - if (/^[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+$/.test(inner)) { - out += inner - i = close - continue - } - } - inTag = true - } else if (inTag && s[i] === '>') { - inTag = false - // Don't emit a replacement space — surrounding whitespace in the source markdown - // already provides the correct spacing for github-slugger (e.g. `allow ` from - // the space before an octicon tag). - } else if (!inTag) { - out += s[i] - } - } - return out - } - - // Process non-code portions: strip HTML and inline formatting markup. - function processNonCode(s: string): string { - return stripHtmlTags(s) - .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images: ![alt](url) → alt - .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links: [text](url) → text - .replace(/\*\*([^*]+)\*\*/g, '$1') // bold **text** - .replace(/\*([^*]+)\*/g, '$1') // italic *text* - .replace(/(? 0) { - const open = remaining.indexOf('`') - if (open === -1) { - parts.push(processNonCode(remaining)) - break - } - if (open > 0) parts.push(processNonCode(remaining.slice(0, open))) - const close = remaining.indexOf('`', open + 1) - if (close === -1) { - // Unclosed backtick — treat remainder as non-code - parts.push(processNonCode(remaining.slice(open))) - break - } - parts.push(remaining.slice(open + 1, close)) // code content verbatim - remaining = remaining.slice(close + 1) - } - return parts.join('') - // Note: no .trim() — see comment above. -} - /** * Check anchor links on a page using fast heading ID computation from Liquid-rendered * markdown. Avoids the expensive full HTML render previously used. * * Uses github-slugger (the same library as rehype-slug in the render pipeline) to compute * heading anchor IDs, producing results that match the live site. + * + * `headingIds` is precomputed once per page in checkPage and shared with the cross-page + * anchor cache, so this function only checks same-page (`#fragment`) links here. */ function checkAnchorsFromHeadings( page: Page, rawResult: LinkExtractionResult, renderedResult: LinkExtractionResult, - renderedMarkdown: string, + headingIds: Set, ): BrokenLink[] { - if (page.autogenerated) return [] - const fmOffset = getFrontmatterLineOffset(page.fullPath) - // Compute heading anchor IDs from the Liquid-rendered markdown in document order. - // github-slugger deduplicates identical headings with -1, -2, ... suffixes, - // matching the behaviour of rehype-slug in the full render pipeline. - const slugger = new GithubSlugger() - const headingIds = new Set() - - // ATX headings: ## Heading text (optional trailing ##) - const ATX_HEADING_RE = /^#{1,6}\s+(.+?)(?:\s+#+)?\s*$/gm - let m: RegExpExecArray | null - while ((m = ATX_HEADING_RE.exec(renderedMarkdown)) !== null) { - headingIds.add(slugger.slug(headingTextToPlain(m[1]))) - } - - // Setext headings: text line followed by === or --- underline - const SETEXT_HEADING_RE = /^([^\n]+)\n[=-]{2,}\s*$/gm - while ((m = SETEXT_HEADING_RE.exec(renderedMarkdown)) !== null) { - headingIds.add(slugger.slug(headingTextToPlain(m[1]))) - } - - // Explicit and anchors embedded in the markdown. - // Some pages (e.g. site-policy) use raw HTML anchors instead of headings. - const NAMED_ANCHOR_RE = /]*(?:name|id)="([^"]+)"[^>]*>/gi - while ((m = NAMED_ANCHOR_RE.exec(renderedMarkdown)) !== null) { - headingIds.add(m[1]) - } - // Build line-number map from the raw (pre-Liquid) source for accurate file line numbers. const anchorLineMap = new Map() for (const link of rawResult.anchorLinks) { @@ -363,9 +266,16 @@ async function checkPage( pageMap: Record, redirects: Record, options: { checkAnchors: boolean }, -): Promise<{ brokenLinks: BrokenLink[]; redirectLinks: BrokenLink[]; linksChecked: number }> { +): Promise<{ + brokenLinks: BrokenLink[] + redirectLinks: BrokenLink[] + linksChecked: number + headingIds: Set | null + crossPageAnchors: PendingCrossPageAnchor[] +}> { const brokenLinks: BrokenLink[] = [] const redirectLinks: BrokenLink[] = [] + const crossPageAnchors: PendingCrossPageAnchor[] = [] const rawMarkdownLinks = extractLinksFromMarkdown(page.markdown) @@ -376,6 +286,15 @@ async function checkPage( pageContext, ) + // Compute this page's heading anchor IDs once from the Liquid-rendered markdown. + // Autogenerated pages (REST/GraphQL/webhooks) derive their anchors from OpenAPI + // operation IDs, not markdown headings, so we can't compute them here — leave them + // out of the cache so links into them are never flagged (they resolve at runtime). + // Skip the work entirely when anchor checking is disabled: nothing downstream reads + // the heading cache in that mode. + const headingIds = + options.checkAnchors && !page.autogenerated ? computeHeadingIds(renderedMarkdown) : null + const links = await getLinksFromMarkdown(page, pageContext, rawMarkdownLinks, renderedLinkResult) for (const link of links) { @@ -413,20 +332,35 @@ async function checkPage( isRedirect: true, redirectTarget: result.redirectTarget, }) + } else if (options.checkAnchors && link.fragment) { + // Direct (non-redirect) hit with a fragment: defer a cross-page anchor check. + // We can't validate it now because the target page may not have been rendered + // yet, so collect it and validate after the whole version finishes. + const targetKey = resolveInternalLinkKey(link.href, pageMap) + if (targetKey) { + crossPageAnchors.push({ + targetKey, + fragment: link.fragment, + href: `${link.href}#${link.fragment}`, + file: page.relativePath, + line: link.line, + text: link.text, + }) + } } } - if (options.checkAnchors) { + if (options.checkAnchors && headingIds) { const anchorFlaws = checkAnchorsFromHeadings( page, rawMarkdownLinks, renderedLinkResult, - renderedMarkdown, + headingIds, ) brokenLinks.push(...anchorFlaws) } - return { brokenLinks, redirectLinks, linksChecked: links.length } + return { brokenLinks, redirectLinks, linksChecked: links.length, headingIds, crossPageAnchors } } /** @@ -474,6 +408,17 @@ async function checkVersion( let totalPagesChecked = 0 let totalLinksChecked = 0 + // Cross-page anchor validation is a two-pass process within the version: + // pass 1 — render every page, caching its heading IDs and collecting the + // cross-page anchor links it contains (target may not be rendered yet) + // pass 2 — after all pages are rendered, validate each collected anchor against + // the now-complete heading cache + // The cache is keyed by pageMap key (lang + version + path). A link whose target + // resolves to a different version isn't in this run's cache and is skipped here; + // it's validated when the workflow runs the checker for that target version. + const headingIdsByPageKey = new Map>() + const pendingCrossPageAnchors: PendingCrossPageAnchor[] = [] + // Bounded concurrency: process up to `options.concurrency` pages simultaneously. // All workers drain from the same shared iterator — no page is processed twice. const queue = relevantPages.entries() @@ -493,6 +438,10 @@ async function checkVersion( // between await points cannot interleave with another worker's pushes. allBrokenLinks.push(...result.brokenLinks) allRedirectLinks.push(...result.redirectLinks) + if (result.headingIds) headingIdsByPageKey.set(permalink.href, result.headingIds) + if (result.crossPageAnchors.length) { + pendingCrossPageAnchors.push(...result.crossPageAnchors) + } totalPagesChecked++ totalLinksChecked += result.linksChecked @@ -505,6 +454,11 @@ async function checkVersion( // Launch `concurrency` workers that all drain from the same shared queue iterator. await Promise.all(Array.from({ length: options.concurrency }, worker)) + // Pass 2: validate cross-page anchors now that every page's headings are cached. + if (options.checkAnchors) { + allBrokenLinks.push(...validateCrossPageAnchors(pendingCrossPageAnchors, headingIdsByPageKey)) + } + return { brokenLinks: allBrokenLinks, redirectLinks: allRedirectLinks, diff --git a/src/links/scripts/update-internal-links.ts b/src/links/scripts/update-internal-links.ts index 2217022ca0c1..c1d23109d8d2 100755 --- a/src/links/scripts/update-internal-links.ts +++ b/src/links/scripts/update-internal-links.ts @@ -28,6 +28,10 @@ program .option('--check', 'Exit and fail if it found something to fix') .option('--aggregate-stats', 'Display aggregate numbers about all possible changes') .option('--strict', "Throw an error (instead of a warning) if a link can't be processed") + .option( + '--keep-stale-fragments', + "Keep a link's #anchor after a redirect rewrites its path, even when the anchor doesn't exist on the destination page (default: drop such anchors)", + ) .option('--exclude [paths...]', 'Specific files to exclude') .arguments('[files-or-directories...]') .parse(process.argv) @@ -43,6 +47,7 @@ type Options = { check: boolean aggregateStats: boolean strict: boolean + keepStaleFragments: boolean exclude: string[] filesOrDirectories?: string[] } @@ -99,6 +104,7 @@ async function main(files: string[], opts: Options) { fixHref: !opts.dontFixHref, verbose, strict: !!opts.strict, + keepStaleFragments: !!opts.keepStaleFragments, } // Remember, updateInternalLinks() doesn't actually change the files diff --git a/src/links/tests/cross-page-anchors.ts b/src/links/tests/cross-page-anchors.ts new file mode 100644 index 000000000000..15d34b5cc057 --- /dev/null +++ b/src/links/tests/cross-page-anchors.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'vitest' + +import { validateCrossPageAnchors, type PendingCrossPageAnchor } from '../lib/cross-page-anchors' + +function anchor(overrides: Partial = {}): PendingCrossPageAnchor { + return { + targetKey: '/en/some/target', + fragment: 'a-heading', + href: '/some/target#a-heading', + file: 'content/source.md', + line: 10, + text: 'link text', + ...overrides, + } +} + +describe('validateCrossPageAnchors (pass 2)', () => { + test('returns nothing when the fragment exists on the target page', () => { + const cache = new Map([['/en/some/target', new Set(['a-heading', 'other'])]]) + expect(validateCrossPageAnchors([anchor()], cache)).toEqual([]) + }) + + test('flags a link whose fragment is missing from the target page', () => { + const cache = new Map([['/en/some/target', new Set(['other-heading'])]]) + const broken = validateCrossPageAnchors([anchor()], cache) + expect(broken).toEqual([ + { href: '/some/target#a-heading', file: 'content/source.md', lines: [10], text: 'link text' }, + ]) + }) + + test('skips (does not flag) a target with no cache entry', () => { + // A missing cache entry means the target resolved to a version not covered by this + // run, or is an autogenerated page — either way it is validated elsewhere / at runtime. + const emptyCache = new Map>() + expect(validateCrossPageAnchors([anchor()], emptyCache)).toEqual([]) + }) + + test('evaluates each anchor independently against its own target', () => { + const cache = new Map([ + ['/en/page-a', new Set(['present'])], + ['/en/page-b', new Set(['also-present'])], + // /en/page-c intentionally absent from the cache + ]) + const pending = [ + anchor({ targetKey: '/en/page-a', fragment: 'present', href: '/page-a#present' }), + anchor({ targetKey: '/en/page-a', fragment: 'missing', href: '/page-a#missing', line: 20 }), + anchor({ targetKey: '/en/page-b', fragment: 'also-present', href: '/page-b#also-present' }), + anchor({ targetKey: '/en/page-c', fragment: 'whatever', href: '/page-c#whatever', line: 30 }), + ] + const broken = validateCrossPageAnchors(pending, cache) + expect(broken).toEqual([ + { href: '/page-a#missing', file: 'content/source.md', lines: [20], text: 'link text' }, + ]) + }) + + test('returns nothing for an empty pending list', () => { + expect(validateCrossPageAnchors([], new Map())).toEqual([]) + }) + + test('does not flag a cross-page #top link', () => { + // Mirrors the same-page anchor exemption for `#` / `#top`. Browsers scroll to the + // top of the document for `#top` when nothing carries that ID, so it is always + // valid even though it never appears in the target's computed heading IDs. + const cache = new Map([['/en/some/target', new Set(['a-heading'])]]) + const pending = [ + anchor({ fragment: 'top', href: '/some/target#top' }), + anchor({ fragment: 'missing', href: '/some/target#missing', line: 20 }), + ] + expect(validateCrossPageAnchors(pending, cache)).toEqual([ + { href: '/some/target#missing', file: 'content/source.md', lines: [20], text: 'link text' }, + ]) + }) +}) diff --git a/src/links/tests/extract-links.ts b/src/links/tests/extract-links.ts index 03063fbcc8c6..638aad86aae0 100644 --- a/src/links/tests/extract-links.ts +++ b/src/links/tests/extract-links.ts @@ -3,6 +3,7 @@ import { extractLinksFromMarkdown, normalizeLinkPath, checkInternalLink, + resolveInternalLinkKey, checkAssetLink, isAssetLink, } from '../lib/extract-links' @@ -561,6 +562,40 @@ describe('checkInternalLink', () => { }) }) +describe('resolveInternalLinkKey', () => { + const pageMap = { + '/en/actions/getting-started': {} as unknown as Page, + '/actions/guides': {} as unknown as Page, + [`/en/enterprise-server@${latestStable}/admin/overview`]: {} as unknown as Page, + } + + test('resolves a direct pageMap key', () => { + expect(resolveInternalLinkKey('/actions/guides', pageMap)).toBe('/actions/guides') + }) + + test('resolves a language-prefixed page from a bare path', () => { + expect(resolveInternalLinkKey('/actions/getting-started', pageMap)).toBe( + '/en/actions/getting-started', + ) + }) + + test('ignores query strings and fragments when resolving', () => { + expect(resolveInternalLinkKey('/actions/guides?foo=1#some-anchor', pageMap)).toBe( + '/actions/guides', + ) + }) + + test('normalizes enterprise-server@latest to the latest stable release', () => { + expect(resolveInternalLinkKey('/enterprise-server@latest/admin/overview', pageMap)).toBe( + `/en/enterprise-server@${latestStable}/admin/overview`, + ) + }) + + test('returns null when the path does not resolve directly to a page', () => { + expect(resolveInternalLinkKey('/does/not/exist', pageMap)).toBeNull() + }) +}) + describe('isAssetLink', () => { test('returns true for asset paths', () => { expect(isAssetLink('/assets/images/help/test.png')).toBe(true) diff --git a/src/links/tests/heading-anchors.ts b/src/links/tests/heading-anchors.ts new file mode 100644 index 000000000000..f67a7c94565f --- /dev/null +++ b/src/links/tests/heading-anchors.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'vitest' + +import { computeHeadingIds, headingTextToPlain } from '../lib/heading-anchors' + +describe('headingTextToPlain', () => { + test('leaves plain text unchanged', () => { + expect(headingTextToPlain('Configuring proxy settings')).toBe('Configuring proxy settings') + }) + + test('strips inline markdown formatting', () => { + expect(headingTextToPlain('**Bold** and *italic* text')).toBe('Bold and italic text') + expect(headingTextToPlain('__Bold__ and _italic_ text')).toBe('Bold and italic text') + }) + + test('extracts link and image text', () => { + expect(headingTextToPlain('See [the guide](/actions) here')).toBe('See the guide here') + expect(headingTextToPlain('![alt text](/img.png) caption')).toBe('alt text caption') + }) + + test('preserves inline code spans verbatim, including angle-bracket placeholders', () => { + expect(headingTextToPlain('Use `` in your workflow')).toBe( + 'Use in your workflow', + ) + }) + + test('strips HTML/octicon tags but keeps underscore placeholders outside code', () => { + expect(headingTextToPlain('Allow ')).toBe('Allow ') + expect(headingTextToPlain('Set here')).toBe('Set job_id here') + }) +}) + +describe('computeHeadingIds', () => { + test('slugs ATX headings', () => { + const ids = computeHeadingIds( + '## Configuring proxy settings for Copilot\n\ntext\n\n### Usage limits and policy', + ) + expect(ids.has('configuring-proxy-settings-for-copilot')).toBe(true) + expect(ids.has('usage-limits-and-policy')).toBe(true) + }) + + test('slugs Setext headings', () => { + const ids = computeHeadingIds('Introduction\n============\n\nDetails\n-------') + expect(ids.has('introduction')).toBe(true) + expect(ids.has('details')).toBe(true) + }) + + test('dedupes repeated headings with github-slugger suffixes', () => { + const ids = computeHeadingIds('## Overview\n\n## Overview') + expect(ids.has('overview')).toBe(true) + expect(ids.has('overview-1')).toBe(true) + }) + + test('captures explicit / anchors', () => { + const ids = computeHeadingIds('\n\n') + expect(ids.has('legacy-anchor')).toBe(true) + expect(ids.has('another-anchor')).toBe(true) + }) + + test('reproduces Liquid-rendered heading with variable already expanded', () => { + // After Liquid render, the variable is expanded to plain text; the full slug includes it. + const ids = computeHeadingIds( + '## Disabling or enabling Copilot coding agent in your repositories', + ) + expect(ids.has('disabling-or-enabling-copilot-coding-agent-in-your-repositories')).toBe(true) + }) +}) diff --git a/src/links/tests/validate-redirected-fragment.ts b/src/links/tests/validate-redirected-fragment.ts new file mode 100644 index 000000000000..c1e68f4ff16e --- /dev/null +++ b/src/links/tests/validate-redirected-fragment.ts @@ -0,0 +1,159 @@ +import { describe, expect, test, vi } from 'vitest' + +import { RedirectedFragmentValidator } from '../lib/validate-redirected-fragment' +import { renderMarkdownLiquid } from '@/links/lib/extract-links' +import type { Page } from '@/types' + +// headingIdsFor renders the destination page's Liquid and warms the server to do it. Stub +// both so the render-failure path can be exercised without loading the site. +vi.mock('@/links/lib/extract-links', () => ({ + renderMarkdownLiquid: vi.fn(), +})) +vi.mock('@/frame/lib/warm-server', () => ({ + default: vi.fn(async () => ({})), +})) + +// A test double that overrides the (expensive, render-based) heading lookup with a fixed +// map, so the keep/drop/mixed/unvalidatable decision logic can be exercised in isolation. +class StubValidator extends RedirectedFragmentValidator { + constructor(private idsByKey: Record | null>) { + super({}, {}, 'en') + } + protected async headingIdsFor(page: Page, version: string): Promise | null> { + const key = `${version}|${page.relativePath}` + return key in this.idsByKey ? this.idsByKey[key] : new Set() + } +} + +function fakePage(overrides: Partial): Page { + return { + relativePath: 'content/foo.md', + applicableVersions: ['free-pro-team@latest'], + markdown: '', + ...overrides, + } as unknown as Page +} + +describe('RedirectedFragmentValidator.classify', () => { + test('keep: anchor exists in every applicable version', async () => { + const page = fakePage({ + applicableVersions: ['free-pro-team@latest', 'enterprise-cloud@latest'], + }) + const validator = new StubValidator({ + 'free-pro-team@latest|content/foo.md': new Set(['intro', 'setup']), + 'enterprise-cloud@latest|content/foo.md': new Set(['intro', 'setup']), + }) + expect(await validator.classify(page, '#setup')).toBe('keep') + }) + + test('drop: anchor is absent from every applicable version', async () => { + const page = fakePage({ + applicableVersions: ['free-pro-team@latest', 'enterprise-cloud@latest'], + }) + const validator = new StubValidator({ + 'free-pro-team@latest|content/foo.md': new Set(['intro']), + 'enterprise-cloud@latest|content/foo.md': new Set(['intro']), + }) + expect(await validator.classify(page, '#setup')).toBe('drop') + }) + + test('mixed: anchor exists in some but not all versions', async () => { + const page = fakePage({ + applicableVersions: ['free-pro-team@latest', 'enterprise-cloud@latest'], + }) + const validator = new StubValidator({ + 'free-pro-team@latest|content/foo.md': new Set(['intro', 'setup']), + 'enterprise-cloud@latest|content/foo.md': new Set(['intro']), + }) + expect(await validator.classify(page, '#setup')).toBe('mixed') + }) + + test('unvalidatable: no destination page', async () => { + const validator = new StubValidator({}) + expect(await validator.classify(undefined, '#setup')).toBe('unvalidatable') + }) + + test('unvalidatable: autogenerated destination page', async () => { + const page = fakePage({ autogenerated: 'rest' }) + const validator = new StubValidator({ + 'free-pro-team@latest|content/foo.md': new Set(['intro']), + }) + expect(await validator.classify(page, '#setup')).toBe('unvalidatable') + }) + + test('unvalidatable: destination has no applicable versions', async () => { + const page = fakePage({ applicableVersions: [] }) + const validator = new StubValidator({}) + expect(await validator.classify(page, '#setup')).toBe('unvalidatable') + }) + + test('unvalidatable: a version fails to render (never risk a destructive drop)', async () => { + const page = fakePage({ + applicableVersions: ['free-pro-team@latest', 'enterprise-cloud@latest'], + }) + const validator = new StubValidator({ + 'free-pro-team@latest|content/foo.md': new Set(['intro']), + // null models a render failure — must not lead to a drop. + 'enterprise-cloud@latest|content/foo.md': null, + }) + expect(await validator.classify(page, '#setup')).toBe('unvalidatable') + }) + + test('keep: empty fragment is a no-op', async () => { + const page = fakePage({}) + const validator = new StubValidator({ + 'free-pro-team@latest|content/foo.md': new Set(), + }) + expect(await validator.classify(page, '#')).toBe('keep') + }) + + test('keep: #top is always valid', async () => { + // Browsers scroll to the top of the document for `#top` when nothing carries that ID, + // so it never appears in computed heading IDs and must not be treated as stale. + const page = fakePage({}) + const validator = new StubValidator({ + 'free-pro-team@latest|content/foo.md': new Set(['intro']), + }) + expect(await validator.classify(page, '#top')).toBe('keep') + }) + + test('handles a fragment passed without the leading hash', async () => { + const page = fakePage({}) + const validator = new StubValidator({ + 'free-pro-team@latest|content/foo.md': new Set(['setup']), + }) + expect(await validator.classify(page, 'setup')).toBe('keep') + }) +}) + +describe('RedirectedFragmentValidator.headingIdsFor render failures', () => { + test('unvalidatable, not drop, when the destination fails to render', async () => { + // Regression guard: renderAndExtractLinks swallows Liquid failures and returns the raw + // markdown, so heading IDs would be computed from unrendered `{% data %}` expressions. + // Those never match a real anchor, so classify() would return 'drop' and destructively + // delete a valid fragment. headingIdsFor must use the throwing renderer instead. + vi.mocked(renderMarkdownLiquid).mockRejectedValueOnce(new Error('Liquid syntax error')) + + const page = fakePage({ + relativePath: 'content/bar.md', + applicableVersions: ['free-pro-team@latest'], + markdown: '## {% data variables.product.prodname_dotcom %} settings\n', + }) + const validator = new RedirectedFragmentValidator({}, {}, 'en') + + expect(await validator.classify(page, '#github-settings')).toBe('unvalidatable') + }) + + test('drop still works when the destination renders cleanly', async () => { + vi.mocked(renderMarkdownLiquid).mockResolvedValueOnce('## Intro\n') + + const page = fakePage({ + relativePath: 'content/baz.md', + applicableVersions: ['free-pro-team@latest'], + markdown: '## Intro\n', + }) + const validator = new RedirectedFragmentValidator({}, {}, 'en') + + expect(await validator.classify(page, '#setup')).toBe('drop') + }) +})