-
Notifications
You must be signed in to change notification settings - Fork 7.9k
tweak: DevEx to run changelog independently #5774
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| #!/usr/bin/env bun | ||
|
|
||
| import { $ } from "bun" | ||
| import { createOpencode } from "@opencode-ai/sdk" | ||
|
|
||
| const TEAM = [ | ||
| "actions-user", | ||
| "opencode", | ||
| "rekram1-node", | ||
| "thdxr", | ||
| "kommander", | ||
| "jayair", | ||
| "fwang", | ||
| "adamdotdevin", | ||
| "iamdavidhill", | ||
| "opencode-agent[bot]", | ||
| ] | ||
|
|
||
| const MODEL = "gemini-3-flash" | ||
|
|
||
| function getAreaFromPath(file: string): string { | ||
| if (file.startsWith("packages/")) { | ||
| const parts = file.replace("packages/", "").split("/") | ||
| if (parts[0] === "extensions" && parts[1]) return `extensions/${parts[1]}` | ||
| return parts[0] || "other" | ||
| } | ||
| if (file.startsWith("sdks/")) { | ||
| const name = file.replace("sdks/", "").split("/")[0] || "other" | ||
| return `extensions/${name}` | ||
| } | ||
| const rootDir = file.split("/")[0] | ||
| if (rootDir && !rootDir.includes(".")) return rootDir | ||
| return "other" | ||
| } | ||
|
|
||
| function buildPrompt(previous: string, commits: string): string { | ||
| return ` | ||
| Analyze these commits and generate a changelog of all notable user facing changes, grouped by area. | ||
|
|
||
| Each commit below includes: | ||
| - [author: username] showing the GitHub username of the commit author | ||
| - [areas: ...] showing which areas of the codebase were modified | ||
|
|
||
| Commits between ${previous} and HEAD: | ||
| ${commits} | ||
|
|
||
| Group the changes into these categories based on the [areas: ...] tags (omit any category with no changes): | ||
| - **TUI**: Changes to "opencode" area (the terminal/CLI interface) | ||
| - **Desktop**: Changes to "desktop" or "tauri" areas (the desktop application) | ||
| - **SDK**: Changes to "sdk" or "plugin" areas (the SDK and plugin system) | ||
| - **Extensions**: Changes to "extensions/zed", "extensions/vscode", or "github" areas (editor extensions and GitHub Action) | ||
| - **Other**: Any user-facing changes that don't fit the above categories | ||
|
|
||
| Excluded areas (omit these entirely unless they contain user-facing changes like refactors that may affect behavior): | ||
| - "nix", "infra", "script" - CI/build infrastructure | ||
| - "ui", "docs", "web", "console", "enterprise", "function", "util", "identity", "slack" - internal packages | ||
|
|
||
| Rules: | ||
| - Use the [areas: ...] tags to determine the correct category. If a commit touches multiple areas, put it in the most relevant user-facing category. | ||
| - ONLY include commits that have user-facing impact. Omit purely internal changes (CI, build scripts, internal tooling). | ||
| - However, DO include refactors that touch user-facing code - refactors can introduce bugs or change behavior. | ||
| - Do NOT make general statements about "improvements", be very specific about what was changed. | ||
| - For commits that are already well-written and descriptive, avoid rewording them. Simply capitalize the first letter, fix any misspellings, and ensure proper English grammar. | ||
| - DO NOT read any other commits than the ones listed above (THIS IS IMPORTANT TO AVOID DUPLICATING THINGS IN OUR CHANGELOG). | ||
| - If a commit was made and then reverted do not include it in the changelog. If the commits only include a revert but not the original commit, then include the revert in the changelog. | ||
| - Omit categories that have no changes. | ||
| - For community contributors: if the [author: username] is NOT in the team list, add (@username) at the end of the changelog entry. This is REQUIRED for all non-team contributors. | ||
| - The team members are: ${TEAM.join(", ")}. Do NOT add @ mentions for team members. | ||
|
|
||
| IMPORTANT: ONLY return the grouped changelog, do not include any other information. Do not include a preamble like "Based on my analysis..." or "Here is the changelog..." | ||
|
|
||
| <example> | ||
| ## TUI | ||
| - Added experimental support for the Ty language server (@OpeOginni) | ||
| - Added /fork slash command for keyboard-friendly session forking (@ariane-emory) | ||
| - Increased retry attempts for failed requests | ||
| - Fixed model validation before executing slash commands (@devxoul) | ||
|
|
||
| ## Desktop | ||
| - Added shell mode support | ||
| - Fixed prompt history navigation and optimistic prompt duplication | ||
| - Disabled pinch-to-zoom on Linux (@Brendonovich) | ||
|
|
||
| ## Extensions | ||
| - Added OIDC_BASE_URL support for custom GitHub App installations (@elithrar) | ||
| </example> | ||
| ` | ||
| } | ||
|
|
||
| function parseChangelog(raw: string): string[] { | ||
| const lines: string[] = [] | ||
| for (const line of raw.split("\n")) { | ||
| if (line.startsWith("## ")) { | ||
| if (lines.length > 0) lines.push("") | ||
| lines.push(line) | ||
| } else if (line.startsWith("- ")) { | ||
| lines.push(line) | ||
| } | ||
| } | ||
| return lines | ||
| } | ||
|
|
||
| function formatContributors(contributors: Map<string, string[]>): string[] { | ||
| if (contributors.size === 0) return [] | ||
| const lines: string[] = [] | ||
| lines.push("") | ||
| lines.push(`**Thank you to ${contributors.size} community contributor${contributors.size > 1 ? "s" : ""}:**`) | ||
| for (const username of contributors.keys()) { | ||
| lines.push(`- @${username}`) | ||
| } | ||
| return lines | ||
| } | ||
|
|
||
| /** | ||
| * Generates a changelog for a release. | ||
| * | ||
| * Uses GitHub API for commit authors, git for file changes, | ||
| * and Gemini Flash via opencode SDK for changelog generation. | ||
| * | ||
| * @param previous - The previous version tag (e.g. "v1.0.167") | ||
| * @param current - The current ref (e.g. "HEAD" or "v1.0.168") | ||
| * @returns Formatted changelog string ready for GitHub release notes | ||
| */ | ||
| export async function generateChangelog(previous: string, current: string): Promise<string> { | ||
| // Fetch commit authors from GitHub API (hash -> login) | ||
| const compare = | ||
| await $`gh api "/repos/sst/opencode/compare/${previous}...${current}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'` | ||
| .text() | ||
| .catch(() => "") | ||
|
|
||
| const authorByHash = new Map<string, string>() | ||
| const contributors = new Map<string, string[]>() | ||
|
|
||
| for (const line of compare.split("\n").filter(Boolean)) { | ||
| const { sha, login, message } = JSON.parse(line) as { sha: string; login: string | null; message: string } | ||
| if (login) authorByHash.set(sha, login) | ||
|
|
||
| const title = message.split("\n")[0] || "" | ||
| if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue | ||
| if (login && !TEAM.includes(login)) { | ||
| if (!contributors.has(login)) contributors.set(login, []) | ||
| contributors.get(login)?.push(title) | ||
| } | ||
| } | ||
|
|
||
| function findAuthor(shortHash: string): string | undefined { | ||
| for (const [sha, login] of authorByHash) { | ||
| if (sha.startsWith(shortHash)) return login | ||
| } | ||
| } | ||
|
|
||
| // Batch-fetch files for all commits (hash -> areas) | ||
| const diffLog = await $`git log ${previous}..${current} --name-only --format="%h"`.text() | ||
| const areasByHash = new Map<string, Set<string>>() | ||
| let currentHash: string | null = null | ||
|
|
||
| for (const rawLine of diffLog.split("\n")) { | ||
| const line = rawLine.trim() | ||
| if (!line) continue | ||
| if (/^[0-9a-f]{7,}$/i.test(line) && !line.includes("/")) { | ||
| currentHash = line | ||
| if (!areasByHash.has(currentHash)) areasByHash.set(currentHash, new Set()) | ||
| continue | ||
| } | ||
| if (currentHash) { | ||
| areasByHash.get(currentHash)!.add(getAreaFromPath(line)) | ||
| } | ||
| } | ||
|
|
||
| // Build commit lines with author and areas | ||
| const log = await $`git log ${previous}..${current} --oneline --format="%h %s"`.text() | ||
| const commitLines = log.split("\n").filter((line) => line && !line.match(/^\w+ (ignore:|test:|chore:|ci:|release:)/i)) | ||
|
|
||
| const commitsWithMeta = commitLines | ||
| .map((line) => { | ||
| const hash = line.split(" ")[0] | ||
| if (!hash) return null | ||
| const author = findAuthor(hash) | ||
| const authorStr = author ? ` [author: ${author}]` : "" | ||
| const areas = areasByHash.get(hash) | ||
| const areaStr = areas && areas.size > 0 ? ` [areas: ${[...areas].join(", ")}]` : " [areas: other]" | ||
| return `${line}${authorStr}${areaStr}` | ||
| }) | ||
| .filter(Boolean) as string[] | ||
|
|
||
| const commits = commitsWithMeta.join("\n") | ||
|
|
||
| // Generate changelog via LLM | ||
| // different port to not conflict with dev running opencode | ||
| const opencode = await createOpencode({ port: 8192 }) | ||
| let raw: string | undefined | ||
| try { | ||
| const session = await opencode.client.session.create() | ||
| raw = await opencode.client.session | ||
| .prompt({ | ||
| path: { id: session.data!.id }, | ||
| body: { | ||
| model: { providerID: "opencode", modelID: MODEL }, | ||
| parts: [{ type: "text", text: buildPrompt(previous, commits) }], | ||
| }, | ||
| }) | ||
| .then((x) => x.data?.parts?.find((y) => y.type === "text")?.text) | ||
| } finally { | ||
| opencode.server.close() | ||
| } | ||
|
|
||
| const notes = parseChangelog(raw ?? "") | ||
| notes.push(...formatContributors(contributors)) | ||
|
|
||
| return notes.join("\n") | ||
| } | ||
|
|
||
| // Standalone runner for local testing | ||
| if (import.meta.main) { | ||
| const [previous, current] = process.argv.slice(2) | ||
| if (!previous || !current) { | ||
| console.error("Usage: bun script/changelog.ts <previous> <current>") | ||
| console.error("Example: bun script/changelog.ts v1.0.167 HEAD") | ||
| process.exit(1) | ||
| } | ||
| const changelog = await generateChangelog(previous, current) | ||
| console.log(changelog) | ||
| process.exit(0) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Silently catching and ignoring GitHub API errors could lead to generating an incomplete changelog with missing author information. Consider logging the error or handling the failure more explicitly, especially since this affects the quality of the generated changelog.