diff --git a/.github/release-template.md b/.github/release-template.md new file mode 100644 index 00000000..d70055d8 --- /dev/null +++ b/.github/release-template.md @@ -0,0 +1,42 @@ +# SkillHub {{version}} + +{{One-line summary of the key changes in this release}} + +## ๐ŸŒŸ Highlights + +- {{Highlight 1}} +- {{Highlight 2}} +- {{Highlight 3}} + +## ๐Ÿšจ Breaking Changes + +โš ๏ธ {{If any, describe impact and migration guide}} + +## โœจ Features + +- {{Feature description}} by @author in #PR + +## ๐Ÿ› Bug Fixes + +- {{Fix description}} by @author in #PR + +## โšก Performance + +- {{Performance improvement}} by @author in #PR + +## ๐Ÿ“š Documentation + +- {{Documentation changes}} by @author in #PR + +## ๐Ÿ”ง Chore + +- {{Maintenance work}} by @author in #PR + +## ๐Ÿ“– Documentation +- Docs site: https://iflytek.github.io/skillhub/ + +## ๐Ÿ‘ฅ New Contributors +{{Keep as-is}} + +**Full Changelog**: https://github.com/iflytek/skillhub/compare/{{prev_tag}}...{{tag}} + diff --git a/.github/scripts/github.ts b/.github/scripts/github.ts index 7b9778b5..70103211 100644 --- a/.github/scripts/github.ts +++ b/.github/scripts/github.ts @@ -138,6 +138,47 @@ export class GitHubClient { ); } + async listCommitPulls(sha: string): Promise> { + return this.request( + "GET", + `/repos/${this.owner}/${this.repo}/commits/${sha}/pulls`, + ); + } + + async createDraftRelease( + tag: string, + name: string, + body: string, + ): Promise<{ id: number; upload_url: string }> { + return this.request("POST", `/repos/${this.owner}/${this.repo}/releases`, { + tag_name: tag, + name, + body, + draft: true, + prerelease: false, + }); + } + + async uploadReleaseAsset( + releaseId: number, + filename: string, + content: string, + ): Promise { + const uploadUrl = `https://uploads.github.com/repos/${this.owner}/${this.repo}/releases/${releaseId}/assets?name=${encodeURIComponent(filename)}`; + const response = await fetch(uploadUrl, { + method: "POST", + headers: { + ...this.headers(), + "Content-Type": "text/markdown", + }, + body: content, + }); + + if (!response.ok) { + throw await GitHubApiError.fromResponse(response); + } + } + private async paginate(path: string): Promise { const collected: T[] = []; let nextPath: string | null = path; diff --git a/.github/scripts/issue-llm-provider.ts b/.github/scripts/issue-llm-provider.ts index 276193c3..f73dd8bf 100644 --- a/.github/scripts/issue-llm-provider.ts +++ b/.github/scripts/issue-llm-provider.ts @@ -37,6 +37,35 @@ export async function requestOpenAiCompatibleJson( throw lastError ?? new Error("LLM request failed for an unknown reason."); } +export async function requestOpenAiCompatibleMarkdown( + config: IssueLlmConfig, + systemPrompt: string, + userPrompt: string, +): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) { + try { + return await requestOnceMarkdown(config, systemPrompt, userPrompt); + } catch (error) { + const normalized = normalizeRequestError( + error, + attempt, + config.maxAttempts, + ); + lastError = normalized; + + if (!shouldRetry(error) || attempt >= config.maxAttempts) { + throw normalized; + } + + await sleep(resolveRetryDelay(error, config.retryBackoffMs, attempt)); + } + } + + throw lastError ?? new Error("LLM request failed for an unknown reason."); +} + async function requestOnce( config: IssueLlmConfig, systemPrompt: string, @@ -88,6 +117,57 @@ async function requestOnce( } } +async function requestOnceMarkdown( + config: IssueLlmConfig, + systemPrompt: string, + userPrompt: string, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.timeoutMs); + + try { + const response = await fetch(`${config.baseUrl}/chat/completions`, { + method: "POST", + signal: controller.signal, + headers: { + Authorization: `Bearer ${config.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: config.model, + temperature: config.temperature, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }), + }); + + if (!response.ok) { + const message = + `LLM request failed with status ${response.status}: ${await response + .text()}`; + throw new RetryableHttpError( + message, + response.status, + response.headers.get("retry-after"), + ); + } + + const payload = (await response.json()) as OpenAiCompatibleResponse; + const content = payload.choices?.[0]?.message?.content; + const text = normalizeMessageContent(content); + + if (!text) { + throw new Error("LLM response did not include message content."); + } + + return text; + } finally { + clearTimeout(timeout); + } +} + class RetryableHttpError extends Error { status: number; retryAfterSeconds: number | null; diff --git a/.github/scripts/release-notes-config.ts b/.github/scripts/release-notes-config.ts new file mode 100644 index 00000000..d059cfc4 --- /dev/null +++ b/.github/scripts/release-notes-config.ts @@ -0,0 +1,53 @@ +import { IssueLlmConfig } from "./issue-llm-types.ts"; + +const DEFAULT_TIMEOUT_MS = 30000; +const DEFAULT_MAX_ATTEMPTS = 2; +const DEFAULT_RETRY_BACKOFF_MS = 1500; +const DEFAULT_TEMPERATURE = 0.2; + +export function readReleaseNotesLlmConfig(): IssueLlmConfig | null { + const baseUrl = normalizeUrl( + Deno.env.get("RELEASE_NOTES_LLM_BASE_URL") || + Deno.env.get("ISSUE_TRIAGE_LLM_BASE_URL"), + ); + const apiKey = ( + Deno.env.get("RELEASE_NOTES_LLM_API_KEY") || + Deno.env.get("ISSUE_TRIAGE_LLM_API_KEY") + )?.trim() ?? ""; + const model = ( + Deno.env.get("RELEASE_NOTES_LLM_MODEL") || + Deno.env.get("ISSUE_TRIAGE_LLM_MODEL") + )?.trim() ?? ""; + + if (!baseUrl || !apiKey || !model) { + console.warn( + "LLM config missing, will use fallback mode (conventional commit grouping)", + ); + return null; + } + + return { + mode: "assist", + provider: "openai-compatible", + baseUrl, + apiKey, + model, + timeoutMs: DEFAULT_TIMEOUT_MS, + maxAttempts: DEFAULT_MAX_ATTEMPTS, + retryBackoffMs: DEFAULT_RETRY_BACKOFF_MS, + temperature: DEFAULT_TEMPERATURE, + maxComments: 0, + maxCommentChars: 0, + maxBodyChars: 0, + }; +} + +function normalizeUrl(value: string | undefined | null) { + const trimmed = value?.trim(); + + if (!trimmed) { + return ""; + } + + return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; +} diff --git a/.github/scripts/release-notes-generator.ts b/.github/scripts/release-notes-generator.ts new file mode 100644 index 00000000..f1437177 --- /dev/null +++ b/.github/scripts/release-notes-generator.ts @@ -0,0 +1,271 @@ +import { GitHubClient } from "./github.ts"; +import { IssueLlmConfig } from "./issue-llm-types.ts"; +import { requestOpenAiCompatibleMarkdown } from "./issue-llm-provider.ts"; + +interface RawCommit { + sha: string; + message: string; + author: string; + prNumber?: number; + prTitle?: string; + excluded: boolean; +} + +interface ChangeEntry { + type: string; + scope: string; + description: string; + prNumber?: number; + authors: string[]; + commits: string[]; +} + +export async function generateReleaseNotes( + owner: string, + repo: string, + tag: string, + prevTag: string, + llmConfig: IssueLlmConfig | null, + dryRun: boolean, +): Promise { + const github = new GitHubClient(Deno.env.get("GH_TOKEN") ?? "", owner, repo); + + console.log(`Collecting changes from ${prevTag} to ${tag}...`); + const changes = await collectChanges(github, prevTag, tag); + console.log(`Found ${changes.length} changes after deduplication`); + + if (llmConfig) { + console.log("Generating release notes with LLM..."); + try { + const markdown = await generateMarkdownWithLLM( + llmConfig, + changes, + tag, + prevTag, + owner, + repo, + ); + return markdown; + } catch (error) { + console.error("LLM generation failed:", error); + console.log("Falling back to conventional commit grouping..."); + return generateFallback(changes, tag, prevTag, owner, repo); + } + } else { + console.log("Using fallback mode (conventional commit grouping)..."); + return generateFallback(changes, tag, prevTag, owner, repo); + } +} + +async function collectChanges( + github: GitHubClient, + prevTag: string, + tag: string, +): Promise { + const gitLogCmd = new Deno.Command("git", { + args: ["log", `${prevTag}..${tag}`, "--format=%H|%s|%an"], + stdout: "piped", + }); + const gitLogOutput = await gitLogCmd.output(); + const gitLogText = new TextDecoder().decode(gitLogOutput.stdout); + + const rawCommits: RawCommit[] = []; + for (const line of gitLogText.trim().split("\n")) { + if (!line) continue; + const [sha, message, author] = line.split("|"); + rawCommits.push({ + sha, + message, + author, + excluded: false, + }); + } + + for (const commit of rawCommits) { + if (/^Revert "(.+)"$/.test(commit.message)) { + commit.excluded = true; + const revertedMsg = commit.message.match(/^Revert "(.+)"$/)?.[1]; + if (revertedMsg) { + const reverted = rawCommits.find((c) => c.message === revertedMsg); + if (reverted) reverted.excluded = true; + } + } + + if (/^Merge (pull request|branch|remote-tracking)/.test(commit.message)) { + commit.excluded = true; + } + } + + for (const commit of rawCommits.filter((c) => !c.excluded)) { + try { + const pulls = await github.listCommitPulls(commit.sha); + if (pulls.length > 0) { + commit.prNumber = pulls[0].number; + commit.prTitle = pulls[0].title; + } + } catch (error) { + console.warn(`Failed to fetch PR for commit ${commit.sha}:`, error); + } + } + + const prMap = new Map(); + const standaloneCommits: ChangeEntry[] = []; + + for (const commit of rawCommits.filter((c) => !c.excluded)) { + const parsed = parseConventionalCommit(commit.message); + const description = commit.prTitle || parsed.description; + + if (commit.prNumber) { + if (!prMap.has(commit.prNumber)) { + prMap.set(commit.prNumber, { + type: parsed.type, + scope: parsed.scope, + description, + prNumber: commit.prNumber, + authors: [commit.author], + commits: [commit.sha], + }); + } else { + const entry = prMap.get(commit.prNumber)!; + if (!entry.authors.includes(commit.author)) { + entry.authors.push(commit.author); + } + entry.commits.push(commit.sha); + } + } else { + standaloneCommits.push({ + type: parsed.type, + scope: parsed.scope, + description, + authors: [commit.author], + commits: [commit.sha], + }); + } + } + + return [...prMap.values(), ...standaloneCommits]; +} + +function parseConventionalCommit(message: string): { + type: string; + scope: string; + description: string; +} { + const match = message.match(/^(\w+)(?:\(([^)]+)\))?: (.+)$/); + if (match) { + return { + type: match[1], + scope: match[2] || "", + description: match[3], + }; + } + return { + type: "other", + scope: "", + description: message, + }; +} + +async function generateMarkdownWithLLM( + config: IssueLlmConfig, + changes: ChangeEntry[], + tag: string, + prevTag: string, + owner: string, + repo: string, +): Promise { + const template = await Deno.readTextFile(".github/release-template.md"); + + const systemPrompt = `You are a senior product manager and technical documentation expert. Rewrite the following technical change list into user-friendly Release Notes. + +Requirements: +1. Strictly follow the Markdown structure and heading levels of the template below +2. Remove any section entirely (including its heading) if there are no items for it +3. Rewrite technical jargon into language that end-users can understand +4. For Breaking Changes, add an upgrade / migration guide +5. Highlights must contain 2-4 items, distilled from the most important changes +6. PR number format: #123 +7. Contributor format: @username +8. Replace {{version}}, {{prev_tag}}, {{tag}} with actual values +9. Output ONLY the Markdown content โ€” no extra commentary, no code fences + +Template: +--- +${template} +---`; + + const changesList = changes.map((c) => { + const pr = c.prNumber ? ` (#${c.prNumber})` : ""; + const authors = c.authors.map((a) => `@${a}`).join(", "); + return `- ${c.type}(${c.scope}): ${c.description}${pr} by ${authors}`; + }).join("\n"); + + const userPrompt = `Version: ${tag} +Previous version: ${prevTag} +Repository: ${owner}/${repo} + +Change list: +${changesList}`; + + const markdown = await requestOpenAiCompatibleMarkdown( + config, + systemPrompt, + userPrompt, + ); + + return markdown + .replace(/\{\{version\}\}/g, tag) + .replace(/\{\{prev_tag\}\}/g, prevTag) + .replace(/\{\{tag\}\}/g, tag); +} + +function generateFallback( + changes: ChangeEntry[], + tag: string, + prevTag: string, + owner: string, + repo: string, +): string { + const grouped = new Map(); + for (const change of changes) { + const type = change.type; + if (!grouped.has(type)) { + grouped.set(type, []); + } + grouped.get(type)!.push(change); + } + + const typeLabels: Record = { + feat: "## โœจ Features", + fix: "## ๐Ÿ› Bug Fixes", + docs: "## ๐Ÿ“š Documentation", + perf: "## โšก Performance", + refactor: "## ๐Ÿ”ง Improvements", + test: "## ๐Ÿงช Tests", + chore: "## ๐Ÿ”ง Chore", + }; + + let md = `# SkillHub ${tag}\n\n`; + md += `> [Auto-generated - LLM unavailable]\n\n`; + + for (const [type, items] of grouped.entries()) { + const label = typeLabels[type] || `## ${type}`; + md += `${label}\n\n`; + for (const item of items) { + const pr = item.prNumber ? ` in #${item.prNumber}` : ""; + const authors = item.authors.map((a) => `@${a}`).join(", "); + md += `- ${item.description}${pr} by ${authors}\n`; + } + md += "\n"; + } + + const contributors = [ + ...new Set(changes.flatMap((c) => c.authors)), + ]; + md += `## ๐Ÿ‘ฅ Contributors\n\n`; + md += contributors.map((a) => `@${a}`).join(", ") + "\n\n"; + + md += `**Full Changelog**: https://github.com/${owner}/${repo}/compare/${prevTag}...${tag}\n`; + + return md; +} diff --git a/.github/scripts/release-notes.ts b/.github/scripts/release-notes.ts new file mode 100644 index 00000000..64691cec --- /dev/null +++ b/.github/scripts/release-notes.ts @@ -0,0 +1,79 @@ +import { GitHubClient } from "./github.ts"; +import { readReleaseNotesLlmConfig } from "./release-notes-config.ts"; +import { generateReleaseNotes } from "./release-notes-generator.ts"; + +function readFlag(name: string): string | null { + const index = Deno.args.indexOf(name); + if (index === -1 || index === Deno.args.length - 1) { + return null; + } + return Deno.args[index + 1]; +} + +function hasFlag(name: string): boolean { + return Deno.args.includes(name); +} + +async function detectPrevTag(tag: string): Promise { + const cmd = new Deno.Command("git", { + args: ["tag", "--sort=-v:refname"], + stdout: "piped", + }); + const output = await cmd.output(); + const tags = new TextDecoder().decode(output.stdout).trim().split("\n"); + + const currentIndex = tags.indexOf(tag); + if (currentIndex === -1 || currentIndex === tags.length - 1) { + throw new Error(`Cannot find previous tag for ${tag}`); + } + + return tags[currentIndex + 1]; +} + +async function main() { + const owner = readFlag("--owner"); + const repo = readFlag("--repo"); + const tag = readFlag("--tag"); + const prevTagArg = readFlag("--prev-tag"); + const dryRun = hasFlag("--dry-run"); + const skipLlm = hasFlag("--skip-llm"); + + if (!owner || !repo || !tag) { + console.error("Usage: release-notes.ts --owner --repo --tag [--prev-tag ] [--dry-run] [--skip-llm]"); + Deno.exit(1); + } + + const prevTag = prevTagArg || await detectPrevTag(tag); + console.log(`Generating release notes for ${tag} (previous: ${prevTag})`); + + const llmConfig = skipLlm ? null : readReleaseNotesLlmConfig(); + + const markdown = await generateReleaseNotes( + owner, + repo, + tag, + prevTag, + llmConfig, + dryRun, + ); + + if (dryRun) { + console.log("\n=== DRY RUN MODE ===\n"); + console.log(markdown); + console.log("\n=== END DRY RUN ==="); + return; + } + + console.log("Creating draft release..."); + const github = new GitHubClient(Deno.env.get("GH_TOKEN") ?? "", owner, repo); + const release = await github.createDraftRelease(tag, tag, markdown); + console.log(`Draft release created: ${release.id}`); + + console.log(`\nDraft release created successfully!`); + console.log(`View at: https://github.com/${owner}/${repo}/releases/tag/${tag}`); +} + +main().catch((error) => { + console.error("Error:", error); + Deno.exit(1); +}); diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml new file mode 100644 index 00000000..ebba66bc --- /dev/null +++ b/.github/workflows/release-notes.yml @@ -0,0 +1,46 @@ +name: AI Release Notes + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Tag to generate release notes for (e.g. v0.3.0)" + required: true + prev_tag: + description: "Previous tag (auto-detect if empty)" + required: false + +permissions: + contents: write + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: Generate release notes + env: + GH_TOKEN: ${{ github.token }} + RELEASE_NOTES_LLM_BASE_URL: ${{ vars.RELEASE_NOTES_LLM_BASE_URL || 'https://models.inference.ai.azure.com' }} + RELEASE_NOTES_LLM_API_KEY: ${{ secrets.RELEASE_NOTES_LLM_API_KEY || github.token }} + RELEASE_NOTES_LLM_MODEL: ${{ vars.RELEASE_NOTES_LLM_MODEL || 'gpt-4o-mini' }} + ISSUE_TRIAGE_LLM_BASE_URL: ${{ vars.ISSUE_TRIAGE_LLM_BASE_URL }} + ISSUE_TRIAGE_LLM_API_KEY: ${{ secrets.ISSUE_TRIAGE_LLM_API_KEY }} + ISSUE_TRIAGE_LLM_MODEL: ${{ vars.ISSUE_TRIAGE_LLM_MODEL }} + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + PREV="${{ github.event.inputs.prev_tag }}" + ARGS="--owner ${{ github.repository_owner }} --repo ${{ github.event.repository.name }} --tag $TAG" + [ -n "$PREV" ] && ARGS="$ARGS --prev-tag $PREV" + + deno run --allow-env --allow-net --allow-run --allow-read \ + .github/scripts/release-notes.ts $ARGS diff --git a/.gitignore b/.gitignore index dcf12884..2eae3701 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ package-lock.json **/.playwright/ **/playwright-report/ **/test-results/ +.playwright-mcp/ # Python / temporary files *.py[cod] @@ -82,3 +83,6 @@ docs/superpowers/ # Local workspace metadata AGENTS.md CLAUDE.md + +# Local config file +.mcp.json