diff --git a/.github/workflows/link-check-external.yml b/.github/workflows/link-check-external.yml index af8a04adc6ad..cb0bc35af3fd 100644 --- a/.github/workflows/link-check-external.yml +++ b/.github/workflows/link-check-external.yml @@ -43,7 +43,7 @@ jobs: - name: Upload report artifact if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: external-link-report path: artifacts/external-link-report.* diff --git a/.github/workflows/link-check-internal.yml b/.github/workflows/link-check-internal.yml index baa966011acd..6dc735d57ffb 100644 --- a/.github/workflows/link-check-internal.yml +++ b/.github/workflows/link-check-internal.yml @@ -90,7 +90,7 @@ jobs: - name: Upload report artifact if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: link-report-${{ matrix.version }}-${{ matrix.language }} path: artifacts/link-report-*.md @@ -113,7 +113,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download all artifacts - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: reports pattern: link-report-* diff --git a/.github/workflows/link-check-on-pr.yml b/.github/workflows/link-check-on-pr.yml index 755fc0581e1c..dd424e2739c4 100644 --- a/.github/workflows/link-check-on-pr.yml +++ b/.github/workflows/link-check-on-pr.yml @@ -37,7 +37,7 @@ jobs: - name: Get changed files id: changed-files - uses: tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c # v45 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v45 with: files: | content/**/*.md diff --git a/content/copilot/reference/copilot-feature-matrix.md b/content/copilot/reference/copilot-feature-matrix.md index b3cd87dbf83b..d6f1c02b4848 100644 --- a/content/copilot/reference/copilot-feature-matrix.md +++ b/content/copilot/reference/copilot-feature-matrix.md @@ -26,10 +26,20 @@ topics: The following table shows supported {% data variables.product.prodname_copilot_short %} features in the latest version of each IDE. +{%- comment %} +This loop generates the "Features by IDE" comparison table: +- Outer loop: Iterates through each feature from VS Code's feature list (using VS Code as the canonical source) +- Inner loop: For each feature, iterates through all IDEs to check support in their latest version + - Gets the latest version using ideEntry[1].versions | first + - Looks up the support level for that feature in that version + - Outputs ✓ (supported), P (preview), or ✗ (not supported) +Example row: | Agent mode | ✓ | ✓ | P | ✗ | ... | +{%- endcomment %} + | Feature{%- for entry in tables.copilot.copilot-matrix.ides %} | {{ entry[0] }}{%- endfor %} | |:----{%- for entry in tables.copilot.copilot-matrix.ides %}|:----:{%- endfor %}| {%- for featureEntry in tables.copilot.copilot-matrix.ides["VS Code"].features %} -| {{ featureEntry[0] }}{%- for ideEntry in tables.copilot.copilot-matrix.ides %}{%- assign latestVersion = ideEntry[1].versions | last %}{%- assign supportLevel = ideEntry[1].features[featureEntry[0]][latestVersion] %} | {%- case supportLevel -%}{%- when "supported" %}✓{%- when "preview" %}P{%- else %}✗{%- endcase -%}{%- endfor %} | +| {{ featureEntry[0] }}{%- for ideEntry in tables.copilot.copilot-matrix.ides %}{%- assign latestVersion = ideEntry[1].versions | first %}{%- assign supportLevel = ideEntry[1].features[featureEntry[0]][latestVersion] %} | {%- case supportLevel -%}{%- when "supported" %}✓{%- when "preview" %}P{%- else %}✗{%- endcase -%}{%- endfor %} | {%- endfor %} {% endides %} diff --git a/content/github-cli/github-cli/about-github-cli.md b/content/github-cli/github-cli/about-github-cli.md index dca96a80b060..802b3cbab97d 100644 --- a/content/github-cli/github-cli/about-github-cli.md +++ b/content/github-cli/github-cli/about-github-cli.md @@ -26,7 +26,7 @@ For more information about what you can do with {% data variables.product.prodna The Git command line interface (`git`) allows you to work with a local or remote Git repository. The remote repository may be hosted on {% data variables.product.prodname_dotcom %} or it may be hosted by another service. -{% data variables.product.prodname_cli %} (`gh`) is specifically for working with {% data variables.product.prodname_dotcom %}. It allows you to use the command line to interact with {% data variables.product.prodname_dotcom %} in all sorts of ways, as illustrated by the previous list. If you tend to work on the command line you may prefer using {% data variables.product.prodname_cli %} instead of using {% data variables.product.prodname_dotcom %} in a browser. {% data variables.product.prodname_cli %} also makes it easier for you to create scripts to automate {% data variables.product.prodname_dotcom %} operations. +{% data variables.product.prodname_cli %} (`gh`) is specifically for working with {% data variables.product.prodname_dotcom %}. It allows you to use the command line to interact with {% data variables.product.prodname_dotcom %} in all sorts of ways, as illustrated by the previous list. If you tend to work on the command line, you may prefer using {% data variables.product.prodname_cli %} instead of using {% data variables.product.prodname_dotcom %} in a browser. {% data variables.product.prodname_cli %} also makes it easier for you to create scripts to automate {% data variables.product.prodname_dotcom %} operations. ## Installing {% data variables.product.prodname_cli %} diff --git a/content/github-cli/github-cli/using-multiple-accounts.md b/content/github-cli/github-cli/using-multiple-accounts.md index 216fa82d4c5b..a530190a537a 100644 --- a/content/github-cli/github-cli/using-multiple-accounts.md +++ b/content/github-cli/github-cli/using-multiple-accounts.md @@ -25,7 +25,7 @@ The {% data variables.product.prodname_cli %} **can't automatically detect** you * The {% data variables.product.prodname_cli %} will default to {% data variables.product.prodname_dotcom_the_website %}. * You can set the `GH_HOST` environment variable to change the default target for these kinds of requests. See [gh environment](https://cli.github.com/manual/gh_help_environment) in the {% data variables.product.prodname_cli %} manual. -* Some commands allow you allow you to specify your target environment with the `--hostname` option, such as `gh api`, or pass the full URL for a repository, such as `gh pr view`. +* Some commands allow you to specify your target environment with the `--hostname` option, such as `gh api`, or pass the full URL for a repository, such as `gh pr view`. ## Can I use multiple accounts on the same platform? diff --git a/data/release-notes/enterprise-server/3-16/13.yml b/data/release-notes/enterprise-server/3-16/13.yml index b4eb899a97b7..388686a17f70 100644 --- a/data/release-notes/enterprise-server/3-16/13.yml +++ b/data/release-notes/enterprise-server/3-16/13.yml @@ -27,6 +27,8 @@ sections: Now, if an administrator sets the instance's `skip_rebase_commit_generation_from_rebase_merge_settings` configuration variable to `true`, the "Allow rebase merging" option in a repository's pull request settings becomes the source of truth for whether rebase commits are generated when mergeability is checked. known_issues: + - | + When applying an enterprise security configuration to all repositories (for example, enabling Secret Scanning or Code Scanning across all repositories), the system immediately enqueues enablement jobs for every organization in the enterprise simultaneously. For enterprises with a large number of repositories, this can result in significant system load and potential performance degradation. If you manage a large enterprise with many organizations and repositories, we recommend applying security configurations at the organization level rather than at the enterprise level in the UI. This allows you to enable security features incrementally and monitor system performance as you roll out changes. - | During an upgrade of GitHub Enterprise Server, custom firewall rules are removed. If you use custom firewall rules, you must reapply them after upgrading. - | diff --git a/data/release-notes/enterprise-server/3-17/10.yml b/data/release-notes/enterprise-server/3-17/10.yml index d3f719704284..b4427a69808e 100644 --- a/data/release-notes/enterprise-server/3-17/10.yml +++ b/data/release-notes/enterprise-server/3-17/10.yml @@ -33,6 +33,8 @@ sections: Now, if an administrator sets the instance's `skip_rebase_commit_generation_from_rebase_merge_settings` configuration variable to `true`, the "Allow rebase merging" option in a repository's pull request settings becomes the source of truth for whether rebase commits are generated when mergeability is checked. known_issues: + - | + When applying an enterprise security configuration to all repositories (for example, enabling Secret Scanning or Code Scanning across all repositories), the system immediately enqueues enablement jobs for every organization in the enterprise simultaneously. For enterprises with a large number of repositories, this can result in significant system load and potential performance degradation. If you manage a large enterprise with many organizations and repositories, we recommend applying security configurations at the organization level rather than at the enterprise level in the UI. This allows you to enable security features incrementally and monitor system performance as you roll out changes. - | During an upgrade of GitHub Enterprise Server, custom firewall rules are removed. If you use custom firewall rules, you must reapply them after upgrading. - | diff --git a/data/release-notes/enterprise-server/3-18/4.yml b/data/release-notes/enterprise-server/3-18/4.yml index 07b55637e1f6..a654815383a8 100644 --- a/data/release-notes/enterprise-server/3-18/4.yml +++ b/data/release-notes/enterprise-server/3-18/4.yml @@ -43,6 +43,8 @@ sections: Now, if an administrator sets the instance's `skip_rebase_commit_generation_from_rebase_merge_settings` configuration variable to `true`, the "Allow rebase merging" option in a repository's pull request settings becomes the source of truth for whether rebase commits are generated when mergeability is checked. known_issues: + - | + When applying an enterprise security configuration to all repositories (for example, enabling Secret Scanning or Code Scanning across all repositories), the system immediately enqueues enablement jobs for every organization in the enterprise simultaneously. For enterprises with a large number of repositories, this can result in significant system load and potential performance degradation. If you manage a large enterprise with many organizations and repositories, we recommend applying security configurations at the organization level rather than at the enterprise level in the UI. This allows you to enable security features incrementally and monitor system performance as you roll out changes. - | During an upgrade of GitHub Enterprise Server, custom firewall rules are removed. If you use custom firewall rules, you must reapply them after upgrading. - | diff --git a/data/release-notes/enterprise-server/3-19/1.yml b/data/release-notes/enterprise-server/3-19/1.yml index ba2b9e888076..c5878bb8249d 100644 --- a/data/release-notes/enterprise-server/3-19/1.yml +++ b/data/release-notes/enterprise-server/3-19/1.yml @@ -48,6 +48,8 @@ sections: - | You can configure multiple data disks to host MySQL and repository data. This capability is currently in public preview and is applicable only for standalone and high availability topologies. It does not apply to cluster topologies. For more information, see [AUTOTITLE](/admin/monitoring-and-managing-your-instance/multiple-data-disks/configuring-multiple-data-disks). [Updated: 2026-01-19] known_issues: + - | + When applying an enterprise security configuration to all repositories (for example, enabling Secret Scanning or Code Scanning across all repositories), the system immediately enqueues enablement jobs for every organization in the enterprise simultaneously. For enterprises with a large number of repositories, this can result in significant system load and potential performance degradation. If you manage a large enterprise with many organizations and repositories, we recommend applying security configurations at the organization level rather than at the enterprise level in the UI. This allows you to enable security features incrementally and monitor system performance as you roll out changes. - | Upgrading or hotpatching to 3.19.1 may fail on very old nodes that have been continuously upgraded from versions older than 2021 versions (i.e. 2.17). If this issue occurs, you will see log entries prefixed with `invalid secret` in ghe-config.log. If you are running nodes this old, it is recommended not to upgrade to 3.19.1. If you must hotpatch to 3.19.1, first run `ghe-config 'secrets.session-manage' | tr -d '\n' | wc -c`. If the output is less than 64, run `ghe-config --unset 'secrets.session-manage'` and `ghe-config-apply` before you start the hotpatch. You can also run these same commands after the hotpatch to recover from the failure. [Updated: 2026-01-12] diff --git a/eslint.config.ts b/eslint.config.ts index 8d2ed2888c58..ecb8ae8abd3d 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -12,6 +12,7 @@ import noOnlyTests from 'eslint-plugin-no-only-tests' import prettierPlugin from 'eslint-plugin-prettier' import prettier from 'eslint-config-prettier' import globals from 'globals' +import customRules from 'eslint-plugin-custom-rules' export default [ { @@ -57,6 +58,7 @@ export default [ '@typescript-eslint': tseslint, 'primer-react': primerReact, 'jsx-a11y': jsxA11y, + 'custom-rules': customRules, }, rules: { // ESLint recommended rules @@ -98,6 +100,77 @@ export default [ // Disabled rules to review 'no-console': 'off', // 800+ '@typescript-eslint/no-explicit-any': 'off', + + // Custom rules (disabled by default for now) + 'custom-rules/use-custom-logger': 'off', + }, + }, + + // Configuration for eslint-rules directory (CommonJS JavaScript files) + { + files: ['src/eslint-rules/**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'script', + globals: { + ...globals.node, + ...globals.commonjs, + ...globals.es2020, + }, + }, + plugins: { + github, + import: importPlugin, + 'eslint-comments': eslintComments, + filenames, + 'no-only-tests': noOnlyTests, + prettier: prettierPlugin, + }, + rules: { + // ESLint recommended rules + ...js.configs.recommended.rules, + + // GitHub plugin recommended rules + ...github.configs.recommended.rules, + + // Import plugin error rules + ...importPlugin.configs.errors.rules, + + // Allow CommonJS in eslint rules + 'import/no-commonjs': 'off', + + // Overrides + 'import/extensions': ['error', { json: 'always' }], + 'no-empty': ['error', { allowEmptyCatch: true }], + 'prefer-const': ['error', { destructuring: 'all' }], + + // Disabled rules + 'i18n-text/no-en': 'off', + 'filenames/match-regex': 'off', + camelcase: 'off', + 'no-console': 'off', + }, + }, + + // Disable custom logger rule for logger implementation itself + { + files: ['src/observability/logger/**/*.{ts,js}'], + rules: { + 'custom-rules/use-custom-logger': 'off', + }, + }, + + // Override for scripts, tests, workflows, content-linter, and React files (disable custom logger rule) + { + files: [ + '**/scripts/**/*.{ts,js}', + '**/tests/**/*.{ts,js}', + 'src/workflows/**/*.{ts,js}', + 'src/content-linter/**/*.{ts,js}', + '**/*.{tsx,jsx}', + ], + rules: { + 'custom-rules/use-custom-logger': 'off', }, }, diff --git a/package-lock.json b/package-lock.json index ca34e554ae3e..adde603e9480 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "dependencies": { "@elastic/elasticsearch": "8.19.1", "@github/failbot": "0.8.3", + "@github/hydro-analytics-client": "^2.3.3", "@gr2m/gray-matter": "4.0.3-with-pr-137", "@horizon-rs/language-guesser": "0.1.1", "@octokit/graphql": "9.0.1", @@ -51,7 +52,7 @@ "helmet": "^8.0.0", "highlight.js": "^11.11.1", "highlightjs-curl": "^1.3.0", - "hot-shots": "^12.0.0", + "hot-shots": "^13.1.0", "html-entities": "^2.5.6", "http-proxy-middleware": "^3.0.5", "imurmurhash": "^0.1.4", @@ -143,6 +144,7 @@ "eslint": "^9.33.0", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.2", + "eslint-plugin-custom-rules": "file:src/eslint-rules", "eslint-plugin-escompat": "^3.11.4", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-filenames": "^1.3.2", @@ -299,6 +301,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -484,6 +487,7 @@ "version": "7.23.3", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz", "integrity": "sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -521,12 +525,14 @@ "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "peer": true }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -560,6 +566,7 @@ "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "peer": true, "dependencies": { "@babel/compat-data": "^7.22.9", "@babel/helper-validator-option": "^7.22.15", @@ -575,6 +582,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -583,6 +591,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -590,7 +599,8 @@ "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "peer": true }, "node_modules/@babel/helper-environment-visitor": { "version": "7.22.20", @@ -638,6 +648,7 @@ "version": "7.23.3", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "peer": true, "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-module-imports": "^7.22.15", @@ -664,6 +675,7 @@ "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "peer": true, "dependencies": { "@babel/types": "^7.22.5" }, @@ -702,6 +714,7 @@ "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -710,6 +723,7 @@ "version": "7.26.10", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", + "peer": true, "dependencies": { "@babel/template": "^7.26.9", "@babel/types": "^7.26.10" @@ -1595,6 +1609,12 @@ "npm": ">= 7.x" } }, + "node_modules/@github/hydro-analytics-client": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@github/hydro-analytics-client/-/hydro-analytics-client-2.3.3.tgz", + "integrity": "sha512-zjelWB1hWOZ+CKHi5alBB5cclWmBedM0zABXoeJwVHCebwYg0XSBmKgYtlakYyuNaNRa3v4sGbbU1ts56Pl29Q==", + "license": "MIT" + }, "node_modules/@github/image-crop-element": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@github/image-crop-element/-/image-crop-element-5.0.0.tgz", @@ -2620,7 +2640,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -2921,7 +2940,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -3293,7 +3311,6 @@ "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.56.1" }, @@ -4117,7 +4134,6 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4279,7 +4295,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4291,7 +4306,6 @@ "integrity": "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4462,7 +4476,6 @@ "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", @@ -5108,7 +5121,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5138,7 +5150,6 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5704,7 +5715,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001733", "electron-to-chromium": "^1.5.199", @@ -5986,7 +5996,6 @@ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", "license": "MIT", - "peer": true, "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", @@ -7258,7 +7267,6 @@ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -7320,7 +7328,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7466,6 +7473,10 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-custom-rules": { + "resolved": "src/eslint-rules", + "link": true + }, "node_modules/eslint-plugin-escompat": { "version": "3.11.4", "resolved": "https://registry.npmjs.org/eslint-plugin-escompat/-/eslint-plugin-escompat-3.11.4.tgz", @@ -7592,7 +7603,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8788,6 +8798,7 @@ "node_modules/gensync": { "version": "1.0.0-beta.2", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -8994,7 +9005,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", "dev": true, - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -9375,9 +9385,9 @@ "license": "MIT" }, "node_modules/hot-shots": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-12.0.0.tgz", - "integrity": "sha512-VdEXhNX4FYOY/Qx8sPcdR8+y/jXgwGlQdhk77dgFvJsVx+uS9WwiHEc+NKJqEQmd+0FTwy2/5FOtqe4cZ/JV5g==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-13.1.0.tgz", + "integrity": "sha512-dvLYrOSh4dZknxpsVM3nSiXZfipGDLIPtS7Ad2KSaM/oUFOIOeKg0T1Rp0KwVGP9u2e3eW4wvjbta2bNwkHU/g==", "license": "MIT", "engines": { "node": ">=16.0.0" @@ -10367,7 +10377,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -10494,6 +10503,7 @@ "node_modules/json5": { "version": "2.2.3", "license": "MIT", + "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -13456,7 +13466,6 @@ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -13520,7 +13529,6 @@ "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13690,7 +13698,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13711,7 +13718,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -14379,7 +14385,6 @@ "integrity": "sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -15317,7 +15322,6 @@ "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.11.tgz", "integrity": "sha512-uuzIIfnVkagcVHv9nE0VPlHPSCmXIUGKfJ42LNjxCCTDTL5sgnJ8Z7GZBq0EnLYGln77tPpEpExt2+qa+cZqSw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.0.0", "@babel/traverse": "^7.4.5", @@ -15576,7 +15580,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15902,7 +15905,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16257,7 +16259,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.2.2" }, @@ -16460,7 +16461,6 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16569,7 +16569,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17155,6 +17154,14 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "src/eslint-rules": { + "name": "eslint-plugin-custom-rules", + "version": "1.0.0", + "dev": true, + "peerDependencies": { + "eslint": "^8.0.0 || ^9.0.0" + } } } } diff --git a/package.json b/package.json index 8e621ecf96ba..791b0d4e8fd7 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,7 @@ "dependencies": { "@elastic/elasticsearch": "8.19.1", "@github/failbot": "0.8.3", + "@github/hydro-analytics-client": "^2.3.3", "@gr2m/gray-matter": "4.0.3-with-pr-137", "@horizon-rs/language-guesser": "0.1.1", "@octokit/graphql": "9.0.1", @@ -199,7 +200,7 @@ "helmet": "^8.0.0", "highlight.js": "^11.11.1", "highlightjs-curl": "^1.3.0", - "hot-shots": "^12.0.0", + "hot-shots": "^13.1.0", "html-entities": "^2.5.6", "http-proxy-middleware": "^3.0.5", "imurmurhash": "^0.1.4", @@ -291,6 +292,7 @@ "eslint": "^9.33.0", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.2", + "eslint-plugin-custom-rules": "file:src/eslint-rules", "eslint-plugin-escompat": "^3.11.4", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-filenames": "^1.3.2", diff --git a/src/archives/middleware/archived-enterprise-versions.ts b/src/archives/middleware/archived-enterprise-versions.ts index 74dbf9caf2e1..cce873631096 100644 --- a/src/archives/middleware/archived-enterprise-versions.ts +++ b/src/archives/middleware/archived-enterprise-versions.ts @@ -2,6 +2,7 @@ import type { Response, NextFunction } from 'express' import { fetchWithRetry } from '@/frame/lib/fetch-utils' import statsd from '@/observability/lib/statsd' +import { createLogger } from '@/observability/logger' import { firstVersionDeprecatedOnNewSite, lastVersionWithoutArchivedRedirectsFile, @@ -19,6 +20,8 @@ import getRedirect, { splitPathByLanguage } from '@/redirects/lib/get-redirect' import getRemoteJSON from '@/frame/lib/get-remote-json' import { ExtendedRequest } from '@/types' +const logger = createLogger(import.meta.url) + const OLD_PUBLIC_AZURE_BLOB_URL = 'https://githubdocs.azureedge.net' // Old Azure Blob Storage `enterprise` container. const OLD_AZURE_BLOB_ENTERPRISE_DIR = `${OLD_PUBLIC_AZURE_BLOB_URL}/enterprise` @@ -78,11 +81,17 @@ const cacheAggressively = (res: Response) => { const retryConfiguration = { limit: 3 } // According to our Datadog metrics, the *average* time for the // the 'archive_enterprise_proxy' metric is ~70ms (excluding spikes) -// which much less than 1500ms. +// which is much less than 3000ms. // We have observed errors of timeout, in production, when it was -// set to 500ms. Let's try to be very conservative here to avoid -// unnecessary error reporting. -const timeoutConfiguration = { response: 1500 } +// set to 500ms and then 1500ms. Let's be more conservative here to +// avoid unnecessary error reporting during occasional slow responses. +const timeoutConfiguration = { response: 3000 } + +// Monitoring thresholds for logging response times +// Log warnings when responses exceed half the timeout threshold +const WARN_RESPONSE_THRESHOLD = timeoutConfiguration.response / 2 // 1500ms +// Log info for responses that are noticeably slow but not concerning +const SLOW_RESPONSE_THRESHOLD = 500 // ms // This module handles requests for deprecated GitHub Enterprise versions // by routing them to static content in @@ -201,10 +210,44 @@ export default async function archivedEnterpriseVersions( ) const statsdTags = [`version:${requestedVersion}`] + const startTime = Date.now() const r = await statsd.asyncTimer(doGet, 'archive_enterprise_proxy', [ ...statsdTags, `path:${req.path}`, ])() + const responseTime = Date.now() - startTime + + // Log warnings for slow responses to help identify degraded performance + // A response time over half the timeout indicates potential issues + if (responseTime > WARN_RESPONSE_THRESHOLD) { + logger.warn('Slow response from archived enterprise content', { + version: requestedVersion, + path: req.path, + responseTime: `${responseTime}ms`, + status: r.status, + threshold: `${WARN_RESPONSE_THRESHOLD}ms`, + }) + } + + // Log errors for non-200 responses to help identify issues with archived content + if (r.status !== 200) { + logger.error('Failed to fetch archived enterprise content', { + version: requestedVersion, + path: req.path, + status: r.status, + responseTime: `${responseTime}ms`, + url: getProxyPath(req.path, requestedVersion), + }) + } + + // Log successful responses with timing for monitoring trends + if (r.status === 200 && responseTime > SLOW_RESPONSE_THRESHOLD) { + logger.info('Archived enterprise content response', { + version: requestedVersion, + responseTime: `${responseTime}ms`, + status: r.status, + }) + } if (r.status === 200) { const body = await r.text() @@ -317,6 +360,7 @@ export default async function archivedEnterpriseVersions( return res.send(modifiedBody) } + // In releases 2.13 - 2.17, we lost access to frontmatter redirects // during the archival process. This workaround finds potentially // relevant frontmatter redirects in currently supported pages diff --git a/src/eslint-rules/README.md b/src/eslint-rules/README.md new file mode 100644 index 000000000000..3b7ec018a9c4 --- /dev/null +++ b/src/eslint-rules/README.md @@ -0,0 +1,21 @@ +# Custom ESLint Rules + +We can declare custom rules in this directory and import them from our ESLint config in [eslint.config.ts](../../eslint.config.ts) + +Custom rules are useful for enforcing best practices and more effective than a "warning" comment since automated linter tests will fail if custom rules aren't followed. + +**Note:** Custom rules must be written as JavaScript (`.js`) files, not TypeScript. ESLint loads custom rule plugins at runtime using Node's module system, which expects JavaScript. While the main ESLint config can be TypeScript, the rule implementations themselves need to be JavaScript. + +## Creating a new rule + +1. Create the rule in this directory +2. Export it from [index.js](./index.js) +3. Enable it in [eslint.config.ts](../../eslint.config.ts) under the `"rules"` key, e.g. + +```typescript +'custom-rules/use-custom-logger': 'error', +``` + +Note that the rule is prepended with `custom-rules` + +4. Please include a `README.md` in your rule's directory explaining why it is necessary and any times the rule can be ignored. diff --git a/src/eslint-rules/index.js b/src/eslint-rules/index.js new file mode 100644 index 000000000000..8e0e03398c32 --- /dev/null +++ b/src/eslint-rules/index.js @@ -0,0 +1,6 @@ +// Export custom rules from here +module.exports = { + rules: { + "use-custom-logger": require("./use-custom-logger/use-custom-logger"), + }, +}; diff --git a/src/eslint-rules/package.json b/src/eslint-rules/package.json new file mode 100644 index 000000000000..a6b961f3d116 --- /dev/null +++ b/src/eslint-rules/package.json @@ -0,0 +1,10 @@ +{ + "name": "eslint-plugin-custom-rules", + "type": "commonjs", + "version": "1.0.0", + "license": "MIT", + "main": "index.js", + "peerDependencies": { + "eslint": "^8.0.0 || ^9.0.0" + } +} diff --git a/src/eslint-rules/use-custom-logger/README.md b/src/eslint-rules/use-custom-logger/README.md new file mode 100644 index 000000000000..80f4d003dffa --- /dev/null +++ b/src/eslint-rules/use-custom-logger/README.md @@ -0,0 +1,15 @@ +# Use Custom Logger Rule + +This rule enforces using `logger.` instead of `console.log` in backend code. + +Please see [the logger README](../../observability/logger/README.md) for more details. + +## When to ignore + +Anywhere that isn't server code doesn't need to use the `logger`. e.g. React components, GitHub Actions, and CLI scripts. + +## Auto fix + +This rule supports auto-fixing via `--fix`. + +Run `npm run lint -- --fix` to apply the changes, but please make sure to double check that the automatic fix looks correct before committing. diff --git a/src/eslint-rules/use-custom-logger/tests/use-custom-logger.ts b/src/eslint-rules/use-custom-logger/tests/use-custom-logger.ts new file mode 100644 index 000000000000..a9eb68e4d7db --- /dev/null +++ b/src/eslint-rules/use-custom-logger/tests/use-custom-logger.ts @@ -0,0 +1,470 @@ +import { describe, it } from 'vitest' +import { RuleTester, Rule } from 'eslint' +import ruleModule from '../use-custom-logger' + +const rule = ruleModule as Rule.RuleModule + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}) + +describe('use-custom-logger', () => { + it('should pass valid code without console usage', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [ + { + code: ` + import { createLogger } from '@/observability/logger'; + const logger = createLogger(import.meta.url); + logger.info('test'); + `, + }, + { + code: ` + const x = 5; + const y = 10; + `, + }, + ], + invalid: [], + }) + }) + + it('should detect and fix console.log', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + import { something } from 'somewhere'; + console.log('test'); + `, + errors: [ + { + message: 'Please use our internal logger.info instead of console.log', + }, + ], + output: ` + import { something } from 'somewhere'; +import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + logger.info('test'); + `, + }, + ], + }) + }) + + it('should detect and fix console.error', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + import { something } from 'somewhere'; + console.error('test'); + `, + errors: [ + { + message: 'Please use our internal logger.error instead of console.error', + }, + ], + output: ` + import { something } from 'somewhere'; +import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + logger.error('test'); + `, + }, + ], + }) + }) + + it('should detect and fix console.warn', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + import { something } from 'somewhere'; + console.warn('warning'); + `, + errors: [ + { + message: 'Please use our internal logger.warn instead of console.warn', + }, + ], + output: ` + import { something } from 'somewhere'; +import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + logger.warn('warning'); + `, + }, + ], + }) + }) + + it('should detect and fix console.debug', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + import { something } from 'somewhere'; + console.debug('debug info'); + `, + errors: [ + { + message: 'Please use our internal logger.debug instead of console.debug', + }, + ], + output: ` + import { something } from 'somewhere'; +import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + logger.debug('debug info'); + `, + }, + ], + }) + }) + + it('should handle files with no existing imports', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: `console.log('hello');`, + errors: [ + { + message: 'Please use our internal logger.info instead of console.log', + }, + ], + output: `import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); +logger.info('hello');`, + }, + ], + }) + }) + + it('should not add duplicate imports when logger import exists', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + import { createLogger } from '@/observability/logger'; + console.log('test'); + `, + errors: [ + { + message: 'Please use our internal logger.info instead of console.log', + }, + ], + output: ` + import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + logger.info('test'); + `, + }, + ], + }) + }) + + it('should not add duplicate logger declaration when it exists', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + import { createLogger } from '@/observability/logger'; + const logger = createLogger(import.meta.url); + console.log('test'); + `, + errors: [ + { + message: 'Please use our internal logger.info instead of console.log', + }, + ], + output: ` + import { createLogger } from '@/observability/logger'; + const logger = createLogger(import.meta.url); + logger.info('test'); + `, + }, + ], + }) + }) + + it('should handle multiple console calls in one file', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + import { something } from 'somewhere'; + console.log('first'); + console.error('second'); + `, + errors: [ + { + message: 'Please use our internal logger.info instead of console.log', + }, + { + message: 'Please use our internal logger.error instead of console.error', + }, + ], + output: ` + import { something } from 'somewhere'; +import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + logger.info('first'); + logger.error('second'); + `, + }, + ], + }) + }) + + it('should transform console.error(err) to logger.error with message', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + import { something } from 'somewhere'; + const err = new Error('test'); + console.error(err); + `, + errors: [ + { + message: 'Please use our internal logger.error instead of console.error', + }, + ], + output: ` + import { something } from 'somewhere'; +import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + const err = new Error('test'); + logger.error('Error occurred', { err }); + `, + }, + ], + }) + }) + + it('should transform console.error(error) to logger.error with message', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + import { something } from 'somewhere'; + const error = new Error('test'); + console.error(error); + `, + errors: [ + { + message: 'Please use our internal logger.error instead of console.error', + }, + ], + output: ` + import { something } from 'somewhere'; +import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + const error = new Error('test'); + logger.error('Error occurred', { error }); + `, + }, + ], + }) + }) + + it('should transform console.warn(err) to logger.warn with message', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + import { something } from 'somewhere'; + const err = new Error('test'); + console.warn(err); + `, + errors: [ + { + message: 'Please use our internal logger.warn instead of console.warn', + }, + ], + output: ` + import { something } from 'somewhere'; +import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + const err = new Error('test'); + logger.warn('Error occurred', { err }); + `, + }, + ], + }) + }) + + it('should transform console.error(e) to logger.error with message', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + try { + throw new Error('test'); + } catch (e) { + console.error(e); + } + `, + errors: [ + { + message: 'Please use our internal logger.error instead of console.error', + }, + ], + output: `import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + try { + throw new Error('test'); + } catch (e) { + logger.error('Error occurred', { e }); + } + `, + }, + ], + }) + }) + + it('should transform console.error(failBotError) to logger.error with message', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + const failBotError = new Error('test'); + console.error(failBotError); + `, + errors: [ + { + message: 'Please use our internal logger.error instead of console.error', + }, + ], + output: `import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + const failBotError = new Error('test'); + logger.error('Error occurred', { failBotError }); + `, + }, + ], + }) + }) + + it('should not transform console.error with message and error object', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + const err = new Error('test'); + console.error('Something went wrong', err); + `, + errors: [ + { + message: 'Please use our internal logger.error instead of console.error', + }, + ], + output: `import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + const err = new Error('test'); + logger.error('Something went wrong', err); + `, + }, + ], + }) + }) + + it('should not transform console.log with error variable', () => { + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + const err = new Error('test'); + console.log(err); + `, + errors: [ + { + message: 'Please use our internal logger.info instead of console.log', + }, + ], + output: `import { createLogger } from '@/observability/logger'; + +const logger = createLogger(import.meta.url); + + const err = new Error('test'); + logger.info(err); + `, + }, + ], + }) + }) + + it('should handle logger variable with destructuring pattern', () => { + // This test ensures the rule recognizes logger variables from destructuring patterns + // and doesn't create a duplicate declaration + ruleTester.run('use-custom-logger', rule, { + valid: [], + invalid: [ + { + code: ` + const { logger } = something; + console.log('test'); + `, + errors: [ + { + message: 'Please use our internal logger.info instead of console.log', + }, + ], + // The auto-fix will add the import but not the declaration since logger exists via destructuring + output: `import { createLogger } from '@/observability/logger'; + + const { logger } = something; + logger.info('test'); + `, + }, + ], + }) + }) +}) diff --git a/src/eslint-rules/use-custom-logger/use-custom-logger.js b/src/eslint-rules/use-custom-logger/use-custom-logger.js new file mode 100644 index 000000000000..e498cb20e7db --- /dev/null +++ b/src/eslint-rules/use-custom-logger/use-custom-logger.js @@ -0,0 +1,179 @@ +module.exports = { + meta: { + type: "suggestion", + docs: { + description: "Use our internal logger instead of console", + category: "Best Practices", + recommended: false, + }, + fixable: "code", + schema: [], // no options + }, + create(context) { + const sourceCode = context.getSourceCode(); + // flag to ensure we add the logger setup only once per file + let setupInserted = false; + + // Check if the logger import is already present. + function needsLoggerImport() { + return !sourceCode.ast.body.some( + (node) => + node.type === "ImportDeclaration" && + node.source.value === "@/observability/logger", + ); + } + + // Check if a logger variable is already declared. + // This checks for both direct declarations (const logger = ...) and + // destructured patterns (const { logger } = ...). + function needsLoggerDeclaration() { + return !sourceCode.ast.body.some((node) => { + if (node.type === "VariableDeclaration") { + return node.declarations.some((decl) => { + // Check for direct identifier: const logger = ... + if (decl.id.type === "Identifier" && decl.id.name === "logger") { + return true; + } + // Check for destructured pattern: const { logger } = ... + if (decl.id.type === "ObjectPattern") { + return decl.id.properties.some( + (prop) => + prop.type === "Property" && + prop.key.type === "Identifier" && + prop.key.name === "logger", + ); + } + return false; + }); + } + return false; + }); + } + + // Retrieve the last import statement. + function getLastImportNode() { + const imports = sourceCode.ast.body.filter( + (node) => node.type === "ImportDeclaration", + ); + return imports.length > 0 ? imports[imports.length - 1] : null; + } + + return { + CallExpression(node) { + const callee = node.callee; + if ( + callee && + callee.type === "MemberExpression" && + callee.object && + callee.object.name === "console" && + callee.property && + ["log", "error", "debug", "warn"].includes(callee.property.name) + ) { + const method = callee.property.name; + // Determine the replacement method: "log" should become "info". + const newMethod = method === "log" ? "info" : method; + context.report({ + node: callee, + message: `Please use our internal logger.${newMethod} instead of console.${method}`, + fix(fixer) { + const fixes = []; + const args = node.arguments; + + // Replace 'console' with 'logger' + fixes.push(fixer.replaceText(callee.object, "logger")); + // Replace the property; if it's "log", change to "info" + fixes.push(fixer.replaceText(callee.property, newMethod)); + + // Check if we need to transform arguments for error-level methods + // If the first argument appears to be an error variable (common pattern: err, error, e) + // and there's only one argument, we should add a descriptive message + if ( + (newMethod === "error" || newMethod === "warn") && + args.length === 1 && + args[0].type === "Identifier" && + /^(err|error|e|.+Error|.+Err|failBotError|exception)$/i.test( + args[0].name, + ) + ) { + // Transform console.error(err) to logger.error('Error occurred', { err }) + // This makes the log message more useful and follows structured logging pattern + const errorVarName = sourceCode.getText(args[0]); + fixes.push( + fixer.replaceText( + args[0], + `'Error occurred', { ${errorVarName} }`, + ), + ); + } + + // Insert our logger setup (import + declaration) only once per file. + if (!setupInserted) { + setupInserted = true; + + const needsImport = needsLoggerImport(); + const needsDeclaration = needsLoggerDeclaration(); + const lastImport = getLastImportNode(); + + if (needsImport && needsDeclaration) { + // Insert both import and declaration together + if (lastImport) { + fixes.push( + fixer.insertTextAfter( + lastImport, + "\nimport { createLogger } from '@/observability/logger';\n\nconst logger = createLogger(import.meta.url);\n", + ), + ); + } else { + // No imports – insert at the top + fixes.push( + fixer.insertTextBeforeRange( + [0, 0], + "import { createLogger } from '@/observability/logger';\n\nconst logger = createLogger(import.meta.url);\n", + ), + ); + } + } else if (needsImport) { + // Only insert the import + if (lastImport) { + fixes.push( + fixer.insertTextAfter( + lastImport, + "\nimport { createLogger } from '@/observability/logger';", + ), + ); + } else { + fixes.push( + fixer.insertTextBeforeRange( + [0, 0], + "import { createLogger } from '@/observability/logger';\n", + ), + ); + } + } else if (needsDeclaration) { + // Only insert the logger declaration + if (lastImport) { + fixes.push( + fixer.insertTextAfter( + lastImport, + "\n\nconst logger = createLogger(import.meta.url);\n", + ), + ); + } else { + fixes.push( + fixer.insertTextAfterRange( + [0, 0], + "\nconst logger = createLogger(import.meta.url);\n", + ), + ); + } + } + } + + return fixes; + }, + }); + } + }, + }; + }, +}; diff --git a/src/events/components/events.ts b/src/events/components/events.ts index 111e3afcf8ad..f1a539e5b339 100644 --- a/src/events/components/events.ts +++ b/src/events/components/events.ts @@ -6,6 +6,7 @@ import { isLoggedIn } from '@/frame/components/hooks/useHasAccount' import { getExperimentVariationForContext } from './experiments/experiment' import { EventType, EventPropsByType } from '../types' import { isHeadless } from './is-headless' +import { sendHydroAnalyticsEvent, getOctoClientId } from './hydro-analytics' const COOKIE_NAME = '_docs-events' @@ -114,6 +115,7 @@ export function sendEvent({ content_type: getMetaContent('page-content-type'), status: Number(getMetaContent('status') || 0), is_logged_in: isLoggedIn(), + octo_client_id: getOctoClientId(), // Device information // os, os_version, browser, browser_version: @@ -152,6 +154,9 @@ export function sendEvent({ queueEvent(body) + // Send events to hydro-analytics-client for cross-subdomain tracking + sendHydroAnalyticsEvent(body) + if (type === EventType.exit) { flushQueue() } diff --git a/src/events/components/hydro-analytics.ts b/src/events/components/hydro-analytics.ts new file mode 100644 index 000000000000..7a39ac08651e --- /dev/null +++ b/src/events/components/hydro-analytics.ts @@ -0,0 +1,98 @@ +/** + * Integration with @github/hydro-analytics-client for cross-subdomain tracking. + * + * This sends events to collector.githubapp.com alongside our existing analytics. + * The client auto-collects: page, title, client_id, referrer, user_agent, + * screen_resolution, browser_resolution, browser_languages, pixel_ratio, timestamp, tz_seconds + * + * We send all other docs-specific context fields, including: + * - path_language, path_version, path_product, path_article + * - page_document_type, page_type, content_type + * - color_mode_preference, is_logged_in, experiment_variation, is_headless + * - event_id, page_event_id, octo_client_id + * - Plus any event-specific properties (exit metrics, link_url, etc.) + * + * All functions are wrapped in try/catch to ensure that issues with the + * hydro-analytics-client or collector don't affect our primary analytics. + */ + +import { + AnalyticsClient, + getOrCreateClientId as hydroGetOrCreateClientId, +} from '@github/hydro-analytics-client' +import { EventType } from '../types' + +/** + * Safe wrapper around hydro-analytics-client's getOrCreateClientId. + * Returns undefined if the client fails for any reason. + */ +export function getOctoClientId(): string | undefined { + try { + return hydroGetOrCreateClientId() + } catch (error) { + console.log('hydro-analytics-client getOctoClientId error:', error) + return undefined + } +} + +const hydroClient = new AnalyticsClient({ + collectorUrl: 'https://collector.githubapp.com/docs/collect', + clientId: getOctoClientId(), +}) + +// Fields that hydro-analytics-client already collects automatically +const AUTO_COLLECTED_FIELDS = new Set([ + 'referrer', + 'user_agent', + 'viewport_width', + 'viewport_height', + 'screen_width', + 'screen_height', + 'pixel_ratio', + 'timezone', + 'user_language', + 'href', + 'title', +]) + +/** + * Flatten a nested event body into a single-level context object, + * excluding fields that hydro-analytics-client already auto-collects. + */ +export function prepareData(body: Record): { + type: string + context: Record +} { + const { context: nestedContext, type, ...rest } = body + const flattened = { + ...((nestedContext as Record) || {}), + ...rest, + } + const context = Object.fromEntries( + Object.entries(flattened) + .filter(([, value]) => value != null) + .filter(([key]) => !AUTO_COLLECTED_FIELDS.has(key)) + .map(([key, value]) => [key, String(value)]), + ) + return { type: typeof type === 'string' ? type : 'unknown', context } +} + +/** + * Send an event to hydro-analytics-client. + * For page events, sends as a page view. For all other events, sends as a custom event. + * + * This is wrapped in try/catch to ensure that if the hydro collector is down + * or errors, it doesn't affect our primary analytics pipeline. + */ +export function sendHydroAnalyticsEvent(body: Record): void { + try { + const { type, context } = prepareData(body) + if (type === EventType.page) { + hydroClient.sendPageView(context) + } else { + hydroClient.sendEvent(type, context) + } + } catch (error) { + console.log('hydro-analytics-client error:', error) + } +} diff --git a/src/events/lib/schema.ts b/src/events/lib/schema.ts index 3f1bb45ccddd..719457cbaa8e 100644 --- a/src/events/lib/schema.ts +++ b/src/events/lib/schema.ts @@ -124,6 +124,11 @@ const context = { type: 'boolean', description: 'The cookie value of staffonly', }, + octo_client_id: { + type: 'string', + description: + 'The _octo cookie client ID for cross-subdomain tracking with github.com analytics.', + }, // Device information os: { diff --git a/src/events/tests/hydro-analytics.ts b/src/events/tests/hydro-analytics.ts new file mode 100644 index 000000000000..e29858da7f18 --- /dev/null +++ b/src/events/tests/hydro-analytics.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from 'vitest' +import { prepareData } from '../components/hydro-analytics' + +describe('prepareData', () => { + test('flattens nested context into top level', () => { + const body = { + type: 'page', + context: { + event_id: '123', + path_language: 'en', + }, + } + const result = prepareData(body) + expect(result.type).toBe('page') + expect(result.context.event_id).toBe('123') + expect(result.context.path_language).toBe('en') + }) + + test('includes top-level props alongside context', () => { + const body = { + type: 'exit', + context: { event_id: '123' }, + exit_scroll_length: 0.75, + } + const result = prepareData(body) + expect(result.type).toBe('exit') + expect(result.context.event_id).toBe('123') + expect(result.context.exit_scroll_length).toBe('0.75') + }) + + test('filters out auto-collected fields', () => { + const body = { + type: 'page', + context: { + event_id: '123', + referrer: 'https://google.com', + user_agent: 'Mozilla/5.0', + viewport_width: 1024, + title: 'Test Page', + path_language: 'en', + }, + } + const result = prepareData(body) + expect(result.context.event_id).toBe('123') + expect(result.context.path_language).toBe('en') + expect(result.context.referrer).toBeUndefined() + expect(result.context.user_agent).toBeUndefined() + expect(result.context.viewport_width).toBeUndefined() + expect(result.context.title).toBeUndefined() + }) + + test('filters out null and undefined values', () => { + const body = { + type: 'page', + context: { + event_id: '123', + path_language: null, + path_version: undefined, + path_product: 'actions', + }, + } + const result = prepareData(body) + expect(result.context.event_id).toBe('123') + expect(result.context.path_product).toBe('actions') + expect(result.context.path_language).toBeUndefined() + expect(result.context.path_version).toBeUndefined() + }) + + test('converts all values to strings', () => { + const body = { + type: 'exit', + context: { + status: 200, + is_logged_in: true, + is_headless: false, + }, + } + const result = prepareData(body) + expect(result.context.status).toBe('200') + expect(result.context.is_logged_in).toBe('true') + expect(result.context.is_headless).toBe('false') + }) + + test('defaults type to unknown if not a string', () => { + const body = { + type: 123, + context: { event_id: '123' }, + } + const result = prepareData(body) + expect(result.type).toBe('unknown') + }) + + test('handles missing context gracefully', () => { + const body = { + type: 'page', + exit_scroll_length: 0.5, + } + const result = prepareData(body) + expect(result.type).toBe('page') + expect(result.context.exit_scroll_length).toBe('0.5') + }) +}) diff --git a/src/events/types.ts b/src/events/types.ts index 36e63d0dc7eb..006d244ada23 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -41,6 +41,7 @@ export type EventProps = { is_logged_in: boolean dotcom_user: string is_staff: boolean + octo_client_id?: string os: string os_version: string browser: string diff --git a/src/frame/middleware/helmet.ts b/src/frame/middleware/helmet.ts index e045cbbaeb80..f2979fab0776 100644 --- a/src/frame/middleware/helmet.ts +++ b/src/frame/middleware/helmet.ts @@ -28,7 +28,9 @@ const DEFAULT_OPTIONS = { prefetchSrc: ["'self'"], // When doing local dev, especially in Safari, you need to add `ws:` // which NextJS uses for the hot module reloading. - connectSrc: ["'self'", isDev && 'ws:'].filter(Boolean) as string[], + connectSrc: ["'self'", 'https://collector.githubapp.com', isDev && 'ws:'].filter( + Boolean, + ) as string[], fontSrc: ["'self'", 'data:'], imgSrc: [...GITHUB_DOMAINS, 'data:', 'placehold.it'], objectSrc: ["'self'"], diff --git a/src/graphql/data/fpt/changelog.json b/src/graphql/data/fpt/changelog.json index cc48673762b5..7f783c6c4b8d 100644 --- a/src/graphql/data/fpt/changelog.json +++ b/src/graphql/data/fpt/changelog.json @@ -1,4 +1,18 @@ [ + { + "schemaChanges": [ + { + "title": "The GraphQL schema includes these changes:", + "changes": [ + "

