Merge branch 'main' of github.com:riverfor/skillhub

This commit is contained in:
River 2026-04-17 18:36:33 +08:00
commit 27443315ff
8 changed files with 616 additions and 0 deletions

42
.github/release-template.md vendored Normal file
View file

@ -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}}

View file

@ -138,6 +138,47 @@ export class GitHubClient {
);
}
async listCommitPulls(sha: string): Promise<Array<{ number: number; title: string }>> {
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<void> {
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<T>(path: string): Promise<T[]> {
const collected: T[] = [];
let nextPath: string | null = path;

View file

@ -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<string> {
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<string> {
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;

53
.github/scripts/release-notes-config.ts vendored Normal file
View file

@ -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;
}

View file

@ -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<string> {
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<ChangeEntry[]> {
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<number, ChangeEntry>();
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<string> {
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<string, ChangeEntry[]>();
for (const change of changes) {
const type = change.type;
if (!grouped.has(type)) {
grouped.set(type, []);
}
grouped.get(type)!.push(change);
}
const typeLabels: Record<string, string> = {
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;
}

79
.github/scripts/release-notes.ts vendored Normal file
View file

@ -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<string> {
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 <owner> --repo <repo> --tag <tag> [--prev-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);
});

46
.github/workflows/release-notes.yml vendored Normal file
View file

@ -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

4
.gitignore vendored
View file

@ -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