diff --git a/.github/ISSUE_TEMPLATE/reward-task.yml b/.github/ISSUE_TEMPLATE/reward-task.yml new file mode 100644 index 00000000..b9bc7c21 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/reward-task.yml @@ -0,0 +1,48 @@ +name: ๐Ÿ’ฐ Reward Task +description: Task issue with Reward +title: '[Reward] ' +labels: + - reward +body: + - type: textarea + id: description + attributes: + label: Task description + validations: + required: true + + - type: dropdown + id: currency + attributes: + label: Reward currency + options: + - 'USD $' + - 'CAD C$' + - 'AUD A$' + - 'GBP ยฃ' + - 'EUR โ‚ฌ' + - 'CNY ยฅ' + - 'HKD HK$' + - 'TWD NT$' + - 'SGD S$' + - 'KRW โ‚ฉ' + - 'JPY ยฅ' + - 'INR โ‚น' + - 'UAH โ‚ด' + validations: + required: true + + - type: input + id: amount + attributes: + label: Reward amount + validations: + required: true + + - type: input + id: payer + attributes: + label: Reward payer + description: GitHub username of the payer (optional, defaults to issue creator) + validations: + required: false diff --git a/.github/scripts/count-reward.ts b/.github/scripts/count-reward.ts new file mode 100644 index 00000000..a6448cbc --- /dev/null +++ b/.github/scripts/count-reward.ts @@ -0,0 +1,66 @@ +import { $, YAML } from "npm:zx"; + +import { Reward } from "./type.ts"; + +$.verbose = true; + +const rawTags = + await $`git tag --list "reward-*" --format="%(refname:short) %(creatordate:short)"`; + +const lastMonth = new Date(); +lastMonth.setMonth(lastMonth.getMonth() - 1); +const lastMonthStr = lastMonth.toJSON().slice(0, 7); + +const rewardTags = rawTags.stdout + .split("\n") + .filter((line) => line.split(/\s+/)[1] >= lastMonthStr) + .map((line) => line.split(/\s+/)[0]); + +let rawYAML = ""; + +for (const tag of rewardTags) + rawYAML += (await $`git tag -l --format="%(contents)" ${tag}`) + "\n"; + +if (!rawYAML.trim()) { + console.warn("No reward data is found for the last month."); + + process.exit(0); +} + +const rewards = YAML.parse(rawYAML) as Reward[]; + +const groupedRewards = Object.groupBy(rewards, ({ payee }) => payee); + +const summaryList = Object.entries(groupedRewards).map(([payee, rewards]) => { + const reward = rewards!.reduce( + (acc, { currency, reward }) => { + acc[currency] ??= 0; + acc[currency] += reward; + return acc; + }, + {} as Record, + ); + + return { + payee, + reward, + accounts: rewards!.map(({ payee: _, ...account }) => account), + }; +}); + +const summaryText = YAML.stringify(summaryList); + +console.log(summaryText); + +const tagName = `statistic-${new Date().toJSON().slice(0, 7)}`; + +await $`git config user.name "github-actions[bot]"`; +await $`git config user.email "github-actions[bot]@users.noreply.github.com"`; + +await $`git tag -a ${tagName} $(git rev-parse HEAD) -m ${summaryText}`; +await $`git push origin --tags --no-verify`; + +await $`git config unset user.name`; +await $`git config unset user.email`; + +await $`gh release create ${tagName} --notes ${summaryText}`; diff --git a/.github/scripts/deno.json b/.github/scripts/deno.json new file mode 100644 index 00000000..6950ade9 --- /dev/null +++ b/.github/scripts/deno.json @@ -0,0 +1,3 @@ +{ + "nodeModulesDir": "none" +} \ No newline at end of file diff --git a/.github/scripts/github.ts b/.github/scripts/github.ts new file mode 100644 index 00000000..7b9778b5 --- /dev/null +++ b/.github/scripts/github.ts @@ -0,0 +1,230 @@ +interface GitHubUser { + login: string; +} + +interface GitHubLabelRef { + name?: string; +} + +export interface GitHubIssue { + number: number; + title: string; + body: string | null; + state: string; + labels: GitHubLabelRef[]; + comments: number; + created_at: string; + updated_at: string; + user: GitHubUser; + html_url: string; + pull_request?: Record; +} + +export interface GitHubIssueComment { + id: number; + body: string; + user: GitHubUser; + created_at: string; + updated_at: string; + html_url: string; +} + +export interface GitHubLabelDefinition { + name: string; + color: string; + description: string; +} + +function buildApiUrl(path: string) { + return `https://api.github.com${path}`; +} + +export class GitHubClient { + constructor( + private readonly token: string, + private readonly owner: string, + private readonly repo: string, + ) {} + + async getIssue(issueNumber: number): Promise { + return this.request( + "GET", + `/repos/${this.owner}/${this.repo}/issues/${issueNumber}`, + ); + } + + async listIssueComments(issueNumber: number): Promise { + return this.paginate( + `/repos/${this.owner}/${this.repo}/issues/${issueNumber}/comments?per_page=100`, + ); + } + + async listOpenIssuesByLabel( + label: string, + limit = 0, + ): Promise { + const collected: GitHubIssue[] = []; + const unlimited = limit === 0; + let page = 1; + + while (unlimited || collected.length < limit) { + const pageItems = await this.request( + "GET", + `/repos/${this.owner}/${this.repo}/issues?state=open&labels=${ + encodeURIComponent(label) + }&per_page=100&page=${page}`, + ); + + const nonPrIssues = pageItems.filter((item) => !item.pull_request); + collected.push(...nonPrIssues); + + if (pageItems.length < 100) { + break; + } + + page += 1; + } + + return unlimited ? collected : collected.slice(0, limit); + } + + async replaceIssueLabels(issueNumber: number, labels: string[]) { + await this.request( + "PUT", + `/repos/${this.owner}/${this.repo}/issues/${issueNumber}/labels`, + { labels }, + ); + } + + async upsertLabel(definition: GitHubLabelDefinition) { + const encodedName = encodeURIComponent(definition.name); + + try { + await this.request( + "PATCH", + `/repos/${this.owner}/${this.repo}/labels/${encodedName}`, + { + new_name: definition.name, + color: definition.color, + description: definition.description, + }, + ); + } catch (error) { + if (!(error instanceof GitHubApiError) || error.status !== 404) { + throw error; + } + + await this.request("POST", `/repos/${this.owner}/${this.repo}/labels`, { + name: definition.name, + color: definition.color, + description: definition.description, + }); + } + } + + async createIssueComment(issueNumber: number, body: string) { + return this.request( + "POST", + `/repos/${this.owner}/${this.repo}/issues/${issueNumber}/comments`, + { body }, + ); + } + + async updateIssueComment(commentId: number, body: string) { + return this.request( + "PATCH", + `/repos/${this.owner}/${this.repo}/issues/comments/${commentId}`, + { body }, + ); + } + + private async paginate(path: string): Promise { + const collected: T[] = []; + let nextPath: string | null = path; + + while (nextPath) { + const response = await fetch(buildApiUrl(nextPath), { + headers: this.headers(), + }); + + if (!response.ok) { + throw await GitHubApiError.fromResponse(response); + } + + const pageItems = (await response.json()) as T[]; + collected.push(...pageItems); + nextPath = parseNextLink(response.headers.get("link")); + } + + return collected; + } + + private async request( + method: string, + path: string, + body?: unknown, + ): Promise { + const response = await fetch(buildApiUrl(path), { + method, + headers: this.headers(), + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + throw await GitHubApiError.fromResponse(response); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; + } + + private headers() { + return { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + "User-Agent": "skillhub-issue-triage", + "X-GitHub-Api-Version": "2022-11-28", + }; + } +} + +export class GitHubApiError extends Error { + constructor( + readonly status: number, + readonly responseBody: string, + ) { + super(`GitHub API request failed with status ${status}: ${responseBody}`); + } + + static async fromResponse(response: Response) { + return new GitHubApiError(response.status, await response.text()); + } +} + +function parseNextLink(linkHeader: string | null) { + if (!linkHeader) { + return null; + } + + const nextEntry = linkHeader + .split(",") + .map((item) => item.trim()) + .find((item) => item.endsWith('rel="next"')); + + if (!nextEntry) { + return null; + } + + const urlMatch = nextEntry.match(/<([^>]+)>/); + + if (!urlMatch) { + return null; + } + + const url = new URL(urlMatch[1]); + return `${url.pathname}${url.search}`; +} diff --git a/.github/scripts/issue-backlog-rescore.ts b/.github/scripts/issue-backlog-rescore.ts new file mode 100644 index 00000000..dd0cf9c0 --- /dev/null +++ b/.github/scripts/issue-backlog-rescore.ts @@ -0,0 +1,128 @@ +import { GitHubClient } from "./github.ts"; +import { readIssueLlmConfig, shouldUseLlm } from "./issue-llm-config.ts"; +import { evaluateIssueWithLlm } from "./issue-llm-evaluator.ts"; +import { TRIAGE_MANUAL_OVERRIDE_LABEL } from "./issue-triage-config.ts"; +import { + analyzeIssue, + buildManagedLabels, + ensureManagedLabels, + findTriageComment, + parseTriageMachineState, + previewTriageMutation, + syncManagedLabels, + upsertTriageComment, +} from "./issue-triage-lib.ts"; +import { mergeRuleAndLlm } from "./issue-triage-merge.ts"; + +function readFlag(name: string) { + const index = Deno.args.indexOf(`--${name}`); + return index >= 0 ? Deno.args[index + 1] : undefined; +} + +function hasFlag(name: string) { + return Deno.args.includes(`--${name}`); +} + +const owner = readFlag("owner"); +const repo = readFlag("repo"); +const limitValue = readFlag("limit") ?? "0"; +const dryRun = hasFlag("dry-run"); +const token = Deno.env.get("GH_TOKEN") ?? Deno.env.get("GITHUB_TOKEN"); + +if (!owner || !repo || !token) { + throw new Error( + "Usage: deno run issue-backlog-rescore.ts --owner --repo [--limit 0 for all] with GH_TOKEN set.", + ); +} + +const limit = Number.parseInt(limitValue, 10); + +if (Number.isNaN(limit) || limit < 0) { + throw new Error(`Invalid limit: ${limitValue}`); +} + +const client = new GitHubClient(token, owner, repo); +if (!dryRun) { + await ensureManagedLabels(client); +} +const llmConfig = readIssueLlmConfig(); + +const issues = await client.listOpenIssuesByLabel("triage/deferred", limit); +const dryRunResults: Array> = []; + +for (const issue of issues) { + if ( + issue.labels.some((label) => label.name === TRIAGE_MANUAL_OVERRIDE_LABEL) + ) { + console.log( + `Skipping #${issue.number} because ${TRIAGE_MANUAL_OVERRIDE_LABEL} is set.`, + ); + continue; + } + + const comments = await client.listIssueComments(issue.number); + const ruleResult = analyzeIssue(issue, comments); + const existingComment = findTriageComment(comments); + const previousState = existingComment + ? parseTriageMachineState(existingComment.body) + : null; + let result = ruleResult; + + if (llmConfig) { + const llmDecision = shouldUseLlm(issue, ruleResult); + + if (llmDecision.use) { + const { inputHash, assessment } = await evaluateIssueWithLlm( + llmConfig, + issue, + comments, + ruleResult, + previousState, + ); + + result = mergeRuleAndLlm({ + ...ruleResult, + inputHash, + llm: assessment, + mode: assessment.mode === "assist" ? "llm-assist" : "llm-shadow", + }); + } + } + + if (dryRun) { + const preview = previewTriageMutation(result, comments); + dryRunResults.push({ + issue: issue.number, + mode: result.mode, + route: result.route, + priority: result.priority, + effort: result.effort, + confidence: result.confidence, + riskLevel: result.riskLevel, + labels: preview.labels, + commentAction: preview.existingComment ? "update" : "create", + commentBody: preview.commentBody, + }); + continue; + } + + await syncManagedLabels(client, issue, result); + await upsertTriageComment(client, issue.number, result, comments); + + console.log( + JSON.stringify( + { + issue: issue.number, + route: result.route, + priority: result.priority, + labels: buildManagedLabels(issue, result), + }, + null, + 2, + ), + ); +} + +if (dryRun) { + console.log(JSON.stringify({ dryRun: true, issues: dryRunResults }, null, 2)); +} diff --git a/.github/scripts/issue-handoff-brief.ts b/.github/scripts/issue-handoff-brief.ts new file mode 100644 index 00000000..e19dacaa --- /dev/null +++ b/.github/scripts/issue-handoff-brief.ts @@ -0,0 +1,257 @@ +import { MaintainerHandoffBrief, TriageResult } from "./issue-triage-types.ts"; + +const AREA_RULES: Array<{ keywords: string[]; area: string }> = [ + { + keywords: ["clawhub publish", "publish skill", "publish", "namespace"], + area: + "CLI ๅ‘ๅธƒๅ‘ฝไปคๅ‚ๆ•ฐ่งฃๆžไธŽ namespace ๆ„Ÿ็Ÿฅๅ‘ๅธƒๆต็จ‹ / CLI publish command option parsing and namespace-aware publish flow", + }, + { + keywords: ["clawhub install", "install skill", "install"], + area: + "ๆŠ€่ƒฝๅฎ‰่ฃ…ๆต็จ‹ไธŽ registry/lockfile ้›†ๆˆ / Skill installation flow and registry/lockfile integration", + }, + { + keywords: ["clawhub update", "update skill", "update"], + area: + "ๅทฒๅฎ‰่ฃ…ๆŠ€่ƒฝๆ›ดๆ–ฐๆต็จ‹ไธŽ็‰ˆๆœฌ่งฃๆž / Installed skill update flow and version resolution", + }, + { + keywords: ["clawhub sync", "sync skill", "sync"], + area: + "ๆœฌๅœฐๆŠ€่ƒฝๅŒๆญฅๆต็จ‹ไธŽๅ‘ๅธƒ diff ๆฃ€ๆต‹ / Local skill sync flow and publish diff detection", + }, + { + keywords: ["inspect", "search", "explore"], + area: + "Registry ๅ‘็ŽฐไธŽ CLI ๆŸฅ่ฏขๆต็จ‹ / Registry discovery and CLI query workflow", + }, + { + keywords: ["auth", "login", "ldap", "sso", "token"], + area: + "่ฎค่ฏใ€ไผš่ฏไธŽ่บซไปฝ้›†ๆˆ / Authentication, session, and identity integration", + }, + { + keywords: ["openapi", "sdk", "api contract", "contract"], + area: + "ๅ…ฌๅผ€ API ๅฅ‘็บฆใ€็”Ÿๆˆ SDK ไธŽๅ…ผๅฎนๆ€ง่กจ้ข / Public API contract, generated SDKs, and compatibility surface", + }, + { + keywords: ["docs", "documentation", "manual", "help", "--help"], + area: + "ๆ–‡ๆกฃใ€ๆ“ไฝœๆŒ‡ๅผ•ไธŽ CLI help ่พ“ๅ‡บ / Documentation, operator guidance, and CLI help output", + }, + { + keywords: ["scanner", "security", "audit"], + area: + "ๅฎ‰ๅ…จๆ‰ซๆๆต็จ‹ไธŽๅฎก่ฎก/ๆŠฅๅ‘Š่กŒไธบ / Security scanner pipeline and audit/reporting behavior", + }, +]; + +export function buildMaintainerHandoffBrief( + result: TriageResult, +): MaintainerHandoffBrief | undefined { + if (result.route !== "core") { + return undefined; + } + + const summary = buildSummary(result); + const whyCore = unique([ + result.requiresCoreMaintainer + ? "้˜ปๅกž OpenClaw/ClawHub ๆ ธๅฟƒๅทฅไฝœๆต๏ผŒๅ› ๆญคๅณไพฟๆ”นๅŠจ่Œƒๅ›ด็œ‹่ตทๆฅๅฏๆŽง๏ผŒไนŸ้œ€่ฆ maintainer judgment / Blocks an OpenClaw/ClawHub core workflow, so maintainer judgment is required even if the code change looks bounded." + : "", + result.riskLevel === "high" + ? "่งฆๅŠ้ซ˜้ฃŽ้™ฉๅŒบๅŸŸ๏ผŒๆœช็ป maintainer ๅฎกๆŸฅไธๅบ”็›ดๆŽฅไฟกไปป่‡ชๅŠจไฟฎๅค / Touches a higher-risk area where automated fixes should not be trusted without maintainer review." + : "", + result.effort >= 4 + ? "ๅคงๆฆ‚็އ่ทจๅคšไธชๆจกๅ—ๆˆ–ๅ…ฌๅ…ฑๅ…ผๅฎน้ข / Likely spans multiple modules or a public compatibility surface." + : "", + result.confidence <= 3 + ? "้—ฎ้ข˜ๆœฌ่บซ้‡่ฆ๏ผŒไฝ†ไป้œ€่ฆ maintainer ๅ…ˆๆ”ถๆ•›่Œƒๅ›ดๅ†ๅฎžๆ–ฝ / The issue is important, but a maintainer still needs to tighten scope before implementation." + : "", + ...result.highRiskReasons, + ]).slice(0, 4); + + const reproduction = buildReproduction(result); + const suspectedAreas = inferSuspectedAreas(result); + const risks = buildRisks(result, suspectedAreas); + const validation = buildValidation(result, suspectedAreas); + + return { + summary, + whyCore, + reproduction, + suspectedAreas, + risks, + validation, + }; +} + +function buildSummary(result: TriageResult) { + const llmSummary = result.llm?.summaryZh ?? result.llm?.summary ?? + result.llm?.summaryEn; + + if (llmSummary && llmSummary.trim().length > 0) { + return llmSummary.trim(); + } + + const preferred = [ + result.sections["summary"], + result.sections["problem"], + result.sections["expected behavior"], + ].find((value) => value && value.trim().length > 0); + + if (preferred) { + return compact(preferred); + } + + return result.issue.title.replace(/^\[[^\]]+\]\s*/, "").trim(); +} + +function buildReproduction(result: TriageResult) { + const commandFocusedSteps = extractCommandAndErrorLines( + result.sections["steps to reproduce"], + ); + + if (commandFocusedSteps.length > 0) { + return commandFocusedSteps.slice(0, 4); + } + + const steps = splitIntoBullets(result.sections["steps to reproduce"]); + + if (steps.length > 0) { + return steps.slice(0, 5); + } + + const problem = splitIntoBullets(result.sections["problem"]); + + if (problem.length > 0) { + return problem.slice(0, 4); + } + + return [ + "ๆŒ‰ issue ไธญๆ่ฟฐ็š„ๆ“ไฝœ่ทฏๅพ„ๅค็Žฐ๏ผŒๅนถ็กฎ่ฎคๅฝ“ๅ‰ๅคฑ่ดฅๆจกๅผ / Recreate the operator flow described in the issue and confirm the current failure mode.", + ]; +} + +function inferSuspectedAreas(result: TriageResult) { + const text = [ + result.issue.title, + result.sections["summary"] ?? "", + result.sections["problem"] ?? "", + result.sections["steps to reproduce"] ?? "", + result.sections["impact"] ?? "", + result.sections["api contract impact"] ?? "", + result.sections["contract or sdk impact"] ?? "", + ] + .join("\n") + .toLowerCase(); + + const areas = AREA_RULES.filter((rule) => + rule.keywords.some((keyword) => text.includes(keyword)) + ).map((rule) => rule.area); + + if (areas.length > 0) { + return unique(areas).slice(0, 5); + } + + return [ + "ๆœ€ๆŽฅ่ฟ‘่ฏฅๅคฑ่ดฅ่ทฏๅพ„็š„ owner-facing ๅทฅไฝœๆตๆจกๅ— / The closest owner-facing workflow module for the issue's reported failure path", + "ๅฝ“ๅ‰ๅฏนๅค–ๆ‰ฟ่ฏบ่ฏฅ่กŒไธบ็š„ๆ–‡ๆกฃๆˆ– help ๆ–‡ๆœฌ / Any docs or help text that currently promise the affected behavior", + ]; +} + +function buildRisks(result: TriageResult, suspectedAreas: string[]) { + const risks = unique([ + ...result.highRiskReasons, + result.llm?.riskFlags.includes("cli-protocol") + ? "CLI ่กŒไธบใ€ๆ–‡ๆกฃๅ’Œๆ“ไฝœ้ข„ๆœŸๅฏ่ƒฝๅ‘็”Ÿๆผ‚็งป๏ผŒ้œ€่ฆๅŒๆญฅๆ›ดๆ–ฐๅ‘ฝไปค help ไธŽๅ…ผๅฎนๆ€ง่ฏดๆ˜Ž / CLI behavior, docs, and operator expectations may drift unless command help and compatibility notes are updated together." + : "", + suspectedAreas.some((area) => area.toLowerCase().includes("namespace")) + ? "namespace ่Œƒๅ›ด่กŒไธบๅฆ‚ๆžœๆฒกๆœ‰ไฟ็•™ fallback routing๏ผŒๅฏ่ƒฝๅ›žๅฝ’้ป˜่ฎค publish/install ๆต็จ‹ / Namespace-scoped behavior can regress default publish/install flows if fallback routing is not preserved." + : "", + result.requiresCoreMaintainer + ? "่ฏฅ้—ฎ้ข˜ๅฝฑๅ“ๅทฒๅฎšไน‰ไธปๆต็จ‹๏ผŒๅ›žๅฝ’ไผšๅพˆๅฟซ่ขซ็ปˆ็ซฏ็”จๆˆทๆ„Ÿ็Ÿฅ / This issue affects a documented primary workflow, so regressions would be visible to end users quickly." + : "", + ]); + + return risks.length > 0 ? risks.slice(0, 4) : [ + "ๅˆๅนถๅ‰ๆฃ€ๆŸฅ็›ธ้‚ป็”จๆˆท่ทฏๅพ„ๆ˜ฏๅฆๅ‡บ็Žฐๅ›žๅฝ’ / Check for regressions in adjacent user-facing workflow paths before merging.", + ]; +} + +function buildValidation(result: TriageResult, suspectedAreas: string[]) { + const validation = unique([ + result.sections["steps to reproduce"] + ? "ๆŒ‰ issue ไธญ็š„ๅค็Žฐๆญฅ้ชค้€ๆกๅ›žๆ”พ๏ผŒ็กฎ่ฎคๆŠฅๅ‘Š็š„้—ฎ้ข˜ๅทฒๆถˆๅคฑ / Replay the exact reproduction steps from the issue and confirm the reported failure disappears." + : "ไฟฎๅคๅŽ็ซฏๅˆฐ็ซฏ้ชŒ่ฏไธปๆŠฅๅ‘Šๆต็จ‹ / Validate the primary reported workflow end-to-end after the fix.", + result.sections["expected behavior"] + ? `็กฎ่ฎคๆœ€็ปˆ่กŒไธบ็ฌฆๅˆ issue ๆœŸๆœ› / Confirm the final behavior matches the issue's expected outcome: ${ + compact(result.sections["expected behavior"]) + }` + : "", + suspectedAreas.some((area) => area.toLowerCase().includes("documentation")) + ? "ๆ›ดๆ–ฐๆˆ–ๆ ธๅฏนๆ–‡ๆกฃไธŽ CLI help ่พ“ๅ‡บ๏ผŒ็กฎไฟๅ…ถไธŽๅฎž็Žฐ่กŒไธบไธ€่‡ด / Update or verify documentation and CLI help output so they match the implemented behavior." + : "", + suspectedAreas.some((area) => area.toLowerCase().includes("api contract")) + ? "ๅ‘ๅธƒๅ‰ๆฃ€ๆŸฅไธ‹ๆธธ API/SDK/CLI ็š„ๅ…ผๅฎนๆ€ง้ข„ๆœŸ / Check for downstream API/SDK/CLI compatibility expectations before shipping." + : "", + suspectedAreas.some((area) => area.toLowerCase().includes("namespace")) + ? "ๅŒๆ—ถ้ชŒ่ฏ namespace ่Œƒๅ›ด่กŒไธบไธŽ้ป˜่ฎค้ž namespace ๆต็จ‹ / Verify both namespace-scoped behavior and the default non-namespace flow." + : "", + result.requiresCoreMaintainer + ? "ๅ›ด็ป•ๅ—ๅฝฑๅ“็š„ OpenClaw/ClawHub ็”จๆˆท่ทฏๅพ„ๆ‰ง่กŒๆœ€ๅฐๅฟ…่ฆๅ›žๅฝ’ๆต‹่ฏ• / Run the smallest relevant regression test around the affected OpenClaw/ClawHub user journey." + : "", + ]); + + return validation.slice(0, 5); +} + +function splitIntoBullets(value: string | undefined) { + if (!value) { + return []; + } + + return value + .split("\n") + .map((line) => line.trim()) + .filter((line) => + line.length > 0 && + line !== "```" && + !line.startsWith("PS ") && + !line.startsWith("Usage:") && + !line.startsWith("Options:") && + !line.startsWith("Arguments:") + ) + .map((line) => line.replace(/^[*-]\s*/, "")) + .slice(0, 6); +} + +function extractCommandAndErrorLines(value: string | undefined) { + if (!value) { + return []; + } + + return value + .split("\n") + .map((line) => line.trim()) + .filter((line) => + line.length > 0 && + ( + line.toLowerCase().includes("clawhub ") || + line.toLowerCase().startsWith("error:") || + line.toLowerCase().includes("unknown option") || + line.toLowerCase().includes("usage:") + ) + ) + .map((line) => line.replace(/^[>*-]\s*/, "")) + .slice(0, 4); +} + +function compact(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +function unique(values: string[]) { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} diff --git a/.github/scripts/issue-llm-config.ts b/.github/scripts/issue-llm-config.ts new file mode 100644 index 00000000..f204d89b --- /dev/null +++ b/.github/scripts/issue-llm-config.ts @@ -0,0 +1,139 @@ +import { GitHubIssue } from "./github.ts"; +import { IssueLlmConfig } from "./issue-llm-types.ts"; +import { TriageResult } from "./issue-triage-types.ts"; + +const DEFAULT_TIMEOUT_MS = 30000; +const DEFAULT_MAX_ATTEMPTS = 2; +const DEFAULT_RETRY_BACKOFF_MS = 1500; +const DEFAULT_TEMPERATURE = 0.1; +const DEFAULT_MAX_COMMENTS = 4; +const DEFAULT_MAX_COMMENT_CHARS = 900; +const DEFAULT_MAX_BODY_CHARS = 6000; + +export function readIssueLlmConfig(): IssueLlmConfig | null { + const mode = normalizeMode(Deno.env.get("ISSUE_TRIAGE_LLM_MODE")); + + if (mode === "off") { + return null; + } + + const baseUrl = normalizeUrl(Deno.env.get("ISSUE_TRIAGE_LLM_BASE_URL")); + const apiKey = Deno.env.get("ISSUE_TRIAGE_LLM_API_KEY")?.trim() ?? ""; + const model = Deno.env.get("ISSUE_TRIAGE_LLM_MODEL")?.trim() ?? ""; + + if (!baseUrl || !apiKey || !model) { + console.warn( + "LLM triage is configured in a non-off mode but base URL, model, or API key is missing. Falling back to rules-only.", + ); + return null; + } + + return { + mode, + provider: "openai-compatible", + baseUrl, + apiKey, + model, + timeoutMs: parseInteger( + Deno.env.get("ISSUE_TRIAGE_LLM_TIMEOUT_MS"), + DEFAULT_TIMEOUT_MS, + ), + maxAttempts: Math.max( + 1, + parseInteger( + Deno.env.get("ISSUE_TRIAGE_LLM_MAX_ATTEMPTS"), + DEFAULT_MAX_ATTEMPTS, + ), + ), + retryBackoffMs: Math.max( + 0, + parseInteger( + Deno.env.get("ISSUE_TRIAGE_LLM_RETRY_BACKOFF_MS"), + DEFAULT_RETRY_BACKOFF_MS, + ), + ), + temperature: parseFloatSetting( + Deno.env.get("ISSUE_TRIAGE_LLM_TEMPERATURE"), + DEFAULT_TEMPERATURE, + ), + maxComments: parseInteger( + Deno.env.get("ISSUE_TRIAGE_LLM_MAX_COMMENTS"), + DEFAULT_MAX_COMMENTS, + ), + maxCommentChars: parseInteger( + Deno.env.get("ISSUE_TRIAGE_LLM_MAX_COMMENT_CHARS"), + DEFAULT_MAX_COMMENT_CHARS, + ), + maxBodyChars: parseInteger( + Deno.env.get("ISSUE_TRIAGE_LLM_MAX_BODY_CHARS"), + DEFAULT_MAX_BODY_CHARS, + ), + }; +} + +export function shouldUseLlm(issue: GitHubIssue, result: TriageResult) { + const reasons: string[] = []; + + if (result.route === "needs-info") { + reasons.push("route-needs-info"); + } + + if (result.route === "core") { + reasons.push("route-core"); + } + + if (result.priority >= 3 && result.priority <= 4.2) { + reasons.push("priority-near-threshold"); + } + + if (result.confidence <= 3) { + reasons.push("confidence-low"); + } + + if (issue.comments >= 4) { + reasons.push("discussion-heavy"); + } + + if ((issue.body ?? "").length >= 1200) { + reasons.push("body-long"); + } + + if (result.issueKind === "feature" || result.issueKind === "reward") { + reasons.push("non-bug-judgment"); + } + + return { + use: reasons.length > 0, + reasons, + }; +} + +function normalizeMode(raw: string | undefined | null) { + const value = raw?.trim().toLowerCase(); + + if (value === "shadow" || value === "assist") { + return value; + } + + return "off"; +} + +function normalizeUrl(value: string | undefined | null) { + const trimmed = value?.trim(); + + if (!trimmed) { + return ""; + } + + return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; +} + +function parseInteger(raw: string | undefined, fallback: number) { + const parsed = Number.parseInt(raw ?? "", 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +function parseFloatSetting(raw: string | undefined, fallback: number) { + const parsed = Number.parseFloat(raw ?? ""); + return Number.isNaN(parsed) ? fallback : parsed; +} diff --git a/.github/scripts/issue-llm-evaluator.ts b/.github/scripts/issue-llm-evaluator.ts new file mode 100644 index 00000000..c9ac93ef --- /dev/null +++ b/.github/scripts/issue-llm-evaluator.ts @@ -0,0 +1,466 @@ +import { GitHubIssue, GitHubIssueComment } from "./github.ts"; +import { + IssueLlmConfig, + IssueLlmPayload, + IssueLlmResponse, +} from "./issue-llm-types.ts"; +import { requestOpenAiCompatibleJson } from "./issue-llm-provider.ts"; +import { + IssueRoute, + LlmAssessment, + TriageMachineState, + TriageResult, +} from "./issue-triage-types.ts"; + +const ALLOWED_RISK_FLAGS = new Set([ + "auth", + "security", + "token", + "permission", + "migration", + "schema", + "api-contract", + "sdk", + "cli-protocol", + "data-loss", +]); +const PROMPT_VERSION = 3; + +export async function evaluateIssueWithLlm( + config: IssueLlmConfig, + issue: GitHubIssue, + comments: GitHubIssueComment[], + ruleResult: TriageResult, + previousState: TriageMachineState | null, +) { + const payload = buildPayload(config, issue, comments, ruleResult); + const inputHash = await buildIssueInputHash(payload); + const cached = previousState?.llm; + + if ( + cached && + cached.inputHash === inputHash && + cached.provider === config.provider && + cached.model === config.model && + cached.mode === config.mode && + !cached.failed + ) { + return { + inputHash, + assessment: { + ...cached, + reused: true, + } as LlmAssessment, + }; + } + + try { + const rawJson = await requestOpenAiCompatibleJson( + config, + buildSystemPrompt(), + JSON.stringify(payload, null, 2), + ); + const parsed = validateLlmResponse(JSON.parse(rawJson), ruleResult); + + return { + inputHash, + assessment: { + provider: config.provider, + model: config.model, + mode: config.mode, + inputHash, + summary: parsed.summary_zh || parsed.summary || parsed.summary_en || "", + summaryEn: parsed.summary_en || parsed.summary || parsed.summary_zh || + "", + summaryZh: parsed.summary_zh || parsed.summary || parsed.summary_en || + "", + impact: parsed.impact, + urgency: parsed.urgency, + effort: parsed.effort, + confidence: parsed.confidence, + riskFlags: parsed.risk_flags, + missingInfo: parsed.missing_info, + suggestedQuestions: parsed.suggested_questions, + recommendedRoute: parsed.recommended_route, + rationale: parsed.rationale, + reused: false, + failed: false, + } satisfies LlmAssessment, + }; + } catch (error) { + const failureReason = error instanceof Error + ? error.message + : String(error); + + return { + inputHash, + assessment: { + provider: config.provider, + model: config.model, + mode: config.mode, + inputHash, + summary: "", + summaryEn: "", + summaryZh: "", + impact: ruleResult.impact, + urgency: ruleResult.urgency, + effort: ruleResult.effort, + confidence: ruleResult.confidence, + riskFlags: [], + missingInfo: [], + suggestedQuestions: [], + recommendedRoute: ruleResult.route, + rationale: [], + reused: false, + failed: true, + failureReason, + } satisfies LlmAssessment, + }; + } +} + +function buildPayload( + config: IssueLlmConfig, + issue: GitHubIssue, + comments: GitHubIssueComment[], + ruleResult: TriageResult, +): IssueLlmPayload { + const latestComments = comments + .filter((comment) => + !comment.body.includes("`; +} + +function calculateConfidence( + issueKind: IssueKind, + rawBody: string, + sections: Record, + missingFields: string[], +) { + const required = requiredFields(issueKind); + const requiredFilled = + required.filter((field) => hasMeaningfulSection(sections[field])).length; + const supportFields = Object.entries(sections).filter( + ([key, value]) => !required.includes(key) && hasMeaningfulSection(value), + ).length; + + let score = 1; + score += requiredFilled; + score += supportFields >= 1 ? 0.5 : 0; + score += supportFields >= 3 ? 0.5 : 0; + score += rawBody.length >= 400 ? 0.5 : 0; + score -= missingFields.length > 0 ? 1 : 0; + + return clamp(Math.round(score), 1, 5); +} + +function calculateAgePolicy(createdAt: string, now: Date) { + const created = new Date(createdAt); + const openDays = Math.floor( + (now.getTime() - created.getTime()) / (24 * 60 * 60 * 1000), + ); + const safeOpenDays = Math.max(0, openDays); + + if (safeOpenDays >= 14) { + return { + openDays: safeOpenDays, + ageBoost: 1.5, + priorityFloor: 4.4, + reason: + `ๅทฒๆ‰“ๅผ€ ${safeOpenDays} ๅคฉ๏ผŒ่ถ…่ฟ‡ 14 ๅคฉ้—ญ็Žฏ SLA๏ผŒไผ˜ๅ…ˆ็บงๅผบๅˆถๆๅ‡ๅˆฐ P0 / Open for ${safeOpenDays} days; the 14-day closure SLA is breached, so priority is forced to P0.`, + }; + } + + if (safeOpenDays >= 10) { + return { + openDays: safeOpenDays, + ageBoost: 1, + priorityFloor: 3.6, + reason: + `ๅทฒๆ‰“ๅผ€ ${safeOpenDays} ๅคฉ๏ผŒไธบ้ฟๅ…่ถ…่ฟ‡ 14 ๅคฉไปๆœช้—ญ็Žฏ๏ผŒๅผบๅˆถ่ฟ›ๅ…ฅ active lane / Open for ${safeOpenDays} days; forced into an active lane before the 14-day closure SLA is missed.`, + }; + } + + if (safeOpenDays >= 7) { + return { + openDays: safeOpenDays, + ageBoost: 0.6, + priorityFloor: 2.6, + reason: + `ๅทฒๆ‰“ๅผ€ ${safeOpenDays} ๅคฉ๏ผŒๅผ€ๅง‹่ฟ›ๅ…ฅ 2 ๅ‘จ้—ญ็Žฏ้ข„็ƒญ็ช—ๅฃ / Open for ${safeOpenDays} days; entering the 2-week closure warm-up window.`, + }; + } + + return { + openDays: safeOpenDays, + ageBoost: 0, + priorityFloor: 0, + reason: "", + }; +} + +function calculateEngagementBoost( + commentCount: number, + rewardAmountText?: string, +) { + let boost = Math.min(0.8, commentCount * 0.1); + const rewardAmount = Number.parseFloat( + (rewardAmountText ?? "").replaceAll(/[^0-9.]/g, ""), + ); + + if (!Number.isNaN(rewardAmount)) { + if (rewardAmount >= 500) { + boost += 0.6; + } else if (rewardAmount >= 100) { + boost += 0.3; + } else if (rewardAmount > 0) { + boost += 0.1; + } + } + + return Math.min(1, boost); +} + +function requiredFields(issueKind: IssueKind) { + return REQUIRED_SECTIONS[issueKind] ?? []; +} + +function buildSearchText(issue: GitHubIssue, sections: Record) { + return [issue.title, issue.body ?? "", ...Object.values(sections)].join("\n") + .toLowerCase(); +} + +function buildRiskText(issue: GitHubIssue, sections: Record) { + const preferredSections = [ + "summary", + "problem", + "proposed solution", + "expected behavior", + "steps to reproduce", + "impact", + "api contract impact", + "contract or sdk impact", + ]; + + return [ + issue.title, + ...preferredSections.map((section) => sections[section] ?? ""), + ] + .join("\n") + .toLowerCase(); +} + +function buildWorkflowText( + issue: GitHubIssue, + sections: Record, +) { + const preferredSections = [ + "summary", + "problem", + "steps to reproduce", + "expected behavior", + "impact", + ]; + + return [ + issue.title, + ...preferredSections.map((section) => sections[section] ?? ""), + ] + .join("\n") + .toLowerCase(); +} + +function normalizeHeading(value: string) { + return value.trim().toLowerCase(); +} + +function cleanupSectionContent(value: string) { + return value + .replaceAll(/^_No response_\s*$/gim, "") + .replaceAll(/^no response\s*$/gim, "") + .trim(); +} + +function hasMeaningfulSection(value: string | undefined) { + return Boolean(value && cleanupSectionContent(value).length >= 3); +} + +function uniqueNonEmpty(values: string[]) { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function roundToOneDecimal(value: number) { + return Math.round(value * 10) / 10; +} + +export function findTriageComment(comments: GitHubIssueComment[]) { + return comments.find((comment) => + comment.body.includes(TRIAGE_COMMENT_MARKER) + ); +} + +export function buildManagedLabels(issue: GitHubIssue, result: TriageResult) { + const existingLabels = issue.labels + .map((label) => label.name) + .filter((label): label is string => Boolean(label)); + + const unmanagedLabels = existingLabels.filter( + (label) => + !MANAGED_LABEL_PREFIXES.some((prefix) => label.startsWith(prefix)), + ); + + return [ + ...unmanagedLabels, + routeLabel(result.route), + priorityLabel(result.priority), + effortLabel(result.effort), + ...riskLabels(result.riskLevel), + ]; +} + +export function previewTriageMutation( + result: TriageResult, + comments: GitHubIssueComment[], +) { + return { + labels: uniqueNonEmpty(buildManagedLabels(result.issue, result)), + commentBody: renderTriageComment(result), + existingComment: findTriageComment(comments) ?? null, + }; +} + +export function parseTriageMachineState( + commentBody: string, +): TriageMachineState | null { + const start = commentBody.indexOf(TRIAGE_COMMENT_MARKER); + + if (start < 0) { + return null; + } + + const jsonStart = start + TRIAGE_COMMENT_MARKER.length; + const end = commentBody.indexOf("-->", jsonStart); + + if (end < 0) { + return null; + } + + const rawJson = commentBody.slice(jsonStart, end).trim(); + + try { + const parsed = JSON.parse(rawJson) as TriageMachineState; + + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.issue !== "number" || + typeof parsed.route !== "string" + ) { + return null; + } + + return parsed; + } catch { + return null; + } +} diff --git a/.github/scripts/issue-triage-merge.ts b/.github/scripts/issue-triage-merge.ts new file mode 100644 index 00000000..63c162dc --- /dev/null +++ b/.github/scripts/issue-triage-merge.ts @@ -0,0 +1,166 @@ +import { TriageResult, TriageSnapshot } from "./issue-triage-types.ts"; +import { buildMaintainerHandoffBrief } from "./issue-handoff-brief.ts"; + +export function mergeRuleAndLlm(ruleResult: TriageResult): TriageResult { + const llm = ruleResult.llm; + + if (!llm || llm.failed || llm.mode !== "assist") { + return { + ...ruleResult, + handoffBrief: ruleResult.route === "core" + ? buildMaintainerHandoffBrief(ruleResult) + : undefined, + mode: llm && !llm.failed && llm.mode === "shadow" + ? "llm-shadow" + : "rules-only", + inputHash: llm?.inputHash ?? ruleResult.inputHash, + }; + } + + const impact = nudgeScore(ruleResult.impact, llm.impact); + const urgency = nudgeScore(ruleResult.urgency, llm.urgency); + const effort = nudgeScore(ruleResult.effort, llm.effort); + const confidence = nudgeScore(ruleResult.confidence, llm.confidence); + const missingFields = unique([ + ...ruleResult.missingFields, + ...llm.missingInfo, + ]); + const highRiskReasons = unique([ + ...ruleResult.highRiskReasons, + ...llm.riskFlags.map((flag) => + `LLM ๆ ‡่ฎฐไบ†้ซ˜้ฃŽ้™ฉๅŒบๅŸŸ๏ผš${flag} / LLM flagged high-risk area: ${flag}.` + ), + ]); + const requiresCoreMaintainer = ruleResult.requiresCoreMaintainer; + const riskLevel = highRiskReasons.length > 0 ? "high" : "low"; + const priority = clamp( + roundToOneDecimal( + impact * 0.45 + + urgency * 0.35 + + ruleResult.ageBoost + + ruleResult.engagementBoost, + ), + 1, + 5, + ); + const route = determineRoute( + priority, + effort, + confidence, + riskLevel, + missingFields, + requiresCoreMaintainer, + ); + const nextAction = describeNextAction(route, missingFields); + const reasons = unique([ + ...ruleResult.reasons, + ...llm.rationale, + llm.summary + ? `LLM ๆ‘˜่ฆ๏ผš${llm.summaryZh || llm.summary} / LLM summary: ${ + llm.summaryEn || llm.summary + }` + : "", + ]).slice(0, 6); + + const mergedSnapshot: TriageSnapshot = { + route, + riskLevel, + requiresCoreMaintainer, + openDays: ruleResult.openDays, + impact, + urgency, + effort, + confidence, + priority, + ageBoost: ruleResult.ageBoost, + priorityFloor: ruleResult.priorityFloor, + engagementBoost: ruleResult.engagementBoost, + missingFields, + reasons, + highRiskReasons, + nextAction, + }; + + return { + ...ruleResult, + ...mergedSnapshot, + mode: "llm-assist", + inputHash: llm.inputHash, + handoffBrief: route === "core" + ? buildMaintainerHandoffBrief({ + ...ruleResult, + ...mergedSnapshot, + mode: "llm-assist", + inputHash: llm.inputHash, + }) + : undefined, + }; +} + +export function determineRoute( + priority: number, + effort: number, + confidence: number, + riskLevel: "low" | "high", + missingFields: string[], + requiresCoreMaintainer = false, +) { + if (requiresCoreMaintainer) { + return "core"; + } + + if (missingFields.length > 0 || confidence <= 2) { + return "needs-info"; + } + + if (priority < 3.6) { + return "deferred"; + } + + if (riskLevel === "high" || effort >= 4 || confidence <= 3) { + return "core"; + } + + return "agent-ready"; +} + +export function describeNextAction( + route: TriageResult["route"], + missingFields: string[], +) { + if (route === "needs-info") { + return `็ญ‰ๅพ…่กฅๅ……ๆ›ดๅคšไฟกๆฏ๏ผ›ไฝœ่€…ๆ›ดๆ–ฐ issue ๆˆ–่ฏ„่ฎบ \`/retriage\` ๅŽ้‡ๆ–ฐๅˆ†ๆต / Wait for more detail, then rerun triage after the author edits the issue or comments \`/retriage\`. Missing: ${ + missingFields.join(", ") + }.`; + } + + if (route === "deferred") { + return "ๅฐ† issue ไฟ็•™ๅœจ deferred ้˜Ÿๅˆ—๏ผŒๅนถ็”ฑ 6 ๅฐๆ—ถไธ€ๆฌก็š„ rescore ๆŒ็ปญๆŠฌๅ‡๏ผ›ๆœ€ๆ™šๅœจ็ฌฌ 10 ๅคฉๅผบๅˆถ่ฟ›ๅ…ฅ active laneใ€‚่‹ฅ็ฌฌ 14 ๅคฉไปๆœช้—ญ็Žฏ๏ผŒๅบ”ๆŒ‰ SLA ่ง†ไธบ P0 ๅ‡็บง็›ฎๆ ‡๏ผŒๅนถๅœจไธ‹ไธ€ๆฌก triage ไธญ้‡็‚นๅค„็† / Keep the issue in the deferred queue and let the 6-hour rescore keep lifting it; it is forced into an active lane by day 10. If it is still open on day 14, treat it as a P0 escalation target under the SLA and prioritize it in the next triage pass."; + } + + if (route === "core") { + return "ไบค็ป™ core maintainer๏ผŒๅนถ็ป“ๅˆๆœฌๅœฐ็ผ–็จ‹AgentๅๅŠฉๅฎŒๆˆๅค็Žฐใ€ๆ”ถๆ•›่Œƒๅ›ดไธŽ้ชŒ่ฏ้—ญ็Žฏ / Hand the issue to a core maintainer and use a local programming agent for reproduction, scoping, and validation."; + } + + return "ๅœจ self-hosted issue-agent runner ๅฏ็”จๅŽ๏ผŒๅฐ†ๅ…ถๆ ‡่ฎฐไธบไฝŽ้ฃŽ้™ฉ agent ๅฏๆ‰ง่กŒๅ€™้€‰ / Mark as a candidate for low-risk agent execution once the self-hosted issue-agent runner is enabled."; +} + +function nudgeScore(ruleScore: number, llmScore: number) { + if (llmScore === ruleScore) { + return ruleScore; + } + + return clamp(ruleScore + Math.sign(llmScore - ruleScore), 1, 5); +} + +function unique(values: string[]) { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function roundToOneDecimal(value: number) { + return Math.round(value * 10) / 10; +} diff --git a/.github/scripts/issue-triage-types.ts b/.github/scripts/issue-triage-types.ts new file mode 100644 index 00000000..e2830a15 --- /dev/null +++ b/.github/scripts/issue-triage-types.ts @@ -0,0 +1,93 @@ +import { GitHubIssue } from "./github.ts"; + +export type IssueKind = "bug" | "feature" | "reward" | "other"; +export type IssueRoute = "needs-info" | "deferred" | "core" | "agent-ready"; +export type RiskLevel = "low" | "high"; +export type LlmMode = "off" | "shadow" | "assist"; +export type AnalysisMode = "rules-only" | "llm-shadow" | "llm-assist"; + +export interface ParsedIssueBody { + sections: Record; + missingFields: string[]; +} + +export interface TriageSnapshot { + route: IssueRoute; + riskLevel: RiskLevel; + requiresCoreMaintainer: boolean; + openDays: number; + impact: number; + urgency: number; + effort: number; + confidence: number; + priority: number; + ageBoost: number; + priorityFloor: number; + engagementBoost: number; + missingFields: string[]; + reasons: string[]; + highRiskReasons: string[]; + nextAction: string; +} + +export interface MaintainerHandoffBrief { + summary: string; + whyCore: string[]; + reproduction: string[]; + suspectedAreas: string[]; + risks: string[]; + validation: string[]; +} + +export interface LlmAssessment { + provider: string; + model: string; + mode: LlmMode; + inputHash: string; + summary: string; + summaryEn?: string; + summaryZh?: string; + impact: number; + urgency: number; + effort: number; + confidence: number; + riskFlags: string[]; + missingInfo: string[]; + suggestedQuestions: string[]; + recommendedRoute: IssueRoute; + rationale: string[]; + reused: boolean; + failed: boolean; + failureReason?: string; +} + +export interface TriageResult extends TriageSnapshot { + issue: GitHubIssue; + issueKind: IssueKind; + sections: Record; + mode: AnalysisMode; + inputHash: string; + rule: TriageSnapshot; + llm?: LlmAssessment; + handoffBrief?: MaintainerHandoffBrief; +} + +export interface TriageMachineState { + version: number; + issue: number; + inputHash?: string; + mode?: AnalysisMode; + route: IssueRoute; + priority: number; + requiresCoreMaintainer?: boolean; + impact: number; + urgency: number; + effort: number; + confidence: number; + riskLevel: RiskLevel; + ageBoost: number; + engagementBoost: number; + missingFields: string[]; + updatedAt: string; + llm?: LlmAssessment; +} diff --git a/.github/scripts/issue-triage.ts b/.github/scripts/issue-triage.ts new file mode 100644 index 00000000..c9269ac4 --- /dev/null +++ b/.github/scripts/issue-triage.ts @@ -0,0 +1,129 @@ +import { GitHubClient } from "./github.ts"; +import { readIssueLlmConfig, shouldUseLlm } from "./issue-llm-config.ts"; +import { evaluateIssueWithLlm } from "./issue-llm-evaluator.ts"; +import { TRIAGE_MANUAL_OVERRIDE_LABEL } from "./issue-triage-config.ts"; +import { + analyzeIssue, + buildManagedLabels, + ensureManagedLabels, + findTriageComment, + parseTriageMachineState, + previewTriageMutation, + syncManagedLabels, + upsertTriageComment, +} from "./issue-triage-lib.ts"; +import { mergeRuleAndLlm } from "./issue-triage-merge.ts"; + +function readFlag(name: string) { + const index = Deno.args.indexOf(`--${name}`); + return index >= 0 ? Deno.args[index + 1] : undefined; +} + +function hasFlag(name: string) { + return Deno.args.includes(`--${name}`); +} + +const owner = readFlag("owner"); +const repo = readFlag("repo"); +const issueNumberValue = readFlag("issue-number"); +const dryRun = hasFlag("dry-run"); +const token = Deno.env.get("GH_TOKEN") ?? Deno.env.get("GITHUB_TOKEN"); + +if (!owner || !repo || !issueNumberValue || !token) { + throw new Error( + "Usage: deno run issue-triage.ts --owner --repo --issue-number with GH_TOKEN set.", + ); +} + +const issueNumber = Number.parseInt(issueNumberValue, 10); + +if (Number.isNaN(issueNumber)) { + throw new Error(`Invalid issue number: ${issueNumberValue}`); +} + +const client = new GitHubClient(token, owner, repo); +const issue = await client.getIssue(issueNumber); + +if (issue.pull_request) { + console.log(`Skipping #${issue.number} because it is a pull request conversation.`); + Deno.exit(0); +} + +if (issue.labels.some((label) => label.name === TRIAGE_MANUAL_OVERRIDE_LABEL)) { + console.log(`Skipping #${issue.number} because ${TRIAGE_MANUAL_OVERRIDE_LABEL} is set.`); + Deno.exit(0); +} + +const comments = await client.listIssueComments(issueNumber); +const ruleResult = analyzeIssue(issue, comments); +const existingComment = findTriageComment(comments); +const previousState = existingComment + ? parseTriageMachineState(existingComment.body) + : null; +const llmConfig = readIssueLlmConfig(); +let result = ruleResult; + +if (llmConfig) { + const llmDecision = shouldUseLlm(issue, ruleResult); + + if (llmDecision.use) { + const { inputHash, assessment } = await evaluateIssueWithLlm( + llmConfig, + issue, + comments, + ruleResult, + previousState, + ); + + result = mergeRuleAndLlm({ + ...ruleResult, + inputHash, + llm: assessment, + mode: assessment.mode === "assist" ? "llm-assist" : "llm-shadow", + }); + } +} + +if (dryRun) { + const preview = previewTriageMutation(result, comments); + console.log( + JSON.stringify( + { + dryRun: true, + issue: issue.number, + mode: result.mode, + route: result.route, + priority: result.priority, + effort: result.effort, + confidence: result.confidence, + riskLevel: result.riskLevel, + labels: preview.labels, + commentAction: preview.existingComment ? "update" : "create", + commentBody: preview.commentBody, + }, + null, + 2, + ), + ); + Deno.exit(0); +} + +await ensureManagedLabels(client); +await syncManagedLabels(client, issue, result); +await upsertTriageComment(client, issueNumber, result, comments); + +console.log( + JSON.stringify( + { + issue: issue.number, + route: result.route, + priority: result.priority, + effort: result.effort, + confidence: result.confidence, + riskLevel: result.riskLevel, + labels: buildManagedLabels(issue, result), + }, + null, + 2, + ), +); diff --git a/.github/scripts/share-reward.ts b/.github/scripts/share-reward.ts new file mode 100644 index 00000000..01897448 --- /dev/null +++ b/.github/scripts/share-reward.ts @@ -0,0 +1,125 @@ +import "npm:array-unique-proposal"; + +import { components } from "npm:@octokit/openapi-types"; +import { $, argv, YAML } from "npm:zx"; + +import { Reward } from "./type.ts"; + +$.verbose = true; + +const [ + repositoryOwner, + repositoryName, + issueNumber, + payer, // GitHub username of the payer (provided by workflow, defaults to issue creator) + currency, + reward, +] = argv._; + +interface PRMeta { + author: components["schemas"]["simple-user"]; + assignees: components["schemas"]["simple-user"][]; +} + +const graphqlQuery = ` + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + closedByPullRequestsReferences(first: 10) { + nodes { + url + merged + mergeCommit { + oid + } + } + } + } + } + } +`; +const PR_DATA = await $`gh api graphql \ + -f query=${graphqlQuery} \ + -f owner=${repositoryOwner} \ + -f name=${repositoryName} \ + -F number=${issueNumber} \ + --jq '.data.repository.issue.closedByPullRequestsReferences.nodes[] | select(.merged == true) | {url: .url, mergeCommitSha: .mergeCommit.oid}' | head -n 1`; + +const prData = PR_DATA.text().trim(); + +if (!prData) + throw new ReferenceError("No merged PR is found for the given issue number."); + +const { url: PR_URL, mergeCommitSha } = JSON.parse(prData); + +if (!PR_URL || !mergeCommitSha) + throw new Error("Missing required fields in PR data"); + +console.table({ PR_URL, mergeCommitSha }); + +const { author, assignees }: PRMeta = await ( + await $`gh pr view ${PR_URL} --json author,assignees` +).json(); + +function isBotUser(login: string) { + const lowerLogin = login.toLowerCase(); + return ( + lowerLogin.includes("copilot") || + lowerLogin.includes("[bot]") || + lowerLogin === "github-actions[bot]" || + lowerLogin.endsWith("[bot]") + ); +} + +// Filter out Bot users from the list +const allUsers = [ + author.login, + ...assignees.map(({ login }) => login), +].uniqueBy(); + +const users = allUsers.filter((login) => !isBotUser(login)); + +console.log(`All users: ${allUsers.join(", ")}`); +console.log(`Filtered users (excluding bots): ${users.join(", ")}`); + +if (!users[0]) + throw new ReferenceError( + "No real users found (all users are bots). Skipping reward distribution.", + ); + +const rewardNumber = parseFloat(reward); + +if (isNaN(rewardNumber) || rewardNumber <= 0) + throw new RangeError( + `Reward amount is not a valid number, can not proceed with reward distribution. Received reward value: ${reward}`, + ); + +const averageReward = (rewardNumber / users.length).toFixed(2); + +const list: Reward[] = users.map((login) => ({ + issue: `#${issueNumber}`, + payer: `@${payer}`, + payee: `@${login}`, + currency, + reward: parseFloat(averageReward), +})); +const listText = YAML.stringify(list); + +console.log(listText); + +await $`git config user.name "github-actions[bot]"`; +await $`git config user.email "github-actions[bot]@users.noreply.github.com"`; + +await $`git tag -a "reward-${issueNumber}" ${mergeCommitSha} -m ${listText}`; +await $`git push origin --tags --no-verify`; + +await $`git config unset user.name`; +await $`git config unset user.email`; + +const commentBody = `## Reward data + +\`\`\`yml +${listText} +\`\`\` +`; +await $`gh issue comment ${issueNumber} --body ${commentBody}`; diff --git a/.github/scripts/type.ts b/.github/scripts/type.ts new file mode 100644 index 00000000..e61d2f03 --- /dev/null +++ b/.github/scripts/type.ts @@ -0,0 +1,7 @@ +export interface Reward { + issue: string; + payer: string; + payee: string; + currency: string; + reward: number; +} diff --git a/.github/workflows/claim-issue-reward.yml b/.github/workflows/claim-issue-reward.yml new file mode 100644 index 00000000..5765d9d9 --- /dev/null +++ b/.github/workflows/claim-issue-reward.yml @@ -0,0 +1,46 @@ +name: Claim Issue Reward +on: + issues: + types: + - closed + +concurrency: + group: claim-issue-reward-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + claim-issue-reward: + runs-on: ubuntu-latest + if: contains(github.event.issue.labels.*.name, 'reward') + permissions: + contents: write + issues: write + pull-requests: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: Get Issue details + id: parse_issue + uses: stefanbuck/github-issue-parser@10dcc54158ba4c137713d9d69d70a2da63b6bda3 # v3.2.3 + with: + template-path: ".github/ISSUE_TEMPLATE/reward-task.yml" + + - name: Calculate & Save Reward + env: + GH_TOKEN: ${{ github.token }} + run: | + deno --allow-run --allow-sys --allow-env --allow-read --allow-net=api.github.com \ + .github/scripts/share-reward.ts \ + "${{ github.repository_owner }}" \ + "${{ github.event.repository.name }}" \ + "${{ github.event.issue.number }}" \ + "${{ steps.parse_issue.outputs.issueparser_payer || github.event.issue.user.login }}" \ + "${{ steps.parse_issue.outputs.issueparser_currency }}" \ + "${{ steps.parse_issue.outputs.issueparser_amount }}" diff --git a/.github/workflows/issue-backlog-rescore.yml b/.github/workflows/issue-backlog-rescore.yml new file mode 100644 index 00000000..99f11939 --- /dev/null +++ b/.github/workflows/issue-backlog-rescore.yml @@ -0,0 +1,51 @@ +name: Issue Backlog Rescore + +on: + schedule: + - cron: "0 */6 * * *" + workflow_dispatch: + inputs: + limit: + description: Maximum number of deferred issues to rescore + required: false + default: "0" + +concurrency: + group: issue-backlog-rescore + cancel-in-progress: false + +permissions: + contents: read + issues: write + +jobs: + rescore: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: Rescore deferred issues + env: + GH_TOKEN: ${{ github.token }} + ISSUE_TRIAGE_LLM_MODE: ${{ vars.ISSUE_TRIAGE_LLM_MODE }} + ISSUE_TRIAGE_LLM_BASE_URL: ${{ vars.ISSUE_TRIAGE_LLM_BASE_URL }} + ISSUE_TRIAGE_LLM_MODEL: ${{ vars.ISSUE_TRIAGE_LLM_MODEL }} + ISSUE_TRIAGE_LLM_TIMEOUT_MS: ${{ vars.ISSUE_TRIAGE_LLM_TIMEOUT_MS }} + ISSUE_TRIAGE_LLM_MAX_ATTEMPTS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_ATTEMPTS }} + ISSUE_TRIAGE_LLM_RETRY_BACKOFF_MS: ${{ vars.ISSUE_TRIAGE_LLM_RETRY_BACKOFF_MS }} + ISSUE_TRIAGE_LLM_TEMPERATURE: ${{ vars.ISSUE_TRIAGE_LLM_TEMPERATURE }} + ISSUE_TRIAGE_LLM_MAX_COMMENTS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_COMMENTS }} + ISSUE_TRIAGE_LLM_MAX_COMMENT_CHARS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_COMMENT_CHARS }} + ISSUE_TRIAGE_LLM_MAX_BODY_CHARS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_BODY_CHARS }} + ISSUE_TRIAGE_LLM_API_KEY: ${{ secrets.ISSUE_TRIAGE_LLM_API_KEY }} + run: | + deno run --allow-env --allow-net \ + .github/scripts/issue-backlog-rescore.ts \ + --owner "${{ github.repository_owner }}" \ + --repo "${{ github.event.repository.name }}" \ + --limit "${{ inputs.limit || '0' }}" diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 00000000..c60d5d9e --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,62 @@ +name: Issue Triage + +on: + issues: + types: + - opened + - edited + - reopened + issue_comment: + types: + - created + workflow_dispatch: + inputs: + issue_number: + description: Issue number to re-triage manually + required: true + +concurrency: + group: issue-triage-${{ github.event.issue.number || inputs.issue_number }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + +jobs: + triage: + if: | + github.event_name != 'issue_comment' || + ( + github.event.issue.pull_request == null && + contains(github.event.comment.body, '/retriage') + ) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: Run triage + env: + GH_TOKEN: ${{ github.token }} + ISSUE_TRIAGE_LLM_MODE: ${{ vars.ISSUE_TRIAGE_LLM_MODE }} + ISSUE_TRIAGE_LLM_BASE_URL: ${{ vars.ISSUE_TRIAGE_LLM_BASE_URL }} + ISSUE_TRIAGE_LLM_MODEL: ${{ vars.ISSUE_TRIAGE_LLM_MODEL }} + ISSUE_TRIAGE_LLM_TIMEOUT_MS: ${{ vars.ISSUE_TRIAGE_LLM_TIMEOUT_MS }} + ISSUE_TRIAGE_LLM_MAX_ATTEMPTS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_ATTEMPTS }} + ISSUE_TRIAGE_LLM_RETRY_BACKOFF_MS: ${{ vars.ISSUE_TRIAGE_LLM_RETRY_BACKOFF_MS }} + ISSUE_TRIAGE_LLM_TEMPERATURE: ${{ vars.ISSUE_TRIAGE_LLM_TEMPERATURE }} + ISSUE_TRIAGE_LLM_MAX_COMMENTS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_COMMENTS }} + ISSUE_TRIAGE_LLM_MAX_COMMENT_CHARS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_COMMENT_CHARS }} + ISSUE_TRIAGE_LLM_MAX_BODY_CHARS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_BODY_CHARS }} + ISSUE_TRIAGE_LLM_API_KEY: ${{ secrets.ISSUE_TRIAGE_LLM_API_KEY }} + run: | + deno run --allow-env --allow-net \ + .github/scripts/issue-triage.ts \ + --owner "${{ github.repository_owner }}" \ + --repo "${{ github.event.repository.name }}" \ + --issue-number "${{ github.event.issue.number || inputs.issue_number }}" diff --git a/.github/workflows/statistic-member-reward.yml b/.github/workflows/statistic-member-reward.yml new file mode 100644 index 00000000..09e5b88f --- /dev/null +++ b/.github/workflows/statistic-member-reward.yml @@ -0,0 +1,43 @@ +name: Statistic Member Reward +on: + schedule: + - cron: "0 0 1 * *" # Run at 00:00 on the first day of every month + +jobs: + statistic-member-reward: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Check for new commits since last statistic + run: | + last_tag=$(git describe --tags --abbrev=0 --match "statistic-*" || echo "") + + if [ -z "$last_tag" ]; then + echo "No previous statistic tags found." + echo "NEW_COMMITS=true" >> $GITHUB_ENV + else + new_commits=$(git log $last_tag..HEAD --oneline) + if [ -z "$new_commits" ]; then + echo "No new commits since last statistic tag." + echo "NEW_COMMITS=false" >> $GITHUB_ENV + else + echo "New commits found." + echo "NEW_COMMITS=true" >> $GITHUB_ENV + fi + fi + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + if: env.NEW_COMMITS == 'true' + with: + deno-version: v2.x + + - name: Statistic rewards + if: env.NEW_COMMITS == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: deno --allow-run --allow-sys --allow-env --allow-read --allow-net=api.github.com .github/scripts/count-reward.ts diff --git a/LICENSE b/LICENSE index 0c5fba18..a5da3893 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 iFlytek Co., Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index e96ef1e5..cf803028 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ --- +
+ SkillHub Demo +
+ SkillHub is a self-hosted platform that gives teams a private, governed place to share agent skills. Publish a skill package, push it to a namespace, and let others find it through search or diff --git a/README_zh.md b/README_zh.md index 86df8120..edb58fc8 100644 --- a/README_zh.md +++ b/README_zh.md @@ -17,6 +17,10 @@ --- +
+ SkillHub Demo +
+ SkillHub ๆ˜ฏไธ€ไธช่‡ชๆ‰˜็ฎกๅนณๅฐ๏ผŒไธบๅ›ข้˜Ÿๆไพ›็งๆœ‰็š„ใ€ๅ—ๆฒป็†็š„ๆ™บ่ƒฝไฝ“ๆŠ€่ƒฝๅ…ฑไบซ็ฉบ้—ดใ€‚ๅ‘ๅธƒๆŠ€่ƒฝๅŒ…๏ผŒๆŽจ้€ๅˆฐๅ‘ฝๅ็ฉบ้—ด๏ผŒ่ฎฉๅ…ถไป–ไบบ้€š่ฟ‡ๆœ็ดขๅ‘็Žฐๆˆ–้€š่ฟ‡ CLI ๅฎ‰่ฃ…ใ€‚ไธ“ไธบ้˜ฒ็ซๅข™ๅŽ็š„ๆœฌๅœฐ้ƒจ็ฝฒ่€Œๆž„ๅปบ๏ผŒๆไพ›ไธŽๅ…ฌๅ…ฑๆณจๅ†Œไธญๅฟƒ็›ธๅŒ็š„็ฒพ่‡ดไฝ“้ชŒใ€‚ ## ๆ–‡ๆกฃ diff --git a/docs/2026-04-08-issue-automation-design.md b/docs/2026-04-08-issue-automation-design.md new file mode 100644 index 00000000..420e78ab --- /dev/null +++ b/docs/2026-04-08-issue-automation-design.md @@ -0,0 +1,323 @@ +# Issue ่‡ชๅŠจๅˆ†่ฏŠ MVP ่ฎพ่ฎก + +## ็›ฎๆ ‡ + +้€š่ฟ‡่‡ชๅŠจๅฐ† GitHub issue ๅˆ†่ฏŠๅˆฐไธ‰ไธช้˜Ÿๅˆ—ไธญ๏ผŒ้™ไฝŽ็ปดๆŠค่€…่ดŸๆ‹…๏ผš + +- `triage/deferred`๏ผšไฝŽไผ˜ๅ…ˆ็บง issue๏ผŒไผš้š็€ๆ—ถ้—ดๆŽจ็งป้€ๆญฅไธŠๆตฎ +- `triage/core`๏ผš้ซ˜ไผ˜ๅ…ˆ็บงๆˆ–้ซ˜้ฃŽ้™ฉ issue๏ผŒ้œ€่ฆ core maintainer ๆŽฅๆ‰‹ +- `triage/agent-ready`๏ผš้ซ˜ไผ˜ๅ…ˆ็บงใ€ไฝŽ้ฃŽ้™ฉ issue๏ผŒ้€‚ๅˆไฝœไธบๅŽ็ปญ agent ๆ‰ง่กŒๅ€™้€‰ + +ๆœฌ MVP ็‰ˆๆœฌ่ฟ˜ไธไผš่‡ชๅŠจไฟฎๅค issueใ€‚ๅฎƒ่š็„ฆๅœจ่ฏ„ๅˆ†ใ€่ทฏ็”ฑใ€ๆ‰“ๆ ‡็ญพ๏ผŒไปฅๅŠ่ฎฉ +backlog ๆŒ็ปญๆตๅŠจใ€‚ + +ๅฝ“ๅ‰็‰ˆๆœฌๆ”ฏๆŒไธค็งๆ‰ง่กŒๆจกๅผ๏ผš + +- ไป…่ง„ๅˆ™ๅˆ†่ฏŠ +- ่ง„ๅˆ™ + ๅ…ผๅฎน OpenAI ็š„ LLM ่พ…ๅŠฉ + +## ไธบไป€ไนˆ่ฟ™ๆ ทๆ‹†ๅˆ† + +ๆœ€ๅˆ็š„ๆ–นๆกˆๆŠŠไผ˜ๅ…ˆ็บงๅ’Œๆ‰ง่กŒ้šพๅบฆๆททๅœจๅŒไธ€ไธชๅ†ณ็ญ–้‡Œใ€‚ๅฎž่ทตไธŠ๏ผŒๅฆ‚ๆžœๆŠŠๅฎƒไปฌๆ‹†ๅผ€๏ผŒ +็ณป็ปŸไผšๆ›ดๅฎนๆ˜“่ฐƒๅ‚๏ผš + +- `Priority`๏ผš่ฟ™ไธช issue ็Žฐๅœจๆ˜ฏๅฆๅ€ผๅพ—ๆŠ•ๅ…ฅๆ—ถ้—ด๏ผŸ +- `Route`๏ผšไธ€ๆ—ฆๅ€ผๅพ—ๅค„็†๏ผŒๅบ”่ฏฅ็”ฑ่ฐๆฅๆŽฅๆ‰‹๏ผŸ + +่ฟ™ๆ ทไธ€ๆฅ๏ผŒ้ซ˜ไปทๅ€ผไฝ†้ซ˜้šพๅบฆ็š„ issue ไป็„ถๅฏไปฅไฟๆŒ้ซ˜ไผ˜ๅ…ˆ็บง๏ผŒๅŒๆ—ถ็ปง็ปญ่ทฏ็”ฑๅˆฐ +`triage/core`ใ€‚ + +## ่พ“ๅ…ฅ + +่‡ชๅŠจๅŒ–ไผš่ฏปๅ– issue ็š„ๅฎžๆ—ถๆ ‡้ข˜ใ€ๆญฃๆ–‡ใ€ๆ ‡็ญพใ€่ฏ„่ฎบๅ’Œๆ—ถ้—ดๆˆณใ€‚ + +็ป“ๆž„ๅŒ–็š„ issue ่กจๅ•ๅญ—ๆฎตๆฅ่‡ช๏ผš + +- [bug_report.yml](../.github/ISSUE_TEMPLATE/bug_report.yml) +- [feature_request.yml](../.github/ISSUE_TEMPLATE/feature_request.yml) +- [reward-task.yml](../.github/ISSUE_TEMPLATE/reward-task.yml) + +## ่ฏ„ๅˆ†ๆจกๅž‹ + +ๆฏไธช issue ไผšๆฒฟๅ››ไธช็ปดๅบฆ่ฏ„ๅˆ†๏ผš + +- `impact`๏ผˆ1-5๏ผ‰๏ผšๅฏน็”จๆˆทๅ’Œๅทฅไฝœๆต็š„ๅฝฑๅ“ +- `urgency`๏ผˆ1-5๏ผ‰๏ผšๅ‘ๅธƒๆ—ถ้—ดๅŽ‹ๅŠ›ใ€ๅŠŸ่ƒฝๆŸๅๆƒ…ๅ†ตๆˆ–้‡ๅค่ฎจ่ฎบ็จ‹ๅบฆ +- `effort`๏ผˆ1-5๏ผ‰๏ผš้ข„ไผฐๆ”นๅŠจ่ง„ๆจกๅ’Œๅไฝœๆˆๆœฌ +- `confidence`๏ผˆ1-5๏ผ‰๏ผšissue ๆ่ฟฐ็š„ๅฎŒๆ•ดๆ€งๅ’Œๅฏๆ‰ง่กŒ็จ‹ๅบฆ + +ไผ˜ๅ…ˆ็บง่ฎก็ฎ—ๅ…ฌๅผๅฆ‚ไธ‹๏ผš + +```text +priority = impact * 0.45 + urgency * 0.35 + age_boost + engagement_boost +``` + +ๅ…ถไธญ๏ผš + +- `age_boost`๏ผšๅŸบไบŽ SLA ็š„ๅ‡็บงๆœบๅˆถ + - ็ฌฌ 7-9 ๅคฉ๏ผš้ข„็ƒญ้˜ถๆฎต๏ผŒๆœ€ไฝŽๆๅ‡ๅˆฐ `priority/p2` + - ็ฌฌ 10-13 ๅคฉ๏ผšๅผบๅˆถ็งปๅ‡บ `triage/deferred`๏ผŒๆœ€ไฝŽๆๅ‡ๅˆฐ `priority/p1` + - ็ฌฌ 14 ๅคฉๅŠไปฅๅŽ๏ผšๅœจไธ‹ไธ€ๆฌก triage/rescore ๆ—ถ๏ผŒๅฐ†่ฏฅ issue ่ง†ไธบๅทฒ่ฟๅ SLA๏ผŒ + ๅนถ่‡ณๅฐ‘ๆๅ‡ๅˆฐ `priority/p0` +- `engagement_boost`๏ผš็”ฑ่ฏ„่ฎบๅŽ‹ๅŠ›ๅ’Œๅฅ–ๅŠฑ้‡‘้ขๅ…ฑๅŒๅ†ณๅฎš๏ผŒไธŠ้™ไธบ +1.0 + +ๅœจ MVP ไธญ๏ผŒ`effort` ไธไผš็›ดๆŽฅ้™ไฝŽไผ˜ๅ…ˆ็บง๏ผŒๅฎƒๅชๅฝฑๅ“่ทฏ็”ฑใ€‚ + +## LLM ่พ…ๅŠฉๅˆ†่ฏŠ + +้…็ฝฎๅŽ๏ผŒๅทฅไฝœๆตๅฏไปฅ่ฐƒ็”จๅ…ผๅฎน OpenAI ็š„ chat completions APIใ€‚ + +LLM ไธไผšๆ›ฟไปฃ่ง„ๅˆ™ๅผ•ๆ“Žใ€‚ๅฎƒๅช็”จไบŽ่พ…ๅŠฉ๏ผš + +- ็”Ÿๆˆ issue ๆ‘˜่ฆ +- ๅฏน่ฝฏๆ€งๅˆ†ๆ•ฐๅšๅพฎ่ฐƒ +- ็”Ÿๆˆ `needs-info` ็š„่ฟฝ้—ฎ้—ฎ้ข˜ +- ไธบ็ปดๆŠค่€…ๆไพ›ๆ›ดๅฅฝ็š„ๅˆคๆ–ญไพๆฎ +- ไธบ `triage/core` ็”Ÿๆˆ maintainer ไบคๆŽฅๆ‘˜่ฆ + +็กฌๆ€ง้—จๆง›ไป็„ถ็”ฑ่ง„ๅˆ™ๆŽงๅˆถ๏ผš + +- ็ผบๅคฑๅฟ…ๅกซไฟกๆฏ +- authใ€schemaใ€migrationใ€SDK ๆˆ–ๅ…ฌๅ…ฑๅฅ‘็บฆๅ˜ๆ›ด็ญ‰้ซ˜้ฃŽ้™ฉๅŒบๅŸŸ +- ๆœ€็ปˆๆ˜ฏๅฆๅฏไปฅๆๅ‡ๅˆฐ `triage/agent-ready` + +issue ๆญฃๆ–‡ๅ’Œ่ฏ„่ฎบ้ƒฝ่ง†ไธบไธๅฏไฟก่พ“ๅ…ฅใ€‚ๅทฅไฝœๆตไผš๏ผš + +- ๅœจๅ‘้€็ป™ๆจกๅž‹ๅ‰ๆˆชๆ–ญ่ฟ‡้•ฟ็š„ๆญฃๆ–‡ๅ’Œ่ฏ„่ฎบ +- ๆ˜Ž็กฎๅ‘Š่ฏ‰ๆจกๅž‹๏ผŒissue ๆ–‡ๆœฌๆ˜ฏๆ•ฐๆฎ่€Œไธๆ˜ฏๆŒ‡ไปค +- ไฝฟ็”จไธฅๆ ผ็š„ JSON ๅ่ฎฎๆ ก้ชŒๆจกๅž‹่พ“ๅ‡บ +- ๅฆ‚ๆžœ provider ่ฐƒ็”จๅคฑ่ดฅๆˆ– JSON ๆ ก้ชŒๅคฑ่ดฅ๏ผŒๅˆ™ๅ›ž้€€ๅˆฐไป…่ง„ๅˆ™ๆจกๅผ + +### ๆจกๅผ + +- `off`๏ผšไป…่ง„ๅˆ™ +- `shadow`๏ผš่ฐƒ็”จ LLM ๅนถๅฑ•็คบๅ…ถๅปบ่ฎฎ๏ผŒไฝ†ๆœ€็ปˆไปๆฒฟ็”จไป…่ง„ๅˆ™็š„่ทฏ็”ฑๅ’Œๆ ‡็ญพ +- `assist`๏ผšๅ…่ฎธ LLM ๅฏน่ฝฏๆ€งๅˆ†ๆ•ฐๅšๆœ€ๅคš `+/-1` ็š„ๅพฎ่ฐƒ๏ผŒ็„ถๅŽ้‡ๆ–ฐๅบ”็”จ็กฌๆ€ง้—จๆง› + +### ไฝ•ๆ—ถไฝฟ็”จ LLM + +ๅทฅไฝœๆตๅชไผšๅœจ issue ็œ‹่ตทๆฅๅญ˜ๅœจๆญงไน‰ๆˆ–ไปทๅ€ผ่พƒ้ซ˜ๆ—ถ่ฐƒ็”จ LLM๏ผŒไพ‹ๅฆ‚๏ผš + +- `triage/needs-info` +- `triage/core` +- ้ ่ฟ‘่ทฏ็”ฑ้˜ˆๅ€ผ็š„ issue +- ไฝŽ็ฝฎไฟกๅบฆๆกˆไพ‹ +- ๆญฃๆ–‡ๅพˆ้•ฟๆˆ–่ฎจ่ฎบๅพˆๅคš็š„ issue +- ้œ€่ฆๆ›ดๅคšๅˆคๆ–ญ็š„ feature ๆˆ– reward issue + +## ่ทฏ็”ฑ่ง„ๅˆ™ + +1. `triage/needs-info` + ๅฝ“็ผบๅฐ‘ๅฟ…ๅกซๅญ—ๆฎตๆˆ– `confidence <= 2` ๆ—ถ่งฆๅ‘ใ€‚ + +2. `triage/deferred` + ๅฝ“ `priority < 3.6`ใ€issue ไธๅ—ไฟกๆฏ็ผบๅคฑ้˜ปๅกžใ€ไธ” issue ๅนด้พ„ไปไฝŽไบŽ SLA + ๅ‡็บงๅบ•็บฟๆ—ถ่งฆๅ‘ใ€‚ + +3. `triage/core` + ๅฝ“ `priority >= 3.6` ไธ”ๆปก่ถณไปฅไธ‹ไปปไธ€ๆกไปถๆ—ถ่งฆๅ‘๏ผš + - issue ้˜ปๅกžไบ† OpenClaw/ClawHub ๆ ธๅฟƒๅทฅไฝœๆต๏ผŒไพ‹ๅฆ‚ installใ€publishใ€ + updateใ€sync ๆˆ–ๅŸบไบŽ namespace ็š„ๅ‘ๅธƒ + - `effort >= 4` + - `confidence <= 3` + - ๅญ˜ๅœจ้ซ˜้ฃŽ้™ฉๅ…ณ้”ฎ่ฏๆˆ–ไผšๅฝฑๅ“ๅฅ‘็บฆ็š„ๅญ—ๆฎต + +4. `triage/agent-ready` + ๅฝ“ `priority >= 3.6`ใ€`effort <= 3`ใ€`confidence >= 4`๏ผŒไธ”ไธๅญ˜ๅœจ้ซ˜้ฃŽ้™ฉ + ไฟกๅทๆ—ถ่งฆๅ‘ใ€‚ + +ๅœจ `assist` ๆจกๅผไธ‹๏ผŒLLM ๅปบ่ฎฎๅฏไปฅๅฏน `impact`ใ€`urgency`ใ€`effort` ๅ’Œ +`confidence` ๅ„่‡ชๆœ€ๅคš่ฐƒๆ•ด 1 ๅˆ†ใ€‚่ง„ๅˆ™ๅผ•ๆ“Ž้šๅŽไผš้‡ๆ–ฐ่ฎก็ฎ—ไผ˜ๅ…ˆ็บงๅ’Œ่ทฏ็”ฑใ€‚ + +ๆถ‰ๅŠ OpenClaw/ClawHub ๆ ธๅฟƒๅทฅไฝœๆต็š„ issue ๆ˜ฏ่ฟ›ๅ…ฅ `triage/core` ็š„็กฌๆ€ง้—จๆง›๏ผ› +LLM ่พ…ๅŠฉไธไผšๆ”พๅฎฝ่ฟ™ไธ€่ง„ๅˆ™ใ€‚ + +## ๅ—็ฎกๆ ‡็ญพ + +่‡ชๅŠจๅŒ–่ดŸ่ดฃ็ฎก็†ไปฅไธ‹ๆ ‡็ญพๅ‰็ผ€๏ผš + +- `triage/` +- `priority/` +- `effort/` +- `risk/` + +ๅฝ“ๅ‰ไฝฟ็”จ็š„ๅ…ทไฝ“ๆ ‡็ญพๆœ‰๏ผš + +- `triage/needs-info` +- `triage/deferred` +- `triage/core` +- `triage/agent-ready` +- `priority/p0` +- `priority/p1` +- `priority/p2` +- `priority/p3` +- `effort/s` +- `effort/m` +- `effort/l` +- `risk/high` + +ๅ…ถไฝ™ๆ‰€ๆœ‰ๆ ‡็ญพ้ƒฝไฟๆŒไธๅ˜ใ€‚ + +ๅฆๅค–๏ผŒ่‡ชๅŠจๅŒ–่ฟ˜่ฏ†ๅˆซไธ€ไธชไธ็”ฑๅ…ถ็ฎก็†็š„ไบบๅทฅๆ“ไฝœๆ ‡็ญพ๏ผš + +- `triage-manual`๏ผšๅ†ป็ป“่ฏฅ issue ็š„่‡ชๅŠจๅˆ†่ฏŠๆ›ดๆ–ฐ + +## ๅทฅไฝœๆต + +### 1. Issue ๅˆ†่ฏŠ + +ๆ–‡ไปถ๏ผš[issue-triage.yml](../.github/workflows/issue-triage.yml) + +่งฆๅ‘ๆกไปถ๏ผš + +- `issues.opened` +- `issues.edited` +- `issues.reopened` +- ๅฝ“่ฏ„่ฎบๅŒ…ๅซ `/retriage` ๆ—ถ่งฆๅ‘ `issue_comment.created` +- `workflow_dispatch` + +ๆ‰ง่กŒๅŠจไฝœ๏ผš + +- ๆ‹‰ๅ– issue ๅ’Œ่ฏ„่ฎบ +- ่ฎก็ฎ—ๅˆ†ๆ•ฐๅ’Œ่ทฏ็”ฑ +- ๆ›ดๆ–ฐๆˆ–ๅˆ›ๅปบๅ—็ฎกๆ ‡็ญพ +- ๆ›ดๆ–ฐๆˆ–ๅˆ›ๅปบไธ€ๆกๅˆ†่ฏŠ่ฏ„่ฎบ๏ผŒๅ…ถไธญๅŒๆ—ถๅŒ…ๅซไบบ็ฑปๅฏ่ฏป็š„ๅˆคๆ–ญ็†็”ฑๅ’Œ้š่—็š„ๆœบๅ™จ็Šถๆ€ +- ๅฏ้€‰่ฐƒ็”จๅ…ผๅฎน OpenAI ็š„ provider๏ผŒๅนถๅˆๅนถ็ป“ๆžœ + +### 2. Deferred Backlog ้‡ๆ–ฐ่ฏ„ๅˆ† + +ๆ–‡ไปถ๏ผš +[issue-backlog-rescore.yml](../.github/workflows/issue-backlog-rescore.yml) + +่งฆๅ‘ๆกไปถ๏ผš + +- ๆฏ 6 ๅฐๆ—ถไธ€ๆฌก +- `workflow_dispatch` + +ๆ‰ง่กŒๅŠจไฝœ๏ผš + +- ๅˆ—ๅ‡บๆ‰€ๆœ‰ๅธฆๆœ‰ `triage/deferred` ๆ ‡็ญพ็š„ open issue +- ็ป“ๅˆๅนด้พ„ๅ’Œๅ‚ไธŽๅบฆๅŠ ๆˆ้‡ๆ–ฐ่ฎก็ฎ—ไผ˜ๅ…ˆ็บง +- ๅ†ณๅฎšๅฐ†ๆฏไธช issue ๅ‡็บง่ฟ˜ๆ˜ฏไฟ็•™ +- ๅŽŸๅœฐๆ›ดๆ–ฐๅˆ†่ฏŠ่ฏ„่ฎบ +- ๅฝ“ issue ๅ†…ๅฎนๆœชๅ˜ๅŒ–ๆ—ถๅค็”จ็ผ“ๅญ˜็š„ LLM ็ป“ๆžœ + +่ฏ•่ฟ่กŒ่ฏดๆ˜Ž๏ผš + +- ๅฝ“ๅ‰ๅฎšๆ—ถ rescore ๅชๆ‰ซๆ `triage/deferred` ้˜Ÿๅˆ—ไธญ็š„ issue +- ่ฟ™ๅฏไปฅไฟ่ฏไฝŽไผ˜ๅ…ˆ็บง backlog ไธไผšๅœจ `deferred` ไธญ้—ฒ็ฝฎ่ถ…่ฟ‡็ฌฌ 10 ๅคฉ +- ไธ€ๆ—ฆๆŸไธช issue ๅทฒ็ปไปŽ `deferred` ไธญๅ‡็บงๅ‡บๅŽป๏ผŒไน‹ๅŽ็ฌฌ 14 ๅคฉ็š„่ฟ›ไธ€ๆญฅๅ‡็บง + ไพ่ต–ๆ–ฐ็š„ triage ไบ‹ไปถๆˆ–ๆ‰‹ๅŠจ `/retriage` +- ๅœจ่ฏ•่ฟ่กŒ้˜ถๆฎต๏ผŒ14 ๅคฉ่ง„ๅˆ™ๅบ”่ขซ่ง†ไธบ่ฟ่ฅๅฑ‚้ข็š„ SLA ็›ฎๆ ‡๏ผŒ่€Œไธๆ˜ฏไป“ๅบ“่Œƒๅ›ดๅ†…็š„ + ็กฌๆ€ง่ฎกๆ—ถๅ™จ + +## ่„šๆœฌ + +ๆ–ฐ็š„ GitHub ่‡ชๅŠจๅŒ–่„šๆœฌไฝไบŽ +[`.github/scripts`](/Users/wowo/workspace/skillhub/.github/scripts)๏ผš + +- [github.ts](/Users/wowo/workspace/skillhub/.github/scripts/github.ts)๏ผš็ฒพ็ฎ€็‰ˆ + GitHub REST ๅฎขๆˆท็ซฏ +- [issue-triage-config.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-triage-config.ts)๏ผš + ๆ ‡็ญพใ€้˜ˆๅ€ผๅ’Œๅ…ณ้”ฎ่ฏ่ง„ๅˆ™ +- [issue-llm-config.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-llm-config.ts)๏ผš + LLM ๆจกๅผใ€็Žฏๅขƒๅ˜้‡ๅ’Œ่ฐƒ็”จๅฏๅ‘ๅผ +- [issue-llm-provider.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-llm-provider.ts)๏ผš + ๅ…ผๅฎน OpenAI ็š„ chat completions ๅฎขๆˆท็ซฏ +- [issue-llm-evaluator.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-llm-evaluator.ts)๏ผš + prompt ๆž„้€ ใ€JSON ๆ ก้ชŒๅ’Œ็ผ“ๅญ˜ key ็”Ÿๆˆ +- [issue-triage-lib.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-triage-lib.ts)๏ผš + ่งฃๆžใ€่ฏ„ๅˆ†ใ€่ทฏ็”ฑๅ’Œ่ฏ„่ฎบๆธฒๆŸ“ +- [issue-triage-merge.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-triage-merge.ts)๏ผš + ๆœ‰็•Œๅˆๅนถๅ’Œ็กฌๆ€ง้—จๆง›้‡ๅบ”็”จ +- [issue-triage.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-triage.ts)๏ผš + ๅ• issue ๅ…ฅๅฃ +- [issue-backlog-rescore.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-backlog-rescore.ts)๏ผš + deferred ้˜Ÿๅˆ—้‡ๆ–ฐ่ฏ„ๅˆ†ๅ…ฅๅฃ + +## ้…็ฝฎ + +่ฎพ็ฝฎไปฅไธ‹ GitHub ไป“ๅบ“ๅ˜้‡ๅ’Œ secret๏ผŒๅณๅฏๅฏ็”จ LLM ่พ…ๅŠฉๅˆ†่ฏŠ๏ผš + +ไป“ๅบ“ๅ˜้‡๏ผš + +- `ISSUE_TRIAGE_LLM_MODE` +- `ISSUE_TRIAGE_LLM_BASE_URL` +- `ISSUE_TRIAGE_LLM_MODEL` +- `ISSUE_TRIAGE_LLM_TIMEOUT_MS` ๅฏ้€‰ +- `ISSUE_TRIAGE_LLM_TEMPERATURE` ๅฏ้€‰ +- `ISSUE_TRIAGE_LLM_MAX_COMMENTS` ๅฏ้€‰ +- `ISSUE_TRIAGE_LLM_MAX_COMMENT_CHARS` ๅฏ้€‰ +- `ISSUE_TRIAGE_LLM_MAX_BODY_CHARS` ๅฏ้€‰ + +ไป“ๅบ“ secret๏ผš + +- `ISSUE_TRIAGE_LLM_API_KEY` + +ๅปบ่ฎฎ็š„็ฌฌไธ€่ฝฎไธŠ็บฟๆ–นๅผ๏ผš + +- `ISSUE_TRIAGE_LLM_MODE=shadow` +- ๅ…ˆ่ง‚ๅฏŸๅ‡ ๅคฉๅˆ†่ฏŠ่ฏ„่ฎบ +- ็ญ‰ LLM ๅปบ่ฎฎ็œ‹่ตทๆฅ็จณๅฎšๅŽ๏ผŒๅ†ๅˆ‡ๆขๅˆฐ `assist` + +ๅ…ผๅฎน OpenAI ็š„ๅ˜้‡็คบไพ‹๏ผš + +```text +ISSUE_TRIAGE_LLM_MODE=shadow +ISSUE_TRIAGE_LLM_BASE_URL=https://your-provider.example.com/v1 +ISSUE_TRIAGE_LLM_MODEL=gpt-4.1-mini +``` + +## ๆŽจๅ‡บ่ฎกๅˆ’ + +### Phase 1๏ผšๅฝ“ๅ‰้˜ถๆฎต + +- ๅฏ็”จ triage ๅ’Œ backlog rescore +- ่ง‚ๅฏŸๅ‡ ๅ‘จ็š„ issue ๆต้‡ๅŽๅพฎ่ฐƒ้˜ˆๅ€ผ +- ๅ…่ฎธ็ปดๆŠค่€…้€š่ฟ‡ `triage-manual` ๅ†ป็ป“็‰นๅฎš issue ็š„่‡ชๅŠจๅŒ–ๅค„็† +- ๅฆ‚ๆžœไฝฟ็”จ LLM๏ผŒไปŽ `shadow` ๆจกๅผๅผ€ๅง‹ + +### Phase 2๏ผšMaintainer ไบคๆŽฅ + +ไธบ `triage/core` issue ๅขžๅŠ  issue-brief ็”Ÿๆˆๅ™จ๏ผŒ่พ“ๅ‡บๅ†…ๅฎนๅŒ…ๆ‹ฌ๏ผš + +- ๅค็Žฐๆ็คบ +- ๅฏ่ƒฝๆถ‰ๅŠ็š„ๆจกๅ— +- ้ฃŽ้™ฉๅค‡ๆณจ +- ้ชŒ่ฏๆธ…ๅ• + +่ฟ™ไบ›่พ“ๅ‡บๅฏไปฅ็›ดๆŽฅ็”จไบŽๆœฌๅœฐ็ผ–็จ‹ agent ไผš่ฏ๏ผŒไปฅๅŠ็Žฐๆœ‰็š„ๅนถ่กŒ worktree ๆต็จ‹ใ€‚ + +ๅฝ“ๅ‰ MVP ๅทฒ็ปไผšๅœจ `triage/core` issue ็š„ๅˆ†่ฏŠ่ฏ„่ฎบไธญ็›ดๆŽฅๅตŒๅ…ฅไธ€ไธช +`Maintainer Brief` ๅŒบๅ—ใ€‚่ฏฅๆ‘˜่ฆๅŒ…ๆ‹ฌ๏ผš + +- ็ฎ€ๆด็š„ issue ๆ‘˜่ฆ +- issue ไธบไป€ไนˆ่ขซๅ‡็บงๅˆฐ core +- ๅค็Žฐ่ทฏๅพ„ๆˆ–ๆ“ไฝœ่ทฏๅพ„ๅค‡ๆณจ +- ็–‘ไผผ็›ธๅ…ณๆจกๅ—ๆˆ–ๅทฅไฝœๆต่ดŸ่ดฃไบบ +- ้ฃŽ้™ฉๆ็คบ +- ้ชŒ่ฏๆธ…ๅ• + +### Phase 3๏ผš่‡ชๆ‰˜็ฎก Issue Agent + +ๅขžๅŠ ไธ€ไธช่‡ชๆ‰˜็ฎก runner๏ผŒ็›‘ๅฌ `triage/agent-ready`๏ผŒๅนถๆ‰ง่กŒ๏ผš + +- ๅˆ›ๅปบ้š”็ฆป็š„ๅˆ†ๆ”ฏๅ’Œ worktree +- ่ฟ่กŒ่งฃๅ†ณ issue ็š„ agent +- ๆ‰ง่กŒๆœ€ๅฐ็›ธๅ…ณๆต‹่ฏ•้›† +- ๆ‰“ๅผ€ไธ€ไธช draft PR + +ๅœจ่ฟ™ไธช้˜ถๆฎต๏ผŒไปฅไธ‹ๅœบๆ™ฏไปๅบ”ไฟ็•™็กฌๆ€ง้˜ปๆ–ญ๏ผš + +- auth ๅ’Œๆƒ้™ๅ˜ๆ›ด +- ๅฎ‰ๅ…จๆ•ๆ„Ÿๅ˜ๆ›ด +- schema ๆˆ– migration ็›ธๅ…ณๅทฅไฝœ +- ๅ…ฌๅ…ฑ APIใ€SDK ๆˆ– CLI ๅฅ‘็บฆๅ˜ๆ›ด + +## ๅพ…่ฐƒไผ˜้—ฎ้ข˜ + +- ๅ‚ไธŽๅบฆๅŠ ๆˆๆ˜ฏๅฆๅช็œ‹่ฏ„่ฎบๆ•ฐๅฐฑๅคŸไบ†๏ผŒ่ฟ˜ๆ˜ฏไนŸๅบ”่ฏฅๆ‹‰ๅ– reactions +- reward issue ๆ˜ฏๅฆๅบ”ๆฏ”ๅฝ“ๅ‰ MVP ่Žทๅพ—ๆ›ดๅผบ็š„ไปทๅ€ผๅŠ ๆˆ +- `agent-ready` ๆ˜ฏๅฆๅบ”่ฆๆฑ‚ `effort <= 2`๏ผŒ่€Œไธๆ˜ฏ `<= 3` +- ๆŸไบ›ๅŒบๅŸŸ๏ผˆๅฆ‚ `scanner`๏ผ‰ๆ˜ฏๅฆๅบ”้ป˜่ฎค่ง†ไธบ้ซ˜้ฃŽ้™ฉ +- ๆŸไบ›ๅ›ข้˜Ÿๆ˜ฏๅฆๅบ”้•ฟๆœŸไฟๆŒ `shadow` ๆจกๅผ๏ผŒๅชๆŠŠ `assist` ็”จๅœจๆ›ด็ช„็š„ไป“ๅบ“ๅญ้›†ไธŠ diff --git a/scripts/runtime.sh b/scripts/runtime.sh index 76dc9c93..20c2dd58 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -22,6 +22,7 @@ SKILLHUB_SCANNER_IMAGE_VALUE="${SKILLHUB_SCANNER_IMAGE:-}" POSTGRES_IMAGE_VALUE="${POSTGRES_IMAGE:-}" REDIS_IMAGE_VALUE="${REDIS_IMAGE:-}" DISABLE_SCANNER=false +USE_ALIYUN=false while [ "$#" -gt 0 ]; do case "$1" in @@ -36,6 +37,7 @@ while [ "$#" -gt 0 ]; do exit 1 fi SKILLHUB_MIRROR_REGISTRY_VALUE="${SKILLHUB_ALIYUN_REGISTRY%/}/${SKILLHUB_ALIYUN_NAMESPACE}" + USE_ALIYUN=true shift ;; --mirror-registry) @@ -114,7 +116,13 @@ EOF esac done -SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}" +if [ "$USE_ALIYUN" = "true" ]; then + SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://imageless.oss-cn-beijing.aliyuncs.com}" + echo "Using Aliyun OSS for runtime files: $SKILLHUB_RAW_BASE" +else + SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}" + echo "Using GitHub raw for runtime files: $SKILLHUB_RAW_BASE" +fi COMPOSE_FILE="$SKILLHUB_HOME/compose.release.yml" ENV_EXAMPLE_FILE="$SKILLHUB_HOME/.env.release.example" ENV_FILE="$SKILLHUB_HOME/.env.release" diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java index dfd39d35..0bb52cbc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.dto; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; import java.time.Instant; @@ -9,6 +10,8 @@ public record MemberResponse( Long id, Long namespaceId, String userId, + String displayName, + String email, NamespaceRole role, Instant createdAt, Instant updatedAt @@ -18,6 +21,21 @@ public record MemberResponse( member.getId(), member.getNamespaceId(), member.getUserId(), + null, + null, + member.getRole(), + member.getCreatedAt(), + member.getUpdatedAt() + ); + } + + public static MemberResponse from(NamespaceMember member, UserAccount user) { + return new MemberResponse( + member.getId(), + member.getNamespaceId(), + member.getUserId(), + user != null ? user.getDisplayName() : null, + user != null ? user.getEmail() : null, member.getRole(), member.getCreatedAt(), member.getUpdatedAt() diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java index 92c1340b..3e81884b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java @@ -7,6 +7,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.MessageResponse; import com.iflytek.skillhub.dto.NamespaceLifecycleRequest; @@ -28,15 +30,18 @@ public class NamespacePortalCommandAppService { private final NamespaceRepository namespaceRepository; private final NamespaceGovernanceService namespaceGovernanceService; private final NamespaceMemberService namespaceMemberService; + private final UserAccountRepository userAccountRepository; public NamespacePortalCommandAppService(NamespaceService namespaceService, NamespaceRepository namespaceRepository, NamespaceGovernanceService namespaceGovernanceService, - NamespaceMemberService namespaceMemberService) { + NamespaceMemberService namespaceMemberService, + UserAccountRepository userAccountRepository) { this.namespaceService = namespaceService; this.namespaceRepository = namespaceRepository; this.namespaceGovernanceService = namespaceGovernanceService; this.namespaceMemberService = namespaceMemberService; + this.userAccountRepository = userAccountRepository; } @Transactional @@ -135,7 +140,8 @@ public class NamespacePortalCommandAppService { role, operatorUserId ); - return MemberResponse.from(member); + UserAccount user = userAccountRepository.findById(memberUserId).orElse(null); + return MemberResponse.from(member, user); } @Transactional @@ -157,7 +163,7 @@ public class NamespacePortalCommandAppService { request.role(), operatorUserId ); - return MemberResponse.from(member); + return MemberResponse.from(member, userAccountRepository.findById(userId).orElse(null)); } private boolean canCreateNamespace(PlatformPrincipal principal) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 1e0d9486..cff2eb59 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -9,6 +9,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.MyNamespaceResponse; import com.iflytek.skillhub.dto.NamespaceResponse; @@ -16,6 +18,8 @@ import com.iflytek.skillhub.dto.PageResponse; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -34,15 +38,18 @@ public class NamespacePortalQueryAppService { private final NamespaceService namespaceService; private final NamespaceMemberService namespaceMemberService; private final NamespaceAccessPolicy namespaceAccessPolicy; + private final UserAccountRepository userAccountRepository; public NamespacePortalQueryAppService(NamespaceRepository namespaceRepository, NamespaceService namespaceService, NamespaceMemberService namespaceMemberService, - NamespaceAccessPolicy namespaceAccessPolicy) { + NamespaceAccessPolicy namespaceAccessPolicy, + UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.namespaceService = namespaceService; this.namespaceMemberService = namespaceMemberService; this.namespaceAccessPolicy = namespaceAccessPolicy; + this.userAccountRepository = userAccountRepository; } @Transactional(readOnly = true) @@ -107,6 +114,18 @@ public class NamespacePortalQueryAppService { Namespace namespace = namespaceService.getNamespaceBySlug(slug); namespaceService.assertMember(namespace.getId(), userId); Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); - return PageResponse.from(members.map(MemberResponse::from)); + + List memberUserIds = members.getContent().stream() + .map(NamespaceMember::getUserId) + .toList(); + + Map userMap = memberUserIds.isEmpty() + ? Map.of() + : userAccountRepository.findByIdIn(memberUserIds).stream() + .collect(Collectors.toMap(UserAccount::getId, Function.identity())); + + return PageResponse.from(members.map(member -> + MemberResponse.from(member, userMap.get(member.getUserId())) + )); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java index 2070d360..24087c0d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java @@ -1,5 +1,9 @@ package com.iflytek.skillhub; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.mockito.Mockito; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -7,6 +11,13 @@ import org.springframework.context.annotation.Primary; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; @Configuration public class TestRedisConfig { @@ -27,6 +38,76 @@ public class TestRedisConfig { @Bean @Primary public StringRedisTemplate stringRedisTemplate() { - return Mockito.mock(StringRedisTemplate.class); + StringRedisTemplate template = Mockito.mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOps = Mockito.mock(ValueOperations.class); + Map values = new ConcurrentHashMap<>(); + Map expirations = new ConcurrentHashMap<>(); + + when(template.opsForValue()).thenReturn(valueOps); + + when(valueOps.get(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + evictExpired(values, expirations, key); + return values.get(key); + }); + + when(valueOps.increment(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + evictExpired(values, expirations, key); + long next = Long.parseLong(values.getOrDefault(key, "0")) + 1L; + values.put(key, Long.toString(next)); + return next; + }); + + doAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + String value = invocation.getArgument(1, String.class); + Long timeout = invocation.getArgument(2, Long.class); + TimeUnit unit = invocation.getArgument(3, TimeUnit.class); + values.put(key, value); + expirations.put(key, Instant.now().plusMillis(unit.toMillis(timeout))); + return null; + }).when(valueOps).set(anyString(), anyString(), anyLong(), any(TimeUnit.class)); + + when(template.delete(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + boolean removed = values.remove(key) != null; + expirations.remove(key); + return removed; + }); + + when(template.expire(anyString(), any())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + java.time.Duration ttl = invocation.getArgument(1, java.time.Duration.class); + if (!values.containsKey(key)) { + return false; + } + expirations.put(key, Instant.now().plus(ttl)); + return true; + }); + + when(template.getExpire(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + evictExpired(values, expirations, key); + Instant expiresAt = expirations.get(key); + if (expiresAt == null) { + return -1L; + } + long seconds = java.time.Duration.between(Instant.now(), expiresAt).getSeconds(); + return Math.max(seconds, -1L); + }); + + return template; + } + + private static void evictExpired(Map values, + Map expirations, + String key) { + Instant expiresAt = expirations.get(key); + if (expiresAt != null && expiresAt.isBefore(Instant.now())) { + values.remove(key); + expirations.remove(key); + } } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index 421fa1d8..01728e91 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -1,12 +1,17 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.service.SkillSearchAppService; +import java.math.BigDecimal; +import java.time.Instant; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -18,10 +23,12 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import java.util.Optional; import java.util.Set; -import java.math.BigDecimal; -import java.time.Instant; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; @@ -49,6 +56,9 @@ class ClawHubCompatControllerTest { @MockBean private SkillQueryService skillQueryService; + @MockBean + private CompatSkillLookupService compatSkillLookupService; + @Test void search_returns_mapped_results() throws Exception { when(skillSearchAppService.search("test", null, "relevance", 0, 20, null, null)) @@ -124,6 +134,9 @@ class ClawHubCompatControllerTest { @Test void resolve_query_with_legacy_slug_keeps_legacy_lookup_behavior() throws Exception { + when(compatSkillLookupService.findByLegacySlug("my-skill")) + .thenReturn(legacyCompatContext("global", "my-skill")); + when(compatSkillLookupService.canAccess(any(), isNull(), anyMap())).thenReturn(true); when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of())) .thenReturn(new SkillQueryService.ResolvedVersionDTO( 1L, "global", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/global/my-skill/download")); @@ -149,6 +162,9 @@ class ClawHubCompatControllerTest { @Test void download_query_with_legacy_slug_keeps_legacy_lookup_behavior() throws Exception { + when(compatSkillLookupService.findByLegacySlug("my-skill")) + .thenReturn(legacyCompatContext("global", "my-skill")); + when(compatSkillLookupService.canAccess(any(), isNull(), anyMap())).thenReturn(true); mockMvc.perform(get("/api/v1/download") .param("slug", "my-skill") .param("version", "latest")) @@ -192,4 +208,10 @@ class ClawHubCompatControllerTest { .andExpect(jsonPath("$.user.displayName").value("tester")) .andExpect(jsonPath("$.user.image").value("https://example.com/avatar.png")); } + + private CompatSkillLookupService.CompatSkillContext legacyCompatContext(String namespaceSlug, String skillSlug) { + Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, "tester"); + Skill skill = new Skill(1L, skillSlug, "tester", SkillVisibility.PUBLIC); + return new CompatSkillLookupService.CompatSkillContext(namespace, skill, Optional.empty()); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index f2cf8a50..404d6419 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -12,6 +12,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; import org.junit.jupiter.api.Test; @@ -71,6 +73,9 @@ class NamespacePortalControllerTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private UserAccountRepository userAccountRepository; + @Test void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); @@ -179,9 +184,12 @@ class NamespacePortalControllerTest { void addMember_returnsCreatedMember() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.ADMIN); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); given(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1")) .willReturn(member); + given(userAccountRepository.findById("user-2")) + .willReturn(java.util.Optional.of(user)); mockMvc.perform(post("/api/v1/namespaces/team-a/members") .with(csrf()) @@ -194,7 +202,9 @@ class NamespacePortalControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("user-2")) - .andExpect(jsonPath("$.data.role").value("ADMIN")); + .andExpect(jsonPath("$.data.role").value("ADMIN")) + .andExpect(jsonPath("$.data.displayName").value("Alice")) + .andExpect(jsonPath("$.data.email").value("alice@example.com")); } @Test @@ -215,9 +225,12 @@ class NamespacePortalControllerTest { void updateMemberRole_returnsUpdatedMember() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.OWNER); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); given(namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.OWNER, "owner-1")) .willReturn(member); + given(userAccountRepository.findById("user-2")) + .willReturn(java.util.Optional.of(user)); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/api/v1/namespaces/team-a/members/user-2/role") .with(csrf()) @@ -230,7 +243,9 @@ class NamespacePortalControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("user-2")) - .andExpect(jsonPath("$.data.role").value("OWNER")); + .andExpect(jsonPath("$.data.role").value("OWNER")) + .andExpect(jsonPath("$.data.displayName").value("Alice")) + .andExpect(jsonPath("$.data.email").value("alice@example.com")); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java index 8c796f22..6fb3e82d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java @@ -12,6 +12,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; import org.junit.jupiter.api.Test; @@ -26,6 +28,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.util.List; +import java.util.Optional; import java.util.Set; import static org.mockito.ArgumentMatchers.any; @@ -69,12 +72,16 @@ class NamespaceWorkflowContractTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private UserAccountRepository userAccountRepository; + @Test void namespaceWorkflowEndpoints_shareExpectedEnvelopeShapes() throws Exception { Namespace namespace = namespace(7L, "team-flow", NamespaceStatus.ACTIVE, NamespaceType.TEAM); Namespace frozen = namespace(7L, "team-flow", NamespaceStatus.FROZEN, NamespaceType.TEAM); Namespace archived = namespace(7L, "team-flow", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); NamespaceMember adminMember = new NamespaceMember(7L, "user-admin", NamespaceRole.ADMIN); + UserAccount adminUser = new UserAccount("user-admin", "Admin", "admin@example.com", null); setMemberId(adminMember, 11L); given(namespaceService.createNamespace(eq("team-flow"), eq("Team Flow"), eq("workflow"), eq("owner-1"))) @@ -92,6 +99,8 @@ class NamespaceWorkflowContractTest { .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(adminMember))); given(namespaceMemberService.updateMemberRole(7L, "user-admin", NamespaceRole.ADMIN, "owner-1")) .willReturn(adminMember); + given(userAccountRepository.findById("user-admin")).willReturn(Optional.of(adminUser)); + given(userAccountRepository.findByIdIn(List.of("user-admin"))).willReturn(List.of(adminUser)); mockMvc.perform(post("/api/web/namespaces") .with(csrf()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java new file mode 100644 index 00000000..431223a8 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java @@ -0,0 +1,184 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.SkillhubApplication; +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.infra.jpa.ReviewTaskJpaRepository; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.search.SearchEmbeddingService; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(classes = SkillhubApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillApprovalVisibilityFlowIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private NamespaceRepository namespaceRepository; + + @Autowired + private SkillRepository skillRepository; + + @Autowired + private SkillVersionRepository skillVersionRepository; + + @Autowired + private ReviewTaskJpaRepository reviewTaskJpaRepository; + + @Autowired + private SkillSearchDocumentJpaRepository skillSearchDocumentJpaRepository; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private SearchEmbeddingService searchEmbeddingService; + + @MockBean + private RbacService rbacService; + + @BeforeEach + void setUp() { + when(searchEmbeddingService.embed(anyString())).thenReturn(""); + when(searchEmbeddingService.similarity(anyString(), anyString())).thenReturn(0.0d); + when(rbacService.getUserRoleCodes("super-1")).thenReturn(Set.of("SUPER_ADMIN")); + } + + @Test + void approveReview_indexesGlobalSkillOnlyAfterApproval() throws Exception { + PendingSkillGraph graph = createPendingGlobalSkill("local-user"); + + assertThat(skillSearchDocumentJpaRepository.findBySkillId(graph.skill().getId())).isEmpty(); + assertThat(skillRepository.findById(graph.skill().getId())).get().extracting(Skill::getLatestVersionId).isNull(); + assertThat(skillVersionRepository.findById(graph.version().getId())).get() + .extracting(SkillVersion::getStatus) + .isEqualTo(SkillVersionStatus.PENDING_REVIEW); + + mockMvc.perform(post("/api/v1/reviews/" + graph.reviewTask().getId() + "/approve") + .contentType("application/json") + .content("{\"comment\":\"ship it\"}") + .with(authentication(apiAuth("super-1", "SUPER_ADMIN"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(graph.reviewTask().getId())) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.reviewedBy").value("super-1")) + .andExpect(jsonPath("$.data.reviewComment").value("ship it")); + + Skill savedSkill = skillRepository.findById(graph.skill().getId()).orElseThrow(); + SkillVersion savedVersion = skillVersionRepository.findById(graph.version().getId()).orElseThrow(); + + assertThat(savedSkill.getLatestVersionId()).isEqualTo(graph.version().getId()); + assertThat(savedVersion.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); + assertThat(savedVersion.getPublishedAt()).isNotNull(); + + SkillSearchDocumentEntity indexedDocument = awaitIndexedDocument(graph.skill().getId()); + assertThat(indexedDocument.getSkillId()).isEqualTo(graph.skill().getId()); + assertThat(indexedDocument.getNamespaceId()).isEqualTo(graph.namespace().getId()); + assertThat(indexedDocument.getNamespaceSlug()).isEqualTo(graph.namespace().getSlug()); + assertThat(indexedDocument.getVisibility()).isEqualTo("PUBLIC"); + assertThat(indexedDocument.getStatus()).isEqualTo("ACTIVE"); + assertThat(indexedDocument.getTitle()).isEqualTo(graph.skill().getDisplayName()); + } + + private PendingSkillGraph createPendingGlobalSkill(String ownerId) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + + Namespace namespace = new Namespace("global-approval-" + suffix, "Global Approval " + suffix, "system"); + namespace.setType(NamespaceType.GLOBAL); + namespace = namespaceRepository.save(namespace); + + Skill skill = new Skill(namespace.getId(), "approval-skill-" + suffix, ownerId, SkillVisibility.PUBLIC); + skill.setDisplayName("Approval Skill " + suffix); + skill.setSummary("Visible in search only after approval."); + skill.setCreatedBy(ownerId); + skill.setUpdatedBy(ownerId); + skill = skillRepository.save(skill); + skillRepository.flush(); + + SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", ownerId); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + version.setRequestedVisibility(SkillVisibility.PUBLIC); + version = skillVersionRepository.save(version); + skillVersionRepository.flush(); + + ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId)); + + return new PendingSkillGraph(namespace, skill, version, reviewTask); + } + + private SkillSearchDocumentEntity awaitIndexedDocument(Long skillId) throws InterruptedException { + Instant deadline = Instant.now().plus(Duration.ofSeconds(5)); + Optional indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); + while (indexed.isEmpty() && Instant.now().isBefore(deadline)) { + Thread.sleep(100L); + indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); + } + return indexed.orElseThrow(() -> new AssertionError("Expected search document for skill " + skillId)); + } + + private UsernamePasswordAuthenticationToken apiAuth(String userId, String... roles) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + Set.of(roles) + ); + List authorities = java.util.Arrays.stream(roles) + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + return new UsernamePasswordAuthenticationToken(principal, null, authorities); + } + + private record PendingSkillGraph(Namespace namespace, Skill skill, SkillVersion version, ReviewTask reviewTask) { + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java index 80a76c15..719ad40d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java @@ -9,17 +9,24 @@ import static org.mockito.Mockito.when; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.NamespaceLifecycleRequest; import com.iflytek.skillhub.dto.NamespaceRequest; +import com.iflytek.skillhub.dto.UpdateMemberRoleRequest; import com.iflytek.skillhub.exception.ForbiddenException; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; +import java.util.Optional; import java.util.Set; class NamespacePortalCommandAppServiceTest { @@ -28,11 +35,13 @@ class NamespacePortalCommandAppServiceTest { private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); private final NamespaceGovernanceService namespaceGovernanceService = mock(NamespaceGovernanceService.class); private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class); + private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); private final NamespacePortalCommandAppService service = new NamespacePortalCommandAppService( namespaceService, namespaceRepository, namespaceGovernanceService, - namespaceMemberService + namespaceMemberService, + userAccountRepository ); @Test @@ -71,4 +80,92 @@ class NamespacePortalCommandAppServiceTest { namespace.setType(NamespaceType.TEAM); return namespace; } + + @Test + void addMember_populatesDisplayNameAndEmail() { + Namespace ns = namespace(1L, "team-a"); + NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.ADMIN); + ReflectionTestUtils.setField(member, "id", 10L); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1")) + .thenReturn(member); + when(userAccountRepository.findById("user-2")) + .thenReturn(Optional.of(user)); + + MemberResponse result = service.addMember("team-a", "user-2", NamespaceRole.ADMIN, "owner-1"); + + assertThat(result.userId()).isEqualTo("user-2"); + assertThat(result.displayName()).isEqualTo("Alice"); + assertThat(result.email()).isEqualTo("alice@example.com"); + assertThat(result.role()).isEqualTo(NamespaceRole.ADMIN); + } + + @Test + void addMember_withoutUserAccount_degradesGracefully() { + Namespace ns = namespace(1L, "team-a"); + NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER); + ReflectionTestUtils.setField(member, "id", 20L); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceMemberService.addMember(1L, "ghost", NamespaceRole.MEMBER, "owner-1")) + .thenReturn(member); + when(userAccountRepository.findById("ghost")) + .thenReturn(Optional.empty()); + + MemberResponse result = service.addMember("team-a", "ghost", NamespaceRole.MEMBER, "owner-1"); + + assertThat(result.userId()).isEqualTo("ghost"); + assertThat(result.displayName()).isNull(); + assertThat(result.email()).isNull(); + } + + @Test + void updateMemberRole_populatesDisplayNameAndEmail() { + Namespace ns = namespace(1L, "team-a"); + NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.OWNER); + ReflectionTestUtils.setField(member, "id", 10L); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.OWNER, "owner-1")) + .thenReturn(member); + when(userAccountRepository.findById("user-2")) + .thenReturn(Optional.of(user)); + + MemberResponse result = service.updateMemberRole( + "team-a", "user-2", + new UpdateMemberRoleRequest(NamespaceRole.OWNER), + "owner-1" + ); + + assertThat(result.userId()).isEqualTo("user-2"); + assertThat(result.displayName()).isEqualTo("Alice"); + assertThat(result.email()).isEqualTo("alice@example.com"); + assertThat(result.role()).isEqualTo(NamespaceRole.OWNER); + } + + @Test + void updateMemberRole_withoutUserAccount_degradesGracefully() { + Namespace ns = namespace(1L, "team-a"); + NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER); + ReflectionTestUtils.setField(member, "id", 20L); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceMemberService.updateMemberRole(1L, "ghost", NamespaceRole.ADMIN, "owner-1")) + .thenReturn(member); + when(userAccountRepository.findById("ghost")) + .thenReturn(Optional.empty()); + + MemberResponse result = service.updateMemberRole( + "team-a", "ghost", + new UpdateMemberRoleRequest(NamespaceRole.ADMIN), + "owner-1" + ); + + assertThat(result.userId()).isEqualTo("ghost"); + assertThat(result.displayName()).isNull(); + assertThat(result.email()).isNull(); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 5c79f687..6091a905 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -2,12 +2,15 @@ package com.iflytek.skillhub.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -15,7 +18,12 @@ import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.MemberResponse; +import com.iflytek.skillhub.dto.PageResponse; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; @@ -28,11 +36,13 @@ class NamespacePortalQueryAppServiceTest { private final NamespaceService namespaceService = mock(NamespaceService.class); private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class); private final NamespaceAccessPolicy namespaceAccessPolicy = mock(NamespaceAccessPolicy.class); + private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); private final NamespacePortalQueryAppService service = new NamespacePortalQueryAppService( namespaceRepository, namespaceService, namespaceMemberService, - namespaceAccessPolicy + namespaceAccessPolicy, + userAccountRepository ); @Test @@ -103,4 +113,48 @@ class NamespacePortalQueryAppServiceTest { namespace.setType(NamespaceType.TEAM); return namespace; } + + @Test + void listMembers_withUserAccount_returnsDisplayNameAndEmail() { + Namespace ns = namespace(1L, "team-a"); + NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.ADMIN); + ReflectionTestUtils.setField(member, "id", 10L); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceMemberService.listMembers(eq(1L), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(member), PageRequest.of(0, 20), 1)); + when(userAccountRepository.findByIdIn(List.of("user-2"))) + .thenReturn(List.of(user)); + + PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1"); + + assertThat(result.items()).hasSize(1); + MemberResponse mr = result.items().get(0); + assertThat(mr.userId()).isEqualTo("user-2"); + assertThat(mr.displayName()).isEqualTo("Alice"); + assertThat(mr.email()).isEqualTo("alice@example.com"); + assertThat(mr.role()).isEqualTo(NamespaceRole.ADMIN); + } + + @Test + void listMembers_withoutUserAccount_returnsNullFields() { + Namespace ns = namespace(1L, "team-a"); + NamespaceMember member = new NamespaceMember(1L, "ghost-user", NamespaceRole.MEMBER); + ReflectionTestUtils.setField(member, "id", 20L); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceMemberService.listMembers(eq(1L), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(member), PageRequest.of(0, 20), 1)); + when(userAccountRepository.findByIdIn(List.of("ghost-user"))) + .thenReturn(List.of()); + + PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1"); + + assertThat(result.items()).hasSize(1); + MemberResponse mr = result.items().get(0); + assertThat(mr.userId()).isEqualTo("ghost-user"); + assertThat(mr.displayName()).isNull(); + assertThat(mr.email()).isNull(); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 16f888b9..eb533b8a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -14,6 +14,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.*; +import jakarta.persistence.EntityManager; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,6 +44,7 @@ public class PromotionService { private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; private final GovernanceNotificationService governanceNotificationService; + private final EntityManager entityManager; private final Clock clock; public PromotionService(PromotionRequestRepository promotionRequestRepository, @@ -53,6 +55,7 @@ public class PromotionService { ReviewPermissionChecker permissionChecker, ApplicationEventPublisher eventPublisher, GovernanceNotificationService governanceNotificationService, + EntityManager entityManager, Clock clock) { this.promotionRequestRepository = promotionRequestRepository; this.skillRepository = skillRepository; @@ -62,6 +65,7 @@ public class PromotionService { this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; this.governanceNotificationService = governanceNotificationService; + this.entityManager = entityManager; this.clock = clock; } @@ -193,9 +197,9 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } - - PromotionRequest approvedRequest = promotionRequestRepository.findById(promotionId) - .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId)); + syncPromotionRequestState(request, ReviewTaskStatus.APPROVED, reviewerId, comment); + entityManager.detach(request); + PromotionRequest approvedRequest = request; Skill sourceSkill = skillRepository.findById(approvedRequest.getSourceSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", approvedRequest.getSourceSkillId())); @@ -282,6 +286,8 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } + syncPromotionRequestState(request, ReviewTaskStatus.REJECTED, reviewerId, comment); + entityManager.detach(request); eventPublisher.publishEvent(new PromotionRejectedEvent( request.getId(), request.getSourceSkillId(), reviewerId, request.getSubmittedBy(), comment)); @@ -294,7 +300,7 @@ public class PromotionService { "{\"status\":\"REJECTED\"}" ); - return promotionRequestRepository.findById(promotionId).orElse(request); + return request; } public boolean canViewPromotion(PromotionRequest request, String userId, Set platformRoles) { @@ -313,4 +319,14 @@ public class PromotionService { private Instant currentTime() { return Instant.now(clock); } + + private void syncPromotionRequestState(PromotionRequest request, + ReviewTaskStatus status, + String reviewedBy, + String comment) { + request.setStatus(status); + request.setReviewedBy(reviewedBy); + request.setReviewComment(comment); + request.setReviewedAt(currentTime()); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index b1c66925..bd31218a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -20,6 +20,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import jakarta.persistence.EntityManager; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -51,6 +52,7 @@ public class ReviewService { private final ObjectMapper objectMapper; private final SkillGovernanceService skillGovernanceService; private final GovernanceNotificationService governanceNotificationService; + private final EntityManager entityManager; private final Clock clock; public ReviewService(ReviewTaskRepository reviewTaskRepository, @@ -62,6 +64,7 @@ public class ReviewService { ObjectMapper objectMapper, SkillGovernanceService skillGovernanceService, GovernanceNotificationService governanceNotificationService, + EntityManager entityManager, Clock clock) { this.reviewTaskRepository = reviewTaskRepository; this.skillVersionRepository = skillVersionRepository; @@ -72,6 +75,7 @@ public class ReviewService { this.objectMapper = objectMapper; this.skillGovernanceService = skillGovernanceService; this.governanceNotificationService = governanceNotificationService; + this.entityManager = entityManager; this.clock = clock; } @@ -191,6 +195,8 @@ public class ReviewService { if (updated == 0) { throw new ConcurrentModificationException("Review task was modified concurrently"); } + syncReviewTaskState(task, ReviewTaskStatus.APPROVED, reviewerId, comment); + entityManager.detach(task); Skill skill = skillRepository.findById(skillVersion.getSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); @@ -234,8 +240,7 @@ public class ReviewService { "{\"status\":\"APPROVED\"}" ); - // Reload to return updated state - return reviewTaskRepository.findById(reviewTaskId).orElse(task); + return task; } /** @@ -267,6 +272,8 @@ public class ReviewService { if (updated == 0) { throw new ConcurrentModificationException("Review task was modified concurrently"); } + syncReviewTaskState(task, ReviewTaskStatus.REJECTED, reviewerId, comment); + entityManager.detach(task); SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); @@ -286,7 +293,7 @@ public class ReviewService { "{\"status\":\"REJECTED\"}" ); - return reviewTaskRepository.findById(reviewTaskId).orElse(task); + return task; } /** @@ -358,4 +365,14 @@ public class ReviewService { private Instant currentTime() { return Instant.now(clock); } + + private void syncReviewTaskState(ReviewTask task, + ReviewTaskStatus status, + String reviewedBy, + String comment) { + task.setStatus(status); + task.setReviewedBy(reviewedBy); + task.setReviewComment(comment); + task.setReviewedAt(currentTime()); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 31695868..ea00a449 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -10,6 +10,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.*; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -42,6 +43,7 @@ class PromotionServiceTest { @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; @Mock private GovernanceNotificationService governanceNotificationService; + @Mock private EntityManager entityManager; private PromotionService promotionService; @@ -58,7 +60,7 @@ class PromotionServiceTest { void setUp() { promotionService = new PromotionService( promotionRequestRepository, skillRepository, skillVersionRepository, - skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService, CLOCK); + skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService, entityManager, CLOCK); } private static void setField(Object target, String fieldName, Object value) { @@ -391,17 +393,11 @@ class PromotionServiceTest { @Test void shouldApprovePromotionSuccessfully() { PromotionRequest pr = createPendingPromotion(); - PromotionRequest approvedPromotion = createPendingPromotion(); - setField(approvedPromotion, "status", ReviewTaskStatus.APPROVED); - setField(approvedPromotion, "version", 2); - setField(approvedPromotion, "reviewedBy", REVIEWER_ID); - setField(approvedPromotion, "reviewComment", "LGTM"); Skill sourceSkill = createSourceSkill(); SkillVersion sourceVersion = createPublishedVersion(); List sourceFiles = createSourceFiles(); - when(promotionRequestRepository.findById(PROMOTION_ID)) - .thenReturn(Optional.of(pr), Optional.of(approvedPromotion)); + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", null, pr.getVersion())) @@ -420,12 +416,16 @@ class PromotionServiceTest { }); when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(sourceFiles); when(skillFileRepository.saveAll(anyList())).thenAnswer(inv -> inv.getArgument(0)); - when(promotionRequestRepository.save(approvedPromotion)).thenReturn(approvedPromotion); + when(promotionRequestRepository.save(pr)).thenReturn(pr); PromotionRequest result = promotionService.approvePromotion( PROMOTION_ID, REVIEWER_ID, "LGTM", Set.of("SKILL_ADMIN")); assertNotNull(result); + assertEquals(ReviewTaskStatus.APPROVED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("LGTM", result.getReviewComment()); + assertEquals(Instant.now(CLOCK), result.getReviewedAt()); // Verify new skill created in global namespace ArgumentCaptor skillCaptor = ArgumentCaptor.forClass(Skill.class); @@ -467,8 +467,8 @@ class PromotionServiceTest { assertEquals(REVIEWER_ID, event.publisherId()); // Verify targetSkillId updated on promotion request - verify(promotionRequestRepository).save(approvedPromotion); - assertEquals(NEW_SKILL_ID, approvedPromotion.getTargetSkillId()); + verify(promotionRequestRepository).save(pr); + assertEquals(NEW_SKILL_ID, pr.getTargetSkillId()); } @Test @@ -556,12 +556,14 @@ class PromotionServiceTest { when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion())) .thenReturn(1); - when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); - PromotionRequest result = promotionService.rejectPromotion( PROMOTION_ID, REVIEWER_ID, "Not ready", Set.of("SKILL_ADMIN")); assertNotNull(result); + assertEquals(ReviewTaskStatus.REJECTED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("Not ready", result.getReviewComment()); + assertEquals(Instant.now(CLOCK), result.getReviewedAt()); verify(promotionRequestRepository).updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion()); verify(eventPublisher, never()).publishEvent(any()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index 76453d1b..90246ed1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -18,6 +18,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -54,6 +55,7 @@ class ReviewServiceTest { @Mock private ApplicationEventPublisher eventPublisher; @Mock private SkillGovernanceService skillGovernanceService; @Mock private GovernanceNotificationService governanceNotificationService; + @Mock private EntityManager entityManager; private ReviewService reviewService; @@ -70,7 +72,7 @@ class ReviewServiceTest { objectMapper = new ObjectMapper(); reviewService = new ReviewService( reviewTaskRepository, skillVersionRepository, skillRepository, - namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService, governanceNotificationService, CLOCK); + namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService, governanceNotificationService, entityManager, CLOCK); } private SkillVersion createDraftSkillVersion() { @@ -253,6 +255,10 @@ class ReviewServiceTest { Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); assertNotNull(result); + assertEquals(ReviewTaskStatus.APPROVED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("LGTM", result.getReviewComment()); + assertEquals(Instant.now(CLOCK), result.getReviewedAt()); assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus()); assertEquals(Instant.now(CLOCK), sv.getPublishedAt()); assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId()); @@ -335,9 +341,13 @@ class ReviewServiceTest { when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill())); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); - reviewService.rejectReview(REVIEW_TASK_ID, REVIEWER_ID, "Needs work", + ReviewTask result = reviewService.rejectReview(REVIEW_TASK_ID, REVIEWER_ID, "Needs work", Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); + assertEquals(ReviewTaskStatus.REJECTED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("Needs work", result.getReviewComment()); + assertEquals(Instant.now(CLOCK), result.getReviewedAt()); verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("REVIEW"), eq("REVIEW_TASK"), eq(REVIEW_TASK_ID), eq("Review rejected"), any()); } diff --git a/web/e2e/helpers/search-seed.ts b/web/e2e/helpers/search-seed.ts new file mode 100644 index 00000000..f7f0ab1d --- /dev/null +++ b/web/e2e/helpers/search-seed.ts @@ -0,0 +1,265 @@ +import type { Browser, Locator, Page, TestInfo } from '@playwright/test' +import { createFreshSession, loginWithCredentials, registerSession } from './session' +import { E2eTestDataBuilder, type SeededNamespace, type SeededSkill } from './test-data-builder' + +export const DEFAULT_SEARCH_KEYWORD = 'agent' + +export interface SearchSeedContext { + builder: E2eTestDataBuilder + keyword: string + namespace: SeededNamespace + skills: SeededSkill[] + skillNames: string[] +} + +export interface PreparedSearchSeed extends SearchSeedContext { + dispose: () => Promise +} + +interface PublisherSession { + builder: E2eTestDataBuilder + context: Awaited> + namespace: SeededNamespace + page: Page +} + +function requireEnv(name: string): string { + const value = process.env[name] + if (!value) { + throw new Error(`Missing required E2E env: ${name}`) + } + return value +} + +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim() + return value ? value : undefined +} + +function publisherCredentials() { + return { + username: requireEnv('E2E_PUBLISH_USERNAME'), + password: requireEnv('E2E_PUBLISH_PASSWORD'), + } +} + +function adminCredentials() { + return { + username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin', + password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026', + } +} + +function hasPublisherCredentials() { + return Boolean(getOptionalEnv('E2E_PUBLISH_USERNAME') && getOptionalEnv('E2E_PUBLISH_PASSWORD')) +} + +async function openProvidedPublisherSession(browser: Browser, testInfo: TestInfo): Promise { + const context = await browser.newContext() + const page = await context.newPage() + const builder = new E2eTestDataBuilder(page, testInfo) + + await loginWithCredentials(page, publisherCredentials(), testInfo) + await builder.init() + + return { + builder, + context, + namespace: await builder.ensureWritableNamespace(), + page, + } +} + +async function openAdhocPublisherSession(browser: Browser, testInfo: TestInfo): Promise { + const context = await browser.newContext() + const page = await context.newPage() + const builder = new E2eTestDataBuilder(page, testInfo) + + try { + await createFreshSession(page, testInfo) + } catch { + // Fall back to a regular worker session when transient registration issues happen + // after Playwright restarts the worker following an earlier test failure. + await registerSession(page, testInfo) + } + await builder.init() + + return { + builder, + context, + namespace: await builder.ensureWritableNamespace(), + page, + } +} + +async function publishSearchSkillsChunk( + session: PublisherSession, + keyword: string, + description: string, + seedSuffix: string, + startIndex: number, + count: number, +) { + const skills: SeededSkill[] = [] + const skillNames: string[] = [] + + for (let offset = 0; offset < count; offset += 1) { + const skillIndex = startIndex + offset + 1 + const skillName = `${keyword}-search-${skillIndex}-${seedSuffix}`.slice(0, 48) + const skill = await session.builder.publishSkill(session.namespace.slug, { + name: skillName, + description, + }) + skills.push(skill) + skillNames.push(skillName) + } + + return { skillNames, skills } +} + +export async function seedPublicSearchSkills( + page: Page, + testInfo: TestInfo, + options?: { + awaitSearchIndexed?: boolean + count?: number + keyword?: string + description?: string + }, +): Promise { + const count = options?.count ?? 1 + const builder = new E2eTestDataBuilder(page, testInfo) + const seedSuffix = `${testInfo.parallelIndex ?? 0}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` + const keyword = options?.keyword || `agent-${seedSuffix}`.slice(0, 32) + + await loginWithCredentials(page, publisherCredentials(), testInfo) + await builder.init() + + const namespace = await builder.ensureWritableNamespace() + const skills: SeededSkill[] = [] + const skillNames: string[] = [] + + for (let index = 0; index < count; index += 1) { + const skillName = `${keyword}-search-${index + 1}-${seedSuffix}`.slice(0, 48) + const skill = await builder.publishSkill(namespace.slug, { + name: skillName, + description: options?.description || `Searchable ${keyword} skill ${index + 1} for Playwright E2E coverage.`, + }) + skills.push(skill) + skillNames.push(skillName) + } + + if (options?.awaitSearchIndexed ?? true) { + await builder.waitForSearchResults(keyword, skills.map((skill) => skill.slug)) + } + + return { + builder, + keyword, + namespace, + skills, + skillNames, + } +} + +export async function cleanupSearchSeed(seed?: SearchSeedContext) { + if (seed) { + await seed.builder.cleanup() + } +} + +export async function prepareSearchSeed( + browser: Browser, + testInfo: TestInfo, + options?: { + awaitSearchIndexed?: boolean + count?: number + keyword?: string + description?: string + }, +): Promise { + const count = options?.count ?? 1 + const seedSuffix = `${testInfo.parallelIndex ?? 0}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` + const keyword = options?.keyword || `agent-${seedSuffix}`.slice(0, 32) + const description = options?.description || `Searchable ${keyword} skill for Playwright E2E coverage.` + const useProvidedPublisher = count <= 3 && hasPublisherCredentials() + const publisherSessions: PublisherSession[] = [ + useProvidedPublisher + ? await openProvidedPublisherSession(browser, testInfo) + : await openAdhocPublisherSession(browser, testInfo), + ] + const skills: SeededSkill[] = [] + const skillNames: string[] = [] + let publishedCount = 0 + + while (publishedCount < count) { + if (publishedCount >= 10 && publisherSessions.length === 1) { + publisherSessions.push(await openAdhocPublisherSession(browser, testInfo)) + } + + const activeSession = publishedCount < 10 ? publisherSessions[0] : publisherSessions[publisherSessions.length - 1] + const chunkSize = publishedCount < 10 ? Math.min(10 - publishedCount, count - publishedCount) : count - publishedCount + const chunk = await publishSearchSkillsChunk( + activeSession, + keyword, + description, + seedSuffix, + publishedCount, + chunkSize, + ) + skills.push(...chunk.skills) + skillNames.push(...chunk.skillNames) + publishedCount += chunkSize + } + + const seed: SearchSeedContext = { + builder: publisherSessions[0].builder, + keyword, + namespace: publisherSessions[0].namespace, + skills, + skillNames, + } + + const adminContext = await browser.newContext() + const adminPage = await adminContext.newPage() + const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo) + + await loginWithCredentials(adminPage, adminCredentials(), testInfo) + await adminBuilder.init() + + for (const skill of seed.skills) { + const reviewTaskId = await adminBuilder.waitForPendingReview(skill.namespace, skill.slug, skill.version) + await adminBuilder.approveReview(reviewTaskId) + } + + await seed.builder.waitForSearchResults(seed.keyword, seed.skills.map((skill) => skill.slug)) + + return { + ...seed, + dispose: async () => { + await adminContext.close() + for (let index = publisherSessions.length - 1; index >= 0; index -= 1) { + await cleanupSearchSeed({ + builder: publisherSessions[index].builder, + keyword: seed.keyword, + namespace: publisherSessions[index].namespace, + skills: [], + skillNames: [], + }) + await publisherSessions[index].context.close() + } + }, + } +} + +export function getSearchCard(page: Page, skillName: string): Locator { + return getSearchCards(page).filter({ + has: page.getByRole('heading', { name: skillName, exact: true }), + }).first() +} + +export function getSearchCards(page: Page): Locator { + return page.getByRole('link').filter({ + has: page.locator('h3'), + }) +} diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts index da6cca00..6b6ee60f 100644 --- a/web/e2e/helpers/session.ts +++ b/web/e2e/helpers/session.ts @@ -2,6 +2,29 @@ import { expect, type Page, type TestInfo } from '@playwright/test' const password = 'Passw0rd!123' const cachedUserByWorker = new Map() +const cachedSessionByAccount = new Map() +const requestTimeoutMs = process.env.CI ? 12_000 : 8_000 + +export interface TestCredentials { + password: string + username: string +} + +interface SessionSnapshot { + username: string + cookies: Array<{ + name: string + value: string + domain: string + path: string + expires: number + httpOnly: boolean + secure: boolean + sameSite: 'Strict' | 'Lax' | 'None' + }> +} + +const cachedSessionByWorker = new Map() function usernameForWorker(testInfo?: TestInfo): string { const worker = testInfo?.parallelIndex ?? 0 @@ -25,12 +48,14 @@ function isRetryableStatus(status: number): boolean { async function loginWithRetry( request: Page['request'], username: string, + currentPassword = password, retries = process.env.CI ? 10 : 6, ): Promise { for (let i = 0; i < retries; i += 1) { try { const login = await request.post('/api/v1/auth/local/login', { - data: { username, password }, + data: { username, password: currentPassword }, + timeout: requestTimeoutMs, }) if (login.ok()) { @@ -51,30 +76,148 @@ async function loginWithRetry( return false } +async function hasActiveSession(page: Page): Promise { + try { + const response = await page.context().request.get('/api/v1/auth/me', { + timeout: requestTimeoutMs, + }) + return response.ok() + } catch { + return false + } +} + +async function cacheSession(page: Page, worker: number, username: string) { + const snapshot = { + username, + cookies: await page.context().cookies(), + } + cachedSessionByWorker.set(worker, snapshot) + cachedSessionByAccount.set(username, snapshot) +} + +async function cacheAccountSession(page: Page, username: string) { + cachedSessionByAccount.set(username, { + username, + cookies: await page.context().cookies(), + }) +} + +async function restoreCachedSession(page: Page, worker: number): Promise { + const snapshot = cachedSessionByWorker.get(worker) + if (!snapshot) { + return null + } + + await page.context().addCookies(snapshot.cookies) + if (await hasActiveSession(page)) { + return snapshot + } + + cachedSessionByWorker.delete(worker) + return null +} + +async function restoreCachedSessionForAccount(page: Page, username: string): Promise { + const snapshot = cachedSessionByAccount.get(username) + if (!snapshot) { + return null + } + + await page.context().addCookies(snapshot.cookies) + if (await hasActiveSession(page)) { + return snapshot + } + + cachedSessionByAccount.delete(username) + return null +} + +async function primeAuthProviders(page: Page) { + try { + await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs }) + } catch { + // Best effort warm-up. + } +} + +async function tryBootstrapMockSession(page: Page, worker: number): Promise<{ username: string, password: string } | null> { + try { + await page.context().request.get('/api/v1/auth/providers', { + headers: { 'X-Mock-User-Id': 'local-user' }, + timeout: requestTimeoutMs, + }) + } catch { + return null + } + + if (!(await hasActiveSession(page))) { + return null + } + + await cacheSession(page, worker, 'local-user') + cachedUserByWorker.set(worker, 'local-user') + return { username: 'local-user', password } +} + async function registerSessionOnce(page: Page, testInfo?: TestInfo) { const worker = testInfo?.parallelIndex ?? 0 const cached = cachedUserByWorker.get(worker) const username = usernameForWorker(testInfo) const request = page.context().request - // Prime auth provider endpoint to stabilize cookie/bootstrap behavior. - try { - await request.get('/api/v1/auth/providers') - } catch { - // Best effort warm-up. + await primeAuthProviders(page) + + // Avoid hammering auth endpoints on every test run for the same worker. + const restored = await restoreCachedSession(page, worker) + if (restored) { + cachedUserByWorker.set(worker, restored.username) + return { username: restored.username, password } + } + + const mockSession = await tryBootstrapMockSession(page, worker) + if (mockSession) { + return mockSession } // Prefer the known-good cached account to avoid repeated failed-logins on a fixed username. if (cached && await loginWithRetry(request, cached)) { + await cacheSession(page, worker, cached) return { username: cached, password } } // Support environments where a deterministic worker account already exists. - if (!cached && await loginWithRetry(request, username, process.env.CI ? 4 : 3)) { + if (!cached && await loginWithRetry(request, username, password, process.env.CI ? 4 : 3)) { cachedUserByWorker.set(worker, username) + await cacheSession(page, worker, username) return { username, password } } + try { + const register = await request.post('/api/v1/auth/local/register', { + data: { + username, + password, + email: `${username}@example.test`, + }, + timeout: requestTimeoutMs, + }) + + if (register.ok()) { + cachedUserByWorker.set(worker, username) + await cacheSession(page, worker, username) + return { username, password } + } + + if (register.status() === 409 && await loginWithRetry(request, username, password, process.env.CI ? 8 : 6)) { + cachedUserByWorker.set(worker, username) + await cacheSession(page, worker, username) + return { username, password } + } + } catch { + // Fall through to the unique-account fallback below. + } + // Registering creates session cookies for the current request context. // Prefer creating a new unique account to avoid password drift and login throttling. for (let i = 0; i < 12; i += 1) { @@ -87,10 +230,12 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) { password, email: `${uniqueUsername}@example.test`, }, + timeout: requestTimeoutMs, }) if (register.ok()) { cachedUserByWorker.set(worker, uniqueUsername) + await cacheSession(page, worker, uniqueUsername) return { username: uniqueUsername, password } } @@ -118,8 +263,9 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) { // Final fallback for environments where registration is temporarily unavailable. const fallbackCandidates = [cached, username].filter((candidate): candidate is string => Boolean(candidate)) for (const candidate of fallbackCandidates) { - if (await loginWithRetry(request, candidate, process.env.CI ? 12 : 8)) { + if (await loginWithRetry(request, candidate, password, process.env.CI ? 12 : 8)) { cachedUserByWorker.set(worker, candidate) + await cacheSession(page, worker, candidate) return { username: candidate, password } } } @@ -127,6 +273,50 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) { throw new Error(`Failed to establish e2e session for worker ${worker}`) } +async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) { + const worker = testInfo?.parallelIndex ?? 0 + const request = page.context().request + + await primeAuthProviders(page) + + for (let i = 0; i < 12; i += 1) { + const uniqueUsername = `${uniqueUsernameForWorker(testInfo)}_${i}` + + try { + const register = await request.post('/api/v1/auth/local/register', { + data: { + username: uniqueUsername, + password, + email: `${uniqueUsername}@example.test`, + }, + timeout: requestTimeoutMs, + }) + + if (register.ok()) { + cachedUserByWorker.set(worker, uniqueUsername) + await cacheSession(page, worker, uniqueUsername) + return { username: uniqueUsername, password } + } + + const status = register.status() + if (status === 409 || status === 400) { + continue + } + + if (isRetryableStatus(status)) { + await sleep(300 * (i + 1)) + continue + } + + expect(register.ok()).toBeTruthy() + } catch { + await sleep(300 * (i + 1)) + } + } + + throw new Error(`Failed to create fresh e2e session for worker ${worker}`) +} + export async function registerSession(page: Page, testInfo?: TestInfo) { let lastError: unknown @@ -143,3 +333,42 @@ export async function registerSession(page: Page, testInfo?: TestInfo) { throw lastError } + +export async function createFreshSession(page: Page, testInfo?: TestInfo) { + let lastError: unknown + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await createFreshSessionOnce(page, testInfo) + } catch (error) { + lastError = error + if (attempt < 2) { + await sleep(500 * (attempt + 1)) + } + } + } + + throw lastError +} + +export async function loginWithCredentials(page: Page, credentials: TestCredentials, _testInfo?: TestInfo) { + const request = page.context().request + + await primeAuthProviders(page) + + const restored = await restoreCachedSessionForAccount(page, credentials.username) + if (restored) { + return credentials + } + + const loggedIn = await loginWithRetry( + request, + credentials.username, + credentials.password, + process.env.CI ? 12 : 8, + ) + expect(loggedIn).toBeTruthy() + + await cacheAccountSession(page, credentials.username) + return credentials +} diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index f3259729..7d7d4e97 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -25,6 +25,15 @@ export interface SeededReviewData { skill: SeededSkill } +interface ReviewTaskSummary { + id: number + namespace: string + skillSlug: string + status: string + submittedBy: string + version: string +} + interface ApiEnvelope { code: number msg: string @@ -36,6 +45,13 @@ interface ApiFailure extends Error { code?: number } +export interface SeedSkillOptions { + name?: string + description?: string + version?: string + readmeHeading?: string +} + function asApiErrorBody(value: unknown): string { if (!value || typeof value !== 'object') { return '' @@ -49,26 +65,39 @@ function uniqueSuffix(testInfo?: TestInfo): string { return `${worker}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` } -function buildSkillPackageZipBuffer(suffix: string): Buffer { - const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-')) - try { - const packageDir = path.join(tempRoot, `pkg-${suffix}`) - const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`) - const skillName = `e2e-skill-${suffix}`.slice(0, 48) - const skillMd = `--- +function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) { + const skillName = (options?.name || `e2e-skill-${suffix}`).slice(0, 48) + const description = options?.description || 'E2E generated skill for real-request tests' + const version = options?.version || '1.0.0' + const readmeHeading = options?.readmeHeading || skillName + const skillMd = `--- name: ${skillName} -description: E2E generated skill for real-request tests -version: 1.0.0 +description: ${description} +version: ${version} --- -# ${skillName} +# ${readmeHeading} Generated by Playwright E2E. ` + return { + readmeHeading, + skillMd, + skillName, + } +} + +function buildSkillPackageZipBuffer(suffix: string, options?: SeedSkillOptions): Buffer { + const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-')) + try { + const packageDir = path.join(tempRoot, `pkg-${suffix}`) + const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`) + const { readmeHeading, skillMd } = buildSkillPackageContent(suffix, options) + execFileSync('mkdir', ['-p', packageDir]) writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') - writeFileSync(path.join(packageDir, 'README.md'), `# ${skillName}\n`, 'utf8') + writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) return readFileSync(zipPath) } finally { @@ -76,25 +105,15 @@ Generated by Playwright E2E. } } -function createSkillPackageZipFile(suffix: string): { filePath: string; cleanup: () => void } { +function createSkillPackageZipFile(suffix: string, options?: SeedSkillOptions): { filePath: string; cleanup: () => void } { const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-file-')) const packageDir = path.join(tempRoot, `pkg-${suffix}`) const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`) - const skillName = `e2e-skill-${suffix}`.slice(0, 48) - const skillMd = `--- -name: ${skillName} -description: E2E generated skill for real-request tests -version: 1.0.0 ---- - -# ${skillName} - -Generated by Playwright E2E. -` + const { readmeHeading, skillMd } = buildSkillPackageContent(suffix, options) execFileSync('mkdir', ['-p', packageDir]) writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') - writeFileSync(path.join(packageDir, 'README.md'), `# ${skillName}\n`, 'utf8') + writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) return { @@ -239,32 +258,115 @@ export class E2eTestDataBuilder { } } - async publishSkill(namespaceSlug: string): Promise { - const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` - const zipBuffer = buildSkillPackageZipBuffer(unique) + async waitForSearchResult(query: string, expectedSlug?: string): Promise { + const encodedQuery = encodeURIComponent(query) - let result: SeededSkill - try { - result = await parseEnvelope( - await this.request.post(`/api/web/skills/${encodeURIComponent(namespaceSlug)}/publish`, { - multipart: { - file: { - name: 'sample-skill.zip', - mimeType: 'application/zip', - buffer: zipBuffer, - }, - visibility: 'PUBLIC', - }, - }), - ) - } catch (error) { - const fallback = await this.getMySkillInNamespace(namespaceSlug) - if (!fallback) { - throw error + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const page = await parseEnvelope<{ + items: Array<{ slug: string }> + }>( + await this.request.get(`/api/web/skills?q=${encodedQuery}&sort=relevance&page=0&size=50`), + ) + if (!expectedSlug || page.items.some((item) => item.slug === expectedSlug)) { + return + } + } catch { + // Search indexing can lag briefly behind publish in local environments. } - return fallback + + await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))) } + throw new Error(`Timed out waiting for search result "${query}"${expectedSlug ? ` (${expectedSlug})` : ''}`) + } + + async waitForSearchResults(query: string, expectedSlugs: string[]): Promise { + const pending = new Set(expectedSlugs) + if (pending.size === 0) { + return + } + + const encodedQuery = encodeURIComponent(query) + + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const page = await parseEnvelope<{ + items: Array<{ slug: string }> + }>( + await this.request.get(`/api/web/skills?q=${encodedQuery}&sort=relevance&page=0&size=50`), + ) + + for (const item of page.items) { + pending.delete(item.slug) + } + + if (pending.size === 0) { + return + } + } catch { + // Search indexing can lag briefly behind publish in local environments. + } + + await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))) + } + + throw new Error(`Timed out waiting for search results "${query}" (${Array.from(pending).join(', ')})`) + } + + async waitForPendingReview(namespaceSlug: string, skillSlug: string, version: string): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const page = await parseEnvelope<{ + items: ReviewTaskSummary[] + }>( + await this.request.get('/api/web/reviews?status=PENDING&page=0&size=100&sortDirection=DESC'), + ) + + const matched = page.items.find((item) => + item.namespace === namespaceSlug && + item.skillSlug === skillSlug && + item.version === version && + item.status === 'PENDING', + ) + if (matched) { + return matched.id + } + } catch { + // Review list can lag behind publish very briefly. + } + + await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))) + } + + throw new Error(`Timed out waiting for pending review ${namespaceSlug}/${skillSlug}@${version}`) + } + + async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise { + await parseEnvelope( + await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, { + data: { comment }, + }), + ) + } + + async publishSkill(namespaceSlug: string, options?: SeedSkillOptions): Promise { + const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` + const zipBuffer = buildSkillPackageZipBuffer(unique, options) + + const result = await parseEnvelope( + await this.request.post(`/api/web/skills/${encodeURIComponent(namespaceSlug)}/publish`, { + multipart: { + file: { + name: 'sample-skill.zip', + mimeType: 'application/zip', + buffer: zipBuffer, + }, + visibility: 'PUBLIC', + }, + }), + ) + this.cleanupTasks.push(async () => { await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`) }) @@ -272,9 +374,9 @@ export class E2eTestDataBuilder { return result } - createSkillPackageFile(): string { + createSkillPackageFile(options?: SeedSkillOptions): string { const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` - const { filePath, cleanup } = createSkillPackageZipFile(unique) + const { filePath, cleanup } = createSkillPackageZipFile(unique, options) this.cleanupTasks.push(async () => { cleanup() }) diff --git a/web/e2e/register-login-validation.spec.ts b/web/e2e/register-login-validation.spec.ts new file mode 100644 index 00000000..2d0c8531 --- /dev/null +++ b/web/e2e/register-login-validation.spec.ts @@ -0,0 +1,309 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { createFreshSession } from './helpers/session' + +// TC_UN_* ็”จๆˆทๅ่พ“ๅ…ฅๆก† / TC_EM_* ้‚ฎ็ฎฑ่พ“ๅ…ฅๆก† / TC_PW_* ๅฏ†็ ่พ“ๅ…ฅๆก† +// TC_REG_* ๆณจๅ†Œ/็™ปๅฝ•ๆต็จ‹ / TC_UI_* UI/UX + +let existingRegisteredUsername: string | null = null +const DUPLICATE_USERNAME_ERROR = /already.*exist|taken|username.*used/i +const REGISTER_RATE_LIMIT_ERROR = /too many|too frequent|rate limit|่ฏทๆฑ‚่ฟ‡ไบŽ้ข‘็น/ + +test.describe('Register - Username Validation (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.goto('/register') + }) + + // TC_UN_008 P0 + test('TC_UN_008: shows required error when username is empty', async ({ page }) => { + await page.getByRole('button', { name: 'Register' }).click() + await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible() + }) + + // TC_UN_001 P0 - valid minimum length + test('TC_UN_001: accepts valid username with minimum 3 characters', async ({ page }) => { + await page.getByLabel(/username/i).fill('abc') + await page.getByLabel(/username/i).blur() + await expect(page.getByText(/ไป…ๆ”ฏๆŒ|only.*letter|username.*required/i)).not.toBeVisible() + }) + + // TC_UN_006 P1 - 2 chars below minimum + test('TC_UN_006: shows length error for 2-character username', async ({ page }) => { + await page.getByLabel(/username/i).fill('ab') + await page.getByLabel(/username/i).blur() + await expect(page.getByText(/3.{0,10}64|length|at least/i)).toBeVisible() + }) + + // TC_UN_009 P1 - special chars + test('TC_UN_009: shows error for username with special characters like @', async ({ page }) => { + await page.getByLabel(/username/i).fill('user@123') + await page.getByLabel(/username/i).blur() + await expect(page.getByText(/letter|number|underscore|alphanumeric/i)).toBeVisible() + }) + + // TC_UN_010 P1 - Chinese chars + test('TC_UN_010: shows error for username containing Chinese characters', async ({ page }) => { + await page.getByLabel(/username/i).fill('็”จๆˆท123') + await page.getByLabel(/username/i).blur() + await expect(page.getByText(/letter|number|underscore|alphanumeric/i)).toBeVisible() + }) +}) + +test.describe('Register - Email Validation (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.goto('/register') + }) + + // TC_EM_007 P0 - email is optional + test('TC_EM_007: allows empty email (email is optional)', async ({ page }) => { + const emailField = page.getByLabel(/email/i) + if (await emailField.isVisible()) { + await emailField.clear() + await emailField.blur() + await expect(page.getByText(/email.*required/i)).not.toBeVisible() + } + }) + + // TC_EM_008 P1 - missing @ + test('TC_EM_008: shows error for email missing @ symbol', async ({ page }) => { + const emailField = page.getByLabel(/email/i) + if (await emailField.isVisible()) { + await emailField.fill('userexample.com') + await emailField.blur() + await expect(page.getByText(/email.*invalid|invalid.*email|format/i)).toBeVisible() + } + }) + + // TC_EM_009 P1 - missing domain + test('TC_EM_009: shows error for email missing domain after @', async ({ page }) => { + const emailField = page.getByLabel(/email/i) + if (await emailField.isVisible()) { + await emailField.fill('user@') + await emailField.blur() + await expect(page.getByText(/email.*invalid|invalid.*email|format/i)).toBeVisible() + } + }) +}) + +test.describe('Register - Password Validation (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.goto('/register') + }) + + // TC_PW_013 P0 - empty password + test('TC_PW_013: shows required error when password is empty', async ({ page }) => { + await page.getByRole('button', { name: 'Register' }).click() + await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible() + }) + + // TC_PW_007 P1 - 7 chars (below minimum 8) + test('TC_PW_007: shows length error for 7-character password', async ({ page }) => { + await page.getByLabel(/^password/i).fill('Abc123!') + await page.getByLabel(/^password/i).blur() + await expect(page.getByText(/8|at least|minimum/i)).toBeVisible() + }) + + // TC_PW_008 P1 - only 2 types (uppercase + lowercase) + test('TC_PW_008: shows complexity error for password with only 2 character types', async ({ page }) => { + await page.getByLabel(/^password/i).fill('Abcdefgh') + await page.getByLabel(/^password/i).blur() + await expect(page.getByText(/three|3.*type|character type|complexity/i)).toBeVisible() + }) + + // TC_PW_001 P0 - valid password with 3+ types + test('TC_PW_001: accepts valid password with 3 character types and minimum length', async ({ page }) => { + await page.getByLabel(/^password/i).fill('Abc123!@') + await page.getByLabel(/^password/i).blur() + await expect(page.getByText(/three|3.*type|character type|complexity/i)).not.toBeVisible() + await expect(page.getByText(/8|at least|minimum/i)).not.toBeVisible() + }) +}) + +test.describe('Register Flow (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_REG_001 P0 - successful registration with all fields + test('TC_REG_001: registers successfully with valid username, email and password', async ({ page }) => { + await page.goto('/register') + const suffix = Date.now().toString(36) + const username = `testuser_${suffix}` + await page.getByLabel(/username/i).fill(username) + const emailField = page.getByLabel(/email/i) + if (await emailField.isVisible()) { + await emailField.fill(`test_${suffix}@example.test`) + } + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByRole('button', { name: 'Register' }).click() + // Should redirect away from /register on success + await expect(page).not.toHaveURL('/register') + existingRegisteredUsername = username + }) + + // TC_REG_003 P0 - duplicate username + test('TC_REG_003: shows error when registering with existing username', async ({ browser, page }, testInfo) => { + let username = existingRegisteredUsername + if (!username) { + const seedContext = await browser.newContext() + const seedPage = await seedContext.newPage() + const seedCredentials = await createFreshSession(seedPage, testInfo) + username = seedCredentials.username + existingRegisteredUsername = username + await seedContext.close() + } + + // Now try to register with the same username again + await page.goto('/register') + await setEnglishLocale(page) + await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/^password/i).fill('Test123!@') + const main = page.getByRole('main') + const duplicateUsernameError = main.getByText(DUPLICATE_USERNAME_ERROR).first() + const registerRateLimitError = main.getByText(REGISTER_RATE_LIMIT_ERROR).first() + + for (let attempt = 0; attempt < 3; attempt += 1) { + await page.getByRole('button', { name: 'Register' }).click() + + if (await duplicateUsernameError.isVisible().catch(() => false)) { + return + } + + if (attempt < 2 && await registerRateLimitError.isVisible().catch(() => false)) { + await page.waitForTimeout(1_500 * (attempt + 1)) + continue + } + + break + } + + await expect(duplicateUsernameError).toBeVisible() + }) + + // TC_REG_002 P0 - registration without email + test('TC_REG_002: registers successfully without email (email is optional)', async ({ page }) => { + await page.goto('/register') + const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 5) + await page.getByLabel(/username/i).fill(`noemail_${suffix}`) + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByRole('button', { name: 'Register' }).click() + await expect(page).not.toHaveURL('/register') + }) + + // TC_REG_005 P0 - required fields empty on submit + test('TC_REG_005: shows validation errors when submitting empty required fields', async ({ page }) => { + await page.goto('/register') + await page.getByRole('button', { name: 'Register' }).click() + await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible() + await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible() + }) +}) + +test.describe('Login Flow (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_REG_006 P0 - successful login (already tested in auth-entry.spec.ts partially; extend here) + test('TC_REG_006: shows required field errors when submitting empty login form', async ({ page }) => { + await page.goto('/login') + await page.getByRole('button', { name: 'Login' }).click() + await expect(page.getByText('Username is required')).toBeVisible() + await expect(page.getByText('Password is required')).toBeVisible() + }) + + // TC_REG_007 P0 - wrong password + test('TC_REG_007: shows error for wrong password on existing account', async ({ page }) => { + // First register a user, then attempt login with wrong password + const suffix = Date.now().toString(36) + const username = `logintest_${suffix}` + + await page.goto('/register') + await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByRole('button', { name: 'Register' }).click() + await expect(page).not.toHaveURL('/register') + + await page.goto('/login') + await setEnglishLocale(page) + await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/^password/i).fill('WrongPassword999!') + await page.getByRole('button', { name: 'Login' }).click() + await expect(page.getByText(/invalid|incorrect|wrong|username.*password/i)).toBeVisible() + }) + + // TC_REG_008 P0 - non-existent username + test('TC_REG_008: shows error for non-existent username login attempt', async ({ page }) => { + await page.goto('/login') + await page.getByLabel(/username/i).fill('nonexistent_user_xyz99999') + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByRole('button', { name: 'Login' }).click() + await expect(page.getByText(/invalid|incorrect|wrong|username.*password|not found/i)).toBeVisible() + }) + + // TC_REG_010 P1 - SQL injection safety + test('TC_REG_010: safely handles SQL injection input in username field', async ({ page }) => { + await page.goto('/login') + await page.getByLabel(/username/i).fill("admin' OR '1'='1") + await page.getByLabel(/^password/i).fill('anything') + await page.getByRole('button', { name: 'Login' }).click() + // Should not log in; should show error or validation message, NOT redirect to dashboard + await expect(page).not.toHaveURL('/dashboard') + }) + + // TC_REG_011 P1 - XSS in input + test('TC_REG_011: safely handles XSS payload in username field without executing script', async ({ page }) => { + let alerted = false + page.on('dialog', () => { alerted = true }) + + await page.goto('/login') + await page.getByLabel(/username/i).fill("") + await page.getByLabel(/^password/i).fill('anything') + await page.getByRole('button', { name: 'Login' }).click() + await expect(page).not.toHaveURL('/dashboard') + expect(alerted).toBe(false) + }) +}) + +test.describe('Register/Login UI (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_UI_003 P2 - password visibility toggle + test('TC_UI_003: password visibility toggle switches between masked and plain text', async ({ page }) => { + await page.goto('/register') + const passwordInput = page.getByLabel(/^password/i) + await expect(passwordInput).toHaveAttribute('type', 'password') + + const toggleBtn = page.getByRole('button', { name: /show|hide|toggle/i }) + .or(page.locator('[data-testid*="password-toggle"], [aria-label*="password"]')) + if (await toggleBtn.isVisible()) { + await toggleBtn.click() + await expect(passwordInput).toHaveAttribute('type', 'text') + } + }) + + // TC_UI_005 P2 - Enter key submits form + test('TC_UI_005: pressing Enter in the last input field submits the login form', async ({ page }) => { + await page.goto('/login') + await page.getByLabel(/username/i).fill('someuser') + await page.getByLabel(/^password/i).fill('SomePass123!') + await page.getByLabel(/^password/i).press('Enter') + // Form should attempt submission (either error msg or redirect) + await expect( + page.getByText(/invalid|incorrect|dashboard/i) + .or(page.locator('[role="alert"]')) + ).toBeVisible({ timeout: 5000 }) + }) + + // returnTo param preservation (from auth-entry.spec.ts - extended) + test('preserves returnTo param when navigating from register link on login page', async ({ page }) => { + await page.goto('/login?returnTo=%2Fdashboard%2Ftokens') + await page.getByRole('link', { name: /sign up|register/i }).click() + await expect(page).toHaveURL('/register?returnTo=%2Fdashboard%2Ftokens') + }) +}) diff --git a/web/e2e/search-card-interaction.spec.ts b/web/e2e/search-card-interaction.spec.ts new file mode 100644 index 00000000..3fca443a --- /dev/null +++ b/web/e2e/search-card-interaction.spec.ts @@ -0,0 +1,438 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { + getSearchCard, + getSearchCards, + prepareSearchSeed, + type PreparedSearchSeed, +} from './helpers/search-seed' +import { registerSession } from './helpers/session' + +const SEARCH_URL = (q: string, sort = 'relevance', page = 0) => + `/search?q=${encodeURIComponent(q)}&sort=${sort}&page=${page}&starredOnly=false` + +function latestSeed(seed: PreparedSearchSeed) { + return { + skill: seed.skills[seed.skills.length - 1], + skillName: seed.skillNames[seed.skillNames.length - 1], + } +} + +async function waitForCards(page: Page) { + const cards = getSearchCards(page) + + if (basicSeed) { + await basicSeed.builder.waitForSearchResults( + basicSeed.keyword, + basicSeed.skills.map((skill) => skill.slug), + ) + } + + const keyword = basicSeed?.keyword + const encodedKeyword = keyword ? encodeURIComponent(keyword) : null + + for (let attempt = 0; attempt < 4; attempt += 1) { + await page.waitForLoadState('networkidle') + await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 }) + + if (await cards.count() > 0) { + return cards + } + + if (attempt < 3) { + const responsePromise = encodedKeyword + ? page.waitForResponse(async (response) => { + if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) { + return false + } + if (response.status() !== 200) { + return false + } + + try { + const payload = await response.json() as { data?: { items?: Array } } + return Array.isArray(payload.data?.items) && payload.data.items.length > 0 + } catch { + return false + } + }, { timeout: 12_000 }).catch(() => null) + : Promise.resolve(null) + + await page.waitForTimeout(750 * (attempt + 1)) + await page.reload({ waitUntil: 'networkidle' }) + await responsePromise + } + } + + return cards +} + +let basicSeed: PreparedSearchSeed | undefined + +test.setTimeout(300_000) + +test.beforeAll(async ({ browser }, testInfo) => { + test.setTimeout(300_000) + basicSeed = await prepareSearchSeed(browser, testInfo, { count: 13 }) +}) + +test.afterAll(async () => { + await basicSeed?.dispose() + basicSeed = undefined +}) + +// โ”€โ”€โ”€ Card Display After Search โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Card Display (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_001 P0 + test('TC_SEARCH_INTERACT_001: cards appear immediately after search', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const cards = await waitForCards(page) + await expect(cards.first()).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_INTERACT_005 P0 - cards show complete info + test('TC_SEARCH_INTERACT_005: each card shows name, description, and version', async ({ page }) => { + const current = latestSeed(basicSeed!) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + await expect(firstCard.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible() + await expect(firstCard.getByText(`v${current.skill.version}`)).toBeVisible() + }) + + // TC_SEARCH_INTERACT_039 P0 - version number format + test('TC_SEARCH_INTERACT_039: version number is displayed in v1.2.3 format', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(page.getByText(/v\d+\.\d+\.\d+/).first()).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_INTERACT_038 P0 - long descriptions truncated + test('TC_SEARCH_INTERACT_038: long descriptions are truncated with ellipsis', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const cards = await waitForCards(page) + expect(await cards.count()).toBeGreaterThan(0) + }) + + // TC_SEARCH_INTERACT_031 P0 - no results shows empty state + test('TC_SEARCH_INTERACT_031: no results shows empty state instead of cards', async ({ page }) => { + await page.goto(SEARCH_URL('xyznonexistentkeyword99999abc')) + await page.waitForLoadState('networkidle') + await expect(getSearchCards(page)).toHaveCount(0) + await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_INTERACT_035 P0 - large results show pagination + test('TC_SEARCH_INTERACT_035: large result sets show pagination controls', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await page.waitForLoadState('networkidle') + await expect(page.getByRole('button', { name: /next|โ€บ/i })).toBeVisible({ timeout: 10_000 }) + }) +}) + +// โ”€โ”€โ”€ Card Content & Search Relevance โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Card Content (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_003 P0 - card count matches count indicator + test('TC_SEARCH_INTERACT_003: displayed card count is consistent with skill count indicator', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const cards = getSearchCards(page) + const cardCount = await cards.count() + const countText = await page.getByText(/\d+\s+skills found/i).first().textContent() + const totalMatch = countText?.match(/\d+/) + if (totalMatch) { + const total = parseInt(totalMatch[0], 10) + expect(total).toBeGreaterThanOrEqual(cardCount) + } + }) + + // TC_SEARCH_INTERACT_040 P0 - download count formatted + test('TC_SEARCH_INTERACT_040: download counts are formatted correctly (numbers or K/M)', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(page.locator('body')).not.toContainText(/error|500/i) + await expect(getSearchCards(page).first()).toContainText(/\d/) + }) +}) + +// โ”€โ”€โ”€ Card Click Navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Card Navigation (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_007 P0 - clicking card navigates to detail page + test('TC_SEARCH_INTERACT_007: clicking a skill card navigates to the skill detail page', async ({ page }, testInfo) => { + const current = latestSeed(basicSeed!) + await registerSession(page, testInfo) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + await firstCard.click() + await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}`)) + }) + + // TC_SEARCH_INTERACT_008 P0 - detail page matches clicked card + test('TC_SEARCH_INTERACT_008: skill detail page matches the card that was clicked', async ({ page }, testInfo) => { + const current = latestSeed(basicSeed!) + await registerSession(page, testInfo) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + await firstCard.click() + await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}`)) + await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible() + }) + + // TC_SEARCH_INTERACT_009 P1 - Ctrl+click opens in new tab + test('TC_SEARCH_INTERACT_009: Ctrl+click on card opens skill detail in new tab', async ({ page, context }) => { + test.skip(true, 'Skill cards render as clickable divs, so browser-level new-tab semantics do not apply.') + const current = latestSeed(basicSeed!) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + + const [newPage] = await Promise.all([ + context.waitForEvent('page'), + firstCard.click({ modifiers: ['Meta'] }), + ]) + await newPage.waitForLoadState() + await expect(newPage).toHaveURL(/\/space\//) + await newPage.close() + }) +}) + +// โ”€โ”€โ”€ Sort Switching Updates Cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Card Sort Interaction (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_021 P0 - switching sort updates cards + test('TC_SEARCH_INTERACT_021: switching sort tab re-renders card list', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + + await page.getByRole('button', { name: 'Downloads' }).click() + await page.waitForLoadState('networkidle') + await expect(page).toHaveURL(/sort=downloads/) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_INTERACT_026 P0 - re-search replaces cards + test('TC_SEARCH_INTERACT_026: re-searching with new keyword replaces card list', async ({ page }) => { + await page.goto(SEARCH_URL('')) + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill(basicSeed!.keyword) + await searchInput.press('Enter') + await page.waitForLoadState('networkidle') + await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_INTERACT_027 P0 - re-search resets page to 0 + test('TC_SEARCH_INTERACT_027: re-searching resets page number to 0', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword, 'relevance', 1)) + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill(basicSeed!.keyword) + await searchInput.press('Enter') + await expect(page).toHaveURL(/page=0/) + }) +}) + +// โ”€โ”€โ”€ Pagination Card Updates โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Card Pagination (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_023 P0 - switching page updates cards + test('TC_SEARCH_INTERACT_023: switching to next page shows different cards', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await page.waitForLoadState('networkidle') + + const nextBtn = page.getByRole('button', { name: /next|โ€บ/i }) + const firstCardTitle = await getSearchCards(page).first().getByRole('heading').textContent() + await expect(nextBtn).toBeVisible({ timeout: 10_000 }) + await nextBtn.click() + await page.waitForLoadState('networkidle') + await expect(page).toHaveURL(/page=1/) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + const secondPageFirstTitle = await getSearchCards(page).first().getByRole('heading').textContent() + expect(secondPageFirstTitle).not.toBe(firstCardTitle) + }) + + // TC_SEARCH_INTERACT_025 P1 - page switch scrolls to top + test('TC_SEARCH_INTERACT_025: switching page scrolls back to top of results', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await page.waitForLoadState('networkidle') + + const nextBtn = page.getByRole('button', { name: /next|โ€บ/i }) + await expect(nextBtn).toBeVisible({ timeout: 10_000 }) + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await nextBtn.click() + await page.waitForLoadState('networkidle') + await expect.poll( + () => page.evaluate(() => window.scrollY), + { timeout: 5_000, intervals: [100, 250, 500, 1_000] }, + ).toBeLessThan(300) + }) +}) + +// โ”€โ”€โ”€ Loading State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Card Loading State (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_030 P0 - skeleton disappears after load + test('TC_SEARCH_INTERACT_030: skeleton screen disappears and real cards appear after load', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await page.waitForLoadState('networkidle') + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + await expect(page.locator('[class*="skeleton"], [class*="shimmer"]')).toHaveCount(0) + }) +}) + +// โ”€โ”€โ”€ Responsive Layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Card Responsive Layout (Real API)', () => { + test.describe.configure({ retries: 2 }) + test.use({ hasTouch: true }) + + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_042 P0 - desktop 3-column grid + test('TC_SEARCH_INTERACT_042: desktop viewport shows 3-column card grid', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + const grid = page.locator('[class*="grid"]').first() + await expect(grid).toBeVisible() + }) + + // TC_SEARCH_INTERACT_044 P0 - mobile 1-column layout + test('TC_SEARCH_INTERACT_044: mobile viewport shows single-column card layout', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const cards = await waitForCards(page) + await expect(cards.first()).toBeVisible({ timeout: 8_000 }) + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) + + // TC_SEARCH_INTERACT_043 P0 - tablet 2-column layout + test('TC_SEARCH_INTERACT_043: tablet viewport shows 2-column card layout', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const cards = await waitForCards(page) + await expect(cards.first()).toBeVisible({ timeout: 8_000 }) + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) + + // TC_SEARCH_INTERACT_045 P1 - responsive layout adjusts on resize + test('TC_SEARCH_INTERACT_045: card layout adjusts when browser window is resized', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + + await page.setViewportSize({ width: 375, height: 812 }) + await expect(getSearchCards(page).first()).toBeVisible() + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) + + // TC_SEARCH_INTERACT_046 P0 - mobile touch interaction + test('TC_SEARCH_INTERACT_046: mobile touch on card navigates to skill detail', async ({ page }, testInfo) => { + const current = latestSeed(basicSeed!) + await registerSession(page, testInfo) + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + await firstCard.tap() + await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}`)) + }) +}) + +// โ”€โ”€โ”€ Keyboard Navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Card Keyboard Navigation (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_049 P1 - Tab key navigates between cards + test('TC_SEARCH_INTERACT_049: Tab key can navigate between skill cards', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + + await page.keyboard.press('Tab') + await page.keyboard.press('Tab') + const focused = page.locator(':focus') + await expect(focused).toBeVisible() + }) + + // TC_SEARCH_INTERACT_050 P1 - Enter key opens focused card + test('TC_SEARCH_INTERACT_050: pressing Enter on a focused card opens the skill detail', async ({ page }, testInfo) => { + const current = latestSeed(basicSeed!) + await registerSession(page, testInfo) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + + await firstCard.focus() + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/\/space\//) + }) + + // TC_SEARCH_INTERACT_051 P1 - focus state visible on cards + test('TC_SEARCH_INTERACT_051: focused card has a visible focus indicator', async ({ page }) => { + const current = latestSeed(basicSeed!) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + await firstCard.focus() + const focused = page.locator(':focus') + await expect(focused).toBeVisible() + }) +}) + +// โ”€โ”€โ”€ Error Handling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Card Error Handling (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_033 P1 - single result displays correctly + test('TC_SEARCH_INTERACT_033: single search result displays card layout correctly', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const cards = await waitForCards(page) + expect(await cards.count()).toBeGreaterThan(0) + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) + + // TC_SEARCH_INTERACT_060 P1 - cache: returning to search page shows results quickly + test('TC_SEARCH_INTERACT_060: returning to search page shows cached results quickly', async ({ page }, testInfo) => { + await registerSession(page, testInfo) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + + await page.goto('/dashboard') + await page.goBack() + await expect(page).toHaveURL(/\/search/) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + }) +}) diff --git a/web/e2e/search-page-full.spec.ts b/web/e2e/search-page-full.spec.ts new file mode 100644 index 00000000..f5775bfb --- /dev/null +++ b/web/e2e/search-page-full.spec.ts @@ -0,0 +1,324 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { + DEFAULT_SEARCH_KEYWORD, + getSearchCards, + prepareSearchSeed, + type PreparedSearchSeed, +} from './helpers/search-seed' +import { registerSession } from './helpers/session' + +function searchUrl(query: string, sort = 'relevance', page = 0, starredOnly = false) { + return `/search?q=${encodeURIComponent(query)}&sort=${sort}&page=${page}&starredOnly=${starredOnly}` +} + +let basicSeed: PreparedSearchSeed | undefined + +test.setTimeout(300_000) + +test.beforeAll(async ({ browser }, testInfo) => { + test.setTimeout(300_000) + basicSeed = await prepareSearchSeed(browser, testInfo, { count: 13 }) +}) + +test.afterAll(async () => { + await basicSeed?.dispose() + basicSeed = undefined +}) + +// โ”€โ”€โ”€ Search Input โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Input (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INPUT_001 P0 + test('TC_SEARCH_INPUT_001: searches with a single keyword and shows results', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 }) + }) + + // TC_SEARCH_INPUT_003 P0 - empty search guidance + test('TC_SEARCH_INPUT_003: empty search shows keyword guidance instead of a default list', async ({ page }) => { + await page.goto(searchUrl('')) + await expect(page).toHaveURL(/\/search/) + await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible() + await expect(page.getByText('Please enter a search keyword')).toBeVisible() + }) + + // TC_SEARCH_INPUT_004 P0 - Enter key triggers search + test('TC_SEARCH_INPUT_004: pressing Enter in search box triggers search', async ({ page }) => { + await page.goto(searchUrl('')) + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill(basicSeed!.keyword) + await searchInput.press('Enter') + await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`)) + await expect(getSearchCards(page).first()).toBeVisible() + }) + + // TC_SEARCH_INPUT_009 P0 - Chinese keyword search + test('TC_SEARCH_INPUT_009: supports Chinese keyword search without error', async ({ page }) => { + await page.goto(searchUrl('ๆต‹่ฏ•ๆŠ€่ƒฝ')) + await expect(page).toHaveURL(/\/search/) + await expect(page.locator('body')).not.toContainText(/error|500|crash/i) + }) + + // TC_SEARCH_INPUT_010 P0 - English keyword search + test('TC_SEARCH_INPUT_010: supports English keyword search', async ({ page }) => { + await page.goto(searchUrl('skill')) + await expect(page).toHaveURL(/q=skill/) + await expect(page.locator('body')).not.toContainText(/error|500|crash/i) + }) + + // TC_SEARCH_INPUT_007 P1 - special characters handled gracefully + test('TC_SEARCH_INPUT_007: handles special characters in search without crashing', async ({ page }) => { + await page.goto(searchUrl('@#$%')) + await expect(page).toHaveURL(/\/search/) + await expect(page.locator('body')).not.toContainText(/error|500|crash/i) + }) + + // TC_SEARCH_INPUT_011 P1 - leading/trailing spaces trimmed + test('TC_SEARCH_INPUT_011: trims leading and trailing spaces from search query', async ({ page }) => { + await page.goto(searchUrl('')) + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill(` ${basicSeed!.keyword} `) + await searchInput.press('Enter') + await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`)) + await expect(getSearchCards(page).first()).toBeVisible() + }) +}) + +// โ”€โ”€โ”€ Sort / Filter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Sort and Filter (Authenticated Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + // TC_SEARCH_SORT_001 P0 - default relevance tab selected + test('TC_SEARCH_SORT_001: relevance sort tab is selected by default', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance')) + await expect(page.getByRole('button', { name: 'Relevance' })).toBeVisible() + }) + + // TC_SEARCH_SORT_004 P0 - downloads sort + test('TC_SEARCH_SORT_004: clicking Downloads tab updates sort in URL', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance')) + await page.getByRole('button', { name: 'Downloads' }).click() + await expect(page).toHaveURL(/sort=downloads/) + }) + + // TC_SEARCH_SORT_005 P0 - newest sort + test('TC_SEARCH_SORT_005: clicking Newest tab updates sort in URL', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance')) + await page.getByRole('button', { name: 'Newest' }).click() + await expect(page).toHaveURL(/sort=newest|sort=created/) + }) + + // TC_SEARCH_SORT_006 P0 - switching sort preserves search keyword + test('TC_SEARCH_SORT_006: switching sort tab preserves the search keyword', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance')) + await page.getByRole('button', { name: 'Downloads' }).click() + await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`)) + await expect(page).toHaveURL(/sort=downloads/) + }) + + // TC_SEARCH_SORT_007 P0 - switching sort resets page to 0 + test('TC_SEARCH_SORT_007: switching sort tab resets page to 0', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 1)) + await page.getByRole('button', { name: 'Downloads' }).click() + await expect(page).toHaveURL(/page=0/) + }) + + // TC_SEARCH_SORT_012 P1 - URL contains sort param + test('TC_SEARCH_SORT_012: URL contains sort parameter after switching tabs', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance')) + await page.getByRole('button', { name: 'Downloads' }).click() + await expect(page).toHaveURL(/sort=/) + }) + + test('starred only filter stays on search page for authenticated user', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance')) + await page.getByRole('button', { name: 'Starred only' }).click() + await expect(page).toHaveURL(/starredOnly=true/) + await expect(page).not.toHaveURL(/\/login/) + }) +}) + +test.describe('Search Sort and Filter (Anonymous Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('starred only filter redirects anonymous user to login', async ({ page }) => { + await page.goto(searchUrl(DEFAULT_SEARCH_KEYWORD, 'relevance')) + await page.getByRole('button', { name: 'Starred only' }).click() + await expect(page).toHaveURL(/\/login\?returnTo=/) + }) +}) + +// โ”€โ”€โ”€ Skill Count โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Skill Count Display (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_COUNT_001 P0 - count visible + test('TC_SEARCH_COUNT_001: skill count indicator is visible on search page with results', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword)) + await expect(page.getByText(/\d+\s+skills found/i)).toBeVisible({ timeout: 10_000 }) + }) + + // TC_SEARCH_COUNT_007 P0 - count updates after search + test('TC_SEARCH_COUNT_007: skill count updates after performing a search', async ({ page }) => { + await page.goto(searchUrl('')) + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill(basicSeed!.keyword) + await searchInput.press('Enter') + await expect(page.getByText(/\d+\s+skills found/i)).toBeVisible({ timeout: 10_000 }) + }) + + // TC_SEARCH_COUNT_009 P0 - zero results shows 0 + test('TC_SEARCH_COUNT_009: shows empty-state copy when search returns no results', async ({ page }) => { + await page.goto(searchUrl('xyznonexistentkeyword99999')) + await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_COUNT_008 P0 - count stays same when switching sort + test('TC_SEARCH_COUNT_008: skill count remains the same after switching sort tab', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword)) + const countText = await page.getByText(/\d+\s+skills found/i).textContent() + const initialCount = Number(countText?.match(/\d+/)?.[0] ?? '0') + await page.getByRole('button', { name: 'Downloads' }).click() + const updatedCountText = await page.getByText(/\d+\s+skills found/i).textContent() + const updatedCount = Number(updatedCountText?.match(/\d+/)?.[0] ?? '0') + expect(updatedCount).toBe(initialCount) + }) +}) + +// โ”€โ”€โ”€ Search Results โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Results (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_RESULT_001 P0 - results shown + test('TC_SEARCH_RESULT_001: shows skill cards when search returns results', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 }) + }) + + // TC_SEARCH_RESULT_002 P0 - no results message + test('TC_SEARCH_RESULT_002: shows empty state message when no results found', async ({ page }) => { + await page.goto(searchUrl('xyznonexistentkeyword99999')) + await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_RESULT_006 P0 - loading state + test('TC_SEARCH_RESULT_006: page renders without error during and after search', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword)) + await expect(page.locator('body')).not.toContainText(/error|500|crash/i) + }) + + // TC_SEARCH_RESULT_008 P0 - result count matches cards + test('TC_SEARCH_RESULT_008: number of displayed cards matches the count indicator', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword)) + await page.waitForLoadState('networkidle') + const cards = getSearchCards(page) + const visibleCount = await cards.count() + const countText = await page.getByText(/\d+\s+skills found/i).textContent() + const totalMatch = countText?.match(/\d+/) + expect(totalMatch).toBeTruthy() + expect(visibleCount).toBeGreaterThan(0) + expect(Number(totalMatch?.[0])).toBeGreaterThanOrEqual(visibleCount) + }) + + // TC_SEARCH_RESULT_009 P0 - downloads sort order + test('TC_SEARCH_RESULT_009: results are sorted by downloads when Downloads tab is selected', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'downloads')) + await expect(page).toHaveURL(/sort=downloads/) + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) + + // TC_SEARCH_RESULT_010 P0 - newest sort order + test('TC_SEARCH_RESULT_010: results are sorted by newest when Newest tab is selected', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'newest')) + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) +}) + +// โ”€โ”€โ”€ Pagination โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Pagination (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_PAGE_011 P1 - URL contains page param + test('TC_SEARCH_PAGE_011: URL contains page parameter', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 0)) + await expect(page).toHaveURL(/page=/) + }) + + // TC_SEARCH_PAGE_012 P0 - switching page preserves search and sort + test('TC_SEARCH_PAGE_012: switching page preserves search keyword and sort', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'downloads', 0)) + const nextBtn = page.getByRole('button', { name: /next|โ€บ|ยป/i }) + await expect(nextBtn).toBeVisible({ timeout: 10_000 }) + await nextBtn.click() + await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`)) + await expect(page).toHaveURL(/sort=downloads/) + await expect(page).toHaveURL(/page=1/) + }) + + // TC_SEARCH_PAGE_007 P0 - first page disables previous button + test('TC_SEARCH_PAGE_007: previous page button is disabled on first page', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 0)) + const prevBtn = page.getByRole('button', { name: /prev|โ€น|ยซ/i }) + if (await prevBtn.isVisible()) { + await expect(prevBtn).toBeDisabled() + } + }) +}) + +// โ”€โ”€โ”€ Security โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test.describe('Search Security (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_SEC_001 P0 - XSS in search box + test('TC_SEARCH_SEC_001: XSS payload in search box is not executed', async ({ page }) => { + let alerted = false + page.on('dialog', () => { alerted = true }) + + await page.goto(searchUrl('')) + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill("") + await searchInput.press('Enter') + + await page.waitForTimeout(1_000) + expect(alerted).toBe(false) + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) + + // TC_SEARCH_SEC_002 P0 - SQL injection in search box + test('TC_SEARCH_SEC_002: SQL injection payload in search box is handled safely', async ({ page }) => { + await page.goto(searchUrl("' OR '1'='1")) + await expect(page.locator('body')).not.toContainText(/sql|syntax error|database/i) + await expect(page).toHaveURL(/\/search/) + }) + + // TC_SEARCH_SEC_003 P1 - URL param tampering + test('TC_SEARCH_SEC_003: tampered URL parameters are handled gracefully', async ({ page }, testInfo) => { + await registerSession(page, testInfo) + await page.goto('/search?q=agent&sort=INVALID_SORT&page=-1&starredOnly=invalid') + await expect(page).toHaveURL(/\/search/) + await expect(page.locator('body')).not.toContainText(/error|500|crash/i) + }) +}) diff --git a/web/playwright.config.ts b/web/playwright.config.ts index b7206587..00b74867 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ timeout: process.env.CI ? 90_000 : 45_000, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : 2, + workers: Number(process.env.PLAYWRIGHT_WORKERS ?? 1), reporter: 'html', use: { baseURL: 'http://localhost:3000', diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 2e2df9ab..5da2df9d 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3037,6 +3037,8 @@ export interface components { /** Format: int64 */ namespaceId?: number; userId?: string; + displayName?: string; + email?: string; /** @enum {string} */ role?: "OWNER" | "ADMIN" | "MEMBER"; /** Format: date-time */ diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 07f861a4..61c9fa46 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -116,6 +116,8 @@ export interface ManagedNamespace extends Namespace { export interface NamespaceMember { id: number userId: string + displayName?: string + email?: string role: NamespaceRole createdAt: string } diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index fa0c2980..ff8fcd1b 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -21,59 +21,72 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard const { data: starStatus } = useStar(skill.id, highlightStarred && isAuthenticated) const showStarredHighlight = highlightStarred && isAuthenticated && starStatus?.starred const headlineVersion = getHeadlineVersion(skill) + const isInteractive = typeof onClick === 'function' return ( -
-
-
-

- {skill.displayName} -

-
-
- -
+ className="h-full p-5 cursor-pointer group relative overflow-hidden bg-white border shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2" + style={{ borderColor: 'hsl(var(--border-card))' }} + onClick={onClick} + onKeyDown={(event) => { + if (!isInteractive) { + return + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onClick() + } + }} + role={isInteractive ? 'link' : undefined} + tabIndex={isInteractive ? 0 : undefined} + > +
+
+
+

+ {skill.displayName} +

- - {skill.summary && ( -

- {skill.summary} -

- )} - -
- {headlineVersion && ( - - v{headlineVersion.version} - - )} - - - - - {formatCompactCount(skill.downloadCount)} - - - - {skill.starCount} - - {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( - - - - - {skill.ratingAvg.toFixed(1)} - - )} +
+
- + + {skill.summary && ( +

+ {skill.summary} +

+ )} + +
+ {headlineVersion && ( + + v{headlineVersion.version} + + )} + + + + + {formatCompactCount(skill.downloadCount)} + + + + {skill.starCount} + + {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( + + + + + {skill.ratingAvg.toFixed(1)} + + )} +
+
+ ) } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b1e35669..1f88708e 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -234,6 +234,14 @@ "usernamePlaceholder": "3-64 characters: letters, numbers, or underscores", "emailPlaceholder": "Optional, for account identification", "passwordPlaceholder": "At least 8 characters with 3 character types", + "usernameRequired": "Username is required", + "usernameInvalid": "Username must be 3-64 characters and contain only letters, numbers, or underscores", + "emailInvalid": "Email format is invalid", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least 8 characters", + "passwordTooWeak": "Password must include at least 3 character types", + "usernameExists": "Username already exists", + "emailExists": "Email already exists", "submitting": "Registering...", "submit": "Register & Login", "hasAccount": "Already have an account?", @@ -983,6 +991,8 @@ "savingRole": "Saving...", "changeRole": "Change role", "colUserId": "User ID", + "colUsername": "Username", + "colEmail": "Email", "colRole": "Role", "colJoinedAt": "Joined At", "colActions": "Actions", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 41972c55..92920e8f 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -234,6 +234,14 @@ "usernamePlaceholder": "3-64 ไฝๅญ—ๆฏใ€ๆ•ฐๅญ—ๆˆ–ไธ‹ๅˆ’็บฟ", "emailPlaceholder": "ๅฏ้€‰๏ผŒ็”จไบŽๅŽ็ปญ่ดฆๅท่ฏ†ๅˆซ", "passwordPlaceholder": "่‡ณๅฐ‘ 8 ไฝ๏ผŒๅŒ…ๅซ 3 ็งๅญ—็ฌฆ็ฑปๅž‹", + "usernameRequired": "่ฏท่พ“ๅ…ฅ็”จๆˆทๅ", + "usernameInvalid": "็”จๆˆทๅ้œ€ไธบ 3-64 ไฝ๏ผŒไธ”ๅช่ƒฝๅŒ…ๅซๅญ—ๆฏใ€ๆ•ฐๅญ—ๆˆ–ไธ‹ๅˆ’็บฟ", + "emailInvalid": "้‚ฎ็ฎฑๆ ผๅผไธๆญฃ็กฎ", + "passwordRequired": "่ฏท่พ“ๅ…ฅๅฏ†็ ", + "passwordTooShort": "ๅฏ†็ ่‡ณๅฐ‘้œ€่ฆ 8 ไฝ", + "passwordTooWeak": "ๅฏ†็ ่‡ณๅฐ‘้œ€่ฆๅŒ…ๅซ 3 ็งๅญ—็ฌฆ็ฑปๅž‹", + "usernameExists": "็”จๆˆทๅๅทฒๅญ˜ๅœจ", + "emailExists": "้‚ฎ็ฎฑๅทฒๅญ˜ๅœจ", "submitting": "ๆณจๅ†Œไธญ...", "submit": "ๆณจๅ†Œๅนถ็™ปๅฝ•", "hasAccount": "ๅทฒๆœ‰่ดฆๅท๏ผŸ", @@ -983,6 +991,8 @@ "savingRole": "ไฟๅญ˜ไธญ...", "changeRole": "ไฟฎๆ”น่ง’่‰ฒ", "colUserId": "็”จๆˆท ID", + "colUsername": "็”จๆˆทๅ", + "colEmail": "้‚ฎ็ฎฑ", "colRole": "่ง’่‰ฒ", "colJoinedAt": "ๅŠ ๅ…ฅๆ—ถ้—ด", "colActions": "ๆ“ไฝœ", diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index 76504b38..88adb53d 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -178,7 +178,8 @@ export function NamespaceMembersPage() { - + + @@ -193,7 +194,13 @@ export function NamespaceMembersPage() { return ( - + +
{t('members.colUserId')}{t('members.colUsername')}{t('members.colEmail')} {t('members.colRole')} {t('members.colJoinedAt')} {t('members.colActions')}
{member.userId} +
+ {member.displayName || member.userId} + {member.userId} +
+
{member.email || '-'} {canManageMembers && !isOwner ? (
diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index 93d67803..de6b8cf6 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -1,6 +1,7 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { ApiError } from '@/api/client' import { LoginButton } from '@/features/auth/login-button' import { useLocalRegister } from '@/features/auth/use-local-auth' import { Button } from '@/shared/ui/button' @@ -8,6 +9,44 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/sha import { Input } from '@/shared/ui/input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,64}$/ +const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/ + +type RegisterFieldErrors = { + username?: string + email?: string + password?: string +} + +function countPasswordCharacterTypes(password: string) { + let typeCount = 0 + if (/[a-z]/.test(password)) { + typeCount += 1 + } + if (/[A-Z]/.test(password)) { + typeCount += 1 + } + if (/\d/.test(password)) { + typeCount += 1 + } + if (/[^A-Za-z0-9]/.test(password)) { + typeCount += 1 + } + return typeCount +} + +function isDuplicateUsernameError(errorKey: string) { + return errorKey === 'error.auth.local.username.exists' + || errorKey.includes('Username already exists') + || errorKey.includes('็”จๆˆทๅๅทฒๅญ˜ๅœจ') +} + +function isDuplicateEmailError(errorKey: string) { + return errorKey === 'error.auth.local.email.exists' + || errorKey.includes('Email already exists') + || errorKey.includes('้‚ฎ็ฎฑๅทฒๅญ˜ๅœจ') +} + /** * Registration page for local accounts with an alternate OAuth-based entry path. */ @@ -19,16 +58,109 @@ export function RegisterPage() { const [username, setUsername] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [fieldErrors, setFieldErrors] = useState({}) + const [formError, setFormError] = useState(null) const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' + function validateUsername(value: string) { + const trimmed = value.trim() + if (!trimmed) { + return t('register.usernameRequired') + } + if (!USERNAME_PATTERN.test(trimmed)) { + return t('register.usernameInvalid') + } + return undefined + } + + function validateEmail(value: string) { + const trimmed = value.trim().toLowerCase() + if (!trimmed) { + return undefined + } + if (!EMAIL_PATTERN.test(trimmed)) { + return t('register.emailInvalid') + } + return undefined + } + + function validatePassword(value: string) { + if (!value) { + return t('register.passwordRequired') + } + if (value.length < 8) { + return t('register.passwordTooShort') + } + if (countPasswordCharacterTypes(value) < 3) { + return t('register.passwordTooWeak') + } + return undefined + } + + function mapRegisterApiError(error: unknown): { fieldErrors?: RegisterFieldErrors, formError?: string } { + if (!(error instanceof ApiError)) { + return { + formError: error instanceof Error ? error.message : t('apiError.unknown'), + } + } + + const errorKey = error.serverMessageKey ?? error.serverMessage ?? error.message + + switch (errorKey) { + case 'validation.auth.local.username.notBlank': + return { fieldErrors: { username: t('register.usernameRequired') } } + case 'validation.auth.local.password.notBlank': + return { fieldErrors: { password: t('register.passwordRequired') } } + case 'validation.auth.local.email.invalid': + return { fieldErrors: { email: t('register.emailInvalid') } } + case 'error.auth.local.username.invalid': + return { fieldErrors: { username: t('register.usernameInvalid') } } + case 'error.auth.local.password.tooShort': + return { fieldErrors: { password: t('register.passwordTooShort') } } + case 'error.auth.local.password.tooWeak': + return { fieldErrors: { password: t('register.passwordTooWeak') } } + case 'error.auth.local.username.exists': + return { fieldErrors: { username: t('register.usernameExists') } } + case 'error.auth.local.email.exists': + return { fieldErrors: { email: t('register.emailExists') } } + default: + if (isDuplicateUsernameError(errorKey)) { + return { fieldErrors: { username: t('register.usernameExists') } } + } + if (isDuplicateEmailError(errorKey)) { + return { fieldErrors: { email: t('register.emailExists') } } + } + return { formError: error.serverMessage || error.message || t('apiError.unknown') } + } + } + async function handleSubmit(event: React.FormEvent) { event.preventDefault() + const trimmedUsername = username.trim() + const trimmedEmail = email.trim().toLowerCase() + const nextFieldErrors: RegisterFieldErrors = {} + + nextFieldErrors.username = validateUsername(username) + nextFieldErrors.email = validateEmail(email) + nextFieldErrors.password = validatePassword(password) + + if (nextFieldErrors.username || nextFieldErrors.email || nextFieldErrors.password) { + setFieldErrors(nextFieldErrors) + setFormError(null) + registerMutation.reset() + return + } + + setFieldErrors({}) + setFormError(null) try { - await registerMutation.mutateAsync({ username, email, password }) + await registerMutation.mutateAsync({ username: trimmedUsername, email: trimmedEmail, password }) await navigate({ to: returnTo }) - } catch { - // mutation state drives the error UI + } catch (error) { + const { fieldErrors: nextApiFieldErrors, formError: nextFormError } = mapRegisterApiError(error) + setFieldErrors(nextApiFieldErrors ?? {}) + setFormError(nextFormError ?? null) } } @@ -54,9 +186,21 @@ export function RegisterPage() { id="register-username" autoComplete="username" value={username} - onChange={(event) => setUsername(event.target.value)} + onChange={(event) => { + setUsername(event.target.value) + if (fieldErrors.username || formError) { + setFieldErrors((current) => ({ ...current, username: undefined })) + setFormError(null) + registerMutation.reset() + } + }} placeholder={t('register.usernamePlaceholder')} + aria-invalid={fieldErrors.username ? 'true' : 'false'} + onBlur={() => { + setFieldErrors((current) => ({ ...current, username: validateUsername(username) })) + }} /> + {fieldErrors.username ?

{fieldErrors.username}

: null}
@@ -65,9 +209,21 @@ export function RegisterPage() { type="email" autoComplete="email" value={email} - onChange={(event) => setEmail(event.target.value)} + onChange={(event) => { + setEmail(event.target.value) + if (fieldErrors.email || formError) { + setFieldErrors((current) => ({ ...current, email: undefined })) + setFormError(null) + registerMutation.reset() + } + }} placeholder={t('register.emailPlaceholder')} + aria-invalid={fieldErrors.email ? 'true' : 'false'} + onBlur={() => { + setFieldErrors((current) => ({ ...current, email: validateEmail(email) })) + }} /> + {fieldErrors.email ?

{fieldErrors.email}

: null}
@@ -76,13 +232,23 @@ export function RegisterPage() { type="password" autoComplete="new-password" value={password} - onChange={(event) => setPassword(event.target.value)} + onChange={(event) => { + setPassword(event.target.value) + if (fieldErrors.password || formError) { + setFieldErrors((current) => ({ ...current, password: undefined })) + setFormError(null) + registerMutation.reset() + } + }} placeholder={t('register.passwordPlaceholder')} + aria-invalid={fieldErrors.password ? 'true' : 'false'} + onBlur={() => { + setFieldErrors((current) => ({ ...current, password: validatePassword(password) })) + }} /> + {fieldErrors.password ?

{fieldErrors.password}

: null}
- {registerMutation.error ? ( -

{registerMutation.error.message}

- ) : null} + {formError ?

{formError}

: null} diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 58137415..6258ca19 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -1,4 +1,4 @@ -import { startTransition, useEffect, useState } from 'react' +import { startTransition, useEffect, useRef, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Loader2 } from 'lucide-react' @@ -18,6 +18,37 @@ import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' const PAGE_SIZE = 12 +function blurActiveElement() { + if (typeof document === 'undefined' || typeof HTMLElement === 'undefined') { + return + } + + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } +} + +function scrollToTopOnPageChange() { + if (typeof window === 'undefined') { + return () => {} + } + + let secondFrame = 0 + const firstFrame = window.requestAnimationFrame(() => { + window.scrollTo({ top: 0, behavior: 'auto' }) + secondFrame = window.requestAnimationFrame(() => { + window.scrollTo({ top: 0, behavior: 'auto' }) + }) + }) + + return () => { + window.cancelAnimationFrame(firstFrame) + if (secondFrame) { + window.cancelAnimationFrame(secondFrame) + } + } +} + /** * Skill discovery page with synchronized URL state. * @@ -60,11 +91,26 @@ export function SearchPage() { const page = searchParams.page ?? 0 const starredOnly = searchParams.starredOnly ?? false const [queryInput, setQueryInput] = useState(q) + const previousPageRef = useRef(page) useEffect(() => { setQueryInput(q) }, [q]) + useEffect(() => { + if (previousPageRef.current !== page) { + blurActiveElement() + const cleanupScroll = scrollToTopOnPageChange() + + previousPageRef.current = page + return () => { + cleanupScroll() + } + } + + previousPageRef.current = page + }, [page]) + const { data, isLoading, isFetching } = useSearchSkills({ q, label: selectedLabel || undefined, @@ -79,6 +125,7 @@ export function SearchPage() { isLoading: isLoadingStarred, isFetching: isFetchingStarred, } = useMyStars(starredOnly && isAuthenticated) + const shouldShowGuidance = !starredOnly && !q && !selectedLabel useEffect(() => { // Debounce URL updates while the user is typing so query state stays shareable without @@ -117,6 +164,7 @@ export function SearchPage() { } const handlePageChange = (newPage: number) => { + blurActiveElement() navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: newPage, starredOnly } }) } @@ -154,10 +202,10 @@ export function SearchPage() { : data ? Math.ceil(data.total / data.size) : 0 - const displayItems = starredOnly ? starredPageItems : (data?.items ?? []) - const isPageLoading = starredOnly ? isLoadingStarred : isLoading - const isUpdatingResults = starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading - const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0) + const displayItems = shouldShowGuidance ? [] : (starredOnly ? starredPageItems : (data?.items ?? [])) + const isPageLoading = shouldShowGuidance ? false : (starredOnly ? isLoadingStarred : isLoading) + const isUpdatingResults = shouldShowGuidance ? false : (starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading) + const resultCount = shouldShowGuidance ? 0 : (starredOnly ? filteredStarredSkills.length : (data?.total ?? 0)) return (
@@ -265,7 +313,9 @@ export function SearchPage() { searchSkills(params), - enabled: params.starredOnly !== true, + enabled: params.starredOnly !== true && Boolean(params.q || params.label), }) }