Network Hub #119
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: KubeJS Scripts Lint & Format Review | |
| on: | |
| pull_request: | |
| paths: | |
| - "kubejs/**/*.js" | |
| - ".eslintrc*" | |
| - "eslint.config*" | |
| - ".prettierrc*" | |
| - "package.json" | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| jobs: | |
| lint-and-review: | |
| name: Lint KubeJS Scripts | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout PR branch | |
| uses: actions/checkout@v4 | |
| with: | |
| # Full history so we can diff against base | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: "20" | |
| cache: "npm" | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Run ESLint | |
| id: eslint | |
| run: | | |
| npx eslint . \ | |
| --format json \ | |
| --output-file eslint-report.json || true | |
| - name: Run Typecheck | |
| id: typecheck | |
| run: | | |
| npm run lint:typecheck || true | |
| - name: Run Prettier (check mode + diff) | |
| id: prettier | |
| run: | | |
| # Write a diff of what Prettier would change | |
| npx prettier --check . 2>&1 | tee prettier-check.txt || true | |
| # Generate per-file diffs for the review suggestions | |
| mkdir -p prettier-diffs | |
| mapfile -t changed_files < <(git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- 'kubejs/**/*.js') | |
| echo "Checking ${#changed_files[@]} changed file(s) for formatting..." | |
| for file in "${changed_files[@]}"; do | |
| [[ -f "$file" ]] || continue | |
| echo " formatting check: $file" | |
| formatted=$(node_modules/.bin/prettier "$file" 2>/dev/null) | |
| original=$(cat "$file") | |
| if [ "$formatted" != "$original" ]; then | |
| # Save the formatted version - workflow script picks it up | |
| safe=$(echo "$file" | tr '/' '_') | |
| echo "$formatted" > "prettier-diffs/${safe}.formatted" | |
| echo "$file" >> prettier-changed-files.txt | |
| fi | |
| done | |
| - name: Post PR Review | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const { execSync } = require('child_process'); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const pullNumber = context.payload.pull_request.number; | |
| const headSha = context.payload.pull_request.head.sha; | |
| function diffToSuggestions(originalLines, formattedLines, filePath) { | |
| const suggestions = []; | |
| let i = 0, j = 0; | |
| while (i < originalLines.length || j < formattedLines.length) { | |
| if (originalLines[i] === formattedLines[j]) { | |
| i++; j++; | |
| continue; | |
| } | |
| const hunkStart = i; | |
| let oi = i + 1, fj = j + 1; | |
| while ( | |
| (oi < originalLines.length || fj < formattedLines.length) && | |
| originalLines[oi] !== formattedLines[fj] | |
| ) { oi++; fj++; } | |
| const hunkEnd = oi - 1; | |
| const replacement = formattedLines.slice(j, fj).join('\n'); | |
| suggestions.push({ | |
| path: filePath, | |
| line: hunkEnd + 1, | |
| start_line: hunkStart + 1, | |
| body: `\`\`\`suggestion\n${replacement}\n\`\`\``, | |
| }); | |
| i = oi; | |
| j = fj; | |
| } | |
| return suggestions; | |
| } | |
| const prettierComments = []; | |
| let changedFiles = []; | |
| try { | |
| changedFiles = fs.readFileSync('prettier-changed-files.txt', 'utf8') | |
| .split('\n').filter(Boolean); | |
| } catch (_) {} | |
| for (const filePath of changedFiles) { | |
| const safe = filePath.replace(/\//g, '_'); | |
| const formattedPath = `prettier-diffs/${safe}.formatted`; | |
| if (!fs.existsSync(formattedPath)) continue; | |
| const original = fs.readFileSync(filePath, 'utf8').split('\n'); | |
| const formatted = fs.readFileSync(formattedPath, 'utf8').split('\n'); | |
| const hunks = diffToSuggestions(original, formatted, filePath); | |
| for (const h of hunks.slice(0, 50)) { | |
| prettierComments.push(h); | |
| } | |
| } | |
| let eslintResults = []; | |
| try { | |
| eslintResults = JSON.parse(fs.readFileSync('eslint-report.json', 'utf8')); | |
| } catch (_) {} | |
| const eslintComments = []; | |
| for (const result of eslintResults) { | |
| const relPath = result.filePath.replace(process.cwd() + '/', ''); | |
| for (const msg of result.messages) { | |
| const severity = msg.severity === 2 ? '🔴 **Error**' : '🟡 **Warning**'; | |
| const body = [ | |
| `${severity} \`${msg.ruleId ?? 'eslint'}\``, | |
| msg.message, | |
| msg.fix | |
| ? `\`\`\`suggestion\n${ | |
| // apply the fix inline when possible | |
| // (single-line fixes only for suggestions) | |
| (() => { | |
| try { | |
| const src = fs.readFileSync(relPath, 'utf8').split('\n'); | |
| const fixed = src[msg.line - 1] | |
| .slice(0, msg.fix.range[0] - (src.slice(0, msg.line - 1).join('\n').length + 1)) | |
| + msg.fix.text | |
| + src[msg.line - 1] | |
| .slice(msg.fix.range[1] - (src.slice(0, msg.line - 1).join('\n').length + 1)); | |
| return fixed; | |
| } catch (_) { return null; } | |
| })() | |
| }\n\`\`\`` : '', | |
| ].filter(Boolean).join('\n\n'); | |
| eslintComments.push({ | |
| path: relPath, | |
| line: msg.endLine ?? msg.line, | |
| start_line: msg.line !== (msg.endLine ?? msg.line) ? msg.line : undefined, | |
| body, | |
| }); | |
| } | |
| } | |
| const COMMENT_BODY_LIMIT = 300; | |
| const INLINE_COMMENT_CAP = 20; | |
| const allComments = [...prettierComments, ...eslintComments]; | |
| const cleanedComments = allComments | |
| .filter(c => c.line > 0) | |
| .map(({ start_line, line, body, ...rest }) => ({ | |
| ...rest, | |
| body: body.length > COMMENT_BODY_LIMIT | |
| ? body.slice(0, COMMENT_BODY_LIMIT) + '\n\n_…truncated_' | |
| : body, | |
| line, | |
| ...(start_line && start_line < line ? { start_line, start_side: 'RIGHT' } : {}), | |
| side: 'RIGHT', | |
| })); | |
| const inlineComments = cleanedComments.slice(0, INLINE_COMMENT_CAP); | |
| const omittedCount = cleanedComments.length - inlineComments.length; | |
| const hasIssues = cleanedComments.length > 0; | |
| let summaryBody = hasIssues | |
| ? [ | |
| '## KubeJS Scripts Lint & Format Review', | |
| '', | |
| `Found **${prettierComments.length}** formatting issue(s) and **${eslintComments.length}** lint issue(s).`, | |
| '', | |
| omittedCount > 0 | |
| ? `> Showing first **${inlineComments.length}** inline comment(s) — **${omittedCount}** additional issue(s) not shown.` | |
| : '', | |
| '> Prettier suggestions can be committed directly from this review.', | |
| '> Run `npm run format:fix && npm run lint:fix` locally to fix everything at once.', | |
| ].filter(l => l !== '').join('\n') | |
| : [ | |
| '## ✅ KubeJS Scripts Lint & Format Review', | |
| '', | |
| 'All scripts pass formatting and lint checks. Nice work!', | |
| ].join('\n'); | |
| if (hasIssues) { | |
| try { | |
| await github.rest.pulls.createReview({ | |
| owner, | |
| repo, | |
| pull_number: pullNumber, | |
| commit_id: headSha, | |
| body: summaryBody, | |
| event: 'REQUEST_CHANGES', | |
| comments: inlineComments, | |
| }); | |
| console.log(`Review posted: REQUEST_CHANGES with ${inlineComments.length} inline comment(s) (${omittedCount} omitted).`); | |
| } catch (error) { | |
| if (error.status === 422 && error.response?.data?.message?.includes('Unprocessable Entity')) { | |
| console.log('Inline comments failed to resolve, posting summary comment instead...'); | |
| const detailsBody = [ | |
| summaryBody, | |
| '', | |
| '<details><summary>Detailed Issues</summary>', | |
| '', | |
| '### ESLint Issues (' + eslintComments.length + ')', | |
| eslintComments.length > 0 | |
| ? eslintComments.map(c => `- **${c.path}:${c.line}** - ${c.body.split('\n')[0]}`).join('\n') | |
| : 'None', | |
| '', | |
| '### Prettier Formatting Issues (' + prettierComments.length + ')', | |
| prettierComments.length > 0 | |
| ? prettierComments.map(c => `- **${c.path}:${c.line}**`).join('\n') | |
| : 'None', | |
| '', | |
| '</details>', | |
| ].join('\n'); | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: pullNumber, | |
| body: detailsBody, | |
| }); | |
| console.log(`Comment posted with ${eslintComments.length} eslint and ${prettierComments.length} prettier issues.`); | |
| } else { | |
| throw error; | |
| } | |
| } | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: pullNumber, | |
| body: summaryBody, | |
| }); | |
| console.log(`Comment posted: All checks passed.`); | |
| } |