diff --git a/README.md b/README.md index 8bd9d1a..7ea8268 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,15 @@ jobs: # File path to track. In this example, Ryu-Cho will only track # commits that modified files under `docs` folder. Optional. + # (deprecated: use `includes` instead) path-starts-with: docs/ + # File paths to track (glob patterns). Optional. + includes: [] + + # File paths to exclude from tracking (glob patterns). Optional. + excludes: [] + # GitHub workflow name that runs Ryu-Cho. This is required since # Ryu-Cho determines the last run by looking into last workflow # run timestamp. Optional. Defaults to `ryu-cho`. diff --git a/action.yml b/action.yml index 29369d9..a018fa8 100644 --- a/action.yml +++ b/action.yml @@ -29,7 +29,13 @@ inputs: description: 'The git commit to track from. e.g. 4ed8b2f83a2f149734f3c5ecb6438309bd85a9e5' required: true path-starts-with: - description: 'File path to track. e.g `docs/`' + description: 'File path to track. e.g `docs/` (deprecated: use `includes` instead)' + required: false + includes: + description: 'File paths to track (glob patterns)' + required: false + excludes: + description: 'File paths to exclude from tracking (glob patterns)' required: false workflow-name: description: 'GitHub workflow name that executes Ryu Cho action. Defaults to `ryu-cho`' @@ -43,17 +49,6 @@ runs: run: ${{ github.action_path }}/scripts/checkout.sh shell: bash - name: Run Ryu-Cho - env: - ACCESS_TOKEN: ${{ inputs.access-token }} - USER_NAME: ${{ inputs.username }} - EMAIL: ${{ inputs.email }} - UPSTREAM_REPO: ${{ inputs.upstream-repo }} - UPSTREAM_REPO_BRANCH: ${{ inputs.upstream-repo-branch }} - HEAD_REPO: ${{ inputs.head-repo }} - HEAD_REPO_BRANCH: ${{ inputs.head-repo-branch }} - TRACK_FROM: ${{ inputs.track-from }} - PATH_STARTS_WITH: ${{ inputs.path-starts-with }} - WORKFLOW_NAME: ${{ inputs.workflow-name }} run: | cd ryu-cho yarn install diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index ad261cf..0000000 --- a/jest.config.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - preset: 'ts-jest', - rootDir: __dirname, - moduleNameMapper: { - '^src/(.*)$': '/src/$1' - }, - testMatch: ['/tests/**/*.spec.ts'], - testPathIgnorePatterns: ['/node_modules/'] -} diff --git a/package.json b/package.json index 6678892..a01848d 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,17 @@ "start": "ts-node src/index.ts", "lint": "prettier --check --write --parser typescript \"{src,test}/**/*.ts\"", "lint:fail": "prettier --check --parser typescript \"{src,test}/**/*.ts\"", - "jest": "jest", - "test": "yarn lint && yarn jest" + "vitest": "vitest", + "test": "yarn lint && vitest", + "test:ui": "yarn lint && vitest --ui" }, "dependencies": { + "@actions/core": "^1.9.0", "@octokit/rest": "^18.3.0", "@types/node": "^14.14.31", "@types/shelljs": "^0.8.8", "colors": "^1.4.0", + "micromatch": "^4.0.5", "queue": "^6.0.2", "rss-parser": "^3.12.0", "shelljs": "^0.8.4", @@ -19,9 +22,9 @@ "typescript": "^4.2.2" }, "devDependencies": { - "@types/jest": "^26.0.20", - "jest": "^26.6.3", + "@types/micromatch": "^4.0.2", + "@vitest/ui": "^0.18.1", "prettier": "^2.2.1", - "ts-jest": "^26.5.2" + "vitest": "^0.18.1" } } diff --git a/src/config.ts b/src/config.ts index a3a3ce8..1775fa2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -82,8 +82,20 @@ export interface UserConfig { * path will be not tracked. * * @example 'docs/' + * @deprecated Use `includes` instead. */ pathStartsWith?: string + + /** + * File paths to track (glob patterns). If this option is set, commits + * not containing the paths will not be tracked. + */ + includes: string[] + + /** + * File paths to exclude (glob patterns). + */ + excludes: string[] } export interface Config { @@ -92,7 +104,10 @@ export interface Config { accessToken: string workflowName: string trackFrom: string + /** @deprecated Use `includes` instead. */ pathStartsWith?: string + includes: string[] + excludes: string[] remote: { upstream: Remote @@ -115,6 +130,8 @@ export function createConfig(config: UserConfig): Config { workflowName: config.workflowName ?? 'ryu-cho', trackFrom: config.trackFrom, pathStartsWith: config.pathStartsWith, + includes: config.includes, + excludes: config.excludes, remote: { upstream: { diff --git a/src/index.ts b/src/index.ts index a9df293..93bf828 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,25 +1,23 @@ import { assert } from './utils' import { createConfig } from './config' import { RyuCho } from './ryu-cho' +import core from '@actions/core' -assert(!!process.env.ACCESS_TOKEN, '`accessToken` is required.') -assert(!!process.env.USER_NAME, '`userName` is required.') -assert(!!process.env.EMAIL, '`email` is required.') -assert(!!process.env.UPSTREAM_REPO, '`upstreamRepo` is required.') -assert(!!process.env.HEAD_REPO, '`headRepo` is required.') -assert(!!process.env.TRACK_FROM, '`trackFrom` is required.') +assert(typeof core !== 'undefined', `core is undefined, which probably means you're not running in a GitHub Action`) const config = createConfig({ - accessToken: process.env.ACCESS_TOKEN!, - userName: process.env.USER_NAME!, - email: process.env.EMAIL!, - upstreamRepo: process.env.UPSTREAM_REPO!, - upstreamRepoBranch: process.env.UPSTREAM_REPO_BRANCH, - headRepo: process.env.HEAD_REPO!, - headRepoBranch: process.env.HEAD_REPO_BRANCH, - workflowName: process.env.WORKFLOW_NAME, - trackFrom: process.env.TRACK_FROM!, - pathStartsWith: process.env.PATH_STARTS_WITH + accessToken: core.getInput('access-token', { required: true }), + userName: core.getInput('username', { required: true }), + email: core.getInput('email', { required: true }), + upstreamRepo: core.getInput('upstream-repo', { required: true }), + upstreamRepoBranch: core.getInput('upstream-repo-branch', { required: true }), + headRepo: core.getInput('head-repo', { required: true }), + headRepoBranch: core.getInput('head-repo-branch'), + workflowName: core.getInput('workflow-name'), + trackFrom: core.getInput('track-from', { required: true }), + pathStartsWith: core.getInput('path-starts-with'), + includes: core.getMultilineInput('includes'), + excludes: core.getMultilineInput('excludes'), }) const ryuCho = new RyuCho(config) diff --git a/src/ryu-cho.ts b/src/ryu-cho.ts index 28e9b0c..feab4c1 100644 --- a/src/ryu-cho.ts +++ b/src/ryu-cho.ts @@ -3,8 +3,9 @@ import { Config, Remote } from './config' import { Rss } from './rss' import { GitHub } from './github' import { Repository } from './repository' +import micromatch from 'micromatch' -interface Feed { +export interface Feed { link: string title: string contentSnippet: string @@ -23,7 +24,6 @@ export class RyuCho { this.config = config this.upstream = config.remote.upstream this.head = config.remote.head - this.rss = new Rss() this.github = new GitHub(config.accessToken) @@ -98,15 +98,43 @@ export class RyuCho { } protected async containsValidFile(feed: Feed, hash: string) { - if (!this.config.pathStartsWith) { + if (!this.config.pathStartsWith && !this.config.includes && !this.config.excludes) { return true } const res = await this.github.getCommit(this.head, hash) - return res.data.files!.some((file) => { - return file.filename!.startsWith(this.config.pathStartsWith!) - }) + let hasValidFile = false + + if (this.config.pathStartsWith) { + log('W', '`path-starts-with` is deprecated. Use `includes` instead.') + + hasValidFile = res.data.files!.some((file) => { + return file.filename!.startsWith(this.config.pathStartsWith!) + }) + } + + if (this.config.includes?.length) { + const isFileIncluded = (filename: string) => { + return micromatch.isMatch(filename, this.config.includes) + } + + hasValidFile = res.data.files!.some((file) => { + return isFileIncluded(file.filename!) + }) + } + + if (this.config.excludes?.length) { + const isFileExcluded = (filename: string) => { + return micromatch.isMatch(filename, this.config.excludes) + } + + hasValidFile = res.data.files!.some((file) => { + return !isFileExcluded(file.filename!) + }) + } + + return hasValidFile } protected async createIssueIfNot(feed: Feed, hash: string) { diff --git a/tests/ryo-cho.spec.ts b/tests/ryo-cho.spec.ts new file mode 100644 index 0000000..b97bcfe --- /dev/null +++ b/tests/ryo-cho.spec.ts @@ -0,0 +1,136 @@ +import { RyuCho, type Feed } from '../src/ryu-cho' +import type { Config } from '../src/config' +import * as GitHub from '../src/github' +import { describe, it, expect, vi } from 'vitest' + +vi.mock('../src/github') + +const DEFAULT_CONFIG: Config = { + userName: '', + email: '', + accessToken: '', + workflowName: '', + trackFrom: '', + pathStartsWith: '', + includes: [], + excludes: [], + + remote: { + upstream: { + url: '', + owner: '', + name: '', + branch: '', + }, + head: { + url: '', + owner: '', + name: '', + branch: '', + } + } +} + +const DEFAULT_FEED: Feed = { + isoDate: '', + link: '', + title: '', + contentSnippet: '', +} + +type Mutable = { + -readonly [P in keyof T]: T[P]; +} + +type MutableGitHub = Mutable + +function makeRyuCho(config: Partial, filenames: string[]) { + class TestRyuCho extends RyuCho { + public containsValidFile(feed: Feed, hash: string): Promise { + return super.containsValidFile(feed, hash) + } + } + (GitHub as MutableGitHub).GitHub = vi.fn(() => ({ + getCommit() { + return Promise.resolve({ + data: { + files: filenames.map(n => ({ filename: n })) + } + }) + } + })) as unknown as typeof GitHub.GitHub + + const ryuCho = new TestRyuCho({ + ...DEFAULT_CONFIG, + ...config, + }) + + return ryuCho +} + +describe('RyuCho', () => { + it('containsValidFile matches single config.includes[]', async () => { + const includes = ['/docs/**/*.md'] + const filenames = [ + '/docs/guide/index.md', + '/docs/team.md', + '/README.md', + ] + const ryuCho = makeRyuCho({ includes }, filenames) + + const hasValidFile = ryuCho.containsValidFile(DEFAULT_FEED, 'hash') + await expect(hasValidFile).resolves.toBe(true) + }) + + it('containsValidFile does not match any config.includes[]', async () => { + const includes = ['/docs/**/*.md'] + const filenames = [ + '/docs/guide/index.txt', + '/docs/team.txt', + '/README.md', + ] + const ryuCho = makeRyuCho({ includes }, filenames) + + const hasValidFile = ryuCho.containsValidFile(DEFAULT_FEED, 'hash') + await expect(hasValidFile).resolves.toBe(false) + }) + + it('containsValidFile matches multiple config.includes[]', async () => { + const includes = ['/docs/**/*.md', '/README.md'] + const filenames = [ + '/docs/guide/index.md', + '/docs/team.md', + '/README.md', + ] + const ryuCho = makeRyuCho({ includes }, filenames) + + const hasValidFile = ryuCho.containsValidFile(DEFAULT_FEED, 'hash') + await expect(hasValidFile).resolves.toBe(true) + }) + + it('containsValidFile excludes specified config.includes[]', async () => { + // match all *.md files except README.md + const includes = ['**/!(README).md'] + const filenames = [ + '/docs/guide/index.txt', + '/docs/team.txt', + '/README.md', + ] + const ryuCho = makeRyuCho({ includes }, filenames) + + const hasValidFile = ryuCho.containsValidFile(DEFAULT_FEED, 'hash') + await expect(hasValidFile).resolves.toBe(false) + }) + + it('containsValidFile excludes specified config.excludes[]', async () => { + const includes = ['**/*.md'] + const filenames = [ + '/README.md', + ] + const excludes = ['/README.md'] + const ryuCho = makeRyuCho({ includes, excludes }, filenames) + + const hasValidFile = ryuCho.containsValidFile(DEFAULT_FEED, 'hash') + await expect(hasValidFile).resolves.toBe(false) + }) +}) \ No newline at end of file diff --git a/tests/utils.spec.ts b/tests/utils.spec.ts index 484da8a..3725240 100644 --- a/tests/utils.spec.ts +++ b/tests/utils.spec.ts @@ -1,4 +1,5 @@ import * as Utils from 'src/utils' +import { describe, it, expect } from 'vitest' describe('utils', () => { const https = 'https://github.com/vuejs/vuejs.org' diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..74dc67e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + reporters: 'verbose', + }, +}) \ No newline at end of file