diff --git a/.github/workflows/python-emitter-regen-diff-comment.yml b/.github/workflows/python-emitter-regen-diff-comment.yml new file mode 100644 index 00000000000..efe3efa3481 --- /dev/null +++ b/.github/workflows/python-emitter-regen-diff-comment.yml @@ -0,0 +1,80 @@ +name: Python Emitter Regen Diff Comment + +# Posts/updates the PR comment for the "Python Emitter Regen Diff" workflow. +# Uses the secure workflow_run pattern (this workflow runs in the base repo context +# with write permission and does NOT build any PR code) so it also works for fork PRs. +# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + +on: + workflow_run: + workflows: ["Python Emitter Regen Diff"] + types: [completed] + +permissions: + pull-requests: write + +jobs: + comment: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Download comment payload + uses: actions/download-artifact@v8 + with: + name: python-regen-diff-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: comment-payload + + - name: Post or update PR comment + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + const prNumber = parseInt( + fs.readFileSync('comment-payload/pr-number.txt', 'utf8').trim(), + 10, + ); + if (!prNumber) { + core.info('No PR number in payload; nothing to comment on.'); + return; + } + + const runUrl = context.payload.workflow_run.html_url; + let body = fs.readFileSync('comment-payload/comment-body.md', 'utf8'); + // The diff workflow leaves a placeholder; point it at the run page, + // which lists the `python-regen-diff-html` artifact for download. + body = body.replaceAll('__ARTIFACT_URL__', runUrl); + + const marker = ''; + body = `${marker}\n${body}`; + + const { owner, repo } = context.repo; + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + const existing = comments.find( + (c) => c.user.type === 'Bot' && c.body && c.body.includes(marker), + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + core.info(`Updated comment ${existing.id} on PR #${prNumber}.`); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + core.info(`Created comment on PR #${prNumber}.`); + } diff --git a/.github/workflows/python-emitter-regen-diff.yml b/.github/workflows/python-emitter-regen-diff.yml new file mode 100644 index 00000000000..0f61814538b --- /dev/null +++ b/.github/workflows/python-emitter-regen-diff.yml @@ -0,0 +1,157 @@ +name: Python Emitter Regen Diff + +# Regenerates the Python emitter's generated test code with the emitter at the PR +# head and at a pinned baseline commit, then diffs the two outputs. CI fails when +# they differ. An HTML view of the diff is uploaded as an artifact, and a PR comment +# (posted by the companion "Python Emitter Regen Diff Comment" workflow) links to it. +# +# Proof of concept for the http-client-python emitter; intended to be replicated for +# the other language emitters and to Azure/typespec-azure. + +on: + pull_request: + branches: [main, "release/*"] + paths: + - "packages/http-client-python/**" + - ".github/workflows/python-emitter-regen-diff.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + read-baseline: + name: Read baseline SHA + runs-on: ubuntu-latest + if: | + !startsWith(github.head_ref, 'dependabot/') && + !startsWith(github.head_ref, 'publish/') && + !startsWith(github.head_ref, 'backmerge/') && + !startsWith(github.head_ref, 'revert-') + outputs: + sha: ${{ steps.read.outputs.sha }} + steps: + - uses: actions/checkout@v6 + - name: Read pinned baseline commit + id: read + run: | + FILE=packages/http-client-python/eng/regen-diff-baseline.txt + SHA=$(grep -v '^[[:space:]]*#' "$FILE" | grep -v '^[[:space:]]*$' | head -n1 | tr -d '[:space:]') + if [ -z "$SHA" ]; then echo "No baseline SHA found in $FILE" >&2; exit 1; fi + echo "Baseline SHA: $SHA" + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + + generate: + name: Generate (${{ matrix.which }}) + needs: read-baseline + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + which: [head, base] + steps: + - name: Checkout ${{ matrix.which }} + uses: actions/checkout@v6 + with: + ref: ${{ matrix.which == 'base' && needs.read-baseline.outputs.sha || github.event.pull_request.head.sha || github.sha }} + + - uses: ./.github/actions/setup + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pnpm install + + - name: Build http-client-python (and deps) + run: npx turbo run build --filter=@typespec/http-client-python + + - name: Set up Python environment + run: npm run install + working-directory: packages/http-client-python + + - name: Regenerate + run: npm run regenerate + working-directory: packages/http-client-python + + - name: Upload generated code + uses: actions/upload-artifact@v7 + with: + name: python-generated-${{ matrix.which }} + path: packages/http-client-python/tests/generated + retention-days: 1 + include-hidden-files: true + + diff: + name: Diff & report + needs: [read-baseline, generate] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v4 + with: + node-version: 24.x + + - name: Download head generated code + uses: actions/download-artifact@v8 + with: + name: python-generated-head + path: gen/head + + - name: Download base generated code + uses: actions/download-artifact@v8 + with: + name: python-generated-base + path: gen/base + + - name: Build diff report + run: | + npx -y tsx packages/http-client-python/eng/scripts/ci/regen-diff-html.ts \ + --base gen/base \ + --head gen/head \ + --out regen-diff-out \ + --baseline-sha "${{ needs.read-baseline.outputs.sha }}" + + - name: Stage comment payload + run: | + mkdir -p comment-payload + cp regen-diff-out/comment-body.md comment-payload/comment-body.md + cp regen-diff-out/result.json comment-payload/result.json + echo "${{ github.event.pull_request.number }}" > comment-payload/pr-number.txt + + - name: Upload HTML diff + uses: actions/upload-artifact@v7 + with: + name: python-regen-diff-html + path: regen-diff-out/regen-diff.html + retention-days: 14 + + - name: Upload diff patch + uses: actions/upload-artifact@v7 + with: + name: python-regen-diff-patch + path: regen-diff-out/regen-diff.patch + retention-days: 14 + + - name: Upload comment payload + uses: actions/upload-artifact@v7 + with: + name: python-regen-diff-comment + path: comment-payload + retention-days: 1 + + - name: Fail if generated code changed + run: | + HAS_DIFF=$(node -e "process.stdout.write(String(require('./regen-diff-out/result.json').hasDiff))") + if [ "$HAS_DIFF" = "true" ]; then + echo "::error::Regenerating the Python emitter changed the generated code vs the pinned baseline. See the 'python-regen-diff-html' artifact for details." + cat regen-diff-out/comment-body.md + exit 1 + fi + echo "No generated-code differences against the baseline." diff --git a/packages/http-client-python/eng/regen-diff-baseline.txt b/packages/http-client-python/eng/regen-diff-baseline.txt new file mode 100644 index 00000000000..ce56c99c4e3 --- /dev/null +++ b/packages/http-client-python/eng/regen-diff-baseline.txt @@ -0,0 +1,12 @@ +# Pinned baseline commit for the Python emitter regen-diff CI check. +# +# The regen-diff workflow regenerates the generated test code twice: +# 1. with the emitter at the PR head +# 2. with the emitter at the commit below +# and diffs the two outputs. CI fails if they differ, and a PR comment links to +# an HTML view of the diff. +# +# Bump this SHA (usually to a recent `main`) whenever the current generated output +# is intentionally accepted as the new baseline. Lines starting with `#` and blank +# lines are ignored; the first non-comment line must be a 40-char commit SHA. +212a9eda5a651f723b9f6054120564e4083e715b diff --git a/packages/http-client-python/eng/scripts/ci/regen-diff-html.ts b/packages/http-client-python/eng/scripts/ci/regen-diff-html.ts new file mode 100644 index 00000000000..a80a6847011 --- /dev/null +++ b/packages/http-client-python/eng/scripts/ci/regen-diff-html.ts @@ -0,0 +1,291 @@ +/* eslint-disable no-console */ +/** + * Regen-diff HTML reporter. + * + * Compares two directories of generated code (a pinned baseline vs the PR head) + * and produces: + * - /regen-diff.patch raw unified diff (git diff --no-index) + * - /regen-diff.html colorized, per-file HTML view of the diff + * - /comment-body.md markdown body for the PR comment + * - /result.json { hasDiff, filesChanged, insertions, deletions } + * + * Exit code is always 0; the workflow decides whether to fail based on result.json. + * + * Usage: + * tsx regen-diff-html.ts --base --head --out [--baseline-sha ] + */ + +import { spawnSync } from "child_process"; +import { mkdirSync, writeFileSync } from "fs"; +import { join, resolve } from "path"; +import { parseArgs } from "util"; + +const { values } = parseArgs({ + options: { + base: { type: "string" }, + head: { type: "string" }, + out: { type: "string" }, + "baseline-sha": { type: "string" }, + }, +}); + +if (!values.base || !values.head || !values.out) { + console.error("Usage: tsx regen-diff-html.ts --base --head --out "); + process.exit(2); +} + +const baseDir = resolve(values.base); +const headDir = resolve(values.head); +const outDir = resolve(values.out); +const baselineSha = values["baseline-sha"] ?? ""; + +mkdirSync(outDir, { recursive: true }); + +// git diff --no-index exits 1 when there are differences; that is not an error for us. +const diff = spawnSync( + "git", + [ + "--no-pager", + "diff", + "--no-index", + "--no-color", + "--src-prefix=baseline/", + "--dst-prefix=head/", + "--", + baseDir, + headDir, + ], + { encoding: "utf8", maxBuffer: 512 * 1024 * 1024 }, +); + +if (diff.status !== 0 && diff.status !== 1) { + console.error(`git diff failed (status ${diff.status}): ${diff.stderr}`); + process.exit(2); +} + +const patch = diff.stdout ?? ""; +writeFileSync(join(outDir, "regen-diff.patch"), patch); + +interface FileDiff { + path: string; + status: "modified" | "added" | "removed"; + lines: string[]; + insertions: number; + deletions: number; + oldPath?: string; + newPath?: string; +} + +// Git C-quotes paths containing special characters (e.g. backslashes on Windows): +// "head/C:\\Users\\..." -> head/C:\Users\... +function unquote(p: string): string { + if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) { + return p.slice(1, -1).replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + } + return p; +} + +// Reduce a diff header path to a package-relative path: +// strip git's baseline/ | head/ prefix, then the absolute baseDir/headDir prefix. +function normalizePath(raw: string): string { + let p = unquote(raw.trim()).replace(/^(baseline|head)[/\\]/, ""); + for (const root of [headDir, baseDir]) { + for (const v of [root, root.replace(/\\/g, "/")]) { + if (p.startsWith(v)) { + p = p.slice(v.length); + break; + } + } + } + return p.replace(/^[/\\]+/, "").replace(/\\/g, "/"); +} + +function parseDiff(raw: string): FileDiff[] { + const files: FileDiff[] = []; + const lines = raw.split("\n"); + let current: FileDiff | null = null; + + for (const line of lines) { + if (line.startsWith("diff --git ")) { + if (current) files.push(current); + current = { path: "", status: "modified", lines: [], insertions: 0, deletions: 0 }; + continue; + } + if (!current) continue; + + if (line.startsWith("--- ")) { + const p = line.slice(4); + if (p === "/dev/null") current.status = "added"; + else current.oldPath = p; + continue; + } + if (line.startsWith("+++ ")) { + const p = line.slice(4); + if (p === "/dev/null") current.status = "removed"; + else current.newPath = p; + continue; + } + if (line.startsWith("rename from ") || line.startsWith("rename to ")) { + current.path = normalizePath(line.replace(/^rename (from|to) /, "")); + continue; + } + if ( + line.startsWith("index ") || + line.startsWith("new file") || + line.startsWith("deleted file") + ) { + continue; + } + // Body lines (hunks). + current.lines.push(line); + if (line.startsWith("+") && !line.startsWith("+++")) current.insertions++; + else if (line.startsWith("-") && !line.startsWith("---")) current.deletions++; + } + if (current) files.push(current); + + for (const f of files) { + if (!f.path) { + const src = f.newPath ?? f.oldPath; + if (src) f.path = normalizePath(src); + } + } + return files.filter((f) => f.path); +} + +const files = parseDiff(patch); +const insertions = files.reduce((s, f) => s + f.insertions, 0); +const deletions = files.reduce((s, f) => s + f.deletions, 0); +const hasDiff = files.length > 0; + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +function renderFile(f: FileDiff): string { + const rows = f.lines + .map((l) => { + let cls = "ctx"; + if (l.startsWith("@@")) cls = "hunk"; + else if (l.startsWith("+")) cls = "add"; + else if (l.startsWith("-")) cls = "del"; + return `
${escapeHtml(l) || " "}
`; + }) + .join("\n"); + const badge = f.status === "added" ? "added" : f.status === "removed" ? "removed" : "modified"; + return ` +
+ + ${badge} + ${escapeHtml(f.path)} + +${f.insertions} -${f.deletions} + +
${rows}
+
`; +} + +const generatedAt = new Date().toISOString(); +const summaryRows = files + .map( + (f) => + `${escapeHtml(f.path)}${f.status}+${f.insertions}-${f.deletions}`, + ) + .join("\n"); + +const html = ` + + + + +Python emitter regen diff + + + +

