├── CLAUDE.md ├── pnpm-workspace.yaml ├── docs ├── assets │ ├── logo.webp │ ├── settings.webp │ ├── create-an-action.webp │ ├── create-sync-label.webp │ ├── change-tracking-example.webp │ └── release-tracking-example.webp ├── COMPATIBILITY.md └── MIGRATION.md ├── packages ├── batch-pr │ ├── tsconfig.json │ ├── docs │ │ └── workflow-permissions.png │ ├── tsconfig.build.json │ ├── constants.ts │ ├── utils │ │ ├── resolveFileNameWithRootDir.ts │ │ ├── isBinaryFile.ts │ │ ├── createCommit.ts │ │ ├── createPrBody.ts │ │ ├── filterPendedTranslationIssues.ts │ │ ├── extractFileChanges.ts │ │ ├── getTrackedIssues.ts │ │ ├── parseFileStatuses.ts │ │ └── setupBatchPr.ts │ ├── types.ts │ ├── package.json │ ├── README.md │ ├── index.ts │ └── tests │ │ └── resolveFileNameWithRootDir.test.ts ├── core │ ├── tsconfig.json │ ├── types │ │ ├── git.ts │ │ ├── github.ts │ │ ├── config.ts │ │ └── plugin.ts │ ├── README.md │ ├── tsconfig.build.json │ ├── utils-infra │ │ ├── createIssue.ts │ │ ├── getLatestSuccessfulRunISODate.ts │ │ ├── getOpenedIssues.ts │ │ ├── getCommits.ts │ │ └── lookupCommitsInIssues.ts │ ├── tests │ │ ├── plugin-sdk │ │ │ ├── loadPlugins-error.test.ts │ │ │ ├── plugin-version-validation.test.ts │ │ │ └── plugins.test.ts │ │ ├── utils-infra │ │ │ ├── createIssue.test.ts │ │ │ ├── getLatestSuccessfulRunISODate.test.ts │ │ │ ├── getOpenedIssues.test.ts │ │ │ ├── lookupCommitsInIssues.test.ts │ │ │ └── getCommits.test.ts │ │ ├── utils │ │ │ ├── log.test.ts │ │ │ └── input.test.ts │ │ ├── infra │ │ │ ├── github.test.ts │ │ │ └── git.test.ts │ │ ├── integration │ │ │ ├── orchestration-error.test.ts │ │ │ └── orchestration.test.ts │ │ └── createConfig.test.ts │ ├── utils │ │ ├── createFileNameFilter.ts │ │ ├── input.ts │ │ ├── log.ts │ │ └── common.ts │ ├── plugin │ │ └── index.ts │ ├── package.json │ ├── infra │ │ ├── git.ts │ │ └── github.ts │ ├── createConfig.ts │ └── index.ts └── release-tracking │ ├── tsconfig.json │ ├── tsconfig.build.json │ ├── utils │ ├── hasAnyRelease.ts │ ├── array.ts │ ├── getReleaseTrackingLabels.ts │ ├── updateIssueLabelsByRelease.ts │ ├── getLastIssueComments.ts │ ├── getRelease.ts │ └── updateIssueCommentsByRelease.ts │ ├── tests │ ├── hasAnyRelease.test.ts │ ├── utils │ │ └── array.test.ts │ ├── getRelease.test.ts │ ├── getLastIssueComments.test.ts │ ├── updateIssueLabelsByRelease.test.ts │ ├── updateIssueCommentsByRelease.test.ts │ └── integration │ │ └── plugin-orchestration.test.ts │ ├── README.md │ ├── package.json │ └── index.ts ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── tsconfig.dev.base.json ├── tsconfig.base.json ├── tsconfig.build.base.json ├── .prettierrc ├── vitest.config.ts ├── .github └── workflows │ ├── ci.yml │ ├── publish.yml │ ├── claude-code-review.yml │ └── claude.yml ├── eslint.config.js ├── LICENSE ├── package.json ├── scripts └── checkout.sh └── action.yml /CLAUDE.md: -------------------------------------------------------------------------------- 1 | @AGENTS.md 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' -------------------------------------------------------------------------------- /docs/assets/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gumball12/yuki-no/HEAD/docs/assets/logo.webp -------------------------------------------------------------------------------- /docs/assets/settings.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gumball12/yuki-no/HEAD/docs/assets/settings.webp -------------------------------------------------------------------------------- /docs/assets/create-an-action.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gumball12/yuki-no/HEAD/docs/assets/create-an-action.webp -------------------------------------------------------------------------------- /docs/assets/create-sync-label.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gumball12/yuki-no/HEAD/docs/assets/create-sync-label.webp -------------------------------------------------------------------------------- /packages/batch-pr/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.dev.base.json", 3 | "include": ["./**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.dev.base.json", 3 | "include": ["./**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /docs/assets/change-tracking-example.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gumball12/yuki-no/HEAD/docs/assets/change-tracking-example.webp -------------------------------------------------------------------------------- /packages/release-tracking/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.dev.base.json", 3 | "include": ["./**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /docs/assets/release-tracking-example.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gumball12/yuki-no/HEAD/docs/assets/release-tracking-example.webp -------------------------------------------------------------------------------- /packages/batch-pr/docs/workflow-permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gumball12/yuki-no/HEAD/packages/batch-pr/docs/workflow-permissions.png -------------------------------------------------------------------------------- /packages/core/types/git.ts: -------------------------------------------------------------------------------- 1 | export type Commit = { 2 | title: string; 3 | isoDate: string; 4 | hash: string; 5 | fileNames: string[]; 6 | }; 7 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # Yuki-no Plugin SDK 2 | 3 | 📖 **[Plugin Development Guide](https://github.com/Gumball12/yuki-no/blob/main/docs/PLUGINS.md)** 4 | -------------------------------------------------------------------------------- /packages/core/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.base.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/batch-pr/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.base.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["index.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/release-tracking/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.base.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["index.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .env 4 | node_modules/ 5 | repo/* 6 | npm-debug.log 7 | pnpm-debug.log 8 | yarn-error.log 9 | !.gitkeep 10 | coverage/ 11 | dist/ 12 | *.tsbuildinfo 13 | .serena/ 14 | -------------------------------------------------------------------------------- /packages/release-tracking/utils/hasAnyRelease.ts: -------------------------------------------------------------------------------- 1 | import type { Git } from '@yuki-no/plugin-sdk/infra/git'; 2 | 3 | export const hasAnyRelease = (git: Git): boolean => { 4 | const result = git.exec('tag'); 5 | return result.length !== 0; 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "references": [ 4 | { "path": "./packages/core" }, 5 | { "path": "./packages/release-tracking" }, 6 | { "path": "./packages/batch-pr" } 7 | ], 8 | "files": [] 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/types/github.ts: -------------------------------------------------------------------------------- 1 | export type IssueMeta = Readonly<{ 2 | title: string; 3 | body: string; 4 | labels: string[]; 5 | }>; 6 | 7 | export type Issue = { 8 | number: number; 9 | body: string; 10 | labels: string[]; 11 | hash: string; 12 | isoDate: string; 13 | }; 14 | -------------------------------------------------------------------------------- /tsconfig.dev.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "allowImportingTsExtensions": true, 5 | "noEmit": true, 6 | "noUnusedLocals": true, 7 | "composite": true 8 | }, 9 | "exclude": ["dist", "node_modules", "coverage", "**/*.tsbuildinfo"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "moduleResolution": "bundler", 7 | "verbatimModuleSyntax": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "esModuleInterop": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.build.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "declaration": true 6 | // "outDir": "dist" 7 | }, 8 | "exclude": [ 9 | "tests/**/*", 10 | "**/*.test.ts", 11 | "dist", 12 | "node_modules", 13 | "coverage", 14 | "**/*.tsbuildinfo" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 3 | 4 | "trailingComma": "all", 5 | "singleQuote": true, 6 | "printWidth": 80, 7 | "arrowParens": "avoid", 8 | "endOfLine": "auto", 9 | 10 | "importOrder": ["", "^\\.\\./", "^\\./", "^"], 11 | "importOrderSeparation": true, 12 | "importOrderSortSpecifiers": true, 13 | "importOrderCaseInsensitive": true 14 | } 15 | -------------------------------------------------------------------------------- /packages/core/types/config.ts: -------------------------------------------------------------------------------- 1 | export type Config = Readonly<{ 2 | accessToken: string; 3 | userName: string; 4 | email: string; 5 | upstreamRepoSpec: RepoSpec; 6 | headRepoSpec: RepoSpec; 7 | trackFrom: string; 8 | include: string[]; 9 | exclude: string[]; 10 | labels: string[]; 11 | plugins: string[]; 12 | verbose: boolean; 13 | }>; 14 | 15 | export type RepoSpec = { 16 | owner: string; 17 | name: string; 18 | branch: string; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/batch-pr/constants.ts: -------------------------------------------------------------------------------- 1 | export const FILE_HEADER_PREFIX: Readonly = [ 2 | '+++', 3 | '---', 4 | 'diff', 5 | 'index', 6 | 'new file mode ', 7 | 'deleted file mode ', 8 | '\\', // special message 9 | ]; 10 | 11 | export const FILE_STATUS_REGEX = { 12 | RENAMED: /^R(\d+)\t(.+)\t(.+)$/, // Rename pattern: R100\told.ts\tnew.ts 13 | COPIED: /^C(\d+)\t(.+)\t(.+)$/, // Copy pattern: C85\tsource.ts\tcopy.ts 14 | TYPE_CHANGED: /^T\t(.+)$/, // Type change pattern: T\tfile.sh 15 | MODIFIED_ADDED_DELETED: /^([MAD])\t(.+)$/, // pattern: M\tfile.ts, A\tfile.ts, D\tfile.ts 16 | }; 17 | -------------------------------------------------------------------------------- /packages/batch-pr/utils/resolveFileNameWithRootDir.ts: -------------------------------------------------------------------------------- 1 | import { normalizeRootDir } from '@yuki-no/plugin-sdk/utils/createFileNameFilter'; 2 | 3 | export const resolveFileNameWithRootDir = ( 4 | fileName: string, 5 | rootDir?: string, 6 | ): string => { 7 | if (!rootDir) { 8 | return fileName; 9 | } 10 | 11 | if (fileName === rootDir) { 12 | return ''; 13 | } 14 | 15 | const normalizedRootDir = normalizeRootDir(rootDir); 16 | 17 | if (!fileName.startsWith(normalizedRootDir)) { 18 | return fileName; 19 | } 20 | 21 | return fileName.substring(normalizedRootDir.length); 22 | }; 23 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from 'vite-tsconfig-paths'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [tsconfigPaths()], 6 | test: { 7 | include: ['./packages/*/tests/**/*.test.ts'], 8 | coverage: { 9 | provider: 'v8', 10 | reporter: ['text', 'json', 'html'], 11 | include: ['packages/**/*.ts'], 12 | exclude: [ 13 | 'packages/*/tests/**/*.ts', 14 | 'packages/*/dist/**/*.ts', 15 | 'vitest.config.ts', 16 | 17 | // types 18 | 'packages/core/types/**/*.ts', 19 | 'packages/batch-pr/types.ts', 20 | ], 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /packages/core/utils-infra/createIssue.ts: -------------------------------------------------------------------------------- 1 | import type { GitHub } from '../infra/github'; 2 | import type { Issue, IssueMeta } from '../types/github'; 3 | import { log } from '../utils/log'; 4 | 5 | export const createIssue = async ( 6 | github: GitHub, 7 | meta: IssueMeta, 8 | ): Promise> => { 9 | const { data } = await github.api.issues.create({ 10 | ...github.ownerAndRepo, 11 | title: meta.title, 12 | body: meta.body, 13 | labels: meta.labels, 14 | }); 15 | 16 | const issueNum = data.number; 17 | const isoDate = data.created_at; 18 | 19 | log('S', `createIssue :: Issue #${issueNum} created (${isoDate})`); 20 | 21 | return { 22 | body: meta.body, 23 | isoDate, 24 | labels: meta.labels, 25 | number: issueNum, 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/release-tracking/utils/array.ts: -------------------------------------------------------------------------------- 1 | export const uniqueWith = (value: V[], mapper: (v: V) => unknown): V[] => { 2 | if (value.length <= 1) { 3 | return [...value]; 4 | } 5 | 6 | const result: V[] = []; 7 | const seen = new Set(); 8 | 9 | for (const v of value) { 10 | const mapped = mapper(v); 11 | 12 | if (seen.has(mapped)) { 13 | continue; 14 | } 15 | 16 | result.push(v); 17 | seen.add(mapped); 18 | } 19 | 20 | return [...result]; 21 | }; 22 | 23 | export const mergeArray = (a: T[], b: T[]): T[] => { 24 | if (a.length === 0 && b.length === 0) { 25 | return []; 26 | } 27 | 28 | if (a.length === 0) { 29 | return [...b]; 30 | } 31 | 32 | if (b.length === 0) { 33 | return [...a]; 34 | } 35 | 36 | return [...a, ...b]; 37 | }; 38 | -------------------------------------------------------------------------------- /packages/release-tracking/utils/getReleaseTrackingLabels.ts: -------------------------------------------------------------------------------- 1 | import type { GitHub } from '@yuki-no/plugin-sdk/infra/github'; 2 | import { getMultilineInput } from '@yuki-no/plugin-sdk/utils/input'; 3 | 4 | const DEFAULT_RELEASE_TRACKING_LABELS = ['pending']; 5 | 6 | export const getReleaseTrackingLabels = (github: GitHub): string[] => { 7 | const rawReleaseLabels = getMultilineInput( 8 | 'YUKI_NO_RELEASE_TRACKING_LABELS', 9 | DEFAULT_RELEASE_TRACKING_LABELS, 10 | ); 11 | 12 | const releaseTrackingLabels = excludeFrom( 13 | rawReleaseLabels, 14 | github.configuredLabels, 15 | ); 16 | 17 | return releaseTrackingLabels; 18 | }; 19 | 20 | const excludeFrom = (excludeSource: string[], reference: string[]): string[] => 21 | excludeSource.filter(sourceEl => !reference.includes(sourceEl)); 22 | -------------------------------------------------------------------------------- /packages/release-tracking/tests/hasAnyRelease.test.ts: -------------------------------------------------------------------------------- 1 | import { hasAnyRelease } from '../utils/hasAnyRelease'; 2 | 3 | import { beforeEach, expect, it, vi } from 'vitest'; 4 | 5 | // Mocking Git to avoid direct execution 6 | const mockGit: any = { exec: vi.fn() }; 7 | 8 | beforeEach(() => { 9 | vi.clearAllMocks(); 10 | }); 11 | 12 | it('returns true when git tags exist', () => { 13 | mockGit.exec.mockReturnValue('v1.0.0\nv1.1.0\nv2.0.0'); 14 | 15 | const result = hasAnyRelease(mockGit); 16 | 17 | expect(result).toBe(true); 18 | expect(mockGit.exec).toHaveBeenCalledWith('tag'); 19 | }); 20 | 21 | it('returns false when no git tags exist', () => { 22 | mockGit.exec.mockReturnValue(''); 23 | 24 | const result = hasAnyRelease(mockGit); 25 | 26 | expect(result).toBe(false); 27 | expect(mockGit.exec).toHaveBeenCalledWith('tag'); 28 | }); 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | name: Lint and Test 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - run: corepack enable 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: '22' 21 | cache: 'pnpm' 22 | 23 | - run: pnpm install --frozen-lockfile 24 | - run: pnpm -r build 25 | - run: pnpm lint 26 | - run: pnpm test 27 | env: 28 | MOCKED_REQUEST_TEST: ${{ secrets.MOCKED_REQUEST_TEST }} 29 | 30 | - name: Upload coverage reports to Codecov 31 | uses: codecov/codecov-action@v4 32 | with: 33 | token: ${{ secrets.CODECOV_TOKEN }} 34 | files: ./coverage/coverage-final.json 35 | fail_ci_if_error: true 36 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | 4 | export default tseslint.config( 5 | js.configs.recommended, 6 | ...tseslint.configs.recommended, 7 | { 8 | languageOptions: { 9 | ecmaVersion: 'latest', 10 | sourceType: 'module', 11 | }, 12 | rules: { 13 | // TypeScript specific rules 14 | '@typescript-eslint/no-unused-vars': 'error', 15 | '@typescript-eslint/no-explicit-any': 'warn', 16 | '@typescript-eslint/no-var-requires': 'error', 17 | 18 | // General rules 19 | 'no-console': 'warn', 20 | 'no-debugger': 'error', 21 | 'prefer-const': 'error', 22 | 'no-var': 'error', 23 | }, 24 | }, 25 | { 26 | ignores: [ 27 | 'node_modules/', 28 | 'dist/', 29 | 'build/', 30 | '*.min.js', 31 | 'coverage/', 32 | '.pnpm-store/', 33 | 'packages/*/dist/', 34 | './**/tests/**/*.ts', 35 | ], 36 | }, 37 | ); 38 | -------------------------------------------------------------------------------- /packages/batch-pr/types.ts: -------------------------------------------------------------------------------- 1 | type FileChangeBase = { 2 | upstreamFileName: string; 3 | }; 4 | 5 | export type FileChange = FileChangeBase & 6 | ( 7 | | { 8 | type: 'update'; 9 | changes: LineChange[] | Buffer; 10 | } 11 | | { 12 | type: 'delete'; 13 | } 14 | | { 15 | type: 'rename' | 'copy'; 16 | nextUpstreamFileName: string; 17 | similarity: number; 18 | changes: LineChange[]; 19 | } 20 | | { 21 | type: 'type'; 22 | } 23 | ); 24 | 25 | export type LineChange = 26 | | { 27 | type: 'insert-line'; 28 | lineNumber: number; 29 | content: string; 30 | } 31 | | { 32 | type: 'delete-line'; 33 | lineNumber: number; 34 | }; 35 | 36 | export type FileStatus = 37 | | { 38 | status: 'M' | 'A' | 'D' | 'T'; 39 | headFileName: string; 40 | } 41 | | { 42 | status: 'R' | 'C'; 43 | headFileName: string; 44 | nextHeadFileName: string; 45 | similarity: number; 46 | }; 47 | -------------------------------------------------------------------------------- /packages/core/types/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from './config'; 2 | import type { Commit } from './git'; 3 | import type { Issue, IssueMeta } from './github'; 4 | 5 | export type YukiNoContext = Readonly<{ 6 | config: Config; 7 | }>; 8 | 9 | export interface YukiNoPlugin extends YukiNoPluginHooks { 10 | name: string; 11 | } 12 | 13 | interface YukiNoPluginHooks { 14 | onInit?(ctx: YukiNoContext): Promise | void; 15 | onBeforeCompare?(ctx: YukiNoContext): Promise | void; 16 | onAfterCompare?( 17 | ctx: YukiNoContext & { commits: Commit[] }, 18 | ): Promise | void; 19 | onBeforeCreateIssue?( 20 | ctx: YukiNoContext & { commit: Commit; issueMeta: IssueMeta }, 21 | ): Promise | void; 22 | onAfterCreateIssue?( 23 | ctx: YukiNoContext & { commit: Commit; issue: Issue }, 24 | ): Promise | void; 25 | onFinally?( 26 | ctx: YukiNoContext & { success: boolean; createdIssues: Issue[] }, 27 | ): Promise | void; 28 | onError?(ctx: YukiNoContext & { error: Error }): Promise | void; 29 | } 30 | -------------------------------------------------------------------------------- /packages/batch-pr/utils/isBinaryFile.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | const BINARY_FILE_EXTENSIONS: Readonly> = new Set([ 4 | // Images 5 | '.jpg', 6 | '.jpeg', 7 | '.png', 8 | '.gif', 9 | '.bmp', 10 | '.tiff', 11 | '.ico', 12 | '.webp', 13 | // Executables 14 | '.exe', 15 | '.dll', 16 | '.so', 17 | '.dylib', 18 | '.bin', 19 | // Archives 20 | '.zip', 21 | '.rar', 22 | '.tar', 23 | '.gz', 24 | '.7z', 25 | // Audio/Video 26 | '.mp3', 27 | '.mp4', 28 | '.avi', 29 | '.mkv', 30 | '.wav', 31 | '.flac', 32 | // Fonts 33 | '.ttf', 34 | '.otf', 35 | '.woff', 36 | '.woff2', 37 | // Documents 38 | '.pdf', 39 | '.doc', 40 | '.docx', 41 | '.xls', 42 | '.xlsx', 43 | '.ppt', 44 | '.pptx', 45 | // Databases 46 | '.db', 47 | '.sqlite', 48 | '.mdb', 49 | // Other 50 | '.iso', 51 | '.dmg', 52 | '.img', 53 | ]); 54 | 55 | export const isBinaryFile = (fileName: string): boolean => { 56 | const ext = path.extname(fileName).toLowerCase(); 57 | return BINARY_FILE_EXTENSIONS.has(ext); 58 | }; 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Vue.js Japan User Group 4 | Copyright (c) 2024 shj 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /packages/core/utils-infra/getLatestSuccessfulRunISODate.ts: -------------------------------------------------------------------------------- 1 | import type { GitHub } from '../infra/github'; 2 | import { log } from '../utils/log'; 3 | 4 | const WORKFLOW_NAME = 'yuki-no'; 5 | 6 | export const getLatestSuccessfulRunISODate = async ( 7 | github: GitHub, 8 | ): Promise => { 9 | log( 10 | 'I', 11 | 'getLatestSuccessfulRunISODate :: Extracting last successful GitHub Actions run time', 12 | ); 13 | const { data } = await github.api.actions.listWorkflowRunsForRepo({ 14 | ...github.ownerAndRepo, 15 | status: 'success', 16 | }); 17 | 18 | const latestSuccessfulRun = data.workflow_runs 19 | .sort((a, b) => a.created_at.localeCompare(b.created_at)) 20 | .findLast(run => run.name === WORKFLOW_NAME); 21 | 22 | if (!latestSuccessfulRun) { 23 | log( 24 | 'I', 25 | 'getLatestSuccessfulRunISODate :: No last successful GitHub Actions run time found', 26 | ); 27 | return; 28 | } 29 | 30 | const latestSuccessfulRunDate = latestSuccessfulRun.created_at; 31 | 32 | log( 33 | 'I', 34 | `getLatestSuccessfulRunISODate :: Last successful GitHub Actions run time: ${latestSuccessfulRunDate}`, 35 | ); 36 | 37 | return latestSuccessfulRunDate; 38 | }; 39 | -------------------------------------------------------------------------------- /packages/core/tests/plugin-sdk/loadPlugins-error.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | describe('Plugin loader - version context on failure', () => { 4 | it('adds version information to error message when specified', async () => { 5 | vi.resetModules(); 6 | vi.mock('../../plugin/index.ts', async importOriginal => { 7 | const orig = 8 | await importOriginal(); 9 | return { 10 | ...orig, 11 | getResolveId: vi.fn((name: string) => name.split('@')[0]), 12 | }; 13 | }); 14 | 15 | const { loadPlugins } = await import('../../plugin/index.ts'); 16 | await expect(loadPlugins(['non-existent-plugin@1.2.3'])).rejects.toThrow( 17 | /Failed to load plugin "/, 18 | ); 19 | await expect(loadPlugins(['non-existent-plugin@1.2.3'])).rejects.toThrow( 20 | /Resolved ID: non-existent-plugin/, 21 | ); 22 | await expect(loadPlugins(['non-existent-plugin@1.2.3'])).rejects.toThrow( 23 | /Original plugin specification: non-existent-plugin@1\.2\.3/, 24 | ); 25 | await expect(loadPlugins(['non-existent-plugin@1.2.3'])).rejects.toThrow( 26 | /Version specification: 1\.2\.3/, 27 | ); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/core/utils/createFileNameFilter.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '../types/config'; 2 | 3 | import picomatch from 'picomatch'; 4 | 5 | export type FileNameFilter = (fileName: string) => boolean; 6 | 7 | export const createFileNameFilter = ( 8 | config: Pick, 9 | rootDir = '', 10 | ): FileNameFilter => { 11 | const isIncluded = picomatch( 12 | config.include.length ? config.include : ['**'], 13 | { dot: true }, 14 | ); 15 | const isExcluded = picomatch(config.exclude, { dot: true }); 16 | 17 | const normalizedRootDir = normalizeRootDir(rootDir); 18 | 19 | return (fileName: string): boolean => { 20 | if (!fileName.length) { 21 | return false; 22 | } 23 | 24 | if (!fileName.startsWith(normalizedRootDir)) { 25 | return false; 26 | } 27 | 28 | if (config.include.length === 0 && config.exclude.length === 0) { 29 | return true; 30 | } 31 | 32 | return !isExcluded(fileName) && isIncluded(fileName); 33 | }; 34 | }; 35 | 36 | export const normalizeRootDir = (rootDir?: string): string => { 37 | if (!rootDir) { 38 | return ''; 39 | } 40 | 41 | if (rootDir.endsWith('/')) { 42 | return rootDir as `${string}/`; 43 | } 44 | 45 | return `${rootDir}/`; 46 | }; 47 | -------------------------------------------------------------------------------- /packages/release-tracking/tests/utils/array.test.ts: -------------------------------------------------------------------------------- 1 | import { mergeArray, uniqueWith } from '../../utils/array'; 2 | 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | describe('utils/array', () => { 6 | describe('mergeArray', () => { 7 | it('returns empty when both arrays are empty', () => { 8 | expect(mergeArray([], [])).toEqual([]); 9 | }); 10 | 11 | it('returns b when a is empty', () => { 12 | expect(mergeArray([], [1, 2])).toEqual([1, 2]); 13 | }); 14 | 15 | it('returns a when b is empty', () => { 16 | expect(mergeArray([1, 2], [])).toEqual([1, 2]); 17 | }); 18 | 19 | it('concat when both are non-empty', () => { 20 | expect(mergeArray([1], [2, 3])).toEqual([1, 2, 3]); 21 | }); 22 | }); 23 | 24 | describe('uniqueWith', () => { 25 | it('returns copy when length <= 1', () => { 26 | const obj = [{ id: 1 }]; 27 | const result = uniqueWith(obj, v => v.id); 28 | expect(result).toEqual(obj); 29 | expect(result).not.toBe(obj); 30 | }); 31 | 32 | it('removes duplicates based on mapper', () => { 33 | const input = [{ id: 1 }, { id: 2 }, { id: 1 }, { id: 3 }]; 34 | const result = uniqueWith(input, v => v.id); 35 | expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/core/utils/input.ts: -------------------------------------------------------------------------------- 1 | import { isNotEmpty } from './common'; 2 | 3 | export function getInput(name: string): string | undefined; 4 | export function getInput(name: string, defaultValue: string): string; 5 | export function getInput(name: string, defaultValue?: string | undefined) { 6 | return process.env[name] ?? defaultValue; 7 | } 8 | 9 | export const getBooleanInput = ( 10 | name: string, 11 | defaultValue = false, 12 | ): boolean => { 13 | const value = getInput(name); 14 | 15 | if (value === undefined) { 16 | return defaultValue; 17 | } 18 | 19 | return value?.toLowerCase() === 'true'; 20 | }; 21 | 22 | export const getMultilineInput = ( 23 | name: string, 24 | defaultValue: string[] = [], 25 | ): string[] => { 26 | const value = getInput(name); 27 | 28 | if (value === undefined) { 29 | return defaultValue; 30 | } 31 | 32 | return splitByNewline(value); 33 | }; 34 | 35 | export const splitByNewline = (text?: string, trim = true): string[] => { 36 | const normalizedText = trim ? text?.trim() : text; 37 | if (!normalizedText) { 38 | return []; 39 | } 40 | 41 | let splittedByNewline = normalizedText.split('\n'); 42 | 43 | if (trim) { 44 | splittedByNewline = splittedByNewline.map(line => line.trim()); 45 | } 46 | 47 | return splittedByNewline.filter(isNotEmpty); 48 | }; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad", 3 | "engines": { 4 | "node": ">=22.12.0" 5 | }, 6 | "type": "module", 7 | "scripts": { 8 | "type-check": "tsc --build -noEmit", 9 | "test": "vitest --coverage --run", 10 | "lint": "pnpm lint:eslint && pnpm lint:prettier && pnpm type-check", 11 | "lint:eslint": "eslint .", 12 | "lint:eslint:fix": "eslint . --fix", 13 | "lint:prettier": "prettier --check --write --parser typescript \"**/*.ts\" --ignore-path .gitignore", 14 | "start": "pnpm --filter @yuki-no/plugin-sdk start", 15 | "start:dev": "pnpm --filter @yuki-no/plugin-sdk start:dev" 16 | }, 17 | "devDependencies": { 18 | "@eslint/js": "^9.32.0", 19 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 20 | "@types/node": "^22.12.0", 21 | "@typescript-eslint/eslint-plugin": "^8.38.0", 22 | "@typescript-eslint/parser": "^8.38.0", 23 | "@vitest/coverage-v8": "2.1.8", 24 | "@vitest/ui": "^2.1.8", 25 | "eslint": "^9.32.0", 26 | "prettier": "^3.4.2", 27 | "tsx": "^4.19.2", 28 | "typescript": "^5.7.2", 29 | "typescript-eslint": "^8.38.0", 30 | "vite-tsconfig-paths": "^5.1.4", 31 | "vitest": "^2.1.8" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/core/utils/log.ts: -------------------------------------------------------------------------------- 1 | import colors from 'colors/safe'; 2 | 3 | /** 4 | * Log types: 5 | * - I: For development debugging 6 | * - S: For successful operations 7 | * - W: For warning messages 8 | * - E: For error messages 9 | */ 10 | export type LogType = 'I' | 'W' | 'E' | 'S'; 11 | 12 | export function log(type: LogType, message: string): void { 13 | // Only show warnings and errors unless verbose mode is enabled 14 | if ( 15 | process.env.VERBOSE?.toLowerCase() !== 'true' && 16 | type !== 'W' && 17 | type !== 'E' 18 | ) { 19 | return; 20 | } 21 | 22 | switch (type) { 23 | case 'I': 24 | // eslint-disable-next-line no-console 25 | console.info('[INFO]', colors.blue(message)); 26 | break; 27 | case 'S': 28 | // eslint-disable-next-line no-console 29 | console.info('[SUCCESS]', colors.green(message)); 30 | break; 31 | case 'W': 32 | // eslint-disable-next-line no-console 33 | console.warn('[WARNING]', colors.yellow(message)); 34 | break; 35 | case 'E': 36 | // eslint-disable-next-line no-console 37 | console.error('[ERROR]', colors.red(message)); 38 | break; 39 | } 40 | } 41 | 42 | export const formatError = (error: unknown) => { 43 | if (!(error instanceof Error)) { 44 | return ''; 45 | } 46 | 47 | return error.message; 48 | }; 49 | -------------------------------------------------------------------------------- /packages/batch-pr/utils/createCommit.ts: -------------------------------------------------------------------------------- 1 | import { Git } from '@yuki-no/plugin-sdk/infra/git'; 2 | import { log } from '@yuki-no/plugin-sdk/utils/log'; 3 | 4 | type CreateCommitOptions = { 5 | message: string; 6 | allowEmpty?: boolean; 7 | needSquash?: boolean; 8 | }; 9 | 10 | export const createCommit = ( 11 | git: Git, 12 | { message, allowEmpty = false }: CreateCommitOptions, 13 | ): void => { 14 | log( 15 | 'I', 16 | `createCommit :: Starting commit process with message: "${message}"`, 17 | ); 18 | 19 | const emptyFlag = allowEmpty ? '--allow-empty' : ''; 20 | const escapedMessage = escapeShellArg(message); 21 | 22 | log('I', 'createCommit :: Adding all changes to staging area'); 23 | git.exec('add .'); 24 | 25 | log( 26 | 'I', 27 | `createCommit :: Creating commit${allowEmpty ? ' (allow empty)' : ''}`, 28 | ); 29 | git.exec(`commit ${emptyFlag} -m "${escapedMessage}"`); 30 | 31 | log('S', 'createCommit :: Commit created successfully'); 32 | }; 33 | 34 | const escapeShellArg = (arg: string): string => 35 | arg 36 | .replace(/\\/g, '\\\\') 37 | .replace(/"/g, '\\"') 38 | .replace(/'/g, "\\'") 39 | .replace(/`/g, '\\`') 40 | .replace(/\$/g, '\\$') 41 | .replace(/;/g, '\\;') 42 | .replace(/&/g, '\\&') 43 | .replace(/\|/g, '\\|') 44 | .replace(//g, '\\>') 46 | .replace(/\(/g, '\\(') 47 | .replace(/\)/g, '\\)') 48 | .replace(/\n/g, '\\n') 49 | .replace(/\r/g, '\\r') 50 | .replace(/\t/g, '\\t'); 51 | -------------------------------------------------------------------------------- /packages/release-tracking/utils/updateIssueLabelsByRelease.ts: -------------------------------------------------------------------------------- 1 | import type { ReleaseInfo } from './getRelease'; 2 | import { getReleaseTrackingLabels } from './getReleaseTrackingLabels'; 3 | 4 | import type { GitHub } from '@yuki-no/plugin-sdk/infra/github'; 5 | import type { Issue } from '@yuki-no/plugin-sdk/types/github'; 6 | import { unique } from '@yuki-no/plugin-sdk/utils/common'; 7 | import { log } from '@yuki-no/plugin-sdk/utils/log'; 8 | 9 | export const updateIssueLabelsByRelease = async ( 10 | github: GitHub, 11 | issue: Issue, 12 | releaseInfo: ReleaseInfo, 13 | ): Promise => { 14 | const releaseTrackingLabels = getReleaseTrackingLabels(github); 15 | const isReleased = releaseInfo.release !== undefined; 16 | const nextLabels = isReleased 17 | ? issue.labels.filter(label => !releaseTrackingLabels.includes(label)) 18 | : unique([...issue.labels, ...releaseTrackingLabels]); 19 | 20 | log( 21 | 'I', 22 | `updateIssueLabelsByRelease :: Attempting to update #${issue.number} labels (${nextLabels.join(', ')})`, 23 | ); 24 | 25 | const isLabelChanged = 26 | JSON.stringify(issue.labels) !== JSON.stringify(nextLabels.sort()); 27 | 28 | if (isLabelChanged) { 29 | await github.api.issues.setLabels({ 30 | ...github.ownerAndRepo, 31 | issue_number: issue.number, 32 | labels: nextLabels, 33 | }); 34 | 35 | log('S', 'updateIssueLabelsByRelease :: Labels changed successfully'); 36 | } else { 37 | log( 38 | 'S', 39 | 'updateIssueLabelsByRelease :: No change needed (identical labels already exist)', 40 | ); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /packages/batch-pr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yuki-no/plugin-batch-pr", 3 | "version": "1.0.18", 4 | "description": "Batch PR plugin for yuki-no - Collects opened Yuki-no translation issues and creates a single pull request", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/Gumball12/yuki-no.git", 8 | "directory": "packages/batch-pr" 9 | }, 10 | "author": { 11 | "name": "Gumball12", 12 | "email": "to@shj.rip", 13 | "url": "https://github.com/Gumball12" 14 | }, 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/Gumball12/yuki-no/issues" 18 | }, 19 | "homepage": "https://github.com/Gumball12/yuki-no/tree/next/packages/batch-pr#readme", 20 | "keywords": [ 21 | "yuki-no", 22 | "plugins", 23 | "batch", 24 | "pull-request", 25 | "sync", 26 | "documentation", 27 | "automatically", 28 | "translation", 29 | "github-actions", 30 | "issue-management" 31 | ], 32 | "type": "module", 33 | "main": "dist/index.js", 34 | "types": "dist/index.d.ts", 35 | "exports": { 36 | ".": { 37 | "development": "./index.ts", 38 | "import": "./dist/index.js", 39 | "types": "./dist/index.d.ts" 40 | } 41 | }, 42 | "files": [ 43 | "dist" 44 | ], 45 | "scripts": { 46 | "build": "rm -rf dist && tsc -p tsconfig.build.json", 47 | "prepublishOnly": "pnpm build" 48 | }, 49 | "peerDependencies": { 50 | "@yuki-no/plugin-sdk": "^1.0.0" 51 | }, 52 | "devDependencies": { 53 | "@octokit/rest": "^21.1.1", 54 | "@yuki-no/plugin-sdk": "workspace:*" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /docs/COMPATIBILITY.md: -------------------------------------------------------------------------------- 1 | # Compatibility 2 | 3 | ## Plugin System 4 | 5 | Yuki-no maintains backward compatibility for all existing workflows. Recent updates introduced a plugin system that automatically handles legacy options. 6 | 7 | ### Legacy `release-tracking` Support 8 | 9 | Your existing `release-tracking` configurations continue to work without changes: 10 | 11 | ```yaml 12 | # Legacy style (still fully supported) 13 | - uses: Gumball12/yuki-no@v1 14 | with: 15 | release-tracking: true 16 | release-tracking-labels: | 17 | pending 18 | needs-release 19 | ``` 20 | 21 | ### Recommended Approach 22 | 23 | For new projects, we recommend using the explicit plugin syntax with environment variables: 24 | 25 | ```yaml 26 | # Recommended style 27 | - uses: Gumball12/yuki-no@v1 28 | env: 29 | YUKI_NO_RELEASE_TRACKING_LABELS: | 30 | pending 31 | needs-release 32 | with: 33 | plugins: | 34 | @yuki-no/plugin-release-tracking@latest 35 | ``` 36 | 37 | ### Key Changes 38 | 39 | - **Plugin System:** Release tracking is now implemented as a plugin [`@yuki-no/plugin-release-tracking`](../packages/release-tracking/) 40 | - **Environment Variables:** `release-tracking-labels` option moved to `YUKI_NO_RELEASE_TRACKING_LABELS` environment variable 41 | - **Automatic Migration:** Legacy `release-tracking` option automatically enables the plugin 42 | 43 | ### Deprecated Options 44 | 45 | The following options are deprecated but still supported for backward compatibility: 46 | 47 | - `release-tracking`: Use `plugins: ["@yuki-no/plugin-release-tracking"]` instead 48 | - `release-tracking-labels`: Use `env.YUKI_NO_RELEASE_TRACKING_LABELS` instead 49 | -------------------------------------------------------------------------------- /packages/batch-pr/utils/createPrBody.ts: -------------------------------------------------------------------------------- 1 | import { log } from '@yuki-no/plugin-sdk/utils/log'; 2 | 3 | export type BatchIssueType = 'Resolved'; 4 | 5 | type BatchIssueStatus = { 6 | number: number; 7 | type: BatchIssueType; 8 | }; 9 | 10 | type PrBodyOptions = { 11 | excludedFiles?: string[]; 12 | }; 13 | 14 | export const createPrBody = ( 15 | issueStatus: BatchIssueStatus[], 16 | { excludedFiles } = {} as PrBodyOptions, 17 | ): string => { 18 | log('I', `createPrBody :: Creating PR body for ${issueStatus.length} issues`); 19 | 20 | const resolvedIssueComments = issueStatus.map(createIssueComment); 21 | const excludedFileComments = excludedFiles?.map(createExcludedFileComment); 22 | 23 | return ` 24 | > [!CAUTION] 25 | > **DO NOT EDIT THIS PR MANUALLY** 26 | > This PR is automatically managed by the Batch PR plugin. Manual changes will be overwritten. 27 | 28 | ## ❄️ Batch Pull Request 29 | 30 | This PR collects opened Yuki-no translation issues into a single pull request. 31 | 32 | ### Resolved Issues 33 | 34 | ${resolvedIssueComments.join('\n')} 35 | 36 | ### Excluded Files (manual changes required) 37 | 38 | > These files matched \`YUKI_NO_BATCH_PR_EXCLUDE\` and were not applied by the plugin. Please update them manually. 39 | 40 | ${excludedFileComments?.join('\n')} 41 | 42 | --- 43 | *Generated by [Yuki-no Batch PR Plugin](https://github.com/Gumball12/yuki-no/tree/next/packages/batch-pr/)* 44 | `; 45 | }; 46 | 47 | const createIssueComment = (issueStatus: BatchIssueStatus): string => 48 | `${issueStatus.type} #${issueStatus.number}`; 49 | 50 | const createExcludedFileComment = (excludedFileName: string): string => 51 | `- \`${excludedFileName}\``; 52 | -------------------------------------------------------------------------------- /packages/batch-pr/utils/filterPendedTranslationIssues.ts: -------------------------------------------------------------------------------- 1 | import type { GitHub } from '@yuki-no/plugin-sdk/infra/github'; 2 | import type { Issue } from '@yuki-no/plugin-sdk/types/github'; 3 | import { formatError, log } from '@yuki-no/plugin-sdk/utils/log'; 4 | 5 | // NOTE: Filter out issues that are pending @yuki-no/plugin-release-tracking status 6 | export const filterPendedTranslationIssues = async ( 7 | github: GitHub, 8 | translationIssues: Issue[], 9 | ): Promise => { 10 | const pendedTranslationLabels = await getYukiNoReleaseTrackingLabels(github); 11 | log( 12 | 'I', 13 | `filterPendedTranslationIssues :: Getting release tracking labels [${pendedTranslationLabels.join(', ')}]`, 14 | ); 15 | 16 | return translationIssues.filter(({ labels }) => 17 | labels.every(l => !pendedTranslationLabels.includes(l)), 18 | ); 19 | }; 20 | 21 | const getYukiNoReleaseTrackingLabels = async ( 22 | github: GitHub, 23 | ): Promise => { 24 | try { 25 | const { getReleaseTrackingLabels } = await import( 26 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 27 | // @ts-ignore 28 | '@yuki-no/plugin-release-tracking/getReleaseTrackingLabels' 29 | ); 30 | 31 | log( 32 | 'I', 33 | 'getYukiNoReleaseTrackingLabels :: use @yuki-no/plugin-release-tracking', 34 | ); 35 | 36 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 37 | // @ts-ignore 38 | return getReleaseTrackingLabels(github); 39 | } catch (error) { 40 | log( 41 | 'I', 42 | `getYukiNoReleaseTrackingLabels :: cannot find @yuki-no/plugin-release-tracking / ${formatError(error)}`, 43 | ); 44 | } 45 | 46 | return []; 47 | }; 48 | -------------------------------------------------------------------------------- /packages/release-tracking/README.md: -------------------------------------------------------------------------------- 1 | # @yuki-no/plugin-release-tracking 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/@yuki-no/plugin-release-tracking?style=flat-square&label=@yuki-no/plugin-release-tracking)](https://www.npmjs.com/package/@yuki-no/plugin-release-tracking) 4 | 5 | This plugin tracks the release status for each commit and automatically updates issue information with release details. 6 | 7 | ## Features 8 | 9 | - **Automatic Label Management:** Automatically adds specified labels for unreleased changes and removes them after release. 10 | - **Release Status Comments:** Automatically adds comments showing pre-release and release status to each issue. 11 | 12 | ## Usage 13 | 14 | ```yaml 15 | - uses: Gumball12/yuki-no@v1 16 | env: 17 | # [optional] 18 | # Labels to add for unreleased changes 19 | YUKI_NO_RELEASE_TRACKING_LABELS: | 20 | pending 21 | needs-release 22 | with: 23 | plugins: | 24 | @yuki-no/plugin-release-tracking@latest 25 | ``` 26 | 27 | ### Configuration 28 | 29 | This plugin reads configuration from environment variables: 30 | 31 | - `YUKI_NO_RELEASE_TRACKING_LABELS` (_optional_): Labels to add for unreleased changes (default: `pending`) 32 | 33 | To avoid conflicts, overlapping labels in `YUKI_NO_RELEASE_TRACKING_LABELS` with yuki-no `labels` option are automatically excluded. 34 | 35 | ## Comment Generation 36 | 37 | Comments are added to each issue in the following format: 38 | 39 | ```md 40 | - pre-release: [v1.0.0-beta.1](https://github.com/owner/repo/releases/tag/v1.0.0-beta.1) 41 | - release: [v1.0.0](https://github.com/owner/repo/releases/tag/v1.0.0) 42 | ``` 43 | 44 | When no release information is available: 45 | 46 | ```md 47 | - pre-release: none 48 | - release: none 49 | ``` 50 | -------------------------------------------------------------------------------- /packages/release-tracking/utils/getLastIssueComments.ts: -------------------------------------------------------------------------------- 1 | import type { GitHub } from '@yuki-no/plugin-sdk/infra/github'; 2 | 3 | type Comment = { 4 | body?: string; 5 | }; 6 | 7 | export const getLastIssueComment = async ( 8 | github: GitHub, 9 | issueNumber: number, 10 | ): Promise => { 11 | const response = await github.api.issues.listComments({ 12 | ...github.ownerAndRepo, 13 | issue_number: issueNumber, 14 | }); 15 | 16 | const comments = response.data.map(item => ({ 17 | body: item.body, 18 | })); 19 | 20 | const lastReleaseComment = findLastReleaseComment(comments)?.body; 21 | 22 | if (!lastReleaseComment) { 23 | return ''; 24 | } 25 | 26 | return extractReleaseComment(lastReleaseComment); 27 | }; 28 | 29 | const findLastReleaseComment = (comments: Comment[]) => 30 | comments.findLast(isReleaseTrackingComment); 31 | 32 | const isReleaseTrackingComment = ({ body }: Comment): boolean => { 33 | if (!body) { 34 | return false; 35 | } 36 | 37 | const hasPrereleaseComment = 38 | body.includes('- pre-release: none') || 39 | body.match(/- pre-release: \[.+?\]\(https:\/\/github\.com\/.+?\)/) !== null; 40 | const hasReleaseComment = 41 | body.includes('- release: none') || 42 | body.match(/- release: \[.+?\]\(https:\/\/github\.com\/.+?\)/) !== null; 43 | 44 | return hasPrereleaseComment && hasReleaseComment; 45 | }; 46 | 47 | const extractReleaseComment = (body: string): string => { 48 | const lines = body.split('\n'); 49 | const preReleaseComment = lines.find(line => 50 | line.startsWith('- pre-release: '), 51 | ); 52 | const releaseComment = lines.find(line => line.startsWith('- release: ')); 53 | 54 | return [preReleaseComment, releaseComment].join('\n'); 55 | }; 56 | -------------------------------------------------------------------------------- /packages/batch-pr/utils/extractFileChanges.ts: -------------------------------------------------------------------------------- 1 | import type { FileChange } from '../types'; 2 | 3 | import { createFileChanges } from './createFileChanges'; 4 | import { parseFileStatuses } from './parseFileStatuses'; 5 | 6 | import type { Git } from '@yuki-no/plugin-sdk/infra/git'; 7 | import type { FileNameFilter } from '@yuki-no/plugin-sdk/utils/createFileNameFilter'; 8 | import { log } from '@yuki-no/plugin-sdk/utils/log'; 9 | 10 | type ExtractOpts = { 11 | onExcluded?: (path: string) => void; 12 | rootDir?: string; 13 | }; 14 | 15 | export const extractFileChanges = ( 16 | headGit: Git, 17 | hash: string, 18 | fileNameFilter: FileNameFilter, 19 | { onExcluded, rootDir } = {} as ExtractOpts, 20 | ): FileChange[] => { 21 | log('I', `extractFileChanges :: Starting extraction for hash: ${hash}`); 22 | 23 | const fileStatusString = headGit.exec( 24 | `show --name-status --format="" ${hash}`, 25 | ); 26 | const fileStatuses = parseFileStatuses( 27 | fileStatusString, 28 | fileNameFilter, 29 | onExcluded, 30 | ); 31 | 32 | log('I', `extractFileChanges :: Found ${fileStatuses.length} file statuses`); 33 | 34 | if (fileStatuses.length === 0) { 35 | log( 36 | 'I', 37 | 'extractFileChanges :: No file changes found, returning empty array', 38 | ); 39 | return []; 40 | } 41 | 42 | log( 43 | 'I', 44 | `extractFileChanges :: Processing ${fileStatuses.length} file statuses`, 45 | ); 46 | const fileChanges: FileChange[] = []; 47 | 48 | for (const fileStatus of fileStatuses) { 49 | fileChanges.push(...createFileChanges(headGit, hash, fileStatus, rootDir)); 50 | } 51 | 52 | log( 53 | 'S', 54 | `extractFileChanges :: Successfully extracted ${fileChanges.length} file changes`, 55 | ); 56 | return fileChanges; 57 | }; 58 | -------------------------------------------------------------------------------- /packages/core/plugin/index.ts: -------------------------------------------------------------------------------- 1 | import type { YukiNoPlugin } from '../types/plugin'; 2 | 3 | export const loadPlugins = async (names: string[]): Promise => { 4 | const plugins: YukiNoPlugin[] = []; 5 | 6 | for (const name of names) { 7 | try { 8 | const id = getResolveId(name); 9 | const mod = await import(id); 10 | const plugin = mod.default as YukiNoPlugin | undefined; 11 | 12 | if (!plugin) { 13 | throw new Error( 14 | `Plugin "${name}" does not export a default plugin object`, 15 | ); 16 | } 17 | 18 | if (!plugin.name) { 19 | throw new Error(`Plugin "${name}" must have a "name" property`); 20 | } 21 | 22 | plugins.push(plugin); 23 | } catch (error) { 24 | const err = error as Error; 25 | const resolvedId = getResolveId(name); 26 | const contextInfo = [ 27 | `Failed to load plugin "${name}": ${err.message}`, 28 | `Resolved ID: ${resolvedId}`, 29 | `Original plugin specification: ${name}`, 30 | ]; 31 | 32 | // Add version info if available 33 | if (name !== resolvedId) { 34 | const versionPart = name.replace(resolvedId, '').replace(/^@/, ''); 35 | if (versionPart) { 36 | contextInfo.push(`Version specification: ${versionPart}`); 37 | } 38 | } 39 | 40 | throw new Error(contextInfo.join('\n')); 41 | } 42 | } 43 | 44 | return plugins; 45 | }; 46 | 47 | export const getResolveId = (name: string): string => { 48 | const isScopedPackage = name.startsWith('@'); 49 | if (isScopedPackage) { 50 | return name.split('@').slice(0, 2).join('@'); 51 | } 52 | 53 | const hasVersion = name.includes('@'); 54 | if (hasVersion) { 55 | return name.split('@')[0]; 56 | } 57 | 58 | return name; 59 | }; 60 | -------------------------------------------------------------------------------- /packages/release-tracking/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yuki-no/plugin-release-tracking", 3 | "version": "1.0.2", 4 | "description": "Release tracking plugin for yuki-no - Tracks release status for commits and manages issue labels/comments automatically", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/Gumball12/yuki-no.git", 8 | "directory": "packages/release-tracking" 9 | }, 10 | "author": { 11 | "name": "Gumball12", 12 | "email": "to@shj.rip", 13 | "url": "https://github.com/Gumball12" 14 | }, 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/Gumball12/yuki-no/issues" 18 | }, 19 | "homepage": "https://github.com/Gumball12/yuki-no/tree/next/packages/release-tracking#readme", 20 | "keywords": [ 21 | "yuki-no", 22 | "plugins", 23 | "sync", 24 | "documentation", 25 | "automatically", 26 | "translation", 27 | "github-actions" 28 | ], 29 | "type": "module", 30 | "main": "dist/index.js", 31 | "types": "dist/index.d.ts", 32 | "exports": { 33 | ".": { 34 | "development": "./index.ts", 35 | "import": "./dist/index.js", 36 | "types": "./dist/index.d.ts" 37 | }, 38 | "./getReleaseTrackingLabels": { 39 | "development": "./utils/getReleaseTrackingLabels.ts", 40 | "import": "./dist/utils/getReleaseTrackingLabels.js", 41 | "types": "./dist/utils/getReleaseTrackingLabels.d.ts" 42 | } 43 | }, 44 | "files": [ 45 | "dist" 46 | ], 47 | "scripts": { 48 | "build": "rm -rf dist && tsc -p tsconfig.build.json", 49 | "prepublishOnly": "pnpm build" 50 | }, 51 | "peerDependencies": { 52 | "@yuki-no/plugin-sdk": "^1.0.0" 53 | }, 54 | "devDependencies": { 55 | "@yuki-no/plugin-sdk": "workspace:*", 56 | "@types/semver": "^7.7.0" 57 | }, 58 | "dependencies": { 59 | "semver": "^7.7.2" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/core/utils-infra/getOpenedIssues.ts: -------------------------------------------------------------------------------- 1 | import type { GitHub } from '../infra/github'; 2 | import type { Issue } from '../types/github'; 3 | import { isNotEmpty } from '../utils/common'; 4 | import { extractHashFromIssue } from '../utils/common'; 5 | import { log } from '../utils/log'; 6 | 7 | export const getOpenedIssues = async (github: GitHub): Promise => { 8 | log('I', 'getOpenedIssues :: Starting search for open issues'); 9 | 10 | const allIssues = await github.api.paginate(github.api.issues.listForRepo, { 11 | ...github.ownerAndRepo, 12 | state: 'open', 13 | per_page: 100, 14 | }); 15 | 16 | const totalIssuesChecked = allIssues.length; 17 | 18 | const openedIssuesWithoutHash = allIssues.map>(item => ({ 19 | number: item.number, 20 | body: item.body ?? '', 21 | isoDate: item.created_at, 22 | labels: item.labels 23 | .map(convGithubIssueLabelToString) 24 | .filter(isNotEmpty) 25 | .sort(), 26 | })); 27 | 28 | const issues = openedIssuesWithoutHash 29 | .filter(issue => isYukiNoIssue(github.configuredLabels, issue)) 30 | .map(issue => ({ ...issue, hash: extractHashFromIssue(issue) })) 31 | .filter(hasHash); 32 | 33 | log( 34 | 'I', 35 | `getOpenedIssues :: Completed: Found ${issues.length} Yuki-no issues out of ${totalIssuesChecked} total issues`, 36 | ); 37 | 38 | issues.sort((a, b) => (a.isoDate > b.isoDate ? 1 : -1)); 39 | 40 | return issues; 41 | }; 42 | 43 | const convGithubIssueLabelToString = ( 44 | label: string | { name?: string }, 45 | ): string => (typeof label === 'string' ? label : (label.name ?? '')); 46 | 47 | const isYukiNoIssue = ( 48 | configuredLabels: string[], 49 | issue: Pick, 50 | ): boolean => configuredLabels.every(cl => issue.labels.includes(cl)); 51 | 52 | const hasHash = (issue: { hash?: string }): issue is Issue => 53 | issue.hash !== undefined && issue.hash.length > 0; 54 | -------------------------------------------------------------------------------- /packages/release-tracking/utils/getRelease.ts: -------------------------------------------------------------------------------- 1 | import type { Git } from '@yuki-no/plugin-sdk/infra/git'; 2 | import { splitByNewline } from '@yuki-no/plugin-sdk/utils/input'; 3 | import { log } from '@yuki-no/plugin-sdk/utils/log'; 4 | import { valid as isValidVersion, parse as parseVersion } from 'semver'; 5 | 6 | type Tag = { 7 | version: string; 8 | url: string; 9 | }; 10 | 11 | export type ReleaseInfo = { 12 | prerelease: Tag | undefined; 13 | release: Tag | undefined; 14 | }; 15 | 16 | export const getRelease = (git: Git, commitHash: string): ReleaseInfo => { 17 | log('I', `getRelease :: Retrieving release list for commit ${commitHash}`); 18 | const result = git.exec(`tag --contains ${commitHash}`); 19 | 20 | if (!result.length) { 21 | log('I', 'getRelease :: Not released'); 22 | return { 23 | prerelease: undefined, 24 | release: undefined, 25 | }; 26 | } 27 | 28 | const versions = splitByNewline(result); 29 | const parsedVersions = versions 30 | .filter(v => isValidVersion(v)) 31 | .map(v => parseVersion(v)); 32 | 33 | const firstPrereleaseVersion = parsedVersions.find( 34 | v => v?.prerelease.length && v.prerelease.length > 0, 35 | )?.raw; 36 | const firstReleaseVersion = parsedVersions.find( 37 | v => v?.prerelease.length === 0, 38 | )?.raw; 39 | 40 | const releaseInfo = { 41 | prerelease: createTag(git.repoUrl, firstPrereleaseVersion), 42 | release: createTag(git.repoUrl, firstReleaseVersion), 43 | }; 44 | 45 | log( 46 | 'I', 47 | `getRelease :: Released (pre: ${releaseInfo.prerelease?.version ?? ''} / prod: ${releaseInfo.release?.version ?? ''})`, 48 | ); 49 | 50 | return releaseInfo; 51 | }; 52 | 53 | const createTag = (repoUrl: string, version?: string): Tag | undefined => { 54 | if (!version) { 55 | return; 56 | } 57 | 58 | const tagUrl = `${repoUrl}/releases/tag/${version}`; 59 | 60 | return { 61 | url: tagUrl, 62 | version, 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /packages/core/tests/utils-infra/createIssue.test.ts: -------------------------------------------------------------------------------- 1 | import { GitHub } from '../../infra/github'; 2 | import { createIssue } from '../../utils-infra/createIssue'; 3 | 4 | import { beforeEach, expect, it, vi } from 'vitest'; 5 | 6 | const MOCK_LABELS = ['test-label']; 7 | const MOCK_HEAD_REPO_SPEC = { 8 | owner: 'test-owner', 9 | name: 'head-repo', 10 | branch: 'main', 11 | }; 12 | const MOCK_COMMIT = { 13 | hash: '0123456789abcdef', 14 | title: 'test commit msg', 15 | isoDate: '2023-01-01T12:00:00Z', 16 | fileNames: ['test.ts'], 17 | }; 18 | 19 | const mockCreate = vi.fn(); 20 | 21 | vi.mock('../../infra/github', () => ({ 22 | GitHub: vi.fn().mockImplementation(() => ({ 23 | api: { issues: { create: mockCreate } }, 24 | ownerAndRepo: { 25 | owner: MOCK_HEAD_REPO_SPEC.owner, 26 | repo: MOCK_HEAD_REPO_SPEC.name, 27 | }, 28 | configuredLabels: MOCK_LABELS, 29 | repoSpec: { 30 | owner: MOCK_HEAD_REPO_SPEC.owner, 31 | name: MOCK_HEAD_REPO_SPEC.name, 32 | branch: MOCK_HEAD_REPO_SPEC.branch, 33 | }, 34 | })), 35 | })); 36 | 37 | const MOCK_CONFIG = { 38 | accessToken: 'test-token', 39 | labels: MOCK_LABELS, 40 | repoSpec: MOCK_HEAD_REPO_SPEC, 41 | }; 42 | 43 | const mockGitHub = new GitHub(MOCK_CONFIG); 44 | 45 | beforeEach(() => { 46 | vi.clearAllMocks(); 47 | }); 48 | 49 | it('Should create issue correctly', async () => { 50 | const ISSUE_NUM = 123; 51 | 52 | mockCreate.mockResolvedValue({ 53 | data: { 54 | number: ISSUE_NUM, 55 | }, 56 | }); 57 | 58 | const meta = { 59 | title: MOCK_COMMIT.title, 60 | body: `body`, 61 | labels: MOCK_LABELS, 62 | }; 63 | 64 | const issue = await createIssue(mockGitHub, meta); 65 | 66 | expect(issue.number).toBe(ISSUE_NUM); 67 | expect(mockCreate).toHaveBeenCalledWith({ 68 | owner: MOCK_HEAD_REPO_SPEC.owner, 69 | repo: MOCK_HEAD_REPO_SPEC.name, 70 | title: meta.title, 71 | body: meta.body, 72 | labels: meta.labels, 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /packages/core/tests/utils-infra/getLatestSuccessfulRunISODate.test.ts: -------------------------------------------------------------------------------- 1 | import { GitHub } from '../../infra/github'; 2 | import { getLatestSuccessfulRunISODate } from '../../utils-infra/getLatestSuccessfulRunISODate'; 3 | 4 | import { beforeEach, expect, it, vi } from 'vitest'; 5 | 6 | const ACTION_NAME = 'yuki-no'; 7 | 8 | const mockListWorkflowRunsForRepo = vi.fn(); 9 | 10 | vi.mock('../../infra/github', () => ({ 11 | GitHub: vi.fn().mockImplementation(() => ({ 12 | api: { actions: { listWorkflowRunsForRepo: mockListWorkflowRunsForRepo } }, 13 | })), 14 | })); 15 | 16 | const MOCK_CONFIG = { 17 | accessToken: 'test-token', 18 | labels: ['test-label'], 19 | repoSpec: { 20 | owner: 'test-owner', 21 | name: 'test-repo', 22 | branch: 'main', 23 | }, 24 | }; 25 | 26 | const mockGitHub = new GitHub(MOCK_CONFIG); 27 | 28 | beforeEach(() => { 29 | vi.clearAllMocks(); 30 | }); 31 | 32 | it('Should return undefined when there are no successful workflow runs', async () => { 33 | mockListWorkflowRunsForRepo.mockResolvedValue({ 34 | data: { 35 | workflow_runs: [], 36 | }, 37 | }); 38 | 39 | const result = await getLatestSuccessfulRunISODate(mockGitHub); 40 | 41 | expect(result).toBeUndefined(); 42 | }); 43 | 44 | it('Should return the last execution time when an action with the matching workflow name exists', async () => { 45 | const EXPECTED_LAST_CREATED_AT = '2023-01-04T12:00:00Z'; 46 | 47 | mockListWorkflowRunsForRepo.mockResolvedValue({ 48 | data: { 49 | workflow_runs: [ 50 | { name: ACTION_NAME, created_at: '2023-01-03T12:00:00Z' }, 51 | { name: 'other-action', created_at: '2023-01-03T12:00:00Z' }, 52 | { name: 'another-action', created_at: '2023-01-02T12:00:00Z' }, 53 | { name: ACTION_NAME, created_at: EXPECTED_LAST_CREATED_AT }, 54 | ], 55 | }, 56 | }); 57 | 58 | const result = await getLatestSuccessfulRunISODate(mockGitHub); 59 | 60 | expect(result).toBe(EXPECTED_LAST_CREATED_AT); 61 | }); 62 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | # -v 10 | if: contains(github.event.release.tag_name, '-v') 11 | 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | id-token: write 16 | 17 | steps: 18 | - name: Extract package info 19 | id: package 20 | run: | 21 | TAG="${{ github.event.release.tag_name }}" 22 | PACKAGE_NAME=${TAG%-v*} 23 | VERSION=${TAG#*-v} 24 | echo "path=packages/$PACKAGE_NAME" >> $GITHUB_OUTPUT 25 | echo "version=$VERSION" >> $GITHUB_OUTPUT 26 | 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | with: 30 | token: ${{ secrets.GITHUB_TOKEN }} 31 | fetch-depth: 0 32 | ref: next 33 | 34 | - run: corepack enable 35 | 36 | - name: Setup Node.js 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: '22' 40 | registry-url: 'https://registry.npmjs.org' 41 | cache: 'pnpm' 42 | 43 | - run: npm i -g npm@latest 44 | 45 | - run: pnpm install --frozen-lockfile 46 | - run: pnpm -r build 47 | - run: pnpm lint 48 | - run: pnpm test 49 | env: 50 | MOCKED_REQUEST_TEST: ${{ secrets.MOCKED_REQUEST_TEST }} 51 | 52 | - name: Update package.json version 53 | working-directory: ${{ steps.package.outputs.path }} 54 | run: | 55 | npm version ${{ steps.package.outputs.version }} --no-git-tag-version 56 | git config --global user.name "github-actions[bot]" 57 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 58 | git add package.json 59 | git commit -m "chore: bump ${{ steps.package.outputs.path }} to v${{ steps.package.outputs.version }}" 60 | git push origin HEAD:next 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | 64 | - name: Publish package 65 | working-directory: ${{ steps.package.outputs.path }} 66 | run: npm publish 67 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yuki-no/plugin-sdk", 3 | "version": "1.0.8", 4 | "description": "A GitHub Action that tracks changes between repositories. It creates GitHub issues based on commits from a head repository, making it ideal for documentation translation projects.", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/Gumball12/yuki-no.git", 8 | "directory": "packages/core" 9 | }, 10 | "author": { 11 | "name": "Gumball12", 12 | "email": "to@shj.rip", 13 | "url": "https://github.com/Gumball12" 14 | }, 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/Gumball12/yuki-no/issues" 18 | }, 19 | "homepage": "https://github.com/Gumball12/yuki-no#readme", 20 | "keywords": [ 21 | "yuki-no", 22 | "sync", 23 | "documentation", 24 | "automatically", 25 | "typescript", 26 | "translation", 27 | "github-actions", 28 | "translation-tools", 29 | "issue-tracking" 30 | ], 31 | "type": "module", 32 | "exports": { 33 | "./infra/*": { 34 | "development": "./infra/*.ts", 35 | "import": "./dist/infra/*.js", 36 | "types": "./dist/infra/*.d.ts" 37 | }, 38 | "./types/*": { 39 | "types": "./dist/types/*.d.ts" 40 | }, 41 | "./utils/*": { 42 | "development": "./utils/*.ts", 43 | "import": "./dist/utils/*.js", 44 | "types": "./dist/utils/*.d.ts" 45 | }, 46 | "./utils-infra/*": { 47 | "development": "./utils-infra/*.ts", 48 | "import": "./dist/utils-infra/*.js", 49 | "types": "./dist/utils-infra/*.d.ts" 50 | } 51 | }, 52 | "files": [ 53 | "dist" 54 | ], 55 | "scripts": { 56 | "build": "rm -rf dist && tsc -p tsconfig.build.json", 57 | "prepublishOnly": "pnpm build", 58 | "start": "tsx ./index.ts", 59 | "start:dev": "tsx --conditions=development --env-file=.env ./index.ts" 60 | }, 61 | "dependencies": { 62 | "@octokit/plugin-retry": "^7.2.0", 63 | "@octokit/plugin-throttling": "^9.6.0", 64 | "@octokit/rest": "^21.0.2", 65 | "colors": "^1.4.0", 66 | "picomatch": "^4.0.2", 67 | "shelljs": "^0.8.5" 68 | }, 69 | "devDependencies": { 70 | "@types/picomatch": "^3.0.2", 71 | "@types/shelljs": "^0.8.15" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/core/utils/common.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | import path from 'node:path'; 3 | 4 | export const assert = (condition: boolean, message: string): void => { 5 | if (!condition) { 6 | throw new Error(message); 7 | } 8 | }; 9 | 10 | export const excludeFrom = ( 11 | excludeSource: string[], 12 | reference: string[], 13 | ): string[] => excludeSource.filter(sourceEl => !reference.includes(sourceEl)); 14 | 15 | export const chunk = (data: T[], chunkSize: number): T[][] => { 16 | if (chunkSize >= data.length) { 17 | return [data]; 18 | } 19 | 20 | if (chunkSize < 1) { 21 | throw new Error('Invalid chunkSize'); 22 | } 23 | 24 | return [...Array(Math.ceil(data.length / chunkSize))].map((_, ind) => 25 | data.slice(ind * chunkSize, (ind + 1) * chunkSize), 26 | ); 27 | }; 28 | 29 | export const uniqueWith = (value: V[], mapper: (v: V) => unknown): V[] => { 30 | if (value.length <= 1) { 31 | return [...value]; 32 | } 33 | 34 | const result: V[] = []; 35 | const seen = new Set(); 36 | 37 | for (const v of value) { 38 | const mapped = mapper(v); 39 | 40 | if (seen.has(mapped)) { 41 | continue; 42 | } 43 | 44 | result.push(v); 45 | seen.add(mapped); 46 | } 47 | 48 | return [...result]; 49 | }; 50 | 51 | export const mergeArray = (a: T[], b: T[]): T[] => { 52 | if (a.length === 0 && b.length === 0) { 53 | return []; 54 | } 55 | 56 | if (a.length === 0) { 57 | return [...b]; 58 | } 59 | 60 | if (b.length === 0) { 61 | return [...a]; 62 | } 63 | 64 | return [...a, ...b]; 65 | }; 66 | 67 | export const isNotEmpty = (value: T | undefined | null): value is T => { 68 | const isNotNullable = value !== undefined && value !== null; 69 | 70 | if (typeof value === 'string') { 71 | return value.length > 0; 72 | } 73 | 74 | return isNotNullable; 75 | }; 76 | 77 | export const unique = (value: T[]): T[] => Array.from(new Set(value)); 78 | 79 | const COMMIT_URL_REGEX = 80 | /https:\/\/github\.com\/[^/]+\/[^/]+\/commit\/([a-f0-9]{7,40})/; 81 | 82 | export const extractHashFromIssue = (issue: { 83 | body?: string; 84 | }): string | undefined => { 85 | const match = issue.body?.match(COMMIT_URL_REGEX); 86 | return match?.[1]; 87 | }; 88 | 89 | export const createTempFilePath = (prefix: string): string => 90 | path.join(os.tmpdir(), prefix); 91 | -------------------------------------------------------------------------------- /packages/core/tests/utils/log.test.ts: -------------------------------------------------------------------------------- 1 | import { formatError, log } from '../../utils/log'; 2 | 3 | import colors from 'colors/safe'; 4 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 5 | 6 | describe('log', () => { 7 | beforeEach(() => { 8 | vi.spyOn(console, 'info').mockImplementation(() => {}); 9 | vi.spyOn(console, 'warn').mockImplementation(() => {}); 10 | vi.spyOn(console, 'error').mockImplementation(() => {}); 11 | }); 12 | 13 | afterEach(() => { 14 | vi.clearAllMocks(); 15 | delete process.env.VERBOSE; 16 | }); 17 | 18 | it('Should log [INFO] message when VERBOSE is true', () => { 19 | process.env.VERBOSE = 'true'; 20 | log('I', 'Test message'); 21 | expect(console.info).toHaveBeenCalledWith( 22 | '[INFO]', 23 | colors.blue('Test message'), 24 | ); 25 | }); 26 | 27 | it('Should log [SUCCESS] message when VERBOSE is true', () => { 28 | process.env.VERBOSE = 'true'; 29 | log('S', 'Test message'); 30 | expect(console.info).toHaveBeenCalledWith( 31 | '[SUCCESS]', 32 | colors.green('Test message'), 33 | ); 34 | }); 35 | 36 | it('[WARNING] logs should always be output regardless of VERBOSE setting', () => { 37 | delete process.env.VERBOSE; 38 | log('W', 'Test message'); 39 | expect(console.warn).toHaveBeenCalledWith( 40 | '[WARNING]', 41 | colors.yellow('Test message'), 42 | ); 43 | }); 44 | 45 | it('[ERROR] logs should always be output regardless of VERBOSE setting', () => { 46 | delete process.env.VERBOSE; 47 | log('E', 'Test message'); 48 | expect(console.error).toHaveBeenCalledWith( 49 | '[ERROR]', 50 | colors.red('Test message'), 51 | ); 52 | }); 53 | 54 | it('Should not output [INFO] and [SUCCESS] logs when VERBOSE is false', () => { 55 | delete process.env.VERBOSE; 56 | log('I', 'Test message'); 57 | log('S', 'Test message'); 58 | expect(console.info).not.toHaveBeenCalled(); 59 | }); 60 | 61 | it('Should process VERBOSE setting regardless of case', () => { 62 | process.env.VERBOSE = 'TRUE'; 63 | log('I', 'Test message'); 64 | expect(console.info).toHaveBeenCalled(); 65 | }); 66 | }); 67 | 68 | describe('formatError', () => { 69 | it('Should format error', () => { 70 | const msg = 'formatted error'; 71 | const error = new Error(msg); 72 | expect(formatError(error)).toBe(msg); 73 | }); 74 | 75 | it('Should not format error', () => { 76 | const msg = { message: 'msg' }; 77 | expect(formatError(msg)).toBe(''); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /packages/release-tracking/utils/updateIssueCommentsByRelease.ts: -------------------------------------------------------------------------------- 1 | import { getLastIssueComment } from './getLastIssueComments'; 2 | import type { ReleaseInfo } from './getRelease'; 3 | import { getReleaseTrackingLabels } from './getReleaseTrackingLabels'; 4 | 5 | import type { GitHub } from '@yuki-no/plugin-sdk/infra/github'; 6 | import type { Issue } from '@yuki-no/plugin-sdk/types/github'; 7 | import { log } from '@yuki-no/plugin-sdk/utils/log'; 8 | 9 | export const updateIssueCommentByRelease = async ( 10 | github: GitHub, 11 | issue: Issue, 12 | releaseInfo: ReleaseInfo, 13 | releasesAvailable: boolean, 14 | ): Promise => { 15 | const comment = await getLastIssueComment(github, issue.number); 16 | const isReleased = comment.includes('- release: ['); 17 | 18 | log( 19 | 'I', 20 | `updateIssueCommentByRelease :: Attempting to add #${issue.number} comment`, 21 | ); 22 | 23 | if (isReleased) { 24 | log('I', 'updateIssueCommentByRelease :: Release comment already exists'); 25 | return; 26 | } 27 | 28 | const nextComment = createReleaseComment( 29 | github, 30 | releaseInfo, 31 | releasesAvailable, 32 | ); 33 | 34 | log('I', `updateIssueCommentByRelease :: Creating comment (${nextComment})`); 35 | 36 | if (nextComment === comment) { 37 | log( 38 | 'S', 39 | 'updateIssueCommentByRelease :: Not added (identical comment already exists)', 40 | ); 41 | return; 42 | } 43 | 44 | await github.api.issues.createComment({ 45 | ...github.ownerAndRepo, 46 | issue_number: issue.number, 47 | body: nextComment, 48 | }); 49 | 50 | log('S', 'updateIssueCommentByRelease :: Comment added successfully'); 51 | }; 52 | 53 | const createReleaseComment = ( 54 | github: GitHub, 55 | { prerelease, release }: ReleaseInfo, 56 | releasesAvailable: boolean, 57 | ): string => { 58 | const pRelContent = `- pre-release: ${prerelease ? `[${prerelease.version}](${prerelease.url})` : 'none'}`; 59 | const relContent = `- release: ${release ? `[${release.version}](${release.url})` : 'none'}`; 60 | 61 | const releaseTrackingLabels = getReleaseTrackingLabels(github); 62 | const releaseAvailableContent = 63 | !releasesAvailable && 64 | [ 65 | `> This comment and the \`${releaseTrackingLabels.join(', ')}\` label appear because release-tracking is enabled.`, 66 | '> To disable, remove `release-tracking` from the plugins list.', 67 | '\n', 68 | ].join('\n'); 69 | 70 | return [releaseAvailableContent, pRelContent, relContent] 71 | .filter(Boolean) 72 | .join('\n'); 73 | }; 74 | -------------------------------------------------------------------------------- /packages/core/utils-infra/getCommits.ts: -------------------------------------------------------------------------------- 1 | import type { Git } from '../infra/git'; 2 | import type { Config } from '../types/config'; 3 | import type { Commit } from '../types/git'; 4 | import { isNotEmpty } from '../utils/common'; 5 | import { createFileNameFilter } from '../utils/createFileNameFilter'; 6 | import { splitByNewline } from '../utils/input'; 7 | import { log } from '../utils/log'; 8 | 9 | export const COMMIT_SEP = ':COMMIT_START_SEP:'; 10 | export const COMMIT_DATA_SEPARATOR = ':COMMIT_DATA_SEP:'; 11 | 12 | export const getCommits = ( 13 | config: Pick, 14 | git: Git, 15 | latestSuccessfulRun?: string, 16 | ): Commit[] => { 17 | const command = [ 18 | 'log', 19 | 'origin/main', 20 | config.trackFrom ? `${config.trackFrom}..` : undefined, 21 | latestSuccessfulRun ? `--since="${latestSuccessfulRun}"` : undefined, 22 | '--name-only', 23 | `--format="${COMMIT_SEP}%H${COMMIT_DATA_SEPARATOR}%s${COMMIT_DATA_SEPARATOR}%aI"`, 24 | '--no-merges', 25 | ] 26 | .filter(isNotEmpty) 27 | .join(' '); 28 | 29 | log('I', `getCommits :: Attempting to extract commits: ${command}`); 30 | 31 | const result = git.exec(command); 32 | 33 | if (result.length === 0) { 34 | return []; 35 | } 36 | 37 | if (!result.includes(COMMIT_SEP)) { 38 | throw new Error(`Invalid trackFrom commit hash: ${config.trackFrom}`); 39 | } 40 | 41 | const fileNameFilter = createFileNameFilter(config); 42 | const commits = result 43 | .split(COMMIT_SEP) 44 | .filter(isNotEmpty) 45 | .map(commitString => splitByNewline(commitString)) 46 | .map(createCommitFromLog) 47 | .filter(commit => commit?.fileNames.some(fileNameFilter)) 48 | .filter(isNotEmpty); 49 | 50 | log('I', `getCommits :: Total ${commits.length} commits extracted`); 51 | 52 | if (commits.length > 0) { 53 | log( 54 | 'I', 55 | `getCommits :: Commit extraction period: ${commits[0].isoDate} ~ ${commits[commits.length - 1].isoDate}`, 56 | ); 57 | } 58 | 59 | commits.sort((a, b) => (a.isoDate > b.isoDate ? 1 : -1)); 60 | return commits; 61 | }; 62 | 63 | const createCommitFromLog = ([line, ...fileNames]: string[]): 64 | | Commit 65 | | undefined => { 66 | const parsed = line.split(COMMIT_DATA_SEPARATOR); 67 | 68 | if (parsed.filter(isNotEmpty).length !== 3) { 69 | return; 70 | } 71 | 72 | const [hash, title, date] = parsed; 73 | const isoDate = getISODate(date); 74 | 75 | return { 76 | title, 77 | isoDate, 78 | hash, 79 | fileNames, 80 | }; 81 | }; 82 | 83 | const getISODate = (atOrDate: string | Date) => 84 | new Date(atOrDate).toISOString().replace(/\.\d{3}Z$/, 'Z'); 85 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | # TEMPORARILY DISABLED 4 | # This automated review workflow is temporarily disabled to support 5 | # selective usage of Claude code reviews. Currently using mention-based 6 | # review workflow (claude.yml) for on-demand feedback only. 7 | 8 | on: 9 | pull_request: 10 | types: [opened, synchronize] 11 | 12 | jobs: 13 | claude-review: 14 | # Temporarily disabled - remove this below line to re-enable 15 | if: false 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | pull-requests: read 20 | issues: read 21 | id-token: write 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 1 28 | 29 | - name: Run Claude Code Review 30 | id: claude-review 31 | uses: anthropics/claude-code-action@beta 32 | with: 33 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 34 | 35 | model: 'claude-sonnet-4-20250514' # Avoid implicit model 36 | use_sticky_comment: true 37 | allowed_tools: 'Bash(pnpm install),Bash(pnpm run build),Bash(pnpm run test),Bash(pnpm run lint)' 38 | 39 | direct_prompt: | 40 | Review this PR systematically. Check each category and provide specific feedback: 41 | 42 | ✅ **Code Design & Architecture:** 43 | □ Is business logic separated from UI/infrastructure concerns? 44 | □ Are functions pure where possible (predictable inputs/outputs)? 45 | □ Is the code testable without major refactoring? 46 | □ Are APIs intuitive and well-designed from user perspective? 47 | □ Is dependency injection used for external dependencies? 48 | 49 | ✅ **Testing Strategy:** 50 | □ Do tests focus on behavior rather than implementation details? 51 | □ Is test coverage meaningful (business logic focused, not just metrics)? 52 | □ Are edge cases and error scenarios properly covered? 53 | □ Do tests follow Given-When-Then structure where applicable? 54 | □ Are tests independent and don't rely on execution order? 55 | 56 | ✅ **Code Quality:** 57 | □ Are performance considerations addressed? 58 | □ Are security concerns properly handled? 59 | □ Is error handling implemented correctly? 60 | □ Is type safety maintained (TypeScript best practices)? 61 | □ Are naming conventions clear and consistent? 62 | 63 | For each failing item, provide specific, actionable feedback with examples. 64 | Focus on what the code DOES, not how it's implemented internally. 65 | -------------------------------------------------------------------------------- /packages/batch-pr/utils/getTrackedIssues.ts: -------------------------------------------------------------------------------- 1 | import type { BatchIssueType } from './createPrBody'; 2 | 3 | import type { RestEndpointMethodTypes } from '@octokit/rest'; 4 | import { GitHub } from '@yuki-no/plugin-sdk/infra/github'; 5 | import type { Issue } from '@yuki-no/plugin-sdk/types/github'; 6 | import { log } from '@yuki-no/plugin-sdk/utils/log'; 7 | 8 | type GetTrackedIssuesReturns = { 9 | trackedIssues: Issue[]; 10 | shouldTrackIssues: Issue[]; 11 | }; 12 | 13 | export const getTrackedIssues = async ( 14 | github: GitHub, 15 | prNumber: number, 16 | notPendedTranslationIssues: Issue[], 17 | ): Promise => { 18 | log('I', `getTrackedIssues :: Processing PR #${prNumber}`); 19 | 20 | const prDetails = await getPrDetails(github, prNumber); 21 | const { body: prBody } = prDetails; 22 | 23 | if (!prBody?.length) { 24 | log('E', `getTrackedIssues :: PR #${prNumber} body is empty or missing`); 25 | throw new Error( 26 | `PR #${prNumber} body is empty or missing. Cannot extract tracked issue numbers.`, 27 | ); 28 | } 29 | 30 | const trackedIssueNumbers = extractTrackedIssueNumbers(prBody, 'Resolved'); 31 | log( 32 | 'I', 33 | `getTrackedIssues :: Found ${trackedIssueNumbers.length} tracked issue numbers in PR body`, 34 | ); 35 | 36 | const results = notPendedTranslationIssues.reduce( 37 | ({ trackedIssues, shouldTrackIssues }, translationIssue) => { 38 | if (trackedIssueNumbers.includes(translationIssue.number)) { 39 | trackedIssues.push(translationIssue); 40 | } else { 41 | shouldTrackIssues.push(translationIssue); 42 | } 43 | 44 | return { trackedIssues, shouldTrackIssues }; 45 | }, 46 | { 47 | trackedIssues: [], 48 | shouldTrackIssues: [], 49 | }, 50 | ); 51 | 52 | log( 53 | 'S', 54 | `getTrackedIssues :: Found ${results.trackedIssues.length} tracked issues and ${results.shouldTrackIssues.length} issues to track`, 55 | ); 56 | return results; 57 | }; 58 | 59 | type PrDetails = RestEndpointMethodTypes['pulls']['get']['response']['data']; 60 | 61 | const getPrDetails = async ( 62 | github: GitHub, 63 | prNumber: number, 64 | ): Promise => { 65 | const { data } = await github.api.pulls.get({ 66 | ...github.ownerAndRepo, 67 | pull_number: prNumber, 68 | }); 69 | 70 | return data; 71 | }; 72 | 73 | const extractTrackedIssueNumbers = ( 74 | prBody: string, 75 | type: BatchIssueType, 76 | ): number[] => { 77 | const resolvedPattern = new RegExp(`${type} #(\\d+)`, 'g'); 78 | const numbers: number[] = []; 79 | let match; 80 | 81 | while ((match = resolvedPattern.exec(prBody)) !== null) { 82 | numbers.push(parseInt(match[1], 10)); 83 | } 84 | 85 | return numbers; 86 | }; 87 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code for PR Reviews 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | pull_request_review: 9 | types: [submitted] 10 | 11 | jobs: 12 | claude: 13 | if: | 14 | (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '@claude')) || 15 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 16 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) 17 | 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: read 21 | pull-requests: read 22 | issues: read # Required for issue_comment events on PRs 23 | id-token: write 24 | actions: read # Required for Claude to read CI results on PRs 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 1 31 | 32 | - name: Run Claude Code 33 | id: claude 34 | uses: anthropics/claude-code-action@beta 35 | with: 36 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 37 | 38 | # Allows Claude to read CI results on PRs 39 | additional_permissions: | 40 | actions: read 41 | 42 | model: 'claude-sonnet-4-20250514' # Avoid implicit model 43 | allowed_tools: 'Bash(pnpm install),Bash(pnpm run build),Bash(pnpm run test),Bash(pnpm run lint)' 44 | 45 | custom_instructions: | 46 | You are a senior code reviewer for this TypeScript/Node.js project. 47 | Follow these core principles: 48 | 49 | 🧠 Design & Architecture Philosophy: 50 | - Always ask: "How can I make this API better and more intuitive?" 51 | - Verify business logic is properly separated from implementation details 52 | - Check if code is designed to be testable (hard-to-test code signals design problems) 53 | - Focus on pure functions and dependency injection where possible 54 | 55 | 🧪 Testing Philosophy: 56 | - Prioritize behavior-driven testing over implementation details 57 | - Look for Given-When-Then patterns in test structure 58 | - Focus on meaningful business logic testing, not just coverage metrics 59 | - Ensure tests verify behavior, not internal structure 60 | 61 | 💬 Communication Style: 62 | - Be constructive and educational in feedback 63 | - Explain the 'why' behind suggestions with examples 64 | - Provide alternative approaches when applicable 65 | - Use CI results and project context for comprehensive analysis 66 | - Adapt your response based on user's specific questions and context 67 | 68 | Remember: Testing is about creating reliable software, not just finding bugs. 69 | -------------------------------------------------------------------------------- /packages/core/infra/git.ts: -------------------------------------------------------------------------------- 1 | import type { Config, RepoSpec } from '../types/config'; 2 | import { createTempFilePath } from '../utils/common'; 3 | import { log } from '../utils/log'; 4 | 5 | import fs from 'node:fs'; 6 | import path from 'node:path'; 7 | import shell from 'shelljs'; 8 | 9 | type GitConfig = Pick & { 10 | repoSpec: RepoSpec; 11 | withClone?: boolean; 12 | }; 13 | 14 | type NotStartsWithGit = T extends `git${string}` ? never : T; 15 | 16 | export class Git { 17 | constructor(private readonly config: GitConfig) { 18 | log('I', 'Git[[Construct]] :: Git instance created'); 19 | 20 | if (config.withClone) { 21 | this.clone(); 22 | } 23 | } 24 | 25 | #dirName?: string; 26 | 27 | get dirName(): string { 28 | if (this.#dirName) { 29 | return this.#dirName; 30 | } 31 | 32 | this.#dirName = fs.mkdtempSync( 33 | createTempFilePath(`cloned-by-yuki-no__${this.config.repoSpec.name}__`), 34 | ); 35 | 36 | return this.#dirName; 37 | } 38 | 39 | exec(command: NotStartsWithGit): string { 40 | shell.cd(this.dirName); 41 | 42 | const result = shell.exec(`git ${command}`); 43 | if (result.code !== 0) { 44 | throw new GitCommandError( 45 | command, 46 | result.code, 47 | result.stderr, 48 | result.stdout, 49 | ); 50 | } 51 | 52 | return result.stdout.trim(); 53 | } 54 | 55 | clone(): void { 56 | const parentDirName = path.resolve(this.dirName, '../'); 57 | const baseName = path.basename(this.dirName); 58 | log('I', `Git.clone :: Cloning repository: ${parentDirName}/${baseName}`); 59 | 60 | shell.cd(parentDirName); 61 | 62 | if (fs.existsSync(baseName)) { 63 | fs.rmSync(baseName, { force: true, recursive: true }); 64 | } 65 | 66 | const authorizedRepoUrl = createAuthorizedRepoUrl( 67 | this.repoUrl, 68 | this.config, 69 | ); 70 | 71 | // Execute exec directly only here since repoDir doesn't exist yet 72 | shell.exec(`git clone ${authorizedRepoUrl} ${baseName}`); 73 | 74 | this.exec(`config user.name "${this.config.userName}"`); 75 | this.exec(`config user.email "${this.config.email}"`); 76 | 77 | log( 78 | 'S', 79 | `Git.clone :: Repository clone completed with '${this.config.userName}' and '${this.config.email}' / ${baseName}`, 80 | ); 81 | } 82 | 83 | get repoUrl(): string { 84 | return `https://github.com/${this.config.repoSpec.owner}/${this.config.repoSpec.name}`; 85 | } 86 | } 87 | 88 | const createAuthorizedRepoUrl = ( 89 | repoUrl: string, 90 | config: Pick, 91 | ): string => 92 | repoUrl.replace( 93 | 'https://', 94 | `https://${config.userName}:${config.accessToken}@`, 95 | ); 96 | 97 | export class GitCommandError extends Error { 98 | constructor( 99 | readonly command: string, 100 | readonly exitCode: number, 101 | readonly stderr: string, 102 | readonly stdout: string, 103 | ) { 104 | super( 105 | `Git command failed: git ${command}\nExit code: ${exitCode}\nError: ${stderr}`, 106 | ); 107 | this.name = 'GitCommandError'; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /packages/release-tracking/index.ts: -------------------------------------------------------------------------------- 1 | import { mergeArray, uniqueWith } from './utils/array'; 2 | import { getRelease } from './utils/getRelease'; 3 | import { hasAnyRelease } from './utils/hasAnyRelease'; 4 | import { updateIssueCommentByRelease } from './utils/updateIssueCommentsByRelease'; 5 | import { updateIssueLabelsByRelease } from './utils/updateIssueLabelsByRelease'; 6 | 7 | import { Git } from '@yuki-no/plugin-sdk/infra/git'; 8 | import { GitHub } from '@yuki-no/plugin-sdk/infra/github'; 9 | import type { Issue } from '@yuki-no/plugin-sdk/types/github'; 10 | import type { YukiNoPlugin } from '@yuki-no/plugin-sdk/types/plugin'; 11 | import { getOpenedIssues } from '@yuki-no/plugin-sdk/utils-infra/getOpenedIssues'; 12 | import { log } from '@yuki-no/plugin-sdk/utils/log'; 13 | 14 | const releaseTrackingPlugin: YukiNoPlugin = { 15 | name: 'release-tracking', 16 | 17 | async onAfterCreateIssue(ctx) { 18 | const git = new Git({ 19 | ...ctx.config, 20 | repoSpec: ctx.config.headRepoSpec, 21 | withClone: true, 22 | }); 23 | const github = new GitHub({ 24 | ...ctx.config, 25 | repoSpec: ctx.config.upstreamRepoSpec, 26 | }); 27 | 28 | await processReleaseTrackingForIssue(github, git, ctx.issue); 29 | }, 30 | 31 | async onFinally(ctx) { 32 | const git = new Git({ 33 | ...ctx.config, 34 | repoSpec: ctx.config.headRepoSpec, 35 | withClone: true, 36 | }); 37 | const github = new GitHub({ 38 | ...ctx.config, 39 | repoSpec: ctx.config.upstreamRepoSpec, 40 | }); 41 | 42 | await processReleaseTracking(github, git); 43 | }, 44 | }; 45 | 46 | export default releaseTrackingPlugin; 47 | 48 | const processReleaseTracking = async ( 49 | github: GitHub, 50 | git: Git, 51 | additionalIssues: Issue[] = [], 52 | ): Promise => { 53 | log('I', '=== Release tracking started ==='); 54 | 55 | const openedIssues = await getOpenedIssues(github); 56 | const releaseTrackingIssues = uniqueWith( 57 | mergeArray(openedIssues, additionalIssues), 58 | ({ hash }) => hash, 59 | ); 60 | 61 | const releaseInfos = releaseTrackingIssues.map(issue => 62 | getRelease(git, issue.hash), 63 | ); 64 | const releasesAvailable = hasAnyRelease(git); 65 | 66 | for (let ind = 0; ind < releaseInfos.length; ind++) { 67 | const releaseInfo = releaseInfos[ind]; 68 | const openedIssue = releaseTrackingIssues[ind]; 69 | 70 | await updateIssueLabelsByRelease(github, openedIssue, releaseInfo); 71 | await updateIssueCommentByRelease( 72 | github, 73 | openedIssue, 74 | releaseInfo, 75 | releasesAvailable, 76 | ); 77 | } 78 | 79 | log( 80 | 'S', 81 | `releaseTracking :: Release information updated for ${releaseTrackingIssues.length} issues`, 82 | ); 83 | }; 84 | 85 | const processReleaseTrackingForIssue = async ( 86 | github: GitHub, 87 | git: Git, 88 | issue: Issue, 89 | ): Promise => { 90 | const releaseInfo = getRelease(git, issue.hash); 91 | const releasesAvailable = hasAnyRelease(git); 92 | 93 | await updateIssueLabelsByRelease(github, issue, releaseInfo); 94 | await updateIssueCommentByRelease( 95 | github, 96 | issue, 97 | releaseInfo, 98 | releasesAvailable, 99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /scripts/checkout.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | echo "🚀 Setting up Yuki-no..." 5 | 6 | # Clone Yuki-no repository 7 | git clone https://github.com/Gumball12/yuki-no.git 8 | 9 | cd yuki-no 10 | 11 | echo "Try to use ${YUKI_NO_VERSION}" 12 | 13 | # Checkout the specific version 14 | # If YUKI_NO_VERSION is not set, it will use 'main' 15 | git checkout "${YUKI_NO_VERSION:-main}" || { 16 | echo "Failed to checkout version ${YUKI_NO_VERSION:-main}" 17 | echo "Falling back to main branch" 18 | git checkout main 19 | } 20 | 21 | # Check if pnpm is installed, install if not 22 | if ! command -v pnpm &> /dev/null; then 23 | echo "📦 pnpm not found, installing pnpm..." 24 | npm install -g pnpm 25 | echo "✅ pnpm installed successfully" 26 | fi 27 | 28 | # Fix workspace package symlink issues when used as plugins 29 | # - Workspace packages get symlinked, but their dist directories don't exist 30 | # because local packages aren't built, causing runtime failures 31 | # - node-linker=hoisted: Creates flat node_modules, eliminates .pnpm virtual store 32 | # - symlink=false: Prevents workspace package symlinks (overrides special workspace handling) 33 | # - package-import-method=hardlink: Forces hardlinks from global store (consistent across filesystems) 34 | echo "node-linker=hoisted" > .npmrc 35 | echo "symlink=false" >> .npmrc 36 | echo "package-import-method=hardlink" >> .npmrc 37 | 38 | # Install base dependencies 39 | echo "📦 Installing base dependencies..." 40 | pnpm install 41 | 42 | # Install plugins with exact version requirement 43 | if [ ! -z "${PLUGINS:-}" ]; then 44 | echo "🔌 Installing plugins with exact version requirement..." 45 | 46 | while IFS= read -r plugin; do 47 | # Skip empty lines and whitespace-only lines 48 | [[ -z "${plugin// }" ]] && continue 49 | 50 | # Check for range versions (not allowed) 51 | if [[ "$plugin" =~ [~^] ]]; then 52 | echo "❌ Range versions (^, ~) are not allowed for security and reproducibility: $plugin" 53 | echo " Please specify exact version (e.g., plugin@1.0.0) or use @latest" 54 | exit 1 55 | fi 56 | 57 | # Check if plugin specifies a version 58 | if [[ ! "$plugin" =~ @ ]]; then 59 | echo "❌ Plugin must specify a version: $plugin" 60 | echo " Use exact version (e.g., plugin@1.0.0) or @latest" 61 | exit 1 62 | fi 63 | 64 | # Handle latest version with warning 65 | if [[ "$plugin" =~ @latest$ ]]; then 66 | echo "⚠️ Warning: Using @latest for $plugin" 67 | echo " This will install the latest available version, which may change over time" 68 | echo " Consider pinning to a specific version for reproducibility" 69 | elif [[ ! "$plugin" =~ @[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then 70 | echo "❌ Invalid version format: $plugin" 71 | echo " Use exact version (e.g., plugin@1.0.0, plugin@1.0.0-beta.1) or @latest" 72 | exit 1 73 | fi 74 | 75 | # Install npm package plugins with exact version 76 | echo "📦 Installing: $plugin" 77 | if pnpm add "$plugin"; then 78 | echo "✅ Successfully installed: $plugin" 79 | else 80 | echo "❌ Failed to install plugin: $plugin" 81 | exit 1 82 | fi 83 | 84 | done <<< "$PLUGINS" 85 | 86 | echo "🎉 All plugins installed successfully!" 87 | else 88 | echo "📝 No plugins specified - using base Yuki-no only" 89 | fi 90 | -------------------------------------------------------------------------------- /packages/core/utils-infra/lookupCommitsInIssues.ts: -------------------------------------------------------------------------------- 1 | import type { GitHub } from '../infra/github'; 2 | import type { RepoSpec } from '../types/config'; 3 | import type { Commit } from '../types/git'; 4 | import { isNotEmpty, unique } from '../utils/common'; 5 | import { extractHashFromIssue } from '../utils/common'; 6 | import { log } from '../utils/log'; 7 | 8 | const COMMIT_CHUNK_UNIT = 5; 9 | 10 | export const lookupCommitsInIssues = async ( 11 | github: GitHub, 12 | commits: Commit[], 13 | ): Promise => { 14 | if (commits.length === 0) { 15 | log('I', 'lookupCommitsInIssues :: No commits to check'); 16 | return []; 17 | } 18 | 19 | log( 20 | 'I', 21 | `lookupCommitsInIssues :: Starting to check ${commits.length} commits for GitHub Issues registration`, 22 | ); 23 | 24 | const chunkedCommitsList = chunk(commits, COMMIT_CHUNK_UNIT); 25 | const searchQueries = chunkedCommitsList.map(chunk => 26 | createCommitIssueSearchQuery(github.repoSpec, chunk), 27 | ); 28 | 29 | const notCreatedCommits: Commit[] = []; 30 | 31 | // For-loop to prevent parallel API calls 32 | for (let ind = 0; ind < searchQueries.length; ind++) { 33 | const q = searchQueries[ind]; 34 | 35 | // Using search.issuesAndPullRequests instead of issues.listForRepo for performance: 36 | // - Server-side filtering: only returns issues that contain specific commit hashes in their body 37 | // - Avoids downloading thousands of irrelevant issues when we only need a few matches 38 | // - Much more efficient than client-side filtering of all repository issues 39 | // Using advanced_search: 'true' to prepare for 2025-09-04 API deprecation 40 | const { data } = await github.api.search.issuesAndPullRequests({ 41 | q, 42 | advanced_search: 'true', 43 | }); 44 | 45 | const findHashes = unique( 46 | data.items.map(extractHashFromIssue).filter(isNotEmpty), 47 | ); 48 | 49 | const chunkedCommits = chunkedCommitsList[ind]; 50 | const currNotCreatedCommits = chunkedCommits.filter( 51 | commit => !findHashes.includes(commit.hash), 52 | ); 53 | 54 | log( 55 | 'I', 56 | `lookupCommitsInIssues :: Progress... ${Math.round(((ind + 1) / searchQueries.length) * 100)}%`, 57 | ); 58 | 59 | log( 60 | 'I', 61 | `lookupCommitsInIssues :: Target commits: ${chunkedCommits.map(({ hash }) => hash.substring(0, 7)).join(' ')}`, 62 | ); 63 | 64 | notCreatedCommits.push(...currNotCreatedCommits); 65 | } 66 | 67 | log( 68 | 'I', 69 | `lookupCommitsInIssues :: Completed - total: ${commits.length} / existing: ${commits.length - notCreatedCommits.length} / created: ${notCreatedCommits.length}`, 70 | ); 71 | 72 | return notCreatedCommits; 73 | }; 74 | 75 | export const chunk = (data: T[], chunkSize: number): T[][] => { 76 | if (chunkSize >= data.length) { 77 | return [data]; 78 | } 79 | 80 | if (chunkSize < 1) { 81 | throw new Error('Invalid chunkSize'); 82 | } 83 | 84 | return [...Array(Math.ceil(data.length / chunkSize))].map((_, ind) => 85 | data.slice(ind * chunkSize, (ind + 1) * chunkSize), 86 | ); 87 | }; 88 | 89 | const createCommitIssueSearchQuery = ( 90 | repoSpec: RepoSpec, 91 | chunk: Commit[], 92 | ): string => { 93 | const query = chunk.map(({ hash }) => `${hash} in:body`).join(' OR '); 94 | return `repo:${repoSpec.owner}/${repoSpec.name} type:issue (${query})`; 95 | }; 96 | -------------------------------------------------------------------------------- /packages/core/createConfig.ts: -------------------------------------------------------------------------------- 1 | import type { Config, RepoSpec } from './types/config'; 2 | import { getBooleanInput, getInput, getMultilineInput } from './utils/input'; 3 | import { log } from './utils/log'; 4 | 5 | import path from 'node:path'; 6 | 7 | export const defaults = { 8 | userName: 'github-actions', 9 | email: 'action@github.com', 10 | branch: 'main', 11 | label: 'sync', 12 | } as const; 13 | 14 | export const createConfig = (): Config => { 15 | log('I', 'createConfig :: Parsing configuration values'); 16 | 17 | // Required values validation 18 | const accessToken = getInput('ACCESS_TOKEN'); 19 | const headRepo = getInput('HEAD_REPO'); 20 | const trackFrom = getInput('TRACK_FROM'); 21 | 22 | assert(!!accessToken, '`accessToken` is required.'); 23 | assert(!!headRepo, '`headRepo` is required.'); 24 | assert(!!trackFrom, '`trackFrom` is required.'); 25 | 26 | // Optional values with defaults 27 | const userName = getInput('USER_NAME', defaults.userName); 28 | const email = getInput('EMAIL', defaults.email); 29 | const upstreamRepo = getInput('UPSTREAM_REPO'); 30 | const headRepoBranch = getInput('HEAD_REPO_BRANCH', defaults.branch); 31 | 32 | const upstreamRepoSpec = createRepoSpec( 33 | upstreamRepo || inferUpstreamRepo(), 34 | defaults.branch, 35 | ); 36 | const headRepoSpec = createRepoSpec(headRepo!, headRepoBranch!); 37 | 38 | const include = getMultilineInput('INCLUDE'); 39 | const exclude = getMultilineInput('EXCLUDE'); 40 | const labels = getMultilineInput('LABELS', [defaults.label]); 41 | const sortedLabels = labels.sort(); 42 | const plugins = getMultilineInput('PLUGINS'); 43 | 44 | const verbose = getBooleanInput('VERBOSE', true); 45 | process.env.VERBOSE = verbose.toString(); 46 | 47 | return { 48 | accessToken: accessToken!, 49 | userName: userName!, 50 | email: email!, 51 | upstreamRepoSpec, 52 | headRepoSpec, 53 | trackFrom: trackFrom!, 54 | include, 55 | exclude, 56 | labels: sortedLabels, 57 | plugins, 58 | verbose, 59 | }; 60 | }; 61 | 62 | const assert = (condition: boolean, message: string): void => { 63 | if (!condition) { 64 | throw new Error(message); 65 | } 66 | }; 67 | 68 | export const inferUpstreamRepo = (): string => { 69 | const serverUrl = getInput('GITHUB_SERVER_URL', 'https://github.com'); 70 | const repository = getInput('GITHUB_REPOSITORY'); 71 | 72 | if (!repository) { 73 | throw new Error( 74 | [ 75 | 'Failed to infer upstream repository: GITHUB_REPOSITORY environment variable is not set.', 76 | 'This typically happens when running outside of GitHub Actions.', 77 | 'For local development, please explicitly set the UPSTREAM_REPO environment variable.', 78 | ].join('\n'), 79 | ); 80 | } 81 | 82 | return `${serverUrl}/${repository}.git`; 83 | }; 84 | 85 | const createRepoSpec = (url: string, branch: string): RepoSpec => ({ 86 | owner: extractRepoOwner(url), 87 | name: extractRepoName(url), 88 | branch, 89 | }); 90 | 91 | const extractRepoOwner = (url: string): string => { 92 | let dirname = path.dirname(url); 93 | 94 | if (dirname.includes(':')) { 95 | dirname = dirname.split(':').pop()!; 96 | } 97 | 98 | return path.basename(dirname); 99 | }; 100 | 101 | const extractRepoName = (url: string): string => path.basename(url, '.git'); 102 | -------------------------------------------------------------------------------- /packages/core/infra/github.ts: -------------------------------------------------------------------------------- 1 | import type { Config, RepoSpec } from '../types/config'; 2 | import { log } from '../utils/log'; 3 | 4 | import { retry } from '@octokit/plugin-retry'; 5 | import { throttling } from '@octokit/plugin-throttling'; 6 | import { Octokit } from '@octokit/rest'; 7 | 8 | type GitHubConfig = Pick & { 9 | repoSpec: RepoSpec; 10 | }; 11 | 12 | const ThrottledOctokit = Octokit.plugin(retry, throttling); 13 | 14 | export class GitHub { 15 | api: Octokit; 16 | 17 | constructor(private readonly config: GitHubConfig) { 18 | log( 19 | 'I', 20 | `GitHub[[Construct]] :: Initializing GitHub API client (${this.config.repoSpec.owner}/${this.config.repoSpec.name})`, 21 | ); 22 | 23 | /** 24 | * Retry policy: 25 | * - Retry network errors up to 3 times 26 | * - Wait 5 seconds between retries 27 | * - Allow retries for all status codes 28 | * 29 | * Rate limit handling: 30 | * - If hitting hourly limit (3600+ seconds), fail immediately 31 | * - For shorter rate limits, retry only once 32 | * 33 | * Secondary rate limit (GitHub API abuse detection) handling: 34 | * - Retry up to 3 times 35 | */ 36 | this.api = new ThrottledOctokit({ 37 | auth: config.accessToken, 38 | retry: { 39 | doNotRetry: [], 40 | retries: 3, 41 | retryAfter: 10, // sec 42 | }, 43 | throttle: { 44 | onRateLimit: (retryAfter, options) => { 45 | log( 46 | 'W', 47 | `GitHub API Rate Limit reached (waiting ${retryAfter} seconds)`, 48 | ); 49 | 50 | if (retryAfter >= 3600) { 51 | log( 52 | 'E', 53 | 'GitHub API hourly request limit (5,000 requests) exceeded, failure without retry', 54 | ); 55 | return false; 56 | } 57 | 58 | if (options.request.retryCount < 1) { 59 | log( 60 | 'W', 61 | `GitHub API Rate Limit: Retry after ${retryAfter} seconds (attempt 1)`, 62 | ); 63 | return true; 64 | } 65 | 66 | log('E', 'GitHub API Rate Limit: Maximum retry count exceeded'); 67 | return false; 68 | }, 69 | onSecondaryRateLimit: (retryAfter, options) => { 70 | log( 71 | 'W', 72 | `GitHub API Secondary Rate Limit reached (waiting ${retryAfter} seconds)`, 73 | ); 74 | 75 | if (options.request.retryCount < 3) { 76 | log( 77 | 'W', 78 | `GitHub API Secondary Rate Limit: Retry after ${retryAfter} seconds (${options.request.retryCount + 1}/3)`, 79 | ); 80 | return true; 81 | } 82 | 83 | log( 84 | 'E', 85 | 'GitHub API Secondary Rate Limit: Maximum retry count exceeded', 86 | ); 87 | return false; 88 | }, 89 | }, 90 | }); 91 | } 92 | 93 | get ownerAndRepo(): { owner: string; repo: string } { 94 | return { 95 | owner: this.config.repoSpec.owner, 96 | repo: this.config.repoSpec.name, 97 | }; 98 | } 99 | 100 | get repoSpec(): RepoSpec { 101 | return this.config.repoSpec; 102 | } 103 | 104 | get configuredLabels(): string[] { 105 | return this.config.labels; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /packages/core/tests/utils-infra/getOpenedIssues.test.ts: -------------------------------------------------------------------------------- 1 | import { GitHub } from '../../infra/github'; 2 | import { getOpenedIssues } from '../../utils-infra/getOpenedIssues'; 3 | 4 | import { beforeEach, expect, it, vi } from 'vitest'; 5 | 6 | const mockPaginate = vi.fn(); 7 | 8 | vi.mock('../../infra/github', () => ({ 9 | GitHub: vi.fn().mockImplementation(() => ({ 10 | api: { 11 | paginate: mockPaginate, 12 | issues: { listForRepo: vi.fn() }, 13 | }, 14 | ownerAndRepo: { owner: 'test-owner', repo: 'test-repo' }, 15 | configuredLabels: ['label1', 'label2'], 16 | })), 17 | })); 18 | 19 | const MOCK_CONFIG = { 20 | accessToken: 'test-token', 21 | labels: ['label1', 'label2'], 22 | repoSpec: { 23 | owner: 'test-owner', 24 | name: 'test-repo', 25 | branch: 'main', 26 | }, 27 | }; 28 | 29 | const mockGitHub = new GitHub(MOCK_CONFIG); 30 | 31 | beforeEach(() => { 32 | vi.clearAllMocks(); 33 | }); 34 | 35 | it('Should return an empty array when there are no issues', async () => { 36 | mockPaginate.mockResolvedValue([]); 37 | 38 | const result = await getOpenedIssues(mockGitHub); 39 | 40 | expect(result).toEqual([]); 41 | }); 42 | 43 | it('Issues with only different labels from the configuration or without body should be filtered out', async () => { 44 | mockPaginate.mockResolvedValue([ 45 | { 46 | number: 1, 47 | body: 'body', 48 | labels: [{ name: 'other-label' }], 49 | created_at: '2023-01-01T12:00:00Z', 50 | }, 51 | { 52 | number: 2, 53 | body: undefined, 54 | labels: ['label1', 'label2'], 55 | created_at: '2023-01-01T12:00:00Z', 56 | }, 57 | ]); 58 | 59 | const result = await getOpenedIssues(mockGitHub); 60 | 61 | expect(result).toEqual([]); 62 | }); 63 | 64 | it('Should return only issues that have all configured labels', async () => { 65 | const EXPECTED_HASH = 'abcd123'; 66 | 67 | mockPaginate.mockResolvedValue([ 68 | { 69 | number: 1, 70 | body: `https://github.com/org/name/commit/${EXPECTED_HASH}`, 71 | created_at: '2023-01-01T12:00:00Z', 72 | labels: [ 73 | 'label1', 74 | { name: 'label2' }, 75 | { name: undefined }, 76 | 'extra-label', 77 | ], 78 | }, 79 | ]); 80 | 81 | const result = await getOpenedIssues(mockGitHub); 82 | 83 | expect(result).toEqual([ 84 | expect.objectContaining({ 85 | number: 1, 86 | labels: ['extra-label', 'label1', 'label2'], // sorted 87 | hash: EXPECTED_HASH, 88 | }), 89 | ]); 90 | }); 91 | 92 | it('Issues without a hash should be filtered out', async () => { 93 | const EXPECTED_HASH = 'abcd123'; 94 | 95 | mockPaginate.mockResolvedValue([ 96 | { 97 | number: 1, 98 | body: `https://github.com/org/repo/commit/${EXPECTED_HASH}`, 99 | created_at: '2023-01-01T12:00:00Z', 100 | labels: [{ name: 'label1' }, { name: 'label2' }], 101 | }, 102 | { 103 | number: 2, 104 | body: 'Issue body without hash', 105 | created_at: '2023-01-01T12:00:00Z', 106 | labels: [{ name: 'label1' }, { name: 'label2' }], 107 | }, 108 | ]); 109 | 110 | const result = await getOpenedIssues(mockGitHub); 111 | 112 | expect(result).toEqual([ 113 | expect.objectContaining({ 114 | number: 1, 115 | labels: ['label1', 'label2'], 116 | hash: EXPECTED_HASH, 117 | }), 118 | ]); 119 | }); 120 | -------------------------------------------------------------------------------- /packages/core/tests/plugin-sdk/plugin-version-validation.test.ts: -------------------------------------------------------------------------------- 1 | import { getResolveId } from '../../plugin'; 2 | 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | describe('Plugin version validation', () => { 6 | it('validates exact version format pattern', () => { 7 | // ref: checkout.sh 8 | const exactVersionPattern = /@[0-9]+\.[0-9]+\.[0-9]+$/; 9 | 10 | // Valid exact versions 11 | expect('plugin@1.0.0').toMatch(exactVersionPattern); 12 | expect('@org/plugin@2.1.3').toMatch(exactVersionPattern); 13 | expect('yuki-no-plugin-test@10.20.30').toMatch(exactVersionPattern); 14 | 15 | // Invalid versions (should be rejected) 16 | expect('plugin@^1.0.0').not.toMatch(exactVersionPattern); 17 | expect('plugin@~1.0.0').not.toMatch(exactVersionPattern); 18 | expect('plugin@1.0').not.toMatch(exactVersionPattern); 19 | expect('plugin@latest').not.toMatch(exactVersionPattern); 20 | expect('plugin@beta').not.toMatch(exactVersionPattern); 21 | expect('plugin').not.toMatch(exactVersionPattern); 22 | expect('plugin@1.0.0-beta').not.toMatch(exactVersionPattern); 23 | expect('plugin@1.0.0.0').not.toMatch(exactVersionPattern); 24 | }); 25 | 26 | it('extracts resolve id', () => { 27 | // Regular packages 28 | expect(getResolveId('plugin@1.0.0')).toBe('plugin'); 29 | expect(getResolveId('yuki-no-plugin-test@2.1.0')).toBe( 30 | 'yuki-no-plugin-test', 31 | ); 32 | 33 | // Scoped packages 34 | expect(getResolveId('@yuki-no/plugin-sdk-plugin-test@1.0.0')).toBe( 35 | '@yuki-no/plugin-sdk-plugin-test', 36 | ); 37 | expect(getResolveId('@org/plugin@2.1.3')).toBe('@org/plugin'); 38 | 39 | // Without versions 40 | expect(getResolveId('plugin')).toBe('plugin'); 41 | expect(getResolveId('@scoped/package')).toBe('@scoped/package'); 42 | }); 43 | 44 | it('handles pre-release version edge cases', () => { 45 | // Pre-release versions should extract package name correctly 46 | expect(getResolveId('plugin@1.0.0-beta.1')).toBe('plugin'); 47 | expect(getResolveId('plugin@1.0.0-alpha.2')).toBe('plugin'); 48 | expect(getResolveId('plugin@1.0.0-rc.1')).toBe('plugin'); 49 | 50 | // Scoped packages with pre-release versions 51 | expect(getResolveId('@scope/plugin@1.0.0-beta.1')).toBe('@scope/plugin'); 52 | expect(getResolveId('@yuki-no/plugin@2.1.0-alpha.3')).toBe( 53 | '@yuki-no/plugin', 54 | ); 55 | expect(getResolveId('@org/test-plugin@3.0.0-rc.2')).toBe( 56 | '@org/test-plugin', 57 | ); 58 | }); 59 | 60 | it('handles build metadata version edge cases', () => { 61 | // Build metadata should be handled correctly 62 | expect(getResolveId('plugin@1.0.0+build.123')).toBe('plugin'); 63 | expect(getResolveId('plugin@1.0.0+20230101.abc123')).toBe('plugin'); 64 | 65 | // Scoped packages with build metadata 66 | expect(getResolveId('@scope/plugin@1.0.0+build.456')).toBe('@scope/plugin'); 67 | expect(getResolveId('@yuki-no/plugin@1.0.0+feature.xyz')).toBe( 68 | '@yuki-no/plugin', 69 | ); 70 | }); 71 | 72 | it('handles complex version combinations', () => { 73 | // Pre-release + build metadata 74 | expect(getResolveId('plugin@1.0.0-beta.1+build.123')).toBe('plugin'); 75 | expect(getResolveId('@scope/plugin@1.0.0-rc.1+build.456')).toBe( 76 | '@scope/plugin', 77 | ); 78 | expect(getResolveId('@yuki-no/plugin@2.0.0-alpha.1+feature.branch')).toBe( 79 | '@yuki-no/plugin', 80 | ); 81 | }); 82 | 83 | it('handles malformed version strings gracefully', () => { 84 | // Multiple @ symbols in malformed strings 85 | expect(getResolveId('plugin@invalid@version')).toBe('plugin'); 86 | expect(getResolveId('@scope/plugin@invalid@version')).toBe('@scope/plugin'); 87 | 88 | // Empty version parts 89 | expect(getResolveId('plugin@')).toBe('plugin'); 90 | expect(getResolveId('@scope/plugin@')).toBe('@scope/plugin'); 91 | 92 | // Just @ symbol 93 | expect(getResolveId('@')).toBe('@'); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /packages/core/tests/infra/github.test.ts: -------------------------------------------------------------------------------- 1 | import { GitHub } from '../../infra/github'; 2 | 3 | import { Octokit } from '@octokit/rest'; 4 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 5 | 6 | // Mocking to bypass network request 7 | vi.mock('@octokit/rest', () => { 8 | const MockOctokit: any = vi.fn(() => ({})); 9 | MockOctokit.plugin = vi.fn(() => MockOctokit); 10 | return { Octokit: MockOctokit }; 11 | }); 12 | 13 | const TEST_CONFIG = { 14 | accessToken: 'test-token', 15 | repoSpec: { 16 | owner: 'test-owner', 17 | name: 'test-repo', 18 | branch: 'main', 19 | }, 20 | labels: ['label1', 'label2'], 21 | }; 22 | 23 | beforeEach(() => { 24 | vi.clearAllMocks(); 25 | }); 26 | 27 | describe('getter methods', () => { 28 | it('ownerAndRepo should return the correct format', () => { 29 | const github = new GitHub(TEST_CONFIG); 30 | 31 | expect(github.ownerAndRepo).toEqual({ 32 | owner: TEST_CONFIG.repoSpec.owner, 33 | repo: TEST_CONFIG.repoSpec.name, 34 | }); 35 | }); 36 | 37 | it('repoSpec should return the correct value', () => { 38 | const github = new GitHub(TEST_CONFIG); 39 | expect(github.repoSpec).toEqual(TEST_CONFIG.repoSpec); 40 | }); 41 | 42 | it('configuredLabels should return the correct value', () => { 43 | const github = new GitHub(TEST_CONFIG); 44 | expect(github.configuredLabels).toEqual(TEST_CONFIG.labels); 45 | }); 46 | }); 47 | 48 | describe('Octokit API', () => { 49 | describe('Rate Limit processing', () => { 50 | let onRateLimit: (retryAfter: number, options: any) => boolean; 51 | 52 | beforeEach(() => { 53 | new GitHub(TEST_CONFIG); 54 | onRateLimit = (Octokit as any).mock.calls[0][0].throttle.onRateLimit; 55 | }); 56 | 57 | it('Should fail without retry when hourly request limit (over 3600 seconds) is exceeded', () => { 58 | const retryAfter = 3600; 59 | const options = { request: { retryCount: 0 } }; 60 | 61 | const result = onRateLimit(retryAfter, options); 62 | 63 | expect(result).toBeFalsy(); 64 | }); 65 | 66 | it('Should retry when a short wait time (less than 3600 seconds) and retryCount is 0', () => { 67 | const retryAfter = 60; 68 | const options = { request: { retryCount: 0 } }; 69 | 70 | const result = onRateLimit(retryAfter, options); 71 | 72 | expect(result).toBeTruthy(); 73 | }); 74 | 75 | it('Should fail when maximum retryCount is reached', () => { 76 | const retryAfter = 60; 77 | const options = { request: { retryCount: 1 } }; 78 | 79 | const result = onRateLimit(retryAfter, options); 80 | 81 | expect(result).toBeFalsy(); 82 | }); 83 | }); 84 | 85 | describe('Secondary Rate Limit handling', () => { 86 | let onSecondaryRateLimit: (retryAfter: number, options: any) => boolean; 87 | 88 | beforeEach(() => { 89 | new GitHub(TEST_CONFIG); 90 | onSecondaryRateLimit = (Octokit as any).mock.calls[0][0].throttle 91 | .onSecondaryRateLimit; 92 | }); 93 | 94 | it('Should retry when retryCount is less than 3', () => { 95 | const retryAfter = 60; 96 | 97 | const result1 = onSecondaryRateLimit(retryAfter, { 98 | request: { retryCount: 0 }, 99 | }); 100 | expect(result1).toBeTruthy(); 101 | 102 | const result2 = onSecondaryRateLimit(retryAfter, { 103 | request: { retryCount: 1 }, 104 | }); 105 | expect(result2).toBeTruthy(); 106 | 107 | const result3 = onSecondaryRateLimit(retryAfter, { 108 | request: { retryCount: 2 }, 109 | }); 110 | expect(result3).toBeTruthy(); 111 | }); 112 | 113 | it('Should fail when maximum retryCount is reached', () => { 114 | const retryAfter = 60; 115 | const options = { request: { retryCount: 3 } }; 116 | 117 | const result = onSecondaryRateLimit(retryAfter, options); 118 | 119 | expect(result).toBeFalsy(); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /packages/batch-pr/utils/parseFileStatuses.ts: -------------------------------------------------------------------------------- 1 | import { FILE_STATUS_REGEX } from '../constants'; 2 | import type { FileStatus } from '../types'; 3 | 4 | import type { FileNameFilter } from '@yuki-no/plugin-sdk/utils/createFileNameFilter'; 5 | import { splitByNewline } from '@yuki-no/plugin-sdk/utils/input'; 6 | import { formatError, log } from '@yuki-no/plugin-sdk/utils/log'; 7 | 8 | export const parseFileStatuses = ( 9 | statusString: string, 10 | fileNameFilter: FileNameFilter, 11 | onExcluded?: (path: string) => void, 12 | ): FileStatus[] => { 13 | const statusLines = splitByNewline(statusString); 14 | log( 15 | 'I', 16 | `parseFileStatuses :: Processing ${statusLines.length} status lines`, 17 | ); 18 | 19 | const statuses: FileStatus[] = []; 20 | let excludedCount = 0; 21 | 22 | for (const statusLine of statusLines) { 23 | const fileStatus = parseFileStatus(statusLine); 24 | let shouldInclude = fileNameFilter(fileStatus.headFileName); 25 | const isRenameOrCopy = 26 | fileStatus.status === 'R' || fileStatus.status === 'C'; 27 | 28 | if (isRenameOrCopy) { 29 | shouldInclude = fileNameFilter(fileStatus.nextHeadFileName); 30 | } 31 | 32 | if (shouldInclude) { 33 | statuses.push(fileStatus); 34 | } else { 35 | const excludedPath = isRenameOrCopy 36 | ? fileStatus.nextHeadFileName 37 | : fileStatus.headFileName; 38 | log('I', `parseFileStatuses :: Excluded: ${excludedPath}`); 39 | onExcluded?.(excludedPath); 40 | excludedCount++; 41 | } 42 | } 43 | 44 | log( 45 | 'I', 46 | `parseFileStatuses :: Filtered ${statuses.length} files (${excludedCount} excluded)`, 47 | ); 48 | log( 49 | 'S', 50 | `parseFileStatuses :: Successfully parsed ${statuses.length} file statuses`, 51 | ); 52 | 53 | return statuses; 54 | }; 55 | 56 | const parseFileStatus = (statusLine: string): FileStatus => { 57 | try { 58 | const renamedMatch = statusLine.match(FILE_STATUS_REGEX.RENAMED); 59 | if (renamedMatch) { 60 | const [, similarityStr, headFileName, nextHeadFileName] = renamedMatch; 61 | log( 62 | 'I', 63 | `parseFileStatus :: Parsed RENAMED: ${headFileName} -> ${nextHeadFileName}`, 64 | ); 65 | return { 66 | status: 'R', 67 | headFileName, 68 | nextHeadFileName, 69 | similarity: parseInt(similarityStr, 10), 70 | }; 71 | } 72 | 73 | const copiedMatch = statusLine.match(FILE_STATUS_REGEX.COPIED); 74 | if (copiedMatch) { 75 | const [, similarityStr, headFileName, nextHeadFileName] = copiedMatch; 76 | log( 77 | 'I', 78 | `parseFileStatus :: Parsed COPIED: ${headFileName} -> ${nextHeadFileName}`, 79 | ); 80 | return { 81 | status: 'C', 82 | headFileName, 83 | nextHeadFileName, 84 | similarity: parseInt(similarityStr, 10), 85 | }; 86 | } 87 | 88 | const typeChangedMatch = statusLine.match(FILE_STATUS_REGEX.TYPE_CHANGED); 89 | if (typeChangedMatch) { 90 | const [, headFileName] = typeChangedMatch; 91 | log('I', `parseFileStatus :: Parsed TYPE_CHANGED: ${headFileName}`); 92 | return { 93 | status: 'T', 94 | headFileName, 95 | }; 96 | } 97 | 98 | const regularMatch = statusLine.match( 99 | FILE_STATUS_REGEX.MODIFIED_ADDED_DELETED, 100 | ); 101 | if (regularMatch) { 102 | const [, status, headFileName] = regularMatch; 103 | log('I', `parseFileStatus :: Parsed ${status}: ${headFileName}`); 104 | return { 105 | status: status as 'M' | 'A' | 'D', 106 | headFileName, 107 | }; 108 | } 109 | 110 | log('E', `parseFileStatus :: Unable to parse status line: ${statusLine}`); 111 | throw new Error(`Unable to parse status line: ${statusLine}`); 112 | } catch (error) { 113 | log( 114 | 'E', 115 | `parseFileStatus :: Error parsing line "${statusLine}": ${formatError(error)}`, 116 | ); 117 | throw error; 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /packages/release-tracking/tests/getRelease.test.ts: -------------------------------------------------------------------------------- 1 | import { getRelease } from '../utils/getRelease'; 2 | 3 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 4 | 5 | // Mock semver module 6 | vi.mock('semver', () => ({ 7 | valid: vi.fn((version: string) => { 8 | // Simple version validation for testing 9 | return /^v?\d+\.\d+\.\d+/.test(version); 10 | }), 11 | parse: vi.fn((version: string) => { 12 | if (!/^v?\d+\.\d+\.\d+/.test(version)) return null; 13 | 14 | const cleanVersion = version.replace(/^v/, ''); 15 | const isPrerelease = cleanVersion.includes('-'); 16 | 17 | return { 18 | raw: version, 19 | prerelease: isPrerelease ? ['beta'] : [], 20 | }; 21 | }), 22 | })); 23 | 24 | const MOCK_REPO_URL = 'https://github.com/username/repo'; 25 | const TEST_COMMIT_HASH = 'abcd1234'; 26 | 27 | const mockGit: any = { exec: vi.fn(), repoUrl: MOCK_REPO_URL }; 28 | 29 | beforeEach(() => { 30 | vi.clearAllMocks(); 31 | }); 32 | 33 | describe('getRelease - when there are no tags', () => { 34 | it('Should handle empty string as undefined', () => { 35 | mockGit.exec.mockReturnValue(''); 36 | 37 | const result = getRelease(mockGit, TEST_COMMIT_HASH); 38 | 39 | expect(mockGit.exec).toHaveBeenCalledWith( 40 | `tag --contains ${TEST_COMMIT_HASH}`, 41 | ); 42 | expect(result).toEqual({ 43 | prerelease: undefined, 44 | release: undefined, 45 | }); 46 | }); 47 | 48 | it('Should handle empty as undefined', () => { 49 | mockGit.exec.mockReturnValue(''); 50 | 51 | const result = getRelease(mockGit, TEST_COMMIT_HASH); 52 | 53 | expect(result).toEqual({ 54 | prerelease: undefined, 55 | release: undefined, 56 | }); 57 | }); 58 | }); 59 | 60 | describe('getRelease - when there are tags', () => { 61 | it('When only release exists, prerelease should be undefined', () => { 62 | mockGit.exec.mockReturnValue(['v1.0.0', 'v2.0.0'].join('\n')); 63 | 64 | const result = getRelease(mockGit, TEST_COMMIT_HASH); 65 | 66 | expect(result).toEqual({ 67 | prerelease: undefined, 68 | release: { 69 | version: 'v1.0.0', 70 | url: `${MOCK_REPO_URL}/releases/tag/v1.0.0`, 71 | }, 72 | }); 73 | }); 74 | 75 | it('When only prerelease exists, release should be undefined', () => { 76 | mockGit.exec.mockReturnValue( 77 | ['v1.0.0-beta.1', 'v1.0.0-alpha.1'].join('\n'), 78 | ); 79 | 80 | const result = getRelease(mockGit, TEST_COMMIT_HASH); 81 | 82 | expect(result).toEqual({ 83 | prerelease: { 84 | version: 'v1.0.0-beta.1', 85 | url: `${MOCK_REPO_URL}/releases/tag/v1.0.0-beta.1`, 86 | }, 87 | release: undefined, 88 | }); 89 | }); 90 | 91 | it('When both exist, should properly distinguish between release and prerelease', () => { 92 | mockGit.exec.mockReturnValue( 93 | ['v1.0.0', 'v1.1.0-beta.1', 'v2.0.0'].join('\n'), 94 | ); 95 | 96 | const result = getRelease(mockGit, TEST_COMMIT_HASH); 97 | 98 | expect(result).toEqual({ 99 | prerelease: { 100 | version: 'v1.1.0-beta.1', 101 | url: `${MOCK_REPO_URL}/releases/tag/v1.1.0-beta.1`, 102 | }, 103 | release: { 104 | version: 'v1.0.0', 105 | url: `${MOCK_REPO_URL}/releases/tag/v1.0.0`, 106 | }, 107 | }); 108 | }); 109 | }); 110 | 111 | describe('getRelease - tag URL generation', () => { 112 | it('prerelease tag URL should be generated correctly', () => { 113 | mockGit.exec.mockReturnValue('v1.0.0-beta.1'); 114 | 115 | const result = getRelease(mockGit, TEST_COMMIT_HASH); 116 | 117 | expect(result.prerelease).toEqual({ 118 | version: 'v1.0.0-beta.1', 119 | url: `${MOCK_REPO_URL}/releases/tag/v1.0.0-beta.1`, 120 | }); 121 | }); 122 | 123 | it('release tag URL should be generated correctly', () => { 124 | mockGit.exec.mockReturnValue('v1.0.0'); 125 | 126 | const result = getRelease(mockGit, TEST_COMMIT_HASH); 127 | 128 | expect(result.release).toEqual({ 129 | version: 'v1.0.0', 130 | url: `${MOCK_REPO_URL}/releases/tag/v1.0.0`, 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /packages/batch-pr/utils/setupBatchPr.ts: -------------------------------------------------------------------------------- 1 | import { createCommit } from './createCommit'; 2 | import { createPrBody } from './createPrBody'; 3 | 4 | import type { RestEndpointMethodTypes } from '@octokit/rest'; 5 | import { Git } from '@yuki-no/plugin-sdk/infra/git'; 6 | import { GitHub } from '@yuki-no/plugin-sdk/infra/github'; 7 | import { log } from '@yuki-no/plugin-sdk/utils/log'; 8 | 9 | const PR_LABEL = '__translation-batch'; 10 | const PR_TITLE_PREFIX = `❄️ Translation Batch`; 11 | 12 | export const setupBatchPr = async ( 13 | github: GitHub, 14 | git: Git, 15 | branchName: string, 16 | ): Promise<{ prNumber: number }> => { 17 | log('I', `setupBatchPr :: Setting up batch PR with branch ${branchName}`); 18 | 19 | const existingPr = await findPrByLabelAndTitle( 20 | github, 21 | PR_LABEL, 22 | PR_TITLE_PREFIX, 23 | ); 24 | 25 | if (existingPr) { 26 | log( 27 | 'I', 28 | `setupBatchPr :: Found existing PR #${existingPr.number}, using it`, 29 | ); 30 | git.exec(`checkout ${branchName}`); 31 | log( 32 | 'S', 33 | `setupBatchPr :: Successfully checked out existing branch ${branchName}`, 34 | ); 35 | return { prNumber: existingPr.number }; 36 | } 37 | 38 | log('I', `setupBatchPr :: No existing PR found, creating new one`); 39 | git.exec(`checkout -B ${branchName}`); 40 | log('I', `setupBatchPr :: Created and checked out new branch ${branchName}`); 41 | 42 | createCommit(git, { 43 | message: 'Initial translation batch commit', 44 | allowEmpty: true, 45 | }); 46 | log('I', `setupBatchPr :: Created initial commit`); 47 | 48 | git.exec(`push -f origin ${branchName}`); 49 | log('I', `setupBatchPr :: Pushed branch ${branchName} to origin`); 50 | 51 | const pr = await createPr(github, { 52 | branch: branchName, 53 | title: `${PR_TITLE_PREFIX} - ${new Date().toISOString().split('T')[0]}`, 54 | body: createPrBody([]), 55 | labels: [PR_LABEL], 56 | }); 57 | 58 | log('S', `setupBatchPr :: Successfully created new PR #${pr.number}`); 59 | return { prNumber: pr.number }; 60 | }; 61 | 62 | type SearchedPrData = 63 | RestEndpointMethodTypes['search']['issuesAndPullRequests']['response']['data']['items'][number]; 64 | 65 | const findPrByLabelAndTitle = async ( 66 | github: GitHub, 67 | label: string, 68 | title: string, 69 | ): Promise => { 70 | log( 71 | 'I', 72 | `findPrByLabelAndTitle :: Searching for existing PR with label "${label}" and title "${title}"`, 73 | ); 74 | 75 | const { data } = await github.api.search.issuesAndPullRequests({ 76 | q: `repo:${github.ownerAndRepo.owner}/${github.ownerAndRepo.repo} is:pr is:open label:${label} in:title ${title}`, 77 | advanced_search: 'true', 78 | }); 79 | 80 | log('I', `findPrByLabelAndTitle :: Found ${data.items.length} matching PRs`); 81 | return data.items[0]; 82 | }; 83 | 84 | type CreatePrOptions = { 85 | branch: string; 86 | title: string; 87 | body: string; 88 | base?: string; 89 | labels?: string[]; 90 | }; 91 | 92 | type CreatedPrData = 93 | RestEndpointMethodTypes['pulls']['create']['response']['data']; 94 | 95 | const createPr = async ( 96 | github: GitHub, 97 | { base = 'main', body, branch, title, labels }: CreatePrOptions, 98 | ): Promise => { 99 | log( 100 | 'I', 101 | `createPr :: Creating PR from ${branch} to ${base} with title "${title}"`, 102 | ); 103 | 104 | const { data } = await github.api.pulls.create({ 105 | ...github.ownerAndRepo, 106 | title, 107 | body, 108 | head: branch, 109 | base, 110 | }); 111 | 112 | log('I', `createPr :: PR #${data.number} created successfully`); 113 | 114 | const shouldApplyLabels = labels && labels.length > 0; 115 | if (shouldApplyLabels) { 116 | log( 117 | 'I', 118 | `createPr :: Applying ${labels.length} labels to PR #${data.number}`, 119 | ); 120 | await github.api.issues.setLabels({ 121 | ...github.ownerAndRepo, 122 | issue_number: data.number, 123 | labels, 124 | }); 125 | log('I', `createPr :: Labels applied successfully`); 126 | } 127 | 128 | return data; 129 | }; 130 | -------------------------------------------------------------------------------- /docs/MIGRATION.md: -------------------------------------------------------------------------------- 1 | # Migrate to Yuki-no 2 | 3 | This guide shows how to use Yuki-no in projects that use GitHub Issues for translation, such as those using [Ryu-Cho](https://github.com/vuejs-translations/ryu-cho). 4 | 5 | ## Migration Process 6 | 7 | ### Eligible Projects 8 | 9 | - Projects that use GitHub Issues for translation, like Ryu-Cho 10 | - Track Head Repo changes through GitHub Issues 11 | - Each issue body contains links to a specific commit in the Head Repo 12 | - Head Repo must be a Public GitHub repository 13 | - GitHub Issues must contain GitHub Commit URLs for original commits 14 | - GitHub Commit URL format: `https://github.com///commit/` 15 | - Examples: 16 | ``` 17 | New updates on head repo. 18 | https://github.com/test/test/commit/1234567 19 | ``` 20 | ```md 21 | Previous translation process: [See Commit](https://github.com/test/test/commit/1234567) 22 | ``` 23 | 24 | ### Migration Steps 25 | 26 | > [!WARNING] 27 | > 28 | > For a successful migration, set `track-from` to the last commit hash that was fully translated. Using an incorrect value when first running Yuki-no may create duplicate issues for already translated content. Note that Yuki-no starts tracking from the commit after your specified `track-from` hash, not including the `track-from` commit itself. 29 | 30 | 31 | 32 | 1. Add Labels to Translation Issues for Yuki-no Tracking 33 | 34 | Create Sync Label 35 | 36 | - Yuki-no identifies which issues it manages through labels 37 | - Go to GitHub and create labels like `sync` 38 | - Add these labels to your existing translation issues 39 | - Note: Adding these labels to non-translation issues may cause problems 40 | 41 | 2. Create Yuki-no Action Configuration File 42 | 43 | Create an Action 44 | 45 | - Remove any existing sync action files (like Ryu-Cho) 46 | - Create a new Yuki-no config file by following the [Usage](../README.md#usage) section 47 | - If moving from Ryu-Cho, check the [Yuki-no Options vs Ryu-Cho Options](#yuki-no-options-vs-ryu-cho-options) section below 48 | 49 | 50 | 51 | 3. Run the Action 52 | - Wait for the next scheduled run, or trigger it manually if you enabled `on.workflow_dispatch` (see [GitHub docs](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow)) 53 | - The first run may take some time as it processes all commits after the `track-from` hash 54 | - Check your translation issues after it finishes 55 | 56 | #### Yuki-no Options vs Ryu-Cho Options 57 | 58 | For detailed option descriptions, see [README](../README.md#configuration). 59 | 60 | **Options No Longer Required:** 61 | 62 | - `username`: Not needed - uses GitHub Actions bot by default 63 | - `email`: Not needed - uses GitHub Actions bot by default 64 | - `upstream-repo`: Automatically detected in GitHub Actions 65 | 66 | **Removed Options:** 67 | 68 | - `upstream-repo-branch`: Upstream Repo always uses the default branch 69 | - `workflow-name`: Not used in Yuki-no 70 | 71 | **Options Kept the Same:** 72 | 73 | - `head-repo`: URL of the original repository 74 | - `head-repo-branch`: Branch of the original repository (default: `main`) 75 | - `track-from`: Starting commit hash for tracking 76 | 77 | **Changed Options:** 78 | 79 | - `path-starts-with`: Use the new `include` option with [Glob patterns](https://github.com/micromatch/picomatch?tab=readme-ov-file#advanced-globbing) instead (e.g., `docs/` becomes `docs/**`) 80 | 81 | **New Options:** 82 | 83 | - `include`, `exclude`: Filter files to track using Glob patterns 84 | - `labels`: Labels to add to issues (default: `sync`) 85 | - `plugins`: Load additional functionality. See [PLUGINS.md](./PLUGINS.md) for details. 86 | - `verbose`: Show detailed logs (default: `true`) 87 | 88 | #### Important Notes 89 | 90 | **If issues or comments aren't being created:** 91 | 92 | Workflow permissions 93 | 94 | - Go to Settings > Actions > General > Workflow permissions 95 | - Select "Read and write permissions" 96 | - Save the changes 97 | -------------------------------------------------------------------------------- /packages/core/index.ts: -------------------------------------------------------------------------------- 1 | import { createConfig } from './createConfig'; 2 | import { Git } from './infra/git'; 3 | import { GitHub } from './infra/github'; 4 | import { loadPlugins } from './plugin'; 5 | import type { Config } from './types/config'; 6 | import type { Issue, IssueMeta } from './types/github'; 7 | import type { YukiNoContext, YukiNoPlugin } from './types/plugin'; 8 | import { createIssue } from './utils-infra/createIssue'; 9 | import { getCommits } from './utils-infra/getCommits'; 10 | import { getLatestSuccessfulRunISODate } from './utils-infra/getLatestSuccessfulRunISODate'; 11 | import { lookupCommitsInIssues } from './utils-infra/lookupCommitsInIssues'; 12 | import { log } from './utils/log'; 13 | 14 | import shell from 'shelljs'; 15 | 16 | shell.config.silent = true; 17 | 18 | const start = async () => { 19 | const startTime = new Date(); 20 | log('I', `Starting Yuki-no (${startTime.toISOString()})`); 21 | 22 | const config = createConfig(); 23 | log('S', 'Configuration initialized'); 24 | 25 | const git = new Git({ 26 | ...config, 27 | repoSpec: config.headRepoSpec, 28 | withClone: true, 29 | }); 30 | 31 | log('S', 'Git initialized'); 32 | 33 | const github = new GitHub({ ...config, repoSpec: config.upstreamRepoSpec }); 34 | log('S', 'GitHub initialized'); 35 | 36 | const plugins = await loadPlugins(config.plugins); 37 | const pluginCtx: YukiNoContext = { config }; 38 | 39 | let success = false; 40 | let createdIssues: Issue[] = []; 41 | 42 | try { 43 | for (const plugin of plugins) { 44 | await plugin.onInit?.({ ...pluginCtx }); 45 | } 46 | 47 | createdIssues = await syncCommits(github, git, config, plugins, pluginCtx); 48 | success = true; 49 | 50 | const endTime = new Date(); 51 | const duration = (endTime.getTime() - startTime.getTime()) / 1000; 52 | log( 53 | 'S', 54 | `Yuki-No completed (${endTime.toISOString()}) - Duration: ${duration}s`, 55 | ); 56 | } catch (error) { 57 | const err = error as Error; 58 | for (const plugin of plugins) { 59 | await plugin.onError?.({ ...pluginCtx, error: err }); 60 | } 61 | throw err; 62 | } finally { 63 | for (const plugin of plugins) { 64 | await plugin.onFinally?.({ 65 | ...pluginCtx, 66 | success, 67 | createdIssues: [...createdIssues], 68 | }); 69 | } 70 | } 71 | }; 72 | 73 | const syncCommits = async ( 74 | github: GitHub, 75 | git: Git, 76 | config: Config, 77 | plugins: YukiNoPlugin[], 78 | ctx: YukiNoContext, 79 | ): Promise => { 80 | log('I', '=== Synchronization started ==='); 81 | 82 | for (const p of plugins) { 83 | await p.onBeforeCompare?.({ ...ctx }); 84 | } 85 | 86 | const latestSuccessfulRun = await getLatestSuccessfulRunISODate(github); 87 | const commits = getCommits(config, git, latestSuccessfulRun); 88 | const notCreatedCommits = await lookupCommitsInIssues(github, commits); 89 | 90 | for (const p of plugins) { 91 | await p.onAfterCompare?.({ ...ctx, commits: notCreatedCommits }); 92 | } 93 | 94 | log( 95 | 'I', 96 | `syncCommits :: Number of commits to create as issues: ${notCreatedCommits.length}`, 97 | ); 98 | 99 | const createdIssues: Issue[] = []; 100 | 101 | for (const notCreatedCommit of notCreatedCommits) { 102 | const commitUrl = `${git.repoUrl}/commit/${notCreatedCommit.hash}`; 103 | const issueMeta: IssueMeta = { 104 | title: notCreatedCommit.title, 105 | body: `New updates on head repo.\r\n${commitUrl}`, 106 | labels: config.labels, 107 | }; 108 | 109 | for (const p of plugins) { 110 | await p.onBeforeCreateIssue?.({ 111 | ...ctx, 112 | commit: notCreatedCommit, 113 | issueMeta, 114 | }); 115 | } 116 | 117 | const issue: Issue = { 118 | ...(await createIssue(github, issueMeta)), 119 | hash: notCreatedCommit.hash, 120 | }; 121 | 122 | createdIssues.push(issue); 123 | 124 | for (const p of plugins) { 125 | await p.onAfterCreateIssue?.({ 126 | ...ctx, 127 | commit: notCreatedCommit, 128 | issue, 129 | }); 130 | } 131 | } 132 | 133 | log( 134 | 'S', 135 | `syncCommits :: ${createdIssues.length} issues created successfully`, 136 | ); 137 | 138 | return createdIssues; 139 | }; 140 | 141 | start(); 142 | -------------------------------------------------------------------------------- /packages/release-tracking/tests/getLastIssueComments.test.ts: -------------------------------------------------------------------------------- 1 | import { getLastIssueComment } from '../utils/getLastIssueComments'; 2 | 3 | import { GitHub } from '@yuki-no/plugin-sdk/infra/github'; 4 | import { beforeEach, expect, it, vi } from 'vitest'; 5 | 6 | const MOCK_OWNER_AND_REPO = { 7 | owner: 'test-owner', 8 | repo: 'test-repo', 9 | }; 10 | 11 | const MOCK_ISSUE_NUMBER = 42; 12 | 13 | const mockListComments = vi.fn(); 14 | 15 | vi.mock('@yuki-no/plugin-sdk/infra/github', () => ({ 16 | GitHub: vi.fn().mockImplementation(() => ({ 17 | api: { issues: { listComments: mockListComments } }, 18 | // Mocking for spy 19 | ownerAndRepo: MOCK_OWNER_AND_REPO, 20 | })), 21 | })); 22 | 23 | const MOCK_CONFIG = { 24 | accessToken: 'test-token', 25 | labels: ['test-label'], 26 | repoSpec: { 27 | owner: 'test-owner', 28 | name: 'test-repo', 29 | branch: 'main', 30 | }, 31 | }; 32 | 33 | const mockGitHub = new GitHub(MOCK_CONFIG); 34 | 35 | beforeEach(() => { 36 | vi.clearAllMocks(); 37 | }); 38 | 39 | it('Should return the last release tracking comment when release tracking comments exist', async () => { 40 | const LAST_COMMENT = 41 | '- pre-release: none\n- release: [v6](https://github.com/test/repo/releases/tag/v6)'; 42 | 43 | mockListComments.mockResolvedValue({ 44 | data: [ 45 | { body: 'Comment 1' }, 46 | { 47 | body: '- pre-release: [v6-beta.0](https://github.com/test/repo/releases/tag/v6-beta.0)\n- release: [v6](https://github.com/test/repo/releases/tag/v6)', 48 | }, 49 | { body: 'Comment 2' }, 50 | { body: LAST_COMMENT }, 51 | 52 | // Invalid format 53 | { body: '- prerelease: [v6.2.0-beta.0]\n- release: [v6.1.1]' }, 54 | { 55 | body: '- release: [v6](https://github.com/test/repo/releases/tag/v6)', 56 | }, 57 | { 58 | body: '- pre-release: [v6-beta.0](https://github.com/test/repo/releases/tag/v6-beta.0)', 59 | }, 60 | ], 61 | }); 62 | 63 | const result = await getLastIssueComment(mockGitHub, MOCK_ISSUE_NUMBER); 64 | 65 | expect(result).toBe(LAST_COMMENT); 66 | expect(mockListComments).toHaveBeenCalledWith({ 67 | owner: MOCK_OWNER_AND_REPO.owner, 68 | repo: MOCK_OWNER_AND_REPO.repo, 69 | issue_number: MOCK_ISSUE_NUMBER, 70 | }); 71 | }); 72 | 73 | it('Should return an empty string when there are no release tracking comments', async () => { 74 | mockListComments.mockResolvedValue({ 75 | data: [{ body: 'Comment 1' }, { body: 'Comment 2' }], 76 | }); 77 | 78 | const result = await getLastIssueComment(mockGitHub, MOCK_ISSUE_NUMBER); 79 | 80 | expect(result).toBe(''); 81 | }); 82 | 83 | it('Should return an empty string when there are no comments', async () => { 84 | mockListComments.mockResolvedValue({ 85 | data: [], 86 | }); 87 | 88 | const result = await getLastIssueComment(mockGitHub, MOCK_ISSUE_NUMBER); 89 | 90 | expect(result).toBe(''); 91 | }); 92 | 93 | it('Should ignore comments with undefined body', async () => { 94 | const COMMENT = 95 | '- pre-release: [v6.2.0-beta.0](https://github.com/test/repo/releases/tag/v6.2.0-beta.0)\n- release: none'; 96 | 97 | mockListComments.mockResolvedValue({ 98 | data: [ 99 | { body: undefined }, 100 | { 101 | body: COMMENT, 102 | }, 103 | { body: undefined }, 104 | ], 105 | }); 106 | 107 | const result = await getLastIssueComment(mockGitHub, MOCK_ISSUE_NUMBER); 108 | 109 | expect(result).toBe(COMMENT); 110 | }); 111 | 112 | it('Should extract release content from comment with release available warning', async () => { 113 | const INFO_CONTENT = [ 114 | '> This comment and the `pending` label appear because release-tracking is enabled.', 115 | '> To disable, remove the `release-tracking` option or set it to `false`.', 116 | '', 117 | '', 118 | '', 119 | ].join('\n'); 120 | 121 | const RELEASE_CONTENT = [ 122 | '- pre-release: [v6.1.0-beta.1](https://github.com/vitejs/vite/releases/tag/v6.1.0-beta.1)', 123 | '- release: [v6.1.0](https://github.com/vitejs/vite/releases/tag/v6.1.0)', 124 | ].join('\n'); 125 | 126 | const COMMENT = [INFO_CONTENT, RELEASE_CONTENT].join('\n'); 127 | 128 | mockListComments.mockResolvedValue({ 129 | data: [{ body: COMMENT }], 130 | }); 131 | 132 | const result = await getLastIssueComment(mockGitHub, MOCK_ISSUE_NUMBER); 133 | 134 | expect(result).toBe(RELEASE_CONTENT); 135 | }); 136 | -------------------------------------------------------------------------------- /packages/core/tests/plugin-sdk/plugins.test.ts: -------------------------------------------------------------------------------- 1 | import { loadPlugins } from '../../plugin'; 2 | import { getInput } from '../../utils/input'; 3 | 4 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 5 | 6 | const mockExamplePlugin = { 7 | name: 'yuki-no-plugin-example', 8 | async onInit() { 9 | const myPluginInput = getInput('MY_PLUGIN_INPUT'); 10 | if (myPluginInput) { 11 | console.log(`my-plugin-input: ${myPluginInput}`); 12 | } 13 | }, 14 | async onBeforeCompare() {}, 15 | async onAfterCompare() {}, 16 | async onBeforeCreateIssue() {}, 17 | async onAfterCreateIssue() {}, 18 | async onFinally() {}, 19 | async onError() {}, 20 | }; 21 | 22 | vi.doMock(mockExamplePlugin.name, () => ({ default: mockExamplePlugin })); 23 | 24 | describe('plugin loading and hooks', () => { 25 | beforeEach(() => { 26 | delete process.env.MY_PLUGIN_INPUT; 27 | }); 28 | 29 | it('loads plugin and calls hooks', async () => { 30 | const plugins = await loadPlugins(['yuki-no-plugin-example']); 31 | const plugin = plugins[0]; 32 | const ctx: any = { octokit: {}, context: {}, config: {} }; 33 | const spies = { 34 | onInit: vi.spyOn(plugin, 'onInit'), 35 | onBeforeCompare: vi.spyOn(plugin, 'onBeforeCompare'), 36 | onAfterCompare: vi.spyOn(plugin, 'onAfterCompare'), 37 | onBeforeCreateIssue: vi.spyOn(plugin, 'onBeforeCreateIssue'), 38 | onAfterCreateIssue: vi.spyOn(plugin, 'onAfterCreateIssue'), 39 | onFinally: vi.spyOn(plugin, 'onFinally'), 40 | }; 41 | 42 | await plugin.onInit?.(ctx); 43 | await plugin.onBeforeCompare?.(ctx); 44 | await plugin.onAfterCompare?.({ ...ctx, commits: [] }); 45 | await plugin.onBeforeCreateIssue?.({ 46 | ...ctx, 47 | commit: {} as any, 48 | issueMeta: {} as any, 49 | }); 50 | await plugin.onAfterCreateIssue?.({ 51 | ...ctx, 52 | commit: {} as any, 53 | result: {} as any, 54 | }); 55 | await plugin.onFinally?.({ ...ctx, success: true }); 56 | 57 | expect(spies.onInit).toHaveBeenCalled(); 58 | expect(spies.onBeforeCompare).toHaveBeenCalled(); 59 | expect(spies.onAfterCompare).toHaveBeenCalled(); 60 | expect(spies.onBeforeCreateIssue).toHaveBeenCalled(); 61 | expect(spies.onAfterCreateIssue).toHaveBeenCalled(); 62 | expect(spies.onFinally).toHaveBeenCalled(); 63 | }); 64 | 65 | it('example plugin logs token when provided via environment variable', async () => { 66 | process.env.MY_PLUGIN_INPUT = 'test-token'; 67 | 68 | const plugins = await loadPlugins(['yuki-no-plugin-example']); 69 | const plugin = plugins[0]; 70 | const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 71 | 72 | const ctx: any = { 73 | octokit: {}, 74 | context: {}, 75 | config: {}, 76 | }; 77 | 78 | await plugin.onInit?.(ctx); 79 | 80 | expect(consoleSpy).toHaveBeenCalledWith('my-plugin-input: test-token'); 81 | 82 | consoleSpy.mockRestore(); 83 | }); 84 | 85 | it('example plugin does not log when environment variable is not provided', async () => { 86 | const plugins = await loadPlugins(['yuki-no-plugin-example']); 87 | const plugin = plugins[0]; 88 | const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 89 | 90 | const ctx: any = { 91 | octokit: {}, 92 | context: {}, 93 | config: {}, 94 | }; 95 | 96 | await plugin.onInit?.(ctx); 97 | 98 | expect(consoleSpy).not.toHaveBeenCalled(); 99 | 100 | consoleSpy.mockRestore(); 101 | }); 102 | 103 | it('throws error for plugin without default export', async () => { 104 | const INVALID_PLUGIN_NAME = 'test-invalid-plugin'; 105 | 106 | vi.doMock(INVALID_PLUGIN_NAME, () => ({ 107 | default: undefined, 108 | someOtherExport: 'test', 109 | })); 110 | 111 | await expect(loadPlugins([INVALID_PLUGIN_NAME])).rejects.toThrow( 112 | `Plugin "${INVALID_PLUGIN_NAME}" does not export a default plugin object`, 113 | ); 114 | 115 | vi.doUnmock(INVALID_PLUGIN_NAME); 116 | }); 117 | 118 | it('throws error for plugin without plugin name', async () => { 119 | const INVALID_PLUGIN_NAME = 'test-invalid-plugin'; 120 | 121 | vi.doMock(INVALID_PLUGIN_NAME, () => ({ 122 | default: {}, 123 | someOtherExport: 'test', 124 | })); 125 | 126 | await expect(loadPlugins([INVALID_PLUGIN_NAME])).rejects.toThrow( 127 | `Plugin "${INVALID_PLUGIN_NAME}" must have a "name" property`, 128 | ); 129 | 130 | vi.doUnmock(INVALID_PLUGIN_NAME); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /packages/core/tests/integration/orchestration-error.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 | 3 | describe('Core Orchestration Integration - Error path', () => { 4 | beforeEach(() => { 5 | vi.resetModules(); 6 | vi.clearAllMocks(); 7 | 8 | process.env.ACCESS_TOKEN = 'test-token'; 9 | process.env.USER_NAME = 'bot'; 10 | process.env.EMAIL = 'bot@ex.com'; 11 | process.env.UPSTREAM_REPO = 'https://github.com/acme/upstream.git'; 12 | process.env.HEAD_REPO = 'https://github.com/acme/head.git'; 13 | process.env.HEAD_REPO_BRANCH = 'main'; 14 | process.env.TRACK_FROM = 'aaaaaaaa'; 15 | process.env.LABELS = 'sync'; 16 | process.env.PLUGINS = 'mock-plugin-err@1.0.0'; 17 | process.env.VERBOSE = 'true'; 18 | 19 | vi.mock('mock-plugin-err', () => { 20 | let resolve: (v?: unknown) => void; 21 | const done = new Promise(r => (resolve = r)); 22 | const events: unknown[] = []; 23 | const plugin = { 24 | name: 'mock-plugin-err', 25 | onInit(ctx: unknown) { 26 | events.push(['onInit', ctx]); 27 | }, 28 | onBeforeCompare() { 29 | events.push(['onBeforeCompare']); 30 | throw new Error('boom'); 31 | }, 32 | onError(ctx: unknown) { 33 | events.push(['onError', ctx]); 34 | }, 35 | onFinally(ctx: unknown) { 36 | events.push(['onFinally', ctx]); 37 | resolve(); 38 | }, 39 | }; 40 | (globalThis as any).__mockErrPlugin = { events, done }; 41 | return { default: plugin }; 42 | }); 43 | 44 | vi.mock('../../infra/git.ts', () => { 45 | class Git { 46 | repoUrl = 'https://github.com/acme/head'; 47 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 48 | constructor(_: any) {} 49 | clone() {} 50 | exec() { 51 | return ''; 52 | } 53 | } 54 | return { Git }; 55 | }); 56 | 57 | vi.mock('../../infra/github.ts', () => { 58 | class GitHub { 59 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 | constructor(readonly config: any) {} 61 | get ownerAndRepo() { 62 | return { 63 | owner: this.config.repoSpec.owner, 64 | repo: this.config.repoSpec.name, 65 | }; 66 | } 67 | get repoSpec() { 68 | return this.config.repoSpec; 69 | } 70 | get configuredLabels() { 71 | return this.config.labels; 72 | } 73 | api = { 74 | actions: { 75 | listWorkflowRunsForRepo: async () => ({ 76 | data: { workflow_runs: [] }, 77 | }), 78 | }, 79 | search: { 80 | issuesAndPullRequests: async () => ({ data: { items: [] } }), 81 | }, 82 | issues: { 83 | create: vi.fn(async () => ({ 84 | data: { number: 1, created_at: '2024-01-01T00:00:00Z' }, 85 | })), 86 | }, 87 | }; 88 | } 89 | return { GitHub }; 90 | }); 91 | }); 92 | 93 | afterEach(() => { 94 | delete process.env.ACCESS_TOKEN; 95 | delete process.env.USER_NAME; 96 | delete process.env.EMAIL; 97 | delete process.env.UPSTREAM_REPO; 98 | delete process.env.HEAD_REPO; 99 | delete process.env.HEAD_REPO_BRANCH; 100 | delete process.env.TRACK_FROM; 101 | delete process.env.LABELS; 102 | delete process.env.PLUGINS; 103 | delete process.env.VERBOSE; 104 | }); 105 | 106 | it('Error path: triggers onError in catch and onFinally with success=false', async () => { 107 | process.once('unhandledRejection', (reason: unknown) => { 108 | if (reason instanceof Error && reason.message === 'boom') { 109 | return; 110 | } 111 | throw reason; 112 | }); 113 | 114 | // @ts-expect-error for testing 115 | await import('mock-plugin-err'); 116 | await import('../../index.ts'); 117 | const { done, events } = (globalThis as any).__mockErrPlugin as { 118 | done: Promise; 119 | events: unknown[]; 120 | }; 121 | await done; 122 | 123 | const calls = (events as any[]).map(e => (e as any[])[0] as string); 124 | expect(calls).toContain('onInit'); 125 | expect(calls).toContain('onBeforeCompare'); 126 | expect(calls).toContain('onError'); 127 | expect(calls).toContain('onFinally'); 128 | 129 | const finallyPayload = ( 130 | (events as any[]).find(e => (e as any[])[0] === 'onFinally') as any[] 131 | )[1] as any; 132 | expect(finallyPayload.success).toBe(false); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Yuki-no' 2 | description: 'Automatic git commit tracking service.' 3 | 4 | inputs: 5 | access-token: 6 | description: 'GitHub access token.' 7 | required: true 8 | username: 9 | description: 'Git username used for GitHub issue operations. Defaults to "github-actions".' 10 | required: false 11 | default: 'github-actions' 12 | email: 13 | description: 'Git username used for GitHub issue operations. Defaults to "action@github.com".' 14 | required: false 15 | default: 'action@github.com' 16 | upstream-repo: 17 | description: 'URL of your repository. If not specified, the current working repository will be used. e.g. https://github.com/vitejs/docs-ko.git' 18 | required: false 19 | head-repo: 20 | description: 'URL of repository to track. e.g. https://github.com/vitejs/vite.git' 21 | required: true 22 | head-repo-branch: 23 | description: 'Branch to track in head repo. Defaults to "main".' 24 | required: false 25 | default: 'main' 26 | track-from: 27 | description: 'Starting commit hash. Tracking starts from the next commit.' 28 | required: true 29 | include: 30 | description: 'Glob patterns for files to track. If not specified, all files will be tracked.' 31 | required: false 32 | exclude: 33 | description: 'Glob patterns for files to exclude. Take precedence over include patterns.' 34 | required: false 35 | plugins: 36 | description: 'Names of Yuki-no plugins to load with exact version specification (newline separated). Format: plugin-name@1.0.0 (exact version required).' 37 | required: false 38 | labels: 39 | description: 'Labels for issues. You can specify multiple labels separated by newlines. If empty string is provided, no labels will be added. Defaults to "sync".' 40 | required: false 41 | default: | 42 | sync 43 | release-tracking: 44 | description: '(Deprecated) Whether to enable release tracking. Use plugins: ["@yuki-no/plugin-release-tracking"] instead.' 45 | required: false 46 | deprecationMessage: 'release-tracking option is deprecated. Use plugins: ["@yuki-no/plugin-release-tracking"] instead. See documentation for migration guide.' 47 | release-tracking-labels: 48 | description: '(Deprecated) Labels for unreleased changes. Use env.YUKI_NO_RELEASE_TRACKING_LABELS instead.' 49 | required: false 50 | deprecationMessage: 'release-tracking-labels option is deprecated. Set YUKI_NO_RELEASE_TRACKING_LABELS in env block instead.' 51 | default: | 52 | pending 53 | verbose: 54 | description: 'When enabled, Yuki-no will show all log messages including info and success messages. This is useful for debugging.' 55 | required: false 56 | default: true 57 | 58 | runs: 59 | using: 'composite' 60 | steps: 61 | - name: Set environment variables 62 | shell: bash 63 | run: | 64 | echo "YUKI_NO_VERSION=${action_ref}" >> $GITHUB_ENV 65 | env: 66 | action_ref: ${{ github.action_ref }} 67 | 68 | - name: Normalize plugins configuration 69 | shell: bash 70 | run: | 71 | if [ "${release_tracking}" = "true" ]; then 72 | echo "⚠️ Warning: RELEASE_TRACKING is deprecated..." 73 | 74 | NORMALIZED_PLUGINS="${plugins}" 75 | if [ ! -z "$NORMALIZED_PLUGINS" ]; then 76 | NORMALIZED_PLUGINS="$NORMALIZED_PLUGINS"$'\n'"@yuki-no/plugin-release-tracking@latest" 77 | else 78 | NORMALIZED_PLUGINS="@yuki-no/plugin-release-tracking@latest" 79 | fi 80 | else 81 | NORMALIZED_PLUGINS="${plugins}" 82 | fi 83 | 84 | echo "PLUGINS<> $GITHUB_ENV 85 | echo "$NORMALIZED_PLUGINS" >> $GITHUB_ENV 86 | echo "EOF" >> $GITHUB_ENV 87 | env: 88 | plugins: ${{ inputs.plugins }} 89 | release_tracking: ${{ inputs.release-tracking }} 90 | 91 | - name: Checkout Yuki-no 92 | run: ${{ github.action_path }}/scripts/checkout.sh 93 | shell: bash 94 | 95 | - name: Run Yuki-no 96 | env: 97 | ACCESS_TOKEN: ${{ inputs.access-token }} 98 | USER_NAME: ${{ inputs.username }} 99 | EMAIL: ${{ inputs.email }} 100 | UPSTREAM_REPO: ${{ inputs.upstream-repo }} 101 | HEAD_REPO: ${{ inputs.head-repo }} 102 | HEAD_REPO_BRANCH: ${{ inputs.head-repo-branch }} 103 | TRACK_FROM: ${{ inputs.track-from }} 104 | INCLUDE: ${{ inputs.include }} 105 | EXCLUDE: ${{ inputs.exclude }} 106 | PLUGINS: ${{ env.PLUGINS }} 107 | LABELS: ${{ inputs.labels }} 108 | VERBOSE: ${{ inputs.verbose }} 109 | YUKI_NO_RELEASE_TRACKING_LABELS: ${{ inputs.release-tracking-labels }} # compat 110 | run: | 111 | cd yuki-no 112 | pnpm start 113 | shell: bash 114 | -------------------------------------------------------------------------------- /packages/core/tests/utils/input.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getBooleanInput, 3 | getInput, 4 | getMultilineInput, 5 | splitByNewline, 6 | } from '../../utils/input'; 7 | 8 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 9 | 10 | describe('plugin input helpers', () => { 11 | beforeEach(() => { 12 | process.env.TEST_TOKEN = 'abc123'; 13 | process.env.TEST_ENABLED = 'true'; 14 | process.env.TEST_PATHS = 'a\nb\nc'; 15 | }); 16 | 17 | afterEach(() => { 18 | delete process.env.TEST_TOKEN; 19 | delete process.env.TEST_ENABLED; 20 | delete process.env.TEST_PATHS; 21 | }); 22 | 23 | describe('getInput', () => { 24 | it('returns environment variable value', () => { 25 | expect(getInput('TEST_TOKEN')).toBe('abc123'); 26 | }); 27 | 28 | it('returns undefined for non-existent variable', () => { 29 | expect(getInput('NON_EXISTENT')).toBeUndefined(); 30 | }); 31 | 32 | it('returns default value when variable is undefined', () => { 33 | expect(getInput('NON_EXISTENT', 'default')).toBe('default'); 34 | }); 35 | 36 | it('returns environment value over default', () => { 37 | expect(getInput('TEST_TOKEN', 'default')).toBe('abc123'); 38 | }); 39 | }); 40 | 41 | describe('getBooleanInput', () => { 42 | it('parses true correctly', () => { 43 | expect(getBooleanInput('TEST_ENABLED')).toBe(true); 44 | }); 45 | 46 | it('parses false correctly', () => { 47 | process.env.TEST_FALSE = 'false'; 48 | expect(getBooleanInput('TEST_FALSE')).toBe(false); 49 | delete process.env.TEST_FALSE; 50 | }); 51 | 52 | it('returns false by default for non-existent variable', () => { 53 | expect(getBooleanInput('NON_EXISTENT')).toBe(false); 54 | }); 55 | 56 | it('returns custom default value', () => { 57 | expect(getBooleanInput('NON_EXISTENT', true)).toBe(true); 58 | }); 59 | }); 60 | 61 | describe('getMultilineInput', () => { 62 | it('splits lines correctly', () => { 63 | expect(getMultilineInput('TEST_PATHS')).toEqual(['a', 'b', 'c']); 64 | }); 65 | 66 | it('returns empty array by default for non-existent variable', () => { 67 | expect(getMultilineInput('NON_EXISTENT')).toEqual([]); 68 | }); 69 | 70 | it('returns custom default value', () => { 71 | expect( 72 | getMultilineInput('NON_EXISTENT', ['default1', 'default2']), 73 | ).toEqual(['default1', 'default2']); 74 | }); 75 | }); 76 | }); 77 | 78 | describe('splitByNewline', () => { 79 | it('Empty strings or undefined should return an empty array', () => { 80 | expect(splitByNewline('')).toEqual([]); 81 | expect(splitByNewline(' ')).toEqual([]); 82 | expect(splitByNewline(undefined)).toEqual([]); 83 | }); 84 | 85 | it('Should split newline-delimited strings into an array', () => { 86 | expect(splitByNewline('a\nb\nc')).toEqual(['a', 'b', 'c']); 87 | }); 88 | 89 | it('Should exclude empty and blank lines from results', () => { 90 | expect(splitByNewline('a\n\nb\n \nc')).toEqual(['a', 'b', 'c']); 91 | }); 92 | 93 | it('Should remove leading and trailing whitespace', () => { 94 | expect(splitByNewline(' a\nb ')).toEqual(['a', 'b']); 95 | }); 96 | 97 | describe('when trim=true (explicit)', () => { 98 | it('should trim whitespace from each line', () => { 99 | // Given 100 | const input = ' a\n b \n c '; 101 | 102 | // When 103 | const result = splitByNewline(input, true); 104 | 105 | // Then 106 | expect(result).toEqual(['a', 'b', 'c']); 107 | }); 108 | 109 | it('should remove blank lines containing only whitespace', () => { 110 | // Given 111 | const input = 'a\n \nb'; 112 | 113 | // When 114 | const result = splitByNewline(input, true); 115 | 116 | // Then 117 | expect(result).toEqual(['a', 'b']); 118 | }); 119 | }); 120 | 121 | describe('when trim=false', () => { 122 | it('should preserve leading and trailing whitespace in lines', () => { 123 | // Given 124 | const input = ' a\n b \n c '; 125 | 126 | // When 127 | const result = splitByNewline(input, false); 128 | 129 | // Then 130 | expect(result).toEqual([' a', ' b ', ' c ']); 131 | }); 132 | 133 | it('should preserve whitespace-only lines', () => { 134 | // Given 135 | const input = 'a\n \nb'; 136 | 137 | // When 138 | const result = splitByNewline(input, false); 139 | 140 | // Then 141 | expect(result).toEqual(['a', ' ', 'b']); 142 | }); 143 | 144 | it('should still remove completely empty lines', () => { 145 | // Given 146 | const input = 'a\n\nb'; 147 | 148 | // When 149 | const result = splitByNewline(input, false); 150 | 151 | // Then 152 | expect(result).toEqual(['a', 'b']); 153 | }); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /packages/core/tests/createConfig.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createConfig, 3 | inferUpstreamRepo, 4 | defaults as yukiNoDefaults, 5 | } from '../createConfig'; 6 | 7 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 8 | 9 | const TEST_ENV = { 10 | ACCESS_TOKEN: 'test-token', 11 | HEAD_REPO: 'https://github.com/test/head-repo.git', 12 | TRACK_FROM: 'test-commit-hash', 13 | GITHUB_SERVER_URL: 'https://github.com', 14 | GITHUB_REPOSITORY: 'test/current-repo', 15 | }; 16 | 17 | const originalEnv = { ...process.env }; 18 | 19 | beforeEach(() => { 20 | process.env = { ...originalEnv, ...TEST_ENV }; 21 | }); 22 | 23 | afterEach(() => { 24 | process.env = originalEnv; 25 | }); 26 | 27 | describe('Basic configuration creation', () => { 28 | it('Creates basic configuration with only required envs', () => { 29 | const config = createConfig(); 30 | 31 | expect(config).toEqual({ 32 | accessToken: TEST_ENV.ACCESS_TOKEN, 33 | userName: yukiNoDefaults.userName, 34 | email: yukiNoDefaults.email, 35 | upstreamRepoSpec: { 36 | owner: 'test', 37 | name: 'current-repo', 38 | branch: yukiNoDefaults.branch, 39 | }, 40 | headRepoSpec: { 41 | owner: 'test', 42 | name: 'head-repo', 43 | branch: yukiNoDefaults.branch, 44 | }, 45 | trackFrom: TEST_ENV.TRACK_FROM, 46 | include: [], 47 | exclude: [], 48 | labels: [yukiNoDefaults.label], 49 | plugins: [], 50 | verbose: true, 51 | }); 52 | }); 53 | 54 | it('Checks if the specified default value is used when the GITHUB_SERVER_URL env is not present', () => { 55 | delete process.env.GITHUB_SERVER_URL; 56 | 57 | const upstreamRepoUrl = inferUpstreamRepo(); 58 | 59 | expect(upstreamRepoUrl).toEqual( 60 | expect.stringContaining('https://github.com'), 61 | ); 62 | }); 63 | }); 64 | 65 | describe('Custom envs processing', () => { 66 | it('Creates correct configuration when all envs are provided', () => { 67 | const LOCAL_TEST_ENV = { 68 | USER_NAME: 'custom-user', 69 | EMAIL: 'custom@example.com', 70 | UPSTREAM_REPO: 'https://github.com/test/upstream-repo.git', 71 | HEAD_REPO_BRANCH: 'develop', 72 | INCLUDE: '**/*.md\n**/*.ts', 73 | EXCLUDE: 'node_modules\ndist', 74 | LABELS: 'label1\nlabel2', 75 | RELEASE_TRACKING: 'true', 76 | YUKI_NO_RELEASE_TRACKING_LABELS: 'pending-release\nreleased', 77 | VERBOSE: 'true', 78 | PLUGINS: 'yuki-no-plugin-example@1.0.0', 79 | }; 80 | 81 | process.env = { ...process.env, ...LOCAL_TEST_ENV }; 82 | 83 | const config = createConfig(); 84 | 85 | expect(config).toEqual({ 86 | accessToken: TEST_ENV.ACCESS_TOKEN, 87 | userName: LOCAL_TEST_ENV.USER_NAME, 88 | email: LOCAL_TEST_ENV.EMAIL, 89 | upstreamRepoSpec: { 90 | owner: 'test', 91 | name: 'upstream-repo', 92 | branch: yukiNoDefaults.branch, 93 | }, 94 | headRepoSpec: { 95 | owner: 'test', 96 | name: 'head-repo', 97 | branch: LOCAL_TEST_ENV.HEAD_REPO_BRANCH, 98 | }, 99 | trackFrom: TEST_ENV.TRACK_FROM, 100 | include: ['**/*.md', '**/*.ts'], 101 | exclude: ['node_modules', 'dist'], 102 | labels: ['label1', 'label2'].sort(), 103 | plugins: ['yuki-no-plugin-example@1.0.0'], 104 | verbose: true, 105 | }); 106 | }); 107 | }); 108 | 109 | describe('Error handling', () => { 110 | it('Throws an error when required envs are missing', () => { 111 | delete process.env.ACCESS_TOKEN; 112 | 113 | expect(() => createConfig()).toThrow('`accessToken` is required.'); 114 | 115 | process.env.ACCESS_TOKEN = TEST_ENV.ACCESS_TOKEN; 116 | delete process.env.HEAD_REPO; 117 | 118 | expect(() => createConfig()).toThrow('`headRepo` is required.'); 119 | 120 | process.env.HEAD_REPO = TEST_ENV.HEAD_REPO; 121 | delete process.env.TRACK_FROM; 122 | 123 | expect(() => createConfig()).toThrow('`trackFrom` is required.'); 124 | }); 125 | 126 | it('Throws an error when GITHUB_REPOSITORY is missing and UPSTREAM_REPO is also missing', () => { 127 | delete process.env.GITHUB_REPOSITORY; 128 | 129 | expect(() => createConfig()).toThrow( 130 | 'Failed to infer upstream repository: GITHUB_REPOSITORY environment variable is not set.', 131 | ); 132 | }); 133 | }); 134 | 135 | describe('Plugin handling edge cases', () => { 136 | it('Filters out empty and whitespace-only plugin strings', () => { 137 | process.env.PLUGINS = 'valid-plugin@1.0.0\n\n \nvalid-plugin2@2.0.0\n '; 138 | 139 | const config = createConfig(); 140 | 141 | expect(config.plugins).toEqual([ 142 | 'valid-plugin@1.0.0', 143 | 'valid-plugin2@2.0.0', 144 | ]); 145 | }); 146 | 147 | it('Returns empty array when all plugin strings are empty or whitespace', () => { 148 | process.env.PLUGINS = '\n \n \n'; 149 | 150 | const config = createConfig(); 151 | 152 | expect(config.plugins).toEqual([]); 153 | }); 154 | 155 | it('Handles single whitespace-only plugin string', () => { 156 | process.env.PLUGINS = ' '; 157 | 158 | const config = createConfig(); 159 | 160 | expect(config.plugins).toEqual([]); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /packages/release-tracking/tests/updateIssueLabelsByRelease.test.ts: -------------------------------------------------------------------------------- 1 | import type { ReleaseInfo } from '../utils/getRelease'; 2 | import { updateIssueLabelsByRelease } from '../utils/updateIssueLabelsByRelease'; 3 | 4 | import { GitHub } from '@yuki-no/plugin-sdk/infra/github'; 5 | import type { Issue } from '@yuki-no/plugin-sdk/types/github'; 6 | import { beforeEach, expect, it, vi } from 'vitest'; 7 | 8 | const MOCK_RELEASE_TRACKING_LABELS = ['needs-release', 'in-next-release']; 9 | 10 | // Mock GitHub API methods 11 | const setIssueLabelsMock = vi.fn(); 12 | 13 | // Mocking to avoid network requests 14 | vi.mock('@yuki-no/plugin-sdk/infra/github', () => ({ 15 | GitHub: vi.fn().mockImplementation(() => ({ 16 | api: { 17 | issues: { 18 | setLabels: setIssueLabelsMock, 19 | }, 20 | }, 21 | ownerAndRepo: { owner: 'test-owner', repo: 'test-repo' }, 22 | configuredLabels: [], 23 | })), 24 | })); 25 | 26 | // Mock environment variables 27 | vi.mock('@yuki-no/plugin-sdk/utils/input', () => ({ 28 | getMultilineInput: vi.fn((key: string, defaultValue: string[]) => { 29 | if (key === 'YUKI_NO_RELEASE_TRACKING_LABELS') { 30 | return MOCK_RELEASE_TRACKING_LABELS; 31 | } 32 | return defaultValue; 33 | }), 34 | })); 35 | 36 | vi.mock('@yuki-no/plugin-sdk/utils/common', () => ({ 37 | unique: vi.fn((arr: string[]) => [...new Set(arr)]), 38 | })); 39 | 40 | const mockGitHub = new GitHub({} as any); 41 | 42 | const MOCK_ISSUE: Issue = { 43 | number: 123, 44 | body: 'Issue body', 45 | labels: ['bug', 'enhancement'], 46 | hash: 'abc123', 47 | isoDate: '2023-01-01T12:00:00Z', 48 | }; 49 | 50 | const MOCK_RELEASE_INFO: ReleaseInfo = { 51 | prerelease: { 52 | version: 'v1.0.0-beta.1', 53 | url: 'https://github.com/test-owner/test-repo/releases/tag/v1.0.0-beta.1', 54 | }, 55 | release: { 56 | version: 'v1.0.0', 57 | url: 'https://github.com/test-owner/test-repo/releases/tag/v1.0.0', 58 | }, 59 | }; 60 | 61 | beforeEach(() => { 62 | vi.clearAllMocks(); 63 | }); 64 | 65 | it('For released issues, release tracking labels should be removed', async () => { 66 | const issueWithTrackingLabels: Issue = { 67 | ...MOCK_ISSUE, 68 | labels: ['bug', 'enhancement', ...MOCK_RELEASE_TRACKING_LABELS], 69 | }; 70 | 71 | await updateIssueLabelsByRelease( 72 | mockGitHub, 73 | issueWithTrackingLabels, 74 | MOCK_RELEASE_INFO, 75 | ); 76 | 77 | expect(setIssueLabelsMock).toHaveBeenCalledWith({ 78 | owner: 'test-owner', 79 | repo: 'test-repo', 80 | issue_number: issueWithTrackingLabels.number, 81 | labels: ['bug', 'enhancement'], 82 | }); 83 | }); 84 | 85 | it('For unreleased issues, release tracking labels should be added', async () => { 86 | const prereleaseOnly: ReleaseInfo = { 87 | prerelease: MOCK_RELEASE_INFO.prerelease, 88 | release: undefined, 89 | }; 90 | 91 | await updateIssueLabelsByRelease(mockGitHub, MOCK_ISSUE, prereleaseOnly); 92 | 93 | expect(setIssueLabelsMock).toHaveBeenCalledWith({ 94 | owner: 'test-owner', 95 | repo: 'test-repo', 96 | issue_number: MOCK_ISSUE.number, 97 | labels: expect.arrayContaining([ 98 | ...MOCK_ISSUE.labels, 99 | ...MOCK_RELEASE_TRACKING_LABELS, 100 | ]), 101 | }); 102 | }); 103 | 104 | it('For unreleased issues, if all release tracking labels are already present, no changes should be made', async () => { 105 | const issueWithAllTrackingLabels: Issue = { 106 | ...MOCK_ISSUE, 107 | labels: [...MOCK_ISSUE.labels, ...MOCK_RELEASE_TRACKING_LABELS].sort(), 108 | }; 109 | 110 | const prereleaseOnly: ReleaseInfo = { 111 | prerelease: MOCK_RELEASE_INFO.prerelease, 112 | release: undefined, 113 | }; 114 | 115 | await updateIssueLabelsByRelease( 116 | mockGitHub, 117 | issueWithAllTrackingLabels, 118 | prereleaseOnly, 119 | ); 120 | 121 | expect(setIssueLabelsMock).not.toHaveBeenCalled(); 122 | }); 123 | 124 | it('For unreleased issues, if only some release tracking labels are present, the remaining labels should be added', async () => { 125 | const issueWithSomeTrackingLabels: Issue = { 126 | ...MOCK_ISSUE, 127 | labels: [...MOCK_ISSUE.labels, MOCK_RELEASE_TRACKING_LABELS[0]], 128 | }; 129 | 130 | const prereleaseOnly: ReleaseInfo = { 131 | prerelease: MOCK_RELEASE_INFO.prerelease, 132 | release: undefined, 133 | }; 134 | 135 | await updateIssueLabelsByRelease( 136 | mockGitHub, 137 | issueWithSomeTrackingLabels, 138 | prereleaseOnly, 139 | ); 140 | 141 | expect(setIssueLabelsMock).toHaveBeenCalledWith({ 142 | owner: 'test-owner', 143 | repo: 'test-repo', 144 | issue_number: issueWithSomeTrackingLabels.number, 145 | labels: expect.arrayContaining([ 146 | ...issueWithSomeTrackingLabels.labels, 147 | MOCK_RELEASE_TRACKING_LABELS[1], 148 | ]), 149 | }); 150 | }); 151 | 152 | it('If release information is undefined, it should be treated as unreleased and labels should be added', async () => { 153 | const noReleaseInfo: ReleaseInfo = { 154 | prerelease: undefined, 155 | release: undefined, 156 | }; 157 | 158 | await updateIssueLabelsByRelease(mockGitHub, MOCK_ISSUE, noReleaseInfo); 159 | 160 | expect(setIssueLabelsMock).toHaveBeenCalledWith({ 161 | owner: 'test-owner', 162 | repo: 'test-repo', 163 | issue_number: MOCK_ISSUE.number, 164 | labels: expect.arrayContaining([ 165 | ...MOCK_ISSUE.labels, 166 | ...MOCK_RELEASE_TRACKING_LABELS, 167 | ]), 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /packages/core/tests/utils-infra/lookupCommitsInIssues.test.ts: -------------------------------------------------------------------------------- 1 | import { GitHub } from '../../infra/github'; 2 | import type { Commit } from '../../types/git'; 3 | import { 4 | chunk, 5 | lookupCommitsInIssues, 6 | } from '../../utils-infra/lookupCommitsInIssues'; 7 | 8 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 9 | 10 | const mockIssuesAndPullRequests = vi.fn(); 11 | 12 | vi.mock('../../infra/github', () => ({ 13 | GitHub: vi.fn().mockImplementation(() => ({ 14 | api: { search: { issuesAndPullRequests: mockIssuesAndPullRequests } }, 15 | repoSpec: { owner: 'test-owner', name: 'test-repo' }, 16 | })), 17 | })); 18 | 19 | const MOCK_CONFIG = { 20 | accessToken: 'test-token', 21 | labels: ['test-label'], 22 | repoSpec: { 23 | owner: 'test-owner', 24 | name: 'test-repo', 25 | branch: 'main', 26 | }, 27 | }; 28 | 29 | const mockGitHub = new GitHub(MOCK_CONFIG); 30 | 31 | const createMockCommit = (len: number): Commit[] => 32 | [...Array(len)].map((_, ind) => ({ 33 | hash: `aaaa${ind}`.padEnd(7, '0'), 34 | title: `Commit ${ind}`, 35 | isoDate: '2023-01-01T00:00:00Z', 36 | fileNames: [`file1-${ind}.ts`, `file2-${ind}.ts`], 37 | })); 38 | 39 | beforeEach(() => { 40 | vi.clearAllMocks(); 41 | }); 42 | 43 | describe('chunk function', () => { 44 | it('Should return single array when chunkSize >= data length', () => { 45 | const data = [1, 2, 3]; 46 | const result = chunk(data, 5); 47 | 48 | expect(result).toEqual([[1, 2, 3]]); 49 | }); 50 | 51 | it('Should chunk data correctly when chunkSize < data length', () => { 52 | const data = [1, 2, 3, 4, 5, 6, 7]; 53 | const result = chunk(data, 3); 54 | 55 | expect(result).toEqual([[1, 2, 3], [4, 5, 6], [7]]); 56 | }); 57 | 58 | it('Should throw error for invalid chunkSize (0)', () => { 59 | const data = [1, 2, 3]; 60 | 61 | expect(() => chunk(data, 0)).toThrow('Invalid chunkSize'); 62 | }); 63 | 64 | it('Should throw error for invalid chunkSize (negative)', () => { 65 | const data = [1, 2, 3]; 66 | 67 | expect(() => chunk(data, -1)).toThrow('Invalid chunkSize'); 68 | }); 69 | }); 70 | 71 | describe('lookupCommitsInIssues', () => { 72 | it('Should return an empty array when the commits array is empty', async () => { 73 | const result = await lookupCommitsInIssues(mockGitHub, []); 74 | 75 | expect(result).toEqual([]); 76 | expect(mockIssuesAndPullRequests).not.toHaveBeenCalled(); 77 | }); 78 | 79 | it('Should return an empty array when all commits are already registered in issues', async () => { 80 | const commits = createMockCommit(2); 81 | 82 | mockIssuesAndPullRequests.mockResolvedValue({ 83 | data: { 84 | items: [ 85 | { body: `https://github.com/test/repo/commit/${commits[0].hash}` }, 86 | { body: `https://github.com/test/repo/commit/${commits[1].hash}` }, 87 | ], 88 | }, 89 | }); 90 | 91 | const result = await lookupCommitsInIssues(mockGitHub, commits); 92 | 93 | expect(result).toEqual([]); 94 | expect(mockIssuesAndPullRequests).toHaveBeenCalledWith({ 95 | q: `repo:test-owner/test-repo type:issue (${commits[0].hash} in:body OR ${commits[1].hash} in:body)`, 96 | advanced_search: 'true', 97 | }); 98 | }); 99 | 100 | it('Should return only unregistered commits', async () => { 101 | const commits = createMockCommit(3); 102 | 103 | mockIssuesAndPullRequests.mockResolvedValue({ 104 | data: { 105 | items: [ 106 | { body: `https://github.com/test/repo/commit/${commits[0].hash}` }, 107 | // Second commit is not registered in issues (should be returned) 108 | { body: `https://github.com/test/repo/commit/${commits[2].hash}` }, 109 | ], 110 | }, 111 | }); 112 | 113 | const result = await lookupCommitsInIssues(mockGitHub, commits); 114 | 115 | expect(result).toEqual([commits[1]]); 116 | expect(result.length).toBe(1); 117 | expect(result[0].hash).toBe(commits[1].hash); 118 | 119 | expect(mockIssuesAndPullRequests).toHaveBeenCalledWith({ 120 | q: `repo:test-owner/test-repo type:issue (${commits[0].hash} in:body OR ${commits[1].hash} in:body OR ${commits[2].hash} in:body)`, 121 | advanced_search: 'true', 122 | }); 123 | }); 124 | 125 | it('Should handle commits when chunked into multiple API calls', async () => { 126 | // Create 7 commits to test chunking (chunk size is 5) 127 | const commits = createMockCommit(7); 128 | 129 | // Mock two API calls since 7 commits will be split into chunks of 5 and 2 130 | mockIssuesAndPullRequests 131 | .mockResolvedValueOnce({ 132 | data: { 133 | items: [ 134 | { body: `https://github.com/test/repo/commit/${commits[0].hash}` }, 135 | ], 136 | }, 137 | }) 138 | .mockResolvedValueOnce({ 139 | data: { 140 | items: [ 141 | { body: `https://github.com/test/repo/commit/${commits[6].hash}` }, 142 | ], 143 | }, 144 | }); 145 | 146 | const result = await lookupCommitsInIssues(mockGitHub, commits); 147 | 148 | // Should return 5 unregistered commits (commits[1] through commits[5]) 149 | expect(result).toHaveLength(5); 150 | expect(result.map(c => c.hash)).toEqual([ 151 | commits[1].hash, 152 | commits[2].hash, 153 | commits[3].hash, 154 | commits[4].hash, 155 | commits[5].hash, 156 | ]); 157 | 158 | // Should make two API calls due to chunking 159 | expect(mockIssuesAndPullRequests).toHaveBeenCalledTimes(2); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /packages/batch-pr/README.md: -------------------------------------------------------------------------------- 1 | # @yuki-no/plugin-batch-pr 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/@yuki-no/plugin-batch-pr?style=flat-square&label=@yuki-no/plugin-batch-pr)](https://www.npmjs.com/package/@yuki-no/plugin-batch-pr) 4 | 5 | Collects opened Yuki-no translation issues and creates a single pull request to consolidate changes. 6 | 7 | ## Usage 8 | 9 | ```yaml 10 | - uses: Gumball12/yuki-no@v1 11 | env: 12 | # [optional] 13 | # Specifies the root directory path in the `head-repo` 14 | # that should be stripped when applying changes to `upstream-repo` 15 | YUKI_NO_BATCH_PR_ROOT_DIR: head-repo-dirname 16 | 17 | # [optional] 18 | # file patterns that should be excluded from batch PR 19 | YUKI_NO_BATCH_PR_EXCLUDE: | 20 | head-repo-patterns 21 | with: 22 | plugins: | 23 | @yuki-no/plugin-batch-pr@latest 24 | ``` 25 | 26 | ### Configuration 27 | 28 | This plugin reads configuration from environment variables: 29 | 30 | - `YUKI_NO_BATCH_PR_ROOT_DIR` (_optional_): Specifies the root directory path in the `head-repo` that should be stripped from file paths when applying changes to the `upstream-repo`. When set, this prefix will be removed from head-repo file paths before applying changes. If not specified, files will be applied with their original paths. 31 | - `YUKI_NO_BATCH_PR_EXCLUDE` (_optional_): Specifies file patterns to exclude from batch PR processing. Supports glob patterns and multiple patterns separated by newlines. 32 | 33 | ### `YUKI_NO_BATCH_PR_ROOT_DIR` 34 | 35 | The `YUKI_NO_BATCH_PR_ROOT_DIR` option allows you to specify a root directory path in the head repository that should be stripped when applying changes to the upstream repository. This is particularly useful when your head repository has a different directory structure than your upstream repository. 36 | 37 | For example, if your head repository has documentation in a `docs/` folder, but your upstream repository expects the files at the root level, you can configure: 38 | 39 | ```yaml 40 | env: 41 | YUKI_NO_BATCH_PR_ROOT_DIR: docs 42 | ``` 43 | 44 | This will ensure that when applying changes from `docs/README.md` in your head repository, they will be applied to `README.md` in the upstream repository (the `docs/` prefix is stripped). 45 | 46 | ### `YUKI_NO_BATCH_PR_EXCLUDE` 47 | 48 | The `YUKI_NO_BATCH_PR_EXCLUDE` option allows you to specify **`head-repo` file patterns** that should be excluded from batch PR processing. This is useful when you want to exclude certain files like build artifacts, temporary files, or sensitive files from being included in the batch PR. 49 | 50 | The option supports glob patterns and can accept multiple patterns, each on a separate line: 51 | 52 | ```yaml 53 | env: 54 | # Specify `head-repo` file patterns 55 | YUKI_NO_BATCH_PR_EXCLUDE: | 56 | *.log 57 | temp/** 58 | build/* 59 | .env* 60 | **/*.tmp 61 | ``` 62 | 63 | Files matched by `YUKI_NO_BATCH_PR_EXCLUDE` are intentionally NOT applied by the plugin. They will be listed in the batch PR body under "Excluded Files (manual changes required)", so you can quickly see what to update manually. 64 | 65 | Note that this behaves slightly differently from Yuki-no's `exclude` option. The `exclude` option is used when creating issues from `head-repo` changes. For more details, please refer to the [Yuki-no Configuration](../../README.md#configuration) section. 66 | 67 | ## Permissions 68 | 69 | **This plugin requires additional permissions** beyond the default yuki-no setup: 70 | 71 | ```yaml 72 | permissions: 73 | # Default yuki-no permissions 74 | issues: write 75 | actions: read 76 | 77 | # Required for branch creation and push operations 78 | contents: write 79 | 80 | # Required for batch PR creation 81 | pull-requests: write 82 | ``` 83 | 84 | **Additionally, you need to enable "Allow GitHub Actions to create and approve pull requests"** in your repository settings: 85 | 86 | ![Set workflow permissions](./docs/workflow-permissions.png) 87 | 88 | 1. Go to **Settings** → **Actions** → **General** 89 | 2. Under **Workflow permissions**, check **"Allow GitHub Actions to create and approve pull requests"** 90 | 3. Click "Save" 91 | 92 | These permissions are required because the plugin needs to create pull requests. 93 | 94 | ## Configuration 95 | 96 | This plugin uses yuki-no's built-in `include` and `exclude` options to determine which files to track. 97 | 98 | ## How It Works 99 | 100 | 1. Collects all opened yuki-no translation issues 101 | 2. Extracts file changes from each issue's commits 102 | 3. Applies changes to a unified branch 103 | 4. Creates or updates a single pull request with all changes 104 | 5. Links all related issues in the PR description 105 | 106 | ## Important Notes 107 | 108 | ### Closed Issue Handling 109 | 110 | When an issue tracked in a batch PR is closed, **the plugin does not automatically revert its changes** from the batch PR. This behavior is intentional for the following reasons: 111 | 112 | - **Preserves manual edits**: Prevents accidental removal of changes you may have made directly in the batch PR 113 | - **Avoids complex conflicts**: Reduces the risk of merge conflicts with other tracked issues 114 | - **Maintains stability**: Ensures predictable behavior and file states 115 | 116 | If you need to remove changes from closed issues, close the current batch PR and re-run the yuki-no action to create a fresh batch PR with only the currently open issues. 117 | 118 | ### Release Tracking Integration 119 | 120 | When using [`@yuki-no/plugin-release-tracking`](../release-tracking/), **not released issues are automatically excluded** from batch PR collection. If release tracking is not installed, all opened translation issues will be included in the batch as normal. 121 | -------------------------------------------------------------------------------- /packages/batch-pr/index.ts: -------------------------------------------------------------------------------- 1 | import type { FileChange } from './types'; 2 | import { applyFileChanges } from './utils/applyFileChanges'; 3 | import { createCommit } from './utils/createCommit'; 4 | import { createPrBody } from './utils/createPrBody'; 5 | import { extractFileChanges } from './utils/extractFileChanges'; 6 | import { filterPendedTranslationIssues } from './utils/filterPendedTranslationIssues'; 7 | import { getTrackedIssues } from './utils/getTrackedIssues'; 8 | import { setupBatchPr } from './utils/setupBatchPr'; 9 | 10 | import { Git } from '@yuki-no/plugin-sdk/infra/git'; 11 | import { GitHub } from '@yuki-no/plugin-sdk/infra/github'; 12 | import type { YukiNoPlugin } from '@yuki-no/plugin-sdk/types/plugin'; 13 | import { getOpenedIssues } from '@yuki-no/plugin-sdk/utils-infra/getOpenedIssues'; 14 | import { uniqueWith } from '@yuki-no/plugin-sdk/utils/common'; 15 | import { createFileNameFilter } from '@yuki-no/plugin-sdk/utils/createFileNameFilter'; 16 | import { getInput, getMultilineInput } from '@yuki-no/plugin-sdk/utils/input'; 17 | import { log } from '@yuki-no/plugin-sdk/utils/log'; 18 | 19 | const BRANCH_NAME = '__yuki-no-batch-pr'; 20 | 21 | const batchPrPlugin: YukiNoPlugin = { 22 | name: 'batch-pr', 23 | 24 | async onFinally({ config }) { 25 | log('I', '=== Batch PR plugin started ==='); 26 | 27 | const upstreamGitHub = new GitHub({ 28 | ...config, 29 | repoSpec: config.upstreamRepoSpec, 30 | }); 31 | const upstreamGit = new Git({ 32 | ...config, 33 | repoSpec: config.upstreamRepoSpec, 34 | withClone: true, 35 | }); 36 | 37 | const translationIssues = await getOpenedIssues(upstreamGitHub); 38 | // NOTE: Filter out issues that are pending @yuki-no/plugin-release-tracking status 39 | const notPendedTranslationIssues = await filterPendedTranslationIssues( 40 | upstreamGitHub, 41 | translationIssues, 42 | ); 43 | 44 | if (!notPendedTranslationIssues.length) { 45 | log( 46 | 'I', 47 | 'batchPr :: No pending translation issues found, skipping batch PR process', 48 | ); 49 | return; 50 | } 51 | 52 | log('I', 'batchPr :: Setting up batch PR branch and pull request'); 53 | const { prNumber } = await setupBatchPr( 54 | upstreamGitHub, 55 | upstreamGit, 56 | BRANCH_NAME, 57 | ); 58 | 59 | log('I', `batchPr :: Getting tracked issues for PR #${prNumber}`); 60 | const { trackedIssues, shouldTrackIssues } = await getTrackedIssues( 61 | upstreamGitHub, 62 | prNumber, 63 | notPendedTranslationIssues, 64 | ); 65 | log('I', `batchPr :: ${trackedIssues.length} already processed`); 66 | 67 | const issuesToProcess = uniqueWith( 68 | await filterPendedTranslationIssues(upstreamGitHub, shouldTrackIssues), 69 | ({ number }) => number, 70 | ); 71 | 72 | log( 73 | 'I', 74 | `batchPr :: Processing ${issuesToProcess.length} issues (${trackedIssues.length} tracked + ${shouldTrackIssues.length} new - ${shouldTrackIssues.length - issuesToProcess.length} filtered)`, 75 | ); 76 | 77 | const rootDir = getInput('YUKI_NO_BATCH_PR_ROOT_DIR'); 78 | if (rootDir) { 79 | log('I', `batchPr :: Using root directory filter: ${rootDir}`); 80 | } 81 | 82 | const batchPrExcludePatterns = getMultilineInput( 83 | 'YUKI_NO_BATCH_PR_EXCLUDE', 84 | ); 85 | const extendedConfig = { 86 | ...config, 87 | exclude: [...config.exclude, ...batchPrExcludePatterns], 88 | }; 89 | const fileNameFilter = createFileNameFilter(extendedConfig, rootDir); 90 | 91 | if (batchPrExcludePatterns.length > 0) { 92 | log( 93 | 'I', 94 | `batchPr :: Using batch PR exclude patterns: ${batchPrExcludePatterns.join(', ')}`, 95 | ); 96 | } 97 | 98 | const headGit = new Git({ 99 | ...config, 100 | repoSpec: config.headRepoSpec, 101 | withClone: true, 102 | }); 103 | 104 | const fileChanges: FileChange[] = []; 105 | const excludedFiles = new Set(); 106 | 107 | log('I', 'batchPr :: Extracting file changes from commits'); 108 | 109 | for (const { hash } of issuesToProcess) { 110 | const changes = extractFileChanges(headGit, hash, fileNameFilter, { 111 | onExcluded: p => excludedFiles.add(p), 112 | rootDir, 113 | }); 114 | fileChanges.push(...changes); 115 | 116 | log( 117 | 'I', 118 | `batchPr :: Extracted ${changes.length} file changes from commit ${hash.substring(0, 8)}`, 119 | ); 120 | } 121 | 122 | if (!fileChanges.length) { 123 | log('W', 'batchPr :: No file changes found, skipping batch PR update'); 124 | return; 125 | } 126 | 127 | log( 128 | 'I', 129 | `batchPr :: Applying ${fileChanges.length} file changes to upstream repository`, 130 | ); 131 | await applyFileChanges(upstreamGit, fileChanges); 132 | 133 | log('I', 'batchPr :: Creating commit with applied changes'); 134 | createCommit(upstreamGit, { 135 | message: 'Apply origin changes', 136 | }); 137 | 138 | log('I', `batchPr :: Pushing changes to branch ${BRANCH_NAME}`); 139 | upstreamGit.exec(`push -f origin ${BRANCH_NAME}`); 140 | 141 | log( 142 | 'I', 143 | `batchPr :: Updating PR body with ${issuesToProcess.length} linked issues`, 144 | ); 145 | const nextPrBody = createPrBody( 146 | [...issuesToProcess, ...trackedIssues].map(({ number }) => ({ 147 | number, 148 | type: 'Resolved', 149 | })), 150 | { excludedFiles: Array.from(excludedFiles) }, 151 | ); 152 | 153 | await upstreamGitHub.api.pulls.update({ 154 | ...upstreamGitHub.ownerAndRepo, 155 | pull_number: prNumber, 156 | body: nextPrBody, 157 | }); 158 | 159 | log( 160 | 'S', 161 | `batchPr :: Batch PR #${prNumber} updated successfully with ${fileChanges.length} file changes`, 162 | ); 163 | }, 164 | }; 165 | 166 | export default batchPrPlugin; 167 | -------------------------------------------------------------------------------- /packages/release-tracking/tests/updateIssueCommentsByRelease.test.ts: -------------------------------------------------------------------------------- 1 | import { getLastIssueComment } from '../utils/getLastIssueComments'; 2 | import type { ReleaseInfo } from '../utils/getRelease'; 3 | import { updateIssueCommentByRelease } from '../utils/updateIssueCommentsByRelease'; 4 | 5 | import { GitHub } from '@yuki-no/plugin-sdk/infra/github'; 6 | import type { Issue } from '@yuki-no/plugin-sdk/types/github'; 7 | import { beforeEach, expect, it, vi } from 'vitest'; 8 | 9 | const MOCK_RELEASE_TRACKING_LABELS = ['pending']; 10 | 11 | // Mock GitHub API methods 12 | const createIssueCommentMock = vi.fn(); 13 | 14 | // Mocking to bypass network requests 15 | vi.mock('@yuki-no/plugin-sdk/infra/github', () => ({ 16 | GitHub: vi.fn().mockImplementation(() => ({ 17 | api: { 18 | issues: { 19 | createComment: createIssueCommentMock, 20 | }, 21 | }, 22 | ownerAndRepo: { owner: 'test-owner', repo: 'test-repo' }, 23 | configuredLabels: [], 24 | })), 25 | })); 26 | 27 | vi.mock('../utils/getLastIssueComments', () => ({ 28 | getLastIssueComment: vi.fn(), 29 | })); 30 | 31 | // Mock environment variables 32 | vi.mock('@yuki-no/plugin-sdk/utils/input', () => ({ 33 | getMultilineInput: vi.fn((key: string, defaultValue: string[]) => { 34 | if (key === 'YUKI_NO_RELEASE_TRACKING_LABELS') { 35 | return MOCK_RELEASE_TRACKING_LABELS; 36 | } 37 | return defaultValue; 38 | }), 39 | })); 40 | 41 | const mockGitHub = new GitHub({} as any); 42 | 43 | const getLastIssueCommentMock = vi.mocked(getLastIssueComment); 44 | 45 | const MOCK_ISSUE: Issue = { 46 | number: 123, 47 | body: 'Issue body', 48 | labels: ['bug', 'enhancement'], 49 | hash: 'abc123', 50 | isoDate: '2023-01-01T12:00:00Z', 51 | }; 52 | 53 | const MOCK_RELEASE_INFO: ReleaseInfo = { 54 | prerelease: { 55 | version: 'v1.0.0-beta.1', 56 | url: 'https://github.com/test-owner/test-repo/releases/tag/v1.0.0-beta.1', 57 | }, 58 | release: { 59 | version: 'v1.0.0', 60 | url: 'https://github.com/test-owner/test-repo/releases/tag/v1.0.0', 61 | }, 62 | }; 63 | 64 | const EXPECTED_RELEASE_COMMENT = { 65 | prerelease: `- pre-release: [${MOCK_RELEASE_INFO.prerelease?.version}](${MOCK_RELEASE_INFO.prerelease?.url})`, 66 | release: `- release: [${MOCK_RELEASE_INFO.release?.version}](${MOCK_RELEASE_INFO.release?.url})`, 67 | }; 68 | 69 | beforeEach(() => { 70 | vi.clearAllMocks(); 71 | }); 72 | 73 | it('Should not add a new comment if a release comment already exists', async () => { 74 | getLastIssueCommentMock.mockResolvedValue( 75 | '- release: [v0.9.0](https://github.com/...)', 76 | ); 77 | 78 | await updateIssueCommentByRelease( 79 | mockGitHub, 80 | MOCK_ISSUE, 81 | MOCK_RELEASE_INFO, 82 | true, 83 | ); 84 | 85 | expect(createIssueCommentMock).not.toHaveBeenCalled(); 86 | }); 87 | 88 | it('Should handle release comment without version prefix', async () => { 89 | getLastIssueCommentMock.mockResolvedValue( 90 | '- release: [0.9.0](https://github.com/...)', 91 | ); 92 | 93 | await updateIssueCommentByRelease( 94 | mockGitHub, 95 | MOCK_ISSUE, 96 | MOCK_RELEASE_INFO, 97 | true, 98 | ); 99 | 100 | expect(createIssueCommentMock).not.toHaveBeenCalled(); 101 | }); 102 | 103 | it('Should create a correct comment', async () => { 104 | getLastIssueCommentMock.mockResolvedValue(''); 105 | 106 | const preReleaseOnly: ReleaseInfo = { 107 | prerelease: MOCK_RELEASE_INFO.prerelease, 108 | release: undefined, 109 | }; 110 | 111 | await updateIssueCommentByRelease( 112 | mockGitHub, 113 | MOCK_ISSUE, 114 | preReleaseOnly, 115 | true, 116 | ); 117 | 118 | expect(createIssueCommentMock).toHaveBeenCalledWith({ 119 | owner: 'test-owner', 120 | repo: 'test-repo', 121 | issue_number: MOCK_ISSUE.number, 122 | body: [EXPECTED_RELEASE_COMMENT.prerelease, '- release: none'].join('\n'), 123 | }); 124 | 125 | const releaseOnly: ReleaseInfo = { 126 | prerelease: undefined, 127 | release: { 128 | version: 'v1.0.0', 129 | url: 'https://github.com/test-owner/test-repo/releases/tag/v1.0.0', 130 | }, 131 | }; 132 | 133 | await updateIssueCommentByRelease(mockGitHub, MOCK_ISSUE, releaseOnly, true); 134 | 135 | expect(createIssueCommentMock).toHaveBeenCalledWith({ 136 | owner: 'test-owner', 137 | repo: 'test-repo', 138 | issue_number: MOCK_ISSUE.number, 139 | body: ['- pre-release: none', EXPECTED_RELEASE_COMMENT.release].join('\n'), 140 | }); 141 | 142 | const noRelease: ReleaseInfo = { 143 | prerelease: undefined, 144 | release: undefined, 145 | }; 146 | 147 | await updateIssueCommentByRelease(mockGitHub, MOCK_ISSUE, noRelease, true); 148 | 149 | expect(createIssueCommentMock).toHaveBeenCalledWith({ 150 | owner: 'test-owner', 151 | repo: 'test-repo', 152 | issue_number: MOCK_ISSUE.number, 153 | body: '- pre-release: none\n- release: none', 154 | }); 155 | }); 156 | 157 | it('Should not add a new comment if the same content already exists', async () => { 158 | const existingComment = [ 159 | EXPECTED_RELEASE_COMMENT.prerelease, 160 | '- release: none', 161 | ].join('\n'); 162 | 163 | getLastIssueCommentMock.mockResolvedValue(existingComment); 164 | 165 | const prereleaseOnly: ReleaseInfo = { 166 | prerelease: MOCK_RELEASE_INFO.prerelease, 167 | release: undefined, 168 | }; 169 | 170 | await updateIssueCommentByRelease( 171 | mockGitHub, 172 | MOCK_ISSUE, 173 | prereleaseOnly, 174 | true, 175 | ); 176 | 177 | expect(createIssueCommentMock).not.toHaveBeenCalled(); 178 | }); 179 | 180 | it('Adds an informational comment when no releases exist', async () => { 181 | getLastIssueCommentMock.mockResolvedValue(''); 182 | 183 | const releaseInfo: ReleaseInfo = { 184 | prerelease: undefined, 185 | release: undefined, 186 | }; 187 | 188 | await updateIssueCommentByRelease(mockGitHub, MOCK_ISSUE, releaseInfo, false); 189 | 190 | expect(createIssueCommentMock).toHaveBeenCalledWith({ 191 | owner: 'test-owner', 192 | repo: 'test-repo', 193 | issue_number: MOCK_ISSUE.number, 194 | body: [ 195 | '> This comment and the `pending` label appear because release-tracking is enabled.', 196 | '> To disable, remove `release-tracking` from the plugins list.', 197 | '\n', 198 | '- pre-release: none', 199 | '- release: none', 200 | ].join('\n'), 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /packages/batch-pr/tests/resolveFileNameWithRootDir.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveFileNameWithRootDir } from '../utils/resolveFileNameWithRootDir'; 2 | 3 | import { describe, expect, test } from 'vitest'; 4 | 5 | describe('resolveFileNameWithRootDir', () => { 6 | describe('basic functionality', () => { 7 | test('should return original fileName when rootDir is not provided', () => { 8 | // Given 9 | const fileName = 'src/components/Button.tsx'; 10 | 11 | // When 12 | const result = resolveFileNameWithRootDir(fileName); 13 | 14 | // Then 15 | expect(result).toBe('src/components/Button.tsx'); 16 | }); 17 | 18 | test('should return original fileName when rootDir is undefined', () => { 19 | // Given 20 | const fileName = 'src/components/Button.tsx'; 21 | const rootDir = undefined; 22 | 23 | // When 24 | const result = resolveFileNameWithRootDir(fileName, rootDir); 25 | 26 | // Then 27 | expect(result).toBe('src/components/Button.tsx'); 28 | }); 29 | 30 | test('should return empty string when fileName equals rootDir exactly', () => { 31 | // Given 32 | const fileName = 'src'; 33 | const rootDir = 'src'; 34 | 35 | // When 36 | const result = resolveFileNameWithRootDir(fileName, rootDir); 37 | 38 | // Then 39 | expect(result).toBe(''); 40 | }); 41 | 42 | test('should return relative path when fileName starts with rootDir', () => { 43 | // Given 44 | const fileName = 'src/components/Button.tsx'; 45 | const rootDir = 'src'; 46 | 47 | // When 48 | const result = resolveFileNameWithRootDir(fileName, rootDir); 49 | 50 | // Then 51 | expect(result).toBe('components/Button.tsx'); 52 | }); 53 | 54 | test('should return original fileName when fileName does not start with rootDir', () => { 55 | // Given 56 | const fileName = 'lib/utils/helper.ts'; 57 | const rootDir = 'src'; 58 | 59 | // When 60 | const result = resolveFileNameWithRootDir(fileName, rootDir); 61 | 62 | // Then 63 | expect(result).toBe('lib/utils/helper.ts'); 64 | }); 65 | }); 66 | 67 | describe('rootDir with trailing slash handling', () => { 68 | test('should work correctly when rootDir has trailing slash', () => { 69 | // Given 70 | const fileName = 'src/components/Button.tsx'; 71 | const rootDir = 'src/'; 72 | 73 | // When 74 | const result = resolveFileNameWithRootDir(fileName, rootDir); 75 | 76 | // Then 77 | expect(result).toBe('components/Button.tsx'); 78 | }); 79 | 80 | test('should work correctly when rootDir does not have trailing slash', () => { 81 | // Given 82 | const fileName = 'src/components/Button.tsx'; 83 | const rootDir = 'src'; 84 | 85 | // When 86 | const result = resolveFileNameWithRootDir(fileName, rootDir); 87 | 88 | // Then 89 | expect(result).toBe('components/Button.tsx'); 90 | }); 91 | }); 92 | 93 | describe('edge cases', () => { 94 | test('should handle empty fileName', () => { 95 | // Given 96 | const fileName = ''; 97 | const rootDir = 'src'; 98 | 99 | // When 100 | const result = resolveFileNameWithRootDir(fileName, rootDir); 101 | 102 | // Then 103 | expect(result).toBe(''); 104 | }); 105 | 106 | test('should handle empty rootDir', () => { 107 | // Given 108 | const fileName = 'src/components/Button.tsx'; 109 | const rootDir = ''; 110 | 111 | // When 112 | const result = resolveFileNameWithRootDir(fileName, rootDir); 113 | 114 | // Then 115 | expect(result).toBe('src/components/Button.tsx'); 116 | }); 117 | 118 | test('should handle single character fileName and rootDir', () => { 119 | // Given 120 | const fileName = 'a/b/c.ts'; 121 | const rootDir = 'a'; 122 | 123 | // When 124 | const result = resolveFileNameWithRootDir(fileName, rootDir); 125 | 126 | // Then 127 | expect(result).toBe('b/c.ts'); 128 | }); 129 | 130 | test('should handle deep nested paths', () => { 131 | // Given 132 | const fileName = 'very/deep/nested/folder/structure/file.ts'; 133 | const rootDir = 'very/deep/nested'; 134 | 135 | // When 136 | const result = resolveFileNameWithRootDir(fileName, rootDir); 137 | 138 | // Then 139 | expect(result).toBe('folder/structure/file.ts'); 140 | }); 141 | 142 | test('should handle paths with special characters', () => { 143 | // Given 144 | const fileName = 'src-main/components_new/Button-v2.tsx'; 145 | const rootDir = 'src-main'; 146 | 147 | // When 148 | const result = resolveFileNameWithRootDir(fileName, rootDir); 149 | 150 | // Then 151 | expect(result).toBe('components_new/Button-v2.tsx'); 152 | }); 153 | 154 | test('should handle similar but different rootDir paths', () => { 155 | // Given 156 | const fileName = 'src-test/components/Button.tsx'; 157 | const rootDir = 'src'; 158 | 159 | // When 160 | const result = resolveFileNameWithRootDir(fileName, rootDir); 161 | 162 | // Then 163 | expect(result).toBe('src-test/components/Button.tsx'); // Should not match 164 | }); 165 | }); 166 | 167 | describe('rootDir normalization integration', () => { 168 | test('should work with various rootDir formats', () => { 169 | // Given - Testing different rootDir formats that would be normalized 170 | const testCases = [ 171 | { 172 | fileName: 'src/components/Button.tsx', 173 | rootDir: 'src', 174 | expected: 'components/Button.tsx', 175 | }, 176 | { 177 | fileName: 'src/components/Button.tsx', 178 | rootDir: 'src/', 179 | expected: 'components/Button.tsx', 180 | }, 181 | { 182 | fileName: 'packages/core/src/index.ts', 183 | rootDir: 'packages/core', 184 | expected: 'src/index.ts', 185 | }, 186 | { 187 | fileName: 'packages/core/src/index.ts', 188 | rootDir: 'packages/core/', 189 | expected: 'src/index.ts', 190 | }, 191 | ]; 192 | 193 | testCases.forEach(({ fileName, rootDir, expected }) => { 194 | // When 195 | const result = resolveFileNameWithRootDir(fileName, rootDir); 196 | 197 | // Then 198 | expect(result).toBe(expected); 199 | }); 200 | }); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /packages/core/tests/integration/orchestration.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | COMMIT_DATA_SEPARATOR, 3 | COMMIT_SEP, 4 | } from '../../utils-infra/getCommits'; 5 | 6 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 7 | 8 | describe('Core Orchestration Integration', () => { 9 | beforeEach(() => { 10 | vi.resetModules(); 11 | vi.clearAllMocks(); 12 | 13 | process.env.ACCESS_TOKEN = 'test-token'; 14 | process.env.USER_NAME = 'bot'; 15 | process.env.EMAIL = 'bot@ex.com'; 16 | process.env.UPSTREAM_REPO = 'https://github.com/acme/upstream.git'; 17 | process.env.HEAD_REPO = 'https://github.com/acme/head.git'; 18 | process.env.HEAD_REPO_BRANCH = 'main'; 19 | process.env.TRACK_FROM = 'aaaaaaaa'; 20 | process.env.LABELS = 'sync'; 21 | process.env.PLUGINS = 'mock-plugin@1.0.0'; 22 | process.env.VERBOSE = 'true'; 23 | 24 | vi.mock('mock-plugin', () => { 25 | let resolve: (v?: unknown) => void; 26 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 27 | const done = new Promise(r => (resolve = r)); 28 | const events: unknown[] = []; 29 | const plugin = { 30 | name: 'mock-plugin', 31 | onInit(ctx: unknown) { 32 | events.push(['onInit', ctx]); 33 | }, 34 | onBeforeCompare(ctx: unknown) { 35 | events.push(['onBeforeCompare', ctx]); 36 | }, 37 | onAfterCompare(ctx: unknown) { 38 | events.push(['onAfterCompare', ctx]); 39 | }, 40 | onBeforeCreateIssue(ctx: unknown) { 41 | events.push(['onBeforeCreateIssue', ctx]); 42 | }, 43 | onAfterCreateIssue(ctx: unknown) { 44 | events.push(['onAfterCreateIssue', ctx]); 45 | }, 46 | onError(ctx: unknown) { 47 | events.push(['onError', ctx]); 48 | }, 49 | onFinally(ctx: unknown) { 50 | events.push(['onFinally', ctx]); 51 | resolve(); 52 | }, 53 | }; 54 | // Expose captured data without importing the module in tests 55 | (globalThis as any).__mockPlugin = { events, done }; 56 | return { default: plugin }; 57 | }); 58 | 59 | vi.mock('../../infra/git.ts', () => { 60 | class Git { 61 | repoUrl = 'https://github.com/acme/head'; 62 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 63 | constructor(_: any) {} 64 | clone() {} 65 | exec(_cmd: string) { 66 | const sep = COMMIT_SEP; 67 | const data = COMMIT_DATA_SEPARATOR; 68 | const c1 = `${sep}1111111${data}feat: one${data}2024-01-01T00:00:00Z\nsrc/a.ts`; 69 | const c2 = `${sep}2222222${data}fix: two${data}2024-01-02T00:00:00Z\nsrc/keep.ts\nREADME.md`; 70 | return [c1, c2].join(''); 71 | } 72 | } 73 | return { Git }; 74 | }); 75 | 76 | vi.mock('../../infra/github.ts', () => { 77 | class GitHub { 78 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 79 | constructor(readonly config: any) {} 80 | get ownerAndRepo() { 81 | return { 82 | owner: this.config.repoSpec.owner, 83 | repo: this.config.repoSpec.name, 84 | }; 85 | } 86 | get repoSpec() { 87 | return this.config.repoSpec; 88 | } 89 | get configuredLabels() { 90 | return this.config.labels; 91 | } 92 | api = { 93 | actions: { 94 | listWorkflowRunsForRepo: async () => ({ 95 | data: { 96 | workflow_runs: [ 97 | { name: 'yuki-no', created_at: '2024-01-01T00:00:00Z' }, 98 | ], 99 | }, 100 | }), 101 | }, 102 | search: { 103 | issuesAndPullRequests: async () => ({ 104 | data: { 105 | items: [ 106 | { 107 | body: 'See commit https://github.com/acme/upstream/commit/2222222', 108 | }, 109 | ], 110 | }, 111 | }), 112 | }, 113 | issues: { 114 | create: vi.fn( 115 | async ({ 116 | title, 117 | body, 118 | labels, 119 | }: { 120 | title: string; 121 | body: string; 122 | labels: string[]; 123 | }) => { 124 | if (process.env.TEST_FORCE_ISSUE_CREATE_FAIL === 'true') { 125 | throw new Error('create failed'); 126 | } 127 | return { 128 | data: { 129 | number: 123, 130 | created_at: '2024-01-03T00:00:00Z', 131 | title, 132 | body, 133 | labels, 134 | }, 135 | }; 136 | }, 137 | ), 138 | }, 139 | }; 140 | } 141 | return { GitHub }; 142 | }); 143 | }); 144 | 145 | afterEach(() => { 146 | delete process.env.ACCESS_TOKEN; 147 | delete process.env.USER_NAME; 148 | delete process.env.EMAIL; 149 | delete process.env.UPSTREAM_REPO; 150 | delete process.env.HEAD_REPO; 151 | delete process.env.HEAD_REPO_BRANCH; 152 | delete process.env.TRACK_FROM; 153 | delete process.env.LABELS; 154 | delete process.env.PLUGINS; 155 | delete process.env.VERBOSE; 156 | }); 157 | 158 | it('Happy path: creates 1 issue and triggers plugin hooks', async () => { 159 | // @ts-expect-error for testing 160 | await import('mock-plugin'); 161 | await import('../../index.ts'); 162 | const { done, events } = (globalThis as any).__mockPlugin as { 163 | done: Promise; 164 | events: unknown[]; 165 | }; 166 | await done; 167 | 168 | const calls = (events as any[]).map(e => (e as any[])[0] as string); 169 | 170 | expect(calls).toContain('onInit'); 171 | expect(calls).toContain('onBeforeCompare'); 172 | expect(calls).toContain('onAfterCompare'); 173 | expect( 174 | calls.filter((c: string) => c === 'onBeforeCreateIssue'), 175 | ).toHaveLength(1); 176 | expect( 177 | calls.filter((c: string) => c === 'onAfterCreateIssue'), 178 | ).toHaveLength(1); 179 | expect(calls).toContain('onFinally'); 180 | 181 | const beforeCreate = ( 182 | (events as any[]).find( 183 | e => (e as any[])[0] === 'onBeforeCreateIssue', 184 | ) as any[] 185 | )[1] as any; 186 | expect(beforeCreate.issueMeta.title).toBe('feat: one'); 187 | expect(beforeCreate.issueMeta.labels).toEqual(['sync']); 188 | expect(beforeCreate.issueMeta.body).toContain( 189 | 'https://github.com/acme/head/commit/1111111', 190 | ); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /packages/core/tests/utils-infra/getCommits.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | COMMIT_DATA_SEPARATOR, 3 | COMMIT_SEP, 4 | getCommits, 5 | } from '../../utils-infra/getCommits'; 6 | 7 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 8 | 9 | // Mocking Git to avoid direct execution during tests 10 | const mockGit: any = { exec: vi.fn() }; 11 | 12 | const MOCK_CONFIG = { 13 | trackFrom: 'start-commit-hash', 14 | headRepoSpec: { 15 | owner: 'test-owner', 16 | name: 'test-repo', 17 | branch: 'main', 18 | }, 19 | include: [], 20 | exclude: [], 21 | }; 22 | 23 | beforeEach(() => { 24 | vi.clearAllMocks(); 25 | }); 26 | 27 | describe('getCommits - git log command creation', () => { 28 | it('Should execute the correct git log command', () => { 29 | mockGit.exec.mockReturnValue( 30 | [ 31 | `${COMMIT_SEP}hash1${COMMIT_DATA_SEPARATOR}title1${COMMIT_DATA_SEPARATOR}2023-01-01T10:00:00Z`, 32 | 'file1.ts', 33 | 'file2.ts', 34 | ].join('\n'), 35 | ); 36 | 37 | getCommits(MOCK_CONFIG, mockGit); 38 | 39 | expect(mockGit.exec).toHaveBeenCalledWith( 40 | [ 41 | 'log origin/main', 42 | 'start-commit-hash..', 43 | '--name-only', 44 | '--format=":COMMIT_START_SEP:%H:COMMIT_DATA_SEP:%s:COMMIT_DATA_SEP:%aI"', 45 | '--no-merges', 46 | ].join(' '), 47 | ); 48 | }); 49 | 50 | it('Should omit the trackFrom argument when not provided', () => { 51 | mockGit.exec.mockReturnValue( 52 | [ 53 | `${COMMIT_SEP}hash1${COMMIT_DATA_SEPARATOR}title1${COMMIT_DATA_SEPARATOR}2023-01-01T10:00:00Z`, 54 | 'file1.ts', 55 | 'file2.ts', 56 | ].join('\n'), 57 | ); 58 | 59 | getCommits({ ...MOCK_CONFIG, trackFrom: '' }, mockGit); 60 | 61 | expect(mockGit.exec).toHaveBeenCalledWith( 62 | expect.stringMatching(/^log origin\/main +--name-only/), 63 | ); 64 | expect(mockGit.exec).toHaveBeenCalledWith( 65 | expect.not.stringMatching(/\.\.$/), 66 | ); 67 | }); 68 | 69 | it('Should filter commits after the given date when latestSuccessfulRun is provided', () => { 70 | mockGit.exec.mockReturnValue( 71 | [ 72 | `${COMMIT_SEP}hash1${COMMIT_DATA_SEPARATOR}title1${COMMIT_DATA_SEPARATOR}2023-01-01T10:00:00Z`, 73 | 'file1.ts', 74 | 'file2.ts', 75 | ].join('\n'), 76 | ); 77 | 78 | const latestRun = '2023-01-01'; 79 | 80 | getCommits(MOCK_CONFIG, mockGit, latestRun); 81 | 82 | expect(mockGit.exec).toHaveBeenCalledWith( 83 | expect.stringMatching(`--since="${latestRun}"`), 84 | ); 85 | }); 86 | }); 87 | 88 | describe('getCommits - result verification', () => { 89 | it('return an empty array when result is empty', () => { 90 | mockGit.exec.mockReturnValue(''); 91 | 92 | const result = getCommits(MOCK_CONFIG, mockGit); 93 | 94 | expect(result).toEqual([]); 95 | }); 96 | 97 | it('git log result should not contain COMMIT_SEP to throw an error', () => { 98 | mockGit.exec.mockReturnValue('invalid result'); 99 | 100 | expect(() => getCommits(MOCK_CONFIG, mockGit)).toThrow( 101 | `Invalid trackFrom commit hash: ${MOCK_CONFIG.trackFrom}`, 102 | ); 103 | }); 104 | 105 | it('git log result should be parsed correctly', () => { 106 | const commitData = [ 107 | `${COMMIT_SEP}hash1${COMMIT_DATA_SEPARATOR}title1${COMMIT_DATA_SEPARATOR}2023-01-01T10:00:00Z`, 108 | 'file1.ts', 109 | 'file2.ts', 110 | `${COMMIT_SEP}hash2${COMMIT_DATA_SEPARATOR}title2${COMMIT_DATA_SEPARATOR}2023-01-02T10:00:00Z`, 111 | 'file3.ts', 112 | ].join('\n'); 113 | 114 | mockGit.exec.mockReturnValue(commitData); 115 | 116 | const result = getCommits(MOCK_CONFIG, mockGit); 117 | 118 | expect(result).toHaveLength(2); 119 | expect(result[0]).toEqual({ 120 | hash: 'hash1', 121 | title: 'title1', 122 | isoDate: expect.any(String), 123 | fileNames: ['file1.ts', 'file2.ts'], 124 | }); 125 | expect(result[1]).toEqual({ 126 | hash: 'hash2', 127 | title: 'title2', 128 | isoDate: expect.any(String), 129 | fileNames: ['file3.ts'], 130 | }); 131 | }); 132 | }); 133 | 134 | describe('getCommits - commit filtering', () => { 135 | it('Should filter commits if any of hash, title, or date is missing', () => { 136 | const commitData = [ 137 | `${COMMIT_SEP}${COMMIT_DATA_SEPARATOR}title1${COMMIT_DATA_SEPARATOR}2023-01-01T10:00:00Z`, 138 | 'src/file1.ts', 139 | `${COMMIT_SEP}hash1${COMMIT_DATA_SEPARATOR}${COMMIT_DATA_SEPARATOR}2023-01-01T10:00:00Z`, 140 | 'src/file1.ts', 141 | `${COMMIT_SEP}hash1${COMMIT_DATA_SEPARATOR}title1${COMMIT_DATA_SEPARATOR}`, 142 | 'file1.ts', 143 | ].join('\n'); 144 | 145 | mockGit.exec.mockReturnValue(commitData); 146 | 147 | const result = getCommits(MOCK_CONFIG, mockGit); 148 | 149 | expect(result).toHaveLength(0); 150 | }); 151 | 152 | it('Should filter commits based on include pattern', () => { 153 | const commitData = [ 154 | `${COMMIT_SEP}hash1${COMMIT_DATA_SEPARATOR}title1${COMMIT_DATA_SEPARATOR}2023-01-01T10:00:00Z`, 155 | 'src/file1.ts', 156 | `${COMMIT_SEP}hash2${COMMIT_DATA_SEPARATOR}title2${COMMIT_DATA_SEPARATOR}2023-01-02T10:00:00Z`, 157 | 'test/file2.ts', 158 | ].join('\n'); 159 | 160 | mockGit.exec.mockReturnValue(commitData); 161 | 162 | const result = getCommits({ ...MOCK_CONFIG, include: ['src/**'] }, mockGit); 163 | 164 | expect(result).toHaveLength(1); 165 | expect(result[0].hash).toBe('hash1'); 166 | }); 167 | 168 | it('Should filter commits based on exclude pattern', () => { 169 | const commitData = [ 170 | `${COMMIT_SEP}hash1${COMMIT_DATA_SEPARATOR}title1${COMMIT_DATA_SEPARATOR}2023-01-01T10:00:00Z`, 171 | 'src/file1.ts', 172 | `${COMMIT_SEP}hash2${COMMIT_DATA_SEPARATOR}title2${COMMIT_DATA_SEPARATOR}2023-01-02T10:00:00Z`, 173 | 'test/file2.ts', 174 | ].join('\n'); 175 | 176 | mockGit.exec.mockReturnValue(commitData); 177 | 178 | const result = getCommits( 179 | { ...MOCK_CONFIG, exclude: ['test/**'] }, 180 | mockGit, 181 | ); 182 | 183 | expect(result).toHaveLength(1); 184 | expect(result[0].hash).toBe('hash1'); 185 | }); 186 | }); 187 | 188 | describe('getCommits - commit sorting', () => { 189 | it('Should sort commits by date', () => { 190 | const commitData = [ 191 | `${COMMIT_SEP}hash2${COMMIT_DATA_SEPARATOR}title2${COMMIT_DATA_SEPARATOR}2023-01-02T10:00:00Z`, 192 | 'file1.ts', 193 | `${COMMIT_SEP}hash1${COMMIT_DATA_SEPARATOR}title1${COMMIT_DATA_SEPARATOR}2023-01-01T10:00:00Z`, 194 | 'file2.ts', 195 | ].join('\n'); 196 | 197 | mockGit.exec.mockReturnValue(commitData); 198 | 199 | const result = getCommits(MOCK_CONFIG, mockGit); 200 | 201 | expect(result[0].hash).toBe('hash1'); 202 | expect(result[1].hash).toBe('hash2'); 203 | }); 204 | }); 205 | -------------------------------------------------------------------------------- /packages/core/tests/infra/git.test.ts: -------------------------------------------------------------------------------- 1 | import { Git, GitCommandError } from '../../infra/git'; 2 | 3 | import fs from 'node:fs'; 4 | import shell from 'shelljs'; 5 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 6 | 7 | // Mocking to bypass logic that would change files or make network requests 8 | vi.mock('shelljs', () => ({ 9 | default: { 10 | cd: vi.fn(), 11 | exec: vi.fn(() => ({ stdout: 'mocked-output', code: 0, stderr: '' })), 12 | }, 13 | })); 14 | 15 | vi.mock('node:fs', () => ({ 16 | default: { 17 | existsSync: vi.fn(), 18 | rmSync: vi.fn(), 19 | mkdtempSync: vi.fn().mockReturnValue('/tmp/test-dir'), 20 | }, 21 | })); 22 | 23 | const MOCK_CONFIG = { 24 | accessToken: 'test-token', 25 | userName: 'test-user', 26 | email: 'user@email.com', 27 | repoSpec: { 28 | owner: 'test', 29 | name: 'repo', 30 | branch: 'main', 31 | }, 32 | }; 33 | 34 | beforeEach(() => { 35 | vi.clearAllMocks(); 36 | }); 37 | 38 | describe('Git constructor', () => { 39 | it('Should create Git instance without clone', () => { 40 | const git = new Git(MOCK_CONFIG); 41 | 42 | expect(git).toBeInstanceOf(Git); 43 | expect(shell.exec).not.toHaveBeenCalled(); 44 | }); 45 | 46 | it('Should create Git instance with clone when withClone is true', () => { 47 | vi.mocked(fs.existsSync).mockReturnValue(false); 48 | 49 | const git = new Git({ ...MOCK_CONFIG, withClone: true }); 50 | 51 | expect(git).toBeInstanceOf(Git); 52 | expect(shell.exec).toHaveBeenCalled(); 53 | }); 54 | }); 55 | 56 | describe('dirName getter', () => { 57 | it('Should return cached dirName on subsequent calls', () => { 58 | const git = new Git(MOCK_CONFIG); 59 | 60 | const firstCall = git.dirName; 61 | const secondCall = git.dirName; 62 | 63 | expect(firstCall).toBe('/tmp/test-dir'); 64 | expect(secondCall).toBe('/tmp/test-dir'); 65 | expect(fs.mkdtempSync).toHaveBeenCalledTimes(1); 66 | }); 67 | }); 68 | 69 | describe('repoUrl getter', () => { 70 | it('Should return GitHub repository URL', () => { 71 | const git = new Git(MOCK_CONFIG); 72 | 73 | const result = git.repoUrl; 74 | 75 | expect(result).toBe('https://github.com/test/repo'); 76 | }); 77 | }); 78 | 79 | describe('exec method', () => { 80 | it('Should throw GitCommandError when git command fails', () => { 81 | vi.mocked(shell.exec).mockReturnValue({ 82 | stdout: 'error output', 83 | code: 1, 84 | stderr: 'git command failed', 85 | } as any); 86 | 87 | const git = new Git(MOCK_CONFIG); 88 | 89 | expect(() => git.exec('invalid-command')).toThrow(GitCommandError); 90 | expect(() => git.exec('invalid-command')).toThrow( 91 | 'Git command failed: git invalid-command', 92 | ); 93 | }); 94 | }); 95 | 96 | describe('clone method', () => { 97 | beforeEach(() => { 98 | // Reset mock to ensure consistent behavior 99 | vi.mocked(shell.exec).mockReturnValue({ 100 | stdout: 'mocked-output', 101 | code: 0, 102 | stderr: '', 103 | } as any); 104 | }); 105 | 106 | it('Should remove and clone into an existing directory', () => { 107 | vi.mocked(fs.existsSync).mockReturnValue(true); 108 | 109 | const git = new Git(MOCK_CONFIG); 110 | git.clone(); 111 | 112 | expect(shell.cd).toHaveBeenCalledWith(git.dirName); 113 | expect(fs.existsSync).toHaveBeenCalledWith('test-dir'); 114 | expect(fs.rmSync).toHaveBeenCalledWith('test-dir', { 115 | force: true, 116 | recursive: true, 117 | }); 118 | expect(shell.exec).toHaveBeenCalledWith( 119 | expect.stringMatching( 120 | /^git clone https:\/\/test-user:test-token@github\.com\/test\/repo /, 121 | ), 122 | ); 123 | expect(shell.exec).toHaveBeenCalledWith('git config user.name "test-user"'); 124 | expect(shell.exec).toHaveBeenCalledWith( 125 | 'git config user.email "user@email.com"', 126 | ); 127 | }); 128 | 129 | it('Should clone into a directory that does not exist', () => { 130 | vi.mocked(fs.existsSync).mockReturnValue(false); 131 | 132 | const git = new Git(MOCK_CONFIG); 133 | git.clone(); 134 | 135 | expect(shell.cd).toHaveBeenCalledWith(git.dirName); 136 | expect(fs.existsSync).toHaveBeenCalledWith('test-dir'); 137 | expect(fs.rmSync).not.toHaveBeenCalled(); 138 | expect(shell.exec).toHaveBeenCalledWith( 139 | expect.stringMatching( 140 | /^git clone https:\/\/test-user:test-token@github\.com\/test\/repo /, 141 | ), 142 | ); 143 | expect(shell.exec).toHaveBeenCalledWith('git config user.name "test-user"'); 144 | expect(shell.exec).toHaveBeenCalledWith( 145 | 'git config user.email "user@email.com"', 146 | ); 147 | }); 148 | 149 | it('Should complete git clone and configuration successfully', () => { 150 | vi.mocked(fs.existsSync).mockReturnValue(false); 151 | 152 | const git = new Git(MOCK_CONFIG); 153 | 154 | expect(() => git.clone()).not.toThrow(); 155 | 156 | expect(shell.exec).toHaveBeenCalledWith( 157 | expect.stringMatching( 158 | /^git clone https:\/\/test-user:test-token@github\.com\/test\/repo /, 159 | ), 160 | ); 161 | expect(shell.exec).toHaveBeenCalledWith('git config user.name "test-user"'); 162 | expect(shell.exec).toHaveBeenCalledWith( 163 | 'git config user.email "user@email.com"', 164 | ); 165 | }); 166 | 167 | it('Should throw error when git config command fails after clone', () => { 168 | // Override the beforeEach mock for this specific test 169 | // git clone succeeds but git config fails 170 | vi.mocked(shell.exec).mockImplementation((command: string) => { 171 | if (command.includes('git clone')) { 172 | return { 173 | stdout: 'Cloning into...', 174 | code: 0, 175 | stderr: '', 176 | } as any; 177 | } 178 | if (command.includes('git config')) { 179 | return { 180 | stdout: '', 181 | code: 1, 182 | stderr: 'config failed', 183 | } as any; 184 | } 185 | return { 186 | stdout: 'mocked-output', 187 | code: 0, 188 | stderr: '', 189 | } as any; 190 | }); 191 | 192 | const git = new Git(MOCK_CONFIG); 193 | 194 | expect(() => git.clone()).toThrow(GitCommandError); 195 | }); 196 | }); 197 | 198 | describe('GitCommandError', () => { 199 | it('Should create error with correct properties', () => { 200 | const command = 'status'; 201 | const exitCode = 1; 202 | const stderr = 'error message'; 203 | const stdout = 'output'; 204 | 205 | const error = new GitCommandError(command, exitCode, stderr, stdout); 206 | 207 | expect(error).toBeInstanceOf(Error); 208 | expect(error.name).toBe('GitCommandError'); 209 | expect(error.command).toBe(command); 210 | expect(error.exitCode).toBe(exitCode); 211 | expect(error.stderr).toBe(stderr); 212 | expect(error.stdout).toBe(stdout); 213 | expect(error.message).toBe( 214 | 'Git command failed: git status\nExit code: 1\nError: error message', 215 | ); 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /packages/release-tracking/tests/integration/plugin-orchestration.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 | 3 | describe('release-tracking plugin integration', () => { 4 | beforeEach(() => { 5 | vi.resetModules(); 6 | vi.clearAllMocks(); 7 | 8 | process.env.YUKI_NO_RELEASE_TRACKING_LABELS = 'pending'; 9 | 10 | // Mock Git from plugin-sdk 11 | vi.mock('@yuki-no/plugin-sdk/infra/git', () => { 12 | class Git { 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | constructor(_: any) {} 15 | repoUrl = 'https://github.com/acme/head'; 16 | exec(cmd: string): string { 17 | if (cmd.startsWith('tag --contains')) { 18 | if (cmd.includes('1111111')) { 19 | return '1.0.0-beta.1\n1.0.0'; 20 | } 21 | if (cmd.includes('AAAAAAA')) { 22 | return ''; 23 | } 24 | if (cmd.includes('BBBBBBB')) { 25 | return '2.0.0'; 26 | } 27 | return ''; 28 | } 29 | if (cmd === 'tag') { 30 | return '1.0.0\n2.0.0'; 31 | } 32 | return ''; 33 | } 34 | } 35 | return { Git }; 36 | }); 37 | 38 | // Mock GitHub from plugin-sdk 39 | vi.mock('@yuki-no/plugin-sdk/infra/github', () => { 40 | const setLabels = vi.fn(async () => ({})); 41 | const createComment = vi.fn(async () => ({ data: { id: 1 } })); 42 | const listComments = vi.fn(async () => ({ data: [] })); 43 | 44 | class GitHub { 45 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 46 | constructor(readonly config: any) {} 47 | get ownerAndRepo() { 48 | return { owner: 'acme', repo: 'upstream' }; 49 | } 50 | get configuredLabels() { 51 | return ['sync']; 52 | } 53 | api = { 54 | issues: { setLabels, createComment, listComments }, 55 | }; 56 | } 57 | 58 | // expose spies for assertions 59 | // @ts-expect-error test helper 60 | globalThis.__mockRT_GH = { setLabels, createComment, listComments }; 61 | return { GitHub }; 62 | }); 63 | 64 | // Mock getOpenedIssues from plugin-sdk 65 | vi.mock('@yuki-no/plugin-sdk/utils-infra/getOpenedIssues', () => ({ 66 | getOpenedIssues: vi.fn(async () => [ 67 | { 68 | number: 10, 69 | body: 'A', 70 | labels: ['sync'], 71 | hash: 'AAAAAAA', 72 | isoDate: '2024-01-01T00:00:00Z', 73 | }, 74 | { 75 | number: 20, 76 | body: 'B', 77 | labels: ['sync', 'pending'], 78 | hash: 'BBBBBBB', 79 | isoDate: '2024-01-02T00:00:00Z', 80 | }, 81 | ]), 82 | })); 83 | }); 84 | 85 | afterEach(() => { 86 | delete process.env.YUKI_NO_RELEASE_TRACKING_LABELS; 87 | }); 88 | 89 | it('onAfterCreateIssue: updates labels and comment for the created issue', async () => { 90 | const plugin = (await import('../../index')).default; 91 | 92 | const config = { 93 | labels: ['sync'], 94 | headRepoSpec: { owner: 'acme', name: 'head', branch: 'main' }, 95 | upstreamRepoSpec: { owner: 'acme', name: 'upstream', branch: 'main' }, 96 | } as any; 97 | 98 | const issue = { 99 | number: 1, 100 | body: 'New updates', 101 | labels: ['sync', 'pending'], 102 | hash: '1111111', 103 | isoDate: '2024-01-01T00:00:00Z', 104 | }; 105 | 106 | await plugin.onAfterCreateIssue?.({ config, issue } as any); 107 | 108 | // @ts-expect-error test helper 109 | const { setLabels, createComment } = globalThis.__mockRT_GH as { 110 | setLabels: ReturnType; 111 | createComment: ReturnType; 112 | }; 113 | 114 | expect(setLabels).toHaveBeenCalledWith({ 115 | owner: 'acme', 116 | repo: 'upstream', 117 | issue_number: 1, 118 | labels: ['sync'], 119 | }); 120 | 121 | const commentArg = createComment.mock.calls[0][0]; 122 | expect(commentArg).toMatchObject({ 123 | owner: 'acme', 124 | repo: 'upstream', 125 | issue_number: 1, 126 | }); 127 | expect(commentArg.body).toContain('- pre-release: [1.0.0-beta.1]'); 128 | expect(commentArg.body).toContain('- release: [1.0.0]'); 129 | }); 130 | 131 | it('onFinally: processes opened issues and applies idempotent behavior', async () => { 132 | const plugin = (await import('../../index')).default; 133 | 134 | const config = { 135 | labels: ['sync'], 136 | headRepoSpec: { owner: 'acme', name: 'head', branch: 'main' }, 137 | upstreamRepoSpec: { owner: 'acme', name: 'upstream', branch: 'main' }, 138 | } as any; 139 | 140 | // First run - should update both issues (A: add pending, B: remove pending) 141 | await plugin.onFinally?.({ config } as any); 142 | 143 | // @ts-expect-error test helper 144 | const gh = globalThis.__mockRT_GH as { 145 | setLabels: ReturnType; 146 | createComment: ReturnType; 147 | listComments: ReturnType; 148 | }; 149 | 150 | // A (unreleased) should add pending (order-insensitive) 151 | const callForA = gh.setLabels.mock.calls 152 | .map((c: any[]) => c[0]) 153 | .find((arg: any) => arg.issue_number === 10); 154 | expect(callForA).toBeDefined(); 155 | expect(callForA).toMatchObject({ owner: 'acme', repo: 'upstream' }); 156 | expect(callForA.labels).toEqual( 157 | expect.arrayContaining(['sync', 'pending']), 158 | ); 159 | expect(callForA.labels).toHaveLength(2); 160 | 161 | // B (released) should remove pending 162 | const callForB = gh.setLabels.mock.calls 163 | .map((c: any[]) => c[0]) 164 | .find((arg: any) => arg.issue_number === 20); 165 | expect(callForB).toBeDefined(); 166 | expect(callForB).toMatchObject({ owner: 'acme', repo: 'upstream' }); 167 | expect(callForB.labels).toEqual(['sync']); 168 | 169 | // Ensure comments created for both 170 | const issueNums = gh.createComment.mock.calls 171 | .map((c: any[]) => c[0].issue_number) 172 | .sort(); 173 | expect(issueNums).toEqual([10, 20]); 174 | 175 | // Second run - make listComments return identical comment to verify idempotency 176 | gh.listComments 177 | // Issue 10: previously created comment without release 178 | .mockResolvedValueOnce({ 179 | data: [{ body: '- pre-release: none\n- release: none' }], 180 | }) 181 | // Issue 20: previously created comment with release link 182 | .mockResolvedValueOnce({ 183 | data: [ 184 | { 185 | body: '- pre-release: none\n- release: [2.0.0](https://github.com/acme/head/releases/tag/2.0.0)', 186 | }, 187 | ], 188 | }); 189 | 190 | const beforeCalls = gh.createComment.mock.calls.length; 191 | await plugin.onFinally?.({ config } as any); 192 | const afterCalls = gh.createComment.mock.calls.length; 193 | expect(afterCalls).toBe(beforeCalls); 194 | }); 195 | }); 196 | --------------------------------------------------------------------------------