diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000000..7e9a584f21 --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,33 @@ +name: Update CHANGELOG +on: + workflow_dispatch + +schedule: + # Runs every Tuesday at 9 PM Pacific Time (5 AM UTC Wednesday) + - cron: '0 5 * * 3' + +permissions: + contents: write + pull-requests: write + +jobs: + update-changelog: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v2 + - name: Install NodeJS + uses: actions/setup-node@v4 + with: + node-version: '20.x' + - name: Install dependencies + run: npm ci + - name: Update version.json + run: npx gulp updateChangelog + - name: Create version update PR + uses: peter-evans/create-pull-request@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: Update ${{ github.ref_name }} CHANGELOG + title: '[automated] Update ${{ github.ref_name }} CHANGELOG' + branch: merge/update-${{ github.ref_name }}-changelog diff --git a/tasks/gitTasks.ts b/tasks/gitTasks.ts index 9230729e2e..5f52050639 100644 --- a/tasks/gitTasks.ts +++ b/tasks/gitTasks.ts @@ -5,6 +5,7 @@ import { spawnSync } from 'child_process'; import { Octokit } from '@octokit/rest'; +import { EOL } from 'os'; /** * Execute a git command with optional logging @@ -146,3 +147,16 @@ export async function createPullRequest( return null; } } + +/** + * Find all tags that match the given version pattern + * @param version The `Major.Minor` version pattern to match + * @returns A sorted list of matching tags from oldest to newest + */ +export async function findTagsByVersion(version: string): Promise { + const tagList = await git(['tag', '--list', `v${version}*`, '--sort=creatordate'], false); + return tagList + .split(EOL) + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); +} diff --git a/tasks/snapTasks.ts b/tasks/snapTasks.ts index 4579910b18..b284d2cb03 100644 --- a/tasks/snapTasks.ts +++ b/tasks/snapTasks.ts @@ -7,6 +7,18 @@ import * as gulp from 'gulp'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { findTagsByVersion } from './gitTasks'; + +const execAsync = promisify(exec); + +function logWarning(message: string, error?: unknown): void { + console.log(`##vso[task.logissue type=warning]${message}`); + if (error instanceof Error && error.stack) { + console.log(`##[debug]${error.stack}`); + } +} gulp.task('incrementVersion', async (): Promise => { // Get the current version from version.json @@ -64,3 +76,113 @@ gulp.task('incrementVersion', async (): Promise => { changelogLines.splice(lineToInsertAt, 0, ...linesToInsert); fs.writeFileSync(changelogPath, changelogLines.join(os.EOL)); }); + +gulp.task('updateChangelog', async (): Promise => { + // Add a new changelog section for the new version. + console.log('Determining version from CHANGELOG'); + + const changelogPath = path.join(path.resolve(__dirname, '..'), 'CHANGELOG.md'); + const changelogContent = fs.readFileSync(changelogPath, 'utf8'); + const changelogLines = changelogContent.split(os.EOL); + + // Find all the headers in the changelog (and their line numbers) + const [currentHeaderLine, currentVersion] = findNextVersionHeaderLine(changelogLines); + if (currentHeaderLine === -1) { + throw new Error('Could not find the current header in the CHANGELOG'); + } + + console.log(`Adding PRs for ${currentVersion} to CHANGELOG`); + + const [previousHeaderLine, previousVersion] = findNextVersionHeaderLine(changelogLines, currentHeaderLine + 1); + if (previousHeaderLine === -1) { + throw new Error('Could not find the previous header in the CHANGELOG'); + } + + const presentPrIds = getPrIdsBetweenHeaders(changelogLines, currentHeaderLine, previousHeaderLine); + console.log(`PRs [#${presentPrIds.join(', #')}] already in the CHANGELOG`); + + const versionTags = await findTagsByVersion(previousVersion!); + if (versionTags.length === 0) { + throw new Error(`Could not find any tags for version ${previousVersion}`); + } + + // The last tag is the most recent one created. + const versionTag = versionTags.pop(); + console.log(`Using tag ${versionTag} for previous version ${previousVersion}`); + + console.log(`Generating PR list from ${versionTag} to HEAD`); + const currentPrs = await generatePRList(versionTag!, 'HEAD'); + + const newPrs = []; + for (const pr of currentPrs) { + const match = prRegex.exec(pr); + if (!match) { + continue; + } + + const prId = match[1]; + if (presentPrIds.includes(prId)) { + console.log(`PR #${prId} is already present in the CHANGELOG`); + continue; + } + + console.log(`Adding new PR to CHANGELOG: ${pr}`); + newPrs.push(pr); + } + + if (newPrs.length === 0) { + console.log('No new PRs to add to the CHANGELOG'); + return; + } + + console.log(`Writing ${newPrs.length} new PRs to the CHANGELOG`); + + changelogLines.splice(currentHeaderLine + 1, 0, ...newPrs); + fs.writeFileSync(changelogPath, changelogLines.join(os.EOL)); +}); + +const prRegex = /^\*.+\(PR: \[#(\d+)\]\(/g; + +function findNextVersionHeaderLine(changelogLines: string[], startLine: number = 0): [number, string] { + const headerRegex = /^#\s(\d+\.\d+)\.(x|\d+)$/gm; + for (let i = startLine; i < changelogLines.length; i++) { + const line = changelogLines.at(i); + const match = headerRegex.exec(line!); + if (match) { + return [i, match[1]]; + } + } + return [-1, '']; +} + +function getPrIdsBetweenHeaders(changelogLines: string[], startLine: number, endLine: number): string[] { + const prs: string[] = []; + for (let i = startLine; i < endLine; i++) { + const line = changelogLines.at(i); + const match = prRegex.exec(line!); + if (match && match[1]) { + prs.push(match[1]); + } + } + return prs; +} + +async function generatePRList(startSHA: string, endSHA: string): Promise { + try { + console.log(`Executing: roslyn-tools pr-finder -s "${startSHA}" -e "${endSHA}" --format o#`); + let { stdout } = await execAsync( + `roslyn-tools pr-finder -s "${startSHA}" -e "${endSHA}" --format o#`, + { maxBuffer: 10 * 1024 * 1024 } // 10MB buffer + ); + + stdout = stdout.trim(); + if (stdout.length === 0) { + return []; + } + + return stdout.split(os.EOL).filter((pr) => pr.length > 0); + } catch (error) { + logWarning(`PR finder failed: ${error instanceof Error ? error.message : error}`, error); + throw error; + } +}