├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── jest.config.js ├── package.json ├── peril.settings.json ├── rules ├── emptybody.ts ├── invite-collaborator.ts ├── labeler.ts ├── merge-on-green.ts ├── not-stale.ts ├── pull-request-on-starter.ts ├── run-stale-immediately.ts └── validate-yaml.ts ├── tasks ├── slack-experiment.ts └── stale.ts ├── tests ├── emptybody.test.ts ├── invite-collaborator.test.ts ├── labeler.test.ts ├── not-stale.test.ts ├── pull-request-on-starter.test.ts ├── stale.test.ts └── validate-yaml │ ├── validate-yaml-authors.test.ts │ ├── validate-yaml-creators.test.ts │ ├── validate-yaml-sites.test.ts │ └── validate-yaml-starters.test.ts ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | aliases: 3 | node8: &node8 4 | docker: 5 | - image: circleci/node:8 6 | restore_node_modules: &restore_node_modules 7 | restore_cache: 8 | name: Restore node_modules cache 9 | keys: 10 | - node-modules-{{ checksum "yarn.lock" }} 11 | install_node_modules: &install_node_modules 12 | run: 13 | name: Install node modules 14 | command: | 15 | yarn 16 | persist_node_modules: &persist_node_modules 17 | save_cache: 18 | name: Save node modules cache 19 | key: node-modules-{{ checksum "yarn.lock" }} 20 | paths: 21 | - node_modules 22 | 23 | commands: 24 | run_command: 25 | parameters: 26 | command: 27 | type: string 28 | steps: 29 | - checkout 30 | - <<: *restore_node_modules 31 | - <<: *install_node_modules 32 | - run: yarn "<< parameters.command >>" 33 | 34 | jobs: 35 | unit_test: 36 | <<: *node8 37 | steps: 38 | - run_command: 39 | command: "test" 40 | 41 | workflows: 42 | version: 2 43 | test: 44 | jobs: 45 | - unit_test 46 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [{*.json, *.svg}] 13 | indent_style = space 14 | indent_size = 4 15 | 16 | # Matches the exact package.json, or *rc 17 | [{package.json,*.yml,*rc}] 18 | indent_style = space 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /package.json 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > Note: this code has been moved to the Gatsby monorepo. See: https://github.com/gatsbyjs/gatsby/tree/master/peril 2 | 3 | # Gatsby's Peril Settings 4 | 5 | This is the configuration repo for Peril on the GatsbyJS org. There’s a [settings file](peril.settings.json) and org-wide dangerfiles which are inside the [rules folder](rules). 6 | 7 | ## tl;dr for this repo 8 | 9 | Peril is Danger running on a web-server and this repo is the configuration for that. Currently the dangerfiles in [rules](rules/) run on every issue and pull request for all GatsbyJS Repos. 10 | 11 | ## How it works 12 | 13 | ### Relevant tools 14 | 15 | Here are links to the relevant tools, docs, and apps we’re using: 16 | 17 | - [Peril](https://github.com/danger/peril) 18 | - [Danger JS](http://danger.systems/js/) 19 | - [Peril for Orgs](https://github.com/danger/peril/blob/master/docs/setup_for_org.md) 20 | - [Peril on the GatsbyJS Heroku team](https://dashboard.heroku.com/apps/gatsby-peril) 21 | 22 | ### How the pieces fit together 23 | 24 | There are three parts to enabling automations on the Gatsby GitHub organization (ignoring some of the nitty-gritty details). Those parts are: 25 | 26 | - Events (issue comments, new pull requests etc) published from the Gatsby GitHub org to a server 27 | - An instance of Peril which receives and responds to those events 28 | - This settings repo which configures rules for the Peril instance 29 | 30 | ![Gatsby Peril event sequence diagram](https://user-images.githubusercontent.com/381801/46957219-048c3b80-d08f-11e8-829c-b535a4a122f3.png) 31 | 32 | > [View source for the diagram](https://sequencediagram.org/index.html#initialData=C4S2BsFMAIHEENgGcBGBPaAFSAnE5pIA3SAO2GiUgEcBXMgY0gChnSB7YGdknORVBlhgAErRTR2OAOYAuAKIly0ABbwADurJJJpaPH7J00HJHXtmCI0NHjJMgLQA+aQPQOtecADoVudgDWtBrq3gzsALYKShQA7pAoKuyBrMyu1h64+L7+QSFhkc6e+A7pggBWOqbmsgDC7KQAZiDS+qQAJtDA8EgBVTT0SMCsHFySvNDF4KVuaJUmZuyyALLwGKbAtDh6EbTgoOpQ0O3wpNK4XT19rGXuUzk4gcGaBREAPJleM9bz1Uv1TRabU63V6-SQ5lIVFYVkEcFsEik0g+30En2yfkeeRe4SiACVaHpQQEFhCGu0QGcuuxCDEYbN4cAxIiZCjbmh0T5MU98rjZASiVdSZCKVTgDTiGRhpYGcImXYkWzZpyHjycZF+YTLr1heTKa1xbSpUA) 33 | 34 | The Peril instance can also be configured to run events on a schedule. 35 | 36 | ### What is this project? 37 | 38 | - [EmptyBody](./rules/emptybody.ts): Automatically requests more information from a user who opens a new issue with a blank body. 39 | - [InviteCollaborator](./rules/invite-collaborator.ts): Automatically invite all contributors who merge a PR into the GatsbyJS org to become members of the [@gatsbyjs/maintainers](https://github.com/orgs/gatsbyjs/teams/maintainers) team. 40 | 41 | ### To Develop 42 | 43 | ```sh 44 | git clone https://github.com/gatsbyjs/peril-gatsbyjs.git 45 | yarn install 46 | code . 47 | ``` 48 | 49 | You will need node and yarn installed beforehand. You can get them both by running `brew install yarn`. 50 | 51 | This will give you auto-completion and types for Danger mainly. 52 | 53 | ### To Deploy 54 | 55 | Changes to [`tasks/`](./tasks) and [`rules/`](./rules) will automatically be picked up by the Heroku App. 56 | 57 | Changes to [`peril.settings.json`](./peril.settings.json) require the Heroku App to be restarted. 58 | 59 | Changes to `settings.modules` in [`peril.settings.json`](./peril.settings.json) require the app to be rebuilt on Heroku. This is to allow Heroku to install any new dependencies. See the repo for more info (TODO: add the repo). 60 | 61 | ### Debugging 62 | 63 | Add the following env var to the Heroku app: `DEBUG=octokit:rest*`. This will enable debug output for the GitHub API library used by Peril, allowing you to see the exact API calls that are made to GitHub. 64 | 65 | ## Acknowledgments 66 | 67 | Huge thanks to [@SD10](https://github.com/SD10) for the initial setup help and for additional guidance along the way. 68 | 69 | And thanks to [@orta](https://github.com/orta) for creating [Peril](https://github.com/danger/peril). This makes our lives so much easier. 70 | 71 | ## Roadmap 72 | 73 | See [this epic](https://github.com/gatsbyjs/gatsby/issues/6728) for additional work planned in this repo. (Works best with the [ZenHub extension](https://www.zenhub.com/extension).) 74 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.tsx?$': '/node_modules/ts-jest/preprocessor.js' 4 | }, 5 | testRegex: '(.test)\\.(ts|tsx)$', 6 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'] 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peril-gatsbyjs", 3 | "version": "1.0.0", 4 | "repository": "https://github.com/gatsbyjs/peril-gatsbyjs", 5 | "author": "Steven Deutsch ", 6 | "contributors": [ 7 | "Jason Lengstorf ", 8 | "Dustin Schau " 9 | ], 10 | "license": "MIT", 11 | "scripts": { 12 | "test": "jest", 13 | "format": "prettier --write \"**/*.ts\"" 14 | }, 15 | "devDependencies": { 16 | "@types/jest": "^22.2.3", 17 | "@types/joi": "^13.6.0", 18 | "@types/js-yaml": "^3.11.2", 19 | "@types/node": "^8.0.25", 20 | "danger": "^3.1.3", 21 | "jest": "^22.4.3", 22 | "joi": "^13.7.0", 23 | "js-yaml": "^3.12.0", 24 | "prettier": "^1.14.3", 25 | "ts-jest": "^22.4.4", 26 | "typescript": "^2.3.2" 27 | }, 28 | "dependencies": { 29 | "@slack/client": "^4.5.0", 30 | "date-fns": "^1.29.0", 31 | "github-webhook-event-types": "^1.2.1", 32 | "tslint": "^5.11.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /peril.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/danger/peril/master/peril-settings-json.schema", 3 | "settings": { 4 | "ignored_repos": [], 5 | "env_vars": ["SLACK_WEBHOOK_URL", "GITHUB_ACCESS_TOKEN"], 6 | "modules": ["@slack/client", "joi", "js-yaml", "date-fns"] 7 | }, 8 | "rules": { 9 | "issue_comment": ["rules/not-stale.ts"], 10 | "issues.opened": ["rules/emptybody.ts", "rules/labeler.ts"], 11 | "pull_request.closed (pull_request.merged == true)": [ 12 | "rules/invite-collaborator.ts" 13 | ] 14 | }, 15 | "repos": { 16 | "gatsbyjs/gatsby": { 17 | "pull_request": ["gatsbyjs/peril-gatsbyjs@rules/validate-yaml.ts"], 18 | "check_run": ["gatsbyjs/peril-gatsbyjs@rules/validate-yaml.ts"], 19 | "pull_request.labeled": [ 20 | "gatsbyjs/peril-gatsbyjs@rules/merge-on-green.ts" 21 | ], 22 | "pull_request_review.submitted": [ 23 | "gatsbyjs/peril-gatsbyjs@rules/merge-on-green.ts" 24 | ], 25 | "check_suite.completed": [ 26 | "gatsbyjs/peril-gatsbyjs@rules/merge-on-green.ts" 27 | ], 28 | "status.success": ["gatsbyjs/peril-gatsbyjs@rules/merge-on-green.ts"] 29 | }, 30 | "gatsbyjs/gatsby-starter-default": { 31 | "pull_request.opened": [ 32 | "gatsbyjs/peril-gatsbyjs@rules/pull-request-on-starter.ts" 33 | ] 34 | }, 35 | "gatsbyjs/gatsby-starter-hello-world": { 36 | "pull_request.opened": [ 37 | "gatsbyjs/peril-gatsbyjs@rules/pull-request-on-starter.ts" 38 | ] 39 | }, 40 | "gatsbyjs/gatsby-starter-blog": { 41 | "pull_request.opened": [ 42 | "gatsbyjs/peril-gatsbyjs@rules/pull-request-on-starter.ts" 43 | ] 44 | } 45 | }, 46 | "tasks": { 47 | "stale": "tasks/stale.ts" 48 | }, 49 | "scheduler": { 50 | "daily": "stale" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /rules/emptybody.ts: -------------------------------------------------------------------------------- 1 | import { danger, markdown } from "danger" 2 | 3 | const getMessage = (username: string) => `\ 4 | @${username} We noticed that the body of this issue is blank. 5 | 6 | Please fill in this field with more information to help the \ 7 | maintainers resolve your issue.\ 8 | ` 9 | 10 | export const emptybody = () => { 11 | const { 12 | user: { login: username }, 13 | body, 14 | } = danger.github.issue as any 15 | 16 | if (body.trim().length === 0) { 17 | markdown(getMessage(username)) 18 | } 19 | } 20 | 21 | export default async () => { 22 | emptybody() 23 | } 24 | -------------------------------------------------------------------------------- /rules/invite-collaborator.ts: -------------------------------------------------------------------------------- 1 | import { danger } from "danger" 2 | 3 | const comment = (username: string) => ` 4 | Holy buckets, @${username} — we just merged your PR to Gatsby! 💪💜 5 | 6 | Gatsby is built by awesome people like you. Let us say “thanks” in two ways: 7 | 8 | 1. **We’d like to send you some Gatsby swag.** As a token of our appreciation, you can go to the [Gatsby Swag Store][store] and log in with your GitHub account to get a coupon code good for one free piece of swag. We’ve got Gatsby t-shirts, stickers, hats, scrunchies, and much more. (You can also unlock _even more_ free swag with 5 contributions — wink wink nudge nudge.) See [gatsby.dev/swag](https://gatsby.dev/swag) for details. 9 | 2. **We just invited you to join the Gatsby organization on GitHub.** This will add you to our team of maintainers. Accept the invite by visiting https://github.com/orgs/gatsbyjs/invitation. By joining the team, you’ll be able to label issues, review pull requests, and merge approved pull requests. 10 | 11 | If there’s anything we can do to help, please don’t hesitate to reach out to us: tweet at [@gatsbyjs][twitter] and we’ll come a-runnin’. 12 | 13 | Thanks again! 14 | 15 | [store]: https://store.gatsbyjs.org 16 | [twitter]: https://twitter.com/gatsbyjs 17 | ` 18 | 19 | export const inviteCollaborator = async () => { 20 | const gh = danger.github 21 | const api = gh.api 22 | 23 | // Details about the repo. 24 | const owner = gh.thisPR.owner 25 | const repo = gh.thisPR.repo 26 | const number = gh.thisPR.number 27 | 28 | // Details about the collaborator. 29 | const username = gh.pr.user.login 30 | 31 | // Check whether or not we’ve already invited this contributor. 32 | try { 33 | const inviteCheck = (await api.orgs.getTeamMembership({ 34 | team_id: "1942254", 35 | username, 36 | } as any)) as any 37 | const isInvited = inviteCheck.headers.status !== "404" 38 | 39 | // If we’ve already invited them, don’t spam them with more messages. 40 | if (isInvited) { 41 | console.log( 42 | `@${username} has already been invited to this org. Doing nothing.` 43 | ) 44 | return 45 | } 46 | } catch (_) { 47 | // If the user hasn’t been invited, the invite check throws an error. 48 | } 49 | 50 | try { 51 | const invite = await api.orgs.addTeamMembership({ 52 | // ID of the @gatsbyjs/maintainers team on GitHub 53 | team_id: "1942254", 54 | username, 55 | } as any) 56 | 57 | if (invite.data.state === "active") { 58 | console.log( 59 | `@${username} is already a ${invite.data.role} for this team.` 60 | ) 61 | } else { 62 | console.log(`We’ve invited @${username} to join this team.`) 63 | } 64 | } catch (err) { 65 | console.log("Something went wrong.") 66 | console.log(err) 67 | return 68 | } 69 | 70 | // For new contributors, roll out the welcome wagon! 71 | await api.issues.createComment({ 72 | owner, 73 | repo, 74 | number, 75 | body: comment(username), 76 | }) 77 | } 78 | 79 | export default async () => { 80 | await inviteCollaborator() 81 | } 82 | -------------------------------------------------------------------------------- /rules/labeler.ts: -------------------------------------------------------------------------------- 1 | import { danger } from "danger" 2 | 3 | interface ApiError { 4 | action: string 5 | opts: object 6 | error: any 7 | } 8 | 9 | export const logApiError = ({ action, opts, error }: ApiError) => { 10 | const msg = `Could not run ${action} with options ${JSON.stringify( 11 | opts 12 | )}\n Error was ${error}\nSet env var DEBUG=octokit:rest* for extended logging info.` 13 | console.warn(msg) 14 | } 15 | 16 | const questionWords: Set = new Set([ 17 | "how", 18 | "who", 19 | "what", 20 | "where", 21 | "when", 22 | "why", 23 | "will", 24 | "which", 25 | ]) 26 | 27 | const documentationWords: Set = new Set([ 28 | "documentation", 29 | "document", 30 | "docs", 31 | "doc", 32 | "readme", 33 | "changelog", 34 | "tutorial", 35 | ]) 36 | 37 | const endsWith = (character: string, sentence: string): boolean => 38 | sentence.slice(-1) === character 39 | 40 | const matchKeyword = ( 41 | keywords: Set, 42 | sentence: string, 43 | firstOnly: boolean = false 44 | ): boolean => { 45 | /* 46 | * We need to turn the title into a parseable array of words. To do this, we: 47 | * 1. Remove any character that’s not a letter or space 48 | * 2. Split the sentence on spaces to create an array of words, and 49 | * 3. Get the first word if `firstOnly` is `true`, or else the whole array 50 | */ 51 | const words = sentence 52 | .replace(/\W /g, " ") 53 | .split(" ") 54 | .slice(0, firstOnly ? 1 : Infinity) as string[] 55 | 56 | // Check if any of the words matches our set of keywords. 57 | return words.some((word: string) => keywords.has(word.toLowerCase())) 58 | } 59 | 60 | export const labeler = async () => { 61 | const gh = danger.github as any 62 | const repo = gh.repository 63 | const issue = gh.issue 64 | const title = issue.title 65 | const currentLabels = danger.github.issue.labels.map(i => i.name) 66 | 67 | let labels: Set = new Set(currentLabels) 68 | 69 | if (endsWith("?", title) || matchKeyword(questionWords, title, true)) { 70 | labels.add("type: question or discussion") 71 | } 72 | 73 | if (matchKeyword(documentationWords, title)) { 74 | labels.add("type: documentation") 75 | } 76 | 77 | if (labels.size > 0) { 78 | const opts = { 79 | owner: repo.owner.login, 80 | repo: repo.name, 81 | number: issue.number, 82 | labels: Array.from(labels), 83 | } 84 | 85 | try { 86 | await danger.github.api.issues.addLabels(opts) 87 | } catch (error) { 88 | logApiError({ action: `issues.addLabel`, opts, error }) 89 | } 90 | } 91 | } 92 | 93 | export default async () => { 94 | await labeler() 95 | } 96 | -------------------------------------------------------------------------------- /rules/merge-on-green.ts: -------------------------------------------------------------------------------- 1 | import { danger, peril } from "danger" 2 | import * as octokit from "@octokit/rest" 3 | 4 | const ACCEPTABLE_MERGEABLE_STATES = [`clean`, `unstable`] 5 | 6 | const checkPRConditionsAndMerge = async ({ 7 | number, 8 | owner, 9 | repo, 10 | }: { 11 | number: number 12 | owner: string 13 | repo: string 14 | }) => { 15 | // we need to check if "bot: merge on green" label is applied and PR is mergeable (checks are green and have approval) 16 | 17 | const userAuthedAPI = new octokit() 18 | userAuthedAPI.authenticate({ 19 | type: "token", 20 | token: peril.env.GITHUB_ACCESS_TOKEN, 21 | }) 22 | 23 | const pr = await userAuthedAPI.pullRequests.get({ number, owner, repo }) 24 | 25 | const isMergeButtonGreen = ACCEPTABLE_MERGEABLE_STATES.includes( 26 | pr.data.mergeable_state 27 | ) 28 | 29 | const hasMergeOnGreenLabel = pr.data.labels.some( 30 | label => label.name === `bot: merge on green` 31 | ) 32 | 33 | console.log({ 34 | number, 35 | owner, 36 | repo, 37 | isMergeButtonGreen, 38 | hasMergeOnGreenLabel, 39 | mergeable_state: pr.data.mergeable_state, 40 | }) 41 | 42 | if (isMergeButtonGreen && hasMergeOnGreenLabel) { 43 | await userAuthedAPI.pullRequests.merge({ 44 | merge_method: `squash`, 45 | commit_title: `${pr.data.title} (#${number})`, 46 | number, 47 | owner, 48 | repo, 49 | }) 50 | } 51 | } 52 | 53 | export const mergeOnGreen = async () => { 54 | try { 55 | if (danger.github.action === `completed` && danger.github.check_suite) { 56 | // this is for check_suite.completed 57 | 58 | // search returns first 100 results, we are not handling pagination right now 59 | // because it's unlikely to get more 100 results for given sha 60 | const results = await danger.github.api.search.issues({ 61 | q: `${danger.github.check_suite.head_sha} is:open repo:${ 62 | danger.github.repository.owner.login 63 | }/${danger.github.repository.name}`, 64 | }) 65 | 66 | let i = 0 67 | while (i < results.data.items.length) { 68 | const pr = results.data.items[i] 69 | i++ 70 | await checkPRConditionsAndMerge({ 71 | number: pr.number, 72 | owner: danger.github.repository.owner.login, 73 | repo: danger.github.repository.name, 74 | }) 75 | } 76 | } else if (danger.github.state === `success` && danger.github.commit) { 77 | // this is for status.success 78 | 79 | // search returns first 100 results, we are not handling pagination right now 80 | // because it's unlikely to get more 100 results for given sha 81 | const results = await danger.github.api.search.issues({ 82 | q: `${danger.github.commit.sha} is:open repo:${ 83 | danger.github.repository.owner.login 84 | }/${danger.github.repository.name}`, 85 | }) 86 | 87 | let i = 0 88 | while (i < results.data.items.length) { 89 | const pr = results.data.items[i] 90 | i++ 91 | await checkPRConditionsAndMerge({ 92 | number: pr.number, 93 | owner: danger.github.repository.owner.login, 94 | repo: danger.github.repository.name, 95 | }) 96 | } 97 | } else if ( 98 | danger.github.action === `submitted` && 99 | danger.github.pull_request 100 | ) { 101 | // this is for pull_request_review.submitted 102 | await checkPRConditionsAndMerge({ 103 | number: danger.github.pull_request.number, 104 | repo: danger.github.pull_request.base.repo.name, 105 | owner: danger.github.pull_request.base.repo.owner.login, 106 | }) 107 | } else { 108 | // this is for pull_request.labeled 109 | await checkPRConditionsAndMerge({ 110 | number: danger.github.pr.number, 111 | repo: danger.github.pr.base.repo.name, 112 | owner: danger.github.pr.base.repo.owner.login, 113 | }) 114 | } 115 | } catch (e) { 116 | console.log(e) 117 | } 118 | } 119 | 120 | export default async () => { 121 | await mergeOnGreen() 122 | } 123 | -------------------------------------------------------------------------------- /rules/not-stale.ts: -------------------------------------------------------------------------------- 1 | import { danger } from "danger" 2 | import { IssueComment } from "github-webhook-event-types" 3 | 4 | export const STALE_LABEL = `stale?` 5 | 6 | export const notStale = async () => { 7 | const gh = (danger.github as any) as IssueComment 8 | const repo = gh.repository 9 | const labels = gh.issue.labels.map(i => i.name) 10 | const opts = { 11 | owner: repo.owner.login, 12 | repo: repo.name, 13 | number: gh.issue.number, 14 | name: encodeURIComponent(STALE_LABEL), 15 | } as any 16 | 17 | if (labels.includes(STALE_LABEL)) { 18 | try { 19 | await danger.github.api.issues.removeLabel(opts) 20 | } catch (error) { 21 | console.log( 22 | `Could not run issues.removeLabel with options: ${JSON.stringify(opts)}` 23 | ) 24 | console.log(error) 25 | } 26 | } 27 | } 28 | 29 | export default async () => { 30 | await notStale() 31 | } 32 | -------------------------------------------------------------------------------- /rules/pull-request-on-starter.ts: -------------------------------------------------------------------------------- 1 | import { danger, markdown } from "danger" 2 | 3 | // TODO: Improve comment 4 | export const comment = (username: string) => ` 5 | Hey, @${username} 6 | 7 | Thank you for your pull request! 8 | 9 | We've moved all our starters over to https://github.com/gatsbyjs/gatsby. Please reopen this there. 10 | 11 | Thanks again! 12 | ` 13 | 14 | export const closePullRequestAndComment = async () => { 15 | const gh = danger.github 16 | const api = gh.api 17 | 18 | // Details about the repo. 19 | const owner = gh.thisPR.owner 20 | const repo = gh.thisPR.repo 21 | const number = gh.thisPR.number 22 | 23 | // Details about the collaborator. 24 | const username = gh.pr.user.login 25 | 26 | // Leave a comment redirecting the collaborator to the monorepo 27 | markdown(comment(username)) 28 | // Close this pull request 29 | await api.pullRequests.update({ 30 | owner, 31 | repo, 32 | number, 33 | state: "closed", 34 | }) 35 | } 36 | 37 | export default async () => { 38 | await closePullRequestAndComment() 39 | } 40 | -------------------------------------------------------------------------------- /rules/run-stale-immediately.ts: -------------------------------------------------------------------------------- 1 | import { danger, peril } from "danger" 2 | 3 | // Trigger this rule to run in order to check a scheduled task immediately 4 | // example config: 5 | // "issue_comment.edited": ["rules/run-stale-immediately.ts"], 6 | export default async () => { 7 | console.log("Running stale task in 1 second") 8 | await peril.runTask("stale", "in 1 second", {}) 9 | console.log("Stale task finished") 10 | } 11 | -------------------------------------------------------------------------------- /rules/validate-yaml.ts: -------------------------------------------------------------------------------- 1 | import { danger, warn } from "danger" 2 | import { load as yamlLoad } from "js-yaml" 3 | import * as Joi from "joi" 4 | import * as path from "path" 5 | 6 | const supportedImageExts = [".jpg", ".jpeg", ".gif", ".png"] 7 | const uriOptions = { scheme: [`https`, `http`] } 8 | const githubRepoRegex: RegExp = new RegExp( 9 | `^https?:\/\/github.com\/[^/]+/[^/]+$` 10 | ) 11 | 12 | const getExistingFiles = async (path: string, base: string) => { 13 | const [owner, repo] = danger.github.pr.head.repo.full_name.split("/") 14 | const imagesDirReponse: { 15 | data: { name: string }[] 16 | } = await danger.github.api.repos.getContent({ 17 | repo, 18 | owner, 19 | path, 20 | ref: danger.github.pr.head.ref, 21 | }) 22 | const files = imagesDirReponse.data.map(({ name }) => `${base}/${name}`) 23 | return files 24 | } 25 | 26 | const customJoi = Joi.extend((joi: any) => ({ 27 | base: joi.string(), 28 | name: "string", 29 | language: { 30 | supportedExtension: "need to use supported extension {{q}}", 31 | fileExists: "need to point to existing file", 32 | }, 33 | rules: [ 34 | { 35 | name: "supportedExtension", 36 | params: { 37 | q: joi.array().items(joi.string()), 38 | }, 39 | validate( 40 | this: Joi.ExtensionBoundSchema, 41 | params: { q: string[] }, 42 | value: string, 43 | state: any, 44 | options: any 45 | ): any { 46 | if (!params.q.includes(path.extname(value))) { 47 | return this.createError( 48 | "string.supportedExtension", 49 | { v: value, q: params.q }, 50 | state, 51 | options 52 | ) 53 | } 54 | 55 | return value 56 | }, 57 | }, 58 | { 59 | name: "fileExists", 60 | params: { 61 | q: joi.array().items(joi.string()), 62 | }, 63 | validate( 64 | this: Joi.ExtensionBoundSchema, 65 | params: { q: string[] }, 66 | value: string, 67 | state: any, 68 | options: any 69 | ): any { 70 | if (!params.q.includes(value)) { 71 | return this.createError( 72 | "string.fileExists", 73 | { v: value, q: params.q }, 74 | state, 75 | options 76 | ) 77 | } 78 | 79 | return value 80 | }, 81 | }, 82 | ], 83 | })) 84 | 85 | const getSitesSchema = () => { 86 | return Joi.array() 87 | .items( 88 | Joi.object().keys({ 89 | title: Joi.string().required(), 90 | url: Joi.string() 91 | .uri(uriOptions) 92 | .required(), 93 | main_url: Joi.string() 94 | .uri(uriOptions) 95 | .required(), 96 | source_url: Joi.string().uri(uriOptions), 97 | description: Joi.string(), 98 | categories: Joi.array() 99 | .items(Joi.string()) 100 | .required(), 101 | built_by: Joi.string(), 102 | built_by_url: Joi.string().uri(uriOptions), 103 | featured: Joi.boolean(), 104 | }) 105 | ) 106 | .unique("title") 107 | .unique("url") 108 | .unique("main_url") 109 | } 110 | 111 | const getCreatorsSchema = async () => { 112 | return Joi.array() 113 | .items( 114 | Joi.object().keys({ 115 | name: Joi.string().required(), 116 | type: Joi.string() 117 | .valid(["individual", "agency", "company"]) 118 | .required(), 119 | description: Joi.string(), 120 | location: Joi.string(), 121 | // need to explicitely allow `null` to not fail on github: null fields 122 | github: Joi.string() 123 | .uri(uriOptions) 124 | .allow(null), 125 | website: Joi.string().uri(uriOptions), 126 | for_hire: Joi.boolean(), 127 | portfolio: Joi.boolean(), 128 | hiring: Joi.boolean(), 129 | image: customJoi 130 | .string() 131 | .supportedExtension(supportedImageExts) 132 | .fileExists(await getExistingFiles("docs/community/images", "images")) 133 | .required(), 134 | }) 135 | ) 136 | .unique("name") 137 | } 138 | 139 | const getAuthorsSchema = async () => { 140 | return Joi.array() 141 | .items( 142 | Joi.object().keys({ 143 | id: Joi.string().required(), 144 | bio: Joi.string().required(), 145 | avatar: customJoi 146 | .string() 147 | .supportedExtension(supportedImageExts) 148 | .fileExists(await getExistingFiles("docs/blog/avatars", "avatars")) 149 | .required(), 150 | twitter: Joi.string().regex(/^@/), 151 | }) 152 | ) 153 | .unique("id") 154 | } 155 | 156 | const getStartersSchema = () => { 157 | return Joi.array() 158 | .items( 159 | Joi.object().keys({ 160 | url: Joi.string() 161 | .uri(uriOptions) 162 | .required(), 163 | repo: Joi.string() 164 | .uri(uriOptions) 165 | .regex(githubRepoRegex) 166 | .required(), 167 | description: Joi.string().required(), 168 | tags: Joi.array() 169 | .items(Joi.string()) 170 | .required(), 171 | features: Joi.array() 172 | .items(Joi.string()) 173 | .required(), 174 | }) 175 | ) 176 | .unique("url") 177 | .unique("repo") 178 | } 179 | 180 | const fileSchemas = { 181 | "docs/sites.yml": getSitesSchema, 182 | "docs/community/creators.yml": getCreatorsSchema, 183 | "docs/blog/author.yaml": getAuthorsSchema, 184 | "docs/starters.yml": getStartersSchema, 185 | } 186 | 187 | export const utils = { 188 | addErrorMsg: ( 189 | index: string, 190 | message: string, 191 | customErrors: { [id: string]: string[] } 192 | ) => { 193 | if (!customErrors[index]) { 194 | customErrors[index] = [] 195 | } 196 | customErrors[index].push(message) 197 | }, 198 | } 199 | 200 | export const validateYaml = async () => { 201 | return Promise.all( 202 | Object.entries(fileSchemas).map(async ([filePath, schemaFn]) => { 203 | if (!danger.git.modified_files.includes(filePath)) { 204 | return 205 | } 206 | const textContent = await danger.github.utils.fileContents(filePath) 207 | let content: any 208 | try { 209 | content = yamlLoad(textContent) 210 | } catch (e) { 211 | warn( 212 | `## ${filePath} is not valid YAML file:\n\n\`\`\`${e.message}\n\`\`\`` 213 | ) 214 | return 215 | } 216 | 217 | const result = Joi.validate(content, await schemaFn(), { 218 | abortEarly: false, 219 | }) 220 | if (result.error) { 221 | const customErrors: { [id: string]: string[] } = {} 222 | result.error.details.forEach(detail => { 223 | if (detail.path.length > 0) { 224 | const index = detail.path[0] 225 | 226 | let message = detail.message 227 | if (detail.type === "array.unique" && detail.context) { 228 | // by default it doesn't say what field is not unique 229 | message = `"${detail.context.path}" is not unique` 230 | } 231 | utils.addErrorMsg(index, message, customErrors) 232 | } else { 233 | utils.addErrorMsg("root", detail.message, customErrors) 234 | } 235 | }) 236 | 237 | const errors = Object.entries(customErrors).map( 238 | ([index, errors]: [string, string[]]) => { 239 | if (index === "root") { 240 | return errors.map(msg => ` - ${msg}`).join("\n") 241 | } else { 242 | const errorsString = errors.map(msg => ` - ${msg}`).join("\n") 243 | return `- \`\`\`json\n${JSON.stringify(content[index], null, 2) 244 | .split("\n") 245 | .map(line => ` ${line}`) 246 | .join("\n")}\n \`\`\`\n **Errors**:\n${errorsString}` 247 | } 248 | } 249 | ) 250 | 251 | warn( 252 | `## ${filePath} didn't pass validation:\n\n${errors.join("\n---\n")}` 253 | ) 254 | } 255 | }) 256 | ) 257 | } 258 | 259 | export default async () => { 260 | return await validateYaml() 261 | } 262 | -------------------------------------------------------------------------------- /tasks/slack-experiment.ts: -------------------------------------------------------------------------------- 1 | import { danger, peril } from "danger" 2 | import { IncomingWebhook, IncomingWebhookSendArguments } from "@slack/client" 3 | 4 | const org = "gatsbyjs" 5 | const label = "stale?" 6 | 7 | /** 8 | * A task that accepts Slack incoming webhook data 9 | * and sends a message into the Artsy Dev chat room. 10 | * 11 | * The full API docs for the syntax of the expected data 12 | * can be found: https://slackapi.github.io/node-slack-sdk/reference/IncomingWebhook 13 | * 14 | * Usage in a Dangerfile: 15 | * 16 | const message = { 17 | unfurl_links: false, 18 | attachments: [ 19 | { 20 | pretext: "We can throw words around like two hundred million galaxies", 21 | color: "good", 22 | title: issue.title, 23 | title_link: issue.html_url, 24 | author_name: issue.user.login, 25 | author_icon: issue.user.avatar_url, 26 | }, 27 | ], 28 | } 29 | peril.runTask("slack-dev-channel", "in 5 minutes", message) 30 | */ 31 | 32 | /** 33 | * The default, send a slack message with some data that's come in 34 | * this is also usable as a task 35 | */ 36 | 37 | const slackData = async (data: IncomingWebhookSendArguments) => { 38 | if (!data) { 39 | console.log( 40 | "No data was passed to slack-dev-channel, so a message will not be sent." 41 | ) 42 | } else { 43 | const url = peril.env.SLACK_WEBHOOK_URL || "" 44 | const webhook = new IncomingWebhook(url) 45 | await webhook.send(data) 46 | } 47 | } 48 | 49 | /** 50 | * Send a slack message to the dev channel in Artsy 51 | * @param message the message to send to #dev 52 | */ 53 | const slackMessage = async (message: string) => { 54 | const data = { 55 | unfurl_links: false, 56 | attachments: [ 57 | { 58 | color: "good", 59 | title: message, 60 | }, 61 | ], 62 | } 63 | await slackData(data) 64 | } 65 | export interface Result { 66 | url: string 67 | repository_url: string 68 | labels_url: string 69 | comments_url: string 70 | events_url: string 71 | html_url: string 72 | id: number 73 | node_id: string 74 | number: number 75 | title: string 76 | user: any 77 | labels: any[] 78 | state: string 79 | assignee?: any 80 | milestone?: any 81 | comments: number 82 | created_at: Date 83 | updated_at: Date 84 | closed_at?: any 85 | pull_request: any 86 | body: string 87 | score: number 88 | } 89 | 90 | // https://developer.github.com/v3/search/#search-issues 91 | 92 | export default async () => { 93 | const api = danger.github.api 94 | const staleQuery = `org:${org} label:${label} state:open` 95 | const searchResponse = await api.search.issues({ q: staleQuery }) 96 | const items = searchResponse.data.items 97 | 98 | // Bail early 99 | if (items.length === 0) { 100 | await slackMessage("No stale issues found.") 101 | return 102 | } 103 | 104 | // Convert the open issues into attachments 105 | const attachments = items.map((r: Result) => ({ 106 | fallback: "Required plain-text summary of the attachment.", 107 | color: "#36a64f", 108 | author_name: r.user.login, 109 | author_link: r.user.html_url, 110 | author_icon: r.user.avatar_url, 111 | title: r.title, 112 | title_link: r.html_url, 113 | })) 114 | 115 | const text = `There are ${items.length} stale issues:` 116 | await slackData({ 117 | text, 118 | attachments, 119 | unfurl_links: false, 120 | }) 121 | } 122 | -------------------------------------------------------------------------------- /tasks/stale.ts: -------------------------------------------------------------------------------- 1 | import { danger, peril } from "danger" 2 | import * as endOfToday from "date-fns/end_of_today" 3 | import * as subDays from "date-fns/sub_days" 4 | import * as format from "date-fns/format" 5 | import { IssueComment } from "github-webhook-event-types" 6 | 7 | const owner = `gatsbyjs` 8 | 9 | const STALE_LABEL = `stale?` 10 | const EXEMPT_LABEL = `not stale` 11 | const DAYS_TO_STALE = 20 12 | const DAYS_TO_CLOSE = 10 13 | const MAX_ACTIONS = 20 14 | const CONTRIBUTION_GUIDE_LINK = `https://www.gatsbyjs.org/contributing/how-to-contribute/` 15 | const STALE_MESSAGE = `Hiya! 16 | 17 | This issue has gone quiet. Spooky quiet. 👻 18 | 19 | We get a lot of issues, so we currently close issues after ${DAYS_TO_STALE + 20 | DAYS_TO_CLOSE} days of inactivity. It’s been at least ${DAYS_TO_STALE} days since the last update here. 21 | 22 | If we missed this issue or if you want to keep it open, please reply here. You can also add the label "${EXEMPT_LABEL}" to keep this issue open! 23 | 24 | As a friendly reminder: the best way to see this issue, or any other, fixed is to open a Pull Request. Check out [gatsby.dev/contribute](${CONTRIBUTION_GUIDE_LINK}) for more information about opening PRs, triaging issues, and contributing! 25 | 26 | Thanks for being a part of the Gatsby community! 💪💜` 27 | 28 | const CLOSE_MESSAGE = `Hey again! 29 | 30 | It’s been ${DAYS_TO_STALE + 31 | DAYS_TO_CLOSE} days since anything happened on this issue, so our friendly neighborhood robot (that’s me!) is going to close it. 32 | 33 | Please keep in mind that I’m only a robot, so if I’ve closed this issue in error, I’m \`HUMAN_EMOTION_SORRY\`. Please feel free to reopen this issue or create a new one if you need anything else. 34 | 35 | As a friendly reminder: the best way to see this issue, or any other, fixed is to open a Pull Request. Check out [gatsby.dev/contribute](${CONTRIBUTION_GUIDE_LINK}) for more information about opening PRs, triaging issues, and contributing! 36 | 37 | Thanks again for being part of the Gatsby community!` 38 | 39 | export const dateDaysAgo = (today: Date, days: number): string => { 40 | const daysAgo = subDays(today, days) 41 | return format(daysAgo, "YYYY-MM-DD") 42 | } 43 | 44 | interface ApiError { 45 | action: string 46 | opts: object 47 | error: any 48 | } 49 | 50 | const logApiError = ({ action, opts, error }: ApiError) => { 51 | const msg = `Could not run ${action} with options ${JSON.stringify( 52 | opts 53 | )}\n Error was ${error}\nSet env var DEBUG=octokit:rest* for extended logging info.` 54 | console.warn(msg) 55 | } 56 | 57 | // url format is "https://api.github.com/repos//" 58 | // See https://api.github.com/search/issues?q=-label:%22not%20stale%22 for examples 59 | // TODO: hit the url and parse the name from the response, with error handling 60 | const getRepoFromUrl = (url: string) => url.split("/").pop() 61 | 62 | const search = async (days: number, query: string) => { 63 | const api = danger.github.api 64 | const timestamp = dateDaysAgo(endOfToday(), days) 65 | const q = `-label:"${EXEMPT_LABEL}" org:${owner} type:issue state:open archived:false updated:<${timestamp} ${query}` 66 | const searchResponse = await api.search.issues({ 67 | q, 68 | order: "asc", 69 | sort: "updated", 70 | per_page: MAX_ACTIONS, 71 | }) 72 | const items = searchResponse.data.items 73 | return items.slice(0, Math.min(items.length, MAX_ACTIONS)) 74 | } 75 | 76 | const makeItStale = async (issue: { 77 | number: number 78 | repository_url: string 79 | }) => { 80 | let opts: any // any :( 81 | const api = danger.github.api 82 | const repo = getRepoFromUrl(issue.repository_url) 83 | const defaultOpts = { owner, repo, number: issue.number } 84 | 85 | opts = { ...defaultOpts, labels: [STALE_LABEL] } 86 | try { 87 | await api.issues.addLabels(opts) 88 | } catch (error) { 89 | logApiError({ action: `issues.addLabels`, opts, error }) 90 | } 91 | 92 | opts = { ...defaultOpts, body: STALE_MESSAGE } 93 | try { 94 | await api.issues.createComment(opts) 95 | } catch (error) { 96 | logApiError({ action: `issues.createComment`, opts, error }) 97 | } 98 | } 99 | 100 | const makeItClosed = async (issue: { 101 | number: number 102 | repository_url: string 103 | }) => { 104 | let opts: any // any :( 105 | const api = danger.github.api 106 | const repo = getRepoFromUrl(issue.repository_url) 107 | const defaultOpts = { owner, repo, number: issue.number } 108 | 109 | opts = { ...defaultOpts, body: CLOSE_MESSAGE } 110 | try { 111 | await api.issues.createComment(opts) 112 | } catch (error) { 113 | logApiError({ action: `issues.createComment`, opts, error }) 114 | } 115 | 116 | opts = { ...defaultOpts, state: "closed" } 117 | try { 118 | await api.issues.edit(opts) 119 | } catch (error) { 120 | logApiError({ action: `issues.edit`, opts, error }) 121 | } 122 | } 123 | 124 | export default async () => { 125 | console.log("Stale task: init") 126 | 127 | // mark old issues as stale 128 | const toLabel = await search(DAYS_TO_STALE, `-label:"${STALE_LABEL}"`) 129 | console.log(`Stale task: found ${toLabel.length} issues to label`) 130 | await Promise.all(toLabel.map(makeItStale)) 131 | console.log("Stale task: labelled stale issues") 132 | 133 | // close out untouched stale issues 134 | const toClose = await search(DAYS_TO_CLOSE, `label:"${STALE_LABEL}"`) 135 | console.log(`Stale task: found ${toClose.length} issues to close`) 136 | await Promise.all(toClose.map(makeItClosed)) 137 | console.log("Stale task: closed issues") 138 | } 139 | -------------------------------------------------------------------------------- /tests/emptybody.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | import { emptybody } from "../rules/emptybody" 6 | 7 | beforeEach(() => { 8 | dm.danger = { 9 | github: { 10 | repository: { 11 | owner: "gatsbyjs", 12 | }, 13 | issue: { 14 | user: { 15 | login: "someUser", 16 | }, 17 | }, 18 | }, 19 | } 20 | 21 | dm.markdown = jest.fn() 22 | }) 23 | 24 | describe("a new issue", () => { 25 | it("has no content in the body", async () => { 26 | dm.danger.github.issue.body = "" 27 | 28 | await emptybody() 29 | 30 | expect(dm.markdown).toBeCalled() 31 | }) 32 | it("only contains whitespace in body", async () => { 33 | dm.danger.github.issue.body = "\n" 34 | await emptybody() 35 | 36 | expect(dm.markdown).toBeCalled() 37 | }) 38 | it("has a body with content", async () => { 39 | dm.danger.github.issue.body = "Moya is awesome" 40 | await emptybody() 41 | expect(dm.markdown).not.toBeCalled() 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /tests/invite-collaborator.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | 4 | const dm = danger as any 5 | 6 | import { inviteCollaborator } from "../rules/invite-collaborator" 7 | 8 | beforeEach(() => { 9 | dm.danger = { 10 | github: { 11 | thisPR: { 12 | owner: "gatsbyjs", 13 | repo: "peril-gatsbyjs", 14 | number: 1, 15 | }, 16 | pr: { 17 | user: { 18 | login: "someUser", 19 | }, 20 | }, 21 | api: { 22 | orgs: { 23 | getTeamMembership: () => Promise.resolve({ meta: { status: "404" } }), 24 | addTeamMembership: jest.fn(() => 25 | Promise.resolve({ data: { state: "pending" } }) 26 | ), 27 | }, 28 | issues: { 29 | createComment: jest.fn(), 30 | }, 31 | }, 32 | }, 33 | } 34 | }) 35 | 36 | describe("a closed pull request", () => { 37 | it("was merged and authored by a first-time contributor", async () => { 38 | dm.danger.github.pr.merged = true 39 | 40 | await inviteCollaborator() 41 | 42 | expect(dm.danger.github.api.issues.createComment).toBeCalled() 43 | expect(dm.danger.github.api.orgs.addTeamMembership).toBeCalled() 44 | }) 45 | 46 | it("was merged and authored by an existing collaborator", async () => { 47 | dm.danger.github.pr.merged = true 48 | dm.danger.github.api.orgs.getTeamMembership = () => 49 | Promise.resolve({ headers: { status: "204 No Content" } }) 50 | 51 | await inviteCollaborator() 52 | 53 | expect(dm.danger.github.api.issues.createComment).not.toBeCalled() 54 | }) 55 | 56 | it("does not comment if invitation failed", async () => { 57 | dm.danger.github.pr.merged = true 58 | dm.danger.github.api.orgs.addTeamMembership = () => 59 | Promise.reject({ headers: { status: "422 Unprocessable Entity" } }) 60 | 61 | await inviteCollaborator() 62 | 63 | expect(dm.danger.github.api.issues.createComment).not.toBeCalled() 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /tests/labeler.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | import * as l from "../rules/labeler" 6 | jest.spyOn(l, `logApiError`) 7 | 8 | let apiError: any 9 | 10 | beforeEach(() => { 11 | dm.danger = { 12 | github: { 13 | repository: { 14 | name: "gatsby", 15 | owner: { 16 | login: "gatsbyjs", 17 | }, 18 | }, 19 | issue: { 20 | labels: [], 21 | number: 100, 22 | }, 23 | api: { 24 | issues: { 25 | addLabels: jest.fn(), 26 | }, 27 | }, 28 | }, 29 | } 30 | }) 31 | 32 | describe("a new issue", () => { 33 | it("with question mark in a title", () => { 34 | dm.danger.github.issue.title = 35 | "Help - Has anyone hosted a gatsby.js site on Platform.sh?" 36 | return l.labeler().then(() => { 37 | expect(dm.danger.github.api.issues.addLabels).toBeCalledWith({ 38 | repo: "gatsby", 39 | owner: "gatsbyjs", 40 | number: 100, 41 | labels: ["type: question or discussion"], 42 | }) 43 | }) 44 | }) 45 | 46 | it("with existing labels", () => { 47 | dm.danger.github.issue.title = "How are labels handled?" 48 | dm.danger.github.issue.labels = [{ name: "good first issue" }] 49 | return l.labeler().then(() => { 50 | expect(dm.danger.github.api.issues.addLabels).toBeCalledWith({ 51 | repo: "gatsby", 52 | owner: "gatsbyjs", 53 | number: 100, 54 | labels: ["good first issue", "type: question or discussion"], 55 | }) 56 | }) 57 | }) 58 | 59 | it("starting with how", () => { 60 | dm.danger.github.issue.title = 61 | "How do you justify Gatsby’s bundle size to clients" 62 | return l.labeler().then(() => { 63 | expect(dm.danger.github.api.issues.addLabels).toBeCalledWith({ 64 | repo: "gatsby", 65 | owner: "gatsbyjs", 66 | number: 100, 67 | labels: ["type: question or discussion"], 68 | }) 69 | }) 70 | }) 71 | 72 | it("including tutorial", () => { 73 | dm.danger.github.issue.title = "Tutorial template + gold standard example" 74 | return l.labeler().then(() => { 75 | expect(dm.danger.github.api.issues.addLabels).toBeCalledWith({ 76 | repo: "gatsby", 77 | owner: "gatsbyjs", 78 | number: 100, 79 | labels: ["type: documentation"], 80 | }) 81 | }) 82 | }) 83 | 84 | it("including readme", () => { 85 | dm.danger.github.issue.title = "[v2] default starter: update README" 86 | return l.labeler().then(() => { 87 | expect(dm.danger.github.api.issues.addLabels).toBeCalledWith({ 88 | repo: "gatsby", 89 | owner: "gatsbyjs", 90 | number: 100, 91 | labels: ["type: documentation"], 92 | }) 93 | }) 94 | }) 95 | 96 | it("not recognised", () => { 97 | dm.danger.github.issue.title = "Supporting HSTS and how to HSTS preloading" 98 | return l.labeler().then(() => { 99 | expect(dm.danger.github.api.issues.addLabels).not.toBeCalled() 100 | }) 101 | }) 102 | 103 | describe("error logging", () => { 104 | beforeEach(() => { 105 | apiError = new Error("Mocked error") 106 | dm.danger.github.issue.title = 107 | "Help - Has anyone hosted a gatsby.js site on Platform.sh?" 108 | dm.danger.github.api.issues.addLabels = () => Promise.reject(apiError) 109 | }) 110 | 111 | it("log error", () => { 112 | return l.labeler().then(() => { 113 | expect(l.logApiError).toHaveBeenCalledWith({ 114 | action: "issues.addLabel", 115 | error: apiError, 116 | opts: { 117 | labels: ["type: question or discussion"], 118 | number: 100, 119 | owner: "gatsbyjs", 120 | repo: "gatsby", 121 | }, 122 | }) 123 | }) 124 | }) 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /tests/not-stale.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import { IssueComment } from "github-webhook-event-types" 3 | import * as danger from "danger" 4 | const dm = danger as any 5 | 6 | import { notStale, STALE_LABEL } from "../rules/not-stale" 7 | 8 | beforeEach(() => { 9 | const github = ({ 10 | repository: { 11 | name: "gatsby", 12 | owner: { 13 | login: "gatsbyjs", 14 | }, 15 | }, 16 | issue: { 17 | labels: [], 18 | number: 100, 19 | }, 20 | api: { 21 | issues: { 22 | removeLabel: jest.fn(), 23 | }, 24 | }, 25 | } as any) as IssueComment 26 | dm.danger = { github } 27 | }) 28 | 29 | describe("a new issue comment", () => { 30 | it("removes stale? label", async () => { 31 | dm.danger.github.issue.labels = [ 32 | { name: "foo" }, 33 | { name: "stale?" }, 34 | { name: "bar" }, 35 | ] 36 | await notStale() 37 | expect(dm.danger.github.api.issues.removeLabel).toBeCalledWith({ 38 | repo: "gatsby", 39 | owner: "gatsbyjs", 40 | number: 100, 41 | name: encodeURIComponent(STALE_LABEL), 42 | }) 43 | }) 44 | it("does nothing without a stale? label", () => { 45 | return notStale().then(() => { 46 | expect(dm.danger.github.api.issues.removeLabel).not.toHaveBeenCalled() 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /tests/pull-request-on-starter.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | 4 | const dm = danger as any 5 | 6 | import { 7 | closePullRequestAndComment, 8 | comment, 9 | } from "../rules/pull-request-on-starter" 10 | 11 | beforeEach(() => { 12 | dm.danger = { 13 | github: { 14 | thisPR: { 15 | owner: "gatsbyjs", 16 | repo: "peril-gatsbyjs", 17 | number: 1, 18 | }, 19 | pr: { 20 | user: { 21 | login: "someUser", 22 | }, 23 | }, 24 | api: { 25 | pullRequests: { 26 | update: jest.fn(), 27 | }, 28 | }, 29 | }, 30 | } 31 | dm.markdown = jest.fn() 32 | }) 33 | 34 | describe("an opened pull request", () => { 35 | it("was closed with a comment", async () => { 36 | await closePullRequestAndComment() 37 | 38 | expect(dm.danger.github.api.pullRequests.update).toBeCalledWith({ 39 | owner: "gatsbyjs", 40 | repo: "peril-gatsbyjs", 41 | number: 1, 42 | state: "closed", 43 | }) 44 | 45 | expect(dm.markdown).toBeCalledWith(comment("someUser")) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /tests/stale.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | 3 | import { dateDaysAgo } from "../tasks/stale" 4 | 5 | describe("date handling", () => { 6 | it("subtracts one day", () => { 7 | const dateToday = new Date(2015, 4, 21) 8 | const formatted = dateDaysAgo(dateToday, 1) 9 | expect(formatted).toEqual(`2015-05-20`) 10 | }) 11 | it("subtracts across months", () => { 12 | const dateToday = new Date(2015, 4, 21) 13 | const formatted = dateDaysAgo(dateToday, 22) 14 | expect(formatted).toEqual(`2015-04-29`) 15 | }) 16 | it("subtracts across years", () => { 17 | const dateToday = new Date(2015, 0, 21) 18 | const formatted = dateDaysAgo(dateToday, 21) 19 | expect(formatted).toEqual(`2014-12-31`) 20 | }) 21 | }) 22 | 23 | // test for quotes around labels 24 | -------------------------------------------------------------------------------- /tests/validate-yaml/validate-yaml-authors.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | import { validateYaml, utils } from "../../rules/validate-yaml" 4 | const dm = danger as any 5 | const mockedUtils = utils as any 6 | 7 | let mockedResponses: { [id: string]: any } 8 | const setAuthorYmlContent = (content: string) => 9 | (mockedResponses["docs/blog/author.yaml"] = content) 10 | const setAvatarFiles = (filesnames: string[]) => 11 | (mockedResponses["docs/blog/avatars"] = { 12 | data: filesnames.map(filename => ({ name: filename })), 13 | }) 14 | const resetMockedResponses = () => { 15 | mockedResponses = {} 16 | setAvatarFiles([]) 17 | } 18 | 19 | dm.warn = jest.fn() 20 | mockedUtils.addErrorMsg = jest.fn() 21 | 22 | beforeEach(() => { 23 | resetMockedResponses() 24 | dm.warn.mockClear() 25 | mockedUtils.addErrorMsg.mockClear() 26 | dm.danger = { 27 | git: { 28 | modified_files: ["docs/blog/author.yaml"], 29 | }, 30 | github: { 31 | api: { 32 | repos: { 33 | getContent: ({ path }: { path: string }) => mockedResponses[path], 34 | }, 35 | }, 36 | pr: { 37 | head: { 38 | repo: { 39 | full_name: "test/test", 40 | }, 41 | ref: "branch", 42 | }, 43 | }, 44 | utils: { 45 | fileContents: (path: string) => mockedResponses[path], 46 | }, 47 | }, 48 | } 49 | }) 50 | 51 | describe("a new PR", () => { 52 | it(`Valid entry passes validation`, async () => { 53 | setAuthorYmlContent(` 54 | - id: lorem 55 | bio: ipsum 56 | twitter: '@lorem' 57 | avatar: avatars/lorem.jpg 58 | `) 59 | setAvatarFiles(["lorem.jpg"]) 60 | 61 | await validateYaml() 62 | expect(dm.warn).not.toBeCalled() 63 | expect(mockedUtils.addErrorMsg).not.toBeCalled() 64 | }) 65 | 66 | it(`Check for required fields`, async () => { 67 | setAuthorYmlContent(` 68 | - twitter: '@lorem' 69 | `) 70 | 71 | await validateYaml() 72 | expect(dm.warn).toBeCalled() 73 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(3) 74 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 75 | 0, 76 | expect.stringContaining('"id" is required'), 77 | expect.anything() 78 | ) 79 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 80 | 0, 81 | expect.stringContaining('"bio" is required'), 82 | expect.anything() 83 | ) 84 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 85 | 0, 86 | expect.stringContaining('"avatar" is required'), 87 | expect.anything() 88 | ) 89 | }) 90 | 91 | it(`Check type of fields`, async () => { 92 | setAuthorYmlContent(` 93 | - id: 1 94 | bio: 2 95 | twitter: 3 96 | avatar: 4 97 | `) 98 | 99 | await validateYaml() 100 | expect(dm.warn).toBeCalled() 101 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(4) 102 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 103 | 0, 104 | expect.stringContaining('"id" must be a string'), 105 | expect.anything() 106 | ) 107 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 108 | 0, 109 | expect.stringContaining('"bio" must be a string'), 110 | expect.anything() 111 | ) 112 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 113 | 0, 114 | expect.stringContaining('"twitter" must be a string'), 115 | expect.anything() 116 | ) 117 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 118 | 0, 119 | expect.stringContaining('"avatar" must be a string'), 120 | expect.anything() 121 | ) 122 | }) 123 | 124 | it(`Doesn't allow not supported extensions`, async () => { 125 | setAuthorYmlContent(` 126 | - id: lorem 127 | bio: ipsum 128 | twitter: '@lorem' 129 | avatar: avatars/lorem.svg 130 | `) 131 | setAvatarFiles(["lorem.svg"]) 132 | 133 | await validateYaml() 134 | expect(dm.warn).toBeCalled() 135 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(1) 136 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 137 | 0, 138 | expect.stringContaining('"avatar" need to use supported extension'), 139 | expect.anything() 140 | ) 141 | }) 142 | 143 | it(`Doesn't allow not existing images`, async () => { 144 | setAuthorYmlContent(` 145 | - id: lorem 146 | bio: ipsum 147 | twitter: '@lorem' 148 | avatar: avatars/lorem.jpg 149 | `) 150 | setAvatarFiles(["ipsum.jpg"]) 151 | 152 | await validateYaml() 153 | expect(dm.warn).toBeCalled() 154 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(1) 155 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 156 | 0, 157 | expect.stringContaining('"avatar" need to point to existing file'), 158 | expect.anything() 159 | ) 160 | }) 161 | }) 162 | -------------------------------------------------------------------------------- /tests/validate-yaml/validate-yaml-creators.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | import { validateYaml, utils } from "../../rules/validate-yaml" 4 | const dm = danger as any 5 | const mockedUtils = utils as any 6 | 7 | let mockedResponses: { [id: string]: any } 8 | const setCreatorsYmlContent = (content: string) => 9 | (mockedResponses["docs/community/creators.yml"] = content) 10 | const setImagesFiles = (filesnames: string[]) => 11 | (mockedResponses["docs/community/images"] = { 12 | data: filesnames.map(filename => ({ name: filename })), 13 | }) 14 | const resetMockedResponses = () => { 15 | mockedResponses = {} 16 | setImagesFiles([]) 17 | } 18 | 19 | dm.warn = jest.fn() 20 | mockedUtils.addErrorMsg = jest.fn() 21 | 22 | beforeEach(() => { 23 | resetMockedResponses() 24 | dm.warn.mockClear() 25 | mockedUtils.addErrorMsg.mockClear() 26 | dm.danger = { 27 | git: { 28 | modified_files: ["docs/community/creators.yml"], 29 | }, 30 | github: { 31 | api: { 32 | repos: { 33 | getContent: ({ path }: { path: string }) => mockedResponses[path], 34 | }, 35 | }, 36 | pr: { 37 | head: { 38 | repo: { 39 | full_name: "test/test", 40 | }, 41 | ref: "branch", 42 | }, 43 | }, 44 | utils: { 45 | fileContents: (path: string) => mockedResponses[path], 46 | }, 47 | }, 48 | } 49 | }) 50 | 51 | describe("a new PR", () => { 52 | it(`Minimal valid entry passes validation`, async () => { 53 | setCreatorsYmlContent(` 54 | - name: lorem 55 | type: individual 56 | image: images/lorem.jpg 57 | `) 58 | setImagesFiles(["lorem.jpg"]) 59 | 60 | await validateYaml() 61 | expect(dm.warn).not.toBeCalled() 62 | expect(mockedUtils.addErrorMsg).not.toBeCalled() 63 | }) 64 | 65 | it(`Full valid entry passes validation`, async () => { 66 | setCreatorsYmlContent(` 67 | - name: lorem 68 | type: individual 69 | image: images/lorem.jpg 70 | description: lorem ipsum 71 | location: moon 72 | github: https://github.com/gatsbyjs 73 | website: https://www.gatsbyjs.org/ 74 | for_hire: false 75 | portfolio: true 76 | hiring: false 77 | 78 | `) 79 | setImagesFiles(["lorem.jpg"]) 80 | 81 | await validateYaml() 82 | expect(dm.warn).not.toBeCalled() 83 | expect(mockedUtils.addErrorMsg).not.toBeCalled() 84 | }) 85 | 86 | it(`Check for required fields`, async () => { 87 | setCreatorsYmlContent(` 88 | - location: moon 89 | `) 90 | 91 | await validateYaml() 92 | expect(dm.warn).toBeCalled() 93 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(3) 94 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 95 | 0, 96 | expect.stringContaining('"name" is required'), 97 | expect.anything() 98 | ) 99 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 100 | 0, 101 | expect.stringContaining('"type" is required'), 102 | expect.anything() 103 | ) 104 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 105 | 0, 106 | expect.stringContaining('"image" is required'), 107 | expect.anything() 108 | ) 109 | }) 110 | 111 | it(`Check type of fields`, async () => { 112 | setCreatorsYmlContent(` 113 | - name: 1 114 | type: 2 115 | description: 3 116 | location: 4 117 | github: 5 118 | website: 6 119 | for_hire: foo 120 | portfolio: bar 121 | hiring: baz 122 | image: 7 123 | `) 124 | 125 | await validateYaml() 126 | expect(dm.warn).toBeCalled() 127 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(10) 128 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 129 | 0, 130 | expect.stringContaining('"name" must be a string'), 131 | expect.anything() 132 | ) 133 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 134 | 0, 135 | expect.stringContaining('"type" must be a string'), 136 | expect.anything() 137 | ) 138 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 139 | 0, 140 | expect.stringContaining('"description" must be a string'), 141 | expect.anything() 142 | ) 143 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 144 | 0, 145 | expect.stringContaining('"location" must be a string'), 146 | expect.anything() 147 | ) 148 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 149 | 0, 150 | expect.stringContaining('"github" must be a string'), 151 | expect.anything() 152 | ) 153 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 154 | 0, 155 | expect.stringContaining('"website" must be a string'), 156 | expect.anything() 157 | ) 158 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 159 | 0, 160 | expect.stringContaining('"for_hire" must be a boolean'), 161 | expect.anything() 162 | ) 163 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 164 | 0, 165 | expect.stringContaining('"portfolio" must be a boolean'), 166 | expect.anything() 167 | ) 168 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 169 | 0, 170 | expect.stringContaining('"hiring" must be a boolean'), 171 | expect.anything() 172 | ) 173 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 174 | 0, 175 | expect.stringContaining('"image" must be a string'), 176 | expect.anything() 177 | ) 178 | }) 179 | 180 | it(`Doesn't allow not supported types`, async () => { 181 | setCreatorsYmlContent(` 182 | - name: lorem 183 | type: doesn't exist 184 | image: images/lorem.jpg 185 | `) 186 | setImagesFiles(["lorem.jpg"]) 187 | 188 | await validateYaml() 189 | expect(dm.warn).toBeCalled() 190 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(1) 191 | expect(mockedUtils.addErrorMsg).toBeCalledWith( 192 | 0, 193 | expect.stringContaining( 194 | '"type" must be one of [individual, agency, company]' 195 | ), 196 | expect.anything() 197 | ) 198 | }) 199 | 200 | it(`Doesn't allow bad links`, async () => { 201 | setCreatorsYmlContent(` 202 | - name: lorem 203 | type: company 204 | image: images/lorem.jpg 205 | github: foo 206 | website: www.google.com 207 | `) 208 | setImagesFiles(["lorem.jpg"]) 209 | 210 | await validateYaml() 211 | expect(dm.warn).toBeCalled() 212 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(2) 213 | expect(mockedUtils.addErrorMsg).toBeCalledWith( 214 | 0, 215 | expect.stringContaining( 216 | '"github" must be a valid uri with a scheme matching the https|http pattern' 217 | ), 218 | expect.anything() 219 | ) 220 | expect(mockedUtils.addErrorMsg).toBeCalledWith( 221 | 0, 222 | expect.stringContaining( 223 | '"website" must be a valid uri with a scheme matching the https|http pattern' 224 | ), 225 | expect.anything() 226 | ) 227 | }) 228 | 229 | it(`Doesn't allow not supported extensions`, async () => { 230 | setCreatorsYmlContent(` 231 | - name: lorem 232 | type: company 233 | image: images/lorem.svg 234 | `) 235 | setImagesFiles(["lorem.svg"]) 236 | 237 | await validateYaml() 238 | expect(dm.warn).toBeCalled() 239 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(1) 240 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 241 | 0, 242 | expect.stringContaining('"image" need to use supported extension'), 243 | expect.anything() 244 | ) 245 | }) 246 | 247 | it(`Doesn't allow not existing images`, async () => { 248 | setCreatorsYmlContent(` 249 | - name: lorem 250 | type: company 251 | image: images/lorem.jpg 252 | `) 253 | setImagesFiles(["ipsum.jpg"]) 254 | 255 | await validateYaml() 256 | expect(dm.warn).toBeCalled() 257 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(1) 258 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 259 | 0, 260 | expect.stringContaining('"image" need to point to existing file'), 261 | expect.anything() 262 | ) 263 | }) 264 | }) 265 | -------------------------------------------------------------------------------- /tests/validate-yaml/validate-yaml-sites.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | import { validateYaml, utils } from "../../rules/validate-yaml" 4 | const dm = danger as any 5 | const mockedUtils = utils as any 6 | 7 | let mockedResponses: { [id: string]: any } 8 | const setSitesYmlContent = (content: string) => 9 | (mockedResponses["docs/sites.yml"] = content) 10 | const resetMockedResponses = () => { 11 | mockedResponses = {} 12 | } 13 | 14 | dm.warn = jest.fn() 15 | mockedUtils.addErrorMsg = jest.fn() 16 | 17 | beforeEach(() => { 18 | resetMockedResponses() 19 | dm.warn.mockClear() 20 | mockedUtils.addErrorMsg.mockClear() 21 | dm.danger = { 22 | git: { 23 | modified_files: ["docs/sites.yml"], 24 | }, 25 | github: { 26 | pr: { 27 | head: { 28 | repo: { 29 | full_name: "test/test", 30 | }, 31 | ref: "branch", 32 | }, 33 | }, 34 | utils: { 35 | fileContents: (path: string) => mockedResponses[path], 36 | }, 37 | }, 38 | } 39 | }) 40 | 41 | describe("a new PR", () => { 42 | it(`Minimal valid entry passes validation`, async () => { 43 | setSitesYmlContent(` 44 | - title: lorem 45 | url: http://google.com/ 46 | main_url: http://google.com/ 47 | categories: 48 | - Web Development 49 | `) 50 | 51 | await validateYaml() 52 | expect(dm.warn).not.toBeCalled() 53 | expect(mockedUtils.addErrorMsg).not.toBeCalled() 54 | }) 55 | 56 | it(`Full valid entry passes validation`, async () => { 57 | setSitesYmlContent(` 58 | - title: lorem 59 | url: http://google.com/ 60 | main_url: http://google.com/ 61 | source_url: http://google.com/ 62 | description: lorem 63 | categories: 64 | - Web Development 65 | built_by: lorem 66 | built_by_url: http://google.com/ 67 | featured: false 68 | `) 69 | 70 | await validateYaml() 71 | expect(dm.warn).not.toBeCalled() 72 | expect(mockedUtils.addErrorMsg).not.toBeCalled() 73 | }) 74 | 75 | it(`Check for required fields and disallow unkown fields`, async () => { 76 | setSitesYmlContent(` 77 | - test: loem 78 | `) 79 | 80 | await validateYaml() 81 | expect(dm.warn).toBeCalled() 82 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 83 | 0, 84 | expect.stringContaining('"title" is required'), 85 | expect.anything() 86 | ) 87 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 88 | 0, 89 | expect.stringContaining('"url" is required'), 90 | expect.anything() 91 | ) 92 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 93 | 0, 94 | expect.stringContaining('"main_url" is required'), 95 | expect.anything() 96 | ) 97 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 98 | 0, 99 | expect.stringContaining('"categories" is required'), 100 | expect.anything() 101 | ) 102 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 103 | 0, 104 | expect.stringContaining('"test" is not allowed'), 105 | expect.anything() 106 | ) 107 | }) 108 | 109 | it(`Check type of fields`, async () => { 110 | setSitesYmlContent(` 111 | - title: 1 112 | url: 2 113 | main_url: 3 114 | source_url: 4 115 | description: 5 116 | categories: 6 117 | built_by: 7 118 | built_by_url: 8 119 | featured: 9 120 | - title: lorem 121 | url: www.google.com 122 | main_url: www.google.com 123 | source_url: www.google.com 124 | categories: 125 | - 1 126 | - true 127 | built_by_url: www.google.com 128 | `) 129 | 130 | await validateYaml() 131 | expect(dm.warn).toBeCalled() 132 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 133 | 0, 134 | expect.stringContaining('"title" must be a string'), 135 | expect.anything() 136 | ) 137 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 138 | 0, 139 | expect.stringContaining('"url" must be a string'), 140 | expect.anything() 141 | ) 142 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 143 | 0, 144 | expect.stringContaining('"main_url" must be a string'), 145 | expect.anything() 146 | ) 147 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 148 | 0, 149 | expect.stringContaining('"source_url" must be a string'), 150 | expect.anything() 151 | ) 152 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 153 | 0, 154 | expect.stringContaining('"description" must be a string'), 155 | expect.anything() 156 | ) 157 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 158 | 0, 159 | expect.stringContaining('"categories" must be an array'), 160 | expect.anything() 161 | ) 162 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 163 | 0, 164 | expect.stringContaining('"built_by" must be a string'), 165 | expect.anything() 166 | ) 167 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 168 | 0, 169 | expect.stringContaining('"built_by_url" must be a string'), 170 | expect.anything() 171 | ) 172 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 173 | 0, 174 | expect.stringContaining('"featured" must be a boolean'), 175 | expect.anything() 176 | ) 177 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 178 | 1, 179 | expect.stringContaining( 180 | '"url" must be a valid uri with a scheme matching the https|http pattern' 181 | ), 182 | expect.anything() 183 | ) 184 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 185 | 1, 186 | expect.stringContaining( 187 | '"main_url" must be a valid uri with a scheme matching the https|http pattern' 188 | ), 189 | expect.anything() 190 | ) 191 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 192 | 1, 193 | expect.stringContaining( 194 | '"source_url" must be a valid uri with a scheme matching the https|http pattern' 195 | ), 196 | expect.anything() 197 | ) 198 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 199 | 1, 200 | expect.stringContaining('"0" must be a string'), 201 | expect.anything() 202 | ) 203 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 204 | 1, 205 | expect.stringContaining('"1" must be a string'), 206 | expect.anything() 207 | ) 208 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 209 | 1, 210 | expect.stringContaining( 211 | '"built_by_url" must be a valid uri with a scheme matching the https|http pattern' 212 | ), 213 | expect.anything() 214 | ) 215 | }) 216 | 217 | it(`Check for duplicates`, async () => { 218 | setSitesYmlContent(` 219 | - title: lorem 220 | url: http://google.com/ 221 | main_url: http://google.com/ 222 | categories: 223 | - Web Development 224 | - title: lorem 225 | url: http://google2.com/ 226 | main_url: http://google2.com/ 227 | categories: 228 | - Web Development 229 | - title: ipsum 230 | url: http://google.com/ 231 | main_url: http://google3.com/ 232 | categories: 233 | - Web Development 234 | - title: dolor 235 | url: http://google3.com/ 236 | main_url: http://google.com/ 237 | categories: 238 | - Web Development 239 | `) 240 | 241 | await validateYaml() 242 | expect(dm.warn).toBeCalled() 243 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 244 | 1, 245 | expect.stringContaining('"title" is not unique'), 246 | expect.anything() 247 | ) 248 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 249 | 2, 250 | expect.stringContaining('"url" is not unique'), 251 | expect.anything() 252 | ) 253 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 254 | 3, 255 | expect.stringContaining('"main_url" is not unique'), 256 | expect.anything() 257 | ) 258 | }) 259 | }) 260 | -------------------------------------------------------------------------------- /tests/validate-yaml/validate-yaml-starters.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | import { validateYaml, utils } from "../../rules/validate-yaml" 4 | const dm = danger as any 5 | const mockedUtils = utils as any 6 | 7 | let mockedResponses: { [id: string]: any } 8 | const setStartersYmlContent = (content: string) => 9 | (mockedResponses["docs/starters.yml"] = content) 10 | const resetMockedResponses = () => { 11 | mockedResponses = {} 12 | } 13 | 14 | dm.warn = jest.fn() 15 | mockedUtils.addErrorMsg = jest.fn() 16 | 17 | beforeEach(() => { 18 | resetMockedResponses() 19 | dm.warn.mockClear() 20 | mockedUtils.addErrorMsg.mockClear() 21 | dm.danger = { 22 | git: { 23 | modified_files: ["docs/starters.yml"], 24 | }, 25 | github: { 26 | pr: { 27 | head: { 28 | repo: { 29 | full_name: "test/test", 30 | }, 31 | ref: "branch", 32 | }, 33 | }, 34 | utils: { 35 | fileContents: (path: string) => mockedResponses[path], 36 | }, 37 | }, 38 | } 39 | }) 40 | 41 | describe("a new PR", () => { 42 | it(`Valid entry passes validation`, async () => { 43 | setStartersYmlContent(` 44 | - url: http://gatsbyjs.github.io/gatsby-starter-default/ 45 | repo: https://github.com/gatsbyjs/gatsby-starter-default 46 | description: official default 47 | features: 48 | - It works 49 | tags: 50 | - Official 51 | `) 52 | 53 | await validateYaml() 54 | expect(dm.warn).not.toBeCalled() 55 | expect(mockedUtils.addErrorMsg).not.toBeCalled() 56 | }) 57 | 58 | it(`Check for required fields and disallow unkown fields`, async () => { 59 | setStartersYmlContent(` 60 | - test: loem 61 | `) 62 | 63 | await validateYaml() 64 | expect(dm.warn).toBeCalled() 65 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(6) 66 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 67 | 0, 68 | expect.stringContaining('"url" is required'), 69 | expect.anything() 70 | ) 71 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 72 | 0, 73 | expect.stringContaining('"repo" is required'), 74 | expect.anything() 75 | ) 76 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 77 | 0, 78 | expect.stringContaining('"description" is required'), 79 | expect.anything() 80 | ) 81 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 82 | 0, 83 | expect.stringContaining('"tags" is required'), 84 | expect.anything() 85 | ) 86 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 87 | 0, 88 | expect.stringContaining('"features" is required'), 89 | expect.anything() 90 | ) 91 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 92 | 0, 93 | expect.stringContaining('"test" is not allowed'), 94 | expect.anything() 95 | ) 96 | }) 97 | 98 | it(`Check type of fields`, async () => { 99 | setStartersYmlContent(` 100 | - url: 1 101 | repo: 2 102 | description: 3 103 | tags: 4 104 | features: 5 105 | - url: www.google.com 106 | repo: www.google.com 107 | description: lorem 108 | tags: 109 | - 1 110 | - true 111 | features: 112 | - 2 113 | - false 114 | `) 115 | 116 | await validateYaml() 117 | expect(dm.warn).toBeCalled() 118 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 119 | 0, 120 | expect.stringContaining('"url" must be a string'), 121 | expect.anything() 122 | ) 123 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 124 | 0, 125 | expect.stringContaining('"repo" must be a string'), 126 | expect.anything() 127 | ) 128 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 129 | 0, 130 | expect.stringContaining('"description" must be a string'), 131 | expect.anything() 132 | ) 133 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 134 | 0, 135 | expect.stringContaining('"tags" must be an array'), 136 | expect.anything() 137 | ) 138 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 139 | 0, 140 | expect.stringContaining('"features" must be an array'), 141 | expect.anything() 142 | ) 143 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 144 | 1, 145 | expect.stringContaining( 146 | '"url" must be a valid uri with a scheme matching the https|http pattern' 147 | ), 148 | expect.anything() 149 | ) 150 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 151 | 1, 152 | expect.stringContaining( 153 | '"repo" must be a valid uri with a scheme matching the https|http pattern' 154 | ), 155 | expect.anything() 156 | ) 157 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 158 | 1, 159 | expect.stringContaining('"0" must be a string'), 160 | expect.anything() 161 | ) 162 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 163 | 1, 164 | expect.stringContaining('"1" must be a string'), 165 | expect.anything() 166 | ) 167 | }) 168 | 169 | it(`Doesn't allow non github repos`, async () => { 170 | setStartersYmlContent(` 171 | - url: http://gatsbyjs.github.io/gatsby-starter-default/ 172 | repo: https://gitlab.com/gatsbyjs/gatsby-starter-default 173 | description: official default 174 | features: 175 | - It works 176 | tags: 177 | - Official 178 | `) 179 | 180 | await validateYaml() 181 | expect(dm.warn).toBeCalled() 182 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(1) 183 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 184 | 0, 185 | expect.stringContaining( 186 | '"repo" with value "https://gitlab.com/gatsbyjs/gatsby-starter-default" fails to match the required pattern: /^https?:\\/\\/github.com\\/[^\\/]+\\/[^\\/]+$/' 187 | ), 188 | expect.anything() 189 | ) 190 | }) 191 | 192 | it(`Check for duplicates`, async () => { 193 | setStartersYmlContent(` 194 | - url: http://gatsbyjs.github.io/gatsby-starter-default/ 195 | repo: https://github.com/gatsbyjs/gatsby-starter-default 196 | description: official default 197 | features: 198 | - It works 199 | tags: 200 | - Official 201 | - url: http://gatsbyjs.github.io/gatsby-starter-default2/ 202 | repo: https://github.com/gatsbyjs/gatsby-starter-default 203 | description: official default 204 | features: 205 | - It works 206 | tags: 207 | - Official 208 | - url: http://gatsbyjs.github.io/gatsby-starter-default/ 209 | repo: https://github.com/gatsbyjs/gatsby-starter-default2 210 | description: official default 211 | features: 212 | - It works 213 | tags: 214 | - Official 215 | `) 216 | 217 | await validateYaml() 218 | expect(dm.warn).toBeCalled() 219 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledTimes(2) 220 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 221 | 1, 222 | expect.stringContaining('"repo" is not unique'), 223 | expect.anything() 224 | ) 225 | expect(mockedUtils.addErrorMsg).toHaveBeenCalledWith( 226 | 2, 227 | expect.stringContaining('"url" is not unique'), 228 | expect.anything() 229 | ) 230 | }) 231 | }) 232 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": ["es2017"], 6 | "strict": true 7 | } 8 | } 9 | --------------------------------------------------------------------------------