diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index acbfe33253b2..7f448c4dbbd0 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ # Update ARG and FROM together from the Node devcontainer image tags: # https://mcr.microsoft.com/en-us/product/devcontainers/javascript-node/tags ARG VARIANT=dev-24-bookworm -FROM mcr.microsoft.com/devcontainers/javascript-node:dev-24-bookworm@sha256:5ed0bf1411cffe8182ee6808477dd64c969a4ce78fee1b63e6a654d7deff889c +FROM mcr.microsoft.com/devcontainers/javascript-node:dev-24-bookworm@sha256:d0445b9546f96869b509aa70b8593bdc0054aca6689bc1dd0324083d0fd2bcc3 diff --git a/.github/actions/labeler/labeler.ts b/.github/actions/labeler/labeler.ts index 3f7589c2653c..9a0731a6b4ad 100644 --- a/.github/actions/labeler/labeler.ts +++ b/.github/actions/labeler/labeler.ts @@ -31,17 +31,8 @@ if (import.meta.url.endsWith(process.argv[1])) { ignoreIfLabeled: boolEnvVar('IGNORE_IF_LABELED'), } - // Actions pass comma-separated labels. - if (typeof ADD_LABELS === 'string') { - opts.addLabels = [...ADD_LABELS.split(',')].map((l) => l.trim()) - } else { - opts.addLabels = [] - } - if (typeof REMOVE_LABELS === 'string') { - opts.removeLabels = [...REMOVE_LABELS.split(',')].map((l) => l.trim()) - } else { - opts.removeLabels = [] - } + opts.addLabels = parseLabels(ADD_LABELS) + opts.removeLabels = parseLabels(REMOVE_LABELS) const actionContext = getActionContext() const { owner, repo } = actionContext @@ -58,6 +49,14 @@ if (import.meta.url.endsWith(process.argv[1])) { main(coreLib, octokit, opts) } +// Actions pass comma-separated labels, and an omitted input arrives as an empty string. +export function parseLabels(value?: string): string[] { + return (value || '') + .split(',') + .map((l) => l.trim()) + .filter(Boolean) +} + export default async function main( core: typeof coreLib | CoreInject, octokit: Octokit, diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b44f27721859..2ad74327f53f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,7 +23,9 @@ updates: ['version-update:semver-patch', 'version-update:semver-minor'] - package-ecosystem: 'github-actions' - directory: '/' + directories: + - '/' + - '/.github/actions/*' schedule: interval: weekly day: tuesday diff --git a/.github/workflows/auto-close-dependencies.yml b/.github/workflows/auto-close-dependencies.yml index a5fb729cbf88..3838dd684e25 100644 --- a/.github/workflows/auto-close-dependencies.yml +++ b/.github/workflows/auto-close-dependencies.yml @@ -9,6 +9,7 @@ on: - 'Gemfile*' - 'Dockerfile' - '.github/workflows/**' + - '.github/actions/**' permissions: contents: write diff --git a/.github/workflows/changelog-agent.yml b/.github/workflows/changelog-agent.yml index acf2240f0de7..1d02b16ef51d 100644 --- a/.github/workflows/changelog-agent.yml +++ b/.github/workflows/changelog-agent.yml @@ -227,10 +227,12 @@ jobs: const prNumber = parseInt('${{ steps.resolve_pr.outputs.pr_number }}', 10); const prAuthor = '${{ steps.resolve_pr.outputs.pr_author }}'; - const { data: reviews } = await github.rest.pulls.listReviews({ + // Approvals often come after many comment reviews, so read every page + const reviews = await github.paginate(github.rest.pulls.listReviews, { owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, + per_page: 100, }); const approvedReviewers = [...new Set( diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5c31ec7b3c0a..641d523fbdd2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -32,7 +32,9 @@ jobs: with: languages: javascript - uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7 - continue-on-error: true + # Pull request scans stay non-blocking so analysis errors do not hold up merges. + # Push runs must fail so the alert steps below fire. + continue-on-error: ${{ github.event_name == 'pull_request' }} - uses: ./.github/actions/create-workflow-failure-issue id: create-failure-issue diff --git a/.github/workflows/confirm-internal-staff-work-in-docs.yml b/.github/workflows/confirm-internal-staff-work-in-docs.yml index ba37bf449f4f..82355ca682a1 100644 --- a/.github/workflows/confirm-internal-staff-work-in-docs.yml +++ b/.github/workflows/confirm-internal-staff-work-in-docs.yml @@ -4,9 +4,9 @@ name: Confirm internal staff meant to post in public on: issues: + # Issues moved into github/docs arrive as opened. The transferred event fires in the source repo. types: - opened - - transferred # pull_request_target lets the workflow check org membership for forked PR authors. pull_request_target: types: diff --git a/.github/workflows/dont-delete-assets.yml b/.github/workflows/dont-delete-assets.yml index 3d10e2c54fd1..20d7b234cca2 100644 --- a/.github/workflows/dont-delete-assets.yml +++ b/.github/workflows/dont-delete-assets.yml @@ -44,6 +44,8 @@ jobs: - name: Update comment if: ${{ steps.comment.outputs.markdown != '' }} + # Fork PRs get a read-only token, so posting fails there. The next step still shows the instructions. + continue-on-error: true uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 with: comment-id: ${{ steps.findComment.outputs.comment-id }} @@ -53,7 +55,11 @@ jobs: - name: Ultimately fail the workflow for attention if: ${{ steps.comment.outputs.markdown != '' }} + env: + MARKDOWN: ${{ steps.comment.outputs.markdown }} run: | echo "More than 1 asset image was deleted as part of this PR." - echo "See posted PR commented about how to get them back." + echo "See the PR comment or this job's summary for how to get them back." + echo "$MARKDOWN" >> "$GITHUB_STEP_SUMMARY" + echo "$MARKDOWN" exit 1 diff --git a/.github/workflows/dont-delete-features.yml b/.github/workflows/dont-delete-features.yml index 172ba48603e8..7dac3139490d 100644 --- a/.github/workflows/dont-delete-features.yml +++ b/.github/workflows/dont-delete-features.yml @@ -42,6 +42,8 @@ jobs: - name: Update comment if: ${{ steps.comment.outputs.markdown != '' }} + # Fork PRs get a read-only token, so posting fails there. The next step still shows the instructions. + continue-on-error: true uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 with: comment-id: ${{ steps.findComment.outputs.comment-id }} @@ -51,7 +53,11 @@ jobs: - name: Ultimately fail the workflow for attention if: ${{ steps.comment.outputs.markdown != '' }} + env: + MARKDOWN: ${{ steps.comment.outputs.markdown }} run: | echo "This PR deletes or renames one or more feature files." - echo "See the PR comment for how to get them back." + echo "See the PR comment or this job's summary for how to get them back." + echo "$MARKDOWN" >> "$GITHUB_STEP_SUMMARY" + echo "$MARKDOWN" exit 1 diff --git a/.github/workflows/generate-code-scanning-query-lists.yml b/.github/workflows/generate-code-scanning-query-lists.yml index d1c447c287fc..ec26ed132a45 100644 --- a/.github/workflows/generate-code-scanning-query-lists.yml +++ b/.github/workflows/generate-code-scanning-query-lists.yml @@ -152,6 +152,11 @@ jobs: create-pull-request: if: github.repository == 'github/docs-internal' runs-on: ubuntu-latest + # Serialize publishing so a second run sees the first run's PR before it force-pushes. + # Pull request dry runs get their own group so they never queue behind or cancel a publisher. + concurrency: + group: ${{ github.workflow }}-query-table-publication-${{ github.event_name == 'pull_request' && github.run_id || 'publish' }} + cancel-in-progress: false permissions: contents: write pull-requests: write @@ -216,9 +221,6 @@ jobs: branchname=codeql-query-tables-${{ steps.codeql.outputs.OPENAPI_COMMIT_SHA }} - # Query the remote because actions/checkout with fetch-depth 1 omits other remote-tracking branches. - branchExists=$(git ls-remote --heads origin refs/heads/$branchname | wc -l) - # Pull request runs validate generated files without pushing branches. if [ "$DRY_RUN" = "true" ]; then echo "Dry-run mode when run in a pull request" @@ -226,17 +228,20 @@ jobs: exit 0 fi - if [ $branchExists -ne 0 ]; then - echo "Branch $branchname already exists in the remote repository." + # Check for an open PR before pushing so reruns do not overwrite commits added to it. + existing_pr=$(gh pr list --repo github/docs-internal --head "$branchname" --json number --jq '.[0].number') + if [[ -n "$existing_pr" ]]; then + echo "Pull request #$existing_pr already exists for branch $branchname. Skipping PR creation." exit 0 - else - git checkout -b $branchname fi + git checkout -b $branchname git add data/reusables/code-scanning/codeql-query-tables git add data/reusables/code-quality/codeql-query-tables git commit -m "Update CodeQL query tables" - git push -u origin $branchname + + # Force-push over a branch left by a failed attempt; this workflow exclusively owns these branches. + git push --force -u origin $branchname echo "Creating pull request..." gh pr create \ diff --git a/.github/workflows/index-general-search.yml b/.github/workflows/index-general-search.yml index 99a0261f6af8..6ec3b423bf42 100644 --- a/.github/workflows/index-general-search.yml +++ b/.github/workflows/index-general-search.yml @@ -203,12 +203,6 @@ jobs: curl --retry-connrefused --retry 5 ${{ env.ELASTICSEARCH_URL }}/_cat/indices?v curl --retry-connrefused --retry 5 ${{ env.ELASTICSEARCH_URL }}/_cat/indices?v - - name: Purge Fastly edge cache - env: - FASTLY_TOKEN: ${{ secrets.FASTLY_TOKEN }} - FASTLY_SERVICE_ID: ${{ secrets.FASTLY_SERVICE_ID }} - run: npm run purge-fastly -- --surrogate-key api-search:${{ matrix.language }} - - name: Upload failures artifact if: ${{ steps.check-failures.outputs.has_failures == 'true' }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 diff --git a/.github/workflows/link-check-external.yml b/.github/workflows/link-check-external.yml index 376f6c8e5d5c..a72ff5e52650 100644 --- a/.github/workflows/link-check-external.yml +++ b/.github/workflows/link-check-external.yml @@ -34,6 +34,8 @@ jobs: env: ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} CACHE_MAX_AGE_DAYS: '7' + # The script checks github.com repo links through the REST API only when this is set. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | if [[ -n "${{ inputs.max_urls }}" ]]; then npm run check-links-external -- --max ${{ inputs.max_urls }} diff --git a/.github/workflows/link-check-internal.yml b/.github/workflows/link-check-internal.yml index fb14cf9115de..240d35fd9c20 100644 --- a/.github/workflows/link-check-internal.yml +++ b/.github/workflows/link-check-internal.yml @@ -124,20 +124,23 @@ jobs: } const report = JSON.parse(fs.readFileSync(reportFile, 'utf8')) - const allRedirectGroups = report.groups.filter(g => g.isWarning) + // Raw redirect targets can carry the checked version's prefix; replacementTarget does not. + // Skip strings that could break out of the inline code spans, since directives must stay exact. + const isSafe = s => typeof s === 'string' && s.length > 0 && !/[`\r\n]/.test(s) + const allRedirectGroups = report.groups.filter(g => + g.isWarning && g.fixStrategy === 'codemod' && + isSafe(g.target) && isSafe(g.replacementTarget) && isSafe(g.occurrences[0]?.file)) const redirectGroups = allRedirectGroups.slice(0, 10) if (redirectGroups.length === 0) { - core.info('No redirect groups found, skipping Copilot issue.') + core.info('No renamed redirects found, skipping Copilot issue.') return } const directives = redirectGroups.map(g => { const occ = g.occurrences[0] - const redirectTarget = occ?.redirectTarget ?? 'unknown' - const file = occ?.file ?? 'unknown' - const lines = (occ?.lines ?? []).join(', ') || 'unknown' - return `- Update \`${g.target}\` to \`${redirectTarget}\` in \`${file}\` (line(s): ${lines})` + const lines = (occ.lines ?? []).filter(Number.isInteger).join(', ') || 'unknown' + return `- Update \`${g.target}\` to \`${g.replacementTarget}\` in \`${occ.file}\` (line(s): ${lines})` }).join('\n') const artifactsUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts` diff --git a/.github/workflows/move-content.yml b/.github/workflows/move-content.yml index 0cf5ba6d4035..b0a817c3b0c7 100644 --- a/.github/workflows/move-content.yml +++ b/.github/workflows/move-content.yml @@ -6,13 +6,13 @@ on: pull_request: paths: - src/content-render/scripts/move-content.ts - - src/content-render/scripts/test-move-content.ts - - 'src/frame/lib/**/*.js' + - src/content-render/scripts/test-moved-content.ts + - 'src/frame/lib/**/*.ts' - .github/workflows/move-content.yml # Dependency changes can break the move-content script. - 'package*.json' - - src/fixtures/fixtures/content/get-started/ - - src/fixtures/fixtures/content/code-security/ + - 'src/fixtures/fixtures/content/get-started/**' + - 'src/fixtures/fixtures/content/code-security/**' permissions: contents: read @@ -50,8 +50,9 @@ jobs: env: ROOT: src/fixtures/fixtures run: | + # The trailing slash on the old path matches what shell tab completion adds. npm run move-content -- \ - src/fixtures/fixtures/content/code-security/getting-started \ + src/fixtures/fixtures/content/code-security/getting-started/ \ src/fixtures/fixtures/content/code-security/got-started npm run test-moved-content -- \ diff --git a/.github/workflows/no-response.yaml b/.github/workflows/no-response.yaml index bffeb590ad4e..2049c4106b35 100644 --- a/.github/workflows/no-response.yaml +++ b/.github/workflows/no-response.yaml @@ -20,11 +20,13 @@ jobs: runs-on: ubuntu-latest # Only run in the public repo, and skip automated comments. # Bot-authored issue_comment events are workflow chatter, not author responses. + # Scheduled runs often have a bot actor, so the actor check skips only comments. if: >- github.repository == 'github/docs' && - github.actor != 'docs-bot' && + (github.event_name != 'issue_comment' || + (github.actor != 'docs-bot' && github.actor != 'github-actions' && - !endsWith(github.actor, '[bot]') + !endsWith(github.actor, '[bot]'))) steps: - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 id: stale diff --git a/.github/workflows/notify-about-deployment.yml b/.github/workflows/notify-about-deployment.yml index c025debd6f09..dc21174c3ab9 100644 --- a/.github/workflows/notify-about-deployment.yml +++ b/.github/workflows/notify-about-deployment.yml @@ -37,6 +37,8 @@ jobs: timeout-minutes: 5 env: GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} + # Manual Purge Fastly runs carry no deploy, so the script reads /_build instead. + DEPLOY_SHA: ${{ github.event.workflow_run.event == 'deployment_status' && github.event.workflow_run.head_sha || '' }} run: npm run find-past-built-pr - uses: ./.github/actions/create-workflow-failure-issue diff --git a/.github/workflows/orphaned-files-check.yml b/.github/workflows/orphaned-files-check.yml index 5cbbf71b4a2b..b45c33477f19 100644 --- a/.github/workflows/orphaned-files-check.yml +++ b/.github/workflows/orphaned-files-check.yml @@ -9,15 +9,17 @@ on: - cron: '20 16 * * 1' pull_request: paths: - - .github/workflows/orphaned-assets-check.yml - .github/workflows/orphaned-files-check.yml # Dependency changes can break the orphaned-files scripts. - 'package*.json' - src/assets/scripts/find-orphaned-assets.ts - - src/content-render/scripts/reusables-cli/find/unused.ts + - src/content-render/scripts/reusables-cli.ts + - 'src/content-render/scripts/reusables-cli/**' + - src/content-linter/lib/helpers/liquid-utils.ts - src/data-directory/scripts/find-orphaned-tables.ts - src/workflows/walk-files.ts - src/languages/lib/languages.ts + - src/languages/lib/languages-server.ts - .github/actions/clone-translations/action.yml - .github/actions/node-npm-setup/action.yml @@ -53,7 +55,9 @@ jobs: # The -s flag keeps npm script-alias noise out of the file lists. assetFilesToRemove=$(npm run -s find-orphaned-assets) - reusableFilesToRemove=$(npm run -s reusables -- find unused | grep '^data/reusables' || true) + # Keep the CLI out of the pipeline so set -e catches its failures; grep may match nothing. + reusablesOutput=$(npm run -s reusables -- find unused) + reusableFilesToRemove=$(printf '%s\n' "$reusablesOutput" | grep '^data/reusables' || true) tableFilesToRemove=$(npm run -s find-orphaned-tables) [ -z "$assetFilesToRemove" ] && [ -z "$reusableFilesToRemove" ] && [ -z "$tableFilesToRemove" ] && exit 0 diff --git a/.github/workflows/reviewers-docs-engineering.yml b/.github/workflows/reviewers-docs-engineering.yml index d253c2553af4..4424239f8a41 100644 --- a/.github/workflows/reviewers-docs-engineering.yml +++ b/.github/workflows/reviewers-docs-engineering.yml @@ -61,12 +61,19 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} CHANGED_FILES: ${{ github.event.pull_request.changed_files }} REPO: ${{ github.repository }} + EVENT_ACTION: ${{ github.event.action }} run: | if [ "$PR_AUTHOR" = "dependabot[bot]" ] || [ "$PR_AUTHOR" = "docs-bot" ]; then echo "Author is $PR_AUTHOR; skipping lockfile churn detection." echo "lockfile_only=false" >> "$GITHUB_OUTPUT" exit 0 fi + # Removing the lockfile-churn-only label and reopening the PR is the documented override. + if [ "$EVENT_ACTION" = "reopened" ]; then + echo "PR reopened; skipping lockfile churn detection." + echo "lockfile_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi # `gh pr diff` fails above 300 files, and `gh pr view --json files` stops at 100. changed=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename') echo "Changed files:" @@ -101,7 +108,7 @@ jobs: git push ``` - If the lockfile change is intentional, remove the `lockfile-churn-only` label and push or reopen the PR so this workflow sends it to the engineering review board. + If the lockfile change is intentional, remove the `lockfile-churn-only` label, then close and reopen the PR so this workflow sends it to the engineering review board. EOF gh pr comment "$PR" --body-file /tmp/lockfile-churn-body.md gh pr edit "$PR" --add-label lockfile-churn-only diff --git a/.github/workflows/send-to-triage-board.yml b/.github/workflows/send-to-triage-board.yml index d5bda9b77e7f..defd550489bb 100644 --- a/.github/workflows/send-to-triage-board.yml +++ b/.github/workflows/send-to-triage-board.yml @@ -2,6 +2,7 @@ name: Add new issues and PRs to central triage board # Adds new, reopened, and ready-for-review github/docs work to the Central Triage Group board. # Sets today's date so first responders can triage it. +# Skips PRs opened by docs-bot, such as Repo sync PRs, because they never need triage. on: issues: @@ -15,7 +16,7 @@ permissions: jobs: add-to-central-triage: runs-on: ubuntu-latest - if: github.repository == 'github/docs' + if: github.repository == 'github/docs' && github.event.pull_request.user.login != 'docs-bot' steps: - name: Triage to central triage board env: diff --git a/.github/workflows/triage-stale-check.yml b/.github/workflows/triage-stale-check.yml index 83a8d585cff7..b04ce765881d 100644 --- a/.github/workflows/triage-stale-check.yml +++ b/.github/workflows/triage-stale-check.yml @@ -26,11 +26,11 @@ jobs: days-before-close: 7 stale-issue-message: 'A stale label has been added to this issue, because it has been open for 30 days with no activity. If you think this issue should remain open, please add a new comment.' - exempt-issue-labels: 'help wanted,never-stale,waiting for review' + exempt-issue-labels: 'help wanted,never-stale,waiting for review,needs SME' stale-issue-label: 'Inactive' close-issue-label: 'Closed as inactive' - exempt-pr-labels: 'never-stale,ready to merge,waiting for review' + exempt-pr-labels: 'never-stale,ready to merge,waiting for review,needs SME' stale-pr-message: 'A stale label has been added to this pull request because it has been open 30 days with no activity. If you think this pull request should remain open, please add a new comment.' stale-pr-label: 'Inactive' close-pr-label: 'Closed as inactive' diff --git a/.github/workflows/validate-openapi-check.yml b/.github/workflows/validate-openapi-check.yml index aa0edd90bef4..3207a167c1bc 100644 --- a/.github/workflows/validate-openapi-check.yml +++ b/.github/workflows/validate-openapi-check.yml @@ -8,8 +8,9 @@ on: paths: - 'Dockerfile.openapi_decorator' - 'src/rest/scripts/openapi-check.ts' - - 'src/rest/scripts/utils/get-operations.ts' - - 'src/rest/scripts/utils/operation.ts' + # The check runs operation.ts and the helpers it imports. + - 'src/rest/scripts/utils/*.ts' + - 'src/tests/lib/validate-json-schema.ts' - 'src/rest/fixtures/openapi-check-fixture.json' - 'package.json' - 'package-lock.json' diff --git a/.gitignore b/.gitignore index 326e03a9547a..9e7e1629deb0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ # Node.js version specification .node-version +.nvmrc # getRemoteJSON() disk cache for archived content .remotejson-cache/ diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3ef7444d06..9c9c8b74de90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,7 +129,7 @@ LSP servers greatly improve Copilot CLI's ability to work with your code. For ex We've added documentation to explain LSP servers in greater detail and show how to install these for Copilot CLI: -* [Using LSP servers with GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/copilot-cli/lsp-servers) +* [Using LSP servers with GitHub Copilot CLI](https://docs.github.com/copilot/concepts/customizations/lsp-servers) * [Adding LSP servers for GitHub Copilot CLI](https://docs.github.com/copilot/how-tos/copilot-cli/set-up-copilot-cli/add-lsp-servers)
') && html.endsWith('
')) { - return fastTextOnly(html.slice(3, -4)) - } - return fastTextOnly(html) - }), - })) + changes + // Older changelog data has schema groups with no changes. + .filter((change) => change.changes.length > 0) + .map((change) => ({ + title: change.title, + changes: change.changes.map((html: string) => { + if (html.startsWith('') && html.endsWith('
')) { + return fastTextOnly(html.slice(3, -4)) + } + return fastTextOnly(html) + }), + })) return { date: item.date, diff --git a/src/article-api/transformers/graphql-reference-transformer.ts b/src/article-api/transformers/graphql-reference-transformer.ts index 5425f179e345..8335f91b0ab5 100644 --- a/src/article-api/transformers/graphql-reference-transformer.ts +++ b/src/article-api/transformers/graphql-reference-transformer.ts @@ -173,6 +173,7 @@ export class GraphQLReferenceTransformer implements PageTransformer { type: arg.type, href: arg.href, description: arg.description ? fastTextOnly(arg.description) : '', + defaultValue: arg.defaultValue, })), } } diff --git a/src/article-api/transformers/release-notes-transformer.ts b/src/article-api/transformers/release-notes-transformer.ts index fca2dc76d612..498cd7624254 100644 --- a/src/article-api/transformers/release-notes-transformer.ts +++ b/src/article-api/transformers/release-notes-transformer.ts @@ -50,8 +50,14 @@ const SECTION_LABELS: Record{item === null ? null : item}
+
+ {item === null ? (
+ null
+ ) : typeof item === 'string' ? (
+ item
+ ) : (
+ // React renders nothing for boolean children.
+ JSON.stringify(item)
+ )}
+
{index !== array.length - 1 && ','}{' '}
))}
@@ -127,9 +125,10 @@ export function ParameterRow({
/>
)}
- {/* Empty child groups mark unloaded nested params except shared webhook props; details lazy-loads them. */}
- {rowParams.type &&
- (rowParams.type.includes('object') || rowParams.type.includes('array of')) &&
+ {/* Empty child groups mark unloaded webhook objects except shared props; only rows with a callback can load them. */}
+ {bodyParamExpandCallback &&
+ rowParams.type &&
+ rowParams.type.includes('object') &&
rowParams.childParamsGroups &&
rowParams.childParamsGroups.length === 0 &&
!NO_CHILD_WEBHOOK_PROPERTIES.includes(rowParams.name) ? (
@@ -139,10 +138,7 @@ export function ParameterRow({
data-nested-param-id={rowParams.name}
className="box px-3 ml-1 mb-0"
onToggle={(event) => {
- if (bodyParamExpandCallback) {
- const target = event.target as HTMLDetailsElement
- bodyParamExpandCallback(target)
- }
+ bodyParamExpandCallback(event.target as HTMLDetailsElement)
}}
>
true')
+ })
+
+ test('renders false', () => {
+ expect(renderEnum([false])).toContain('false')
+ })
+
+ test('renders strings without quotes', () => {
+ const html = renderEnum(['open', 'closed'])
+ expect(html).toContain('open')
+ expect(html).toContain('closed')
+ })
+
+ test('renders null in italics', () => {
+ expect(renderEnum([null])).toContain('null')
+ })
+})
diff --git a/src/codeql-cli/README.md b/src/codeql-cli/README.md
index dac5c28c25ba..561950465a59 100644
--- a/src/codeql-cli/README.md
+++ b/src/codeql-cli/README.md
@@ -55,7 +55,7 @@ If you have a question about the CodeQL CLI pipeline, you can ask in the `#techn
|------------|---------|--------------|-------|
| **Pandoc** | Convert ReStructuredText to Markdown | `brew install pandoc` (macOS) | Version 2.x+ required |
| **semmle-code repo** | Source ReStructuredText files | Clone at root of docs-internal | Private GitHub repo |
-| **Node.js** | Run sync script | Already in project | Version specified in `.nvmrc` |
+| **Node.js** | Run sync script | Already in project | Version specified in `engines.node` in `package.json` |
### Source data location
- **Repository**: `github/semmle-code` (private)
diff --git a/src/codeql-cli/scripts/convert-markdown-for-docs.ts b/src/codeql-cli/scripts/convert-markdown-for-docs.ts
index 97d13dd4b964..05cf108e3b1e 100644
--- a/src/codeql-cli/scripts/convert-markdown-for-docs.ts
+++ b/src/codeql-cli/scripts/convert-markdown-for-docs.ts
@@ -3,6 +3,7 @@ import path from 'path'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { toMarkdown } from 'mdast-util-to-markdown'
+import { toString } from 'mdast-util-to-string'
import { visitParents } from 'unist-util-visit-parents'
import { visit, SKIP } from 'unist-util-visit'
import { remove } from 'unist-util-remove'
@@ -90,6 +91,7 @@ export async function convertContentToDocs(
})
let currentNodeIsDescription = false
+ let descriptionParagraph: MdNode | undefined
visit(ast, (rawNode) => {
const node = rawNode as unknown as MdNode
// A bare return is CONTINUE. Returning false would mean EXIT,
@@ -101,7 +103,7 @@ export async function convertContentToDocs(
currentNodeIsDescription = true
}
if (currentNodeIsDescription && node.type === 'paragraph') {
- frontmatter.intro = node.children[0]?.value
+ descriptionParagraph = node
currentNodeIsDescription = false
return SKIP
}
@@ -146,6 +148,10 @@ export async function convertContentToDocs(
(node.value.startsWith('-') || node.value.startsWith('<'))
) {
node.type = 'inlineCode'
+ // Placeholders like
\n
\n1. Next step.\n',
+ )
+ const $ = load(html)
+ expect($('.procedural-image-wrapper')).toHaveLength(2)
+ expect($('.procedural-image-wrapper .procedural-image-wrapper')).toHaveLength(0)
+ const srcs = imgSrcs($)
+ expect(srcs.filter((src) => src.includes('a.png'))).toHaveLength(1)
+ expect(srcs.filter((src) => src.includes('b.png'))).toHaveLength(1)
+ })
+
+ test('does not wrap a loose list image inside a paragraph', async () => {
+ const html = await renderContent(
+ '1. Click **Settings**.\n\n \n\n1. Next step.\n',
+ )
+ const $ = load(html)
+ expect($('.procedural-image-wrapper')).toHaveLength(0)
+ expect($('li p img').attr('src')).toContain('a.png')
+ })
+})
diff --git a/src/content-render/unified/processor.ts b/src/content-render/unified/processor.ts
index 735a58566ec7..4ac6aadad296 100644
--- a/src/content-render/unified/processor.ts
+++ b/src/content-render/unified/processor.ts
@@ -32,6 +32,9 @@ import annotate from './annotate'
import alerts from './alerts'
import removeHtmlComments from 'remark-remove-comments'
import remarkStringify from 'remark-stringify'
+import { visit } from 'unist-util-visit'
+import type { Parents, Root, Text } from 'mdast'
+import type { Info, State } from 'mdast-util-to-markdown'
import type { Context, UnifiedProcessor } from '@/content-render/types'
import type { CollectedHeading } from '@/frame/lib/get-mini-toc-items'
@@ -84,12 +87,49 @@ export function createMarkdownOnlyProcessor(context: Context): UnifiedProcessor
unified()
.use(remarkParse)
.use(gfm)
+ .use(markUnescapedAlertMarkers)
// TypeScript's unified plugin types do not model context-bound transformer factories.
.use(rewriteLocalLinks as unknown as (ctx: Context) => void, context)
- .use(remarkStringify) as unknown as UnifiedProcessor
+ .use(remarkStringify, {
+ handlers: { text: textWithAlertMarkers },
+ }) as unknown as UnifiedProcessor
)
}
+// GitHub alert markers stop rendering as alerts once remark-stringify escapes their brackets.
+const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?=\s|$)/i
+
+// remark-parse drops the backslash from `\[!NOTE]`, so only the source shows an intentionally literal marker.
+const unescapedAlertMarkers = new WeakSetRepresents true or false values.
Represents signed double-precision fractional values as specified by IEEE 754.
", "id": "float", - "href": "/graphql/reference/other#other-scalar-float", + "href": "/graphql/reference/other#scalar-float", "category": "other" }, { @@ -170,14 +170,14 @@ "name": "ID", "description": "Represents a unique identifier that is Base64 obfuscated. It is often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \"VXNlci0xMA==\") or integer (such as 4) input value will be accepted as an ID.
Represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.
", "id": "int", - "href": "/graphql/reference/other#other-scalar-int", + "href": "/graphql/reference/other#scalar-int", "category": "other" }, { @@ -191,7 +191,7 @@ "name": "String", "description": "Represents textual data as UTF-8 character sequences. This type is most often used by GraphQL to represent free-form human-readable text.
", "id": "string", - "href": "/graphql/reference/other#other-scalar-string", + "href": "/graphql/reference/other#scalar-string", "category": "other" }, { diff --git a/src/graphql/data/ghes-3.18/schema-other.json b/src/graphql/data/ghes-3.18/schema-other.json index 0ed061286f70..7bcfef438a88 100644 --- a/src/graphql/data/ghes-3.18/schema-other.json +++ b/src/graphql/data/ghes-3.18/schema-other.json @@ -1622,7 +1622,7 @@ "name": "Boolean", "description": "Represents true or false values.
Represents signed double-precision fractional values as specified by IEEE 754.
", "id": "float", - "href": "/graphql/reference/other#other-other-other-other-other-other-scalar-float", + "href": "/graphql/reference/other#scalar-float", "category": "other" }, { @@ -1685,14 +1685,14 @@ "name": "ID", "description": "Represents a unique identifier that is Base64 obfuscated. It is often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \"VXNlci0xMA==\") or integer (such as 4) input value will be accepted as an ID.
Represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.
", "id": "int", - "href": "/graphql/reference/other#other-other-other-other-other-other-scalar-int", + "href": "/graphql/reference/other#scalar-int", "category": "other" }, { @@ -1706,7 +1706,7 @@ "name": "String", "description": "Represents textual data as UTF-8 character sequences. This type is most often used by GraphQL to represent free-form human-readable text.
", "id": "string", - "href": "/graphql/reference/other#other-other-other-other-other-other-scalar-string", + "href": "/graphql/reference/other#scalar-string", "category": "other" }, { diff --git a/src/graphql/data/ghes-3.19/schema-other.json b/src/graphql/data/ghes-3.19/schema-other.json index 2079a4d94ee8..0bfd0ed98845 100644 --- a/src/graphql/data/ghes-3.19/schema-other.json +++ b/src/graphql/data/ghes-3.19/schema-other.json @@ -1601,7 +1601,7 @@ "name": "Boolean", "description": "Represents true or false values.
Represents signed double-precision fractional values as specified by IEEE 754.
", "id": "float", - "href": "/graphql/reference/other#other-other-other-other-other-scalar-float", + "href": "/graphql/reference/other#scalar-float", "category": "other" }, { @@ -1664,14 +1664,14 @@ "name": "ID", "description": "Represents a unique identifier that is Base64 obfuscated. It is often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \"VXNlci0xMA==\") or integer (such as 4) input value will be accepted as an ID.
Represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.
", "id": "int", - "href": "/graphql/reference/other#other-other-other-other-other-scalar-int", + "href": "/graphql/reference/other#scalar-int", "category": "other" }, { @@ -1685,7 +1685,7 @@ "name": "String", "description": "Represents textual data as UTF-8 character sequences. This type is most often used by GraphQL to represent free-form human-readable text.
", "id": "string", - "href": "/graphql/reference/other#other-other-other-other-other-scalar-string", + "href": "/graphql/reference/other#scalar-string", "category": "other" }, { diff --git a/src/graphql/data/ghes-3.20/schema-other.json b/src/graphql/data/ghes-3.20/schema-other.json index 9f292395e7fa..036e36567fb1 100644 --- a/src/graphql/data/ghes-3.20/schema-other.json +++ b/src/graphql/data/ghes-3.20/schema-other.json @@ -1601,7 +1601,7 @@ "name": "Boolean", "description": "Represents true or false values.
Represents signed double-precision fractional values as specified by IEEE 754.
", "id": "float", - "href": "/graphql/reference/other#other-other-other-other-scalar-float", + "href": "/graphql/reference/other#scalar-float", "category": "other" }, { @@ -1671,14 +1671,14 @@ "name": "ID", "description": "Represents a unique identifier that is Base64 obfuscated. It is often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \"VXNlci0xMA==\") or integer (such as 4) input value will be accepted as an ID.
Represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.
", "id": "int", - "href": "/graphql/reference/other#other-other-other-other-scalar-int", + "href": "/graphql/reference/other#scalar-int", "category": "other" }, { @@ -1692,7 +1692,7 @@ "name": "String", "description": "Represents textual data as UTF-8 character sequences. This type is most often used by GraphQL to represent free-form human-readable text.
", "id": "string", - "href": "/graphql/reference/other#other-other-other-other-scalar-string", + "href": "/graphql/reference/other#scalar-string", "category": "other" }, { diff --git a/src/graphql/data/ghes-3.21/schema-other.json b/src/graphql/data/ghes-3.21/schema-other.json index c256d3c711c8..4e0f78604328 100644 --- a/src/graphql/data/ghes-3.21/schema-other.json +++ b/src/graphql/data/ghes-3.21/schema-other.json @@ -164,7 +164,7 @@ "name": "Boolean", "description": "Represents true or false values.
Represents signed double-precision fractional values as specified by IEEE 754.
", "id": "float", - "href": "/graphql/reference/other#other-other-other-scalar-float", + "href": "/graphql/reference/other#scalar-float", "category": "other" }, { @@ -234,14 +234,14 @@ "name": "ID", "description": "Represents a unique identifier that is Base64 obfuscated. It is often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \"VXNlci0xMA==\") or integer (such as 4) input value will be accepted as an ID.
Represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.
", "id": "int", - "href": "/graphql/reference/other#other-other-other-scalar-int", + "href": "/graphql/reference/other#scalar-int", "category": "other" }, { @@ -255,7 +255,7 @@ "name": "String", "description": "Represents textual data as UTF-8 character sequences. This type is most often used by GraphQL to represent free-form human-readable text.
", "id": "string", - "href": "/graphql/reference/other#other-other-other-scalar-string", + "href": "/graphql/reference/other#scalar-string", "category": "other" }, { diff --git a/src/graphql/data/ghes-3.22/schema-other.json b/src/graphql/data/ghes-3.22/schema-other.json index 61e4e1512507..e0420f2f3c8c 100644 --- a/src/graphql/data/ghes-3.22/schema-other.json +++ b/src/graphql/data/ghes-3.22/schema-other.json @@ -77,7 +77,7 @@ "name": "Boolean", "description": "Represents true or false values.
Represents signed double-precision fractional values as specified by IEEE 754.
", "id": "float", - "href": "/graphql/reference/other#other-other-scalar-float", + "href": "/graphql/reference/other#scalar-float", "category": "other" }, { @@ -147,14 +147,14 @@ "name": "ID", "description": "Represents a unique identifier that is Base64 obfuscated. It is often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \"VXNlci0xMA==\") or integer (such as 4) input value will be accepted as an ID.
Represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.
", "id": "int", - "href": "/graphql/reference/other#other-other-scalar-int", + "href": "/graphql/reference/other#scalar-int", "category": "other" }, { @@ -168,7 +168,7 @@ "name": "String", "description": "Represents textual data as UTF-8 character sequences. This type is most often used by GraphQL to represent free-form human-readable text.
", "id": "string", - "href": "/graphql/reference/other#other-other-scalar-string", + "href": "/graphql/reference/other#scalar-string", "category": "other" }, { diff --git a/src/graphql/lib/categories.ts b/src/graphql/lib/categories.ts index 76eb35cd02c9..cf89598a83bb 100644 --- a/src/graphql/lib/categories.ts +++ b/src/graphql/lib/categories.ts @@ -177,3 +177,8 @@ export function categoryTitle(slug: string): string { return slug.charAt(0).toUpperCase() + slug.slice(1) } } + +// Server and browser must share one collation, or hydration sees a different item order. +export function compareByName(a: { name: string }, b: { name: string }): number { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase(), 'en') +} diff --git a/src/graphql/lib/index.ts b/src/graphql/lib/index.ts index 8f0cfd8a8da2..03e793f5360e 100644 --- a/src/graphql/lib/index.ts +++ b/src/graphql/lib/index.ts @@ -7,6 +7,8 @@ import languages from '@/languages/lib/languages-server' import { allVersions } from '@/versions/lib/all-versions' import type { GraphqlT, + InterfaceImplementerT, + ObjectT, PreviewT, ChangelogItemT, BreakingChangesT, @@ -73,6 +75,26 @@ export function getAllGraphqlObjects(version: string): GraphqlT[] { return allObjectsByVersion.get(graphqlVersion)! } +// Category pages serialize props to the client, so keep only what Interface renders. +export function getGraphqlInterfaceImplementers( + version: string, + interfaceNames: string[], +): InterfaceImplementerT[] { + if (interfaceNames.length === 0) return [] + const names = new Set(interfaceNames) + const implementers: InterfaceImplementerT[] = [] + for (const object of getAllGraphqlObjects(version) as ObjectT[]) { + const matching = (object.implements ?? []).filter((item) => names.has(item.name)) + if (matching.length === 0) continue + implementers.push({ + name: object.name, + href: object.href, + implements: matching.map((item) => ({ name: item.name })), + }) + } + return implementers +} + export function getKindOrder(): SchemaKindKey[] { return ALL_KIND_KEYS } diff --git a/src/graphql/pages/reference.tsx b/src/graphql/pages/reference.tsx index 77dba5be2983..f728a419dd48 100644 --- a/src/graphql/pages/reference.tsx +++ b/src/graphql/pages/reference.tsx @@ -8,7 +8,7 @@ import { getMainContext, addUINamespaces, } from '@/frame/components/context/MainContext' -import type { ObjectT } from '@/graphql/components/types' +import type { InterfaceImplementerT } from '@/graphql/components/types' import type { ExtendedRequest } from '@/types/types' import { AutomatedPage } from '@/automated-pipelines/components/AutomatedPage' import { @@ -21,6 +21,7 @@ import { KIND_SLUG_PREFIX, KIND_LABELS_PLURAL, KIND_URL_SEGMENT, + compareByName, isValidCategory, } from '@/graphql/lib/categories' @@ -28,7 +29,7 @@ type Props = { mainContext: MainContextT automatedPageContext: AutomatedPageContextT schema: CategorySchema - allObjects: ObjectT[] + implementers: InterfaceImplementerT[] language: string categorySlug: string } @@ -37,11 +38,13 @@ export default function GraphqlReferencePage({ mainContext, automatedPageContext, schema, - allObjects, + implementers, categorySlug, }: Props) { // Keying by categorySlug prevents navigation from reusing stale dangerouslySetInnerHTML content. - const content =deployed event')
+ })
+
+ test('converts every quoted word in a description', async () => {
+ const rendered = await helpers.getDescription(
+ "Choose 'custom' or 'system' here. Defaults to 'custom' if not specified",
+ )
+
+ expect(rendered).toContain('Choose custom or system here')
+ expect(rendered).toContain('Defaults to custom if')
+ })
+})
diff --git a/src/graphql/tests/get-schema-files.ts b/src/graphql/tests/get-schema-files.ts
index a8b5b7602abe..e8e9c6e65259 100644
--- a/src/graphql/tests/get-schema-files.ts
+++ b/src/graphql/tests/get-schema-files.ts
@@ -9,7 +9,10 @@ import {
getGraphqlChangelog,
getGraphqlBreakingChanges,
getPreviews,
+ getAllGraphqlObjects,
+ getGraphqlInterfaceImplementers,
} from '../lib/index'
+import type { ObjectT } from '@/graphql/components/types'
interface GraphqlType {
kind: string
@@ -59,4 +62,32 @@ describe('graphql schema', () => {
expect(schema).toBeDefined()
}
})
+
+ test('getGraphqlInterfaceImplementers returns nothing without interface names', () => {
+ expect(getGraphqlInterfaceImplementers('free-pro-team@latest', [])).toEqual([])
+ })
+
+ test('getGraphqlInterfaceImplementers returns slim implementers for a category', () => {
+ const version = 'free-pro-team@latest'
+ const schema = getGraphqlSchema(version, 'issues') as { interfaces?: { name: string }[] }
+ const interfaceNames = (schema.interfaces ?? []).map((item) => item.name)
+ expect(interfaceNames.length).toBeGreaterThan(0)
+
+ const implementers = getGraphqlInterfaceImplementers(version, interfaceNames)
+ const expected = (getAllGraphqlObjects(version) as ObjectT[]).filter((object) =>
+ object.implements?.some((implementsItem) => interfaceNames.includes(implementsItem.name)),
+ )
+ expect(implementers.length).toBeGreaterThan(0)
+ expect(implementers.map((object) => object.name)).toEqual(expected.map((object) => object.name))
+ for (const implementer of implementers) {
+ expect(Object.keys(implementer).sort()).toEqual(['href', 'implements', 'name'])
+ for (const implementsItem of implementer.implements ?? []) {
+ expect(Object.keys(implementsItem)).toEqual(['name'])
+ expect(interfaceNames).toContain(implementsItem.name)
+ }
+ }
+ expect(JSON.stringify(implementers).length).toBeLessThan(
+ JSON.stringify(getAllGraphqlObjects(version)).length / 10,
+ )
+ })
})
diff --git a/src/journeys/lib/journey-path-resolver.ts b/src/journeys/lib/journey-path-resolver.ts
index ecb1640357a3..7b6f0446e2ea 100644
--- a/src/journeys/lib/journey-path-resolver.ts
+++ b/src/journeys/lib/journey-path-resolver.ts
@@ -69,6 +69,20 @@ function needsRendering(str: string): boolean {
return str.includes('{{') || str.includes('{%') || str.includes('[') || str.includes('<')
}
+async function renderTextField(value: string, context: Context): Promise/)
expect(result).not.toMatch(/<\/p>$/)
})
+ test('renders fpt version conditionals as free-pro-team', async () => {
+ expect(await renderLiquid('{% ifversion fpt %}A{% else %}B{% endif %}')).toBe('A')
+ expect(await renderLiquid('{% ifversion fpt or ghec %}A{% else %}B{% endif %}')).toBe('A')
+ expect(await renderLiquid('{% ifversion not fpt %}A{% else %}B{% endif %}')).toBe('B')
+ })
+ test('renders feature flags enabled on free-pro-team', async () => {
+ expect(await renderLiquid('{% ifversion code-search-upgrade %}A{% else %}B{% endif %}')).toBe(
+ 'A',
+ )
+ })
})
describe('getPageTitle', () => {
@@ -149,6 +161,21 @@ describe('loadConfig', () => {
const cfg = loadConfig('data/llms-txt/config-monolith.yml')
expect(cfg.title).toBe('GitHub')
})
+
+ test('every pinned page exists in content', () => {
+ for (const configPath of [
+ 'data/llms-txt/config-docs.yml',
+ 'data/llms-txt/config-monolith.yml',
+ ]) {
+ const missing = loadConfig(configPath).pinned_pages.filter(
+ (p) => !fs.existsSync(`content/${p}.md`) && !fs.existsSync(`content/${p}/index.md`),
+ )
+ expect(
+ missing,
+ `Pinned pages in ${configPath} not found. If you moved or renamed these pages, update pinned_pages in data/llms-txt/config-default.yml.`,
+ ).toEqual([])
+ }
+ })
})
describe('generate', () => {
@@ -222,6 +249,20 @@ describe('generate', () => {
expect(out).toMatch(/\[REST API\]/)
})
+ test('warns about pinned pages that do not exist and keeps the rest', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ try {
+ const pages = makePages({ rest: { title: 'REST API' } })
+ const out = await generate(pages, {}, { ...baseConfig, pinned_pages: ['rest', 'gone/page'] })
+ expect(out).toMatch(/\[REST API\]/)
+ expect(out).not.toMatch(/gone\/page/)
+ expect(warn).toHaveBeenCalledTimes(1)
+ expect(warn.mock.calls[0][0]).toMatch(/^::warning::.*skipped: gone\/page\./)
+ } finally {
+ warn.mockRestore()
+ }
+ })
+
test('includes title, description, how_to_use, and auto_update_comment', async () => {
const out = await generate(
{},
diff --git a/src/workflows/tests/git-utils.ts b/src/workflows/tests/git-utils.ts
new file mode 100644
index 000000000000..9533e3f971c2
--- /dev/null
+++ b/src/workflows/tests/git-utils.ts
@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
+
+const searchCode = vi.fn()
+vi.mock('@/workflows/github', async (importOriginal) => {
+ const actual = await importOriginal