From 557bde93309e3742c33a2ec43abe7ea130393550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=BA=E5=8F=A3=E3=83=AC=E3=82=A4?= Date: Tue, 3 Jun 2025 11:58:46 +0200 Subject: [PATCH 1/7] uwu --- .github/workflows/pr-review.yml | 42 +++ .../curated/games/Genshin Impact.jsonc | 9 + DeadForgeAssets/curated/games/HI3.jsonc | 9 + DeadForgeAssets/curated/games/HSR.jsonc | 9 + scripts/issuesMissingAssets/reportHandler.ts | 8 +- scripts/prReview/index.ts | 291 ++++++++++++++++++ scripts/prReview/types.ts | 25 ++ 7 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/pr-review.yml create mode 100644 DeadForgeAssets/curated/games/Genshin Impact.jsonc create mode 100644 DeadForgeAssets/curated/games/HI3.jsonc create mode 100644 DeadForgeAssets/curated/games/HSR.jsonc create mode 100644 scripts/prReview/index.ts create mode 100644 scripts/prReview/types.ts diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml new file mode 100644 index 0000000..f063821 --- /dev/null +++ b/.github/workflows/pr-review.yml @@ -0,0 +1,42 @@ +name: PR Review + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'DeadForgeAssets/**' + +jobs: + review-pr: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Download Images and Update Hashes + id: download + run: bun run predeploy + + - name: Check for download failures + run: | + if [ -f "download_failures.txt" ]; then + echo "::error::Some images failed to download. See download_failures.txt for details." + cat download_failures.txt + echo "failed=true" >> $GITHUB_OUTPUT + fi + + - name: Run PR Review + run: bun run scripts/prReview/index.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SERVICE_BOT_PAT: ${{ secrets.SERVICE_BOT_PAT }} + PR_NUMBER: ${{ github.event.pull_request.number }} \ No newline at end of file diff --git a/DeadForgeAssets/curated/games/Genshin Impact.jsonc b/DeadForgeAssets/curated/games/Genshin Impact.jsonc new file mode 100644 index 0000000..1a5c616 --- /dev/null +++ b/DeadForgeAssets/curated/games/Genshin Impact.jsonc @@ -0,0 +1,9 @@ +{ + "matches": [ + { + "source": "epic", + "id": "41869934302e4b8cafac2d3c0e7c293d" + } + ], + "media": {} +} \ No newline at end of file diff --git a/DeadForgeAssets/curated/games/HI3.jsonc b/DeadForgeAssets/curated/games/HI3.jsonc new file mode 100644 index 0000000..4af8267 --- /dev/null +++ b/DeadForgeAssets/curated/games/HI3.jsonc @@ -0,0 +1,9 @@ +{ + "matches": [ + { + "source": "epic", + "id": "0dc22b543a40440fab5a98d1e40c02c1" + } + ], + "media": {} +} \ No newline at end of file diff --git a/DeadForgeAssets/curated/games/HSR.jsonc b/DeadForgeAssets/curated/games/HSR.jsonc new file mode 100644 index 0000000..23bfa8b --- /dev/null +++ b/DeadForgeAssets/curated/games/HSR.jsonc @@ -0,0 +1,9 @@ +{ + "matches": [ + { + "source": "epic", + "id": "86ae9acccf8443e18fca6950e0181288" + } + ], + "media": {} +} \ No newline at end of file diff --git a/scripts/issuesMissingAssets/reportHandler.ts b/scripts/issuesMissingAssets/reportHandler.ts index 9d22767..579ad1e 100644 --- a/scripts/issuesMissingAssets/reportHandler.ts +++ b/scripts/issuesMissingAssets/reportHandler.ts @@ -18,23 +18,23 @@ ${REPORT_END_TAG} \`\`\` This will automatically update the report and reopen the issue if there are new missing assets. Thank you for your contribution, and helping us stay organized! ^^`; -export async function addMissingAssetsLabel(context: GitHubContext, issueNumber: number): Promise { +export async function addMissingAssetsLabel(context: GitHubContext, issueOrPullRequestNumber: number): Promise { await context.octokit.issues.addAssignees({ owner: context.owner, repo: context.repo, - issue_number: issueNumber, + issue_number: issueOrPullRequestNumber, assignees: DEFAULT_ASSIGNEES }); await context.octokit.issues.addLabels({ owner: context.owner, repo: context.repo, - issue_number: issueNumber, + issue_number: issueOrPullRequestNumber, labels: [MISSING_ASSETS_LABEL] }); await context.octokit.request('PATCH /repos/{owner}/{repo}/issues/{issue_number}', { owner: context.owner, repo: context.repo, - issue_number: issueNumber, + issue_number: issueOrPullRequestNumber, type: "Assets" }); } diff --git a/scripts/prReview/index.ts b/scripts/prReview/index.ts new file mode 100644 index 0000000..c2ede1f --- /dev/null +++ b/scripts/prReview/index.ts @@ -0,0 +1,291 @@ +import { Octokit } from '@octokit/rest'; +import { parse } from 'jsonc-parser'; +import * as fs from 'fs/promises'; +import { GitHubContext, PR_REVIEW_COMMENT_IDENTIFIER, AssetChange, ReviewResult } from './types'; +import { addMissingAssetsLabel } from '../issuesMissingAssets/reportHandler'; + +const ASSETS_DIR = 'DeadForgeAssets'; +const GAMES_DIR = 'DeadForgeAssets/curated/games'; +const DOWNLOAD_FAILURES_FILE = 'download_failures.txt'; + +async function getAssetChanges(context: GitHubContext): Promise { + const { data: files } = await context.octokit.pulls.listFiles({ + owner: context.owner, + repo: context.repo, + pull_number: context.prNumber, + }); + + const changes: AssetChange[] = []; + + for (const file of files) { + if (!file.filename.startsWith(GAMES_DIR) || !file.filename.endsWith('.jsonc')) continue; + + // Get the content of the changed file in the PR + const { data: content } = await context.octokit.repos.getContent({ + owner: context.owner, + repo: context.repo, + path: file.filename, + ref: `refs/pull/${context.prNumber}/head` + }); + + if ('content' in content) { + const decodedContent = Buffer.from(content.content, 'base64').toString(); + const newGameData = parse(decodedContent); + + try { + // Try to get the base branch content + const { data: baseContent } = await context.octokit.repos.getContent({ + owner: context.owner, + repo: context.repo, + path: file.filename, + ref: 'main' + }); + + if ('content' in baseContent) { + // File exists in base branch, compare changes + const baseDecodedContent = Buffer.from(baseContent.content, 'base64').toString(); + const baseGameData = parse(baseDecodedContent); + + // Compare media entries + if (newGameData.media) { + for (const [mediaType, urlEntry] of Object.entries(newGameData.media)) { + const baseUrlEntry = baseGameData.media?.[mediaType]; + if (!baseUrlEntry) { + // New media type added + changes.push({ + type: 'added', + path: file.filename, + mediaType, + url: urlEntry as any, + hash: (newGameData.media as any)[mediaType].hash + }); + } else if (JSON.stringify(baseUrlEntry) !== JSON.stringify(urlEntry)) { + // Media type modified + changes.push({ + type: 'modified', + path: file.filename, + mediaType, + url: urlEntry as any, + hash: (newGameData.media as any)[mediaType].hash + }); + } + } + + // Check for removed media types + if (baseGameData.media) { + for (const [mediaType, urlEntry] of Object.entries(baseGameData.media)) { + if (!newGameData.media[mediaType]) { + changes.push({ + type: 'removed', + path: file.filename, + mediaType, + url: urlEntry as any, + hash: (baseGameData.media as any)[mediaType].hash + }); + } + } + } + } + } else { + // New file added + if (newGameData.media) { + for (const [mediaType, urlEntry] of Object.entries(newGameData.media)) { + changes.push({ + type: 'added', + path: file.filename, + mediaType, + url: urlEntry as any, + hash: (newGameData.media as any)[mediaType].hash + }); + } + } + } + } catch (error) { + // File doesn't exist in base branch, treat all as new + if (newGameData.media) { + for (const [mediaType, urlEntry] of Object.entries(newGameData.media)) { + changes.push({ + type: 'added', + path: file.filename, + mediaType, + url: urlEntry as any, + hash: (newGameData.media as any)[mediaType].hash + }); + } + } + } + } + } + + return changes; +} + +async function generateReviewComment(result: ReviewResult): Promise { + let comment = `\n# PR Asset Review Report 🎮\n\n`; + + if (result.changes.length === 0) { + comment += '## No asset changes detected\n\n'; + return comment; + } + + comment += '## Asset Changes\n\n'; + + const changeTypes = { + added: '➕ Added', + modified: '🔄 Modified', + removed: '❌ Removed' + }; + + for (const type of ['added', 'modified', 'removed'] as const) { + const typeChanges = result.changes.filter(c => c.type === type); + if (typeChanges.length > 0) { + comment += `### ${changeTypes[type]}\n\n`; + for (const change of typeChanges) { + comment += `- **${change.mediaType}** in \`${change.path}\`\n`; + if (typeof change.url === 'string') { + comment += ` - URL: ${change.url}\n`; + if (change.hash) comment += ` - Hash: ${change.hash}\n`; + } else { + comment += ' - Localized URLs:\n'; + for (const [lang, url] of Object.entries(change.url)) { + comment += ` - ${lang}: ${url}\n`; + if (change.hash && typeof change.hash === 'object') { + comment += ` Hash: ${change.hash[lang]}\n`; + } + } + } + } + comment += '\n'; + } + } + + if (result.downloadFailures.length > 0) { + comment += '## ⚠️ Download Failures\n\n'; + for (const failure of result.downloadFailures) { + comment += `- ${failure}\n`; + } + comment += '\n'; + } + + if (result.validationErrors.length > 0) { + comment += '## ❌ Validation Errors\n\n'; + for (const error of result.validationErrors) { + comment += `- ${error}\n`; + } + comment += '\n'; + } + + if (!result.hasIssues) { + comment += '## ✅ All checks passed!\n\n'; + comment += 'All assets are accessible and properly configured.\n'; + } + + return comment; +} + +async function updatePrReview(context: GitHubContext, reviewComment: string): Promise { + // List all PR comments + const { data: comments } = await context.octokit.issues.listComments({ + owner: context.owner, + repo: context.repo, + issue_number: context.prNumber, + }); + + // Find existing bot comment + const existingComment = comments.find(comment => + comment.body?.includes(PR_REVIEW_COMMENT_IDENTIFIER) + ); + + if (existingComment) { + // Update existing comment + await context.octokit.issues.updateComment({ + owner: context.owner, + repo: context.repo, + comment_id: existingComment.id, + body: reviewComment, + }); + } else { + // Create new comment + await context.octokit.issues.createComment({ + owner: context.owner, + repo: context.repo, + issue_number: context.prNumber, + body: reviewComment, + }); + } +} + +async function main() { + const prNumber = parseInt(process.env.PR_NUMBER || '', 10); + if (isNaN(prNumber)) { + throw new Error('PR_NUMBER environment variable is required'); + } + + const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/'); + if (!owner || !repo) { + throw new Error('GITHUB_REPOSITORY environment variable is required'); + } + + const octokit = new Octokit({ + auth: process.env.SERVICE_BOT_PAT + }); + + const context: GitHubContext = { + owner, + repo, + octokit, + prNumber + }; + + try { + // Get asset changes + const changes = await getAssetChanges(context); + + if (changes.length > 0) { + await addMissingAssetsLabel(context, prNumber); + } + + // Check for download failures + let downloadFailures: string[] = []; + try { + const failuresContent = await fs.readFile(DOWNLOAD_FAILURES_FILE, 'utf-8'); + downloadFailures = failuresContent.split('\n').filter(Boolean); + } catch (error) { + // File might not exist, which is fine + } + + // Check for validation errors (from violations.json) + let validationErrors: string[] = []; + try { + const violationsContent = await fs.readFile('violations.json', 'utf-8'); + const violations = JSON.parse(violationsContent); + validationErrors = violations.map((v: any) => v.message); + } catch (error) { + // File might not exist, which is fine + } + + const result: ReviewResult = { + changes, + downloadFailures, + validationErrors, + hasIssues: downloadFailures.length > 0 || validationErrors.length > 0 + }; + + // Generate and post review comment + const reviewComment = await generateReviewComment(result); + await updatePrReview(context, reviewComment); + + // Exit with error if there are issues + if (result.hasIssues) { + process.exit(1); + } + } catch (error) { + console.error('Error during PR review:', error); + process.exit(1); + } +} + +main().catch(error => { + console.error('Unhandled error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/prReview/types.ts b/scripts/prReview/types.ts new file mode 100644 index 0000000..7e8bdb3 --- /dev/null +++ b/scripts/prReview/types.ts @@ -0,0 +1,25 @@ +import { Octokit } from '@octokit/rest'; + +export const PR_REVIEW_COMMENT_IDENTIFIER = '🤖 PR Asset Review'; + +export interface GitHubContext { + owner: string; + repo: string; + octokit: Octokit; + prNumber: number; +} + +export interface AssetChange { + type: 'added' | 'modified' | 'removed'; + path: string; + mediaType: string; + url: string | Record; + hash?: string | Record; +} + +export interface ReviewResult { + changes: AssetChange[]; + downloadFailures: string[]; + validationErrors: string[]; + hasIssues: boolean; +} \ No newline at end of file From 10d03e54afca81ef143ab35a061ff87ae07db492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=BA=E5=8F=A3=E3=83=AC=E3=82=A4?= Date: Tue, 3 Jun 2025 12:24:28 +0200 Subject: [PATCH 2/7] Update ZZZ.jsonc --- DeadForgeAssets/curated/games/ZZZ.jsonc | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/DeadForgeAssets/curated/games/ZZZ.jsonc b/DeadForgeAssets/curated/games/ZZZ.jsonc index 35cb27a..21623af 100644 --- a/DeadForgeAssets/curated/games/ZZZ.jsonc +++ b/DeadForgeAssets/curated/games/ZZZ.jsonc @@ -8,11 +8,11 @@ "media": { "iconUrl": { "remoteUrl": "https://cdn2.steamgriddb.com/icon/0b8037a0540a830a2a5ef89aff144727.png", - "filePath": "%USERDATA%/game_assets/epic_525aa0efd70f4399b9f64bcd2a5b38c7.icon.png" + "filePath": "%USERDATA%/game_assets/hoyo_zzz.icon.png" }, "heroUrl": { "remoteUrl": "https://cdn2.steamgriddb.com/hero/8f7ac69355e68ed28661180e31b62267.jpg", - "filePath": "%USERDATA%/game_assets/epic_525aa0efd70f4399b9f64bcd2a5b38c7.hero.jpg" + "filePath": "%USERDATA%/game_assets/hoyo_zzz.hero.jpg" }, "logoUrl": { "remoteUrl": { @@ -20,8 +20,8 @@ "schinese": "https://cdn2.steamgriddb.com/logo/54648c18afed2304f5d470943d72917d.png" }, "filePath": { - "english": "%USERDATA%/game_assets/epic_525aa0efd70f4399b9f64bcd2a5b38c7.logo.english.png", - "schinese": "%USERDATA%/game_assets/epic_525aa0efd70f4399b9f64bcd2a5b38c7.logo.schinese.png" + "english": "%USERDATA%/game_assets/hoyo_zzz.logo.english.png", + "schinese": "%USERDATA%/game_assets/hoyo_zzz.logo.schinese.png" }, "logo_position": { "pinned_position": "CenterCenter", @@ -31,11 +31,11 @@ }, "capsuleUrl": { "remoteUrl": "https://cdn2.steamgriddb.com/grid/ad47a060f41a3728e17c1e76f9cbd38d.jpg", - "filePath": "%USERDATA%/game_assets/epic_525aa0efd70f4399b9f64bcd2a5b38c7.capsule.jpg" + "filePath": "%USERDATA%/game_assets/hoyo_zzz.capsule.jpg" }, "headerUrl": { "remoteUrl": "https://cdn2.steamgriddb.com/grid/460f8deae0ae09021b1d22e71c2d2348.webp", - "filePath": "%USERDATA%/game_assets/epic_525aa0efd70f4399b9f64bcd2a5b38c7.header.webp" + "filePath": "%USERDATA%/game_assets/hoyo_zzz.header.webp" } } } \ No newline at end of file From d96f3b89f1b00755d78b274816a63360a053bfc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=BA=E5=8F=A3=E3=83=AC=E3=82=A4?= Date: Tue, 3 Jun 2025 12:29:32 +0200 Subject: [PATCH 3/7] uwu --- scripts/prReview/index.ts | 469 +++++++++++++++++++++++++++++++++----- scripts/prReview/types.ts | 27 ++- 2 files changed, 443 insertions(+), 53 deletions(-) diff --git a/scripts/prReview/index.ts b/scripts/prReview/index.ts index c2ede1f..1e48a03 100644 --- a/scripts/prReview/index.ts +++ b/scripts/prReview/index.ts @@ -1,13 +1,47 @@ import { Octokit } from '@octokit/rest'; import { parse } from 'jsonc-parser'; import * as fs from 'fs/promises'; -import { GitHubContext, PR_REVIEW_COMMENT_IDENTIFIER, AssetChange, ReviewResult } from './types'; +import { GitHubContext, PR_REVIEW_COMMENT_IDENTIFIER, AssetChange, ReviewResult, MediaEntry } from './types'; import { addMissingAssetsLabel } from '../issuesMissingAssets/reportHandler'; +import { findReportSection } from '../issuesMissingAssets/reportHandler'; +import type { RestEndpointMethodTypes } from '@octokit/rest'; const ASSETS_DIR = 'DeadForgeAssets'; const GAMES_DIR = 'DeadForgeAssets/curated/games'; const DOWNLOAD_FAILURES_FILE = 'download_failures.txt'; +function findMediaEntryDifferences(oldEntry: MediaEntry, newEntry: MediaEntry): { field: string; oldValue: any; newValue: any; }[] { + const differences: { field: string; oldValue: any; newValue: any; }[] = []; + + function compareObjects(path: string, oldObj: any, newObj: any) { + if (typeof oldObj !== 'object' || typeof newObj !== 'object') { + if (oldObj !== newObj) { + differences.push({ field: path, oldValue: oldObj, newValue: newObj }); + } + return; + } + + const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]); + + for (const key of allKeys) { + const newPath = path ? `${path}.${key}` : key; + + if (!(key in oldObj)) { + differences.push({ field: newPath, oldValue: undefined, newValue: newObj[key] }); + } else if (!(key in newObj)) { + differences.push({ field: newPath, oldValue: oldObj[key], newValue: undefined }); + } else if (typeof oldObj[key] === 'object' && typeof newObj[key] === 'object') { + compareObjects(newPath, oldObj[key], newObj[key]); + } else if (oldObj[key] !== newObj[key]) { + differences.push({ field: newPath, oldValue: oldObj[key], newValue: newObj[key] }); + } + } + } + + compareObjects('', oldEntry, newEntry); + return differences; +} + async function getAssetChanges(context: GitHubContext): Promise { const { data: files } = await context.octokit.pulls.listFiles({ owner: context.owner, @@ -48,39 +82,44 @@ async function getAssetChanges(context: GitHubContext): Promise { // Compare media entries if (newGameData.media) { - for (const [mediaType, urlEntry] of Object.entries(newGameData.media)) { - const baseUrlEntry = baseGameData.media?.[mediaType]; - if (!baseUrlEntry) { + for (const [mediaType, newEntry] of Object.entries(newGameData.media)) { + const baseEntry = baseGameData.media?.[mediaType]; + if (!baseEntry) { // New media type added changes.push({ type: 'added', path: file.filename, mediaType, - url: urlEntry as any, - hash: (newGameData.media as any)[mediaType].hash - }); - } else if (JSON.stringify(baseUrlEntry) !== JSON.stringify(urlEntry)) { - // Media type modified - changes.push({ - type: 'modified', - path: file.filename, - mediaType, - url: urlEntry as any, - hash: (newGameData.media as any)[mediaType].hash + newValue: newEntry as MediaEntry }); + } else { + // Compare entries for modifications + const differences = findMediaEntryDifferences( + baseEntry as MediaEntry, + newEntry as MediaEntry + ); + if (differences.length > 0) { + changes.push({ + type: 'modified', + path: file.filename, + mediaType, + oldValue: baseEntry as MediaEntry, + newValue: newEntry as MediaEntry, + changes: differences + }); + } } } // Check for removed media types if (baseGameData.media) { - for (const [mediaType, urlEntry] of Object.entries(baseGameData.media)) { + for (const [mediaType, baseEntry] of Object.entries(baseGameData.media)) { if (!newGameData.media[mediaType]) { changes.push({ type: 'removed', path: file.filename, mediaType, - url: urlEntry as any, - hash: (baseGameData.media as any)[mediaType].hash + oldValue: baseEntry as MediaEntry }); } } @@ -89,13 +128,12 @@ async function getAssetChanges(context: GitHubContext): Promise { } else { // New file added if (newGameData.media) { - for (const [mediaType, urlEntry] of Object.entries(newGameData.media)) { + for (const [mediaType, entry] of Object.entries(newGameData.media)) { changes.push({ type: 'added', path: file.filename, mediaType, - url: urlEntry as any, - hash: (newGameData.media as any)[mediaType].hash + newValue: entry as MediaEntry }); } } @@ -103,13 +141,12 @@ async function getAssetChanges(context: GitHubContext): Promise { } catch (error) { // File doesn't exist in base branch, treat all as new if (newGameData.media) { - for (const [mediaType, urlEntry] of Object.entries(newGameData.media)) { + for (const [mediaType, entry] of Object.entries(newGameData.media)) { changes.push({ type: 'added', path: file.filename, mediaType, - url: urlEntry as any, - hash: (newGameData.media as any)[mediaType].hash + newValue: entry as MediaEntry }); } } @@ -120,6 +157,345 @@ async function getAssetChanges(context: GitHubContext): Promise { return changes; } +interface TreeNode { + [key: string]: TreeNode | AssetChange[]; +} + +function buildChangeTree(changes: AssetChange[]): TreeNode { + const tree: TreeNode = {}; + + for (const change of changes) { + const pathParts = change.path.split('/'); + const fileName = pathParts[pathParts.length - 1]; + + // Navigate/create the tree structure + let current = tree; + const treePath = ['DeadForgeAssets', 'curated', 'games', fileName, 'media', change.mediaType]; + + for (const part of treePath) { + if (!current[part]) { + current[part] = {}; + } + current = current[part] as TreeNode; + } + + // Store the change at the leaf + if (!current._changes) { + current._changes = []; + } + (current._changes as AssetChange[]).push(change); + } + + return tree; +} + +function formatObjectValue(obj: any, prefix: string = ''): string { + if (typeof obj !== 'object' || obj === null) { + return `"${obj}"`; + } + + const entries = Object.entries(obj); + if (entries.length === 0) return '{}'; + + let result = '{\n'; + entries.forEach(([key, value], index) => { + const isLast = index === entries.length - 1; + const formattedValue = typeof value === 'object' + ? formatObjectValue(value, prefix + ' ') + : `"${value}"`; + result += `${prefix} "${key}": ${formattedValue}${isLast ? '' : ','}\n`; + }); + result += `${prefix}}`; + return result; +} + +function formatTreeNode(node: TreeNode, prefix: string = '', isLast: boolean = true, depth: number = 0): string { + let result = ''; + const entries = Object.entries(node).filter(([key]) => key !== '_changes'); + const changes = node._changes as AssetChange[] || []; + + // Handle changes at this node + if (changes.length > 0) { + for (const change of changes) { + if (change.type === 'added' && change.newValue) { + result += formatAddedNode(change.newValue, prefix); + } else if (change.type === 'removed' && change.oldValue) { + result += formatRemovedNode(change.oldValue, prefix); + } else if (change.type === 'modified' && change.changes) { + result += formatModifiedNode(change.changes, prefix); + } + } + } + + // Handle child nodes + entries.forEach(([key, childNode], index) => { + const isLastEntry = index === entries.length - 1; + const connector = isLastEntry ? '└──' : '├──'; + result += `${prefix}${connector} ${key}\n`; + + const nextPrefix = prefix + (isLastEntry ? ' ' : '│ '); + result += formatTreeNode(childNode as TreeNode, nextPrefix, isLastEntry, depth + 1); + }); + + return result; +} + +function formatAddedNode(value: any, prefix: string, path: string[] = []): string { + let result = ''; + + if (typeof value === 'object' && value !== null) { + Object.entries(value).forEach(([key, val], index, arr) => { + const isLast = index === arr.length - 1; + const newPath = [...path, key]; + const connector = isLast ? '└──' : '├──'; + result += `${prefix}${connector} ${key}\n`; + + const nextPrefix = prefix + (isLast ? ' ' : '│ '); + if (typeof val === 'object' && val !== null) { + result += formatAddedNode(val, nextPrefix, newPath); + } else { + const linePrefix = nextPrefix.replace(/[└├]──\s*$/, ''); + result += `+${linePrefix}└── "${val}"\n`; + } + }); + } else { + const linePrefix = prefix.replace(/[└├]──\s*$/, ''); + result += `+${linePrefix}└── "${value}"\n`; + } + + return result; +} + +function formatRemovedNode(value: any, prefix: string, path: string[] = []): string { + let result = ''; + + if (typeof value === 'object' && value !== null) { + Object.entries(value).forEach(([key, val], index, arr) => { + const isLast = index === arr.length - 1; + const newPath = [...path, key]; + const connector = isLast ? '└──' : '├──'; + result += `${prefix}${connector} ${key}\n`; + + const nextPrefix = prefix + (isLast ? ' ' : '│ '); + if (typeof val === 'object' && val !== null) { + result += formatRemovedNode(val, nextPrefix, newPath); + } else { + const linePrefix = nextPrefix.replace(/[└├]──\s*$/, ''); + result += `-${linePrefix}└── "${val}"\n`; + } + }); + } else { + const linePrefix = prefix.replace(/[└├]──\s*$/, ''); + result += `-${linePrefix}└── "${value}"\n`; + } + + return result; +} + +function formatModifiedNode(changes: { field: string; oldValue: any; newValue: any; }[], prefix: string): string { + let result = ''; + const changesByPath = new Map(); + + // Group changes by their parent path + changes.forEach(change => { + const parts = change.field.split('.'); + const fieldName = parts.pop()!; + const parentPath = parts.join('.'); + + if (!changesByPath.has(parentPath)) { + changesByPath.set(parentPath, { oldValue: {}, newValue: {} }); + } + const entry = changesByPath.get(parentPath)!; + + if (change.oldValue !== undefined) { + entry.oldValue[fieldName] = change.oldValue; + } + if (change.newValue !== undefined) { + entry.newValue[fieldName] = change.newValue; + } + }); + + // Format each group of changes + changesByPath.forEach((values, path) => { + if (path) { + const pathParts = path.split('.'); + let currentPrefix = prefix; + pathParts.forEach((part, index) => { + const isLast = index === pathParts.length - 1; + const connector = isLast ? '└──' : '├──'; + result += `${currentPrefix}${connector} ${part}\n`; + currentPrefix += isLast ? ' ' : '│ '; + }); + prefix = currentPrefix; + } + + const allKeys = new Set([...Object.keys(values.oldValue), ...Object.keys(values.newValue)]); + const sortedKeys = Array.from(allKeys).sort(); + + sortedKeys.forEach((key, index) => { + const isLast = index === sortedKeys.length - 1; + const connector = isLast ? '└──' : '├──'; + const oldVal = values.oldValue[key]; + const newVal = values.newValue[key]; + + if (typeof oldVal === 'object' || typeof newVal === 'object') { + result += `${prefix}${connector} ${key}\n`; + const nextPrefix = prefix + (isLast ? ' ' : '│ '); + + if (oldVal && newVal) { + // Both objects exist, compare their properties + const subChanges = Object.keys({ ...oldVal, ...newVal }).map(subKey => ({ + field: subKey, + oldValue: oldVal[subKey], + newValue: newVal[subKey] + })); + result += formatModifiedNode(subChanges, nextPrefix); + } else { + // One object is missing, show full add/remove + if (newVal) { + Object.entries(newVal).forEach(([subKey, value], idx, arr) => { + const isLastItem = idx === arr.length - 1; + const linePrefix = nextPrefix.replace(/[└├]──\s*$/, ''); + result += `+${linePrefix}${isLastItem ? '└' : '├'}── "${value}"\n`; + }); + } + if (oldVal) { + Object.entries(oldVal).forEach(([subKey, value], idx, arr) => { + const isLastItem = idx === arr.length - 1; + const linePrefix = nextPrefix.replace(/[└├]──\s*$/, ''); + result += `-${linePrefix}${isLastItem ? '└' : '├'}── "${value}"\n`; + }); + } + } + } else { + if (oldVal === undefined) { + result += `${prefix}${connector} ${key}\n`; + const linePrefix = prefix + (isLast ? ' ' : '│ '); + result += `+${linePrefix.slice(1)}└── "${newVal}"\n`; + } else if (newVal === undefined) { + result += `${prefix}${connector} ${key}\n`; + const linePrefix = prefix + (isLast ? ' ' : '│ '); + result += `-${linePrefix.slice(1)}└── "${oldVal}"\n`; + } else if (oldVal !== newVal) { + result += `${prefix}${connector} ${key}\n`; + const linePrefix = prefix + (isLast ? ' ' : '│ '); + result += `-${linePrefix.slice(1)}├── "${oldVal}"\n`; + result += `+${linePrefix.slice(1)}└── "${newVal}"\n`; + } + } + }); + }); + + return result; +} + +function formatAllChangesTree(changes: AssetChange[]): string { + if (changes.length === 0) return ''; + + const tree = buildChangeTree(changes); + let result = '.\n'; + result += formatTreeNode(tree); + + return result; +} + +interface Report { + source: string; + id: string | number; + name: string; + missingAssets: string[]; +} + +interface ReportData { + reports: Report[]; +} + +interface GameMatch { + source: string; + id: string; +} + +interface GameFile { + matches: GameMatch[]; + media: { + iconUrl?: MediaEntry; + headerUrl?: MediaEntry; + capsuleUrl?: MediaEntry; + heroUrl?: MediaEntry; + logoUrl?: MediaEntry; + }; +} + +async function getRelatedIssues(context: GitHubContext, changes: AssetChange[]): Promise> { + // Extract unique game identifiers from the changed files' contents + const gameIds = new Map(); + + for (const change of changes) { + try { + // Get the content of the changed file + const { data: content } = await context.octokit.repos.getContent({ + owner: context.owner, + repo: context.repo, + path: change.path, + ref: `refs/pull/${context.prNumber}/head` + }); + + if ('content' in content) { + const decodedContent = Buffer.from(content.content, 'base64').toString(); + const gameData = parse(decodedContent) as GameFile; + + // Add all matches from the file + for (const match of gameData.matches) { + const gameKey = `${match.source}/${match.id}`; + gameIds.set(gameKey, match); + } + } + } catch (error) { + console.error(`Error reading file ${change.path}:`, error); + } + } + + // Fetch all open issues with the 'missing deadforge assets' label + const { data: issues } = await context.octokit.issues.listForRepo({ + owner: context.owner, + repo: context.repo, + state: 'open', + labels: 'missing deadforge assets' + }); + + // Filter issues that mention any of our game IDs + const relatedIssues: Array<{number: number; title: string; url: string; labels: string[]}> = []; + for (const issue of issues) { + const issueBody = issue.body || ''; + const reportSection = await findReportSection(issueBody); + + if (reportSection) { + try { + const reportData: ReportData = parse(reportSection.content); + const hasRelatedGame = reportData.reports.some(report => { + const gameKey = `${report.source}/${report.id}`; + return gameIds.has(gameKey); + }); + + if (hasRelatedGame) { + relatedIssues.push({ + number: issue.number, + title: issue.title, + url: issue.html_url, + labels: (issue.labels as RestEndpointMethodTypes['issues']['listForRepo']['response']['data'][0]['labels']) + .map(label => typeof label === 'string' ? label : label.name) + .filter((label): label is string => label !== null) + }); + } + } catch (error) { + console.error(`Error parsing report in issue #${issue.number}:`, error); + } + } + } + + return relatedIssues; +} + async function generateReviewComment(result: ReviewResult): Promise { let comment = `\n# PR Asset Review Report 🎮\n\n`; @@ -130,33 +506,20 @@ async function generateReviewComment(result: ReviewResult): Promise { comment += '## Asset Changes\n\n'; - const changeTypes = { - added: '➕ Added', - modified: '🔄 Modified', - removed: '❌ Removed' - }; + // Generate the unified tree for all changes + const treeOutput = formatAllChangesTree(result.changes); + if (treeOutput) { + comment += '```diff\n'; + comment += treeOutput; + comment += '```\n\n'; + } - for (const type of ['added', 'modified', 'removed'] as const) { - const typeChanges = result.changes.filter(c => c.type === type); - if (typeChanges.length > 0) { - comment += `### ${changeTypes[type]}\n\n`; - for (const change of typeChanges) { - comment += `- **${change.mediaType}** in \`${change.path}\`\n`; - if (typeof change.url === 'string') { - comment += ` - URL: ${change.url}\n`; - if (change.hash) comment += ` - Hash: ${change.hash}\n`; - } else { - comment += ' - Localized URLs:\n'; - for (const [lang, url] of Object.entries(change.url)) { - comment += ` - ${lang}: ${url}\n`; - if (change.hash && typeof change.hash === 'object') { - comment += ` Hash: ${change.hash[lang]}\n`; - } - } - } - } - comment += '\n'; + if (result.relatedIssues && result.relatedIssues.length > 0) { + comment += '## 📝 Related Open Issues\n\n'; + for (const issue of result.relatedIssues) { + comment += `- [#${issue.number}](${issue.url}) - ${issue.title}\n`; } + comment += '\n'; } if (result.downloadFailures.length > 0) { @@ -264,11 +627,15 @@ async function main() { // File might not exist, which is fine } + // Get related issues + const relatedIssues = await getRelatedIssues(context, changes); + const result: ReviewResult = { changes, downloadFailures, validationErrors, - hasIssues: downloadFailures.length > 0 || validationErrors.length > 0 + hasIssues: downloadFailures.length > 0 || validationErrors.length > 0, + relatedIssues }; // Generate and post review comment @@ -288,4 +655,4 @@ async function main() { main().catch(error => { console.error('Unhandled error:', error); process.exit(1); -}); \ No newline at end of file +}); \ No newline at end of file diff --git a/scripts/prReview/types.ts b/scripts/prReview/types.ts index 7e8bdb3..744494c 100644 --- a/scripts/prReview/types.ts +++ b/scripts/prReview/types.ts @@ -9,12 +9,29 @@ export interface GitHubContext { prNumber: number; } +export interface MediaEntry { + remoteUrl: string | Record; + filePath: string | Record; + hash?: string | Record; + logo_position?: { + x: number; + y: number; + width: number; + height: number; + }; +} + export interface AssetChange { type: 'added' | 'modified' | 'removed'; path: string; mediaType: string; - url: string | Record; - hash?: string | Record; + oldValue?: MediaEntry; + newValue?: MediaEntry; + changes?: { + field: string; + oldValue: any; + newValue: any; + }[]; } export interface ReviewResult { @@ -22,4 +39,10 @@ export interface ReviewResult { downloadFailures: string[]; validationErrors: string[]; hasIssues: boolean; + relatedIssues?: Array<{ + number: number; + title: string; + url: string; + labels: string[]; + }>; } \ No newline at end of file From 8be01e7693ae2bd40b2a46e78fceb0d4481b859c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=BA=E5=8F=A3=E3=83=AC=E3=82=A4?= Date: Tue, 3 Jun 2025 16:03:51 +0200 Subject: [PATCH 4/7] Update Genshin Impact.jsonc --- .github/workflows/pr-review.yml | 3 +- .../curated/games/Genshin Impact.jsonc | 36 +++++++++++++++++-- scripts/prReview/index.ts | 10 +++--- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index f063821..6e34ee3 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -4,7 +4,8 @@ on: pull_request: types: [opened, synchronize, reopened] paths: - - 'DeadForgeAssets/**' + - 'DeadForgeAssets/games/**' + - 'scripts/**' jobs: review-pr: diff --git a/DeadForgeAssets/curated/games/Genshin Impact.jsonc b/DeadForgeAssets/curated/games/Genshin Impact.jsonc index 1a5c616..0c881e9 100644 --- a/DeadForgeAssets/curated/games/Genshin Impact.jsonc +++ b/DeadForgeAssets/curated/games/Genshin Impact.jsonc @@ -2,8 +2,40 @@ "matches": [ { "source": "epic", - "id": "41869934302e4b8cafac2d3c0e7c293d" + "id": "41869934302e4b8cafac2d3c0e7c293d" // Epic Version - DISCOURAGED } ], - "media": {} + "media": { + "iconUrl": { + "remoteUrl": "https://cdn2.steamgriddb.com/icon/54795ec619ebda94c86d00184861c96f.png", + "filePath": "%USERDATA%/game_assets/hoyo_genshinimpact.icon.png" + }, + "heroUrl": { + "remoteUrl": "https://cdn2.steamgriddb.com/hero/714aeac233808ffb2b01e3910edff2bc.png", + "filePath": "%USERDATA%/game_assets/hoyo_genshinimpact.hero.jpg" + }, + "logoUrl": { + "remoteUrl": { + "english": "https://cdn2.steamgriddb.com/logo/245142a8282a24362c6a1762f55dab27.png", + "japanese": "https://cdn2.steamgriddb.com/logo/3b8592aa617b0299e2269ed032a13773.png" + }, + "filePath": { + "english": "%USERDATA%/game_assets/hoyo_genshinimpact.logo.english.png", + "japanese": "%USERDATA%/game_assets/hoyo_genshinimpact.logo.japanese.png" + }, + "logo_position": { + "pinned_position": "CenterCenter", + "height_pct": 60, + "width_pct": 40 + } + }, + "capsuleUrl": { + "remoteUrl": "https://cdn2.steamgriddb.com/grid/d1760f7755a3859b6323395fdcdef11d.png", + "filePath": "%USERDATA%/game_assets/hoyo_genshinimpact.capsule.png" + }, + "headerUrl": { + "remoteUrl": "https://cdn2.steamgriddb.com/grid/d70aeeddf4fd712766ac530137a79f91.jpg", + "filePath": "%USERDATA%/game_assets/hoyo_genshinimpact.header.png" + } + } } \ No newline at end of file diff --git a/scripts/prReview/index.ts b/scripts/prReview/index.ts index 1e48a03..65a3e1e 100644 --- a/scripts/prReview/index.ts +++ b/scripts/prReview/index.ts @@ -209,7 +209,7 @@ function formatObjectValue(obj: any, prefix: string = ''): string { return result; } -function formatTreeNode(node: TreeNode, prefix: string = '', isLast: boolean = true, depth: number = 0): string { +function formatTreeNode(node: TreeNode, prefix: string = ' ', isLast: boolean = true, depth: number = 0): string { let result = ''; const entries = Object.entries(node).filter(([key]) => key !== '_changes'); const changes = node._changes as AssetChange[] || []; @@ -255,12 +255,12 @@ function formatAddedNode(value: any, prefix: string, path: string[] = []): strin result += formatAddedNode(val, nextPrefix, newPath); } else { const linePrefix = nextPrefix.replace(/[└├]──\s*$/, ''); - result += `+${linePrefix}└── "${val}"\n`; + result += `+${linePrefix.slice(1)}└── "${val}"\n`; } }); } else { const linePrefix = prefix.replace(/[└├]──\s*$/, ''); - result += `+${linePrefix}└── "${value}"\n`; + result += `+${linePrefix.slice(1)}└── "${value}"\n`; } return result; @@ -281,12 +281,12 @@ function formatRemovedNode(value: any, prefix: string, path: string[] = []): str result += formatRemovedNode(val, nextPrefix, newPath); } else { const linePrefix = nextPrefix.replace(/[└├]──\s*$/, ''); - result += `-${linePrefix}└── "${val}"\n`; + result += `-${linePrefix.slice(1)}└── "${val}"\n`; } }); } else { const linePrefix = prefix.replace(/[└├]──\s*$/, ''); - result += `-${linePrefix}└── "${value}"\n`; + result += `-${linePrefix.slice(1)}└── "${value}"\n`; } return result; From c57ab9f7e412fdafc00c50ed477e8f3f52e03a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=BA=E5=8F=A3=E3=83=AC=E3=82=A4?= Date: Tue, 3 Jun 2025 16:45:17 +0200 Subject: [PATCH 5/7] Update index.ts --- scripts/prReview/index.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/scripts/prReview/index.ts b/scripts/prReview/index.ts index 65a3e1e..a529b62 100644 --- a/scripts/prReview/index.ts +++ b/scripts/prReview/index.ts @@ -218,13 +218,23 @@ function formatTreeNode(node: TreeNode, prefix: string = ' ', isLast: boolean = if (changes.length > 0) { for (const change of changes) { if (change.type === 'added' && change.newValue) { - result += formatAddedNode(change.newValue, prefix); + // For added changes, we want to mark the entire subtree as new + const pathParts = change.path.split('/'); + const fileName = pathParts[pathParts.length - 1]; + const mediaType = change.mediaType; + + // Start building the tree from the file level with + markers + result += `+${prefix.slice(1)}└── ${fileName}\n`; + result += `+${prefix.slice(1)} └── media\n`; + result += `+${prefix.slice(1)} └── ${mediaType}\n`; + result += formatAddedNode(change.newValue, prefix + ' ', [fileName, 'media', mediaType]); } else if (change.type === 'removed' && change.oldValue) { result += formatRemovedNode(change.oldValue, prefix); } else if (change.type === 'modified' && change.changes) { result += formatModifiedNode(change.changes, prefix); } } + return result; } // Handle child nodes @@ -248,14 +258,15 @@ function formatAddedNode(value: any, prefix: string, path: string[] = []): strin const isLast = index === arr.length - 1; const newPath = [...path, key]; const connector = isLast ? '└──' : '├──'; - result += `${prefix}${connector} ${key}\n`; + const linePrefix = prefix.replace(/[└├]──\s*$/, ''); + result += `+${linePrefix.slice(1)}${connector} ${key}\n`; const nextPrefix = prefix + (isLast ? ' ' : '│ '); if (typeof val === 'object' && val !== null) { result += formatAddedNode(val, nextPrefix, newPath); } else { - const linePrefix = nextPrefix.replace(/[└├]──\s*$/, ''); - result += `+${linePrefix.slice(1)}└── "${val}"\n`; + const valLinePrefix = nextPrefix.replace(/[└├]──\s*$/, ''); + result += `+${valLinePrefix.slice(1)}└── "${val}"\n`; } }); } else { From b8cace9c80927cd08a2ec12bcf0113bae04e3680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=BA=E5=8F=A3=E3=83=AC=E3=82=A4?= Date: Tue, 3 Jun 2025 17:14:07 +0200 Subject: [PATCH 6/7] Update index.ts --- scripts/prReview/index.ts | 397 ++++++++++++++++++-------------------- 1 file changed, 193 insertions(+), 204 deletions(-) diff --git a/scripts/prReview/index.ts b/scripts/prReview/index.ts index a529b62..8a40dbd 100644 --- a/scripts/prReview/index.ts +++ b/scripts/prReview/index.ts @@ -157,245 +157,234 @@ async function getAssetChanges(context: GitHubContext): Promise { return changes; } -interface TreeNode { - [key: string]: TreeNode | AssetChange[]; +interface SimpleTreeNode { + type: 'directory' | 'file' | 'value'; + name: string; + children?: SimpleTreeNode[]; + value?: string; } -function buildChangeTree(changes: AssetChange[]): TreeNode { - const tree: TreeNode = {}; +function buildSimpleTree(changes: AssetChange[]): { oldTree: SimpleTreeNode, newTree: SimpleTreeNode } { + const oldTree: SimpleTreeNode = { type: 'directory', name: '.', children: [] }; + const newTree: SimpleTreeNode = { type: 'directory', name: '.', children: [] }; for (const change of changes) { const pathParts = change.path.split('/'); const fileName = pathParts[pathParts.length - 1]; - - // Navigate/create the tree structure - let current = tree; const treePath = ['DeadForgeAssets', 'curated', 'games', fileName, 'media', change.mediaType]; - for (const part of treePath) { - if (!current[part]) { - current[part] = {}; - } - current = current[part] as TreeNode; + if (change.type === 'added' || change.type === 'modified') { + addToTree(newTree, treePath, change.newValue); } - - // Store the change at the leaf - if (!current._changes) { - current._changes = []; + if (change.type === 'removed' || change.type === 'modified') { + addToTree(oldTree, treePath, change.oldValue); } - (current._changes as AssetChange[]).push(change); } - return tree; + return { oldTree, newTree }; } -function formatObjectValue(obj: any, prefix: string = ''): string { - if (typeof obj !== 'object' || obj === null) { - return `"${obj}"`; - } +function addToTree(root: SimpleTreeNode, path: string[], value: any) { + let current = root; - const entries = Object.entries(obj); - if (entries.length === 0) return '{}'; + // Create path + for (const part of path) { + let child = current.children?.find(c => c.name === part); + if (!child) { + child = { type: 'directory', name: part, children: [] }; + current.children = current.children || []; + current.children.push(child); + } + current = child; + } - let result = '{\n'; - entries.forEach(([key, value], index) => { - const isLast = index === entries.length - 1; - const formattedValue = typeof value === 'object' - ? formatObjectValue(value, prefix + ' ') - : `"${value}"`; - result += `${prefix} "${key}": ${formattedValue}${isLast ? '' : ','}\n`; - }); - result += `${prefix}}`; - return result; + // Add values + if (value && typeof value === 'object') { + for (const [key, val] of Object.entries(value)) { + current.children = current.children || []; + current.children.push({ + type: 'value', + name: key, + value: typeof val === 'object' ? JSON.stringify(val) : String(val) + }); + } + } } -function formatTreeNode(node: TreeNode, prefix: string = ' ', isLast: boolean = true, depth: number = 0): string { - let result = ''; - const entries = Object.entries(node).filter(([key]) => key !== '_changes'); - const changes = node._changes as AssetChange[] || []; +function formatTree(node: SimpleTreeNode, prefix: string = '', isLast: boolean = true): string[] { + const lines: string[] = []; + const connector = isLast ? '└──' : '├──'; + const childPrefix = prefix + (isLast ? ' ' : '│ '); - // Handle changes at this node - if (changes.length > 0) { - for (const change of changes) { - if (change.type === 'added' && change.newValue) { - // For added changes, we want to mark the entire subtree as new - const pathParts = change.path.split('/'); - const fileName = pathParts[pathParts.length - 1]; - const mediaType = change.mediaType; - - // Start building the tree from the file level with + markers - result += `+${prefix.slice(1)}└── ${fileName}\n`; - result += `+${prefix.slice(1)} └── media\n`; - result += `+${prefix.slice(1)} └── ${mediaType}\n`; - result += formatAddedNode(change.newValue, prefix + ' ', [fileName, 'media', mediaType]); - } else if (change.type === 'removed' && change.oldValue) { - result += formatRemovedNode(change.oldValue, prefix); - } else if (change.type === 'modified' && change.changes) { - result += formatModifiedNode(change.changes, prefix); + if (node.type === 'value') { + // Handle object values specially + if (node.value?.startsWith('{') && node.value?.endsWith('}')) { + try { + const obj = JSON.parse(node.value); + lines.push(`${prefix}${connector} ${node.name}/`); + Object.entries(obj).forEach(([key, val], idx, arr) => { + const isLastProp = idx === arr.length - 1; + const propConnector = isLastProp ? '└──' : '├──'; + lines.push(`${childPrefix}${propConnector} ${key}: ${JSON.stringify(val)}`); + }); + } catch { + // If not valid JSON, treat as regular value + lines.push(`${prefix}${connector} ${node.name}: ${node.value}`); } + } else { + lines.push(`${prefix}${connector} ${node.name}: ${node.value}`); + } + } else { + if (node.name !== '.') { + lines.push(`${prefix}${connector} ${node.name}`); + } + if (node.children) { + const sortedChildren = node.children.sort((a, b) => { + // Sort directories before values, then alphabetically + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + sortedChildren.forEach((child, index) => { + const isLastChild = index === sortedChildren.length - 1; + lines.push(...formatTree( + child, + node.name === '.' ? prefix : childPrefix, + isLastChild + )); + }); } - return result; } - // Handle child nodes - entries.forEach(([key, childNode], index) => { - const isLastEntry = index === entries.length - 1; - const connector = isLastEntry ? '└──' : '├──'; - result += `${prefix}${connector} ${key}\n`; - - const nextPrefix = prefix + (isLastEntry ? ' ' : '│ '); - result += formatTreeNode(childNode as TreeNode, nextPrefix, isLastEntry, depth + 1); - }); - - return result; + return lines; } -function formatAddedNode(value: any, prefix: string, path: string[] = []): string { - let result = ''; +interface TreeGroup { + path: string; + lines: string[]; + indent: number; +} + +function groupLines(lines: string[]): TreeGroup[] { + const groups: TreeGroup[] = []; + let currentGroup: TreeGroup | null = null; + let rootIndent = -1; - if (typeof value === 'object' && value !== null) { - Object.entries(value).forEach(([key, val], index, arr) => { - const isLast = index === arr.length - 1; - const newPath = [...path, key]; - const connector = isLast ? '└──' : '├──'; - const linePrefix = prefix.replace(/[└├]──\s*$/, ''); - result += `+${linePrefix.slice(1)}${connector} ${key}\n`; - - const nextPrefix = prefix + (isLast ? ' ' : '│ '); - if (typeof val === 'object' && val !== null) { - result += formatAddedNode(val, nextPrefix, newPath); - } else { - const valLinePrefix = nextPrefix.replace(/[└├]──\s*$/, ''); - result += `+${valLinePrefix.slice(1)}└── "${val}"\n`; + for (const line of lines) { + const indent = line.search(/\S/); + const content = line.trim(); + + // Find the game file name level (e.g. "ZZZ.jsonc" or "Genshin Impact.jsonc") + if (content.endsWith('.jsonc')) { + if (currentGroup) { + groups.push(currentGroup); } - }); - } else { - const linePrefix = prefix.replace(/[└├]──\s*$/, ''); - result += `+${linePrefix.slice(1)}└── "${value}"\n`; + currentGroup = { + path: content, + lines: [line], + indent: indent + }; + rootIndent = indent; + } else if (currentGroup && indent > rootIndent) { + currentGroup.lines.push(line); + } else { + // Lines before any .jsonc files (like DeadForgeAssets, curated, games) + if (!currentGroup) { + currentGroup = { + path: 'root', + lines: [], + indent: 0 + }; + groups.push(currentGroup); + } + currentGroup.lines.push(line); + } } - return result; + if (currentGroup && currentGroup.path !== 'root') { + groups.push(currentGroup); + } + + return groups; } -function formatRemovedNode(value: any, prefix: string, path: string[] = []): string { +function diffTrees(oldLines: string[], newLines: string[]): string { + const oldGroups = groupLines(oldLines); + const newGroups = groupLines(newLines); + let result = ''; - if (typeof value === 'object' && value !== null) { - Object.entries(value).forEach(([key, val], index, arr) => { - const isLast = index === arr.length - 1; - const newPath = [...path, key]; - const connector = isLast ? '└──' : '├──'; - result += `${prefix}${connector} ${key}\n`; - - const nextPrefix = prefix + (isLast ? ' ' : '│ '); - if (typeof val === 'object' && val !== null) { - result += formatRemovedNode(val, nextPrefix, newPath); - } else { - const linePrefix = nextPrefix.replace(/[└├]──\s*$/, ''); - result += `-${linePrefix.slice(1)}└── "${val}"\n`; - } - }); - } else { - const linePrefix = prefix.replace(/[└├]──\s*$/, ''); - result += `-${linePrefix.slice(1)}└── "${value}"\n`; + // First, find the root group and output it + const oldRoot = oldGroups.find(g => g.path === 'root'); + const newRoot = newGroups.find(g => g.path === 'root'); + + if (oldRoot && newRoot && oldRoot.lines.join('\n') === newRoot.lines.join('\n')) { + result += oldRoot.lines.map(line => ` ${line}`).join('\n') + '\n'; } - return result; + // Get non-root groups + const oldFileGroups = oldGroups.filter(g => g.path !== 'root'); + const newFileGroups = newGroups.filter(g => g.path !== 'root'); + + // Find added files (in new but not in old) + const addedFiles = newFileGroups.filter(newGroup => + !oldFileGroups.some(oldGroup => oldGroup.path === newGroup.path) + ); + + // Find removed files (in old but not in new) + const removedFiles = oldFileGroups.filter(oldGroup => + !newFileGroups.some(newGroup => newGroup.path === oldGroup.path) + ); + + // Find modified files (in both) + const modifiedFiles = newFileGroups.filter(newGroup => + oldFileGroups.some(oldGroup => oldGroup.path === newGroup.path) + ); + + // Output added files first + for (const group of addedFiles) { + result += group.lines.map(line => `+${line}`).join('\n') + '\n'; + } + + // Output removed files + for (const group of removedFiles) { + result += group.lines.map(line => `-${line}`).join('\n') + '\n'; + } + + // Output modified files + for (const newGroup of modifiedFiles) { + const oldGroup = oldFileGroups.find(g => g.path === newGroup.path)!; + if (oldGroup.lines.join('\n') !== newGroup.lines.join('\n')) { + // If the content is different, show the diff + result += diffLinesWithContext(oldGroup.lines, newGroup.lines); + } else { + // If the content is the same, show it unchanged + result += oldGroup.lines.map(line => ` ${line}`).join('\n') + '\n'; + } + } + + return result.trim(); } -function formatModifiedNode(changes: { field: string; oldValue: any; newValue: any; }[], prefix: string): string { +function diffLinesWithContext(oldLines: string[], newLines: string[]): string { let result = ''; - const changesByPath = new Map(); - - // Group changes by their parent path - changes.forEach(change => { - const parts = change.field.split('.'); - const fieldName = parts.pop()!; - const parentPath = parts.join('.'); - - if (!changesByPath.has(parentPath)) { - changesByPath.set(parentPath, { oldValue: {}, newValue: {} }); - } - const entry = changesByPath.get(parentPath)!; - - if (change.oldValue !== undefined) { - entry.oldValue[fieldName] = change.oldValue; - } - if (change.newValue !== undefined) { - entry.newValue[fieldName] = change.newValue; - } - }); + let i = 0, j = 0; - // Format each group of changes - changesByPath.forEach((values, path) => { - if (path) { - const pathParts = path.split('.'); - let currentPrefix = prefix; - pathParts.forEach((part, index) => { - const isLast = index === pathParts.length - 1; - const connector = isLast ? '└──' : '├──'; - result += `${currentPrefix}${connector} ${part}\n`; - currentPrefix += isLast ? ' ' : '│ '; - }); - prefix = currentPrefix; + while (i < oldLines.length || j < newLines.length) { + if (i < oldLines.length && j < newLines.length && oldLines[i] === newLines[j]) { + result += ` ${oldLines[i]}\n`; + i++; + j++; + } else if (j < newLines.length && (i >= oldLines.length || oldLines[i] > newLines[j])) { + result += `+${newLines[j]}\n`; + j++; + } else if (i < oldLines.length && (j >= newLines.length || oldLines[i] < newLines[j])) { + result += `-${oldLines[i]}\n`; + i++; } - - const allKeys = new Set([...Object.keys(values.oldValue), ...Object.keys(values.newValue)]); - const sortedKeys = Array.from(allKeys).sort(); - - sortedKeys.forEach((key, index) => { - const isLast = index === sortedKeys.length - 1; - const connector = isLast ? '└──' : '├──'; - const oldVal = values.oldValue[key]; - const newVal = values.newValue[key]; - - if (typeof oldVal === 'object' || typeof newVal === 'object') { - result += `${prefix}${connector} ${key}\n`; - const nextPrefix = prefix + (isLast ? ' ' : '│ '); - - if (oldVal && newVal) { - // Both objects exist, compare their properties - const subChanges = Object.keys({ ...oldVal, ...newVal }).map(subKey => ({ - field: subKey, - oldValue: oldVal[subKey], - newValue: newVal[subKey] - })); - result += formatModifiedNode(subChanges, nextPrefix); - } else { - // One object is missing, show full add/remove - if (newVal) { - Object.entries(newVal).forEach(([subKey, value], idx, arr) => { - const isLastItem = idx === arr.length - 1; - const linePrefix = nextPrefix.replace(/[└├]──\s*$/, ''); - result += `+${linePrefix}${isLastItem ? '└' : '├'}── "${value}"\n`; - }); - } - if (oldVal) { - Object.entries(oldVal).forEach(([subKey, value], idx, arr) => { - const isLastItem = idx === arr.length - 1; - const linePrefix = nextPrefix.replace(/[└├]──\s*$/, ''); - result += `-${linePrefix}${isLastItem ? '└' : '├'}── "${value}"\n`; - }); - } - } - } else { - if (oldVal === undefined) { - result += `${prefix}${connector} ${key}\n`; - const linePrefix = prefix + (isLast ? ' ' : '│ '); - result += `+${linePrefix.slice(1)}└── "${newVal}"\n`; - } else if (newVal === undefined) { - result += `${prefix}${connector} ${key}\n`; - const linePrefix = prefix + (isLast ? ' ' : '│ '); - result += `-${linePrefix.slice(1)}└── "${oldVal}"\n`; - } else if (oldVal !== newVal) { - result += `${prefix}${connector} ${key}\n`; - const linePrefix = prefix + (isLast ? ' ' : '│ '); - result += `-${linePrefix.slice(1)}├── "${oldVal}"\n`; - result += `+${linePrefix.slice(1)}└── "${newVal}"\n`; - } - } - }); - }); + } return result; } @@ -403,11 +392,11 @@ function formatModifiedNode(changes: { field: string; oldValue: any; newValue: a function formatAllChangesTree(changes: AssetChange[]): string { if (changes.length === 0) return ''; - const tree = buildChangeTree(changes); - let result = '.\n'; - result += formatTreeNode(tree); + const { oldTree, newTree } = buildSimpleTree(changes); + const oldLines = formatTree(oldTree); + const newLines = formatTree(newTree); - return result; + return diffTrees(oldLines, newLines); } interface Report { @@ -522,7 +511,7 @@ async function generateReviewComment(result: ReviewResult): Promise { if (treeOutput) { comment += '```diff\n'; comment += treeOutput; - comment += '```\n\n'; + comment += '\n```\n'; } if (result.relatedIssues && result.relatedIssues.length > 0) { From 48f79373ade07abaeebc115ce2c15d0b5abb5a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=BA=E5=8F=A3=E3=83=AC=E3=82=A4?= Date: Tue, 3 Jun 2025 18:35:58 +0200 Subject: [PATCH 7/7] fix --- DeadForgeAssets/curated/games/Genshin Impact.jsonc | 4 ++-- DeadForgeAssets/curated/games/ZZZ.jsonc | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/DeadForgeAssets/curated/games/Genshin Impact.jsonc b/DeadForgeAssets/curated/games/Genshin Impact.jsonc index 0c881e9..63ee037 100644 --- a/DeadForgeAssets/curated/games/Genshin Impact.jsonc +++ b/DeadForgeAssets/curated/games/Genshin Impact.jsonc @@ -20,8 +20,8 @@ "japanese": "https://cdn2.steamgriddb.com/logo/3b8592aa617b0299e2269ed032a13773.png" }, "filePath": { - "english": "%USERDATA%/game_assets/hoyo_genshinimpact.logo.english.png", - "japanese": "%USERDATA%/game_assets/hoyo_genshinimpact.logo.japanese.png" + "english": "%USERDATA%/game_assets/hoyo_genshinimpact.logo_english.png", + "japanese": "%USERDATA%/game_assets/hoyo_genshinimpact.logo_japanese.png" }, "logo_position": { "pinned_position": "CenterCenter", diff --git a/DeadForgeAssets/curated/games/ZZZ.jsonc b/DeadForgeAssets/curated/games/ZZZ.jsonc index 21623af..341bb9f 100644 --- a/DeadForgeAssets/curated/games/ZZZ.jsonc +++ b/DeadForgeAssets/curated/games/ZZZ.jsonc @@ -20,8 +20,8 @@ "schinese": "https://cdn2.steamgriddb.com/logo/54648c18afed2304f5d470943d72917d.png" }, "filePath": { - "english": "%USERDATA%/game_assets/hoyo_zzz.logo.english.png", - "schinese": "%USERDATA%/game_assets/hoyo_zzz.logo.schinese.png" + "english": "%USERDATA%/game_assets/hoyo_zzz.logo_english.png", + "schinese": "%USERDATA%/game_assets/hoyo_zzz.logo_schinese.png" }, "logo_position": { "pinned_position": "CenterCenter",