Field Issue.projectItems changed type from 'ProjectV2ItemConnection!toProjectV2ItemConnection'

", + "

Field PullRequest.projectItems changed type from 'ProjectV2ItemConnection!toProjectV2ItemConnection'

" + ] + } + ], + "previewChanges": [], + "upcomingChanges": [], + "date": "2026-01-28" + }, { "schemaChanges": [ { diff --git a/src/graphql/data/fpt/schema.docs.graphql b/src/graphql/data/fpt/schema.docs.graphql index e4ca2fa7349e..e9844d749559 100644 --- a/src/graphql/data/fpt/schema.docs.graphql +++ b/src/graphql/data/fpt/schema.docs.graphql @@ -19282,7 +19282,7 @@ type Issue implements Assignable & Closable & Comment & Deletable & Labelable & Returns the last _n_ elements from the list. """ last: Int - ): ProjectV2ItemConnection! + ): ProjectV2ItemConnection """ Find a project by number. @@ -41103,7 +41103,7 @@ type PullRequest implements Assignable & Closable & Comment & Labelable & Lockab Returns the last _n_ elements from the list. """ last: Int - ): ProjectV2ItemConnection! + ): ProjectV2ItemConnection """ Find a project by number. diff --git a/src/graphql/data/fpt/schema.json b/src/graphql/data/fpt/schema.json index 3ae900ab2ef5..c4cdcb9f1995 100644 --- a/src/graphql/data/fpt/schema.json +++ b/src/graphql/data/fpt/schema.json @@ -30102,7 +30102,7 @@ { "name": "projectItems", "description": "

List of project items associated with this issue.

", - "type": "ProjectV2ItemConnection!", + "type": "ProjectV2ItemConnection", "id": "projectv2itemconnection", "kind": "objects", "href": "/graphql/reference/objects#projectv2itemconnection", @@ -54714,7 +54714,7 @@ { "name": "projectItems", "description": "

List of project items associated with this pull request.

", - "type": "ProjectV2ItemConnection!", + "type": "ProjectV2ItemConnection", "id": "projectv2itemconnection", "kind": "objects", "href": "/graphql/reference/objects#projectv2itemconnection", diff --git a/src/graphql/data/ghec/schema.docs.graphql b/src/graphql/data/ghec/schema.docs.graphql index e4ca2fa7349e..e9844d749559 100644 --- a/src/graphql/data/ghec/schema.docs.graphql +++ b/src/graphql/data/ghec/schema.docs.graphql @@ -19282,7 +19282,7 @@ type Issue implements Assignable & Closable & Comment & Deletable & Labelable & Returns the last _n_ elements from the list. """ last: Int - ): ProjectV2ItemConnection! + ): ProjectV2ItemConnection """ Find a project by number. @@ -41103,7 +41103,7 @@ type PullRequest implements Assignable & Closable & Comment & Labelable & Lockab Returns the last _n_ elements from the list. """ last: Int - ): ProjectV2ItemConnection! + ): ProjectV2ItemConnection """ Find a project by number. diff --git a/src/graphql/data/ghec/schema.json b/src/graphql/data/ghec/schema.json index 3ae900ab2ef5..c4cdcb9f1995 100644 --- a/src/graphql/data/ghec/schema.json +++ b/src/graphql/data/ghec/schema.json @@ -30102,7 +30102,7 @@ { "name": "projectItems", "description": "

List of project items associated with this issue.

", - "type": "ProjectV2ItemConnection!", + "type": "ProjectV2ItemConnection", "id": "projectv2itemconnection", "kind": "objects", "href": "/graphql/reference/objects#projectv2itemconnection", @@ -54714,7 +54714,7 @@ { "name": "projectItems", "description": "

List of project items associated with this pull request.

", - "type": "ProjectV2ItemConnection!", + "type": "ProjectV2ItemConnection", "id": "projectv2itemconnection", "kind": "objects", "href": "/graphql/reference/objects#projectv2itemconnection", diff --git a/src/observability/README.md b/src/observability/README.md index 4fb9f995a06c..732a12f97db2 100644 --- a/src/observability/README.md +++ b/src/observability/README.md @@ -16,6 +16,10 @@ This subject is responsible for: Note: This tracks system health, not user behavior. User behavior tracking is in [`src/events`](../events/README.md). +## Logging + +Please see the [logger README](./logger/README.md) for details on using the logger. + ## Architecture & Key Assets ### Key capabilities and their locations diff --git a/src/search/middleware/search-routes.ts b/src/search/middleware/search-routes.ts index 89342dae0968..8deb2040f641 100644 --- a/src/search/middleware/search-routes.ts +++ b/src/search/middleware/search-routes.ts @@ -10,7 +10,9 @@ import catchMiddlewareError from '@/observability/middleware/catch-middleware-er import { generalSearchRoute } from '@/search/lib/routes/general-search-route' import { aiSearchAutocompleteRoute } from '@/search/lib/routes/ai-search-autocomplete-route' import { combinedSearchRoute } from '@/search/lib/routes/combined-search-route' +import { createLogger } from '@/observability/logger' +const logger = createLogger('search:middleware:search-routes') const router = express.Router() router.get('/legacy', (req: Request, res: Response) => { @@ -37,7 +39,16 @@ export async function handleGetSearchResultsError( const reports = FailBot.report(error, { url: req.url, ...options }) if (reports) await Promise.all(reports) } - res.status(500).json({ error: error.message }) + // Avoid "Cannot set headers after they are sent to the client" error + // if response was already partially sent before the error occurred + if (!res.headersSent) { + res.status(500).json({ error: error.message }) + } else { + logger.warn('Response headers already sent; unable to send error response.', { + url: req.url, + message: error?.message, + }) + } } // Redirects search routes to their latest versions diff --git a/src/secret-scanning/data/public-docs-schema.ts b/src/secret-scanning/data/public-docs-schema.ts index c383e7da919c..5820116614b3 100644 --- a/src/secret-scanning/data/public-docs-schema.ts +++ b/src/secret-scanning/data/public-docs-schema.ts @@ -19,6 +19,7 @@ export interface SecretScanningEntry { isPrivateWithGhas: boolean | string hasPushProtection: boolean | string hasValidityCheck: boolean | string + hasExtendedMetadata?: boolean | string base64Supported: boolean | string isduplicate: boolean } @@ -67,6 +68,10 @@ export default { description: 'whether the secret has its validation status checked', type: ['boolean', 'string'], }, + hasExtendedMetadata: { + description: 'whether extended metadata is available for this secret', + type: ['boolean', 'string'], + }, base64Supported: { description: 'whether scanning for base64-encoded versions of this type is supported', type: ['boolean', 'string'], diff --git a/src/types/eslint-plugins.d.ts b/src/types/eslint-plugins.d.ts index acbb6a189037..9c948f7ae537 100644 --- a/src/types/eslint-plugins.d.ts +++ b/src/types/eslint-plugins.d.ts @@ -55,3 +55,11 @@ declare module 'eslint-plugin-no-only-tests' { export default plugin } + +declare module 'eslint-plugin-custom-rules' { + import type { ESLint } from 'eslint' + + const plugin: ESLint.Plugin + + export default plugin +}