├── .nvmrc ├── web ├── .gitignore ├── public │ ├── favicon.ico │ ├── img │ │ ├── logo.png │ │ ├── ads │ │ │ ├── github.png │ │ │ ├── tasks.png │ │ │ ├── trello.png │ │ │ └── workflow.png │ │ ├── thumbnail.png │ │ ├── examples │ │ │ ├── issues.png │ │ │ ├── yaml.png │ │ │ ├── siblings.png │ │ │ ├── pr-comment.png │ │ │ └── typescript.png │ │ ├── testimonial │ │ │ ├── jan.png │ │ │ └── matic.jpg │ │ └── logos │ │ │ ├── zeit.svg │ │ │ ├── labelsync-red.svg │ │ │ ├── labelsync-green.svg │ │ │ └── labelsync.svg │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── sitemap.txt │ └── site.webmanifest ├── next.config.js ├── postcss.config.js ├── lib │ ├── utils.ts │ ├── scroll.ts │ ├── checkout.ts │ └── tasks.ts ├── next-env.d.ts ├── tailwind.config.js ├── constants.ts ├── styles │ └── index.css ├── pages │ ├── docs.tsx │ ├── terms.tsx │ ├── privacy.tsx │ ├── api │ │ ├── queue │ │ │ ├── list.ts │ │ │ └── add.ts │ │ └── checkout │ │ │ └── create.ts │ ├── _app.tsx │ ├── _document.tsx │ └── success.tsx ├── components │ ├── Redirect.tsx │ ├── LoadingIndicator.tsx │ ├── Button.tsx │ ├── Picker.tsx │ ├── CookieBanner.tsx │ ├── admin │ │ └── Header.tsx │ ├── SelectInput.tsx │ ├── TextInput.tsx │ ├── Toggle.tsx │ ├── Section.tsx │ ├── Tier.tsx │ ├── Table.tsx │ ├── Feature.tsx │ ├── Banner.tsx │ └── Testimonial.tsx ├── tsconfig.json ├── package.json └── README.md ├── packages ├── label-sync │ ├── README.md │ ├── tests │ │ ├── __fixtures__ │ │ │ └── package.json │ │ ├── presets.test.ts │ │ ├── fs.test.ts │ │ ├── __snapshots__ │ │ │ ├── presets.test.ts.snap │ │ │ └── make.test.ts.snap │ │ ├── combinations.test.ts │ │ └── make.test.ts │ ├── src │ │ ├── configurable.ts │ │ ├── index.ts │ │ ├── yaml.ts │ │ ├── utils.ts │ │ ├── make.ts │ │ ├── fs.ts │ │ ├── presets.ts │ │ └── generator.ts │ ├── tsconfig.json │ └── package.json ├── create-label-sync │ ├── README.md │ ├── tsconfig.json │ ├── package.json │ └── src │ │ └── utils.ts ├── config │ ├── tests │ │ ├── __fixtures__ │ │ │ └── configurations │ │ │ │ ├── empty.yml │ │ │ │ ├── invalid.yml │ │ │ │ ├── siblings.yml │ │ │ │ ├── multiple_aliases.yml │ │ │ │ ├── duplicate_aliases.yml │ │ │ │ ├── missing_siblings.yml │ │ │ │ ├── capitalization.yml │ │ │ │ ├── new.yml │ │ │ │ ├── basic.yml │ │ │ │ ├── free.yml │ │ │ │ ├── wildcard.yml │ │ │ │ └── anchors.yml │ │ └── parse.test.ts │ ├── package.json │ └── src │ │ ├── index.ts │ │ ├── utils.ts │ │ └── types.ts ├── queues │ ├── src │ │ ├── index.ts │ │ ├── lib │ │ │ └── utils.ts │ │ └── queues │ │ │ └── tasks.ts │ ├── package.json │ └── tests │ │ └── queue.test.ts └── database │ ├── src │ ├── index.ts │ └── lib │ │ ├── types.ts │ │ ├── dbsource.ts │ │ └── prisma.ts │ ├── prisma │ ├── migrations │ │ ├── migration_lock.toml │ │ ├── 20220719081227_add_github_installation_identifier │ │ │ └── migration.sql │ │ └── 20220424135120_create_installation │ │ │ └── migration.sql │ └── schema.prisma │ ├── tests │ ├── __fixtures__ │ │ └── utils.ts │ └── source.test.ts │ └── package.json ├── workers └── sync │ ├── tests │ ├── __fixtures__ │ │ ├── template │ │ │ ├── ignore │ │ │ │ └── ignored │ │ │ ├── README.md │ │ │ ├── src │ │ │ │ ├── folder │ │ │ │ │ └── nested.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── configurations │ │ │ ├── siblings.yml │ │ │ ├── new.yml │ │ │ └── wildcard.yml │ │ ├── labelsync.yml │ │ ├── queue.ts │ │ ├── utils.ts │ │ └── reports.ts │ ├── lib │ │ ├── reports.test.ts │ │ ├── utils.test.ts │ │ ├── templates.test.ts │ │ ├── filetree.test.ts │ │ └── github.test.ts │ └── processors │ │ ├── siblings.test.ts │ │ ├── unconfiguredLabels.test.ts │ │ └── onboarding.test.ts │ ├── src │ ├── data │ │ ├── maybe.ts │ │ └── dict.ts │ ├── index.ts │ ├── lib │ │ ├── env.ts │ │ ├── utils.ts │ │ ├── processor.ts │ │ ├── constants.ts │ │ ├── filetree.ts │ │ └── templates.ts │ └── processors │ │ ├── unconfiguredLabelsProcessor.ts │ │ ├── siblingsProcessor.ts │ │ ├── onboardingProcessor.ts │ │ ├── repositorySyncProcessor.ts │ │ └── organizationSyncProcessor.ts │ └── package.json ├── templates ├── yaml │ ├── .gitignore │ ├── README.md │ └── labelsync.yml └── typescript │ ├── .gitignore │ ├── labelsync.yml │ ├── tsconfig.json │ ├── package.json │ ├── labelsync.ts │ ├── repos │ ├── github.ts │ └── prisma.ts │ └── README.md ├── assets ├── logo.png ├── logo_tou.png ├── logo_large.png ├── thumbnail.png ├── examples │ ├── yaml.png │ ├── config.png │ ├── issues.png │ ├── siblings.png │ ├── pr-comment.png │ └── typescript.png ├── label-sync.sketch ├── thumbnail-blue.png ├── Twitter Profile Picture.png └── logo.svg ├── prettier.config.js ├── .gitignore ├── server ├── README.md ├── src │ ├── routes │ │ ├── status.route.ts │ │ └── stripe.events.ts │ ├── lib │ │ ├── sources.ts │ │ └── config.ts │ └── index.ts └── package.json ├── .yarnrc.yml ├── .changeset ├── config.json └── README.md ├── docker-compose.yml ├── .github ├── ISSUE_TEMPLATE │ ├── 3.Other.md │ ├── 2.Feature_request.md │ └── 1.Bug_report.md ├── FUNDING.yml ├── stale.yml └── workflows │ ├── test.yml │ └── release.yml ├── jest.config.js ├── package.json ├── tsconfig.json ├── LICENSE ├── render.yaml ├── README.md └── scripts └── build.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.13.0 -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | .next -------------------------------------------------------------------------------- /packages/label-sync/README.md: -------------------------------------------------------------------------------- 1 | # label-sync 2 | -------------------------------------------------------------------------------- /packages/create-label-sync/README.md: -------------------------------------------------------------------------------- 1 | # label-sync 2 | -------------------------------------------------------------------------------- /workers/sync/tests/__fixtures__/template/ignore/ignored: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/config/tests/__fixtures__/configurations/empty.yml: -------------------------------------------------------------------------------- 1 | repos: {} 2 | -------------------------------------------------------------------------------- /workers/sync/tests/__fixtures__/template/README.md: -------------------------------------------------------------------------------- 1 | # Template Fixture 2 | -------------------------------------------------------------------------------- /templates/yaml/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | 4 | .DS_Store 5 | *.log* -------------------------------------------------------------------------------- /templates/typescript/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | 4 | .DS_Store 5 | *.log* -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/logo.png -------------------------------------------------------------------------------- /assets/logo_tou.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/logo_tou.png -------------------------------------------------------------------------------- /packages/label-sync/tests/__fixtures__/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ls-fixture" 3 | } 4 | -------------------------------------------------------------------------------- /assets/logo_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/logo_large.png -------------------------------------------------------------------------------- /assets/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/thumbnail.png -------------------------------------------------------------------------------- /assets/examples/yaml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/examples/yaml.png -------------------------------------------------------------------------------- /assets/label-sync.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/label-sync.sketch -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/logo.png -------------------------------------------------------------------------------- /assets/examples/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/examples/config.png -------------------------------------------------------------------------------- /assets/examples/issues.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/examples/issues.png -------------------------------------------------------------------------------- /assets/thumbnail-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/thumbnail-blue.png -------------------------------------------------------------------------------- /assets/examples/siblings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/examples/siblings.png -------------------------------------------------------------------------------- /web/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | STRIPE_KEY: process.env.STRIPE_KEY, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /web/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/favicon-16x16.png -------------------------------------------------------------------------------- /web/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/favicon-32x32.png -------------------------------------------------------------------------------- /web/public/img/ads/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/ads/github.png -------------------------------------------------------------------------------- /web/public/img/ads/tasks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/ads/tasks.png -------------------------------------------------------------------------------- /web/public/img/ads/trello.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/ads/trello.png -------------------------------------------------------------------------------- /web/public/img/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/thumbnail.png -------------------------------------------------------------------------------- /assets/examples/pr-comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/examples/pr-comment.png -------------------------------------------------------------------------------- /assets/examples/typescript.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/examples/typescript.png -------------------------------------------------------------------------------- /web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /web/public/img/ads/workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/ads/workflow.png -------------------------------------------------------------------------------- /workers/sync/tests/__fixtures__/template/src/folder/nested.ts: -------------------------------------------------------------------------------- 1 | export function nested() { 2 | return true 3 | } 4 | -------------------------------------------------------------------------------- /assets/Twitter Profile Picture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/assets/Twitter Profile Picture.png -------------------------------------------------------------------------------- /templates/typescript/labelsync.yml: -------------------------------------------------------------------------------- 1 | {{! IF YOU SEE THIS LINE SOMETHING BROKE. PLEASE RETRY SCAFFOLDING }} 2 | repos: {} 3 | -------------------------------------------------------------------------------- /web/public/img/examples/issues.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/examples/issues.png -------------------------------------------------------------------------------- /web/public/img/examples/yaml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/examples/yaml.png -------------------------------------------------------------------------------- /web/public/img/testimonial/jan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/testimonial/jan.png -------------------------------------------------------------------------------- /packages/label-sync/src/configurable.ts: -------------------------------------------------------------------------------- 1 | export abstract class Configurable { 2 | abstract getConfiguration(): T 3 | } 4 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /web/public/img/examples/siblings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/examples/siblings.png -------------------------------------------------------------------------------- /web/public/img/testimonial/matic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/testimonial/matic.jpg -------------------------------------------------------------------------------- /workers/sync/tests/__fixtures__/template/src/index.ts: -------------------------------------------------------------------------------- 1 | function helloWorld() { 2 | return 'hi!' 3 | } 4 | 5 | helloWorld() 6 | -------------------------------------------------------------------------------- /web/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /web/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /web/public/img/examples/pr-comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/examples/pr-comment.png -------------------------------------------------------------------------------- /web/public/img/examples/typescript.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maticzav/label-sync/HEAD/web/public/img/examples/typescript.png -------------------------------------------------------------------------------- /packages/queues/src/index.ts: -------------------------------------------------------------------------------- 1 | export { TaskQueue } from './queues/tasks' 2 | export type { Task, ITaskQueue } from './queues/tasks' 3 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | } 7 | -------------------------------------------------------------------------------- /packages/label-sync/src/index.ts: -------------------------------------------------------------------------------- 1 | export { repo, label } from './generator' 2 | export { labelsync } from './make' 3 | export * from './presets' 4 | -------------------------------------------------------------------------------- /packages/database/src/index.ts: -------------------------------------------------------------------------------- 1 | export { InstallationsSource } from './sources/installations' 2 | export { Installation, Plan } from './lib/types' 3 | -------------------------------------------------------------------------------- /web/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Like UnionPick, but for Omit 3 | */ 4 | export type UnionOmit = T extends unknown ? Omit : never 5 | -------------------------------------------------------------------------------- /templates/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "rootDir": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/config/tests/__fixtures__/configurations/invalid.yml: -------------------------------------------------------------------------------- 1 | prisma-test-utils: 2 | strict: false 3 | labels: 4 | bug/0-needs-reproduction: 5 | color: ff0022 6 | -------------------------------------------------------------------------------- /packages/label-sync/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src/**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/queues/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@labelsync/queues", 3 | "private": true, 4 | "dependencies": { 5 | "pino": "^8.1.0", 6 | "redis": "^4.2.0" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /web/public/sitemap.txt: -------------------------------------------------------------------------------- 1 | https://label-sync.com 2 | https://label-sync.com/docs 3 | https://label-sync.com/subscribe 4 | https://label-sync.com/terms 5 | https://label-sync.com/privacy -------------------------------------------------------------------------------- /packages/create-label-sync/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src/**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/database/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /packages/database/prisma/migrations/20220719081227_add_github_installation_identifier/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Installation" ADD COLUMN "ghInstallationId" INTEGER, 3 | ALTER COLUMN "activated" DROP DEFAULT; 4 | -------------------------------------------------------------------------------- /web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /packages/database/tests/__fixtures__/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Resolves a promise after at least the given number of milliseconds. 3 | */ 4 | export function sleep(ms: number) { 5 | return new Promise((resolve) => setTimeout(resolve, ms)) 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | /public 4 | coverage 5 | 6 | .DS_Store 7 | *.log* 8 | .env* 9 | *.tsbuildinfo 10 | package-lock.json 11 | 12 | __tmp__ 13 | 14 | # yarn 15 | .yarn/cache 16 | .yarn/install-state.gz 17 | .yarn/build-state.yml -------------------------------------------------------------------------------- /packages/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@labelsync/config", 3 | "private": true, 4 | "dependencies": { 5 | "js-yaml": "^4.1.0", 6 | "zod": "^3.17.3" 7 | }, 8 | "devDependencies": { 9 | "@types/js-yaml": "^4.0.5" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /workers/sync/tests/__fixtures__/template/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "lib": ["dom", "es2017", "es2019"], 6 | "rootDir": "src", 7 | "outDir": "dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # server 2 | 3 | ```bash 4 | # dev and prod 5 | export DATABASE_URL="postgresql://prisma:prisma@localhost:5433/labelsync" 6 | export STRIPE_API_KEY="" 7 | # only production 8 | export DATADOG_APIKEY="" 9 | export STRIPE_ENDPOINT_SECRET="secret" 10 | ``` 11 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], 3 | theme: { 4 | container: { 5 | center: true, 6 | }, 7 | }, 8 | variants: {}, 9 | plugins: [require('@tailwindcss/forms')], 10 | } 11 | -------------------------------------------------------------------------------- /packages/config/src/index.ts: -------------------------------------------------------------------------------- 1 | export { LSCConfiguration, LSCRepositoryConfiguration, LSCRepository, LSCLabel } from './types' 2 | export { 3 | parseConfig, 4 | isConfigRepo, 5 | getPhysicalRepositories, 6 | normalizeColor, 7 | getLSConfigRepoName, 8 | LS_CONFIG_PATH, 9 | } from './parse' 10 | -------------------------------------------------------------------------------- /workers/sync/src/data/maybe.ts: -------------------------------------------------------------------------------- 1 | export type Maybe = A | null 2 | 3 | /** 4 | * Maps a Just into Just and null into null. 5 | * @param m 6 | * @param fn 7 | */ 8 | export function andThen(m: Maybe, fn: (a: A) => Maybe): Maybe { 9 | if (m === null) return null 10 | else return fn(m) 11 | } 12 | -------------------------------------------------------------------------------- /packages/label-sync/src/yaml.ts: -------------------------------------------------------------------------------- 1 | import yaml from 'js-yaml' 2 | import { Configurable } from './configurable' 3 | 4 | export abstract class YAML extends Configurable { 5 | getYAML(): string { 6 | const cleanJSON = JSON.parse(JSON.stringify(this.getConfiguration())) 7 | return yaml.dump(cleanJSON) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /workers/sync/tests/__fixtures__/configurations/siblings.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | changed: 3 | # needs sync 4 | config: 5 | removeUnconfiguredLabels: true 6 | labels: 7 | 'label/siblings': 8 | color: '000000' 9 | siblings: ['label/regular'] 10 | 'label/regular': 11 | color: '000000' 12 | -------------------------------------------------------------------------------- /packages/config/tests/__fixtures__/configurations/siblings.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | changed: 3 | # needs sync 4 | config: 5 | removeUnconfiguredLabels: true 6 | labels: 7 | 'label/siblings': 8 | color: '000000' 9 | siblings: ['label/regular'] 10 | 'label/regular': 11 | color: '000000' 12 | -------------------------------------------------------------------------------- /server/src/routes/status.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | 3 | import { Sources } from '../lib/sources' 4 | 5 | /** 6 | * Routes associated with Stripe Webhooks. 7 | */ 8 | export const status = (router: Router, sources: Sources) => { 9 | router.get('/healthz', async (req, res) => { 10 | res.send('OK') 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableInlineBuilds: true 2 | 3 | nodeLinker: node-modules 4 | 5 | plugins: 6 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 7 | spec: "@yarnpkg/plugin-interactive-tools" 8 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 9 | spec: "@yarnpkg/plugin-workspace-tools" 10 | 11 | yarnPath: .yarn/releases/yarn-3.2.0.cjs 12 | -------------------------------------------------------------------------------- /web/constants.ts: -------------------------------------------------------------------------------- 1 | export const NOTION_PRIVACY_TOS_URL = 2 | 'https://www.notion.so/LabelSync-s-Terms-of-Service-and-Privacy-Policy-cea6dddad9294eddb95a61fb361e5d2f' 3 | export const NOTION_DOCS_URL = 4 | 'https://www.notion.so/LabelSync-Docs-7c004894c8994ecfbd9fb619d2417210' 5 | export const NOTION_SUPPORT_URL = 6 | 'https://www.notion.so/Support-f5a3ed3183fb40ee8d7e5835100d2a5b' 7 | -------------------------------------------------------------------------------- /web/styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .underline-green { 6 | background-image: linear-gradient( 7 | 180deg, 8 | transparent 70%, 9 | rgba(198, 246, 213, 0.94) 0 10 | ); 11 | } 12 | 13 | .padding-2 { 14 | padding: 2px; 15 | } 16 | 17 | html, 18 | body, 19 | body > div { 20 | width: 100%; 21 | height: 100%; 22 | } 23 | -------------------------------------------------------------------------------- /packages/config/tests/__fixtures__/configurations/multiple_aliases.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | repo-one: 3 | labels: 4 | label/1: 5 | color: '#ff0022' 6 | alias: ['alias', 'two'] 7 | label/2: 8 | color: '#ff0022' 9 | label/3: 10 | color: '#ff0022' 11 | repo-two: 12 | labels: 13 | label/4: 14 | color: '#ff0022' 15 | alias: ['alias'] 16 | -------------------------------------------------------------------------------- /server/src/lib/sources.ts: -------------------------------------------------------------------------------- 1 | import { InstallationsSource } from '@labelsync/database' 2 | import { TaskQueue } from '@labelsync/queues' 3 | import pino from 'pino' 4 | import Stripe from 'stripe' 5 | 6 | /** 7 | * Datasources shared by many components of the app. 8 | */ 9 | export type Sources = { 10 | installations: InstallationsSource 11 | tasks: TaskQueue 12 | stripe: Stripe 13 | log: pino.Logger 14 | } 15 | -------------------------------------------------------------------------------- /web/pages/docs.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect } from 'components/Redirect' 2 | import React from 'react' 3 | 4 | import { NOTION_DOCS_URL } from '../constants' 5 | 6 | export default class Docs extends React.Component { 7 | componentDidMount() { 8 | if (window) { 9 | window.location.href = NOTION_DOCS_URL 10 | } 11 | } 12 | render() { 13 | return 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /web/pages/terms.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect } from 'components/Redirect' 2 | import React from 'react' 3 | 4 | import { NOTION_PRIVACY_TOS_URL } from '../constants' 5 | 6 | export default class Terms extends React.Component { 7 | componentDidMount() { 8 | if (window) { 9 | window.location.href = NOTION_PRIVACY_TOS_URL 10 | } 11 | } 12 | render() { 13 | return 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/database/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { Plan } from '@prisma/client' 2 | import { DateTime } from 'luxon' 3 | /** 4 | * Represents a single LabelSync installation in the database. 5 | */ 6 | export type Installation = { 7 | id: string 8 | 9 | account: string 10 | email: string | null 11 | ghInstallationId: number | null 12 | 13 | plan: Plan 14 | periodEndsAt: DateTime 15 | 16 | activated: boolean 17 | } 18 | 19 | export { Plan } 20 | -------------------------------------------------------------------------------- /web/pages/privacy.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect } from 'components/Redirect' 2 | import React from 'react' 3 | 4 | import { NOTION_PRIVACY_TOS_URL } from '../constants' 5 | 6 | export default class Privacy extends React.Component { 7 | componentDidMount() { 8 | if (window) { 9 | window.location.href = NOTION_PRIVACY_TOS_URL 10 | } 11 | } 12 | render() { 13 | return 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/config/tests/__fixtures__/configurations/duplicate_aliases.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | repo-one: 3 | labels: 4 | label/1: 5 | color: '#ff0022' 6 | alias: ['alias'] 7 | label/2: 8 | color: '#ff0022' 9 | label/3: 10 | color: '#ff0022' 11 | repo-two: 12 | labels: 13 | label/4: 14 | color: '#ff0022' 15 | alias: ['alias'] 16 | label/5: 17 | color: '#ff0022' 18 | alias: ['alias'] 19 | -------------------------------------------------------------------------------- /packages/config/tests/__fixtures__/configurations/missing_siblings.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | repo-one: 3 | labels: 4 | label/1: 5 | color: '#ff0022' 6 | siblings: ['label/6', 'label/7'] 7 | label/2: 8 | color: '#ff0022' 9 | label/3: 10 | color: '#ff0022' 11 | repo-two: 12 | labels: 13 | label/4: 14 | color: '#ff0022' 15 | siblings: ['label/8', 'label/5'] 16 | label/5: 17 | color: '#ff0022' 18 | -------------------------------------------------------------------------------- /packages/config/tests/__fixtures__/configurations/capitalization.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | lowercase: 3 | config: 4 | removeUnconfiguredLabels: true 5 | labels: 6 | label: 7 | color: '#ff0022' 8 | UPPERCASE: 9 | config: 10 | removeUnconfiguredLabels: true 11 | labels: 12 | label: 13 | color: '#ff0022' 14 | mIxEd: 15 | config: 16 | removeUnconfiguredLabels: true 17 | labels: 18 | label: 19 | color: '#ff0022' 20 | -------------------------------------------------------------------------------- /web/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Github LabelSync", 3 | "short_name": "LabelSync", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /packages/database/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@labelsync/database", 3 | "private": true, 4 | "scripts": { 5 | "postinstall": "prisma generate", 6 | "deploy": "prisma migrate deploy" 7 | }, 8 | "dependencies": { 9 | "@prisma/client": "^3.15.2", 10 | "cuid": "^2.1.8", 11 | "luxon": "^3.0.1", 12 | "p-limit": "3.1.0", 13 | "query-string": "^7.1.1" 14 | }, 15 | "devDependencies": { 16 | "@types/luxon": "^2.3.2", 17 | "prisma": "^3.15.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.0.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [ 11 | "@labelsync/config", 12 | "@labelsync/database", 13 | "@labelsync/queues", 14 | "@labelsync/sync", 15 | "@labelsync/server", 16 | "web" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | postgres: 4 | image: postgres:13 5 | restart: always 6 | ports: 7 | - '5432:5432' 8 | expose: 9 | - '5432' 10 | environment: 11 | POSTGRES_USER: prisma 12 | POSTGRES_PASSWORD: prisma 13 | POSTGRES_DB: labelsync 14 | volumes: 15 | - postgres:/var/lib/postgresql/data 16 | 17 | redis: 18 | image: redis:7 19 | restart: always 20 | ports: 21 | - '6379:6379' 22 | volumes: 23 | postgres: 24 | -------------------------------------------------------------------------------- /workers/sync/tests/__fixtures__/configurations/new.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | newrepo: 3 | config: 4 | removeUnconfiguredLabels: true 5 | labels: 6 | bug/0-needs-reproduction: 7 | color: '#ff0022' 8 | bug/1-has-reproduction: 9 | color: '#ff0022' 10 | description: Indicates that an issue has reproduction 11 | bug/2-bug-confirmed: 12 | color: red 13 | bug/3-fixing: 14 | color: 00ff22 15 | description: Indicates that we are working on fixing the issue. 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3.Other.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Other 3 | about: Ask a question about something oddly specific. 4 | --- 5 | 6 | # Explain your question 7 | 8 | - [ ] I have checked existing issue and found none that could answer my question. 9 | 10 | Describe your question in as much detail as possible. 11 | If possible include context, source of the problem, and the end goal you are trying to achieve. 12 | 13 | > I encourage you to try and make an issue with one of the other templates before submitting the general form. 14 | -------------------------------------------------------------------------------- /packages/config/tests/__fixtures__/configurations/new.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | newrepo: 3 | config: 4 | removeUnconfiguredLabels: true 5 | labels: 6 | bug/0-needs-reproduction: 7 | color: '#ff0022' 8 | bug/1-has-reproduction: 9 | color: '#ff0022' 10 | description: Indicates that an issue has reproduction 11 | bug/2-bug-confirmed: 12 | color: red 13 | bug/3-fixing: 14 | color: 00ff22 15 | description: Indicates that we are working on fixing the issue. 16 | -------------------------------------------------------------------------------- /packages/config/tests/__fixtures__/configurations/basic.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | prisma-test-utils: 3 | config: 4 | removeUnconfiguredLabels: true 5 | labels: 6 | bug/0-needs-reproduction: 7 | color: '#ff0022' 8 | bug/1-has-reproduction: 9 | color: '#ff0022' 10 | description: Indicates that an issue has reproduction 11 | bug/2-bug-confirmed: 12 | color: red 13 | bug/3-fixing: 14 | color: 00ff22 15 | description: Indicates that we are working on fixing the issue. 16 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /packages/label-sync/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "label-sync", 3 | "version": "3.5.63", 4 | "description": "LabelSync TypeScript configuration library.", 5 | "main": "dist/index.js", 6 | "repository": "https://github.com/matizav/label-sync", 7 | "author": "Matic Zavadlal ", 8 | "files": [ 9 | "dist" 10 | ], 11 | "dependencies": { 12 | "js-yaml": "^4.1.0" 13 | }, 14 | "devDependencies": { 15 | "@types/js-yaml": "4.0.5", 16 | "@types/node": "18.0.5" 17 | }, 18 | "license": "MIT" 19 | } 20 | -------------------------------------------------------------------------------- /workers/sync/tests/__fixtures__/labelsync.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | changed: 3 | # needs sync 4 | config: 5 | removeUnconfiguredLabels: true 6 | labels: 7 | create/new: 8 | color: '000000' 9 | alias/new: 10 | color: '000000' 11 | alias: ['alias/old:1', 'alias/old:2'] 12 | update/color: 13 | color: 'ffffff' 14 | # remove: 15 | # color: "000000" 16 | 17 | UNCHANGED: 18 | # configuration already matches 19 | labels: 20 | 'label-a': 21 | color: '000000' 22 | -------------------------------------------------------------------------------- /web/components/Redirect.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import React from 'react' 3 | 4 | /** 5 | * A placeholder component that may be showed on a page that redirects 6 | * somewhere else. 7 | */ 8 | export function Redirect({ title }: { title: string }) { 9 | return ( 10 |
11 | 12 | {title} 13 | 14 | LabelSync Logo 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /templates/yaml/README.md: -------------------------------------------------------------------------------- 1 | # LabelSync Yaml configuration. 2 | 3 | Hey there! Welcome to LabelSync. We have scaffolded the configuration file for you. Check it out! 4 | 5 | ### Setting up LabelSync 6 | 7 | {{! we use Handlebars to personalise tutorial. }} 8 | 9 | 1. Create a repository on Github and name it `{{repository}}`. 10 | 1. Commit your configuration (this repository) to Github. 11 | 1. Head over to [LabelSync Manager Github Application](https://github.com/apps/labelsync-manager) and make sure that you install it in all repositories that you have configured. 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require('ts-jest') 2 | const { compilerOptions } = require('./tsconfig') 3 | 4 | module.exports = { 5 | roots: ['/packages/', '/server/', '/workers/'], 6 | testEnvironment: 'node', 7 | transform: { 8 | '^.+\\.tsx?$': 'ts-jest', 9 | }, 10 | testRegex: '(/tests/.*|(\\.|/)test)\\.tsx?$', 11 | testPathIgnorePatterns: ['/node_modules/', '/__fixtures__/'], 12 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 13 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/' }), 14 | } 15 | -------------------------------------------------------------------------------- /packages/database/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource postgresql { 2 | url = env("DATABASE_URL") 3 | provider = "postgresql" 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | model Installation { 11 | id String @id @default(cuid()) 12 | createdAt DateTime @default(now()) 13 | 14 | // Info 15 | account String @unique 16 | ghInstallationId Int? 17 | email String? 18 | 19 | plan Plan 20 | periodEndsAt DateTime 21 | 22 | // Manager properties 23 | activated Boolean 24 | } 25 | 26 | enum Plan { 27 | FREE 28 | PAID 29 | } 30 | -------------------------------------------------------------------------------- /packages/database/src/lib/dbsource.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | import { DateTime } from 'luxon' 3 | 4 | import { getUnsafeGlobalClient } from './prisma' 5 | import { Source } from './source' 6 | 7 | export abstract class DatabaseSource extends Source { 8 | /** 9 | * Reference to the PrismaClient that may be used to connect to the database. 10 | */ 11 | protected prisma: () => PrismaClient 12 | 13 | constructor(concurrency: number, timeout: number) { 14 | super(concurrency, timeout) 15 | this.prisma = getUnsafeGlobalClient 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /workers/sync/tests/__fixtures__/queue.ts: -------------------------------------------------------------------------------- 1 | import { ITaskQueue, Task } from '@labelsync/queues' 2 | 3 | export class MockTaskQueue implements ITaskQueue { 4 | private queue: Task[] = [] 5 | 6 | async push(task: Omit): Promise { 7 | const id = Math.random().toString(36).substring(2, 15) 8 | this.queue.push({ id, ...task } as Task) 9 | return id 10 | } 11 | 12 | async process(fn: (task: Task) => Promise): Promise { 13 | let task: Task | undefined = undefined 14 | 15 | while ((task = this.queue.shift())) { 16 | await fn(task) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/label-sync/tests/presets.test.ts: -------------------------------------------------------------------------------- 1 | import * as ls from '../src' 2 | 3 | describe('presets:', () => { 4 | test('works', () => { 5 | const repo = ls.repo({ 6 | config: { 7 | removeUnconfiguredLabels: true, 8 | }, 9 | labels: [ 10 | ls.type('bug', '#ff0022'), 11 | ls.note('bug', '#ff0022'), 12 | ls.impact('bug', '#ff0022'), 13 | ls.effort('bug', '#ff0022'), 14 | ls.needs('bug', '#ff0022'), 15 | ls.scope('bug', '#ff0022'), 16 | ls.community('bug', '#ff0022'), 17 | ], 18 | }) 19 | 20 | expect(repo).toMatchSnapshot() 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /packages/label-sync/src/utils.ts: -------------------------------------------------------------------------------- 1 | export type Dict = { [key: string]: T } 2 | export type Maybe = T | null 3 | 4 | /** 5 | * Creates a fallback default value. 6 | * @param fallback 7 | * @param value 8 | */ 9 | export function withDefault(fallback: T, value: T | undefined): T { 10 | if (value) return value 11 | else return fallback 12 | } 13 | 14 | /** 15 | * Maps entries of an object. 16 | * @param m 17 | * @param fn 18 | */ 19 | export function mapEntries(m: Dict, fn: (v: T) => V): Dict { 20 | return Object.keys(m).reduce>((acc, key) => { 21 | return { ...acc, [key]: fn(m[key]) } 22 | }, {}) 23 | } 24 | -------------------------------------------------------------------------------- /web/pages/api/queue/list.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | import { users, requireAuth, RequireAuthProp } from '@clerk/nextjs/api' 3 | 4 | import * as tasks from 'lib/tasks' 5 | 6 | export default requireAuth(async (req: RequireAuthProp, res: NextApiResponse) => { 7 | const user = await users.getUser(req.auth.userId) 8 | console.log(user) 9 | 10 | if (!user.publicMetadata['is_admin']) { 11 | res.status(403).json({ message: 'Unauthorized, You Need Admin Privliges!' }) 12 | return 13 | } 14 | 15 | const list = await tasks.shared.list() 16 | 17 | res.status(200).json({ list }) 18 | }) 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: maticzav 3 | patreon: # Replace with a single Patreon username 4 | open_collective: # Open Collective 5 | ko_fi: # Replace with a single Ko-fi username 6 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 7 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 8 | liberapay: # Replace with a single Liberapay username 9 | issuehunt: # Replace with a single IssueHunt username 10 | otechie: # Replace with a single Otechie username 11 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 12 | -------------------------------------------------------------------------------- /packages/database/prisma/migrations/20220424135120_create_installation/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Plan" AS ENUM ('FREE', 'PAID'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "Installation" ( 6 | "id" TEXT NOT NULL, 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "account" TEXT NOT NULL, 9 | "email" TEXT, 10 | "plan" "Plan" NOT NULL, 11 | "periodEndsAt" TIMESTAMP(3) NOT NULL, 12 | "activated" BOOLEAN NOT NULL DEFAULT false, 13 | 14 | CONSTRAINT "Installation_pkey" PRIMARY KEY ("id") 15 | ); 16 | 17 | -- CreateIndex 18 | CREATE UNIQUE INDEX "Installation_account_key" ON "Installation"("account"); 19 | -------------------------------------------------------------------------------- /packages/config/tests/__fixtures__/configurations/free.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | prisma-test-utils: 3 | labels: 4 | bug/0-needs-reproduction: 5 | color: '#ff0022' 6 | label-sync: 7 | labels: 8 | bug/0-needs-reproduction: 9 | color: '#ff0022' 10 | unchanged: 11 | labels: 12 | bug/0-needs-reproduction: 13 | color: '#ff0022' 14 | notquitethere: 15 | labels: 16 | bug/0-needs-reproduction: 17 | color: '#ff0022' 18 | last-one: 19 | labels: 20 | bug/0-needs-reproduction: 21 | color: '#ff0022' 22 | overthetop: 23 | labels: 24 | bug/0-needs-reproduction: 25 | color: '#ff0022' 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2.Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Create a feature request for Label Sync. 4 | --- 5 | 6 | # Feature request 7 | 8 | ## Is your feature request related to a problem? Please describe 9 | 10 | A clear and concise description of what you want and what your use case is. 11 | 12 | ## Describe the solution you propose 13 | 14 | A clear and concise description of how you would solve the problem. 15 | 16 | ## Describe alternatives you've considered 17 | 18 | A collection of alternative solutions or features you've considered. 19 | 20 | ## Additional context 21 | 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /templates/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "{{repository}}", 3 | "private": true, 4 | "main": "index.js", 5 | "scripts": { 6 | "make": "ts-node labelsync.ts" 7 | }, 8 | "dependencies": { 9 | "husky": "latest", 10 | "label-sync": "latest" 11 | }, 12 | "devDependencies": { 13 | "prettier": "*", 14 | "ts-node": "*", 15 | "typescript": "*" 16 | }, 17 | "husky": { 18 | "hooks": { 19 | "pre-commit": "yarn -s make && git add labelsync.yml" 20 | } 21 | }, 22 | "prettier": { 23 | "semi": false, 24 | "singleQuote": true, 25 | "trailingComma": "all", 26 | "printWidth": 100 27 | }, 28 | "license": "MIT" 29 | } 30 | -------------------------------------------------------------------------------- /packages/config/src/utils.ts: -------------------------------------------------------------------------------- 1 | export type Dict = { [key: string]: T } 2 | 3 | /** 4 | * Maps entries in an object. 5 | * @param m 6 | * @param fn 7 | */ 8 | export function mapEntries(m: Dict, fn: (v: T, key: string) => V): Dict { 9 | const mapped = Object.keys(m).map((key) => { 10 | return [key, fn(m[key], key)] 11 | }) 12 | return Object.fromEntries(mapped) 13 | } 14 | 15 | /** 16 | * Maps keys of an object. 17 | * @param m 18 | * @param fn 19 | */ 20 | export function mapKeys(m: Dict, fn: (key: string, v: T) => string): Dict { 21 | const mapped = Object.keys(m).map((key) => { 22 | return [fn(key, m[key]), m[key]] 23 | }) 24 | return Object.fromEntries(mapped) 25 | } 26 | -------------------------------------------------------------------------------- /web/components/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | /** 4 | * Component that may be used as a loading indicator. 5 | */ 6 | export function LoadingIndicator() { 7 | return ( 8 | 14 | 15 | 16 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /templates/typescript/labelsync.ts: -------------------------------------------------------------------------------- 1 | {{! we use Handlebars to personalise initial configuration. }} 2 | {{! IF YOU SEE THIS LINE SOMETHING BROKE. PLEASE RETRY SCAFFOLDING }} 3 | 4 | import { labelsync, repo } from 'label-sync' 5 | 6 | /* Repository */ 7 | import { prisma } from './repos/prisma' 8 | import { github } from './repos/github' 9 | 10 | /* Config */ 11 | labelsync({ 12 | repos: { 13 | /* Check presets in the repos folder. */ 14 | // prisma, 15 | // github, 16 | /* Personalized repositories */ 17 | {{#each repositories}} 18 | {{#with this}} 19 | "{{name}}": repo({ 20 | config: { 21 | removeUnconfiguredLabels: false 22 | }, 23 | labels: [] 24 | }), 25 | {{/with}} 26 | {{/each}} 27 | }, 28 | }) 29 | -------------------------------------------------------------------------------- /workers/sync/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@labelsync/sync", 3 | "private": true, 4 | "scripts": { 5 | "start": "yarn g:ts-node src/index.ts" 6 | }, 7 | "dependencies": { 8 | "@labelsync/config": "workspace:*", 9 | "@labelsync/queues": "workspace:*", 10 | "@octokit/auth-app": "^4.0.4", 11 | "@octokit/core": "^4.0.4", 12 | "@sentry/node": "^7.7.0", 13 | "handlebars": "^4.7.7", 14 | "lodash": "^4.17.21", 15 | "luxon": "^3.0.1", 16 | "multilines": "^1.0.3", 17 | "pino": "^7.11.0", 18 | "prettier": "^2.7.1", 19 | "query-string": "^7.1.1" 20 | }, 21 | "devDependencies": { 22 | "@octokit/types": "^6.40.0", 23 | "@types/lodash": "^4.14.182", 24 | "@types/luxon": "^2.3.2", 25 | "@types/prettier": "^2.6.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/create-label-sync/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-label-sync", 3 | "description": "LabelSync scaffolding tool.", 4 | "version": "2.4.74", 5 | "main": "dist/index.js", 6 | "repository": "https://github.com/maticzav/label-sync.git", 7 | "bin": "dist/index.js", 8 | "files": [ 9 | "dist" 10 | ], 11 | "dependencies": { 12 | "chalk": "^4.1.2", 13 | "creato": "^1.1.4", 14 | "handlebars": "^4.7.7", 15 | "inquirer": "^7.3.3", 16 | "meow": "^9.0.0", 17 | "multilines": "^1.0.3", 18 | "ora": "^5.2.0", 19 | "prettier": "^2.7.1" 20 | }, 21 | "devDependencies": { 22 | "@types/inquirer": "7.3.3", 23 | "@types/meow": "5.0.0", 24 | "@types/node": "18.0.5", 25 | "@types/prettier": "2.6.3" 26 | }, 27 | "license": "MIT" 28 | } 29 | -------------------------------------------------------------------------------- /web/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from 'react' 2 | 3 | export interface Button { 4 | onClick?: () => void 5 | disabled?: boolean 6 | } 7 | 8 | /** 9 | * Simple Button component. 10 | */ 11 | export function Button(props: PropsWithChildren 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /web/components/Picker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export interface Picker { 4 | options: T[] 5 | value: T 6 | onChange: (val: T) => void 7 | } 8 | 9 | export default function Picker(props: Picker) { 10 | return ( 11 |
12 | {props.options.map((option) => { 13 | return ( 14 | 15 | {option} 16 | 17 | ) 18 | })} 19 | 20 | Yearly 21 | 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /workers/sync/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node' 2 | 3 | import { config } from './lib/env' 4 | import { Worker } from './worker' 5 | 6 | Sentry.init({ 7 | dsn: config.sentryDSN, 8 | serverName: 'worker/sync', 9 | environment: config.prod ? 'production' : 'development', 10 | // Set tracesSampleRate to 1.0 to capture 100% 11 | // of transactions for performance monitoring. 12 | // We recommend adjusting this value in production 13 | tracesSampleRate: 1, 14 | integrations: [], 15 | }) 16 | 17 | const worker = new Worker() 18 | 19 | worker 20 | .start() 21 | .then(() => { 22 | console.log(`Started watching... `) 23 | }) 24 | .catch((err) => { 25 | console.error(err) 26 | }) 27 | 28 | process.on('SIGINT', () => { 29 | worker.stop() 30 | process.exit(0) 31 | }) 32 | -------------------------------------------------------------------------------- /packages/config/tests/__fixtures__/configurations/wildcard.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | '*': 3 | config: 4 | removeUnconfiguredLabels: true 5 | labels: 6 | bug/0-needs-reproduction: 7 | color: '#ff0022' 8 | bug/1-has-reproduction: 9 | color: '#ff0022' 10 | description: Indicates that an issue has reproduction 11 | bug/2-bug-confirmed: 12 | color: red 13 | bug/3-fixing: 14 | color: 00ff22 15 | description: Indicates that we are working on fixing the issue. 16 | prisma-test-utils: 17 | config: 18 | removeUnconfiguredLabels: true 19 | labels: 20 | bug/0-needs-reproduction: 21 | color: '#ff0022' 22 | bug/1-has-reproduction: 23 | color: '#ff0022' 24 | description: Indicates that an issue has reproduction 25 | -------------------------------------------------------------------------------- /packages/label-sync/tests/fs.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | import { findUp, findFolderUp, findFileUp } from '../src/fs' 4 | 5 | describe('fs', () => { 6 | test('findUp finds prettier.config.js', async () => { 7 | const rdmPth = findUp(__dirname, (p) => path.basename(p) === 'prettier.config.js') 8 | 9 | expect(rdmPth).toEqual(path.resolve(__dirname, '../../../')) 10 | }) 11 | 12 | test('findDirUp finds scripts', async () => { 13 | const rdmPth = findFolderUp(__dirname, 'scripts') 14 | 15 | expect(rdmPth).toEqual(path.resolve(__dirname, '../../../')) 16 | }) 17 | 18 | test('findFileUp finds prettier.config.js', async () => { 19 | const rdmPth = findFileUp(__dirname, 'prettier.config.js') 20 | 21 | expect(rdmPth).toEqual(path.resolve(__dirname, '../../../')) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /workers/sync/tests/__fixtures__/configurations/wildcard.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | '*': 3 | config: 4 | removeUnconfiguredLabels: true 5 | labels: 6 | bug/0-needs-reproduction: 7 | color: '#ff0022' 8 | bug/1-has-reproduction: 9 | color: '#ff0022' 10 | description: Indicates that an issue has reproduction 11 | bug/2-bug-confirmed: 12 | color: red 13 | bug/3-fixing: 14 | color: 00ff22 15 | description: Indicates that we are working on fixing the issue. 16 | prisma-test-utils: 17 | config: 18 | removeUnconfiguredLabels: true 19 | labels: 20 | bug/0-needs-reproduction: 21 | color: '#ff0022' 22 | bug/1-has-reproduction: 23 | color: '#ff0022' 24 | description: Indicates that an issue has reproduction 25 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 45 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 10 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - reproduction-available 8 | - help-wanted 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | # Limit to only `issues` 19 | only: issues 20 | -------------------------------------------------------------------------------- /workers/sync/src/lib/env.ts: -------------------------------------------------------------------------------- 1 | type Config = { 2 | prod: boolean 3 | ghAppId: string 4 | ghPrivateKey: string 5 | 6 | redisUrl: string 7 | sentryDSN: string 8 | } 9 | 10 | // Environments 11 | 12 | const base = {} 13 | 14 | const prod = { 15 | prod: true, 16 | ghAppId: process.env.GH_APP_ID!, 17 | ghPrivateKey: process.env.GH_PRIVATE_KEY!, 18 | 19 | redisUrl: process.env.REDIS_URL!, 20 | sentryDSN: process.env.SENTRY_DSN!, 21 | } 22 | 23 | const dev = { 24 | prod: false, 25 | ghAppId: '', 26 | ghPrivateKey: '', 27 | 28 | redisUrl: 'redis://localhost:6379', 29 | sentryDSN: '', 30 | } 31 | 32 | const enviroment = process.env.NODE_ENV 33 | 34 | /** 35 | * Configuration credentials for the worker instance. 36 | */ 37 | export const config: Config = Object.assign(base, enviroment === 'production' ? prod : dev) 38 | -------------------------------------------------------------------------------- /workers/sync/tests/__fixtures__/utils.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'os' 2 | 3 | /** 4 | * This is a collection of utility functions for tests. 5 | */ 6 | 7 | /** 8 | * Converts winston's log file to JSON array. 9 | */ 10 | export function logToJSON(file: string): object[] { 11 | const lines = file 12 | .split(EOL) 13 | .filter((line) => Boolean(line)) 14 | .join(',') 15 | const jsonfile = `[${lines}]` 16 | 17 | return JSON.parse(jsonfile) 18 | } 19 | 20 | /** 21 | * Removes date fields from the logs. Useful for snapshot generation. 22 | */ 23 | export function removeLogsDateFields(log: any): any { 24 | delete log['timestamp'] 25 | if (typeof log['meta'] === 'string') { 26 | log.meta = JSON.parse(log.meta) 27 | } 28 | if (log['meta']) { 29 | log.meta['periodEndsAt'] = 'periodEndsAt' 30 | } 31 | return log 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "labelsync", 3 | "private": true, 4 | "scripts": { 5 | "g:ts-node": "cd $INIT_CWD && ts-node", 6 | "build": "node scripts/build.js", 7 | "release": "yarn build && changeset publish", 8 | "test": "NODE_ENV=test LOG_LEVEL=fatal jest" 9 | }, 10 | "dependencies": { 11 | "@changesets/cli": "^2.23.2", 12 | "@types/node": "^18.0.5", 13 | "chalk": "4.1.2", 14 | "execa": "4.1.0", 15 | "jest": "^28.1.3", 16 | "jest-serializer-ansi": "1.0.3", 17 | "prettier": "^2.7.1", 18 | "pretty-quick": "^3.1.3", 19 | "ts-jest": "^28.0.7", 20 | "ts-node": "^10.9.1", 21 | "tsconfig-paths": "^4.0.0", 22 | "typescript": "^4.7.4" 23 | }, 24 | "workspaces": [ 25 | "packages/*", 26 | "server", 27 | "web", 28 | "workers/*" 29 | ], 30 | "packageManager": "yarn@3.2.0" 31 | } 32 | -------------------------------------------------------------------------------- /web/components/CookieBanner.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Link from 'next/link' 3 | 4 | import Banner from './Banner' 5 | 6 | export function CookieBanner(props: { onAccept?: () => void }) { 7 | const [accepted, setAccepted] = useState(false) 8 | 9 | if (accepted) { 10 | return null 11 | } 12 | 13 | return ( 14 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "module": "esnext", 6 | "lib": ["dom", "es2017", "es2019"], 7 | "declaration": true, 8 | "declarationMap": true, 9 | "strict": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "moduleResolution": "node", 13 | "esModuleInterop": true, 14 | "resolveJsonModule": true, 15 | "skipDefaultLibCheck": true, 16 | "skipLibCheck": true, 17 | "jsx": "preserve", 18 | "forceConsistentCasingInFileNames": true, 19 | "noEmit": true, 20 | "isolatedModules": true, 21 | "target": "es5", 22 | "allowJs": false, 23 | "incremental": true, 24 | "paths": { 25 | "@labelsync/queues": ["../packages/queues/src/index.ts"] 26 | } 27 | }, 28 | "references": [{ "path": "../" }], 29 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 30 | "exclude": ["node_modules"] 31 | } 32 | -------------------------------------------------------------------------------- /workers/sync/src/data/dict.ts: -------------------------------------------------------------------------------- 1 | export type Dict = { [key: string]: T } 2 | 3 | /** 4 | * Maps entries in an object. 5 | */ 6 | export function mapEntries(m: Dict, fn: (v: T, key: string) => V): Dict { 7 | return Object.fromEntries( 8 | Object.keys(m).map((key) => { 9 | return [key, fn(m[key], key)] 10 | }), 11 | ) 12 | } 13 | 14 | /** 15 | * Maps entries in an object. 16 | */ 17 | export async function mapEntriesAsync(m: Dict, fn: (v: T, key: string) => Promise): Promise> { 18 | const entries = await Promise.all(Object.keys(m).map((key) => fn(m[key], key).then((value) => [key, value]))) 19 | 20 | return Object.fromEntries(entries) 21 | } 22 | 23 | /** 24 | * Maps keys of an object. 25 | */ 26 | export function mapKeys(m: Dict, fn: (key: string, v: T) => string): Dict { 27 | return Object.fromEntries( 28 | Object.keys(m).map((key) => { 29 | return [fn(key, m[key]), m[key]] 30 | }), 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /web/components/admin/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { UserButton } from '@clerk/nextjs' 3 | 4 | /** 5 | * Component that should be used on authenticated pages as a header. 6 | */ 7 | export function Header() { 8 | return ( 9 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /web/lib/scroll.ts: -------------------------------------------------------------------------------- 1 | export function scrollToId(id: string) { 2 | const duration = 1500 3 | const fps = 100 4 | const chunk = duration / fps 5 | 6 | let time = duration // current frame 7 | const targetElement = document.getElementById(id) 8 | 9 | /* Report unknown element. */ 10 | if (!targetElement) { 11 | throw new Error('No element with id: ' + id) 12 | } 13 | 14 | const targetOffset = targetElement.offsetTop + 10 15 | 16 | var frame = setInterval(() => { 17 | /* Position change each milisecond. */ 18 | var currentOffset = window.pageYOffset 19 | var diffOffset = targetOffset - currentOffset 20 | 21 | var intermediateOffset = currentOffset + (1 - time / duration) * diffOffset 22 | 23 | window.scrollTo(window.pageXOffset, intermediateOffset) 24 | 25 | /* Count time */ 26 | time = time - chunk 27 | 28 | /* Interval reset once it's over of very close. */ 29 | if (time < 100 || Math.abs(diffOffset) < 10) { 30 | clearInterval(frame) 31 | } 32 | }, chunk) 33 | } 34 | -------------------------------------------------------------------------------- /workers/sync/tests/lib/reports.test.ts: -------------------------------------------------------------------------------- 1 | import { generateHumanReadablePRReport, generateHumanReadableCommitReport } from '../../src/lib/reports' 2 | import * as reports from '../__fixtures__/reports' 3 | 4 | describe('reports', () => { 5 | for (const report of [reports.strict, reports.failure, reports.nonstrict, reports.unchanged, reports.unconfigured]) { 6 | test('correcly generates a PR report', () => { 7 | const message = generateHumanReadablePRReport([report]) 8 | expect(message).toMatchSnapshot() 9 | }) 10 | 11 | test('correctly generates a commit report', () => { 12 | const message = generateHumanReadableCommitReport([report]) 13 | expect(message).toMatchSnapshot() 14 | }) 15 | } 16 | 17 | test('correctly combines multiple reports', () => { 18 | const report = generateHumanReadablePRReport([ 19 | reports.strict, 20 | reports.nonstrict, 21 | reports.failure, 22 | reports.unchanged, 23 | ]) 24 | 25 | expect(report).toMatchSnapshot() 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /web/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { ClerkProvider, RedirectToSignIn, SignedIn, SignedOut } from '@clerk/nextjs' 2 | import { AppProps } from 'next/app' 3 | import React from 'react' 4 | import { Toaster } from 'react-hot-toast' 5 | 6 | import '../styles/index.css' 7 | import Head from 'next/head' 8 | 9 | const PRIVATE_PAGES = ['/admin', '/admin/queue'] 10 | 11 | export default function MyApp({ Component, pageProps, router }: AppProps) { 12 | const isPublicPage = !PRIVATE_PAGES.includes(router.pathname) 13 | 14 | if (isPublicPage) { 15 | return ( 16 | <> 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /web/components/SelectInput.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Lets you pick one of the options. 3 | */ 4 | export const SelectInput = ({ 5 | name, 6 | label, 7 | value, 8 | options, 9 | onChange, 10 | }: { 11 | name: string 12 | label: string 13 | value: T 14 | options: { label: string; value: T }[] 15 | onChange: (val: T) => void 16 | }) => { 17 | return ( 18 |
19 | 22 | 34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /packages/database/src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | import qs from 'query-string' 3 | 4 | // We use a "global" PrismaClient instance to make sure we don't 5 | // create multiple instances of the client and pollute the connection pool. 6 | let client: PrismaClient | null = null 7 | 8 | /** 9 | * Returns a PrismaClient instance that shares resources globally. 10 | */ 11 | export const getUnsafeGlobalClient = (): PrismaClient => { 12 | if (client === null) { 13 | // We manually set the connection_limit to avoid congestion. 14 | const { url, query } = qs.parseUrl(process.env.DATABASE_URL!) 15 | const normalizedURL = qs.stringifyUrl({ 16 | url, 17 | query: { 18 | // https://render.com/docs/databases#connecting-to-your-database 19 | connection_limit: 5, 20 | pool_timeout: 30, 21 | ...query, 22 | }, 23 | }) 24 | 25 | client = new PrismaClient({ 26 | datasources: { 27 | postgresql: { url: normalizedURL }, 28 | }, 29 | }) 30 | } 31 | 32 | return client 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "dist", 5 | 6 | "target": "es2019", 7 | "module": "commonjs", 8 | "lib": ["es2017", "es2019"], 9 | 10 | "strict": true, 11 | "composite": true, 12 | "declaration": true, 13 | 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | 17 | "moduleResolution": "node", 18 | "esModuleInterop": true, 19 | "resolveJsonModule": true, 20 | "skipDefaultLibCheck": true, 21 | "skipLibCheck": true, 22 | 23 | "paths": { 24 | "@labelsync/config": ["packages/config/src/index.ts"], 25 | "@labelsync/database": ["packages/database/src/index.ts"], 26 | "@labelsync/queues": ["packages/queues/src/index.ts"] 27 | } 28 | }, 29 | "include": ["packages", "workers", "server"], 30 | "exclude": ["**/dist/**/*", "**/templates/**/*", "**/web/**/*"], 31 | "ts-node": { 32 | "compilerOptions": { 33 | "target": "ES2017", 34 | "module": "commonjs" 35 | }, 36 | 37 | "require": ["tsconfig-paths/register"] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /workers/sync/tests/lib/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { amap, famap, select } from '../../src/lib/utils' 2 | 3 | describe('utils', () => { 4 | test('select', () => { 5 | const object = { 6 | a: 1, 7 | b: 2, 8 | c: 3, 9 | d: 'foo', 10 | } 11 | 12 | const trimmed = select(object, ['a', 'd']) 13 | 14 | expect(trimmed).toEqual({ a: 1, d: 'foo' }) 15 | }) 16 | 17 | test('amap', async () => { 18 | const xs = [1, 2, 3] 19 | const fn = async (x: number) => { 20 | await new Promise((resolve) => setTimeout(resolve, x * 100)) 21 | return x * 2 22 | } 23 | const rs = await amap(xs, fn) 24 | 25 | expect(rs).toEqual([2, 4, 6]) 26 | }) 27 | 28 | test('famap', async () => { 29 | const xs = [null, 1, 2, 3, null] 30 | const fn = async (x: number | null) => { 31 | if (x == null) { 32 | return null 33 | } 34 | 35 | await new Promise((resolve) => setTimeout(resolve, x * 100)) 36 | 37 | return x * 2 38 | } 39 | const rs = await famap(xs, fn) 40 | 41 | expect(rs).toEqual([2, 4, 6]) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /packages/queues/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Distributive Pick - does not collapse unions into a "shared type" only to 3 | * run Pick on it. Instead, it "picks" from each union item separately. 4 | * 5 | * See https://github.com/klimashkin/css-modules-theme/pull/8 6 | * 7 | * Example: 8 | * Pick<{ type: "pick" } | { type: "omit" }, "type"> 9 | * produces { type: "pick" | "omit" } 10 | * 11 | * UnionPick<{ type: "pick" } | { type: "omit" }, "type"> 12 | * produces { type: "pick" } | { type: "omit" } 13 | */ 14 | export type UnionPick = T extends unknown ? Pick : never 15 | 16 | /** 17 | * Like UnionPick, but for Omit 18 | */ 19 | export type UnionOmit = T extends unknown ? Omit : never 20 | 21 | /** 22 | * Lets you distribute a shared type over each union separately. 23 | */ 24 | export type UnionShare = T extends unknown ? T & V : never 25 | 26 | /** 27 | * Returns a promise that resolves after given number of milliseconds. 28 | */ 29 | export function sleep(ms: number) { 30 | return new Promise((resolve) => setTimeout(resolve, ms)) 31 | } 32 | -------------------------------------------------------------------------------- /workers/sync/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Selects desired fields from an object. 3 | */ 4 | export function select(object: T, keys: K[]): Pick { 5 | const result: any = {} 6 | for (const key of keys) { 7 | result[key] = object[key] 8 | } 9 | return result 10 | } 11 | 12 | /** 13 | * Makes a type check that is only valid when all cases of a switch 14 | * statement have been convered. 15 | */ 16 | export class ExhaustiveSwitchCheck extends Error { 17 | constructor(val: never) { 18 | super(`Unreachable case: ${JSON.stringify(val)}`) 19 | } 20 | } 21 | 22 | /** 23 | * A function that asynchronously maps an array. 24 | */ 25 | export function amap(xs: T[], fn: (x: T) => Promise): Promise { 26 | return Promise.all(xs.map(fn)) 27 | } 28 | 29 | /** 30 | * A function that asynchronously maps an array and filters out failed (null) values. 31 | */ 32 | export function famap(xs: T[], fn: (x: T) => Promise): Promise { 33 | return Promise.all(xs.map(fn)).then((rs) => rs.filter(isNotNull)) 34 | } 35 | 36 | function isNotNull(x: T | null): x is T { 37 | return x != null 38 | } 39 | -------------------------------------------------------------------------------- /web/lib/checkout.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe' 2 | 3 | export const PLAN_IDS = { 4 | MONTHLY: process.env.STRIPE_MONTHLY_ID!, 5 | ANNUALLY: process.env.STRIPE_ANNUAL_ID!, 6 | } 7 | 8 | /** 9 | * Async function that returns information about the price of a plan. 10 | */ 11 | export async function getPlanPrice(id: keyof typeof PLAN_IDS): Promise<{ monthly_amount: number }> { 12 | const plan_id = PLAN_IDS[id] 13 | const price = await stripe.prices.retrieve(plan_id) 14 | 15 | const amount = (price.unit_amount ?? 0) / 100 16 | const cadence = getNoOfMonths(price.recurring?.interval_count!, price.recurring?.interval!) 17 | 18 | return { 19 | monthly_amount: amount / cadence, 20 | } 21 | } 22 | 23 | function getNoOfMonths(n: number, cadence: Stripe.Plan.Interval): number { 24 | switch (cadence) { 25 | case 'month': 26 | return n 27 | case 'year': 28 | return n * 12 29 | case 'week': 30 | return 1 31 | case 'day': 32 | return 1 33 | } 34 | } 35 | 36 | /** 37 | * Shared instance of the Stripe client. 38 | */ 39 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { 40 | apiVersion: '2020-08-27', 41 | }) 42 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@labelsync/server", 3 | "private": true, 4 | "scripts": { 5 | "start": "yarn g:ts-node ./src/index.ts" 6 | }, 7 | "dependencies": { 8 | "@labelsync/config": "workspace:*", 9 | "@labelsync/database": "workspace:*", 10 | "@labelsync/queues": "workspace:*", 11 | "@octokit/rest": "19.0.3", 12 | "@sentry/node": "^7.7.0", 13 | "@sentry/tracing": "^7.7.0", 14 | "body-parser": "1.20.0", 15 | "cors": "2.8.5", 16 | "express": "4.18.1", 17 | "jsonwebtoken": "8.5.1", 18 | "lodash": "^4.17.21", 19 | "luxon": "^3.0.1", 20 | "multilines": "1.0.3", 21 | "pino": "^7.11.0", 22 | "pino-datadog": "^2.0.2", 23 | "pino-multi-stream": "^6.0.0", 24 | "probot": "^12.2.5", 25 | "stripe": "8.222.0" 26 | }, 27 | "devDependencies": { 28 | "@types/cors": "2.8.12", 29 | "@types/express": "4.17.13", 30 | "@types/jest": "28.1.6", 31 | "@types/jsonwebtoken": "8.5.8", 32 | "@types/lodash": "^4.14.182", 33 | "@types/luxon": "^2.3.2", 34 | "@types/node": "18.0.5", 35 | "@types/pino-datadog": "^2.0.1", 36 | "@types/pino-multi-stream": "^5.1.3" 37 | }, 38 | "license": "MIT" 39 | } 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1.Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create Label Sync bug report. 4 | --- 5 | 6 | # Bug report 7 | 8 | - [ ] I have checked other issues to make sure this is not a duplicate. 9 | 10 | ## Describe the bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ## To Reproduce 15 | 16 | Steps to reproduce the behavior, please provide code snippets or a repository: 17 | 18 | > (Delete the filler code and replace it with your own) 19 | 20 | 1. This is my configuration. 21 | 22 | ```json 23 | { 24 | "strict": false, 25 | "labels": { 26 | "FAQ": { 27 | "color": "purple", 28 | "description": "Frequently asked questions" 29 | } 30 | }, 31 | "repositories": ["maticzav/*"] 32 | } 33 | ``` 34 | 35 | 2. This is the report I get 36 | 37 | ```ts 38 | 39 | ``` 40 | 41 | > If possible please include a reproduction Github repository link or CodeSandbox. This way I can fix the bug more quickly. 42 | 43 | ## Expected behavior 44 | 45 | A clear and concise description of what you expected to happen. 46 | 47 | ## Actual behaviour 48 | 49 | If applicable, add screenshots to help explain your problem. 50 | 51 | ## Additional context 52 | 53 | Add any other context about the problem here. 54 | -------------------------------------------------------------------------------- /workers/sync/tests/lib/templates.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | import { populateTemplate, loadTreeFromPath } from '../../src/lib/templates' 5 | 6 | const TEMPLATES_PATH = path.resolve(__dirname, '../../../../templates') 7 | const TEMPLATES = fs.readdirSync(TEMPLATES_PATH).map((template) => ({ 8 | root: path.resolve(TEMPLATES_PATH, template), 9 | name: template, 10 | })) 11 | 12 | const DATA = { 13 | repository: 'maticzav-labelsync', 14 | repositories: [{ name: 'changed' }, { name: 'label-sync' }, { name: 'resk' }], 15 | } 16 | 17 | describe('templates', () => { 18 | for (const { name, root } of TEMPLATES) { 19 | test(`correctly populates "${name}" tempalate`, () => { 20 | const tree = loadTreeFromPath({ 21 | root: root, 22 | ignore: ['dist', 'node_modules', '.DS_Store', /.*\.log.*/, /.*\.lock.*/], 23 | }) 24 | const filled = populateTemplate(tree, DATA) 25 | 26 | expect(filled).toMatchSnapshot() 27 | }) 28 | } 29 | 30 | test('correctly loads tree from path', () => { 31 | const tree = loadTreeFromPath({ 32 | root: path.resolve(__dirname, '../__fixtures__/template'), 33 | ignore: ['ignore'], 34 | }) 35 | 36 | expect(tree).toMatchSnapshot() 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /packages/label-sync/src/make.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs' 2 | import path from 'path' 3 | import { isNull, isNullOrUndefined } from 'util' 4 | 5 | import { findFileUp } from './fs' 6 | import { Repository, Configuration } from './generator' 7 | import { withDefault, Dict } from './utils' 8 | 9 | /* Constants */ 10 | 11 | const LS_CONFIG_PATH = 'labelsync.yml' 12 | 13 | export type LabelSyncConfig = { 14 | repos: Dict 15 | } 16 | 17 | /** 18 | * Parses a configuration file for the configuration. 19 | * @param param0 20 | * @param cwd 21 | */ 22 | export async function labelsync( 23 | config: LabelSyncConfig, 24 | output?: string, 25 | cwd: string = process.cwd(), 26 | ): Promise { 27 | /* Search for git folder */ 28 | const pkgPath = findFileUp(cwd, 'package.json') 29 | 30 | /* istanbul ignore next */ 31 | if (isNull(pkgPath) && isNullOrUndefined(output)) { 32 | return false 33 | } 34 | 35 | output = withDefault(path.resolve(pkgPath!, LS_CONFIG_PATH), output) 36 | 37 | /* Generate configuration */ 38 | 39 | const configuration = new Configuration({ repos: config.repos }) 40 | 41 | /* Write config to file */ 42 | writeFileSync(output, configuration.getYAML(), { encoding: 'utf-8' }) 43 | 44 | return configuration 45 | } 46 | -------------------------------------------------------------------------------- /web/components/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | /** 4 | * Component that lets you accept text input. 5 | */ 6 | export const TextInput = ({ 7 | name, 8 | type, 9 | label, 10 | placeholder, 11 | value, 12 | onChange, 13 | icon: Icon, 14 | }: { 15 | name: string 16 | type: string 17 | label: string 18 | placeholder: string 19 | value: string 20 | onChange: (value: string) => void 21 | icon: (props: React.ComponentProps<'svg'>) => JSX.Element 22 | }) => { 23 | return ( 24 |
25 | 28 |
29 |
30 |
32 | onChange(e.target.value)} 39 | /> 40 |
41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /workers/sync/src/lib/processor.ts: -------------------------------------------------------------------------------- 1 | import { ITaskQueue } from '@labelsync/queues' 2 | import pino from 'pino' 3 | 4 | import { IGitHubEndpoints } from './github' 5 | 6 | /** 7 | * A blueprint specification for a task processor that all processors 8 | * should extend. It provides base functionality for the processor. 9 | */ 10 | export class Processor { 11 | protected installation: { id: number } 12 | 13 | /** 14 | * Available GitHub methods to communicate with the API. 15 | */ 16 | protected endpoints: IGitHubEndpoints 17 | 18 | /** 19 | * A general logger that may be used to log significant events 20 | * that occur during execution. 21 | */ 22 | protected log: pino.Logger 23 | 24 | /** 25 | * A queue that lets you push new tasks to the queue. 26 | */ 27 | protected queue: Pick 28 | 29 | constructor( 30 | installation: { id: number }, 31 | queue: Pick, 32 | endpoints: IGitHubEndpoints, 33 | logger: pino.Logger, 34 | ) { 35 | this.endpoints = endpoints 36 | this.installation = installation 37 | this.log = logger 38 | this.queue = queue 39 | } 40 | 41 | /** 42 | * Performs the current task and returns new tasks to be executed. 43 | */ 44 | perform(data: T): Promise { 45 | throw new Error(`Missing perform implementation in processor.`) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/queues/src/queues/tasks.ts: -------------------------------------------------------------------------------- 1 | import { Queue, TaskSpec } from '../lib/queue' 2 | import { UnionOmit, UnionShare } from '../lib/utils' 3 | 4 | type SharedTaskInfo = TaskSpec & { 5 | ghInstallationId: number 6 | } 7 | export type Task = UnionShare< 8 | | { kind: 'onboard_org'; org: string; accountType: string } 9 | | { kind: 'sync_org'; org: string; isPaidPlan: boolean } 10 | | { kind: 'sync_repo'; repo: string; org: string; isPaidPlan: boolean } 11 | | { kind: 'dryrun_config'; org: string; pr_number: number; isPaidPlan: boolean } 12 | | { 13 | kind: 'add_siblings' 14 | org: string 15 | repo: string 16 | issue_number: number 17 | label: string 18 | isPaidPlan: boolean 19 | } 20 | | { 21 | kind: 'check_unconfigured_labels' 22 | org: string 23 | repo: string 24 | label: string 25 | isPaidPlan: boolean 26 | }, 27 | SharedTaskInfo 28 | > 29 | 30 | export interface ITaskQueue { 31 | /** 32 | * Adds a new task to the queue and returns its identifier. 33 | */ 34 | push(task: UnionOmit): Promise 35 | 36 | /** 37 | * Processes the next task in the queue. 38 | */ 39 | process(fn: (task: Task) => Promise): Promise 40 | } 41 | 42 | export class TaskQueue extends Queue implements ITaskQueue { 43 | constructor(url: string) { 44 | super('tasks', url) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /workers/sync/tests/lib/filetree.test.ts: -------------------------------------------------------------------------------- 1 | import { FileTree } from '../../src/lib/filetree' 2 | 3 | describe('filetree', () => { 4 | test('correctly returns files in the root dir', () => { 5 | const files = FileTree.getRootFiles({ 6 | '/lib/models.ts': 'nested file', 7 | '/lib/utils.ts': 'nested file', 8 | '/index.ts': 'root file', 9 | '/main.ts': 'root file', 10 | }) 11 | 12 | expect(files).toEqual({ 13 | '/index.ts': 'root file', 14 | '/main.ts': 'root file', 15 | }) 16 | }) 17 | 18 | test('correctly returns subtrees of a filetree', () => { 19 | const subtrees = FileTree.getSubtrees({ 20 | '/a/models.ts': 'nested file A', 21 | '/a/utils.ts': 'nested file A', 22 | '/a/deep/utils.ts': 'deeply nested file A', 23 | '/b/models.ts': 'nested file B', 24 | '/b/utils.ts': 'nested file B', 25 | '/b/deep/utils.ts': 'deeply nested file B', 26 | '/index.ts': 'root file', 27 | '/main.ts': 'root file', 28 | }) 29 | 30 | expect(subtrees).toEqual({ 31 | a: { 32 | 'models.ts': 'nested file A', 33 | 'utils.ts': 'nested file A', 34 | 'deep/utils.ts': 'deeply nested file A', 35 | }, 36 | b: { 37 | 'models.ts': 'nested file B', 38 | 'utils.ts': 'nested file B', 39 | 'deep/utils.ts': 'deeply nested file B', 40 | }, 41 | }) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /workers/sync/tests/lib/github.test.ts: -------------------------------------------------------------------------------- 1 | import { GitHubEndpoints } from '../../src/lib/github' 2 | 3 | describe('github', () => { 4 | test("correctly tells if label hasn't changed", () => { 5 | expect( 6 | GitHubEndpoints.equals( 7 | { name: 'bug', color: 'ff', description: 'desc' }, 8 | { name: 'bug', description: 'desc', color: 'ff' }, 9 | ), 10 | ).toBeTruthy() 11 | 12 | expect( 13 | GitHubEndpoints.equals( 14 | { name: 'bug', color: '00', description: 'desc' }, 15 | { name: 'bug', description: 'desc', color: 'ff' }, 16 | ), 17 | ).toBeFalsy() 18 | 19 | expect( 20 | GitHubEndpoints.equals( 21 | { name: 'bug', color: '00', description: 'desc' }, 22 | { name: 'bug/0', description: 'this is a bug', color: 'ff' }, 23 | ), 24 | ).toBeFalsy() 25 | }) 26 | 27 | test('correctly tells if two definitions define the same label', () => { 28 | expect( 29 | GitHubEndpoints.definition( 30 | { name: 'bug', color: '00', description: 'desc' }, 31 | { name: 'bug', description: 'this is a bug', color: 'ff' }, 32 | ), 33 | ).toBeTruthy() 34 | 35 | expect( 36 | GitHubEndpoints.definition( 37 | { name: 'bug', color: '00', description: 'desc' }, 38 | { name: 'bug/0', description: 'this is a bug', color: 'ff' }, 39 | ), 40 | ).toBeFalsy() 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next", 7 | "build": "next build", 8 | "start": "next start", 9 | "type-check": "tsc" 10 | }, 11 | "dependencies": { 12 | "@clerk/nextjs": "^3.8.0", 13 | "@headlessui/react": "^1.6.6", 14 | "@heroicons/react": "^1.0.6", 15 | "@labelsync/queues": "workspace:*", 16 | "@stripe/stripe-js": "1.32.0", 17 | "classnames": "^2.3.1", 18 | "lodash": "4.17.21", 19 | "luxon": "^3.0.1", 20 | "multilines": "1.0.3", 21 | "next": "^12.2.2", 22 | "prop-types": "15.7.2", 23 | "react": "18.2.0", 24 | "react-confetti": "^6.1.0", 25 | "react-dom": "18.2.0", 26 | "react-hot-toast": "^2.3.0", 27 | "react-transition-group": "4.4.2", 28 | "react-typist": "2.0.5", 29 | "react-use": "^17.4.0", 30 | "redis": "^4.2.0", 31 | "stripe": "^9.13.0", 32 | "zod": "^3.17.9" 33 | }, 34 | "devDependencies": { 35 | "@tailwindcss/forms": "^0.5.2", 36 | "@tailwindcss/ui": "0.7.2", 37 | "@types/lodash": "4.14.182", 38 | "@types/luxon": "^2.4.0", 39 | "@types/node": "18.0.5", 40 | "@types/react": "^18.0.15", 41 | "@types/react-dom": "16.9.14", 42 | "@types/react-transition-group": "4.4.4", 43 | "@types/react-typist": "2.0.3", 44 | "autoprefixer": "10.4.7", 45 | "tailwindcss": "3.1.6", 46 | "typescript": "4.7.4" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /web/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Document, { Head, Html, Main, NextScript } from 'next/document' 3 | 4 | export default class extends Document { 5 | render() { 6 | return ( 7 | 8 | {/* Head */} 9 | 10 | 11 | 12 | 13 | {/* Twitter */} 14 | 15 | 16 | 17 | 18 | 19 | 20 | {/* Robots */} 21 | 22 | 23 | {/* Icons */} 24 | 25 | 26 | 27 | 28 | 29 | 30 | {/* App */} 31 | 32 |
33 | 34 | 35 | 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Perform Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | timeout-minutes: 15 10 | 11 | services: 12 | postgres: 13 | image: postgres:13 14 | env: 15 | POSTGRES_USER: prisma 16 | POSTGRES_PASSWORD: prisma 17 | POSTGRES_DB: prisma 18 | ports: 19 | - 5432:5432 20 | # Add a health check 21 | options: >- 22 | --health-cmd pg_isready 23 | --health-interval 10s 24 | --health-timeout 5s 25 | --health-retries 5 26 | redis: 27 | image: redis:7 28 | ports: 29 | - 6379:6379 30 | 31 | steps: 32 | - name: Checkout Main 33 | uses: actions/checkout@v2 34 | with: 35 | fetch-depth: 0 36 | - name: Use Node 37 | uses: actions/setup-node@v2 38 | with: 39 | node-version-file: '.nvmrc' 40 | cache: 'yarn' 41 | 42 | - name: Install dependencies 43 | run: yarn install 44 | 45 | - name: SetUp Test Database 46 | run: yarn workspace @labelsync/database deploy 47 | env: 48 | DATABASE_URL: postgresql://prisma:prisma@localhost:5432/prisma 49 | 50 | - name: Test Packages 51 | run: yarn test 52 | env: 53 | DATABASE_URL: postgresql://prisma:prisma@localhost:5432/prisma 54 | REDIS_URL: redis://localhost:6379 55 | -------------------------------------------------------------------------------- /web/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | 3 | /** 4 | * A horizontal component that lets you switch between two states. 5 | */ 6 | export function Toggle({ 7 | isOn, 8 | onClick, 9 | options, 10 | }: { 11 | isOn: boolean 12 | onClick: () => void 13 | options: { on: string; off: string } 14 | }) { 15 | return ( 16 |
17 | {options.off} 18 | 19 | {options.on} 20 |
21 | ) 22 | } 23 | 24 | /** 25 | * A component that shows a toggle switch button. 26 | */ 27 | export function ToggleSwitch({ isOn, onClick }: { isOn: boolean; onClick: () => void }) { 28 | return ( 29 | 36 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /templates/typescript/repos/github.ts: -------------------------------------------------------------------------------- 1 | import { repo, label } from 'label-sync' 2 | 3 | /** 4 | * Default collection of label in a Github repository. 5 | */ 6 | export const github = repo({ 7 | config: { 8 | removeUnconfiguredLabels: false, 9 | }, 10 | labels: [ 11 | label({ 12 | name: 'bug', 13 | color: '#d73a4a', 14 | description: "Something isn't working", 15 | }), 16 | label({ 17 | name: 'documentation', 18 | color: '#0075ca', 19 | description: 'Improvements or additions to documentation', 20 | }), 21 | label({ 22 | name: 'duplicate', 23 | color: '#cfd3d7', 24 | description: 'This issue or pull request already exists', 25 | }), 26 | label({ 27 | name: 'enhancement', 28 | color: '#a2eeef', 29 | description: 'New feature or request', 30 | }), 31 | label({ 32 | name: 'good first issue', 33 | color: '#7057ff', 34 | description: 'Good for newcomers', 35 | }), 36 | label({ 37 | name: 'help wanted', 38 | color: '#008672', 39 | description: 'Extra attention is needed', 40 | }), 41 | label({ 42 | name: 'invalid', 43 | color: '#e4e669', 44 | description: "This doesn't seem right", 45 | }), 46 | label({ 47 | name: 'question', 48 | color: '#d876e3', 49 | description: 'Further information is requested', 50 | }), 51 | label({ 52 | name: 'wontfix', 53 | color: '#000000', 54 | description: 'This will not be worked on', 55 | }), 56 | ], 57 | }) 58 | -------------------------------------------------------------------------------- /packages/label-sync/tests/__snapshots__/presets.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`presets: works 1`] = ` 4 | Repository { 5 | "config": Object { 6 | "removeUnconfiguredLabels": true, 7 | }, 8 | "labels": Array [ 9 | Label { 10 | "alias": Array [], 11 | "color": "#7057ff", 12 | "description": "#ff0022", 13 | "name": "community/bug", 14 | "siblings": Array [], 15 | }, 16 | Label { 17 | "alias": Array [], 18 | "color": "#94ebfc", 19 | "description": "#ff0022", 20 | "name": "scope/bug", 21 | "siblings": Array [], 22 | }, 23 | Label { 24 | "alias": Array [], 25 | "color": "#FFCF2D", 26 | "description": "#ff0022", 27 | "name": "needs/bug", 28 | "siblings": Array [], 29 | }, 30 | Label { 31 | "alias": Array [], 32 | "color": "#EEEEEE", 33 | "description": "#ff0022", 34 | "name": "effort/bug", 35 | "siblings": Array [], 36 | }, 37 | Label { 38 | "alias": Array [], 39 | "color": "#EEEEEE", 40 | "description": "#ff0022", 41 | "name": "impact/bug", 42 | "siblings": Array [], 43 | }, 44 | Label { 45 | "alias": Array [], 46 | "color": "#EEEEEE", 47 | "description": "#ff0022", 48 | "name": "note/bug", 49 | "siblings": Array [], 50 | }, 51 | Label { 52 | "alias": Array [], 53 | "color": "#ff0022", 54 | "description": undefined, 55 | "name": "type/bug", 56 | "siblings": Array [], 57 | }, 58 | ], 59 | } 60 | `; 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Matic Zavadlal 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Landing page for LabelSync 2 | 3 | ```html 4 | 5 | 6 |
7 |
8 | 9 |
10 |

13 | Join the best 14 |

15 |

16 | Their teams are already working faster using LabelSync 17 |

18 |
19 | 20 | 21 |
24 | 25 |
26 | 27 | Algolia 28 | 29 |
30 | 31 |
32 | 33 | Prisma 34 | 35 |
36 | 37 |
38 | 39 | Zeit 40 | 41 |
42 |
43 |
44 |
45 | ``` 46 | 47 | // "build": "postcss css/tailwind.css -o public/build/tailwind.css", 48 | // "dev": "live-server public", 49 | -------------------------------------------------------------------------------- /packages/label-sync/src/fs.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { promisify } from 'util' 4 | 5 | import { Maybe } from './utils' 6 | 7 | /** 8 | * Looks up for a folder starting with the current directory and 9 | * going up until it reaches the root folder or finds the folder. 10 | * 11 | * @param dir 12 | * @param name 13 | */ 14 | export function findFolderUp(dir: string, name: string): Maybe { 15 | return findUp(dir, (p) => fs.lstatSync(p).isDirectory() && path.basename(p) === name) 16 | } 17 | 18 | /** 19 | * Looks up for a folder starting with the current directory and 20 | * going up until it reaches the root folder or finds the folder. 21 | * 22 | * @param dir 23 | * @param name 24 | */ 25 | export function findFileUp(dir: string, name: string): Maybe { 26 | return findUp(dir, (p) => fs.lstatSync(p).isFile() && path.basename(p) === name) 27 | } 28 | 29 | /** 30 | * Traverses file system up to find a specific directory that contains 31 | * a matching pattern. 32 | * 33 | * @param dir 34 | * @param pattern 35 | */ 36 | export function findUp(dir: string, pattern: (path: string) => boolean): Maybe { 37 | switch (path.normalize(dir)) { 38 | /* End case: we reached the root. */ 39 | /* istanbul ignore next */ 40 | case '/': { 41 | return null 42 | } 43 | /* Recursive case. */ 44 | default: { 45 | const elements = fs.readdirSync(dir) 46 | const includes = elements.map((name) => path.resolve(dir, name)).some(pattern) 47 | 48 | if (includes) return dir 49 | else return findUp(path.resolve(dir, '../'), pattern) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /workers/sync/tests/processors/siblings.test.ts: -------------------------------------------------------------------------------- 1 | import ml from 'multilines' 2 | import pino from 'pino' 3 | 4 | import { MockGitHubEndpoints } from '../__fixtures__/endpoints' 5 | 6 | import { SiblingsProcessor } from '../../src/processors/siblingsProcessor' 7 | 8 | describe('siblings', () => { 9 | test('adds siblings to an issue', async () => { 10 | const installation = { id: 1, isPaidPlan: false } 11 | const endpoints = new MockGitHubEndpoints({ 12 | configs: { 13 | 'test-org': ml` 14 | | repos: 15 | | a: 16 | | labels: 17 | | 'label/regular': 18 | | color: '000000' 19 | | siblings: ['a', 'b'] 20 | | 'a': 21 | | color: '000000' 22 | | 'b': 23 | | color: '000000' 24 | `, 25 | }, 26 | installations: { 27 | 'test-org': ['a'], 28 | }, 29 | }) 30 | const logger = pino() 31 | 32 | const processor = new SiblingsProcessor( 33 | installation, 34 | { 35 | push: () => { 36 | fail() 37 | }, 38 | }, 39 | endpoints, 40 | logger, 41 | ) 42 | await processor.perform({ 43 | owner: 'test-org', 44 | repo: 'a', 45 | issue_number: 1, 46 | label: 'label/regular', 47 | isPro: true, 48 | }) 49 | 50 | expect(endpoints.stack()).toEqual([ 51 | MockGitHubEndpoints.getConfig({ owner: 'test-org' }), 52 | MockGitHubEndpoints.addLabelsToIssue({ 53 | owner: 'test-org', 54 | repo: 'a', 55 | issue_number: 1, 56 | labels: [{ name: 'a' }, { name: 'b' }], 57 | }), 58 | ]) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /web/components/Section.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import React, { useState, PropsWithChildren } from 'react' 3 | 4 | export interface Section { 5 | id: string 6 | name: string 7 | className?: string 8 | onReached?: (section: Section) => void 9 | onVisible?: (section: Section) => void 10 | } 11 | 12 | /** 13 | * Wraps elements in a div and tracks the visibility of the section. 14 | */ 15 | export default function Section(props: PropsWithChildren
) { 16 | const [reached, setReached] = useState(false) 17 | 18 | useEffect(() => { 19 | /* Report visibility on reach. */ 20 | if (reached) { 21 | if (props.onReached) props.onReached(props) 22 | } 23 | }, [reached]) 24 | 25 | /* Scroll event handler */ 26 | function handleScroll() { 27 | const section = document.getElementById(props.id)! 28 | 29 | const topOfSection = section.offsetTop 30 | const bottomOfSection = section.offsetTop + section.offsetHeight 31 | const topOfView = window.pageYOffset 32 | const bottomOfView = window.pageYOffset + window.innerHeight 33 | 34 | /* Section visible to viewer. */ 35 | if (bottomOfView >= topOfSection && topOfView <= bottomOfSection) { 36 | if (props.onVisible) props.onVisible(props) 37 | /* Change reached state */ 38 | if (!reached) setReached(true) 39 | } 40 | } 41 | 42 | useEffect(() => { 43 | /* Mount */ 44 | window.addEventListener('scroll', handleScroll) 45 | 46 | /* Unmount */ 47 | return () => { 48 | window.removeEventListener('scroll', handleScroll) 49 | } 50 | }, []) 51 | 52 | return ( 53 |
54 | {props.children} 55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /web/public/img/logos/zeit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Black Full Logo 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /server/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | type Config = { 2 | prod: boolean 3 | 4 | corsOrigins: string[] 5 | 6 | // https://probot.github.io/docs/configuration/ 7 | ghAppId: string 8 | ghPrivateKey: string 9 | ghSecret: string 10 | 11 | stripeApiKey: string 12 | stripeEndpointSecret: string 13 | 14 | databaseUrl: string 15 | redisUrl: string 16 | 17 | sentryDSN: string 18 | datadogApiKey: string 19 | } 20 | 21 | // Environments 22 | 23 | const base = {} 24 | 25 | const prod = { 26 | prod: true, 27 | 28 | corsOrigins: [ 29 | 'https://label-sync.com', 30 | 'https://www.label-sync.com', 31 | 'https://webhook.label-sync.com', 32 | ], 33 | 34 | ghAppId: process.env.GH_APP_ID!, 35 | ghPrivateKey: process.env.GH_PRIVATE_KEY!, 36 | ghSecret: process.env.GH_SECRET!, 37 | 38 | stripeApiKey: process.env.STRIPE_API_KEY!, 39 | stripeEndpointSecret: process.env.STRIPE_ENDPOINT_SECRET!, 40 | 41 | databaseUrl: process.env.DATABASE_URL!, 42 | redisUrl: process.env.REDIS_URL!, 43 | 44 | sentryDSN: process.env.SENTRY_DSN!, 45 | datadogApiKey: process.env.DATADOG_API_KEY!, 46 | } 47 | 48 | const dev = { 49 | prod: false, 50 | 51 | corsOrigins: ['http://localhost', 'http://127.0.0.1'], 52 | 53 | ghAppId: '', 54 | ghPrivateKey: '', 55 | ghSecret: '', 56 | 57 | stripeApiKey: '', 58 | stripeEndpointSecret: '', 59 | 60 | databaseUrl: 'postgres://prisma:prisma@localhost:5432/prisma', 61 | redisUrl: 'redis://localhost:6379', 62 | 63 | sentryDSN: '', 64 | datadogApiKey: '', 65 | } 66 | 67 | const enviroment = process.env.NODE_ENV 68 | 69 | /** 70 | * Configuration credentials for the worker instance. 71 | */ 72 | export const config: Config = Object.assign(base, enviroment === 'production' ? prod : dev) 73 | -------------------------------------------------------------------------------- /workers/sync/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import ml from 'multilines' 2 | import os from 'os' 3 | 4 | export const messages = { 5 | 'insufficient.permissions.issue': (missingRepos: string[]) => ml` 6 | | # Insufficient permissions 7 | | 8 | | Hi there, 9 | | Thank you for installing LabelSync. We have noticed that your configuration stretches beyond repositories we can access. 10 | | We think you forgot to allow access to certain repositories. Please update your installation. 11 | | 12 | | _Missing repositories:_ 13 | | ${missingRepos.map((missing) => ` * ${missing}`).join(os.EOL)} 14 | | 15 | | Best, 16 | | LabelSync Team 17 | `, 18 | 'invalid.config.comment': (error: string) => ml` 19 | | It seems like your configuration uses a format unknown to me. 20 | | That might be a consequence of invalid yaml cofiguration file. 21 | | 22 | | Here's what I am having problems with: 23 | | 24 | | ${error} 25 | 26 | `, 27 | 'insufficient.permissions.comment': (missingRepos: string[]) => ml` 28 | | Your configuration stretches beyond repositories we can access. 29 | | Please update it so I may sync your labels. 30 | | 31 | | _Missing repositories:_ 32 | | ${missingRepos.map((missing) => ` * ${missing}`).join(os.EOL)} 33 | `, 34 | 'onboarding.error.issue': (error: string) => ml` 35 | | # Welcome to LabelSync! 36 | | 37 | | Hi there, 38 | | Thank you for using LabelSync. We hope you enjoyed the experience so far. 39 | | It seems like there are some problems with your configuration. 40 | | Our parser reported that: 41 | | 42 | | ${error} 43 | | 44 | | Let us know if we can help you with the configuration by sending us an email to support@label-sync.com. 45 | | We'll try to get back to you as quickly as possible. 46 | | 47 | | Best, 48 | | LabelSync Team 49 | `, 50 | } 51 | -------------------------------------------------------------------------------- /packages/config/tests/__fixtures__/configurations/anchors.yml: -------------------------------------------------------------------------------- 1 | # Colors 2 | colors: 3 | area: &area '#FFD700' 4 | kind: &kind '#3B5BDB' 5 | status: &status '#F8F9FA' 6 | bug: &bug '#EE0000' 7 | priority: &priority '#F783AC' 8 | scope: &scope '#27CF79' 9 | team: &team'#FDF4E8' 10 | release: &release '#A5D8ff' 11 | process: &process' '#EB9100' 12 | 13 | # Labels 14 | labels: &common 15 | 'bug/0-needs-info': 16 | color: &bug 17 | description: More information is needed for reproduction. 18 | 'bug/1-repro-available': 19 | color: &bug 20 | description: A reproduction exists and needs to be confirmed. 21 | 'bug/2-confirmed': 22 | color: &bug 23 | description: We have confirmed that this is a bug. 24 | 'kind/bug': 25 | color: &bug 26 | description: A reported bug. 27 | 'kind/regression': 28 | color: &kind 29 | description: A reported bug in functionality that used to work before. 30 | 'kind/feature': 31 | color: &kind 32 | description: A request for a new feature. 33 | 'kind/improvement': 34 | color: &kind 35 | description: An improvement to existing feature and code. 36 | 'kind/docs': 37 | color: &kind 38 | description: A documentation change is required. 39 | 'kind/discussion': 40 | color: &kind 41 | description: Discussion is required. 42 | 'kind/question': 43 | color: &kind 44 | description: Developer asked a question. No code changes required. 45 | 'process/candidate': 46 | color: &process 47 | description: Candidate for next Milestone. 48 | 'process/next-milestone': 49 | color: &process 50 | description: Issue earmarked for next Milestone. 51 | 'process/product': 52 | color: &process 53 | description: Temporary label to export products issues from the Engineering process 54 | repos: {} 55 | -------------------------------------------------------------------------------- /web/pages/success.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Confetti from 'react-confetti' 3 | import { useWindowSize } from 'react-use' 4 | 5 | import Link from 'next/link' 6 | 7 | export default function Success() { 8 | const { width, height } = useWindowSize(1920, 1080) 9 | 10 | console.log({ width, height }) 11 | 12 | return ( 13 |
14 |
15 | 16 |
17 | 18 | Success 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 | ) 39 | } 40 | 41 | function Pill({ label, href }: { href?: string; label: string }) { 42 | return ( 43 | 47 | {label} 48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /packages/config/src/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | /** 4 | * Label represents the central unit of LabelSync. Each label 5 | * can have multiple siblings that are meaningfully related to 6 | * a label itself, multiple hooks that trigger different actions. 7 | */ 8 | export const LSCLabel = z.object({ 9 | color: z.string(), 10 | description: z.string().optional(), 11 | siblings: z.array(z.string()).optional(), 12 | alias: z.array(z.string()).optional(), 13 | scope: z.array(z.string()).optional(), 14 | }) 15 | 16 | export type LSCLabel = z.infer 17 | 18 | export const LSCLabelName = z.string() 19 | export type LSCLabelName = z.infer 20 | 21 | /** 22 | * Repository configuration for how LabelSync should sync it. 23 | */ 24 | const LSCRepositoryConfiguration = z.object({ 25 | removeUnconfiguredLabels: z.boolean().optional(), 26 | }) 27 | export interface LSCRepositoryConfiguration extends z.infer {} 28 | 29 | /** 30 | * Repository represents a single Github repository. 31 | * When configured as `strict` it will delete any surplus of labels 32 | * in the repository. 33 | */ 34 | export const LSCRepository = z.object({ 35 | config: LSCRepositoryConfiguration.optional(), 36 | labels: z.record(LSCLabelName, LSCLabel), 37 | }) 38 | export interface LSCRepository extends z.infer {} 39 | 40 | export const LSCRepositoryName = z.string() 41 | export type LSCRepositoryName = z.infer 42 | 43 | /** 44 | * Configuration represents an entire configuration for all 45 | * LabelSync tools that an organisation is using. 46 | */ 47 | export const LSCConfiguration = z.object({ 48 | repos: z.record(LSCRepositoryName, LSCRepository), 49 | }) 50 | export interface LSCConfiguration extends z.infer {} 51 | -------------------------------------------------------------------------------- /workers/sync/src/processors/unconfiguredLabelsProcessor.ts: -------------------------------------------------------------------------------- 1 | import { parseConfig } from '@labelsync/config' 2 | import _ from 'lodash' 3 | 4 | import { Processor } from '../lib/processor' 5 | 6 | type ProcessorData = { 7 | owner: string 8 | repo: string 9 | label: string 10 | isPro: boolean 11 | } 12 | 13 | /** 14 | * A processor that removes a label that is not configured in strict repositories. 15 | */ 16 | export class UnconfiguredLabelsProcessor extends Processor { 17 | /** 18 | * Syncs the configuration of a repository with its labels. 19 | */ 20 | public async perform({ owner, repo, label, isPro }: ProcessorData) { 21 | this.log.info(`New label created in ${repo}: "${label}".`) 22 | 23 | const rawConfig = await this.endpoints.getConfig({ owner }) 24 | if (rawConfig === null) { 25 | this.log.info(`No configuration, skipping siblings sync.`) 26 | return 27 | } 28 | 29 | const parsedConfig = parseConfig({ input: rawConfig, isPro }) 30 | if (!parsedConfig.ok) { 31 | this.log.info(parsedConfig, `Invalid configuration, skipping siblings sync.`) 32 | return 33 | } 34 | 35 | const config = parsedConfig.config.repos[repo] 36 | 37 | /* istanbul ignore if */ 38 | if (!config) { 39 | this.log.info(`No configuration found for the repo, skipping.`) 40 | return 41 | } 42 | 43 | if (label in config.labels) { 44 | this.log.info(`Label "${label}" is configured, skipping.`) 45 | return 46 | } 47 | 48 | if (config.config?.removeUnconfiguredLabels === true) { 49 | this.log.info(`Removing "${label}" from ${repo} because it's not configured.`) 50 | await this.endpoints.removeLabel({ repo, owner }, { name: label }) 51 | this.log.info(`Removed label "${label}" from ${repo}.`) 52 | } 53 | 54 | return 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/queues/tests/queue.test.ts: -------------------------------------------------------------------------------- 1 | import { Queue, TaskSpec } from '../src/lib/queue' 2 | 3 | type TestTask = TaskSpec & { 4 | task: number 5 | } 6 | 7 | describe('queue', () => { 8 | let queue: Queue 9 | 10 | beforeEach(async () => { 11 | const name = Math.random().toString(32) 12 | queue = new Queue(name, 'redis://localhost:6379') 13 | }) 14 | 15 | afterEach(async () => { 16 | await queue?.dispose() 17 | }) 18 | 19 | test('pushes a task to the queue', async () => { 20 | const starttasks = await queue.list() 21 | expect(starttasks).toHaveLength(0) 22 | 23 | await queue.push({ dependsOn: [], task: 42 }) 24 | 25 | const tasks = await queue.list() 26 | expect(tasks).toHaveLength(1) 27 | expect(tasks[0].task).toBe(42) 28 | }) 29 | 30 | test('processes tasks from the queue', async () => { 31 | const trace: [string, number][] = [] 32 | 33 | const a = await queue.push({ dependsOn: [], task: 10 }) 34 | await queue.push({ dependsOn: [a], task: 15 }) 35 | await queue.push({ dependsOn: [], task: 5 }) 36 | await queue.push({ dependsOn: [], task: 92 }) 37 | 38 | const tasks = await queue.list() 39 | expect(tasks).toHaveLength(4) 40 | 41 | const processor = (name: string) => async (task: TestTask) => { 42 | trace.push([name, task.task]) 43 | 44 | // Waits the number of units specified in the task. 45 | await new Promise((resolve) => setTimeout(resolve, task.task * 10)) 46 | 47 | return trace.length < 4 48 | } 49 | 50 | await Promise.all([ 51 | // We run two parallel processors at the same time. 52 | queue.process(processor('one')), 53 | queue.process(processor('two')), 54 | ]) 55 | 56 | expect(trace).toEqual([ 57 | ['one', 10], 58 | ['two', 5], 59 | ['two', 92], 60 | ['one', 15], 61 | ]) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /packages/config/tests/parse.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | import { parseConfig, getPhysicalRepositories, isConfigRepo } from '../src/' 5 | 6 | type Plan = 'PAID' | 'FREE' 7 | 8 | const configurationsPath = path.resolve(__dirname, './__fixtures__/configurations') 9 | const configurations = fs.readdirSync(configurationsPath).map((config) => ({ 10 | config: config, 11 | path: path.resolve(configurationsPath, config), 12 | })) 13 | 14 | describe('configurations:', () => { 15 | const plans: Plan[] = ['PAID', 'FREE'] 16 | 17 | for (const plan of plans) { 18 | for (const { config, path } of configurations) { 19 | test(`${config} on ${plan}`, () => { 20 | const config = parseConfig({ 21 | isPro: plan === 'PAID', 22 | input: fs.readFileSync(path, { encoding: 'utf-8' }), 23 | }) 24 | expect(config).toMatchSnapshot() 25 | }) 26 | } 27 | } 28 | }) 29 | 30 | describe('utility functions:', () => { 31 | test('configRepos returns correct repositories from config', async () => { 32 | const configPath = path.resolve(__dirname, './__fixtures__/configurations/wildcard.yml') 33 | const parsedConfig = parseConfig({ 34 | isPro: true, 35 | input: fs.readFileSync(configPath, { encoding: 'utf-8' }), 36 | }) 37 | 38 | if (!parsedConfig.ok) { 39 | fail() 40 | return 41 | } 42 | 43 | expect(getPhysicalRepositories(parsedConfig.config)).toEqual(['prisma-test-utils']) 44 | }) 45 | 46 | test('isConfigRepo', async () => { 47 | expect(isConfigRepo('acc', 'acc-labelsync')).toBeTruthy() 48 | expect(isConfigRepo('ACC', 'acc-labelsync')).toBeTruthy() 49 | expect(isConfigRepo('acc', 'ACC-labelsync')).toBeTruthy() 50 | expect(isConfigRepo('ACC', 'ACC-labelsync')).toBeTruthy() 51 | 52 | expect(isConfigRepo('not', 'acc-labelsync')).toBeFalsy() 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /web/pages/api/checkout/create.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | import * as z from 'zod' 3 | 4 | import { PLAN_IDS, stripe } from 'lib/checkout' 5 | 6 | const schema = z.object({ 7 | email: z.string().trim().min(3), 8 | account: z 9 | .string() 10 | .trim() 11 | .min(1) 12 | .transform((v) => v.toLowerCase()), 13 | plan: z.enum(['FREE', 'PAID']), 14 | cadence: z.enum(['MONTHLY', 'ANNUALLY']), 15 | coupon: z.string(), 16 | }) 17 | 18 | /** 19 | * API endpoint that creates a Stripe checkout session. 20 | */ 21 | export default async (req: NextApiRequest, res: NextApiResponse) => { 22 | const body = schema.safeParse(JSON.parse(req.body)) 23 | 24 | if (!body.success) { 25 | res.status(400).json({ message: body.error }) 26 | return 27 | } 28 | 29 | const data = body.data 30 | 31 | if (data.plan === 'FREE') { 32 | res.send({ status: 'ok', plan: 'FREE' }) 33 | return 34 | } 35 | 36 | const planId = PLAN_IDS[data.cadence] 37 | 38 | if (!planId) { 39 | res.status(400).send({ status: 'error', message: 'Invalid cadence...' }) 40 | return 41 | } 42 | 43 | try { 44 | const session = await stripe.checkout.sessions.create({ 45 | payment_method_types: ['card'], 46 | subscription_data: { 47 | items: [{ plan: planId }], 48 | metadata: { cadence: data.cadence, account: data.account }, 49 | coupon: data.coupon.trim() === '' ? undefined : data.coupon, 50 | }, 51 | customer_email: data.email, 52 | expand: ['subscription'], 53 | success_url: 'https://label-sync.com/success', 54 | cancel_url: 'https://label-sync.com', 55 | }) 56 | 57 | res.status(200).json({ status: 'ok', plan: 'PAID', session: session.id }) 58 | } catch (err: any) { 59 | res.status(400).json({ status: 'err', message: err.message }) 60 | 61 | console.error(`Error in subscription flow: ${err.message}`) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | ##### 2 | # Documentation 3 | # YAML (all config values): https://render.com/docs/yaml-spec 4 | ##### 5 | 6 | services: 7 | - name: labelsync-api 8 | type: web 9 | env: node 10 | region: frankfurt 11 | buildCommand: yarn workspaces focus labelsync @labelsync/server 12 | startCommand: yarn workspace @labelsync/server start 13 | envVars: 14 | - key: NODE_VERSION 15 | value: 16 16 | - key: DATABASE_URL 17 | fromDatabase: 18 | name: labelsync-db 19 | property: connectionString 20 | - key: REDIS_URL 21 | fromService: 22 | name: labelsync-redis 23 | type: redis 24 | property: connectionString 25 | - fromGroup: labelsync-secrets 26 | buildFilter: 27 | paths: 28 | - server/**/* 29 | - packages/**/* 30 | - package.json 31 | ignoredPaths: 32 | - '**/*.test.ts' 33 | 34 | - name: labelsync-worker 35 | type: worker 36 | env: node 37 | region: frankfurt 38 | buildCommand: yarn workspaces focus labelsync @labelsync/sync 39 | startCommand: yarn workspace @labelsync/sync start 40 | envVars: 41 | - key: NODE_VERSION 42 | value: 16 43 | - key: DATABASE_URL 44 | fromDatabase: 45 | name: labelsync-db 46 | property: connectionString 47 | - key: REDIS_URL 48 | fromService: 49 | name: labelsync-redis 50 | type: redis 51 | property: connectionString 52 | - fromGroup: labelsync-secrets 53 | buildFilter: 54 | paths: 55 | - workers/**/* 56 | - packages/**/* 57 | - package.json 58 | ignoredPaths: 59 | - '**/*.test.ts' 60 | 61 | # Redis 62 | 63 | - name: labelsync-redis 64 | type: redis 65 | region: frankfurt 66 | ipAllowList: # required 67 | - source: 0.0.0.0/0 68 | description: everywhere 69 | 70 | databases: 71 | - name: labelsync-db 72 | region: frankfurt 73 | -------------------------------------------------------------------------------- /workers/sync/src/processors/siblingsProcessor.ts: -------------------------------------------------------------------------------- 1 | import { parseConfig } from '@labelsync/config' 2 | import _ from 'lodash' 3 | 4 | import { Processor } from '../lib/processor' 5 | 6 | type ProcessorData = { 7 | owner: string 8 | repo: string 9 | issue_number: number 10 | label: string 11 | isPro: boolean 12 | } 13 | 14 | export class SiblingsProcessor extends Processor { 15 | /** 16 | * Syncs the configuration of a repository with its labels. 17 | */ 18 | public async perform({ owner, repo, label, issue_number, isPro }: ProcessorData): Promise { 19 | this.log.info(`${issue_number} labeled with "${label}" in ${owner}/${repo}...`) 20 | 21 | const rawConfig = await this.endpoints.getConfig({ owner }) 22 | if (rawConfig === null) { 23 | this.log.info(`No configuration, skipping siblings sync.`) 24 | return 25 | } 26 | const parsedConfig = parseConfig({ input: rawConfig, isPro }) 27 | 28 | if (!parsedConfig.ok) { 29 | this.log.info(parsedConfig, `Invalid configuration, skipping siblings sync.`) 30 | return 31 | } 32 | 33 | this.log.info(parsedConfig, `Successfully parsed organization configuration!`) 34 | const config = parsedConfig.config.repos[repo] 35 | 36 | /* istanbul ignore if */ 37 | if (!config) { 38 | this.log.info(`No configuration found for the repo, skipping.`) 39 | return 40 | } 41 | 42 | if (label in config.labels) { 43 | const siblings = _.get(config, ['labels', label, 'siblings'], [] as string[]) 44 | 45 | /* istanbul ignore if */ 46 | if (siblings.length === 0) { 47 | this.log.info(`No siblings to add to "${label}", skipping.`) 48 | return 49 | } 50 | 51 | const labels = siblings.map((sibling) => ({ name: sibling })) 52 | await this.endpoints.addLabelsToIssue({ repo, owner }, { issue_number, labels }) 53 | 54 | return 55 | } 56 | 57 | this.log.info(`Unconfigured label "${label}", nothing to do.`) 58 | return 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /templates/typescript/README.md: -------------------------------------------------------------------------------- 1 | # LabelSync TypeScript configuration. 2 | 3 | Hey there! Welcome to LabelSync. We have scaffolded the configuration file for you. Check it out! 4 | 5 | ### Setting up LabelSync 6 | 7 | {{! we use Handlebars to personalise tutorial. }} 8 | {{! IF YOU SEE THIS LINE SOMETHING BROKE. PLEASE RETRY SCAFFOLDING }} 9 | 10 | 1. Create a repository on Github and name it `{{repository}}`. 11 | 1. Commit your configuration (this repository) to Github. 12 | 1. Head over to [LabelSync Manager Github Application](https://github.com/apps/labelsync-manager) and make sure that you install it in all repositories that you have configured. 13 | 14 | ### LabelSync library cheat sheet 15 | 16 | **Methods:** 17 | 18 | - `labelsync`: used as a configuration entry point. Outputs yaml version of your configuration to the root of your repository. 19 | - `repo`: used to configure a single repository 20 | - `label`: used to create a single label 21 | 22 | **Presets:** 23 | 24 | Check out `colors` property with a set of common colors for labels, and `type`, `note`, `impact`, `effort`, `needs`, `scope` and `communtiy` label templates to get up to speed more quickly. Label templates all prepend their name to the name of your label and already pack a nice color of our choosing. 25 | 26 | ```ts 27 | function labelsync({ 28 | /* Repositories represent a repo-name:config dictionary */ 29 | repos: { [repo: string]: Repository } 30 | }): Configuration 31 | 32 | /* Repo */ 33 | function repo({ 34 | config?: { 35 | /* removes unconfigured labels from repository to keep it clean */ 36 | removeUnconfiguredLabels?: boolean 37 | } 38 | /* list of labels that we get using label method below */ 39 | labels: Label[] 40 | }) 41 | 42 | /* Label */ 43 | function label(name: string, color: string) 44 | function label({ 45 | /* name of the label */ 46 | name: string 47 | /* color in hex format */ 48 | color: string 49 | description?: string 50 | /* old names of this label */ 51 | alias?: string[] 52 | /* siblings of the label */ 53 | siblings?: string[] 54 | }) 55 | ``` 56 | -------------------------------------------------------------------------------- /workers/sync/src/lib/filetree.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | 3 | import { Dict } from '../data/dict' 4 | 5 | /** 6 | * Represents a Github file and folder structure where 7 | * files are represented as UTF-8 encoded strings keyed by the their relative 8 | * path to the repository root. 9 | */ 10 | export type FileTree = { [path: string]: string } 11 | 12 | /** 13 | * Represents a file and folder structure where 14 | * files are represented as UTF-8 encoded strings keyed by the their relative 15 | * path to the root directory. 16 | */ 17 | export namespace FileTree { 18 | export type Type = { [path: string]: string } 19 | 20 | /** 21 | * Returns the files that are not nested. 22 | */ 23 | export function getRootFiles(tree: FileTree): Dict { 24 | const files = Object.keys(tree) 25 | .filter(isFileInRootFolder) 26 | .map((name) => [name, tree[name]]) 27 | 28 | return Object.fromEntries(files) 29 | } 30 | 31 | /** 32 | * Returns a dictionary of remaining subtrees. 33 | */ 34 | export function getSubtrees(tree: FileTree): Dict { 35 | return Object.keys(tree) 36 | .filter((file) => !isFileInRootFolder(file)) 37 | .reduce>((acc, filepath) => { 38 | const [subTree, newFilepath] = shiftPath(filepath) 39 | if (!acc.hasOwnProperty(subTree)) { 40 | acc[subTree] = {} 41 | } 42 | acc[subTree][newFilepath] = tree[filepath] 43 | return acc 44 | }, {}) 45 | } 46 | 47 | /** 48 | * Shifts path by one and returns the shifted part as first element in tuple 49 | * and remaining part as the second. 50 | */ 51 | function shiftPath(filepath: string): [string, string] { 52 | const [dir, ...dirs] = filepath.split('/').filter(Boolean) 53 | return [dir, dirs.join('/')] 54 | } 55 | 56 | /** 57 | * Determines whether a path references a direct file 58 | * or a file in the nested folder. 59 | * 60 | * "/src/index.ts" -> false 61 | * "/index.ts" -> true 62 | * "index.ts" -> true 63 | */ 64 | function isFileInRootFolder(filePath: string): boolean { 65 | return ['.', '/'].includes(path.dirname(filePath)) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Logo Blue 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | label/sync 27 | 28 | 29 | -------------------------------------------------------------------------------- /web/public/img/logos/labelsync-red.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Logo Red 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | label/sync 27 | 28 | 29 | -------------------------------------------------------------------------------- /web/public/img/logos/labelsync-green.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Logo Green 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | label/sync 27 | 28 | 29 | -------------------------------------------------------------------------------- /web/components/Tier.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react' 2 | 3 | export interface Tier { 4 | name: string 5 | description: string 6 | /* Current price */ 7 | price: ReactElement 8 | 9 | features: { 10 | name: string 11 | }[] 12 | link: ReactElement 13 | } 14 | 15 | export default function Tier(props: Tier) { 16 | return ( 17 |
18 |
19 |
20 | 21 | {props.name} 22 | 23 |
24 |
25 | {props.price} 26 |
27 |

{props.description}

28 |
29 | 30 |
31 |
    32 | {props.features.map((feature, i) => ( 33 |
  • 38 |
    39 | 45 | 51 | 52 |
    53 |

    {feature.name}

    54 |
  • 55 | ))} 56 |
57 | 58 | {/* */} 59 |
{props.link}
60 | 61 | {/* */} 62 |
63 | {/* */} 64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /packages/label-sync/tests/combinations.test.ts: -------------------------------------------------------------------------------- 1 | import { repo, label } from '../src' 2 | 3 | describe('combinations:', () => { 4 | test('extending repository extends labels', () => { 5 | const original = repo({ 6 | labels: [ 7 | label({ 8 | name: 'one', 9 | color: '#111111', 10 | }), 11 | label({ 12 | name: 'two', 13 | color: '#111111', 14 | }), 15 | label({ 16 | name: 'three', 17 | color: '#111111', 18 | }), 19 | ], 20 | }) 21 | 22 | const extended = repo({ 23 | labels: [ 24 | ...original, 25 | label({ 26 | name: 'one', 27 | color: '#222222', 28 | }), 29 | label({ 30 | name: 'four', 31 | color: '#222222', 32 | }), 33 | ], 34 | }) 35 | 36 | expect(original.getConfiguration()).toEqual({ 37 | config: {}, 38 | labels: { 39 | one: { 40 | color: '#111111', 41 | description: undefined, 42 | alias: [], 43 | siblings: [], 44 | }, 45 | two: { 46 | color: '#111111', 47 | description: undefined, 48 | alias: [], 49 | siblings: [], 50 | }, 51 | three: { 52 | color: '#111111', 53 | description: undefined, 54 | alias: [], 55 | siblings: [], 56 | }, 57 | }, 58 | }) 59 | expect(extended.getConfiguration()).toEqual({ 60 | config: {}, 61 | labels: { 62 | one: { 63 | color: '#222222', 64 | description: undefined, 65 | alias: [], 66 | siblings: [], 67 | }, 68 | two: { 69 | color: '#111111', 70 | description: undefined, 71 | alias: [], 72 | siblings: [], 73 | }, 74 | three: { 75 | color: '#111111', 76 | description: undefined, 77 | alias: [], 78 | siblings: [], 79 | }, 80 | four: { 81 | color: '#222222', 82 | description: undefined, 83 | alias: [], 84 | siblings: [], 85 | }, 86 | }, 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /web/public/img/logos/labelsync.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Logo Blue 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | label/sync 27 | 28 | 29 | -------------------------------------------------------------------------------- /web/pages/api/queue/add.ts: -------------------------------------------------------------------------------- 1 | import { users, requireAuth, RequireAuthProp } from '@clerk/nextjs/api' 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | import * as z from 'zod' 4 | 5 | import * as tasks from 'lib/tasks' 6 | 7 | const schema = z.discriminatedUnion('kind', [ 8 | z.object({ 9 | kind: z.literal('onboard_org'), 10 | ghInstallationId: z.number(), 11 | org: z.string(), 12 | accountType: z.string(), 13 | }), 14 | z.object({ 15 | kind: z.literal('sync_org'), 16 | ghInstallationId: z.number(), 17 | isPaidPlan: z.boolean(), 18 | 19 | org: z.string(), 20 | }), 21 | z.object({ 22 | kind: z.literal('sync_repo'), 23 | ghInstallationId: z.number(), 24 | isPaidPlan: z.boolean(), 25 | 26 | repo: z.string(), 27 | org: z.string(), 28 | }), 29 | z.object({ 30 | kind: z.literal('dryrun_config'), 31 | ghInstallationId: z.number(), 32 | isPaidPlan: z.boolean(), 33 | 34 | org: z.string(), 35 | pr_number: z.number(), 36 | }), 37 | z.object({ 38 | kind: z.literal('add_siblings'), 39 | ghInstallationId: z.number(), 40 | isPaidPlan: z.boolean(), 41 | 42 | repo: z.string(), 43 | org: z.string(), 44 | 45 | issue_number: z.number(), 46 | label: z.string(), 47 | }), 48 | z.object({ 49 | kind: z.literal('check_unconfigured_labels'), 50 | ghInstallationId: z.number(), 51 | isPaidPlan: z.boolean(), 52 | 53 | repo: z.string(), 54 | org: z.string(), 55 | 56 | label: z.string(), 57 | }), 58 | ]) 59 | 60 | export default requireAuth(async (req: RequireAuthProp, res: NextApiResponse) => { 61 | if (!req.auth.userId) { 62 | res.status(403).json({ message: 'Unauthenticated' }) 63 | return 64 | } 65 | 66 | const user = await users.getUser(req.auth.userId) 67 | console.log(user) 68 | 69 | if (!user.publicMetadata['is_admin']) { 70 | res.status(403).json({ message: 'Unauthorized, You Need Admin Privliges!' }) 71 | return 72 | } 73 | 74 | const parsed = schema.safeParse(JSON.parse(req.body)) 75 | 76 | if (!parsed.success) { 77 | res.status(400).json({ message: parsed.error.message }) 78 | return 79 | } 80 | 81 | const id = await tasks.shared.push({ 82 | ...parsed.data, 83 | dependsOn: [], 84 | }) 85 | 86 | res.status(200).json({ id }) 87 | }) 88 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Packages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | timeout-minutes: 15 13 | 14 | services: 15 | postgres: 16 | image: postgres:13 17 | env: 18 | POSTGRES_USER: prisma 19 | POSTGRES_PASSWORD: prisma 20 | POSTGRES_DB: prisma 21 | ports: 22 | - 5432:5432 23 | # Add a health check 24 | options: >- 25 | --health-cmd pg_isready 26 | --health-interval 10s 27 | --health-timeout 5s 28 | --health-retries 5 29 | redis: 30 | image: redis:7 31 | ports: 32 | - 6379:6379 33 | 34 | steps: 35 | - name: Checkout Main 36 | uses: actions/checkout@v2 37 | with: 38 | fetch-depth: 0 39 | 40 | - name: Use Node 41 | uses: actions/setup-node@v2 42 | with: 43 | node-version-file: '.nvmrc' 44 | cache: 'yarn' 45 | 46 | - name: Install dependencies 47 | run: yarn install 48 | 49 | - name: SetUp Test Database 50 | run: yarn workspace @labelsync/database deploy 51 | env: 52 | DATABASE_URL: postgresql://prisma:prisma@localhost:5432/prisma 53 | 54 | - name: Test Packages 55 | run: yarn test 56 | env: 57 | DATABASE_URL: postgresql://prisma:prisma@localhost:5432/prisma 58 | REDIS_URL: redis://localhost:6379 59 | 60 | publish: 61 | runs-on: ubuntu-latest 62 | 63 | timeout-minutes: 5 64 | 65 | needs: 66 | - test 67 | 68 | steps: 69 | - name: Checkout Main 70 | uses: actions/checkout@v2 71 | with: 72 | fetch-depth: 0 73 | 74 | - name: Use Node 75 | uses: actions/setup-node@v2 76 | with: 77 | node-version: '16.x' 78 | 79 | - name: Install dependencies 80 | run: yarn install 81 | 82 | - name: Create Release Pull Request or Publish to npm 83 | id: changesets 84 | uses: changesets/action@v1 85 | with: 86 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 87 | publish: yarn release 88 | env: 89 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 90 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 | 7 |
8 | Website 9 |   •   10 | Docs 11 |   •   12 | Pricing 13 |   •   14 | Subscribe 15 |
16 | 17 | > Managing multiple repositories is hard. LabelSync helps you manage labels across your repository fleet. 18 | 19 | [![CircleCI](https://circleci.com/gh/maticzav/label-sync/tree/master.svg?style=shield)](https://circleci.com/gh/maticzav/label-sync/tree/master) 20 | [![codecov](https://codecov.io/gh/maticzav/label-sync/branch/master/graph/badge.svg)](https://codecov.io/gh/maticzav/label-sync) 21 | [![npm version](https://badge.fury.io/js/label-sync.svg)](https://badge.fury.io/js/label-sync) 22 | 23 | ## Why LabelSync? 24 | 25 | While working at Prisma, I discovered that many companies struggle with repository organisation. In particular, companies struggle with managing labels across multiple repositories in their organisation. 26 | 27 | My vision is to develop the best in class software that would help companies triage issues and pull requests, and simplify the use of labels. 28 | 29 | ## Features and Quirks of LabelSync 30 | 31 | Label Sync helps you sync Github labels across multiple repositories: 32 | 33 | - 🛰 **Centralised management**: Handle multiple repositories from a central configuration. 34 | - 👮 **Restricts unconfigured labels**: Prevent adding new labels that don't fit into your workflow. 35 | - 🐣 **Aliases**: Quickly rename old labels to a new label. 36 | - 🎢 **Siblings**: Create workflows with labels. 37 | 38 | ## F.A.Q 39 | 40 | #### Is LabelSync free? 41 | 42 | LabelSync will remain free while in beta. 43 | 44 | #### I have a problem but don't know who to ask. 45 | 46 | Please open up an issue describing your problem, or send us an email to support@label-sync.com. 47 | 48 | #### I have an idea/problem that LabelSync could solve. 49 | 50 | Please reach out to matic@label-sync.com. I'd be more than happy to chat about LabelSync with you! 51 | 52 | ## License 53 | 54 | BSD 3-Clause, see the [LICENSE](./LICENSE) file. 55 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | const chalk = require('chalk') 5 | const execa = require('execa') 6 | const ml = require('multilines').default 7 | 8 | /* Constants */ 9 | 10 | const PACKAGES_DIR = path.resolve(__dirname, '../packages') 11 | 12 | /* Find all packages */ 13 | 14 | const packages = fs 15 | .readdirSync(PACKAGES_DIR) 16 | .map((file) => path.resolve(PACKAGES_DIR, file)) 17 | .filter((f) => fs.lstatSync(path.resolve(f)).isDirectory()) 18 | 19 | const builds = packages.filter((p) => fs.existsSync(path.resolve(p, 'tsconfig.json'))) 20 | 21 | /* Build */ 22 | 23 | console.log(ml` 24 | | ${chalk.reset.inverse.bold.cyan(' BUILDING ')} 25 | | ${builds.map((build) => `- ${build}`).join('\n')} 26 | `) 27 | 28 | const args = ['-b', ...builds, ...process.argv.slice(2)] 29 | 30 | console.log(chalk.inverse('Building TypeScript definition files\n')) 31 | 32 | try { 33 | execa.sync('tsc', args, { stdio: 'inherit' }) 34 | process.stdout.write(`${chalk.reset.inverse.bold.green(' DONE ')}\n`) 35 | } catch (e) { 36 | process.stdout.write('\n') 37 | console.error(chalk.inverse.red('Unable to build TypeScript definition files')) 38 | console.error(e.stack) 39 | process.exitCode = 1 40 | } 41 | 42 | /** 43 | MIT License 44 | 45 | For Jest software 46 | 47 | Copyright (c) 2014-present, Facebook, Inc. 48 | 49 | Permission is hereby granted, free of charge, to any person obtaining a copy 50 | of this software and associated documentation files (the "Software"), to deal 51 | in the Software without restriction, including without limitation the rights 52 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 53 | copies of the Software, and to permit persons to whom the Software is 54 | furnished to do so, subject to the following conditions: 55 | 56 | The above copyright notice and this permission notice shall be included in all 57 | copies or substantial portions of the Software. 58 | 59 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 60 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 61 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 62 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 63 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 64 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 65 | SOFTWARE. 66 | */ 67 | -------------------------------------------------------------------------------- /workers/sync/src/processors/onboardingProcessor.ts: -------------------------------------------------------------------------------- 1 | import { getLSConfigRepoName } from '@labelsync/config' 2 | 3 | import { Processor } from '../lib/processor' 4 | import { populateTemplate, TEMPLATES } from '../lib/templates' 5 | 6 | type ProcessorData = { 7 | owner: string 8 | /** 9 | * The type of the account ("organization", "personal"). 10 | */ 11 | accountType: string 12 | } 13 | 14 | /** 15 | * Processor that onboards an organization to the platform. 16 | */ 17 | export class OnboardingProcessor extends Processor { 18 | async perform({ owner, accountType }: ProcessorData): Promise { 19 | this.log.info(`Onboarding "${owner}"!`) 20 | 21 | const configRepoName = getLSConfigRepoName(owner) 22 | 23 | const accessibleRepos = await this.endpoints.checkInstallationAccess({ 24 | owner, 25 | repos: [], 26 | }) 27 | if (accessibleRepos == null) { 28 | throw new Error(`Couldn't check repository access.`) 29 | } 30 | 31 | // If configuration repository already exists, there's no need to bootstrap anything. 32 | const repo = await this.endpoints.getRepo({ owner, repo: configRepoName }) 33 | if (repo != null) { 34 | this.log.info(`Configuration repository already exists, skipping onboarding.`) 35 | return 36 | } 37 | 38 | this.log.info(`No existing repository for ${owner}, start onboarding!`) 39 | 40 | switch (accountType) { 41 | case 'Organization': { 42 | this.log.info(`Bootstraping config repo for ${owner}.`) 43 | 44 | // Bootstrap a configuration repository in organisation. 45 | const repositories = accessibleRepos.accessible.map((name) => ({ name })) 46 | const personalisedTemplate = populateTemplate(TEMPLATES.yaml, { 47 | repository: configRepoName, 48 | repositories, 49 | }) 50 | await this.endpoints.bootstrapConfigRepository({ owner, repo: configRepoName }, personalisedTemplate) 51 | 52 | this.log.info(`Onboarding complete for ${owner}.`) 53 | return 54 | } 55 | 56 | /* istanbul ignore next */ 57 | case 'User': { 58 | // TODO: Allow personal account scaffolding once Github provides support. 59 | this.log.info(`User account ${owner}, skip onboarding.`) 60 | return 61 | } 62 | 63 | /* istanbul ignore next */ 64 | default: { 65 | this.log.warn(`Unsupported account type: ${accountType}`) 66 | return 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /templates/yaml/labelsync.yml: -------------------------------------------------------------------------------- 1 | {{! we use Handlebars to personalise initial configuration. }} 2 | {{! IF YOU SEE THIS LINE SOMETHING BROKE. PLEASE RETRY SCAFFOLDING }} 3 | 4 | # Colors 5 | colors: 6 | area: &area '#FFD700' 7 | kind: &kind '#3B5BDB' 8 | status: &status '#F8F9FA' 9 | bug: &bug '#EE0000' 10 | priority: &priority '#F783AC' 11 | scope: &scope '#27CF79' 12 | team: &team'#FDF4E8' 13 | release: &release '#A5D8ff' 14 | process: &process '#EB9100' 15 | 16 | # There's one YAML feature that you *absolutely* want to use in your config. 17 | # 18 | # It's references. You make a referencable value using &name, and reuse it 19 | # by typing *name. If you are referencing an object (like "labels" below), use 20 | # "<<: *common" to spread the values out (like "repos" below "labels"). 21 | # 22 | # To learn more about the special YAML symbols, check out 23 | # https://medium.com/@kinghuang/docker-compose-anchors-aliases-extensions-a1e4105d70bd. 24 | 25 | # Labels 26 | labels: &common 27 | 'bug/0-needs-info': 28 | color: *bug 29 | description: More information is needed for reproduction. 30 | siblings: ["kind/bug"] 31 | 'bug/1-repro-available': 32 | color: *bug 33 | description: A reproduction exists and needs to be confirmed. 34 | siblings: ["kind/bug"] 35 | 'bug/2-confirmed': 36 | color: *bug 37 | description: We have confirmed that this is a bug. 38 | siblings: ["kind/bug"] 39 | 'kind/bug': 40 | color: *bug 41 | description: A reported bug. 42 | alias: ["bug"] 43 | 'kind/regression': 44 | color: *kind 45 | description: A reported bug in functionality that used to work before. 46 | 'kind/feature': 47 | color: *kind 48 | description: A request for a new feature. 49 | alias: ["enhancement"] 50 | 'kind/improvement': 51 | color: *kind 52 | description: An improvement to existing feature and code. 53 | 'kind/docs': 54 | color: *kind 55 | description: A documentation change is required. 56 | alias: ["documentation"] 57 | 'kind/discussion': 58 | color: *kind 59 | description: Discussion is required. 60 | 'kind/question': 61 | color: *kind 62 | description: Developer asked a question. No code changes required. 63 | alias: ["question"] 64 | 65 | # Repositories 66 | repos: 67 | {{#each repositories}} 68 | {{#with this}} 69 | {{name}}: 70 | config: 71 | removeUnconfiguredLabels: false 72 | labels: {} 73 | # <<: *common 74 | # Add labels here. 75 | {{/with}} 76 | {{/each}} 77 | 78 | 79 | -------------------------------------------------------------------------------- /web/lib/tasks.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import * as redis from 'redis' 3 | 4 | import type { Task } from '@labelsync/queues' 5 | import { UnionOmit } from './utils' 6 | 7 | /** 8 | * A general purpose queue backed by Redis. 9 | */ 10 | export class TaskQueue { 11 | /** 12 | * Name of the queue. 13 | */ 14 | private name: string 15 | 16 | /** 17 | * Client to use for communication with the Redis queue. 18 | */ 19 | private client: redis.RedisClientType 20 | 21 | private connected: boolean = false 22 | 23 | constructor({ name, url }: { name: string; url: string }) { 24 | this.name = name 25 | this.client = redis.createClient({ url }) 26 | } 27 | 28 | /** 29 | * Starts the queue and connects to Redis. 30 | */ 31 | public async start(): Promise { 32 | if (!this.connected) { 33 | await this.client.connect() 34 | } 35 | this.connected = true 36 | } 37 | 38 | /** 39 | * Returns the list identifier for the queue. 40 | */ 41 | private queue(): string { 42 | return `queue:${this.name}` 43 | } 44 | 45 | /** 46 | * Returns the name of the set used to list unprocessed tasks. 47 | */ 48 | private members(): string { 49 | return `queuemembers:${this.name}` 50 | } 51 | 52 | /** 53 | * Lists all tasks that are currently in the queue. 54 | */ 55 | public async list(): Promise { 56 | if (!this.connected) { 57 | await this.start() 58 | } 59 | 60 | const rawtasks = await this.client.LRANGE(this.queue(), 0, -1) 61 | const tasks = rawtasks.map((raw) => JSON.parse(raw) as Task) 62 | 63 | return tasks 64 | } 65 | 66 | /** 67 | * Function that pushes a new task to the queue and returns 68 | * the task identifier. 69 | */ 70 | public async push(task: UnionOmit): Promise { 71 | if (!this.connected) { 72 | await this.start() 73 | } 74 | 75 | const id = crypto.randomUUID() 76 | 77 | await Promise.all([ 78 | this.client.RPUSH(this.queue(), JSON.stringify({ id, ...task })), 79 | this.client.SADD(this.members(), id), 80 | ]) 81 | 82 | return id 83 | } 84 | 85 | /** 86 | * Stops the server. 87 | */ 88 | public async dispose() { 89 | await this.client.disconnect() 90 | } 91 | } 92 | 93 | /** 94 | * Shared task queue that may be references by multiple API endpoints. 95 | */ 96 | export const shared = new TaskQueue({ 97 | name: 'tasks', 98 | url: process.env.REDIS_URL!, 99 | }) 100 | -------------------------------------------------------------------------------- /templates/typescript/repos/prisma.ts: -------------------------------------------------------------------------------- 1 | import { repo, label, colors } from 'label-sync' 2 | 3 | /** 4 | * Label configuration used internally by Prisma team. Labels are grouped 5 | * by their intention (e.g. bug/*, kind/*, process/*) and give 6 | * great base for issue triaging. 7 | */ 8 | 9 | export const prisma = repo({ 10 | config: { 11 | removeUnconfiguredLabels: false, 12 | }, 13 | labels: [ 14 | /* Bugs */ 15 | label({ 16 | name: 'bug/0-needs-info', 17 | color: colors.danger, 18 | description: 'More information is needed for reproduction.', 19 | }), 20 | label({ 21 | name: 'bug/1-repro-available', 22 | color: colors.danger, 23 | description: 'A reproduction exists and needs to be confirmed.', 24 | }), 25 | label({ 26 | name: 'bug/2-confirmed', 27 | color: colors.danger, 28 | description: 'We have confirmed that this is a bug.', 29 | }), 30 | /* Kind */ 31 | label({ 32 | name: 'kind/bug', 33 | color: colors.neutral, 34 | description: 'A reported bug.', 35 | }), 36 | label({ 37 | name: 'kind/regression', 38 | color: colors.neutral, 39 | description: 'A reported bug in functionality that used to work before.', 40 | }), 41 | label({ 42 | name: 'kind/feature', 43 | color: colors.neutral, 44 | description: 'A request for a new feature.', 45 | }), 46 | label({ 47 | name: 'kind/improvement', 48 | color: colors.neutral, 49 | description: 'An improvement to existing feature and code.', 50 | }), 51 | label({ 52 | name: 'kind/docs', 53 | color: colors.neutral, 54 | description: 'A documentation change is required.', 55 | }), 56 | label({ 57 | name: 'kind/discussion', 58 | color: colors.neutral, 59 | description: 'Discussion is required.', 60 | }), 61 | label({ 62 | name: 'kind/question', 63 | color: colors.neutral, 64 | description: 'Developer asked a question. No code changes required.', 65 | }), 66 | /* Process triaging. */ 67 | label({ 68 | name: 'process/candidate', 69 | color: colors.shiny, 70 | description: 'Candidate for next Milestone.', 71 | }), 72 | label({ 73 | name: 'process/next-milestone', 74 | color: colors.shiny, 75 | description: 'Issue earmarked for next Milestone.', 76 | }), 77 | label({ 78 | name: 'process/product', 79 | color: colors.shiny, 80 | description: 'Temporary label to export products issues from the Engineering process', 81 | }), 82 | ], 83 | }) 84 | -------------------------------------------------------------------------------- /server/src/routes/stripe.events.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser' 2 | import { Router } from 'express' 3 | import { DateTime } from 'luxon' 4 | import Stripe from 'stripe' 5 | 6 | import { config } from '../lib/config' 7 | import { Sources } from '../lib/sources' 8 | 9 | /** 10 | * Routes associated with Stripe Webhooks. 11 | */ 12 | export const stripe = (router: Router, sources: Sources) => { 13 | // Stripe Webhook handler 14 | router.post('/', bodyParser.raw({ type: 'application/json' }), async (req, res) => { 15 | let event 16 | 17 | try { 18 | event = sources.stripe.webhooks.constructEvent( 19 | req.body, 20 | req.headers['stripe-signature'] as string, 21 | config.stripeEndpointSecret, 22 | ) 23 | } catch (err: any) /* istanbul ingore next */ { 24 | sources.log.error(err, `Error in stripe webhook deconstruction.`) 25 | res.status(400).send(`Webhook Error: ${err.message}`) 26 | 27 | return 28 | } 29 | 30 | /* Logger */ 31 | 32 | sources.log.info(event.data, `Stripe WebHook event "${event.type}"`) 33 | 34 | /* Event handlers */ 35 | 36 | switch (event.type) { 37 | /* Customer successfully subscribed to LabelSync */ 38 | case 'checkout.session.completed': 39 | /* Customer paid an invoice */ 40 | case 'invoice.payment_succeeded': { 41 | const payload = event.data.object as { subscription: string } 42 | 43 | const sub = await sources.stripe.subscriptions.retrieve(payload.subscription) 44 | if (sub.status !== 'active') { 45 | sources.log.info(`Subscription ${payload.subscription} is not active.`) 46 | return 47 | } 48 | 49 | let customer: Stripe.Customer 50 | if (typeof sub.customer === 'string') { 51 | customer = (await sources.stripe.customers.retrieve(sub.customer)) as Stripe.Customer 52 | } else { 53 | customer = sub.customer as Stripe.Customer 54 | } 55 | 56 | const installation = await sources.installations.upgrade({ 57 | account: sub.metadata.account, 58 | periodEndsAt: DateTime.fromMillis(sub.current_period_end), 59 | email: customer.email, 60 | }) 61 | 62 | sources.log.info(sub.metadata, `New subscriber ${installation?.account ?? 'NONE'}`) 63 | 64 | return res.json({ received: true }) 65 | } 66 | 67 | default: { 68 | sources.log.warn(`unhandled stripe webhook event: ${event.type}`) 69 | res.status(400).end() 70 | return 71 | } 72 | } 73 | /* End of Stripe Webhook handler */ 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node' 2 | 3 | import dd from 'pino-datadog' 4 | import pinoms from 'pino-multi-stream' 5 | import { Probot, Server, ApplicationFunctionOptions } from 'probot' 6 | import Stripe from 'stripe' 7 | 8 | import { InstallationsSource } from '@labelsync/database' 9 | import { TaskQueue } from '@labelsync/queues' 10 | 11 | import { config } from './lib/config' 12 | import { Sources } from './lib/sources' 13 | 14 | import { github } from './routes/github.events' 15 | import { status } from './routes/status.route' 16 | import { stripe } from './routes/stripe.events' 17 | 18 | /** 19 | * Utility function that starts the server. 20 | */ 21 | const setup = (app: Probot, { getRouter }: ApplicationFunctionOptions) => { 22 | const sources: Sources = { 23 | installations: new InstallationsSource(), 24 | stripe: new Stripe(config.stripeApiKey, { 25 | apiVersion: '2020-08-27', 26 | }), 27 | tasks: new TaskQueue(config.redisUrl), 28 | log: app.log, 29 | } 30 | 31 | Sentry.init({ 32 | dsn: config.sentryDSN, 33 | environment: config.prod ? 'production' : 'development', 34 | // Set tracesSampleRate to 1.0 to capture 100% 35 | // of transactions for performance monitoring. 36 | // We recommend adjusting this value in production 37 | tracesSampleRate: 0.1, 38 | integrations: [ 39 | // enable HTTP calls tracing 40 | new Sentry.Integrations.Http({ tracing: true }), 41 | ], 42 | }) 43 | 44 | /* Routes */ 45 | 46 | if (!getRouter) { 47 | throw new Error(`Couldn't start app because it's missing router.`) 48 | } 49 | 50 | const stripeRouter = getRouter('/stripe') 51 | stripe(stripeRouter, sources) 52 | 53 | const statusRouter = getRouter('/status') 54 | status(statusRouter, sources) 55 | 56 | /* Events */ 57 | 58 | github(app, sources) 59 | 60 | // Done 61 | 62 | app.log(`LabelSync manager up and running! 🚀`) 63 | } 64 | 65 | // MAIN 66 | 67 | /** 68 | * Main function that creates a log stream and spins up the Probot server. 69 | */ 70 | async function main() { 71 | const writeStream = await dd.createWriteStream({ 72 | apiKey: config.datadogApiKey, 73 | ddsource: 'server', 74 | service: 'label-sync', 75 | }) 76 | 77 | const server = new Server({ 78 | Probot: Probot.defaults({ 79 | appId: config.ghAppId, 80 | privateKey: config.ghPrivateKey, 81 | secret: config.ghSecret, 82 | log: pinoms({ streams: [{ stream: writeStream }] }), 83 | }), 84 | }) 85 | await server.load(setup) 86 | await server.start() 87 | } 88 | 89 | // Start 90 | 91 | if (require.main === module) { 92 | main() 93 | } 94 | -------------------------------------------------------------------------------- /workers/sync/tests/processors/unconfiguredLabels.test.ts: -------------------------------------------------------------------------------- 1 | import ml from 'multilines' 2 | import pino from 'pino' 3 | 4 | import { MockGitHubEndpoints } from '../__fixtures__/endpoints' 5 | 6 | import { UnconfiguredLabelsProcessor } from '../../src/processors/unconfiguredLabelsProcessor' 7 | 8 | describe('unconfigured labels', () => { 9 | test('removes an unconfigured label in strict repository', async () => { 10 | const installation = { id: 1, isPaidPlan: false } 11 | const endpoints = new MockGitHubEndpoints({ 12 | configs: { 13 | 'test-org': ml` 14 | | repos: 15 | | a: 16 | | config: 17 | | removeUnconfiguredLabels: true 18 | | labels: 19 | | 'label/regular': 20 | | color: '000000' 21 | `, 22 | }, 23 | installations: { 24 | 'test-org': ['a'], 25 | }, 26 | }) 27 | const logger = pino() 28 | 29 | const processor = new UnconfiguredLabelsProcessor( 30 | installation, 31 | { 32 | push: () => { 33 | fail() 34 | }, 35 | }, 36 | endpoints, 37 | logger, 38 | ) 39 | 40 | await processor.perform({ 41 | owner: 'test-org', 42 | repo: 'a', 43 | label: 'label/unconfigured', 44 | isPro: true, 45 | }) 46 | 47 | expect(endpoints.stack()).toEqual([ 48 | MockGitHubEndpoints.getConfig({ owner: 'test-org' }), 49 | MockGitHubEndpoints.removeLabel({ 50 | owner: 'test-org', 51 | repo: 'a', 52 | label: { name: 'label/unconfigured' }, 53 | }), 54 | ]) 55 | }) 56 | 57 | test('ignores unconfigured label in non-strict repository', async () => { 58 | const installation = { id: 1, isPaidPlan: false } 59 | const endpoints = new MockGitHubEndpoints({ 60 | configs: { 61 | 'test-org': ml` 62 | | repos: 63 | | a: 64 | | labels: 65 | | 'label/regular': 66 | | color: '000000' 67 | `, 68 | }, 69 | installations: { 70 | 'test-org': ['a'], 71 | }, 72 | }) 73 | const logger = pino() 74 | 75 | const processor = new UnconfiguredLabelsProcessor( 76 | installation, 77 | { 78 | push: () => { 79 | fail() 80 | }, 81 | }, 82 | endpoints, 83 | logger, 84 | ) 85 | 86 | await processor.perform({ 87 | owner: 'test-org', 88 | repo: 'a', 89 | label: 'label/unconfigured', 90 | isPro: true, 91 | }) 92 | 93 | expect(endpoints.stack()).toEqual([ 94 | MockGitHubEndpoints.getConfig({ owner: 'test-org' }), 95 | // 96 | ]) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /workers/sync/src/processors/repositorySyncProcessor.ts: -------------------------------------------------------------------------------- 1 | import { parseConfig } from '@labelsync/config' 2 | 3 | import { calculateConfigurationDiff } from '../lib/config' 4 | import { Processor } from '../lib/processor' 5 | import { famap } from '../lib/utils' 6 | 7 | type ProcessorData = { 8 | owner: string 9 | repo: string 10 | isPro: boolean 11 | } 12 | 13 | /** 14 | * A processor that syncs the configuration with the labels configured in a repository. 15 | */ 16 | export class RepositorySyncProcessor extends Processor { 17 | /** 18 | * Syncs configuration of a repository with its labels. 19 | */ 20 | public async perform({ owner, repo, isPro }: ProcessorData): Promise { 21 | this.log.info(`Syncing repository ${owner}/${repo}...`) 22 | 23 | const rawConfig = await this.endpoints.getConfig({ owner }) 24 | if (rawConfig === null) { 25 | this.log.info(`No configuration, skipping siblings sync.`) 26 | return 27 | } 28 | const parsedConfig = parseConfig({ input: rawConfig, isPro }) 29 | if (!parsedConfig.ok) { 30 | this.log.info(`Invalid configuration file in organization ${owner}...`) 31 | return 32 | } 33 | 34 | const config = parsedConfig.config.repos 35 | const repoconfig = config[repo.toLowerCase()] ?? config['*'] 36 | if (repoconfig == null) { 37 | this.log.info(`No configuration for repository ${owner}/${repo}, skipping sync.`) 38 | return 39 | } 40 | 41 | const removeUnconfiguredLabels = repoconfig.config?.removeUnconfiguredLabels === true 42 | 43 | const remoteLabels = await this.endpoints.getLabels({ repo, owner }) 44 | if (remoteLabels == null) { 45 | throw new Error(`Couldn't fetch current labels for repository ${owner}/${repo}.`) 46 | } 47 | 48 | const { added, changed, aliased, removed } = calculateConfigurationDiff({ 49 | config: repoconfig.labels, 50 | currentLabels: remoteLabels, 51 | }) 52 | 53 | try { 54 | await famap(added, (label) => this.endpoints.createLabel({ owner, repo }, label)) 55 | await famap(changed, (label) => this.endpoints.updateLabel({ owner, repo }, label)) 56 | 57 | if (aliased.length > 0) { 58 | await this.endpoints.aliasLabels({ owner, repo }, aliased) 59 | } 60 | 61 | if (removeUnconfiguredLabels) { 62 | await famap(removed, (label) => this.endpoints.removeLabel({ owner, repo }, label)) 63 | } 64 | 65 | this.log.info(`Sync of ${owner}/${repo} completed.`) 66 | } catch (err: any) /* istanbul ignore next */ { 67 | this.log.error(err, `Something went wrong during the sync of ${owner}/${repo}`) 68 | } 69 | 70 | return 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/create-label-sync/src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | 4 | import { promisify } from 'util' 5 | 6 | const mkdir = promisify(fs.mkdir) 7 | const writeFile = promisify(fs.writeFile) 8 | 9 | type Dict = { [key: string]: T } 10 | 11 | /** 12 | * Negates the wrapped function. 13 | */ 14 | export function not>(fn: (...args: L) => boolean): (...args: L) => boolean { 15 | return (...args) => !fn(...args) 16 | } 17 | 18 | /** 19 | * Loads a tree of utf-8 decoded files at paths. 20 | */ 21 | export function loadTreeFromPath(root: string, ignore: (string | RegExp)[]): { [path: string]: string } { 22 | const files = fs.readdirSync(root, { encoding: 'utf-8' }) 23 | const tree = files 24 | .filter((file) => !ignore.some((glob) => RegExp(glob).test(file))) 25 | .flatMap((file) => { 26 | const rootFilePath = path.resolve(root, file) 27 | if (fs.lstatSync(rootFilePath).isDirectory()) { 28 | return Object.entries(mapKeys(loadTreeFromPath(rootFilePath, ignore), (key) => unshift(file, key))) 29 | } else { 30 | return [[file, fs.readFileSync(rootFilePath, { encoding: 'utf-8' })]] 31 | } 32 | }) 33 | 34 | return Object.fromEntries(tree) 35 | } 36 | 37 | /** 38 | * Adds a folder to the path. 39 | */ 40 | function unshift(pre: string, path: string): string { 41 | return [pre, ...path.split('/').filter(Boolean)].join('/') 42 | } 43 | 44 | /** 45 | * Maps entries in an object. 46 | */ 47 | export function mapEntries(m: Dict, fn: (v: T, key: string) => V): Dict { 48 | return Object.fromEntries( 49 | Object.keys(m).map((key) => { 50 | return [key, fn(m[key], key)] 51 | }), 52 | ) 53 | } 54 | 55 | /** 56 | * Writes virtual file system representation to the file system. 57 | */ 58 | export async function writeTreeToPath(root: string, tree: { [path: string]: string }): Promise { 59 | tree = mapKeys(tree, (file) => path.resolve(root, file)) 60 | 61 | const actions = Object.keys(tree).map(async (filePath) => { 62 | await mkdir(path.dirname(filePath), { recursive: true }) 63 | await writeFile(filePath, tree[filePath]) 64 | }) 65 | 66 | try { 67 | await Promise.all(actions) 68 | } catch (err) /* istanbul ignore next */ { 69 | throw err 70 | } 71 | } 72 | 73 | /** 74 | * Maps keys of an object. 75 | */ 76 | export function mapKeys(m: Dict, fn: (key: string, v: T) => string): Dict { 77 | return Object.fromEntries( 78 | Object.keys(m).map((key) => { 79 | return [fn(key, m[key]), m[key]] 80 | }), 81 | ) 82 | } 83 | 84 | /** 85 | * Creates a fallback default value. 86 | */ 87 | export function withDefault(fallback: T, value: T | undefined): T { 88 | if (value) return value 89 | else return fallback 90 | } 91 | -------------------------------------------------------------------------------- /packages/label-sync/tests/make.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { promisify } from 'util' 4 | 5 | import { parseConfig } from '@labelsync/config' 6 | 7 | import * as ls from '../src' 8 | import { labelsync } from '../src' 9 | 10 | const fsReadFile = promisify(fs.readFile) 11 | const fsUnlink = promisify(fs.unlink) 12 | 13 | describe('make:', () => { 14 | test('compiles configuration to path', async () => { 15 | const yamlPath = path.resolve(__dirname, 'labelsync.yml') 16 | 17 | labelsync( 18 | { 19 | repos: { 20 | 'prisma-test-utils': ls.repo({ 21 | config: { 22 | removeUnconfiguredLabels: true, 23 | }, 24 | labels: [ls.label('bug/0-needs-reproduction', '#ff0022')], 25 | }), 26 | }, 27 | }, 28 | yamlPath, 29 | ) 30 | 31 | const file = await fsReadFile(yamlPath, { encoding: 'utf-8' }) 32 | await fsUnlink(yamlPath) 33 | 34 | expect(file).toMatchSnapshot() 35 | }) 36 | 37 | test('integration test: compiles configuration to default path', async () => { 38 | const yamlPath = path.resolve(__dirname, './__fixtures__/labelsync.yml') 39 | 40 | labelsync( 41 | { 42 | repos: { 43 | 'prisma-test-utils': ls.repo({ 44 | config: { 45 | removeUnconfiguredLabels: true, 46 | }, 47 | labels: [ 48 | ls.label('kind/bug', '#02f5aa'), 49 | ls.label({ 50 | name: 'bug/1-has-reproduction', 51 | color: '#ff0022', 52 | description: 'Indicates that an issue has reproduction', 53 | alias: ['bug'], 54 | siblings: ['kind/bug'], 55 | }), 56 | ls.type('bug', '#ff0022'), 57 | ], 58 | }), 59 | changed: ls.repo({ 60 | config: { 61 | removeUnconfiguredLabels: true, 62 | }, 63 | labels: [ 64 | ls.label('kind/bug', '02f5aa'), 65 | ls.label({ 66 | name: 'bug/1-has-reproduction', 67 | color: '#ff0022', 68 | description: 'Indicates that an issue has reproduction', 69 | alias: ['bug'], 70 | siblings: ['kind/bug'], 71 | }), 72 | ], 73 | }), 74 | }, 75 | }, 76 | undefined, 77 | path.resolve(__dirname, './__fixtures__/'), 78 | ) 79 | 80 | const file = await fsReadFile(yamlPath, { encoding: 'utf-8' }) 81 | await fsUnlink(yamlPath) 82 | 83 | expect(file).toMatchSnapshot() 84 | 85 | const config = parseConfig({ 86 | input: file, 87 | isPro: true, 88 | }) 89 | 90 | expect(config).toMatchSnapshot() 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /workers/sync/src/lib/templates.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs' 3 | import * as handlebars from 'handlebars' 4 | import * as prettier from 'prettier' 5 | 6 | import { FileTree } from './filetree' 7 | import { mapKeys, mapEntries } from '../data/dict' 8 | 9 | const IGNORED_FILES = ['dist', 'node_modules', '.DS_Store', /.*\.log.*/, /.*\.lock.*/] 10 | 11 | const TEMPLATES_PATH = path.resolve(__dirname, '../../../../templates') 12 | 13 | /** 14 | * Preloaded template files. 15 | */ 16 | export const TEMPLATES = { 17 | yaml: loadTreeFromPath({ 18 | root: path.resolve(TEMPLATES_PATH, 'yaml'), 19 | ignore: IGNORED_FILES, 20 | }), 21 | typescript: loadTreeFromPath({ 22 | root: path.resolve(TEMPLATES_PATH, 'typescript'), 23 | ignore: IGNORED_FILES, 24 | }), 25 | } 26 | 27 | /** 28 | * Loads a tree of utf-8 decoded files at paths. 29 | */ 30 | export function loadTreeFromPath({ root, ignore }: { root: string; ignore: (string | RegExp)[] }): { 31 | [path: string]: string 32 | } { 33 | const files = fs.readdirSync(root, { encoding: 'utf-8' }) 34 | const tree = files 35 | .filter((file) => !ignore.some((glob) => RegExp(glob).test(file))) 36 | .flatMap((file) => { 37 | const rootFilePath = path.resolve(root, file) 38 | if (fs.lstatSync(rootFilePath).isDirectory()) { 39 | return Object.entries(mapKeys(loadTreeFromPath({ root: rootFilePath, ignore }), (key) => unshift(file, key))) 40 | } else { 41 | return [[file, fs.readFileSync(rootFilePath, { encoding: 'utf-8' })]] 42 | } 43 | }) 44 | 45 | return Object.fromEntries(tree) 46 | } 47 | 48 | /** 49 | * Adds a folder to the path. 50 | * @param pre 51 | * @param path 52 | */ 53 | function unshift(pre: string, path: string): string { 54 | return [pre, ...path.split('/').filter(Boolean)].join('/') 55 | } 56 | 57 | /** 58 | * Populates the template with repositories. 59 | * @param tree 60 | */ 61 | export function populateTemplate( 62 | tree: FileTree, 63 | data: { 64 | repository: string 65 | repositories: { name: string }[] 66 | }, 67 | ): FileTree { 68 | return mapEntries(tree, (file, name) => { 69 | /* Personalize file */ 70 | const populatedFile = handlebars.compile(file)(data) 71 | 72 | /* Format it */ 73 | switch (path.extname(name)) { 74 | case '.ts': { 75 | return prettier.format(populatedFile, { parser: 'typescript' }) 76 | } 77 | case '.yml': { 78 | return prettier.format(populatedFile, { parser: 'yaml' }) 79 | } 80 | case '.md': { 81 | return prettier.format(populatedFile, { parser: 'markdown' }) 82 | } 83 | case '.json': { 84 | return prettier.format(populatedFile, { parser: 'json' }) 85 | } 86 | default: { 87 | return populatedFile 88 | } 89 | } 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /web/components/Table.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import classnames from 'classnames' 3 | 4 | interface TableProps { 5 | header: string 6 | description: string 7 | columns: { label: string; key: T }[] 8 | data: ({ id: string } & { [P in T]?: string })[] 9 | } 10 | 11 | /** 12 | * A component that presents data in a table. 13 | */ 14 | export function Table({ header, description, columns, data }: TableProps) { 15 | return ( 16 |
17 |
18 |
19 |

{header}

20 |

{description}

21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 | 29 | {/* Header */} 30 | 31 | 32 | {columns.map((column, i) => ( 33 | 43 | ))} 44 | 45 | 46 | 47 | {/* Data */} 48 | 49 | {data.map((row) => ( 50 | 51 | {columns.map((column, i) => ( 52 | 61 | ))} 62 | 63 | ))} 64 | 65 |
41 | {column.label} 42 |
59 | {row[column.key] ?? '-'} 60 |
66 |
67 |
68 |
69 |
70 |
71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /packages/label-sync/src/presets.ts: -------------------------------------------------------------------------------- 1 | import { Label } from './generator' 2 | 3 | export const colors = { 4 | neutral: '#EEEEEE', 5 | refine: '#fcaeec', 6 | shiny: '#3BDB8D', 7 | semiShiny: '#9cedc6', 8 | danger: '#FF5D5D', 9 | warning: '#FFCF2D', 10 | social: '#7057ff', 11 | } 12 | 13 | /** 14 | * Based off conventional commit notion of type. The kind of issue. 15 | * These labels may get their own colour to help visually differentiate 16 | * between them faster. The commit that closes this issue should generally 17 | * be of the same type as this label. 18 | */ 19 | export function type(name: string, color: string, description?: string): Label { 20 | return new Label({ 21 | name: `type/${name}`, 22 | color: color, 23 | description: description, 24 | }) 25 | } 26 | 27 | /** 28 | * Labels that help us track issue short-circuites or other minimal 29 | * categorical details. 30 | */ 31 | export function note(name: string, description?: string): Label { 32 | return new Label({ 33 | name: `note/${name}`, 34 | color: colors.neutral, 35 | description: description, 36 | }) 37 | } 38 | 39 | /** 40 | * Labels that help us track how impactful issues will be. Combined 41 | * with complexity label, helps inform prioritization. 42 | */ 43 | export function impact(name: string, description?: string): Label { 44 | return new Label({ 45 | name: `impact/${name}`, 46 | color: colors.neutral, 47 | description: description, 48 | }) 49 | } 50 | 51 | /** 52 | * Effort that help us track how impactful issues will be. Combined 53 | * with complexity label, helps inform prioritization. 54 | */ 55 | export function effort(name: string, description?: string): Label { 56 | return new Label({ 57 | name: `effort/${name}`, 58 | color: colors.neutral, 59 | description: description, 60 | }) 61 | } 62 | 63 | /** 64 | * Labels that help us mark issues as being on hold for some reason. 65 | */ 66 | export function needs(name: string, description?: string): Label { 67 | return new Label({ 68 | name: `needs/${name}`, 69 | color: colors.warning, 70 | description: description, 71 | }) 72 | } 73 | 74 | /** 75 | * Based off conventional commit notion of scope. What area of 76 | * the project does the issue touch. The commit that closes this issue 77 | * should generally be of the same scope as this label. 78 | */ 79 | export function scope(name: string, description?: string): Label { 80 | return new Label({ 81 | name: `scope/${name}`, 82 | color: '#94ebfc', 83 | description: description, 84 | }) 85 | } 86 | 87 | /** 88 | * Labels that help us coordinate with the community. 89 | */ 90 | export function community(name: string, description?: string): Label { 91 | return new Label({ 92 | name: `community/${name}`, 93 | color: colors.social, 94 | description: description, 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /web/components/Feature.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react' 2 | 3 | export interface Feature { 4 | icon: ReactElement 5 | caption: string 6 | title: [string, string] 7 | description: ReactElement 8 | image: string 9 | alt: string 10 | } 11 | 12 | export function Left(props: Feature) { 13 | return ( 14 |
15 | {/* Text */} 16 |
17 |
18 | {props.caption} 19 |
20 |

21 | {props.icon} 22 | 23 | {props.title[0]} 24 |
25 | {props.title[1]} 26 |

27 |

28 | {props.description} 29 |

30 |
31 | 32 | {/* Image */} 33 | 34 |
35 |
36 | {props.alt} 41 |
42 |
43 |
44 | ) 45 | } 46 | 47 | export function Right(props: Feature) { 48 | return ( 49 |
50 | {/* Text */} 51 |
52 |
53 | {props.caption} 54 |
55 |

56 | {props.icon} 57 | {props.title[0]} 58 |
59 | {props.title[1]} 60 |

61 |

62 | {props.description} 63 |

64 |
65 | 66 | {/* Image */} 67 | 68 |
69 |
70 | {props.alt} 71 |
72 |
73 |
74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /workers/sync/src/processors/organizationSyncProcessor.ts: -------------------------------------------------------------------------------- 1 | import { getLSConfigRepoName, getPhysicalRepositories, parseConfig } from '@labelsync/config' 2 | 3 | import { messages } from '../lib/constants' 4 | import { Processor } from '../lib/processor' 5 | 6 | type ProcessorData = { 7 | owner: string 8 | isPro: boolean 9 | } 10 | 11 | /** 12 | * A processor that verifies configuration and syncs the configuration with 13 | * the labels configured in each configured repository. 14 | */ 15 | export class OrganizationSyncProcessor extends Processor { 16 | /** 17 | * Syncs configuration of a repository with its labels. 18 | */ 19 | public async perform({ owner, isPro }: ProcessorData) { 20 | this.log.info(`Performing organization sync for "${owner}"`) 21 | const configRepoName = getLSConfigRepoName(owner) 22 | const rawConfig = await this.endpoints.getConfig({ owner }) 23 | 24 | /* No configuration, skip the evaluation. */ 25 | if (rawConfig === null) { 26 | this.log.info(`No configuration, skipping sync.`) 27 | return 28 | } 29 | const parsedConfig = parseConfig({ input: rawConfig, isPro }) 30 | 31 | if (!parsedConfig.ok) { 32 | this.log.info(`Error in configuration, openning issue.`, { 33 | meta: { config: rawConfig, error: parsedConfig.error }, 34 | }) 35 | 36 | const title = 'Configuration Issue' 37 | const body = messages['onboarding.error.issue'](parsedConfig.error) 38 | const issue = await this.endpoints.openIssue({ owner, repo: configRepoName }, { title, body }) 39 | if (issue == null) { 40 | throw new Error(`Couldn't open an issue...`) 41 | } 42 | 43 | this.log.info(`Opened issue ${issue.id} in ${owner}/${configRepoName}.`) 44 | 45 | return 46 | } 47 | 48 | // Verify that we can access all configured files. 49 | const requiredRepositories = getPhysicalRepositories(parsedConfig.config) 50 | const access = await this.endpoints.checkInstallationAccess({ 51 | owner, 52 | repos: requiredRepositories, 53 | }) 54 | 55 | if (access == null) { 56 | throw new Error(`Couldn't check repository access.`) 57 | } 58 | 59 | if (access.status === 'Sufficient') { 60 | // Even if there's no wildcard configuration, we try to sync all accessible repositories. 61 | for (const repo of access.accessible) { 62 | this.queue.push({ 63 | kind: 'sync_repo', 64 | repo: repo, 65 | org: owner, 66 | dependsOn: [], 67 | ghInstallationId: this.installation.id, 68 | isPaidPlan: isPro, 69 | }) 70 | } 71 | return 72 | } 73 | 74 | this.log.info(`Insufficient permissions, skipping sync.`, { 75 | meta: { access: JSON.stringify(access) }, 76 | }) 77 | 78 | const title = 'Insufficient Permissions' 79 | const body = messages['insufficient.permissions.issue'](access.missing) 80 | 81 | const issue = await this.endpoints.openIssue({ owner, repo: configRepoName }, { title, body }) 82 | if (issue == null) { 83 | throw new Error(`Couldn't open an issue...`) 84 | } 85 | 86 | this.log.info(`Opened issue ${issue.id} in ${owner}/${configRepoName}.`) 87 | 88 | return 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /packages/label-sync/tests/__snapshots__/make.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`make: compiles configuration to path 1`] = ` 4 | "repos: 5 | prisma-test-utils: 6 | config: 7 | removeUnconfiguredLabels: true 8 | labels: 9 | bug/0-needs-reproduction: 10 | color: '#ff0022' 11 | alias: [] 12 | siblings: [] 13 | " 14 | `; 15 | 16 | exports[`make: integration test: compiles configuration to default path 1`] = ` 17 | "repos: 18 | prisma-test-utils: 19 | config: 20 | removeUnconfiguredLabels: true 21 | labels: 22 | type/bug: 23 | color: '#ff0022' 24 | alias: [] 25 | siblings: [] 26 | bug/1-has-reproduction: 27 | color: '#ff0022' 28 | description: Indicates that an issue has reproduction 29 | alias: 30 | - bug 31 | siblings: 32 | - kind/bug 33 | kind/bug: 34 | color: '#02f5aa' 35 | alias: [] 36 | siblings: [] 37 | changed: 38 | config: 39 | removeUnconfiguredLabels: true 40 | labels: 41 | bug/1-has-reproduction: 42 | color: '#ff0022' 43 | description: Indicates that an issue has reproduction 44 | alias: 45 | - bug 46 | siblings: 47 | - kind/bug 48 | kind/bug: 49 | color: '#02f5aa' 50 | alias: [] 51 | siblings: [] 52 | " 53 | `; 54 | 55 | exports[`make: integration test: compiles configuration to default path 2`] = ` 56 | Object { 57 | "config": Object { 58 | "repos": Object { 59 | "changed": Object { 60 | "config": Object { 61 | "removeUnconfiguredLabels": true, 62 | }, 63 | "labels": Object { 64 | "bug/1-has-reproduction": Object { 65 | "alias": Array [ 66 | "bug", 67 | ], 68 | "color": "ff0022", 69 | "description": "Indicates that an issue has reproduction", 70 | "siblings": Array [ 71 | "kind/bug", 72 | ], 73 | }, 74 | "kind/bug": Object { 75 | "alias": Array [], 76 | "color": "02f5aa", 77 | "siblings": Array [], 78 | }, 79 | }, 80 | }, 81 | "prisma-test-utils": Object { 82 | "config": Object { 83 | "removeUnconfiguredLabels": true, 84 | }, 85 | "labels": Object { 86 | "bug/1-has-reproduction": Object { 87 | "alias": Array [ 88 | "bug", 89 | ], 90 | "color": "ff0022", 91 | "description": "Indicates that an issue has reproduction", 92 | "siblings": Array [ 93 | "kind/bug", 94 | ], 95 | }, 96 | "kind/bug": Object { 97 | "alias": Array [], 98 | "color": "02f5aa", 99 | "siblings": Array [], 100 | }, 101 | "type/bug": Object { 102 | "alias": Array [], 103 | "color": "ff0022", 104 | "siblings": Array [], 105 | }, 106 | }, 107 | }, 108 | }, 109 | }, 110 | "ok": true, 111 | } 112 | `; 113 | -------------------------------------------------------------------------------- /web/components/Banner.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react' 2 | 3 | export interface Banner { 4 | message: ReactElement 5 | button: { 6 | text: string 7 | onClick: () => void 8 | } 9 | } 10 | 11 | /*
*/ 12 | 13 | export default function Banner(props: Banner) { 14 | return ( 15 |
16 |
17 |
18 | {/* Icon */} 19 |
20 | 21 | 27 | 33 | 34 | 35 |

36 | {props.message} 37 | {props.message} 38 |

39 |
40 | {/* Cancel */} 41 |
42 |
43 | 49 |
50 |
51 | {/* Dismiss */} 52 |
53 | 73 |
74 |
75 |
76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /packages/database/tests/source.test.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | import { Source } from '../src/lib/source' 3 | import { sleep } from './__fixtures__/utils' 4 | 5 | class MockSource extends Source { 6 | private data: { [key: string]: string } = {} 7 | 8 | constructor(data: { [key: string]: string }) { 9 | super(5, 5) 10 | this.data = data 11 | } 12 | 13 | public async fetch(key: string): Promise<{ ttl: DateTime; time: DateTime; value: string } | null> { 14 | if (key in this.data) { 15 | return { 16 | ttl: DateTime.now().plus({ milliseconds: 5 }), 17 | value: key, 18 | time: DateTime.now(), 19 | } 20 | } 21 | 22 | return null 23 | } 24 | 25 | public identify(key: string): string { 26 | return key 27 | } 28 | 29 | public invalidate(key: string): void { 30 | super.invalidate(key) 31 | } 32 | 33 | public enqueue(fn: (self: Source) => Promise): void { 34 | super.enqueue(fn) 35 | } 36 | } 37 | 38 | describe('source', () => { 39 | test('fetches the data with a key', async () => { 40 | const source = new MockSource({ 41 | foo: 'bar', 42 | }) 43 | 44 | expect(await source.get('foo')).not.toBeNull() 45 | expect(await source.get('qux')).toBeNull() 46 | 47 | source.dispose() 48 | }) 49 | 50 | test('looksup data in the cache', async () => { 51 | const source = new MockSource({ foo: 'bar' }) 52 | 53 | expect(source.lookup('foo')).toBeNull() 54 | await source.get('foo') 55 | expect(source.lookup('foo')).not.toBeNull() 56 | 57 | source.dispose() 58 | }) 59 | 60 | test('clears the cache', async () => { 61 | const source = new MockSource({ 62 | foo: 'bar', 63 | }) 64 | 65 | const firstLookup = await source.get('foo') 66 | const start = DateTime.now() 67 | 68 | expect(firstLookup?.time! <= start).toBeTruthy() 69 | 70 | await sleep(100) 71 | 72 | const secondLookup = await source.get('foo') 73 | expect(secondLookup?.time! > start).toBeTruthy() 74 | 75 | source.dispose() 76 | }) 77 | 78 | test('invalidates the item with a key', async () => { 79 | const source = new MockSource({ 80 | foo: 'bar', 81 | }) 82 | 83 | const lookup = await source.get('foo') 84 | expect(lookup).not.toBeNull() 85 | 86 | const time = DateTime.now() 87 | 88 | expect(lookup?.time! <= time).toBeTruthy() 89 | 90 | source.invalidate('foo') 91 | const refetched = await source.get('foo') 92 | 93 | expect(refetched?.time! >= time).toBeTruthy() 94 | 95 | source.dispose() 96 | }) 97 | 98 | test('enqueues tasks and processes them concurrently', async () => { 99 | const source = new MockSource({}) 100 | 101 | const ref: { concurrent: number } = { concurrent: 0 } 102 | const trace: number[] = [] 103 | 104 | for (let index = 0; index < 7; index++) { 105 | source.enqueue(async () => { 106 | trace.push(ref.concurrent) 107 | ref.concurrent++ 108 | await sleep(30) 109 | ref.concurrent-- 110 | }) 111 | } 112 | 113 | await sleep(100) 114 | 115 | source.dispose() 116 | 117 | // NOTE: Tasks are first completed (i.e. concurrency decreases) 118 | // and after that we start the next one. 119 | expect(trace).toEqual([0, 1, 2, 3, 4, 4, 4]) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /workers/sync/tests/processors/onboarding.test.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino' 2 | 3 | import { populateTemplate, TEMPLATES } from '../../src/lib/templates' 4 | import { MockGitHubEndpoints } from '../__fixtures__/endpoints' 5 | 6 | import { OnboardingProcessor } from '../../src/processors/onboardingProcessor' 7 | 8 | describe('onboarding', () => { 9 | test('onboards organization', async () => { 10 | const installation = { id: 1, isPaidPlan: false } 11 | const endpoints = new MockGitHubEndpoints({ 12 | configs: {}, 13 | installations: { 14 | 'test-org': ['a', 'b', 'c'], 15 | }, 16 | repos: {}, 17 | }) 18 | const logger = pino() 19 | 20 | const processor = new OnboardingProcessor( 21 | installation, 22 | { 23 | push: () => { 24 | fail() 25 | }, 26 | }, 27 | endpoints, 28 | logger, 29 | ) 30 | 31 | await processor.perform({ 32 | accountType: 'Organization', 33 | owner: 'test-org', 34 | }) 35 | 36 | const tree = populateTemplate(TEMPLATES.yaml, { 37 | repository: 'test-org-labelsync', 38 | repositories: [{ name: 'a' }, { name: 'b' }, { name: 'c' }], 39 | }) 40 | 41 | expect(endpoints.stack()).toEqual([ 42 | MockGitHubEndpoints.checkInstallationAccess({ owner: 'test-org', repos: [] }), 43 | MockGitHubEndpoints.getRepo({ repo: 'test-org-labelsync', owner: 'test-org' }), 44 | MockGitHubEndpoints.bootstrapConfigRepository({ owner: 'test-org', repo: 'test-org-labelsync', tree }), 45 | ]) 46 | }) 47 | 48 | test('skips onboarding when config repo exists', async () => { 49 | const installation = { 50 | id: 1, 51 | isPaidPlan: false, 52 | } 53 | const endpoints = new MockGitHubEndpoints({ 54 | configs: {}, 55 | installations: { 56 | 'test-org': ['a', 'b', 'c'], 57 | }, 58 | repos: { 59 | 'test-org': { 60 | 'test-org-labelsync': { 61 | id: 12, 62 | default_branch: 'master', 63 | }, 64 | }, 65 | }, 66 | }) 67 | const logger = pino() 68 | 69 | const processor = new OnboardingProcessor( 70 | installation, 71 | { 72 | push: () => { 73 | fail() 74 | }, 75 | }, 76 | endpoints, 77 | logger, 78 | ) 79 | 80 | await processor.perform({ 81 | accountType: 'Organization', 82 | owner: 'test-org', 83 | }) 84 | 85 | expect(endpoints.stack()).toEqual([ 86 | MockGitHubEndpoints.checkInstallationAccess({ owner: 'test-org', repos: [] }), 87 | MockGitHubEndpoints.getRepo({ repo: 'test-org-labelsync', owner: 'test-org' }), 88 | ]) 89 | }) 90 | 91 | test('onboards personal account', async () => { 92 | const installation = { 93 | id: 1, 94 | isPaidPlan: false, 95 | } 96 | const endpoints = new MockGitHubEndpoints({ 97 | configs: {}, 98 | installations: { 99 | 'test-user': ['a', 'b', 'c'], 100 | }, 101 | }) 102 | const logger = pino() 103 | 104 | const processor = new OnboardingProcessor( 105 | installation, 106 | { 107 | push: () => { 108 | fail() 109 | }, 110 | }, 111 | endpoints, 112 | logger, 113 | ) 114 | 115 | await processor.perform({ 116 | accountType: 'User', 117 | owner: 'test-user', 118 | }) 119 | 120 | expect(endpoints.stack()).toEqual([ 121 | MockGitHubEndpoints.checkInstallationAccess({ owner: 'test-user', repos: [] }), 122 | MockGitHubEndpoints.getRepo({ repo: 'test-user-labelsync', owner: 'test-user' }), 123 | ]) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /web/components/Testimonial.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react' 2 | 3 | export interface Testimonial { 4 | heading: string 5 | content: ReactElement 6 | image: string 7 | name: string 8 | role: string 9 | pattern?: boolean 10 | logo?: { 11 | name: string 12 | url: string 13 | image: string 14 | } 15 | } 16 | 17 | export default function Testimonial(props: Testimonial) { 18 | return ( 19 | <> 20 | {/* */} 21 | 22 |
23 |

24 | {props.heading} 25 |

26 |
27 | 28 | {/* mt-10 container mx-auto px-4 sm:px-6 lg:px-8 */} 29 | 30 |
31 | {/* Pattern */} 32 | {props.pattern && ( 33 | 40 | 41 | 49 | 57 | 58 | 59 | 60 | 61 | )} 62 | 63 | {/* */} 64 | 65 |
66 | {props.logo && ( 67 | 68 | {props.logo.name} 69 | 70 | )} 71 | 72 |
73 | {/* Content */} 74 |
75 | {props.content} 76 |
77 | 78 | {/* Speaker */} 79 |
80 |
81 |
82 | {props.name} 87 |
88 |
89 |
{props.name}
90 | 91 | 96 | 97 | 98 | 99 |
{props.role}
100 |
101 |
102 |
103 |
104 |
105 |
106 | 107 | ) 108 | } 109 | -------------------------------------------------------------------------------- /workers/sync/tests/__fixtures__/reports.ts: -------------------------------------------------------------------------------- 1 | import { LabelSyncReport } from '../../src/lib/reports' 2 | 3 | /** 4 | * Report of a strict repository with additions, delitions, updates and renames. 5 | */ 6 | export const strict: LabelSyncReport = { 7 | status: 'Success', 8 | owner: 'maticzav', 9 | repo: 'success', 10 | additions: [ 11 | { 12 | name: 'addition/one', 13 | color: '#ee263c', 14 | default: false, 15 | }, 16 | { 17 | name: 'addition/two', 18 | color: '#ee263c', 19 | default: false, 20 | }, 21 | ], 22 | updates: [ 23 | { 24 | old_name: 'update/one', 25 | name: 'update/ena', 26 | color: '#FDE8E8', 27 | default: false, 28 | }, 29 | { 30 | old_name: 'update/two', 31 | name: 'update/dve', 32 | old_color: '#bbbbbb', 33 | color: '#FDF6B2', 34 | default: false, 35 | }, 36 | { 37 | old_name: 'update/three', 38 | name: 'update/tri', 39 | old_color: '#aa123d', 40 | color: '#E1EFFE', 41 | old_description: 'old', 42 | description: 'new', 43 | default: false, 44 | }, 45 | ], 46 | removals: [ 47 | { 48 | name: 'removal/one', 49 | color: '#aa123c', 50 | }, 51 | { 52 | name: 'removal/two', 53 | color: '#aa123c', 54 | }, 55 | ], 56 | aliases: [ 57 | { 58 | old_name: 'label/old-first', 59 | name: 'label/alias', 60 | color: '#ee263c', 61 | }, 62 | { 63 | old_name: 'label/old-second', 64 | name: 'label/alias', 65 | color: '#ee263c', 66 | }, 67 | ], 68 | config: { 69 | labels: {}, // not used in report generation 70 | config: { 71 | removeUnconfiguredLabels: true, 72 | }, 73 | }, 74 | } 75 | 76 | /** 77 | * Report of a repository that we failed to sync. 78 | */ 79 | export const failure: LabelSyncReport = { 80 | status: 'Failure', 81 | owner: 'maticzav', 82 | repo: 'failure', 83 | message: `Couldn't make a diff of labels.`, 84 | config: { 85 | labels: {}, // not used in report generation 86 | config: { 87 | removeUnconfiguredLabels: false, 88 | }, 89 | }, 90 | } 91 | 92 | /** 93 | * Report of a repository that is not in the configuration. 94 | */ 95 | export const unconfigured: LabelSyncReport = { 96 | status: 'Success', 97 | owner: 'maticzav', 98 | repo: 'unconfigured', 99 | additions: [ 100 | { 101 | name: 'addition', 102 | color: '#ee263c', 103 | default: false, 104 | }, 105 | ], 106 | updates: [], 107 | removals: [], 108 | aliases: [], 109 | config: { 110 | labels: {}, // not used in report generation 111 | config: { 112 | removeUnconfiguredLabels: false, 113 | }, 114 | }, 115 | } 116 | 117 | /** 118 | * Non-Strict Repository with unconfigured labels. 119 | */ 120 | export const nonstrict: LabelSyncReport = { 121 | status: 'Success', 122 | owner: 'maticzav', 123 | repo: 'unconfigured-nonstrict', 124 | additions: [], 125 | updates: [], 126 | removals: [ 127 | { 128 | name: 'unconfigured-one', 129 | color: '#ee263c', 130 | }, 131 | { 132 | name: 'unconfigured-two', 133 | color: '#ee263c', 134 | }, 135 | ], 136 | aliases: [], 137 | config: { 138 | labels: {}, // not used in report generation 139 | config: { 140 | removeUnconfiguredLabels: false, 141 | }, 142 | }, 143 | } 144 | 145 | /** 146 | * Report of an unchanged repository. 147 | */ 148 | export const unchanged: LabelSyncReport = { 149 | status: 'Success', 150 | owner: 'maticzav', 151 | repo: 'unchanged', 152 | additions: [], 153 | updates: [], 154 | removals: [], 155 | aliases: [], 156 | config: { 157 | labels: {}, // not used in report generation 158 | config: { 159 | removeUnconfiguredLabels: false, 160 | }, 161 | }, 162 | } 163 | -------------------------------------------------------------------------------- /packages/label-sync/src/generator.ts: -------------------------------------------------------------------------------- 1 | import type { LSCConfiguration, LSCRepository, LSCLabel } from '@labelsync/config' 2 | import { Configurable } from './configurable' 3 | import { Dict, withDefault, mapEntries } from './utils' 4 | import { YAML } from './yaml' 5 | 6 | /* Providers */ 7 | 8 | export function repo(repo: RepositoryInput): Repository { 9 | return new Repository(repo) 10 | } 11 | 12 | export function label(label: LabelInput | string, color?: string): Label { 13 | return new Label(label, color) 14 | } 15 | 16 | /* Classes */ 17 | 18 | /* Configuration */ 19 | 20 | export type ConfigurationInput = { 21 | repos: Dict 22 | } 23 | 24 | export class Configuration extends YAML implements Configurable { 25 | private repositories: Dict = {} 26 | 27 | constructor(config: ConfigurationInput) { 28 | super() 29 | this.repositories = config.repos 30 | } 31 | 32 | getConfiguration() { 33 | return { 34 | repos: mapEntries(this.repositories, (r) => r.getConfiguration()), 35 | } 36 | } 37 | } 38 | 39 | /* Repository */ 40 | 41 | export type RepositoryInput = { 42 | config?: RepositoryConfiguration 43 | labels: Label[] 44 | } 45 | 46 | export type RepositoryConfiguration = { 47 | removeUnconfiguredLabels?: boolean 48 | } 49 | 50 | /** 51 | * Repository configuration. 52 | */ 53 | export class Repository implements Configurable { 54 | private config: RepositoryConfiguration 55 | private labels: Label[] = [] 56 | 57 | constructor(repo: RepositoryInput) { 58 | this.config = withDefault({}, repo.config) 59 | 60 | /* Copy over labels and skip duplicates. */ 61 | for (const label of repo.labels.reverse()) { 62 | if (!this.labels.find((rl) => rl.getName() === label.getName())) { 63 | this.labels.push(label) 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * Returns the collection of labels configured in repository. 70 | */ 71 | *[Symbol.iterator]() { 72 | for (const label of this.labels) { 73 | yield label 74 | } 75 | } 76 | 77 | getConfiguration() { 78 | let labels: { [label: string]: LSCLabel } = {} 79 | 80 | /* Process labels */ 81 | for (const label of this.labels) { 82 | const name = label.getName() 83 | /* istanbul ignore next */ 84 | if (labels.hasOwnProperty(name)) { 85 | throw new Error(`Duplicate label ${name}`) 86 | } 87 | labels[name] = label.getConfiguration() 88 | } 89 | 90 | return { 91 | config: this.config, 92 | labels, 93 | } 94 | } 95 | } 96 | 97 | /* Label */ 98 | 99 | export type LabelInput = 100 | | { 101 | name: string 102 | color: string 103 | description?: string 104 | alias?: string[] 105 | siblings?: string[] 106 | } 107 | | string 108 | 109 | export class Label implements Configurable { 110 | private name: string 111 | private color: string = '' 112 | private description: string | undefined 113 | private siblings: string[] = [] 114 | private alias: string[] = [] 115 | 116 | constructor(label: LabelInput | string, color?: string) { 117 | switch (typeof label) { 118 | case 'string': { 119 | this.name = label 120 | /* istanbul ignore next */ 121 | if (!color) { 122 | throw new Error(`Label either accepts label(name, color) or label(config) object!`) 123 | } 124 | this.color = this.fixColor(color) 125 | return 126 | } 127 | case 'object': { 128 | this.name = label.name 129 | this.color = this.fixColor(label.color) 130 | this.description = label.description 131 | this.siblings = withDefault([], label.siblings) 132 | this.alias = withDefault([], label.alias) 133 | return 134 | } 135 | } 136 | } 137 | 138 | fixColor(color: string): string { 139 | if (!color.startsWith('#')) { 140 | return `#${color}` 141 | } 142 | return color 143 | } 144 | 145 | getName() { 146 | return this.name 147 | } 148 | 149 | getConfiguration() { 150 | return { 151 | color: this.color, 152 | description: this.description, 153 | alias: this.alias, 154 | siblings: this.siblings, 155 | } 156 | } 157 | } 158 | --------------------------------------------------------------------------------
15 | 18 | By using LabelSync's services you agree to our 19 | 20 | Cookies Use 21 | 22 | . 23 | 24 | } 25 | button={{ 26 | text: 'Agree', 27 | onClick: () => setAccepted(true), 28 | }} 29 | > 30 |