From a903fcbae1b7f9e97be280d93791f4be504f23c5 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 2 Sep 2025 08:37:05 -0700 Subject: [PATCH 1/4] Replace got.stream with native fetch streaming (Final Phase) (#57196) --- src/frame/lib/fetch-utils.ts | 21 +++ src/search/lib/ai-search-proxy.ts | 132 ++++++++++-------- .../middleware/ai-search-local-proxy.ts | 84 +++++++---- src/search/tests/ai-search-local-proxy.ts | 106 ++++++++++++++ src/search/tests/api-ai-search.ts | 68 +++++++++ 5 files changed, 330 insertions(+), 81 deletions(-) create mode 100644 src/search/tests/ai-search-local-proxy.ts diff --git a/src/frame/lib/fetch-utils.ts b/src/frame/lib/fetch-utils.ts index f90bc3f6c61f..5b3c57fd9a91 100644 --- a/src/frame/lib/fetch-utils.ts +++ b/src/frame/lib/fetch-utils.ts @@ -112,3 +112,24 @@ export async function fetchWithRetry( throw lastError || new Error('Maximum retries exceeded') } + +/** + * Create a streaming fetch request that returns a ReadableStream + * This replaces got.stream functionality + */ +export async function fetchStream( + url: string | URL, + init?: RequestInit, + options: FetchWithRetryOptions = {}, +): Promise { + const { timeout, throwHttpErrors = true } = options + + const response = await fetchWithTimeout(url, init, timeout) + + // Check for HTTP errors if throwHttpErrors is enabled + if (throwHttpErrors && !response.ok && response.status >= 400) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response +} diff --git a/src/search/lib/ai-search-proxy.ts b/src/search/lib/ai-search-proxy.ts index 599ed87dd03f..75a4a42b4cbf 100644 --- a/src/search/lib/ai-search-proxy.ts +++ b/src/search/lib/ai-search-proxy.ts @@ -1,6 +1,6 @@ import { Response } from 'express' import statsd from '@/observability/lib/statsd' -import got from 'got' +import { fetchStream } from '@/frame/lib/fetch-utils' import { getHmacWithEpoch } from '@/search/lib/helpers/get-cse-copilot-auth' import { getCSECopilotSource } from '@/search/lib/helpers/cse-copilot-docs-versions' import type { ExtendedRequest } from '@/types' @@ -56,56 +56,76 @@ export const aiSearchProxy = async (req: ExtendedRequest, res: Response) => { stream: true, } + let reader: ReadableStreamDefaultReader | null = null + try { // TODO: We temporarily add ?ai_search=1 to use a new pattern in cgs-copilot production - const stream = got.stream.post(`${process.env.CSE_COPILOT_ENDPOINT}/answers?ai_search=1`, { - json: body, - headers: { - Authorization: getHmacWithEpoch(), - 'Content-Type': 'application/json', + const response = await fetchStream( + `${process.env.CSE_COPILOT_ENDPOINT}/answers?ai_search=1`, + { + method: 'POST', + body: JSON.stringify(body), + headers: { + Authorization: getHmacWithEpoch(), + 'Content-Type': 'application/json', + }, }, - }) - - // Listen for data events to count characters - stream.on('data', (chunk: Buffer | string) => { - // Ensure we have a string for proper character count - const dataStr = typeof chunk === 'string' ? chunk : chunk.toString() - totalChars += dataStr.length - }) - - // Handle the upstream response before piping - stream.on('response', (upstreamResponse) => { - if (upstreamResponse.statusCode !== 200) { - const errorMessage = `Upstream server responded with status code ${upstreamResponse.statusCode}` - console.error(errorMessage) - statsd.increment('ai-search.stream_response_error', 1, diagnosticTags) - res.status(upstreamResponse.statusCode).json({ - errors: [{ message: errorMessage }], - upstreamStatus: upstreamResponse.statusCode, - }) - stream.destroy() - } else { - // Set response headers - res.setHeader('Content-Type', 'application/x-ndjson') - res.flushHeaders() - - // Pipe the got stream directly to the response - stream.pipe(res) + { + throwHttpErrors: false, + }, + ) + + if (!response.ok) { + const errorMessage = `Upstream server responded with status code ${response.status}` + console.error(errorMessage) + statsd.increment('ai-search.stream_response_error', 1, diagnosticTags) + res.status(response.status).json({ + errors: [{ message: errorMessage }], + upstreamStatus: response.status, + }) + return + } + + // Set response headers + res.setHeader('Content-Type', 'application/x-ndjson') + res.flushHeaders() + + // Stream the response body + if (!response.body) { + res.status(500).json({ errors: [{ message: 'No response body' }] }) + return + } + + reader = response.body.getReader() + const decoder = new TextDecoder() + + try { + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + // Decode chunk and count characters + const chunk = decoder.decode(value, { stream: true }) + totalChars += chunk.length + + // Write chunk to response + res.write(chunk) } - }) - // Handle stream errors - stream.on('error', (error: any) => { - console.error('Error streaming from cse-copilot:', error) + // Calculate metrics on stream end + const totalResponseTime = Date.now() - startTime // in ms + const charPerMsRatio = totalResponseTime > 0 ? totalChars / totalResponseTime : 0 // chars per ms - if (error?.code === 'ERR_NON_2XX_3XX_RESPONSE') { - const upstreamStatus = error?.response?.statusCode || 500 - return res.status(upstreamStatus).json({ - errors: [{ message: 'Upstream server error' }], - upstreamStatus, - }) - } + statsd.gauge('ai-search.total_response_time', totalResponseTime, diagnosticTags) + statsd.gauge('ai-search.response_chars_per_ms', charPerMsRatio, diagnosticTags) + statsd.increment('ai-search.success_stream_end', 1, diagnosticTags) + res.end() + } catch (streamError) { + console.error('Error streaming from cse-copilot:', streamError) statsd.increment('ai-search.stream_error', 1, diagnosticTags) if (!res.headersSent) { @@ -117,22 +137,20 @@ export const aiSearchProxy = async (req: ExtendedRequest, res: Response) => { res.write(errorMessage) res.end() } - }) - - // Calculate metrics on stream end - stream.on('end', () => { - const totalResponseTime = Date.now() - startTime // in ms - const charPerMsRatio = totalResponseTime > 0 ? totalChars / totalResponseTime : 0 // chars per ms - - statsd.gauge('ai-search.total_response_time', totalResponseTime, diagnosticTags) - statsd.gauge('ai-search.response_chars_per_ms', charPerMsRatio, diagnosticTags) - - statsd.increment('ai-search.success_stream_end', 1, diagnosticTags) - res.end() - }) + } finally { + if (reader) { + reader.releaseLock() + reader = null + } + } } catch (error) { statsd.increment('ai-search.route_error', 1, diagnosticTags) console.error('Error posting /answers to cse-copilot:', error) res.status(500).json({ errors: [{ message: 'Internal server error' }] }) + } finally { + // Ensure reader lock is always released + if (reader) { + reader.releaseLock() + } } } diff --git a/src/search/middleware/ai-search-local-proxy.ts b/src/search/middleware/ai-search-local-proxy.ts index 327ffa991072..f3ff0e069bae 100644 --- a/src/search/middleware/ai-search-local-proxy.ts +++ b/src/search/middleware/ai-search-local-proxy.ts @@ -1,8 +1,8 @@ // When in local development we want to proxy to the ai-search route at docs.github.com import { Router, Request, Response, NextFunction } from 'express' -import got from 'got' -import { pipeline } from 'node:stream' +import { fetchStream } from '@/frame/lib/fetch-utils' +import { pipeline, Readable } from 'node:stream' const router = Router() @@ -18,12 +18,13 @@ const hopByHop = new Set([ ]) function filterRequestHeaders(src: Request['headers']) { - const out: Record = {} + const out: Record = {} for (const [key, value] of Object.entries(src)) { if (!value) continue const k = key.toLowerCase() if (hopByHop.has(k) || k === 'cookie' || k === 'host') continue - out[key] = value + // Convert array values to string + out[key] = Array.isArray(value) ? value[0] : value } out['accept'] = 'application/x-ndjson' out['content-type'] = 'application/json' @@ -31,39 +32,74 @@ function filterRequestHeaders(src: Request['headers']) { } router.post('/ai-search/v1', async (req: Request, res: Response, next: NextFunction) => { + let reader: ReadableStreamDefaultReader | null = null + try { - const upstream = got.stream.post('https://docs.github.com/api/ai-search/v1', { - headers: filterRequestHeaders(req.headers), - body: JSON.stringify(req.body ?? {}), - decompress: false, - throwHttpErrors: false, - retry: { limit: 0 }, - }) + const response = await fetchStream( + 'https://docs.github.com/api/ai-search/v1', + { + method: 'POST', + headers: filterRequestHeaders(req.headers), + body: JSON.stringify(req.body ?? {}), + }, + { + throwHttpErrors: false, + }, + ) - upstream.on('response', (uRes) => { - res.status(uRes.statusCode || 500) + // Set status code + res.status(response.status || 500) - for (const [k, v] of Object.entries(uRes.headers)) { - if (!v) continue - const key = k.toLowerCase() - // Never forward hop-by-hop; got already handles chunked → strip content-length - if (hopByHop.has(key) || key === 'content-length') continue - res.setHeader(k, v as string) - } - res.flushHeaders?.() + // Forward response headers + for (const [k, v] of response.headers.entries()) { + if (!v) continue + const key = k.toLowerCase() + // Never forward hop-by-hop; fetch already handles chunked → strip content-length + if (hopByHop.has(key) || key === 'content-length') continue + res.setHeader(k, v) + } + res.flushHeaders?.() + + // Convert fetch ReadableStream to Node.js Readable stream for pipeline + if (!response.body) { + if (!res.headersSent) res.status(502).end('Bad Gateway') + return + } + + reader = response.body.getReader() + const nodeStream = new Readable({ + async read() { + try { + const { done, value } = await reader!.read() + if (done) { + this.push(null) + } else { + this.push(Buffer.from(value)) + } + } catch (err) { + this.destroy(err as Error) + } + }, }) - pipeline(upstream, res, (err) => { + pipeline(nodeStream, res, (err) => { if (err) { console.error('[ai-search proxy] pipeline error:', err) if (!res.headersSent) res.status(502).end('Bad Gateway') } + if (reader) { + reader.releaseLock() + reader = null + } }) - - upstream.on('error', (err) => console.error('[ai-search proxy] upstream error:', err)) } catch (err) { console.error('[ai-search proxy] request failed:', err) next(err) + } finally { + // Ensure reader lock is always released + if (reader) { + reader.releaseLock() + } } }) diff --git a/src/search/tests/ai-search-local-proxy.ts b/src/search/tests/ai-search-local-proxy.ts new file mode 100644 index 000000000000..e301fb07d255 --- /dev/null +++ b/src/search/tests/ai-search-local-proxy.ts @@ -0,0 +1,106 @@ +import { expect, test, describe } from 'vitest' + +import { get, post } from '@/tests/helpers/e2etest' + +describe('AI Search Local Proxy Middleware', () => { + test('should successfully proxy to docs.github.com when CSE_COPILOT_ENDPOINT is not localhost', async () => { + // In local development, the middleware should proxy to docs.github.com + // This test verifies the middleware handles the proxy correctly + + // We can't easily test the actual proxying without setting up a mock for docs.github.com + // But we can test that the route exists and handles requests + const body = { query: 'test query', version: 'dotcom' } + const response = await post('/api/ai-search/v1', { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) + + // The response should either succeed or fail gracefully + // depending on whether docs.github.com is reachable + expect([200, 500, 502, 503, 504]).toContain(response.statusCode) + }) + + test('should handle request body correctly in proxy', async () => { + const testBody = { + query: 'test query with special chars: éñ中文', + version: 'dotcom', + nested: { key: 'value' }, + array: [1, 2, 3], + } + + const response = await post('/api/ai-search/v1', { + body: JSON.stringify(testBody), + headers: { 'Content-Type': 'application/json' }, + }) + + // Should handle complex request bodies without crashing + expect([200, 500, 502, 503, 504]).toContain(response.statusCode) + }) + + test('should handle empty request body in proxy', async () => { + const response = await post('/api/ai-search/v1', { + body: JSON.stringify({}), + headers: { 'Content-Type': 'application/json' }, + }) + + // Should handle empty body gracefully + expect([200, 400, 500, 502, 503, 504]).toContain(response.statusCode) + }) + + test('should handle malformed JSON in proxy', async () => { + const response = await post('/api/ai-search/v1', { + body: '{ invalid json }', + headers: { 'Content-Type': 'application/json' }, + }) + + // Should handle malformed JSON gracefully + expect([400, 500]).toContain(response.statusCode) + }) + + test('should preserve important headers in proxy', async () => { + const response = await post('/api/ai-search/v1', { + body: JSON.stringify({ query: 'test', version: 'dotcom' }), + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'test-agent/1.0', + 'Accept-Language': 'en-US,en;q=0.9', + }, + }) + + // Headers should be processed without causing errors + expect([200, 500, 502, 503, 504]).toContain(response.statusCode) + }) + + test('should filter hop-by-hop headers correctly', async () => { + const response = await post('/api/ai-search/v1', { + body: JSON.stringify({ query: 'test', version: 'dotcom' }), + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'test-agent', + 'X-Custom-Header': 'test-value', + // Note: Connection, Transfer-Encoding, Upgrade are forbidden headers in fetch + // We test with other headers that should be filtered by the middleware + }, + }) + + // Should succeed despite hop-by-hop headers being present + expect([200, 500, 502, 503, 504]).toContain(response.statusCode) + }) + + test('should handle various request methods correctly', async () => { + // Test that only POST is supported + const getResponse = await get('/api/ai-search/v1') + expect([404, 405]).toContain(getResponse.statusCode) + }) + + test('should handle large request bodies in proxy', async () => { + const largeQuery = 'test query '.repeat(1000) // Create a large query string + const response = await post('/api/ai-search/v1', { + body: JSON.stringify({ query: largeQuery, version: 'dotcom' }), + headers: { 'Content-Type': 'application/json' }, + }) + + // Should handle large bodies without crashing + expect([200, 413, 500, 502, 503, 504]).toContain(response.statusCode) + }) +}) diff --git a/src/search/tests/api-ai-search.ts b/src/search/tests/api-ai-search.ts index 7512ca23550c..abf9dddcd545 100644 --- a/src/search/tests/api-ai-search.ts +++ b/src/search/tests/api-ai-search.ts @@ -123,4 +123,72 @@ describe('AI Search Routes', () => { { message: `Missing required key 'query' in request body` }, ]) }) + + test('should handle streaming response correctly', async () => { + // This test verifies the streaming response processing works + const body = { query: 'test streaming query', version: 'dotcom' } + const response = await fetch('http://localhost:4000/api/ai-search/v1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + expect(response.ok).toBe(true) + expect(response.headers.get('content-type')).toBe('application/x-ndjson') + + // Verify we can read the stream without errors + if (response.body) { + const reader = response.body.getReader() + const decoder = new TextDecoder() + let chunks = [] + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(decoder.decode(value, { stream: true })) + } + expect(chunks.length).toBeGreaterThan(0) + } finally { + reader.releaseLock() + } + } + }) + + test('should handle invalid version parameter', async () => { + const body = { query: 'test query', version: 'invalid-version' } + const response = await post('/api/ai-search/v1', { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) + + const responseBody = JSON.parse(response.body) + + expect(response.statusCode).toBe(400) + expect(responseBody.errors).toBeDefined() + expect(responseBody.errors[0].message).toContain("Invalid 'version' in request body") + }) + + test('should handle non-string query parameter', async () => { + const body = { query: 123, version: 'dotcom' } + const response = await post('/api/ai-search/v1', { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) + + const responseBody = JSON.parse(response.body) + + expect(response.statusCode).toBe(400) + expect(responseBody.errors).toBeDefined() + expect(responseBody.errors[0].message).toBe("Invalid 'query' in request body. Must be a string") + }) + + test('should handle malformed JSON in request body', async () => { + const response = await post('/api/ai-search/v1', { + body: '{ invalid json }', + headers: { 'Content-Type': 'application/json' }, + }) + + expect(response.statusCode).toBe(400) + }) }) From 18c72930dd45a17e5a3bbd329aa4af30774153d5 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 2 Sep 2025 09:20:56 -0700 Subject: [PATCH 2/4] Remove got dependency (#57362) --- package-lock.json | 120 +++++++--------------------------------------- package.json | 1 - 2 files changed, 17 insertions(+), 104 deletions(-) diff --git a/package-lock.json b/package-lock.json index 209899e532aa..19bb69d4f042 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,6 @@ "flat": "^6.0.1", "github-slugger": "^2.0.0", "glob": "11.0.2", - "got": "^14.4.7", "hast-util-from-parse5": "^8.0.3", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", @@ -3720,11 +3719,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==" - }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -3886,6 +3880,7 @@ }, "node_modules/@szmarczak/http-timer": { "version": "5.0.1", + "dev": true, "license": "MIT", "dependencies": { "defer-to-connect": "^2.0.1" @@ -4072,7 +4067,8 @@ "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==" + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true }, "node_modules/@types/http-errors": { "version": "2.0.4", @@ -5639,6 +5635,7 @@ }, "node_modules/cacheable-lookup": { "version": "7.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=14.16" @@ -6457,6 +6454,7 @@ }, "node_modules/decompress-response": { "version": "6.0.0", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -6470,6 +6468,7 @@ }, "node_modules/decompress-response/node_modules/mimic-response": { "version": "3.1.0", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6529,6 +6528,7 @@ }, "node_modules/defer-to-connect": { "version": "2.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -8749,102 +8749,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/got": { - "version": "14.4.7", - "resolved": "https://registry.npmjs.org/got/-/got-14.4.7.tgz", - "integrity": "sha512-DI8zV1231tqiGzOiOzQWDhsBmncFW7oQDH6Zgy6pDPrqJuVZMtoSgPLLsBZQj8Jg4JFfwoOsDA8NGtLQLnIx2g==", - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^7.0.1", - "@szmarczak/http-timer": "^5.0.1", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^12.0.1", - "decompress-response": "^6.0.0", - "form-data-encoder": "^4.0.2", - "http2-wrapper": "^2.2.1", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^4.0.1", - "responselike": "^3.0.0", - "type-fest": "^4.26.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/got/node_modules/@sindresorhus/is": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.1.tgz", - "integrity": "sha512-QWLl2P+rsCJeofkDNIT3WFmb6NrRud1SUYW8dIhXK/46XFV8Q/g7Bsvib0Askb0reRLe+WYPeeE+l5cH7SlkuQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/got/node_modules/cacheable-request": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-12.0.1.tgz", - "integrity": "sha512-Yo9wGIQUaAfIbk+qY0X4cDQgCosecfBe3V9NSyeY4qPC2SAkbCS4Xj79VP8WOzitpJUZKc/wsRCYF5ariDIwkg==", - "dependencies": { - "@types/http-cache-semantics": "^4.0.4", - "get-stream": "^9.0.1", - "http-cache-semantics": "^4.1.1", - "keyv": "^4.5.4", - "mimic-response": "^4.0.0", - "normalize-url": "^8.0.1", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/got/node_modules/form-data-encoder": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.0.2.tgz", - "integrity": "sha512-KQVhvhK8ZkWzxKxOr56CPulAhH3dobtuQ4+hNQ+HekH/Wp5gSOafqRAeTphQUJAIk0GBvHZgJ2ZGRWd5kphMuw==", - "engines": { - "node": ">= 18" - } - }, - "node_modules/got/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/got/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/got/node_modules/p-cancelable": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", - "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==", - "engines": { - "node": ">=14.16" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -9340,6 +9244,7 @@ }, "node_modules/http-cache-semantics": { "version": "4.1.1", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/http-errors": { @@ -9431,6 +9336,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "dev": true, "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" @@ -9443,6 +9349,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, "engines": { "node": ">=10" }, @@ -10276,6 +10183,7 @@ }, "node_modules/json-buffer": { "version": "3.0.1", + "dev": true, "license": "MIT" }, "node_modules/json-schema-compare": { @@ -10396,6 +10304,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "dependencies": { "json-buffer": "3.0.1" } @@ -10828,6 +10737,7 @@ }, "node_modules/lowercase-keys": { "version": "3.0.0", + "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -11960,6 +11870,7 @@ }, "node_modules/mimic-response": { "version": "4.0.0", + "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -12667,6 +12578,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", + "dev": true, "engines": { "node": ">=14.16" }, @@ -13801,6 +13713,7 @@ }, "node_modules/resolve-alpn": { "version": "1.2.1", + "dev": true, "license": "MIT" }, "node_modules/resolve-from": { @@ -13823,6 +13736,7 @@ }, "node_modules/responselike": { "version": "3.0.0", + "dev": true, "license": "MIT", "dependencies": { "lowercase-keys": "^3.0.0" diff --git a/package.json b/package.json index 52b1236332d5..7b3607ec466c 100644 --- a/package.json +++ b/package.json @@ -191,7 +191,6 @@ "flat": "^6.0.1", "github-slugger": "^2.0.0", "glob": "11.0.2", - "got": "^14.4.7", "hast-util-from-parse5": "^8.0.3", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", From 28d1469764742f624f5c976c977f28a93834f911 Mon Sep 17 00:00:00 2001 From: mc <42146119+mchammer01@users.noreply.github.com> Date: Tue, 2 Sep 2025 18:00:28 +0100 Subject: [PATCH 3/4] GHSP ROI calculator in the secret risk assessment page (#57188) Co-authored-by: Sophie <29382425+sophietheking@users.noreply.github.com> Co-authored-by: Laura Coursen --- ...ing-the-cost-savings-of-push-protection.md | 88 +++++++++++++++++++ .../choosing-github-secret-protection.md | 8 ++ ...timating-the-price-of-secret-protection.md | 54 ++++++++++++ .../index.md | 2 + .../secret-risk-assessment-calculators.md | 2 + .../push-protection-roi-calculator.md | 1 + .../secret-protection/product-list.md | 2 +- data/variables/secret-scanning.yml | 2 + 8 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/calculating-the-cost-savings-of-push-protection.md create mode 100644 content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/estimating-the-price-of-secret-protection.md create mode 100644 data/reusables/gated-features/secret-risk-assessment-calculators.md create mode 100644 data/reusables/permissions/push-protection-roi-calculator.md diff --git a/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/calculating-the-cost-savings-of-push-protection.md b/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/calculating-the-cost-savings-of-push-protection.md new file mode 100644 index 000000000000..18309da30280 --- /dev/null +++ b/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/calculating-the-cost-savings-of-push-protection.md @@ -0,0 +1,88 @@ +--- +title: Calculating the cost savings of push protection +shortTitle: Push protection cost savings +intro: Learn how to use the {% data variables.secret-scanning.roi-calculator %} to estimate the remediation time and labor costs you'll avoid by preventing leaked secrets. +product: '{% data reusables.gated-features.secret-risk-assessment-calculators %}' +versions: + feature: secret-risk-assessment +permissions: '{% data reusables.permissions.push-protection-roi-calculator %}' +topics: + - Secret scanning + - Secret Protection +contentType: how-tos +--- + +## What is the cost savings calculator? + +You can use the {% data variables.secret-scanning.roi-calculator %} to estimate the cost avoided by preventing leaked secrets with push protection. This information can help you: + +* Determine how widely to enable {% data variables.product.prodname_GH_secret_protection %} in your organization. +* Compare the estimated impact of push protection in different teams or environments. +* Communicate time and cost implications of rollout decisions to stakeholders. + +Push protection is a paid feature which is available with {% data variables.product.prodname_GH_secret_protection %}. For more information, see [AUTOTITLE](/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/choosing-github-secret-protection). + +## Prerequisites + +* You need to have generated a secret risk assessment for your organization. See [AUTOTITLE](/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/viewing-the-secret-risk-assessment-report-for-your-organization). +* You have realistic values for: + * Average remediation time per leaked secret (hours) + * Average annual developer salary (USD) + +## Estimating cost savings from push protection + +{% data reusables.organizations.navigate-to-org %} +{% data reusables.organizations.security-overview %} +{% data reusables.security-overview.open-assessments-view %} +1. On the top right corner of the banner, click **Get started**. +1. In the dropdown, select **Estimate push protection savings**. +1. Review the non-editable value for "Preventable leaks" (P). If 0, a baseline value (such as 70) is shown for modeling purposes. +1. Enter or adjust the average developer annual compensation (C), in USD. + * Use blended fully loaded annual compensation (salary + benefits). + * Keep estimates conservative to avoid overstatement. +1. Enter or adjust the time to remediate each leaked secret (T), in hours. We recommend you use an average remediation time that reflects steps for revoking, rotating, and validating secrets, as well as notifying your teams or customers: + * T = 1-1.5 hours for simple rotation, minimal coordination + * T = 2-3 hours to account for a distributed team or extra checks + * T = 3-4 hours if you work in a regulated / audited environment +1. Review the outputs from the **Return on investment** panel: + * **Secrets prevented**: The number of preventable secrets detected. + * **Time saved**: Total hours saved by preventing these secrets, based on your input. + * **Potential savings with push protection**: The total estimated labor cost avoided. + +{% note %} + +Did you successfully use the {% data variables.secret-scanning.roi-calculator %} to estimate the cost savings of using push protection on your organization? + +Yes No + +{% endnote %} + +## Understanding your results + +Next, review the results to understand their implications and determine the appropriate scope for rolling out push protection in your organization. Keep the following information in mind as you interpret your results. + +The calculator **does**: +* Estimate savings for **secrets blocked by push protection** only. +* Base results on your risk assessment and assumptions you provide. +* Provide estimates based on **labor cost avoidance** only. +* Provide a modeled baseline for preventable leaks if no secrets were detected in the current scan window. + +The calculator does **not**: +* Include any costs related to data breaches or external impacts. For informational purposes, the cost of a data breach averaged $4.88M in 2024 according to IBM. +* Include time savings from other {% data variables.product.prodname_GH_secret_protection %} features. +* Support currencies other than USD. + +## Troubleshooting + +If you run into problems using the calculator, use the following table to troubleshoot. + +| Issue | Action | +|-------|--------| +| **Preventable secrets = 0** | When no preventable secrets are detected, the calculator displays a default baseline value (such as 70) for modeling purposes.
To replace the baseline with real data, enable push protection on more repositories and allow secret scanning to collect more information. | +| **Estimated savings shows $5M+** | The calculator is capped at $5M. If your modeled savings exceed this threshold, the value will be displayed as "$5M+" in the UI. To get the precise amount, export your input values (preventable secrets, time to remediate, and developer salary) and replicate the formula in a spreadsheet:
`(Secrets prevented) × (Time to remediate) × (Hourly rate)` where hourly rate is calculated as `Salary ÷ 2080`. | +| **Value seems low** | Review your inputs for time to remediate and average developer compensation. Ensure you have included all steps involved in remediation (such as revoke, rotate, validate, and notify) and that the salary reflects a fully loaded annual cost. | +| **Value seems high** | Double-check your input values for time to remediate and average compensation to make sure they are realistic and not overstated. Remove any outliers that could be skewing the estimate. | + +## Further reading + +* [Detecting and Preventing Secret Leaks in Code](https://github.com/resources/whitepapers/secret-scanning-a-key-to-your-cybersecurity-strategy) in {% data variables.product.github %}'s `resources` repository diff --git a/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/choosing-github-secret-protection.md b/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/choosing-github-secret-protection.md index 66fa2dd8f545..2f5a96679169 100644 --- a/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/choosing-github-secret-protection.md +++ b/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/choosing-github-secret-protection.md @@ -27,6 +27,14 @@ To generate a {% data variables.product.prodname_secret_risk_assessment %} repor {% data variables.product.prodname_secret_protection %} is billed per active committer to the repositories where it is enabled. It is available to users with a {% data variables.product.prodname_team %} or {% data variables.product.prodname_enterprise %} plan, see [AUTOTITLE](/billing/managing-billing-for-your-products/managing-billing-for-github-advanced-security/about-billing-for-github-advanced-security). +{% ifversion fpt or ghec or ghes > 3.19 %} + +{% data variables.product.github %} provides two calculators to help you budget, justify rollout scope, and prioritize which repositories to enable {% data variables.product.prodname_secret_protection %} on first while optimizing license usage. You can estimate: +* How much you can save by using push protection in repositories in your organization **with the {% data variables.secret-scanning.roi-calculator %}**. See [AUTOTITLE](/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/calculating-the-cost-savings-of-push-protection). +* How much {% data variables.product.prodname_secret_protection %} will cost you monthly for repositories in your organization **with the {% data variables.secret-scanning.pricing-calculator %}**. See [AUTOTITLE](/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/estimating-the-price-of-secret-protection). + +{% endif %} + ## Why you should enable {% data variables.product.prodname_secret_protection %} for 100% of your organization's repositories {% data variables.product.github %} recommends enabling {% data variables.product.prodname_GH_secret_protection %} products for all repositories, in order to protect your organization from the risk of secret leaks and exposures. {% data variables.product.prodname_GH_secret_protection %} is free to enable for public repositories, and available as a purchasable add-on for private and internal repositories. diff --git a/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/estimating-the-price-of-secret-protection.md b/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/estimating-the-price-of-secret-protection.md new file mode 100644 index 000000000000..06a378ad3d62 --- /dev/null +++ b/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/estimating-the-price-of-secret-protection.md @@ -0,0 +1,54 @@ +--- +title: Estimating the price of Secret Protection +shortTitle: Secret protection pricing +intro: Learn how to use the {% data variables.secret-scanning.pricing-calculator %} to estimate the monthly cost of {% data variables.product.prodname_GH_secret_protection %} for your repositories. +product: '{% data reusables.gated-features.secret-risk-assessment-calculators %}' +versions: + feature: secret-risk-assessment +permissions: '{% data reusables.permissions.push-protection-roi-calculator %}' +topics: + - Secret scanning + - Secret Protection +contentType: how-tos +--- + +## What is the pricing calculator? + +You can use the {% data variables.secret-scanning.pricing-calculator %} on the secret risk assessment page to estimate the monthly cost of {% data variables.product.prodname_GH_secret_protection %} for your organization. This tool allows you to preview costs based on your current repositories and active committers, so you can plan for purchase or rollout decisions. + +For more information about {% data variables.product.prodname_secret_protection %}, see [AUTOTITLE](/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/choosing-github-secret-protection). + +## Prerequisites + +You need to have generated a secret risk assessment for your organization. See [AUTOTITLE](/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/viewing-the-secret-risk-assessment-report-for-your-organization). + +## Estimating the price of {% data variables.product.prodname_secret_protection %} + +{% data reusables.organizations.navigate-to-org %} +{% data reusables.organizations.security-overview %} +{% data reusables.security-overview.open-assessments-view %} +1. On the top right corner of the banner, click **Get started**. +1. In the dropdown, select **Preview cost and enable Secret Protection**. +1. In the calculator dialog, choose whether to estimate the cost for: + * **All repositories**: Includes every repository in your organization. + * **Selected repositories**: Choose specific repositories for the estimate. + Once you've made your choices, the calculator shows: + * The **estimated monthly cost** for your organization. + * The **number of {% data variables.product.prodname_secret_protection %} licenses required**, based on active committers in the last 90 days for the selected repositories. + * The **per-committer rate** (for example, $19 per active committer). +1. To proceed with enabling {% data variables.product.prodname_secret_protection %}, click **Review and enable**. + +{% note %} + +Did you successfully use the {% data variables.secret-scanning.pricing-calculator %} to estimate the cost of using {% data variables.product.prodname_secret_protection %} features on your organization? + +Yes No + +{% endnote %} + +## Understanding your results + +* **The {% data variables.secret-scanning.pricing-calculator %} only provides an estimate.** Actual billing is based on the number of active committers in the selected private repositories during the billing period. +* The calculator **does not include costs for other {% data variables.product.prodname_GHAS %} features**. +* The calculator **dynamically calculates active committers** for each repository you select. If two repositories share the same number of committers, adding the second repository shows 0 additional committers, because enabling {% data variables.product.prodname_secret_protection %} for one also covers the other. This helps you quickly see the true incremental cost as you select repositories. +* USD is the only supported currency. diff --git a/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/index.md b/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/index.md index e7749d591200..ec77bf0a7291 100644 --- a/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/index.md +++ b/content/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/index.md @@ -15,4 +15,6 @@ children: - /viewing-the-secret-risk-assessment-report-for-your-organization - /interpreting-secret-risk-assessment-results - /choosing-github-secret-protection + - /calculating-the-cost-savings-of-push-protection + - /estimating-the-price-of-secret-protection --- diff --git a/data/reusables/gated-features/secret-risk-assessment-calculators.md b/data/reusables/gated-features/secret-risk-assessment-calculators.md new file mode 100644 index 000000000000..7075d0a14fb8 --- /dev/null +++ b/data/reusables/gated-features/secret-risk-assessment-calculators.md @@ -0,0 +1,2 @@ + +The calculator is available in organizations on {% data variables.product.prodname_team %}, {% data variables.product.prodname_ghe_cloud %}, and {% data variables.product.prodname_ghe_server %} (_For {% data variables.product.prodname_ghe_server %}, from version 3.20 only_). diff --git a/data/reusables/permissions/push-protection-roi-calculator.md b/data/reusables/permissions/push-protection-roi-calculator.md new file mode 100644 index 000000000000..b4395e969fdf --- /dev/null +++ b/data/reusables/permissions/push-protection-roi-calculator.md @@ -0,0 +1 @@ +Organization owners and security managers diff --git a/data/reusables/secret-protection/product-list.md b/data/reusables/secret-protection/product-list.md index c33e120f7348..08e6134f0896 100644 --- a/data/reusables/secret-protection/product-list.md +++ b/data/reusables/secret-protection/product-list.md @@ -1,6 +1,6 @@ * **{% data variables.product.prodname_secret_scanning_caps %}**: Detect secrets, for example keys and tokens, that have been checked into a repository and receive alerts. -* **Push protection**: Prevent secret leaks before they happen by blocking commits containing secrets.{% ifversion secret-scanning-ai-generic-secret-detection %} +* **Push protection**: Prevent secret leaks before they happen by blocking commits containing secrets. {% ifversion fpt or ghec or ghes > 3.19 %} You can calculate how much you can save by using push protection in repositories in your organization with the {% data variables.secret-scanning.roi-calculator %}. See [AUTOTITLE](/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/calculating-the-cost-savings-of-push-protection).{% endif %}{% ifversion secret-scanning-ai-generic-secret-detection %} * **{% data variables.secret-scanning.copilot-secret-scanning %}**: Leverage AI to detect unstructured credentials, such as passwords, that have been checked into a repository.{% endif %} diff --git a/data/variables/secret-scanning.yml b/data/variables/secret-scanning.yml index 9ed5e89cb24c..294a25693c23 100644 --- a/data/variables/secret-scanning.yml +++ b/data/variables/secret-scanning.yml @@ -13,6 +13,8 @@ custom-pattern-regular-expression-generator-caps: 'Regular expression generator' copilot-secret-scanning: 'Copilot secret scanning' generic-secret-detection: 'generic secret detection' generic-secret-detection-caps: 'Generic secret detection' +roi-calculator: 'ROI calculator' +pricing-calculator: 'pricing calculator' # Secret risk assessment call to action links. If changing the links below, also update the hard-coded link in /code-security/index.md secret-risk-assessment-cta-link: '/code-security/securing-your-organization/understanding-your-organizations-exposure-to-leaked-secrets/viewing-the-secret-risk-assessment-report-for-your-organization#generating-an-initial-secret-risk-assessment' From 76d25627ff99082559ed62c42fbb73115245ada8 Mon Sep 17 00:00:00 2001 From: Sarita Iyer <66540150+saritai@users.noreply.github.com> Date: Tue, 2 Sep 2025 13:22:32 -0400 Subject: [PATCH 4/4] Customization library (custom instructions and prompt files) (#57137) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: hubwriter Co-authored-by: Christopher Harrison --- .../add-repository-instructions.md | 2 +- .../accessibility-auditor.md | 125 ++++++++++++++++++ .../custom-instructions/code-reviewer.md | 66 +++++++++ .../custom-instructions/concept-explainer.md | 51 +++++++ .../custom-instructions/debugging-tutor.md | 61 +++++++++ .../github-actions-helper.md | 65 +++++++++ .../custom-instructions/index.md | 20 +++ .../custom-instructions/issue-manager.md | 60 +++++++++ .../pull-request-assistant.md | 114 ++++++++++++++++ .../custom-instructions/testing-automation.md | 64 +++++++++ .../your-first-custom-instructions.md | 101 ++++++++++++++ .../tutorials/customization-library/index.md | 23 ++++ .../prompt-files/create-readme.md | 76 +++++++++++ .../prompt-files/document-api.md | 83 ++++++++++++ .../prompt-files/generate-unit-tests.md | 84 ++++++++++++ .../prompt-files/index.md | 18 +++ .../prompt-files/onboarding-plan.md | 55 ++++++++ .../prompt-files/review-code.md | 93 +++++++++++++ .../prompt-files/your-first-prompt-file.md | 67 ++++++++++ content/copilot/tutorials/index.md | 1 + .../custom-instructions-further-reading.md | 5 + .../copilot/customization-examples-note.md | 4 + .../copilot/prompt-files-further-reading.md | 5 + .../copilot/prompt-files-generic-note.md | 0 .../copilot/prompt-files-preview-note.md | 3 + .../repository-custom-instructions-types.md | 4 + data/ui.yml | 4 +- src/fixtures/fixtures/data/ui.yml | 4 +- 28 files changed, 1253 insertions(+), 5 deletions(-) create mode 100644 content/copilot/tutorials/customization-library/custom-instructions/accessibility-auditor.md create mode 100644 content/copilot/tutorials/customization-library/custom-instructions/code-reviewer.md create mode 100644 content/copilot/tutorials/customization-library/custom-instructions/concept-explainer.md create mode 100644 content/copilot/tutorials/customization-library/custom-instructions/debugging-tutor.md create mode 100644 content/copilot/tutorials/customization-library/custom-instructions/github-actions-helper.md create mode 100644 content/copilot/tutorials/customization-library/custom-instructions/index.md create mode 100644 content/copilot/tutorials/customization-library/custom-instructions/issue-manager.md create mode 100644 content/copilot/tutorials/customization-library/custom-instructions/pull-request-assistant.md create mode 100644 content/copilot/tutorials/customization-library/custom-instructions/testing-automation.md create mode 100644 content/copilot/tutorials/customization-library/custom-instructions/your-first-custom-instructions.md create mode 100644 content/copilot/tutorials/customization-library/index.md create mode 100644 content/copilot/tutorials/customization-library/prompt-files/create-readme.md create mode 100644 content/copilot/tutorials/customization-library/prompt-files/document-api.md create mode 100644 content/copilot/tutorials/customization-library/prompt-files/generate-unit-tests.md create mode 100644 content/copilot/tutorials/customization-library/prompt-files/index.md create mode 100644 content/copilot/tutorials/customization-library/prompt-files/onboarding-plan.md create mode 100644 content/copilot/tutorials/customization-library/prompt-files/review-code.md create mode 100644 content/copilot/tutorials/customization-library/prompt-files/your-first-prompt-file.md create mode 100644 data/reusables/copilot/custom-instructions-further-reading.md create mode 100644 data/reusables/copilot/customization-examples-note.md create mode 100644 data/reusables/copilot/prompt-files-further-reading.md create mode 100644 data/reusables/copilot/prompt-files-generic-note.md create mode 100644 data/reusables/copilot/prompt-files-preview-note.md create mode 100644 data/reusables/copilot/repository-custom-instructions-types.md diff --git a/content/copilot/how-tos/configure-custom-instructions/add-repository-instructions.md b/content/copilot/how-tos/configure-custom-instructions/add-repository-instructions.md index 998650b0de37..5b840a02a87e 100644 --- a/content/copilot/how-tos/configure-custom-instructions/add-repository-instructions.md +++ b/content/copilot/how-tos/configure-custom-instructions/add-repository-instructions.md @@ -541,7 +541,7 @@ Your choice persists, for all repositories containing a custom instructions file ## Enabling and using prompt files -> [!NOTE] Prompt files are {% data variables.release-phases.public_preview %} and subject to change. +{% data reusables.copilot.prompt-files-preview-note %} Prompt files let you build and share reusable prompt instructions with additional context. A prompt file is a Markdown file, stored in your workspace, that mimics the existing format of writing prompts in {% data variables.copilot.copilot_chat_short %} (for example, `Rewrite #file:x.ts`). You can have multiple prompt files in your workspace, each of which defines a prompt for a different purpose. diff --git a/content/copilot/tutorials/customization-library/custom-instructions/accessibility-auditor.md b/content/copilot/tutorials/customization-library/custom-instructions/accessibility-auditor.md new file mode 100644 index 000000000000..9d7e87d0a1fb --- /dev/null +++ b/content/copilot/tutorials/customization-library/custom-instructions/accessibility-auditor.md @@ -0,0 +1,125 @@ +--- +title: Accessibility auditor +intro: 'Instructions for comprehensive web accessibility testing and compliance.' +versions: + feature: copilot +category: + - Custom instructions + - Development workflows + - Repository + - Path-specific +complexity: + - Intermediate +octicon: book +topics: + - Copilot +contentType: tutorials +--- + +{% data reusables.copilot.customization-examples-note %} + +The following example shows a path-specific `accessibility.instructions.md` file that applies only to HTML files in your repository, and guides {% data variables.product.prodname_copilot %} to generate accessible, inclusive HTML that follows WCAG guidelines. For more information about path-specific instructions files, see [AUTOTITLE](/copilot/how-tos/configure-custom-instructions/add-repository-instructions#using-one-or-more-instructionsmd-files). + +````text copy +--- +applyTo: **/*.html +--- + +When generating code, ensure accessibility compliance by following these priorities: + +## Semantic HTML First +- Use proper semantic elements: `