Python emitter regen diff

+
+ Baseline: ${escapeHtml(baselineSha || "(see regen-diff-baseline.txt)")}
+ Generated: ${generatedAt}
+ Files changed: ${files.length}, + +${insertions} -${deletions} +
+${ + hasDiff + ? ` + + +${summaryRows} + +
FileStatus+-
+${files.map(renderFile).join("\n")}` + : `
No differences. The PR's emitter produces identical generated code to the baseline. ✅
` +} + +`; + +writeFileSync(join(outDir, "regen-diff.html"), html); + +const commentBody = hasDiff + ? [ + `### 🐍 Python emitter regen diff`, + ``, + `This PR changes the Python emitter. Regenerating against baseline \`${baselineSha.slice(0, 12) || "baseline"}\` produced **${files.length} changed file(s)** (+${insertions} / -${deletions}).`, + ``, + `| File | Status | + | - |`, + `| --- | --- | --- | --- |`, + ...files + .slice(0, 50) + .map((f) => `| \`${f.path}\` | ${f.status} | +${f.insertions} | -${f.deletions} |`), + files.length > 50 ? `| … and ${files.length - 50} more | | | |` : ``, + ``, + `📄 **[Open the full HTML diff](__ARTIFACT_URL__)** (download the \`python-regen-diff-html\` artifact).`, + ``, + `If these changes are expected, update the baseline SHA in \`packages/http-client-python/eng/regen-diff-baseline.txt\`.`, + ] + .filter((l) => l !== ``) + .join("\n") + : [ + `### 🐍 Python emitter regen diff`, + ``, + `No generated-code differences against baseline \`${baselineSha.slice(0, 12) || "baseline"}\`. ✅`, + ].join("\n"); + +writeFileSync(join(outDir, "comment-body.md"), commentBody + "\n"); + +writeFileSync( + join(outDir, "result.json"), + JSON.stringify( + { hasDiff, filesChanged: files.length, insertions, deletions, baselineSha }, + null, + 2, + ) + "\n", +); + +console.log( + hasDiff + ? `Regen diff: ${files.length} file(s) changed (+${insertions}/-${deletions}). Report at ${join(outDir, "regen-diff.html")}` + : `Regen diff: no differences.`, +);