From 77c04444e3382376ee8473e5059093b1e256b1a8 Mon Sep 17 00:00:00 2001 From: KOBA789 Date: Thu, 28 Mar 2024 12:25:59 +0900 Subject: [PATCH] initial commit --- .dockerignore | 3 + .eslintignore | 2 + .eslintrc.yml | 18 + .github/workflows/actionlint.yaml | 19 + .github/workflows/lint.yaml | 36 + .gitignore | 7 + .prettierignore | 6 + .prettierrc | 1 + Dockerfile | 11 + README.md | 77 + package.json | 53 + renovate.json | 10 + src/config.test.ts | 289 ++ src/config.ts | 468 +++ src/configFormat/configFormat.ts | 47 + src/configFormat/json.js | 119 + src/configFormat/json.ne | 104 + src/configFormat/json.test.ts | 12 + src/configFormat/jsonFormat.ts | 230 ++ src/configFormat/yamlFormat.ts | 110 + src/configSchema.ts | 456 +++ src/emitter.ts | 9 + src/github.mock.ts | 9 + src/github.ts | 181 + src/index.ts | 215 ++ src/lint.ts | 184 + src/rdjson/DiagnosticResult.jsonschema.d.ts | 163 + src/types.ts | 224 ++ src/util.ts | 127 + test/readme_rule.json | 41 + test/readme_rule.yaml | 69 + test/test_event_on.yaml | 17 + test/test_multiple_rules.yaml | 16 + tsconfig.json | 13 + yarn.lock | 3516 +++++++++++++++++++ 35 files changed, 6862 insertions(+) create mode 100644 .dockerignore create mode 100644 .eslintignore create mode 100644 .eslintrc.yml create mode 100644 .github/workflows/actionlint.yaml create mode 100644 .github/workflows/lint.yaml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 package.json create mode 100644 renovate.json create mode 100644 src/config.test.ts create mode 100644 src/config.ts create mode 100644 src/configFormat/configFormat.ts create mode 100644 src/configFormat/json.js create mode 100644 src/configFormat/json.ne create mode 100644 src/configFormat/json.test.ts create mode 100644 src/configFormat/jsonFormat.ts create mode 100644 src/configFormat/yamlFormat.ts create mode 100644 src/configSchema.ts create mode 100644 src/emitter.ts create mode 100644 src/github.mock.ts create mode 100644 src/github.ts create mode 100644 src/index.ts create mode 100644 src/lint.ts create mode 100644 src/rdjson/DiagnosticResult.jsonschema.d.ts create mode 100644 src/types.ts create mode 100644 src/util.ts create mode 100644 test/readme_rule.json create mode 100644 test/readme_rule.yaml create mode 100644 test/test_event_on.yaml create mode 100644 test/test_multiple_rules.yaml create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9205f49 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +/node_modules/ +/dist/ +/Dockerfile diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..2d51a42 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +/dist/ +/crates/*/pkg diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000..206bf0c --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,18 @@ +env: + es2021: true + browser: false + node: true +extends: + - eslint-config-love + - eslint:recommended + - plugin:@typescript-eslint/eslint-recommended + - prettier +overrides: [] +parserOptions: + project: ["./tsconfig.json"] + ecmaVersion: latest + sourceType: module +rules: + "@typescript-eslint/consistent-type-definitions": off + "@typescript-eslint/explicit-function-return-type": off + "@typescript-eslint/no-non-null-assertion": off diff --git a/.github/workflows/actionlint.yaml b/.github/workflows/actionlint.yaml new file mode 100644 index 0000000..cc1b4a8 --- /dev/null +++ b/.github/workflows/actionlint.yaml @@ -0,0 +1,19 @@ +name: reviewdog / actionlint + +on: + pull_request: + paths: + - ".github/workflows/**" + +jobs: + actionlint: + name: actionlint with reviewdog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: actionlint + uses: reviewdog/action-actionlint@v1.39.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + reporter: github-pr-review diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..a16c736 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,36 @@ +name: lint and test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: Install Dependencies + run: yarn install --frozen-lockfile + + - name: Lint ESLint + run: yarn lint:eslint + + - name: Lint Prettier + run: yarn lint:prettier + + - name: TypeCheck + run: yarn typecheck + + - name: Test + run: yarn test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..625b286 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/node_modules/ +.DS_Store +/dist/ +/dist-ssr/ +*.local + +.env diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..06f423f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +/dist/ +/node_modules/ +/deploy/ +/crates/*/pkg +/crates/*/target +src/configFormat/json.js diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9fe393e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-bullseye-slim + +WORKDIR /app + +COPY package.json yarn.lock ./ +RUN yarn install + +COPY ./ ./ +RUN yarn build + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..88977e5 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# autoproject + +自動で Issue を Project に登録する + +## config + +サンプル:[test/readme_rule.yaml](/test/readme_rule.yaml) + +スキーマは,typescript 的に次と同じ(型名は実際のものと異なる) + +```ts +type RulesYaml = (Partial & { project: Project })[]; + +type Project = + | number + | { + not: number | number[]; + } + | Array< + | number + | { + not: number | number[]; + } + > + | { + only: number | number[]; + } + | { + reject: {}; + }; + +type Payload = { + repo: Partial; + issue: Partial; + pr: Partial; + on: Partial; +}; + +type Repo = { + name: string | string[]; + full_name: string | string[]; + description: string | string[]; + fork: boolean; + private: boolean; + topics: string | string[]; +}; + +type Issue = { + assignees: string | string[]; + labels: string | string[]; +}; + +type PullRequest = { + reviewers: string | string[]; + assignees: string | string[]; + labels: string | string[]; + head: { + label: string | string[]; + ref: string | string[]; + }; +}; + +type EventTarget = { + issue: + | "any" + | "opened" + | "assigned" + | "labeled" + | ("opened" | "assigned" | "labeled")[]; + pr: + | "any" + | "opened" + | "assigned" + | "labeled" + | ("opened" | "assigned" | "labeled")[]; +}; +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..266033c --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "autoproject", + "version": "0.1.0", + "license": "MIT", + "scripts": { + "autopjlint": "node dist/lint.js", + "start": "node dist/index.js", + "compileNearley": "echo '/* eslint-disable */' > src/configFormat/json.js && nearleyc src/configFormat/json.ne >> src/configFormat/json.js", + "build": "tsc", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "lint:prettier": "prettier . --check", + "lint:eslint": "eslint . --format stylish", + "lint": "run-p lint:*", + "fix:prettier": "yarn lint:prettier --write", + "fix:eslint": "yarn lint:eslint --fix", + "fix": "run-s fix:prettier fix:eslint" + }, + "dependencies": { + "@octokit/app": "14.0.2", + "@octokit/graphql-schema": "15.3.0", + "@prantlf/jsonlint": "14.0.3", + "dotenv": "16.4.5", + "fuse.js": "6.6.2", + "log4js": "6.9.1", + "mitt": "3.0.1", + "nearley": "2.20.1", + "yaml": "2.3.4", + "yargs": "17.7.2", + "zod": "3.22.4" + }, + "devDependencies": { + "@octokit/types": "12.6.0", + "@tsconfig/node20": "20.1.4", + "@types/nearley": "2.11.5", + "@types/node": "20.11.30", + "@types/yargs": "17.0.32", + "@typescript-eslint/eslint-plugin": "6.4.0", + "@typescript-eslint/parser": "6.4.0", + "eslint": "8.57.0", + "eslint-config-love": "43.1.0", + "eslint-config-prettier": "8.10.0", + "eslint-plugin-import": "2.29.1", + "eslint-plugin-n": "15.7.0", + "eslint-plugin-prettier": "4.2.1", + "eslint-plugin-promise": "6.1.1", + "npm-run-all": "4.1.5", + "prettier": "2.8.8", + "ts-node": "10.9.2", + "typescript": "5.3.3", + "vitest": "1.4.0" + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..03236eb --- /dev/null +++ b/renovate.json @@ -0,0 +1,10 @@ +{ + "extends": ["config:base"], + "packageRules": [ + { + "updateTypes": ["minor", "patch"], + "automerge": true + } + ], + "postUpdateOptions": ["yarnDedupeHighest"] +} diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..a3017e8 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,289 @@ +import type { + Repo, + Issue, + User, + Label, + PullRequest, + TargetProj, +} from "./types"; +import { TargetProjKind, WebhookEventKind } from "./types"; +import { type OctokitResponse } from "@octokit/types"; +import { processRules, getMatchedProj } from "./config"; +import * as fs from "fs"; +import { expect, it, vi } from "vitest"; +import { type LoggerCat } from "./util"; +import { type GetTeamMemberProp } from "./github"; +import { OctokitRestMock } from "./github.mock"; +import { getFormat } from "./configFormat/configFormat"; + +const loggerCat = "test" as LoggerCat; + +const processYamlRules = (yaml: string) => + processRules(getFormat("yaml"), loggerCat, yaml); + +it("fails to parse empty rule without throw", () => { + const yaml = ""; + expect(processYamlRules(yaml).content).toEqual(null); +}); + +it("can parse readme rules", () => { + const yaml = fs.readFileSync("./test/readme_rule.yaml", "utf-8"); + const rules = processYamlRules(yaml).content!; + expect(rules.length).toBe(6); + expect(rules[1].targetProj).toSatisfy((t: TargetProj) => { + if (t.kind === TargetProjKind.Number) { + if ( + t.projectNumber.flatMap((n) => n.value).toString() === [2, 3].toString() + ) { + return true; + } + } + return false; + }); +}); + +it("can match with readme rules", async () => { + const yaml = fs.readFileSync("./test/readme_rule.yaml", "utf-8"); + const repository: Repo = { + name: "example-repo1", + full_name: "the_owner/example-repo1", + private: false, + description: null, + fork: false, + topics: ["example-topic"], + }; + const user: User = { + login: "octocat", + }; + const sender: User = { + login: "sender", + }; + const user2: User = { + login: "novemdog", + }; + const label: Label = { + name: "bug", + }; + const mock = vi.fn().mockImplementation(async () => { + const resp = { data: [user] }; + return resp; + }); + const octokit = OctokitRestMock(mock); + const issue: Issue & GetTeamMemberProp = { + assignees: [user], + labels: [label], + octokit, + }; + const obj = { repository, sender, issue, pr: null }; + const issue2: Issue & GetTeamMemberProp = { + assignees: [user2], + labels: [label], + octokit, + }; + const pr: PullRequest & GetTeamMemberProp = { + requested_reviewers: [user], + assignees: [user], + head: { + label: "arkedge:renovate/regex-1.x", + ref: "renovate/regex-1.x", + }, + octokit, + }; + const obj2 = { repository, sender, issue: issue2, pr: null }; + const obj3 = { repository, sender, issue: null, pr }; + const obj4 = { + repository: { ...repository, name: "autoproject" }, + sender, + issue: null, + pr, + }; + const rules = processYamlRules(yaml).content!; + await expect(rules[0].test(obj)).resolves.toBeTruthy(); + await expect(rules[1].test(obj)).resolves.toBeFalsy(); + expect(mock).toHaveBeenCalledTimes(0); + await expect(rules[2].test(obj)).resolves.toBeTruthy(); + expect( + mock, + "acquire team member the first time we need" + ).toHaveBeenCalledTimes(1); + + await expect( + getMatchedProj( + rules, + { kind: WebhookEventKind.Issue, action: "assigned" }, + obj + ) + ).resolves.toEqual([1, 4]); + + await expect(rules[2].test(obj2)).resolves.toBeFalsy(); + expect( + mock, + "once called, it will not be called again" + ).toHaveBeenCalledTimes(1); + await expect( + getMatchedProj( + rules, + { kind: WebhookEventKind.Issue, action: "assigned" }, + obj2 + ) + ).resolves.toEqual([]); + + await expect(rules[3].test(obj3)).resolves.toBeFalsy(); + + await expect(rules[3].test(obj4)).resolves.toBeTruthy(); + await expect(rules[4].test(obj4)).resolves.toBeTruthy(); + await expect(rules[5].test(obj4)).resolves.toBeTruthy(); + await expect( + getMatchedProj( + rules, + { kind: WebhookEventKind.PullRequest, action: "assigned" }, + obj4 + ) + ).resolves.toEqual([9]); +}); + +it("can treat multiple rules", async () => { + const yaml = fs.readFileSync("./test/test_multiple_rules.yaml", "utf-8"); + const repository = {} as unknown as Repo; + const mock = vi.fn().mockImplementation(async () => { + const resp = { data: [] } as unknown as OctokitResponse<[]>; + return resp; + }); + const obj = (login: string) => { + const user: User = { + login, + }; + const sender: User = { + login, + }; + const octokit = OctokitRestMock(mock); + const issue: Issue & GetTeamMemberProp = { + assignees: [user], + octokit, + }; + const obj = { repository, sender, issue, pr: null }; + return obj; + }; + const rules = processYamlRules(yaml).content!; + await expect(rules[0].test(obj("z"))).resolves.toBeFalsy(); + await expect(rules[0].test(obj("a"))).resolves.toBeTruthy(); + await expect(rules[1].test(obj("a"))).resolves.toBeFalsy(); + await expect(rules[2].test(obj("a"))).resolves.toBeFalsy(); + await expect(rules[0].test(obj("b"))).resolves.toBeFalsy(); + await expect(rules[1].test(obj("b"))).resolves.toBeTruthy(); + await expect(rules[2].test(obj("b"))).resolves.toBeFalsy(); + expect(mock).toHaveBeenCalledTimes(0); +}); + +it("treat rules about issue and pr exclusively", () => { + const ok = (yml: string, msg?: string) => { + expect(processYamlRules(yml).content, msg).not.toEqual(null); + }; + const ng = (yml: string, msg?: string) => { + expect(processYamlRules(yml).content, msg).toEqual(null); + }; + ok(` + version: "0" + rules: + - issue: + assignees: + - name + project: 1`); + ok(` + version: "0" + rules: + - pr: + assignees: + - name + project: 1`); + ng(` + version: "0" + rules: + - issue: + assignees: + - name + pr: + assignees: + - name + project: 1`); + ng( + ` + version: "0" + rules: + - issue: + assignees: + - name + on: + pr: + - assigned + project: 1`, + "issue rule on pr event" + ); +}); + +it("can limit firing event", async () => { + const yaml = fs.readFileSync("./test/test_event_on.yaml", "utf-8"); + const repository = { + name: "autopj", + } as unknown as Repo; + const mock = vi.fn().mockImplementation(async () => { + const resp = { data: [] }; + return resp; + }); + const arg = (login: string) => { + const user: User = { + login, + }; + const sender: User = { + login, + }; + const octokit = OctokitRestMock(mock); + const issue: Issue & GetTeamMemberProp = { + assignees: [user], + octokit, + }; + const obj = { repository, sender, issue, pr: null }; + return obj; + }; + const rules = processYamlRules(yaml).content!; + await expect( + getMatchedProj( + rules, + { + kind: WebhookEventKind.Issue, + action: "assigned", + }, + arg("assignee") + ) + ).resolves.toEqual([1]); + await expect( + getMatchedProj( + rules, + { + kind: WebhookEventKind.Issue, + action: "opened", + }, + arg("assignee") + ) + ).resolves.toEqual([1, 2]); + await expect( + getMatchedProj( + rules, + { + kind: WebhookEventKind.PullRequest, + action: "opened", + }, + arg("assignee") + ) + ).resolves.toEqual([2]); + await expect( + getMatchedProj( + rules, + { + kind: WebhookEventKind.PullRequest, + action: "assigned", + }, + arg("assignee") + ) + ).resolves.toEqual([]); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..9dffa3a --- /dev/null +++ b/src/config.ts @@ -0,0 +1,468 @@ +import { type YAMLError } from "yaml"; +import type YAML from "yaml"; +import { z } from "zod"; +import { emitter } from "./emitter"; +import { + type ObjPath, + type SemDiag, + SemDiagKind, + type SynDiag, + type Diag, + type MatchArg, + WebhookEventKind, + type WebhookEventActionTarget, + isTargetWebhookEvent, + type WebhookEventAction, + type EventTarget, + type TargetProj, + TargetProjKind, + type TargetProjNumber, + type TargetProjOnly, + type TargetProjReject, +} from "./types"; +import Fuse from "fuse.js"; +import assert from "assert"; +import { getLogger } from "log4js"; +import { + type APred, + type RuleSchema, + type ZodPathComponentUsed, + configSchema, + forallAsync, + flatten, + versionedSchema, +} from "./configSchema"; +import { type YamlDocument, type YamlTagged } from "./configFormat/yamlFormat"; +import { + type JsonDocument, + type JsonTagged, + type LineCol, +} from "./configFormat/jsonFormat"; +import { type Document, type EitherFormat } from "./configFormat/configFormat"; + +const createSearcher = (path: ObjPath) => { + return new Fuse(tryExtractPossibleKeysAt(path)); +}; + +const tryExtractPossibleKeysAt = (path: ObjPath) => { + try { + let def: ZodPathComponentUsed = versionedSchema._def; + for (const elem of path) { + let next: ZodPathComponentUsed; + if (typeof elem === "number") { + assert(def.typeName === z.ZodFirstPartyTypeKind.ZodArray); + next = def.type._def; + } else { + assert(def.typeName === z.ZodFirstPartyTypeKind.ZodObject); + const shape = def.shape(); + next = shape[elem]._def; + } + def = peelTransformer(next); + } + assert(def.typeName === z.ZodFirstPartyTypeKind.ZodObject); + return Object.keys(def.shape()); + } catch (e) { + if (e instanceof Error) { + getLogger("debug").error(e.message); + } + return []; + } +}; + +const peelTransformer = (def: ZodPathComponentUsed): ZodPathComponentUsed => { + if (def.typeName === z.ZodFirstPartyTypeKind.ZodOptional) { + const newDef: ZodPathComponentUsed = def.innerType._def; + def = peelTransformer(newDef); + } + if (def.typeName === z.ZodFirstPartyTypeKind.ZodEffects) { + const newDef: ZodPathComponentUsed = def.schema._def; + def = peelTransformer(newDef); + } + return def; +}; + +export interface Config { + get: (x: string) => number[] | undefined; +} + +interface Rule { + test: APred; + webhookEventTarget: WebhookEventActionTarget; + targetProj: TargetProj; +} + +export async function getMatchedProj( + rules: Rule[], + webhookEvent: WebhookEventAction, + obj: MatchArg +): Promise { + const ruleMatchedProjectNumbers = await Promise.all( + rules.map(async (rule) => { + if (isTargetWebhookEvent(rule.webhookEventTarget, webhookEvent)) { + if (await rule.test(obj)) { + return rule.targetProj; + } + } + return []; + }) + ); + const targetProjs: TargetProj[] = ruleMatchedProjectNumbers.flat(); + return filterTargetProjs(targetProjs); +} + +function filterTargetProjs(targetProjs: TargetProj[]): number[] { + // lift `priority` from `TargetProjNumber` + const targetProjsFlat = targetProjs.flatMap( + ( + p: TargetProj + ): Array< + | ({ kind: TargetProjKind.Number } & TargetProjNumber) + | TargetProjOnly + | TargetProjReject + > => { + if (p.kind === TargetProjKind.Number) { + return p.projectNumber.map((n) => ({ + kind: TargetProjKind.Number, + ...n, + })); + } + return [p]; + } + ); + // dictionary order (priority >> kind) + targetProjsFlat.sort((a, b) => { + if (a.priority === b.priority) { + if (a.kind === b.kind) { + return 0; + } else { + return a.kind - b.kind; + } + } else { + return a.priority - b.priority; + } + }); + let projSet = new Set(); + for (const targetProj of targetProjsFlat) { + switch (targetProj.kind) { + case TargetProjKind.Number: { + if (targetProj.isPositive) { + for (const n of targetProj.value) { + projSet.add(n); + } + } else { + for (const n of targetProj.value) { + projSet.delete(n); + } + } + break; + } + case TargetProjKind.Only: { + projSet = new Set(targetProj.projectNumber); + break; + } + case TargetProjKind.Reject: { + // no projects to add + return []; + } + } + } + return [...projSet]; +} + +export function processRulesV0( + format: EitherFormat, + filepath: string, + src: string +): ParseResult { + const { docResult, content, error } = parseFile( + format, + configSchema, + filepath, + src + ); + return { + docResult, + content: content !== null ? new Map(Object.entries(content)) : null, + error, + }; +} + +export enum ErrorKind { + Syn = "syn", + Sem = "sem", + Unknown = "?", +} + +type ParseError = + | { + type: ErrorKind.Syn; + diag: SynDiag; + } + | { + type: ErrorKind.Sem; + diag: SemDiag; + } + | { + type: ErrorKind.Unknown; + error: unknown; + }; + +type YamlDocResult = + | { + is_ok: true; + doc: JsonDocument | YamlDocument; + } + | { + is_ok: false; + docRaw: + | YamlTagged> + | JsonTagged; + }; + +export type ParseResult = { + docResult: YamlDocResult; + content: T | null; + error: ParseError[]; +}; + +function parseFile>( + format: EitherFormat, + schema: Schema, + filepath: string, + yaml: string +): ParseResult { + const result = format.parse(yaml); + if (!result.is_ok) { + const doc = result.error; + let diags; + switch (doc.kind) { + case "json": + diags = extractJsonSynDiag(doc.value); + break; + case "yaml": + diags = extractYamlSynDiag(doc.value.errors); + break; + default: + assert(false, "configFormat is not exhaustive"); + } + emitter.emit("synerror", { filepath, diags }); + return { + docResult: { + is_ok: false, + docRaw: doc, + }, + content: null, + error: diags.map((diag) => { + return { type: ErrorKind.Syn, diag }; + }), + }; + } + const doc = result.value.value; + const raw: unknown = doc.toJS(); + const zResult = schema.safeParse(raw); + if (zResult.success) { + return { + docResult: { + is_ok: true, + doc, + }, + content: zResult.data, + error: [], + }; + } else { + const diags = extractSemDiag(doc, zResult.error); + emitter.emit("semerror", { filepath, diags }); + return { + docResult: { + is_ok: true, + doc, + }, + content: null, + error: diags.map((diag) => { + return { type: ErrorKind.Sem, diag }; + }), + }; + } +} + +const transformRule = (parsed: RuleSchema) => { + const parts = [parsed.repo, parsed.issue, parsed.pr].flatMap((l) => l ?? []); + const rule: Rule = { + test: async (obj: MatchArg) => + await forallAsync(parts, async (value) => await value(obj)), + webhookEventTarget: getWebhookEventTarget(parsed), + targetProj: parsed.project, + }; + return rule; +}; + +const getEventTarget = (on: "any" | E | E[]): EventTarget => { + if (on === "any") { + return { + kind: "any", + }; + } else { + return { + kind: "oneof", + list: flatten(on), + }; + } +}; + +function getWebhookEventTarget(parsed: RuleSchema): WebhookEventActionTarget { + if (typeof parsed.pr === "undefined") { + if (typeof parsed.issue === "undefined") { + // repo only + return { + kind: "both", + issueAction: getEventTarget(parsed.on?.issue ?? "any"), + prAction: getEventTarget(parsed.on?.pr ?? "any"), + }; + } else { + // issue rule + return { + kind: WebhookEventKind.Issue, + issueAction: getEventTarget(parsed.on?.issue ?? "any"), + }; + } + } else { + return { + kind: WebhookEventKind.PullRequest, + prAction: getEventTarget(parsed.on?.pr ?? "any"), + }; + } +} + +export function processRules( + format: EitherFormat, + filepath: string, + src: string +): ParseResult { + const { docResult, content, error } = parseFile( + format, + versionedSchema, + filepath, + src + ); + return { + docResult, + content: content?.rules.map(transformRule) ?? null, + error, + }; +} + +const extractSemDiag = (doc: Document, error: z.ZodError) => { + const extractFromDoc = (path: ObjPath, msg: string) => { + const node = doc.getIn(path); + if (typeof node !== "undefined") { + const diag: SemDiag = { + objPath: path, + msg: msg.split("\n")[0], + diagKind: { + diagName: SemDiagKind.Any, + }, + range: { + start: node.loc.start, + end: node.loc.end, + }, + }; + return diag; + } else { + throw new Error(`invalid object path: ${path.toString()}`); + } + }; + const diags = error.issues.flatMap((issue) => { + const path = issue.path; + if (path.length === 0) { + const diag: SemDiag = { + objPath: path, + msg: issue.message, + range: { + start: doc.asNode().loc.start, + end: doc.asNode().loc.end, + }, + diagKind: { + diagName: SemDiagKind.Any, + }, + }; + return [diag]; + } + if (issue.code === "unrecognized_keys") { + return issue.keys.map((k) => { + const elem = doc.getIn(path)!; + const key = elem.findKeyAsMap(k)!; + const candidates = [ + ...createSearcher(path) + .search(k) + .map((fr) => fr.item), + ]; + let msg = `Unrecognized key: '${k}'`; + if (candidates.length !== 0) { + msg += `. Did you mean '${candidates[0]}'?`; + } + const diag: SemDiag = { + objPath: path, + msg, + diagKind: { + diagName: SemDiagKind.UnrecognizedKeys, + key: { + value: k, + range: { + start: key.loc.start, + end: key.loc.end, + }, + }, + candidates, + }, + range: { + start: elem.loc.start, + end: elem.loc.end, + }, + }; + return diag; + }); + } + let msg; + if ( + (issue.code === "invalid_union" && + issue.unionErrors.every((e) => + e.errors.every((e) => e.message === "Required") + )) || + issue.message === "Required" + ) { + msg = `'${path.at(-1)!}' is required`; + } else { + msg = issue.message; + } + const docPath = doc.hasIn(path) ? path : path.slice(0, -1); + return [extractFromDoc(docPath, msg)]; + }); + return diags; +}; + +const extractYamlSynDiag = (errors: YAMLError[]) => { + return errors.map((error) => { + const r: Diag = { + msg: error.message, + range: { + start: error.linePos![0], + end: error.linePos![1], + }, + }; + return r; + }); +}; + +const extractJsonSynDiag = (lc: LineCol | null) => { + const start = lc ?? { + line: 1, + col: 1, + }; + const d: SynDiag = { + msg: "syntax error", + range: { + start, + }, + }; + return [d]; +}; diff --git a/src/configFormat/configFormat.ts b/src/configFormat/configFormat.ts new file mode 100644 index 0000000..305f200 --- /dev/null +++ b/src/configFormat/configFormat.ts @@ -0,0 +1,47 @@ +import { type ObjPath, type Range2 } from "../types"; +import { JsonFormat } from "./jsonFormat"; +import { YamlFormat } from "./yamlFormat"; + +export type ParseResult = + | { + is_ok: true; + value: T; + } + | { + is_ok: false; + error: E; + }; + +export type ConfigFormatKind = "yaml" | "json"; +type KindTagged = { + kind: ConfigFormatKind; + value: T; +}; +export type EitherFormat = YamlFormat | JsonFormat; + +export function getFormat(kind: ConfigFormatKind): EitherFormat { + if (kind === "yaml") { + return new YamlFormat(); + } else { + return new JsonFormat(); + } +} + +export interface Format { + parse: (s: string) => ParseResult, KindTagged>; +} + +export interface Document { + toJS: () => unknown; + asNode: () => DocumentNode; + getIn: (path: ObjPath) => (DocumentNode & FindKey) | undefined; + hasIn: (path: ObjPath) => boolean; +} + +export interface FindKey { + findKeyAsMap: (key: string) => DocumentNode | null; +} + +export interface DocumentNode { + loc: Range2; +} diff --git a/src/configFormat/json.js b/src/configFormat/json.js new file mode 100644 index 0000000..26005eb --- /dev/null +++ b/src/configFormat/json.js @@ -0,0 +1,119 @@ +/* eslint-disable */ +// Generated automatically by nearley, version 2.20.1 +// http://github.com/Hardmath123/nearley +(function () { +function id(x) { return x[0]; } + +const moo = require('moo'); + +let lexer = moo.compile({ + space: {match: /\s+/, lineBreaks: true}, + number: /-?(?:[0-9]|[1-9][0-9]+)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?\b/, + string: /"(?:\\["bfnrt\/\\]|\\u[a-fA-F0-9]{4}|[^"\\])*"/, + "{": "{", + "}": "}", + "[": "[", + "]": "]", + ",": ",", + ":": ":", + true: "true", + false: "false", + null: "null", +}) + +function empty(type) { + return function ([open,,close]) { + return { + type, + children: [], + loc: { start: pos(open), end: pos(close, 1) } + }; + }; +} + +function children(type) { + return function ([open,,first,rest,,,close]) { + return { + type, + children: [ + first, + ...rest.map(([,,,property]) => property) + ], + loc: { start: pos(open), end: pos(close, 1) } + }; + }; +} + +function literal() { + return function ([token]) { + return { + type: "Literal", + value: JSON.parse(token.value), + raw: token.text, + loc: { + start: pos(token), + end: pos(token, token.text.length) + } + }; + }; +} + +function property([key,,,,value]) { + return { + type: "Property", + key, + value, + loc: { + start: key.loc.start, + end: value.loc.end + } + }; +} + +function pos({ line, col, offset }, add = 0) { + return { + line, + col: col + add, + offset: offset + add + }; +} +var grammar = { + Lexer: lexer, + ParserRules: [ + {"name": "json", "symbols": ["_", "value", "_"], "postprocess": ([,val,]) => val}, + {"name": "object", "symbols": [{"literal":"{"}, "_", {"literal":"}"}], "postprocess": empty("Object")}, + {"name": "object$ebnf$1", "symbols": []}, + {"name": "object$ebnf$1$subexpression$1", "symbols": ["_", {"literal":","}, "_", "property"]}, + {"name": "object$ebnf$1", "symbols": ["object$ebnf$1", "object$ebnf$1$subexpression$1"], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "object$ebnf$2$subexpression$1", "symbols": [{"literal":","}, "_"]}, + {"name": "object$ebnf$2", "symbols": ["object$ebnf$2$subexpression$1"], "postprocess": id}, + {"name": "object$ebnf$2", "symbols": [], "postprocess": function(d) {return null;}}, + {"name": "object", "symbols": [{"literal":"{"}, "_", "property", "object$ebnf$1", "_", "object$ebnf$2", {"literal":"}"}], "postprocess": children("Object")}, + {"name": "array", "symbols": [{"literal":"["}, "_", {"literal":"]"}], "postprocess": empty("Array")}, + {"name": "array$ebnf$1", "symbols": []}, + {"name": "array$ebnf$1$subexpression$1", "symbols": ["_", {"literal":","}, "_", "value"]}, + {"name": "array$ebnf$1", "symbols": ["array$ebnf$1", "array$ebnf$1$subexpression$1"], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "array$ebnf$2$subexpression$1", "symbols": [{"literal":","}, "_"]}, + {"name": "array$ebnf$2", "symbols": ["array$ebnf$2$subexpression$1"], "postprocess": id}, + {"name": "array$ebnf$2", "symbols": [], "postprocess": function(d) {return null;}}, + {"name": "array", "symbols": [{"literal":"["}, "_", "value", "array$ebnf$1", "_", "array$ebnf$2", {"literal":"]"}], "postprocess": children("Array")}, + {"name": "value", "symbols": ["object"], "postprocess": id}, + {"name": "value", "symbols": ["array"], "postprocess": id}, + {"name": "value", "symbols": [(lexer.has("true") ? {type: "true"} : true)], "postprocess": literal()}, + {"name": "value", "symbols": [(lexer.has("false") ? {type: "false"} : false)], "postprocess": literal()}, + {"name": "value", "symbols": [(lexer.has("null") ? {type: "null"} : null)], "postprocess": literal()}, + {"name": "value", "symbols": [(lexer.has("number") ? {type: "number"} : number)], "postprocess": literal()}, + {"name": "value", "symbols": [(lexer.has("string") ? {type: "string"} : string)], "postprocess": literal()}, + {"name": "property", "symbols": ["key", "_", {"literal":":"}, "_", "value"], "postprocess": property}, + {"name": "key", "symbols": [(lexer.has("string") ? {type: "string"} : string)], "postprocess": literal("Identifier")}, + {"name": "_", "symbols": []}, + {"name": "_", "symbols": [(lexer.has("space") ? {type: "space"} : space)], "postprocess": d => null} +] + , ParserStart: "json" +} +if (typeof module !== 'undefined'&& typeof module.exports !== 'undefined') { + module.exports = grammar; +} else { + window.grammar = grammar; +} +})(); diff --git a/src/configFormat/json.ne b/src/configFormat/json.ne new file mode 100644 index 0000000..a0f5ded --- /dev/null +++ b/src/configFormat/json.ne @@ -0,0 +1,104 @@ +# http://www.json.org/ +# http://www.asciitable.com/ +@lexer lexer + +json -> _ value _ {% ([,val,]) => val %} + +object -> + "{" _ "}" {% empty("Object") %} + | "{" _ property (_ "," _ property):* _ ("," _ ):? "}" {% children("Object") %} + +array -> + "[" _ "]" {% empty("Array") %} + | "[" _ value (_ "," _ value):* _ ("," _ ):? "]" {% children("Array") %} + +value -> + object {% id %} + | array {% id %} + | %true {% literal() %} + | %false {% literal() %} + | %null {% literal() %} + | %number {% literal() %} + | %string {% literal() %} + +property -> key _ ":" _ value {% property %} + +key -> %string {% literal("Identifier") %} + +_ -> null | %space {% d => null %} + +@{% +const moo = require('moo'); + +let lexer = moo.compile({ + space: {match: /\s+/, lineBreaks: true}, + number: /-?(?:[0-9]|[1-9][0-9]+)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?\b/, + string: /"(?:\\["bfnrt\/\\]|\\u[a-fA-F0-9]{4}|[^"\\])*"/, + "{": "{", + "}": "}", + "[": "[", + "]": "]", + ",": ",", + ":": ":", + true: "true", + false: "false", + null: "null", +}) + +function empty(type) { + return function ([open,,close]) { + return { + type, + children: [], + loc: { start: pos(open), end: pos(close, 1) } + }; + }; +} + +function children(type) { + return function ([open,,first,rest,,,close]) { + return { + type, + children: [ + first, + ...rest.map(([,,,property]) => property) + ], + loc: { start: pos(open), end: pos(close, 1) } + }; + }; +} + +function literal() { + return function ([token]) { + return { + type: "Literal", + value: JSON.parse(token.value), + raw: token.text, + loc: { + start: pos(token), + end: pos(token, token.text.length) + } + }; + }; +} + +function property([key,,,,value]) { + return { + type: "Property", + key, + value, + loc: { + start: key.loc.start, + end: value.loc.end + } + }; +} + +function pos({ line, col, offset }, add = 0) { + return { + line, + col: col + add, + offset: offset + add + }; +} +%} diff --git a/src/configFormat/json.test.ts b/src/configFormat/json.test.ts new file mode 100644 index 0000000..496b2e2 --- /dev/null +++ b/src/configFormat/json.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "vitest"; +import { JsonFormat } from "./jsonFormat"; + +it("can parse empty json", () => { + const parsed = new JsonFormat().parse("{}"); + expect(parsed.is_ok).toBeTruthy(); +}); + +it("can parse a json with trailing comma", () => { + const parsed = new JsonFormat().parse('{"a": 1,}'); + expect(parsed.is_ok).toBeTruthy(); +}); diff --git a/src/configFormat/jsonFormat.ts b/src/configFormat/jsonFormat.ts new file mode 100644 index 0000000..4066136 --- /dev/null +++ b/src/configFormat/jsonFormat.ts @@ -0,0 +1,230 @@ +import nearley from "nearley"; +import grammar from "./json.js"; +import { + type Document, + type DocumentNode, + type FindKey, + type Format, + type ParseResult, +} from "./configFormat"; +import { type ObjPath } from "../types"; + +const parserGrammar = nearley.Grammar.fromCompiled( + grammar as nearley.CompiledRules +); + +export type JsonTagged = { + kind: "json"; + value: T; +}; + +function wrap(value: T): JsonTagged { + return { + kind: "json", + value, + }; +} + +type ParseResultWrapped = ParseResult, JsonTagged>; + +export class JsonFormat implements Format { + parse(s: string): ParseResultWrapped { + try { + const parser = new nearley.Parser(parserGrammar); + parser.feed(s); + const [value]: Value[] = parser.finish(); + return { + is_ok: true, + value: wrap(new JsonDocument(value)), + }; + } catch (e) { + // nearley + moo discards linecol of erroneous token while throwing error + // recover it if possible + const lc = recoverLineCol(e); + return { + is_ok: false, + error: wrap(lc), + }; + } + } +} + +export type LineCol = { + line: number; + col: number; +}; + +const reLc = /at line (\d) col (\d)/; + +function recoverLineCol(e: any): LineCol | null { + if (e instanceof Error) { + const result = reLc.exec(e.message); + if (result !== null) { + return { + line: Number(result[1]), + col: Number(result[2]), + }; + } + } + return null; +} + +export class JsonDocument implements Document { + constructor(private readonly value: Value) {} + toJS() { + return valueToJS(this.value); + } + + asNode(): DocumentNode { + return { + loc: this.value.loc, + }; + } + + getIn(path: ObjPath): (DocumentNode & FindKey) | undefined { + let node: Value = this.value; + for (const elem of path) { + switch (node.type) { + case "Object": { + const pair = node.children.find((pair) => pair.key.value === elem); + if (typeof pair === "undefined") { + return undefined; + } + node = pair.value; + break; + } + case "Array": { + if (typeof elem !== "number") { + return undefined; + } + node = node.children[elem]; + break; + } + case "Literal": { + return undefined; + } + } + } + return toDocumentNode(node); + } + + hasIn(path: ObjPath): boolean { + let node: Value = this.value; + for (const elem of path) { + switch (node.type) { + case "Object": { + const pair = node.children.find((pair) => pair.key.value === elem); + if (typeof pair === "undefined") { + return false; + } + node = pair.value; + break; + } + case "Array": { + if (typeof elem !== "number") { + return false; + } + node = node.children[elem]; + break; + } + case "Literal": { + return false; + } + } + } + return true; + } +} + +interface Pos { + line: number; + col: number; + offset: number; +} + +interface Loc { + start: Pos; + end: Pos; +} + +interface NodeBase { + type: string; + loc: Loc; +} + +interface PrimitiveLiteral extends NodeBase { + type: "Literal"; + value: any; + raw: string; +} + +interface Identifier extends NodeBase { + type: "Identifier"; + value: any; + raw: string; +} + +interface ObjectNode extends NodeBase { + type: "Object"; + children: Property[]; +} + +interface ArrayNode extends NodeBase { + type: "Array"; + children: Value[]; +} + +type Value = PrimitiveLiteral | ObjectNode | ArrayNode; + +interface Property extends NodeBase { + type: "Property"; + key: Identifier; + value: Value; +} + +function valueToJS(value: Value): any { + switch (value.type) { + case "Literal": { + return value.value; + } + case "Object": { + const result: any = {}; + for (const prop of value.children) { + result[prop.key.value] = valueToJS(prop.value); + } + return result; + } + case "Array": { + const result = []; + for (const elem of value.children) { + result.push(valueToJS(elem)); + } + return result; + } + } +} + +function toDocumentNode(node: Value): DocumentNode & FindKey { + return { + loc: node.loc, + findKeyAsMap: findDelegator(node), + }; +} + +function findDelegator(node: Value | null) { + return (keyName: string): DocumentNode | null => { + if (node?.type === "Object") { + const key = node?.children.find( + (pair) => pair.key.value.toString() === keyName + )?.key; + if (typeof key === "undefined") { + return null; + } + return { + loc: key.loc, + }; + } else { + return null; + } + }; +} diff --git a/src/configFormat/yamlFormat.ts b/src/configFormat/yamlFormat.ts new file mode 100644 index 0000000..0a0ede8 --- /dev/null +++ b/src/configFormat/yamlFormat.ts @@ -0,0 +1,110 @@ +import YAML, { LineCounter, type ParsedNode } from "yaml"; +import { + type Document, + type DocumentNode, + type FindKey, + type Format, + type ParseResult, +} from "./configFormat"; +import { type ObjPath } from "src/types"; + +export type YamlTagged = { + kind: "yaml"; + value: T; +}; + +function wrap(value: T): YamlTagged { + return { + kind: "yaml", + value, + }; +} + +type ParseResultWrapped = ParseResult, YamlTagged>; + +export class YamlFormat implements Format { + parse( + s: string + ): ParseResultWrapped> { + const lineCounter = new LineCounter(); + const doc = YAML.parseDocument(s, { lineCounter, prettyErrors: true }); + if (doc.errors.length !== 0) { + return { + is_ok: false, + error: wrap(doc), + }; + } else { + return { + is_ok: true, + value: wrap(new YamlDocument(doc, lineCounter)), + }; + } + } +} + +export class YamlDocument implements Document { + constructor( + private readonly doc: YAML.Document.Parsed, + private readonly lineCounter: LineCounter + ) {} + + toJS() { + return this.doc.toJS(); + } + + asNode(): DocumentNode { + return { + loc: { + start: this.lineCounter.linePos(this.doc.range[0]), + end: this.lineCounter.linePos(this.doc.range[2]), + }, + }; + } + + getIn(path: ObjPath): (DocumentNode & FindKey) | undefined { + const n = this.doc.getIn(path, true) as ParsedNode | undefined; + if (typeof n === "undefined") { + return undefined; + } else { + return toDocumentNode(n, this.lineCounter); + } + } + + hasIn(path: ObjPath): boolean { + return this.doc.hasIn(path); + } +} + +function toDocumentNode( + node: YAML.ParsedNode, + lineCounter: LineCounter +): DocumentNode & FindKey { + return { + loc: { + start: lineCounter.linePos(node.range[0]), + end: lineCounter.linePos(node.range[1]), + }, + findKeyAsMap: findDelegator(node, lineCounter), + }; +} + +function findDelegator(node: YAML.ParsedNode | null, lineCounter: LineCounter) { + return (keyName: string) => { + if (YAML.isMap(node)) { + const key = node?.items.find( + (pair) => pair.key.toString() === keyName + )?.key; + if (typeof key === "undefined") { + return null; + } + return { + loc: { + start: lineCounter.linePos(key.range[0]), + end: lineCounter.linePos(key.range[1]), + }, + }; + } else { + return null; + } + }; +} diff --git a/src/configSchema.ts b/src/configSchema.ts new file mode 100644 index 0000000..5218520 --- /dev/null +++ b/src/configSchema.ts @@ -0,0 +1,456 @@ +import { getAllTeamMember, type GetTeamMemberProp } from "./github"; +import { z } from "zod"; +import { + type Issue, + type Label, + type Repo, + type MatchArg, + type PullRequest, + type User, + TargetProjKind, + type TargetProjNumber, + type TargetProjOnly, + type TargetProjReject, + type PullRequestHead, + type TargetProjKindNumber, +} from "./types"; + +type Pred = (x: X) => boolean; +export type APred = (x: X) => Promise; + +type ArrayOrInner = T | T[]; + +export const flatten = (a: ArrayOrInner) => { + if (Array.isArray(a)) { + return a; + } + return [a]; +}; + +const id = (x: T) => x; + +const propProj = + (key: K) => + (t: T) => + t[key]; + +const existsAsync = async (arr: Y[], predicate: APred) => { + for (const e of arr) { + if (await predicate(e)) return true; + } + return false; +}; + +export const forallAsync = async (arr: Y[], predicate: APred) => { + for (const e of arr) { + if (!(await predicate(e))) return false; + } + return true; +}; + +const composeE = + (f: (x: X) => ArrayOrInner, g: Array<(x: Y) => Z>) => + (x: X) => { + const y = flatten(f(x)); + return g.some((g) => y.some((y) => g(y))); + }; + +const composeEAsync = + (f: (x: X) => ArrayOrInner, g: Array<(x: Y) => Promise>) => + async (x: X) => { + const y = flatten(f(x)); + return await existsAsync( + g, + async (g) => await existsAsync(y, async (y) => await g(y)) + ); + }; + +const composeA = + (f: (x: X) => ArrayOrInner, g: Array<(x: Y) => Z>) => + (x: X) => { + const y = flatten(f(x)); + return g.every((g) => y.some((y) => g(y))); + }; + +const composeAAsync = + (f: (x: X) => ArrayOrInner, g: Array<(x: Y) => Promise>) => + async (x: X) => { + const y = flatten(f(x)); + return await forallAsync( + g, + async (g) => await existsAsync(y, async (y) => await g(y)) + ); + }; + +const extendE = + (f: (_: X) => Y) => + (ps: ArrayOrInner>) => + composeE(f, flatten(ps)); + +const extendEAsync = + (f: (_: X) => Y) => + (ps: ArrayOrInner>) => + composeEAsync(f, flatten(ps)); + +const extendPropE = + () => + (key: K) => + (ps: ArrayOrInner>) => + composeE(propProj(key), flatten(ps)); + +const extendPropA = + () => + (key: K) => + (ps: ArrayOrInner>) => + composeA(propProj(key), flatten(ps)); + +const extendPropAAsync = + () => + (key: K) => + (ps: ArrayOrInner>) => + composeAAsync(propProj(key), flatten(ps)); + +const asAsync = + (p: Pred) => + async (x: X) => + p(x); + +const arrayOneOf = (ps: ArrayOrInner>) => + composeE((x: X[]) => x, flatten(ps)); + +const nullTolerantAAsync = + (ps: ArrayOrInner>) => + async (x: X | null) => { + if (x === null) { + // identity element of `and` + return true; + } else { + return await composeAAsync(id, flatten(ps))(x); + } + }; + +const unit = (a: T) => [a]; + +const _number = z.number().int().nonnegative(); + +const numberParser = z.union([_number.transform(unit), _number.array()], { + errorMap: (issue, ctx) => { + if ( + issue.code === z.ZodIssueCode.invalid_union && + issue.unionErrors.length === 2 + ) { + const [num, arr] = issue.unionErrors; + if (num.issues.every((i) => i.code === z.ZodIssueCode.invalid_type)) { + const issue = num.issues.at(0) as z.ZodInvalidTypeIssue; + if ( + num.issues.every( + (i) => + // i.code === z.ZodIssueCode.invalid_type is required because of type inference + i.code === z.ZodIssueCode.invalid_type && + i.received === z.ZodParsedType.array + ) + ) { + // error is array of something + // return `arr` to delegate error message + return arr; + } else { + return { + message: `Expected number or array of number, received ${issue.received}`, + }; + } + } else { + // received a number, but invalid as a number (will be negative or float) + // return `num` to delegate error message + return num; + } + } + return { message: ctx.defaultError }; + }, +}); + +export const configSchema = z.record(numberParser); + +const stringEq = (value: string, ctx: z.RefinementCtx): Pred => { + if (value.startsWith("/") && value.endsWith("/")) { + try { + const regexp = new RegExp(value.substring(1, value.length - 1)); + return (prop: string | null) => regexp.test(prop ?? ""); + } catch (e) { + if (e instanceof SyntaxError) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid regexp: ${e.message}`, + }); + } + return z.NEVER; + } + } else { + return (prop: string | null) => prop === value; + } +}; + +const curryingStrictEq = + (x: T) => + (y: T) => + x === y; + +const _string = z.string().transform(stringEq); + +const stringish = _string.transform(unit).or(_string.array()); + +const booleanParser = z.boolean().transform(curryingStrictEq); + +const repoSchema = z.object({ + name: stringish.transform(extendPropE()("name")), + full_name: stringish.transform(extendPropE()("full_name")), + description: stringish.transform(extendPropE()("description")), + fork: booleanParser.transform(extendPropE()("fork")), + private: booleanParser.transform(extendPropE()("private")), + topics: stringish + .transform(arrayOneOf) + .transform(extendPropE()("topics")), +}); + +const lazyAsync = (f: (i: X) => Promise) => { + let p: Promise | undefined; + return async (i: X) => { + if (typeof p === "undefined") { + p = f(i); + } + return await p; + }; +}; + +const teamRegex = + /^([a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})\/([a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})$/i; + +const loginEq = + (proj: (_: X) => User[]) => + (value: string): APred => { + const r = teamRegex.exec(value); + if (r === null) { + return async (x: X) => { + return proj(x).some((g) => curryingStrictEq(g.login)(value)); + }; + } else { + const p = lazyAsync(async (i: X & GetTeamMemberProp) => { + return await getAllTeamMember(i, r[1], r[2]); + }); + return async (x: X & GetTeamMemberProp) => { + const y = await p(x); + return proj(x).some((g) => + y.some((y) => curryingStrictEq(g.login)(y.login)) + ); + }; + } + }; + +const loginStr = (proj: (_: X) => User[]) => + z.string().transform(loginEq(proj)); + +const loginParser = (proj: (_: X) => User[]) => + loginStr(proj).transform(unit).or(loginStr(proj).array()); + +const labelParser = stringish + .transform(extendPropE