├── .gitignore ├── .travis.yml ├── .vscode └── settings.json ├── tsconfig.json ├── empty-settings.json ├── .github ├── workflows │ ├── run-conventional-commits-check.yml │ └── run-danger-yarn.yml └── ISSUE_TEMPLATE.md ├── wallaby.js ├── dangerfile.ts ├── tests ├── rfc_5.test.ts ├── dangerfile.test.ts ├── rfc_53.test.ts ├── rfc_177.test.ts ├── retroActionItem.test.ts ├── rfc_33.test.ts ├── deploySummary.test.ts ├── rfc_13.test.ts ├── closedSourceRationaleCheck.test.ts ├── rfc_74.test.ts ├── addReviewer.test.ts ├── ossPRsForbidForks.test.ts ├── rfc_7.test.ts ├── standupReminder.test.ts ├── rfc_reaction_1095.test.ts ├── rfc_40.test.ts ├── prReviewReminder.test.ts ├── rfc_16.test.ts └── rfc_10.test.ts ├── peril.settings.json ├── org ├── rfc │ ├── addRFCToNewIssues.ts │ └── scheduleRFCsForLabels.ts ├── newRelease.ts ├── retroActionItems.ts ├── ossPRsForbidForks.ts ├── closedPRs.ts ├── jira │ ├── utils.ts │ └── pr.ts ├── addReviewer.ts ├── mergeOnGreen.ts ├── addVersionLabel.ts ├── markAsMergeOnGreen.ts └── allPRs.ts ├── ambient.d.ts ├── LICENSE ├── tasks ├── supGP.ts ├── weeklyRFCSummary.ts ├── prReviewReminder.ts ├── slackDevChannel.ts ├── dailyLicenseCheck.ts ├── closedSourceRationaleCheck.ts ├── standupReminder.ts └── compareSchemas.ts ├── package.json ├── README.md ├── spellcheck.json └── fixtures ├── jira_examples_transitions.json └── jira_issue_example.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: "node" 3 | install: yarn install 4 | script: 5 | - yarn type-check 6 | - yarn jest 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "cSpell.words": ["changelogs", "gitdata", "jira", "slackify"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "resolveJsonModule": true, 6 | "allowSyntheticDefaultImports": true, 7 | "lib": ["es2018"], 8 | "strict": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /empty-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/danger/peril/master/peril-settings-json.schema", 3 | "settings": { 4 | }, 5 | "rules": { 6 | }, 7 | "repos" : { 8 | }, 9 | "tasks": { 10 | }, 11 | "scheduler": { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/run-conventional-commits-check.yml: -------------------------------------------------------------------------------- 1 | name: ☢️ Conventional Commits Check 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited] 6 | 7 | jobs: 8 | run-conventional-commits-check: 9 | uses: artsy/duchamp/.github/workflows/conventional-commits-check.yml@main 10 | -------------------------------------------------------------------------------- /.github/workflows/run-danger-yarn.yml: -------------------------------------------------------------------------------- 1 | name: ☢️ Danger - Yarn 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, synchronize] 6 | 7 | jobs: 8 | run-danger-yarn: 9 | uses: artsy/duchamp/.github/workflows/danger-yarn.yml@main 10 | secrets: 11 | danger-token: ${{ secrets.DANGER_TOKEN }} 12 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = function(wallaby) { 2 | return { 3 | debug: true, 4 | 5 | env: { 6 | type: "node", 7 | runner: "node", 8 | }, 9 | 10 | testFramework: "jest", 11 | 12 | files: ["tsconfig.json", "org/**/*.ts?(x)", "danger/**/*.ts?(x)", "tasks/**/*.ts?(x)"], 13 | 14 | tests: ["tests/*.test.ts?(x)"], 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /dangerfile.ts: -------------------------------------------------------------------------------- 1 | import { danger, warn } from "danger" 2 | 3 | export default async () => { 4 | const notification = 5 | "New rules detected, you probably need to update [this document](https://github.com/artsy/README/blob/master/culture/peril.md)." 6 | const packageDiff = await danger.git.JSONDiffForFile("peril.settings.json") 7 | const changeDetected = !!packageDiff.rules || !!packageDiff.scheduler 8 | 9 | if (changeDetected) { 10 | warn(notification) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/rfc_5.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | import { rfc5 } from "../org/allPRs" 6 | 7 | beforeEach(() => { 8 | dm.fail = jest.fn() 9 | }) 10 | 11 | it("fails when there's no PR body", () => { 12 | dm.danger = { github: { pr: { body: "" } } } 13 | rfc5() 14 | expect(dm.fail).toHaveBeenCalledWith("Please add a description to your PR.") 15 | }) 16 | 17 | it("does nothing when there's a PR body", () => { 18 | dm.danger = { github: { pr: { body: "Hello world" } } } 19 | rfc5() 20 | expect(dm.fail).not.toHaveBeenCalled() 21 | }) 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Proposal: 2 | 3 | Apply a spell checker to every markdown document that appears in a PR. 4 | 5 | ## Reasoning 6 | 7 | We want to have polished documents, both internally and externally. Having a spellcheck 8 | happening without any effort on a developers part means that we'll get a second look at 9 | any documentation improvements on any repo. 10 | 11 | ## Exceptions: 12 | 13 | This won't be perfect, but it is better to get something working than to not have it at all. 14 | I added the ability to ignore files: so CHANGELOGs which tend to be really jargon heavy will 15 | be avoided in every repo. 16 | 17 | Other than that, we can continue to build up a global list of words to ignore. 18 | 19 | ## Additional Context: 20 | 21 | You can see our discussion [in slack here](/link/to/slack.com) 22 | -------------------------------------------------------------------------------- /peril.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/danger/peril/master/peril-settings-json.schema", 3 | "settings": { 4 | "ignored_repos": ["artsy/looker", "artsy/clouds", "artsy/design", "artsy/hokusai", "artsy/quantum"], 5 | "disable_github_check": true 6 | }, 7 | "rules": { 8 | // Jira integration 9 | "pull_request": ["org/allPRs.ts", "org/jira/pr.ts", "org/ossPRsForbidForks.ts"], 10 | "pull_request.closed": ["org/jira/pr.ts"], 11 | // The RFC process 12 | "issues": "org/rfc/addRFCToNewIssues.ts", 13 | "issues.labeled": ["org/rfc/scheduleRFCsForLabels.ts"], 14 | // Merge on Green 15 | "issue_comment": "org/markAsMergeOnGreen.ts", 16 | "status.success": "org/mergeOnGreen.ts", 17 | "pull_request_review": "org/markAsMergeOnGreen.ts" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /org/rfc/addRFCToNewIssues.ts: -------------------------------------------------------------------------------- 1 | import { danger } from "danger" 2 | import { Issues } from "github-webhook-event-types" 3 | 4 | /** 5 | * When an an issue/PR is created, check to see if the title includes 6 | * RFC, if it does - then add (or create) the label "RFC". 7 | */ 8 | export default async (issues: Issues) => { 9 | const issue = issues.issue 10 | 11 | if (issue.state === "open" && (issue.title.includes("RFC:") || issue.title.includes("[RFC]"))) { 12 | // Marks it as an RFC 13 | console.log("Adding label to the issue") 14 | await danger.github.utils.createOrAddLabel( 15 | { 16 | name: "RFC", 17 | color: "053a68", 18 | description: "Indicates that this PR is a Request For Comments", 19 | }, 20 | { 21 | owner: issues.repository.owner.login, 22 | repo: issues.repository.name, 23 | id: issue.number, 24 | } 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ambient.d.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLScalarType, GraphQLEnumType, GraphQLUnionType } from "graphql" 2 | 3 | // Used for GraphQL diffing 4 | // See: https://github.com/jarwol/graphql-schema-utils/blob/master/lib/diff.js 5 | // 6 | 7 | type GraphQLy = 8 | | GraphQLObjectType 9 | | GraphQLScalarType 10 | | GraphQLEnumType 11 | // | GraphQLNonNull 12 | // | GraphQLList 13 | | GraphQLUnionType 14 | 15 | type DiffTypes = 16 | | "TypeDescriptionDiff" 17 | | "TypeMissing" 18 | | "TypeNameDiff" 19 | | "BaseTypeDiff" 20 | | "UnionTypeDiff" 21 | | "InterfaceDiff" 22 | | "FieldDescriptionDiff" 23 | | "FieldMissing" 24 | | "FieldDiff" 25 | | "ArgDescriptionDiff" 26 | | "ArgDiff" 27 | | "EnumDiff" 28 | 29 | export type GraphQLDiff = { 30 | /** Reference from the old schema */ 31 | thisType: GraphQLy 32 | /** Reference from the new schema */ 33 | otherType: GraphQLy 34 | diffType: DiffTypes 35 | backwardsCompatible: boolean 36 | } 37 | -------------------------------------------------------------------------------- /org/newRelease.ts: -------------------------------------------------------------------------------- 1 | import { peril } from "danger" 2 | import { IncomingWebhook } from "@slack/client" 3 | import { Create } from "github-webhook-event-types" 4 | 5 | // Note new tags inside a releases channel 6 | // https://github.com/artsy/peril-settings/issues/40 7 | // 8 | export default async (create: Create) => { 9 | if (create.ref_type !== "tag") { 10 | return console.log("Skipping because it's not a tag") 11 | } 12 | 13 | if (!peril.env.SLACK_RFC_WEBHOOK_URL) { 14 | throw new Error("There is no slack webhook env var set up") 15 | } 16 | 17 | var webhook = new IncomingWebhook(peril.env.SLACK_RFC_WEBHOOK_URL) 18 | await webhook.send({ 19 | unfurl_links: false, 20 | channel: "CA3LTRT0T", 21 | attachments: [ 22 | { 23 | color: "good", 24 | title: `Deployed ${create.repository.name} - ${create.ref}`, 25 | title_link: create.repository.html_url, 26 | author_name: create.sender.login, 27 | author_icon: create.sender.avatar_url, 28 | }, 29 | ], 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Artsy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/dangerfile.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | import rule from "../dangerfile" 6 | 7 | beforeEach(() => { 8 | dm.warn = jest.fn() 9 | }) 10 | 11 | it("warns on rule change", async () => { 12 | dm.danger = { git: { JSONDiffForFile: () => Promise.resolve({ rules: "not-empty" }) } } 13 | await rule() 14 | expect(dm.warn).toHaveBeenCalledWith( 15 | "New rules detected, you probably need to update [this document](https://github.com/artsy/README/blob/master/culture/peril.md)." 16 | ) 17 | }) 18 | 19 | it("warns on scheduler change", async () => { 20 | dm.danger = { git: { JSONDiffForFile: () => Promise.resolve({ scheduler: "not-empty" }) } } 21 | await rule() 22 | expect(dm.warn).toHaveBeenCalledWith( 23 | "New rules detected, you probably need to update [this document](https://github.com/artsy/README/blob/master/culture/peril.md)." 24 | ) 25 | }) 26 | 27 | it("has nothing to say in absence of rules or scheduler changes", async () => { 28 | dm.danger = { git: { JSONDiffForFile: () => Promise.resolve({ something_else: "foobar" }) } } 29 | await rule() 30 | expect(dm.warn).not.toHaveBeenCalled() 31 | }) 32 | -------------------------------------------------------------------------------- /org/retroActionItems.ts: -------------------------------------------------------------------------------- 1 | import { peril } from "danger" 2 | import { Issues } from "github-webhook-event-types" 3 | import { IncomingWebhookSendArguments, MessageAttachment } from "@slack/client" 4 | 5 | /** 6 | * When an issue has been labelled Retro Action Item, then trigger the scheduler 7 | * to send slack message about this action item. 8 | */ 9 | 10 | export default async (issues: Issues) => { 11 | const issue = issues.issue 12 | 13 | const slackify = (text: string, attachment: MessageAttachment = {}): IncomingWebhookSendArguments => ({ 14 | unfurl_links: false, 15 | attachments: [ 16 | { 17 | pretext: text, 18 | color: "good", 19 | title: issue.title, 20 | title_link: issue.html_url, 21 | author_name: issue.user.login, 22 | author_icon: issue.user.avatar_url, 23 | }, 24 | attachment, 25 | ], 26 | }) 27 | 28 | const retroActionItem = issue.labels.find(l => l.name === "Retro Action Item") 29 | if (retroActionItem) { 30 | console.log("Triggering slack notifications") 31 | 32 | await peril.runTask("slack-dev-channel", "in 5 minutes", slackify("🎉: A new Retro Action Item is shared.")) 33 | 34 | console.log("Triggered slack notifications") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/rfc_53.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | jest.mock("@slack/client", () => ({ 5 | IncomingWebhook: jest.fn(), 6 | })) 7 | import { IncomingWebhook } from "@slack/client" 8 | 9 | import rfc53 from "../org/newRelease" 10 | 11 | it("sends a webhook for creates which are tags", async () => { 12 | IncomingWebhook.prototype.send = jest.fn() 13 | 14 | const webhook = { 15 | ref_type: "tag", 16 | ref: "v1.4.0", 17 | repository: { 18 | name: "eigen", 19 | html_url: "http://url.com", 20 | }, 21 | sender: { 22 | login: "Yuki", 23 | avatar_url: "http://my.avatar.com", 24 | }, 25 | } 26 | 27 | dm.peril = { 28 | env: { SLACK_RFC_WEBHOOK_URL: "https://123.com/api" }, 29 | } 30 | 31 | await rfc53(webhook as any) 32 | 33 | expect(IncomingWebhook.prototype.send).toHaveBeenCalledWith({ 34 | attachments: [ 35 | { 36 | author_icon: "http://my.avatar.com", 37 | author_name: "Yuki", 38 | color: "good", 39 | title: "Deployed eigen - v1.4.0", 40 | title_link: "http://url.com", 41 | }, 42 | ], 43 | channel: "CA3LTRT0T", 44 | unfurl_links: false, 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /tests/rfc_177.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | import { rfc177 } from "../org/allPRs" 6 | 7 | beforeEach(() => { 8 | dm.danger = {} 9 | dm.warn = jest.fn() 10 | }) 11 | 12 | it("warns when more than one person is assigned to a PR", () => { 13 | dm.danger.github = { pr: { title: "A PR with lots of assignees", assignees: ["mdole", "orta", "someotherpeeps"] } } 14 | rfc177() 15 | expect(dm.warn).toHaveBeenCalledWith("Please only assign one person to a PR") 16 | }) 17 | 18 | it("doesn't warn if one person is assigned to a PR", () => { 19 | dm.danger.github = { pr: { title: "A PR with one assignee in an array", assignees: ["mdole"] } } 20 | rfc177() 21 | expect(dm.warn).not.toHaveBeenCalled() 22 | }) 23 | 24 | it("doesn't warn if one person is assigned to a PR (using assignee instead of assignees)", () => { 25 | dm.danger.github = { pr: { title: "A PR with one solo assignee", assignee: "mdole" } } 26 | rfc177() 27 | expect(dm.warn).not.toHaveBeenCalled() 28 | }) 29 | 30 | it("doesn't warn if nobody is assigned to a pr", () => { 31 | dm.danger.github = { pr: { title: "A PR with no assignees" } } 32 | rfc177() 33 | expect(dm.warn).not.toHaveBeenCalled() 34 | }) 35 | -------------------------------------------------------------------------------- /org/ossPRsForbidForks.ts: -------------------------------------------------------------------------------- 1 | import { danger, warn, fail } from "danger" 2 | 3 | // Many OSS repos at Artsy have historically used PRs from branches on the repo, 4 | // instead of PRs from forks. Our tooling still relies on this; PRs from forks 5 | // won't even trigger CI builds on some of these projects, so we need to warn 6 | // the authors via Peril. 7 | const nonForkableRepos = ["eigen", "emission", "eidolon", "energy", "emergence", "rosalind"] 8 | 9 | export default async () => { 10 | const pr = danger.github.pr 11 | const isNonForkableRepo = nonForkableRepos.filter(name => pr.base.repo.name.endsWith(name)).length > 0 12 | 13 | if (isNonForkableRepo && pr.head.repo.fork) { 14 | try { 15 | // Are they a member of the Artsy GitHub org? This will throw if not. 16 | await danger.github.api.orgs.checkMembership({ org: "artsy", username: pr.user.login }) 17 | fail( 18 | "Artsy staff submitting PRs on this repo need to submit them from branches on the repo, and not from forks of the repo. This is a limitation of our CI infrastructure; please close this PR and re-open it from a branch." 19 | ) 20 | } catch (error) { 21 | // They are not. 22 | warn( 23 | "This PR is on a repo with limited CI support for open source contributors; the reviewers may need to check out your code locally to run the tests." 24 | ) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /org/closedPRs.ts: -------------------------------------------------------------------------------- 1 | import { danger, peril } from "danger" 2 | import { IncomingWebhook } from "@slack/client" 3 | 4 | // Ping slack channels for related labels 5 | // https://github.com/artsy/peril-settings/issues/33 6 | export default async () => { 7 | const pr = danger.github.pr 8 | // You can get the channel ID by opening slack in 9 | // the web inspector and looking at the channel name 10 | const labelsMap = { 11 | consignments: "C52403S10", 12 | auctions: "C0C4AJ1PF", 13 | analytics: "C0KEQD4B0", 14 | } as any 15 | 16 | // Find the labels in both the map above, and in the PR's labels 17 | const allWantedLabels = Object.keys(labelsMap) 18 | const labelsToAlert: string[] = danger.github.issue.labels 19 | .map(l => l.name.toLowerCase()) 20 | .filter((l: string) => allWantedLabels.includes(l)) 21 | 22 | // Loop through and send out Slack messages 23 | for (const label of labelsToAlert) { 24 | var url = peril.env.SLACK_RFC_WEBHOOK_URL || "" 25 | var webhook = new IncomingWebhook(url) 26 | 27 | await webhook.send({ 28 | unfurl_links: false, 29 | channel: labelsMap[label], 30 | attachments: [ 31 | { 32 | color: "good", 33 | title: `PR merged on ${pr.base.repo.name} - ${pr.title}`, 34 | title_link: `${pr.base.repo.html_url}/pull/${pr.number}`, 35 | author_name: pr.user.login, 36 | author_icon: pr.user.avatar_url, 37 | }, 38 | ], 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /org/jira/utils.ts: -------------------------------------------------------------------------------- 1 | import { flatten } from "lodash" 2 | 3 | // https://stackoverflow.com/questions/19322669/regular-expression-for-a-jira-identifier#30518972 4 | // The extra bit at the beginning is to exclude Markdown links. 5 | // Note: this regex operates on a reversed version of the string. 6 | const jiraTicketRegex = /(([^\(]\])|\))(?\d+-[A-Z]+(?!-?[a-zA-Z]{1,10}))[\[\(]/g 7 | 8 | export const getJiraTicketIDsFromText = (body: string) => { 9 | // Look for jira ticket references in brackets like (PLAT-123) 10 | const reverseBody = reverse(body) 11 | const shorthandReferences: string[] = [] 12 | let match: RegExpExecArray | null 13 | while ((match = jiraTicketRegex.exec(reverseBody)) !== null) { 14 | if (match.groups) { 15 | shorthandReferences.push(match.groups.ticketID) 16 | } 17 | } 18 | return shorthandReferences.map(id => reverse(id)).reverse() 19 | } 20 | 21 | export const getJiraTicketIDsFromCommits = (commits: Array<{ message: string }>) => { 22 | const commitMessages = commits.map(m => m.message) 23 | return flatten(commitMessages.map(getJiraTicketIDsFromText)) 24 | } 25 | 26 | export const makeJiraTransition = (comment: string, status: any) => ({ 27 | update: { 28 | comment: [ 29 | { 30 | add: { 31 | body: comment, 32 | }, 33 | }, 34 | ], 35 | }, 36 | transition: { 37 | id: status.id, 38 | }, 39 | }) 40 | 41 | export const uniq = (a: any[]) => Array.from(new Set(a)) 42 | 43 | const reverse = (str: string) => 44 | Array.from(str) 45 | .reverse() 46 | .join("") 47 | -------------------------------------------------------------------------------- /tasks/supGP.ts: -------------------------------------------------------------------------------- 1 | import { peril } from "danger" 2 | import fetch from "node-fetch" 3 | import { chunk, shuffle } from "lodash" 4 | 5 | // https://team.artsy.net/api?query=%7B%0A%20%20members(team%3A%22Gallery%20Partnerships%22)%20%7B%0A%20%20%20%20name%0A%20%20%20%20slackID%0A%20%20%20%20country%0A%20%20%20%20city%0A%20%20%7D%0A%7D 6 | 7 | const query = ` 8 | { 9 | members(team:"Gallery Partnerships") { 10 | name 11 | slackID 12 | country 13 | city 14 | title 15 | } 16 | } 17 | ` 18 | interface Member { 19 | name: string 20 | slackID: string 21 | country: string 22 | city: string 23 | title: string 24 | } 25 | 26 | export default async () => { 27 | const req = await fetch("https://team.artsy.net/api", { 28 | method: "POST", 29 | headers: { 30 | Accept: "application/json", 31 | "Content-Type": "application/json", 32 | secret: peril.env.TEAM_NAV_SECRET, 33 | }, 34 | body: JSON.stringify({ query }), 35 | }) 36 | 37 | const data = await req.json() 38 | console.log(data) 39 | const members = data.members as Member[] 40 | const chunkedMembers = chunk(shuffle(members)) 41 | chunkedMembers.forEach(supGroup => { 42 | console.log(supGroup) 43 | }) 44 | // const isSameLocationWeek = true 45 | // const splitPerLocations = _.groupBy(members, "location") 46 | } 47 | 48 | // const loopThroughMembersAndSendSlacks = (members: Member[]) => _.shuffle(members) 49 | 50 | // const filterManagerFolks = (members: Member[]) => 51 | // members.filter(m => !m.title.toLowerCase().includes("director") && !m.title.toLowerCase().includes("manager")) 52 | -------------------------------------------------------------------------------- /tests/retroActionItem.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => ({ 2 | peril: { runTask: jest.fn() }, 3 | danger: { github: { utils: { createOrAddLabel: jest.fn() } } }, 4 | })) 5 | import { peril, danger } from "danger" 6 | 7 | import retroActionItem from "../org/retroActionItems" 8 | 9 | afterEach(() => { 10 | // @ts-ignore 11 | peril.runTask.mockReset() 12 | // @ts-ignore 13 | danger.github.utils.createOrAddLabel.mockReset() 14 | }) 15 | 16 | it("ignores issues which aren't retro action items", async () => { 17 | const issues: any = { 18 | issue: { 19 | title: "Not Sharing some retro action items!", 20 | state: "open", 21 | html_url: "123", 22 | labels: [{ name: "random issue" }], 23 | user: { 24 | login: "orta", 25 | avatar_url: "https://123.com", 26 | }, 27 | }, 28 | } 29 | 30 | await retroActionItem(issues) 31 | 32 | expect(peril.runTask).not.toBeCalled() 33 | expect(danger.github.utils.createOrAddLabel).not.toBeCalled() 34 | }) 35 | 36 | it("Triggers tasks when Retro Action Item is in the label", async () => { 37 | const issues: any = { 38 | repository: { 39 | owner: { 40 | login: "org", 41 | }, 42 | name: "repo", 43 | }, 44 | issue: { 45 | title: "Retro Action Item", 46 | html_url: "123", 47 | number: 123, 48 | labels: [{ name: "Retro Action Item" }], 49 | user: { 50 | login: "orta", 51 | avatar_url: "https://123.com", 52 | }, 53 | }, 54 | } 55 | 56 | await retroActionItem(issues) 57 | 58 | expect(peril.runTask).toHaveBeenCalledWith("slack-dev-channel", "in 5 minutes", expect.anything()) 59 | }) 60 | -------------------------------------------------------------------------------- /tasks/weeklyRFCSummary.ts: -------------------------------------------------------------------------------- 1 | import { danger } from "danger" 2 | 3 | const org = "artsy" 4 | const label = "RFC" 5 | 6 | export interface Result { 7 | url: string 8 | repository_url: string 9 | labels_url: string 10 | comments_url: string 11 | events_url: string 12 | html_url: string 13 | id: number 14 | node_id: string 15 | number: number 16 | title: string 17 | user: any 18 | labels: any[] 19 | state: string 20 | assignee?: any 21 | milestone?: any 22 | comments: number 23 | created_at: Date 24 | updated_at: Date 25 | closed_at?: any 26 | pull_request: any 27 | body: string 28 | score: number 29 | } 30 | 31 | // https://developer.github.com/v3/search/#search-issues 32 | 33 | export default async () => { 34 | const { slackMessage, slackData } = await import("./slackDevChannel") 35 | 36 | const api = danger.github.api 37 | const rfcQuery = `org:${org} label:${label} state:open` 38 | const searchResponse = await api.search.issues({ q: rfcQuery }) 39 | const items = searchResponse.data.items 40 | 41 | // Bail early 42 | if (items.length === 0) { 43 | await slackMessage("No open RFCs this week.") 44 | return 45 | } 46 | 47 | // Convert the open issues into attachments 48 | const attachments = items.map((r: Result) => ({ 49 | fallback: "Required plain-text summary of the attachment.", 50 | color: "#36a64f", 51 | author_name: r.user.login, 52 | author_link: r.user.html_url, 53 | author_icon: r.user.avatar_url, 54 | title: r.title, 55 | title_link: r.html_url, 56 | })) 57 | 58 | const text = `There are ${items.length} open RFCS:` 59 | await slackData({ 60 | text, 61 | attachments, 62 | unfurl_links: false, 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /tasks/prReviewReminder.ts: -------------------------------------------------------------------------------- 1 | import { danger } from "danger" 2 | 3 | export interface PRReviewMetadata { 4 | repoName: string 5 | prNumber: number 6 | requestedReviewer: string 7 | owner: string 8 | } 9 | 10 | export default async (metadata: PRReviewMetadata) => { 11 | const pullParams = { 12 | owner: metadata.owner, 13 | repo: metadata.repoName, 14 | number: metadata.prNumber, 15 | } 16 | // Get the PR we want to add a comment to 17 | const pr = await danger.github.api.pulls.get(pullParams) 18 | const reviews = await danger.github.api.pulls.listReviews(pullParams) 19 | const currentReviewers: string[] = pr.data.requested_reviewers.map(user => user.login) 20 | 21 | // Confirm that the PR is still open and the initially requested reviewer is still requested 22 | if (pr.data.state === "open" && currentReviewers.includes(metadata.requestedReviewer)) { 23 | // Loop through the reviews and see if this reviewer has already reviewed 24 | for (let i = 0; i < reviews.data.length; i++) { 25 | // If they have, just return 26 | if (reviews.data[i].user.login === metadata.requestedReviewer) { 27 | return 28 | } 29 | } 30 | // If we've looped through all the reviews and didn't find one by our reviewer, 31 | // post a message in the pr and @ them. See https://octokit.github.io/rest.js/#octokit-routes-pulls for documentation 32 | const commentParams = { 33 | owner: metadata.owner, 34 | repo: metadata.repoName, 35 | number: metadata.prNumber, 36 | body: `@${ 37 | metadata.requestedReviewer 38 | } it's been a full business day since your review was requested!\nPlease add your review.`, 39 | } 40 | danger.github.api.issues.createComment(commentParams) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/rfc_33.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | declare const global: any 6 | 7 | jest.mock("@slack/client", () => ({ 8 | IncomingWebhook: jest.fn(), 9 | })) 10 | 11 | import { IncomingWebhook } from "@slack/client" 12 | 13 | import rfc33 from "../org/closedPRs" 14 | 15 | it("calls the issues API to get labels", () => { 16 | dm.danger = { 17 | github: { 18 | issue: { 19 | labels: [ 20 | { 21 | name: "Consignments", 22 | }, 23 | { 24 | name: "12345", 25 | }, 26 | ], 27 | }, 28 | pr: { 29 | title: "This awesome PR", 30 | user: { 31 | login: "orta", 32 | avatar_url: "https://123.com/image", 33 | }, 34 | 35 | body: "", 36 | base: { 37 | user: { 38 | login: "orta", 39 | }, 40 | repo: { 41 | name: "danger-js", 42 | html_url: "http://my_url.com", 43 | }, 44 | }, 45 | number: 23, 46 | }, 47 | api: { 48 | issues: { get: jest.fn() }, 49 | }, 50 | }, 51 | } 52 | 53 | dm.peril = { 54 | env: { 55 | SLACK_RFC_WEBHOOK_URL: "123", 56 | }, 57 | } 58 | 59 | IncomingWebhook.prototype.send = jest.fn() 60 | 61 | return rfc33().then(() => { 62 | expect(IncomingWebhook.prototype.send).toHaveBeenCalledWith({ 63 | attachments: [ 64 | { 65 | author_icon: "https://123.com/image", 66 | author_name: "orta", 67 | color: "good", 68 | title: "PR merged on danger-js - This awesome PR", 69 | title_link: "http://my_url.com/pull/23", 70 | }, 71 | ], 72 | channel: "C52403S10", 73 | unfurl_links: false, 74 | }) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /org/rfc/scheduleRFCsForLabels.ts: -------------------------------------------------------------------------------- 1 | import { peril } from "danger" 2 | import { Issues } from "github-webhook-event-types" 3 | import { IncomingWebhookSendArguments, MessageAttachment } from "@slack/client" 4 | 5 | /** 6 | * When an issue has been labelled RFC, then trigger the scheduler 7 | * to send reminders about the issue into our slack. 8 | */ 9 | 10 | export default async (issues: Issues) => { 11 | const issue = issues.issue 12 | 13 | const slackify = (text: string, attachment: MessageAttachment = {}): IncomingWebhookSendArguments => ({ 14 | unfurl_links: false, 15 | attachments: [ 16 | { 17 | pretext: text, 18 | color: "good", 19 | title: issue.title, 20 | title_link: issue.html_url, 21 | author_name: issue.user.login, 22 | author_icon: issue.user.avatar_url, 23 | }, 24 | attachment, 25 | ], 26 | }) 27 | 28 | const rfc = issue.labels.find((l) => l.name === "RFC") 29 | if (rfc && issue.state === "open") { 30 | console.log("Triggering slack notifications") 31 | 32 | await peril.runTask("slack-dev-channel", "in 5 minutes", slackify("🎉: A new RFC has been published.")) 33 | await peril.runTask("slack-dev-channel", "in 3 days", slackify("🕰: A new RFC was published 3 days ago.")) 34 | 35 | // When someone is resolving, you nearly always need the template, so add that incase 36 | const urlForDocumentationAttachment: MessageAttachment = { 37 | title: "How to resolve an RFC", 38 | title_link: "https://github.com/artsy/README/blob/master/playbooks/rfcs.md#resolution", 39 | } 40 | 41 | // Send the final message 42 | await peril.runTask( 43 | "slack-dev-channel", 44 | "in 7 days", 45 | slackify("🕰: A new RFC is ready to be resolved.", urlForDocumentationAttachment) 46 | ) 47 | 48 | console.log("Triggered slack notifications") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tasks/slackDevChannel.ts: -------------------------------------------------------------------------------- 1 | import { peril } from "danger" 2 | import { IncomingWebhook, IncomingWebhookSendArguments } from "@slack/client" 3 | 4 | /** 5 | * A task that accepts Slack incoming webhook data 6 | * and sends a message into the Artsy Dev chat room. 7 | * 8 | * The full API docs for the syntax of the expected data 9 | * can be found: https://slackapi.github.io/node-slack-sdk/reference/IncomingWebhook 10 | * 11 | * Usage in a Dangerfile: 12 | * 13 | const message = { 14 | unfurl_links: false, 15 | attachments: [ 16 | { 17 | pretext: "We can throw words around like two hundred million galaxies", 18 | color: "good", 19 | title: issue.title, 20 | title_link: issue.html_url, 21 | author_name: issue.user.login, 22 | author_icon: issue.user.avatar_url, 23 | }, 24 | ], 25 | } 26 | 27 | peril.runTask("slack-dev-channel", "in 5 minutes", message) 28 | */ 29 | 30 | /** 31 | * The default, send a slack message with some data that's come in 32 | * this is also usable as a task 33 | */ 34 | 35 | export const slackData = async (data: IncomingWebhookSendArguments) => { 36 | if (!data) { 37 | console.log("No data was passed to slack-dev-channel, so a message will not be sent.") 38 | } else { 39 | const url = peril.env.SLACK_RFC_WEBHOOK_URL || "" 40 | const webhook = new IncomingWebhook(url) 41 | await webhook.send(data) 42 | } 43 | } 44 | 45 | /** 46 | * Send a slack message to the dev channel in Artsy 47 | * @param message the message to send to #dev 48 | */ 49 | export const slackMessage = async (message: string) => { 50 | const data = { 51 | unfurl_links: false, 52 | attachments: [ 53 | { 54 | color: "good", 55 | title: message, 56 | }, 57 | ], 58 | } 59 | await slackData(data) 60 | } 61 | 62 | export default slackData 63 | -------------------------------------------------------------------------------- /tests/deploySummary.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | dm.markdown = (message: string) => message 6 | 7 | import { deploySummary } from "../org/allPRs" 8 | 9 | it("outputs associated PR info", async () => { 10 | dm.danger = { 11 | github: { 12 | pr: { 13 | base: { 14 | ref: "release", 15 | }, 16 | }, 17 | commits: [ 18 | { 19 | sha: "sha", 20 | }, 21 | ], 22 | thisPR: { 23 | title: "This awesome PR", 24 | repo: "force", 25 | owner: "artsy", 26 | body: "", 27 | base: { 28 | user: { 29 | login: "orta", 30 | }, 31 | repo: { 32 | name: "danger-js", 33 | html_url: "http://my_url.com", 34 | }, 35 | }, 36 | number: 23, 37 | }, 38 | api: { 39 | request: () => { 40 | return Promise.resolve({ 41 | data: { 42 | data: { 43 | repository: { 44 | sha_sha: { 45 | associatedPullRequests: { 46 | edges: [ 47 | { 48 | node: { 49 | title: "PR to be deployed", 50 | url: "https://github.com/artsy/force/pull/1400", 51 | number: 1400, 52 | }, 53 | }, 54 | ], 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | }) 61 | }, 62 | }, 63 | }, 64 | } 65 | 66 | const generatedSummary = await deploySummary() 67 | expect(generatedSummary).toContain("PR to be deployed (https://github.com/artsy/force/pull/1400)") 68 | }) 69 | -------------------------------------------------------------------------------- /tests/rfc_13.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | import { rfc13 } from "../org/allPRs" 6 | 7 | beforeEach(() => { 8 | dm.danger = {} 9 | dm.warn = jest.fn() 10 | }) 11 | 12 | it("warns when there's no assignee and no WIP in the title", async () => { 13 | // When the check membership API call does not raise 14 | const api = { orgs: { checkMembership: () => {} } } 15 | dm.danger.github = { pr: { title: "My Thing", assignee: null, user: { login: "someone" } }, api } 16 | await rfc13() 17 | expect(dm.warn).toHaveBeenCalledWith( 18 | "Please assign someone to merge this PR, and optionally include people who should review." 19 | ) 20 | }) 21 | 22 | it("does not warns when someone does not have access to the org", async () => { 23 | // When the check membership API call does not raise 24 | const api = { 25 | orgs: { 26 | checkMembership: () => { 27 | throw new Error() 28 | }, 29 | }, 30 | } 31 | dm.danger.github = { pr: { title: "My Thing", assignee: null, user: { login: "someone" } }, api } 32 | await rfc13() 33 | expect(dm.warn).not.toBeCalled() 34 | }) 35 | 36 | it("does not warn when there's there's no assignee and WIP in the title", async () => { 37 | dm.danger.github = { pr: { title: "[WIP] My thing", assignee: null, user: { login: "someone" } } } 38 | await rfc13() 39 | expect(dm.warn).not.toBeCalled() 40 | }) 41 | 42 | it("does not warn when there's there's an assignee", async () => { 43 | dm.danger.github = { pr: { title: "My thing", assignee: {}, user: { login: "someone" } } } 44 | await rfc13() 45 | expect(dm.warn).not.toBeCalled() 46 | }) 47 | 48 | it("does not warn when the PR is created by renovate", async () => { 49 | dm.danger.github = { pr: { title: "My thing", assignee: null, user: { login: "renovate" } } } 50 | await rfc13() 51 | expect(dm.warn).not.toBeCalled() 52 | }) 53 | -------------------------------------------------------------------------------- /tests/closedSourceRationaleCheck.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | import closedSourceRationaleCheck, { 6 | artsyOrg, 7 | issueContent, 8 | issueTitle, 9 | targetText, 10 | } from "../tasks/closedSourceRationaleCheck" 11 | 12 | beforeEach(() => { 13 | dm.danger = { 14 | github: { 15 | api: { 16 | repos: { 17 | getForOrg: jest.fn(), 18 | }, 19 | }, 20 | utils: { 21 | createUpdatedIssueWithID: jest.fn(), 22 | fileContents: jest.fn(), 23 | }, 24 | }, 25 | } 26 | }) 27 | 28 | describe("rationale checks", () => { 29 | it("creates an issue when a private repo does not include a rationale", async () => { 30 | const readme = "No rationale here!" 31 | 32 | const repo = { name: "private-repo" } 33 | 34 | dm.danger.github.api.repos.listForOrg = () => Promise.resolve({ data: [repo] }) 35 | 36 | const createUpdatedIssueWithID = dm.danger.github.utils.createUpdatedIssueWithID 37 | dm.danger.github.utils.fileContents = () => Promise.resolve(readme) 38 | 39 | await closedSourceRationaleCheck().then(() => { 40 | expect(createUpdatedIssueWithID).toHaveBeenCalledWith(repo.name, issueContent, { 41 | open: true, 42 | owner: artsyOrg, 43 | repo: repo.name, 44 | title: issueTitle, 45 | }) 46 | }) 47 | }) 48 | 49 | it("does nothing when a private repo includes a rationale", async () => { 50 | const readme = `blah blah ${targetText} blah` 51 | 52 | const repo = { name: "private-repo" } 53 | 54 | dm.danger.github.api.repos.listForOrg = () => Promise.resolve({ data: [repo] }) 55 | dm.danger.github.utils.fileContents = () => Promise.resolve(readme) 56 | 57 | const createUpdatedIssueWithID = dm.danger.github.utils.createUpdatedIssueWithID 58 | 59 | await closedSourceRationaleCheck().then(() => { 60 | expect(createUpdatedIssueWithID).not.toHaveBeenCalled() 61 | }) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /tests/rfc_74.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | import { getJiraTicketIDsFromText, getJiraTicketIDsFromCommits } from "../org/jira/utils" 5 | 6 | describe("grabbing links", () => { 7 | it("handles small references with parens aka (ABC-123)", () => { 8 | const body = "ok (ABC-123) sure" 9 | expect(getJiraTicketIDsFromText(body)).toEqual(["ABC-123"]) 10 | }) 11 | 12 | it("handles small references aka [ABC-123]", () => { 13 | const body = "ok [ABC-123] sure" 14 | expect(getJiraTicketIDsFromText(body)).toEqual(["ABC-123"]) 15 | }) 16 | 17 | it("handles multiple small references aka [ABC-123] [DEF-456]", () => { 18 | const body = "ok [ABC-123] [DEF-456] sure" 19 | expect(getJiraTicketIDsFromText(body)).toEqual(["ABC-123", "DEF-456"]) 20 | }) 21 | 22 | it("ignores markdown links", () => { 23 | const body = "ok I opened [ABC-123](https://artsyproduct.atlassian.net/browse/ABC-123) for follow-up" 24 | expect(getJiraTicketIDsFromText(body)).toEqual([]) 25 | }) 26 | 27 | it("ignores url references", () => { 28 | const body = "ok https://artsyproduct.atlassian.net/browse/PLATFORM-46 sure" 29 | expect(getJiraTicketIDsFromText(body)).toEqual([]) 30 | }) 31 | 32 | it("ignores url references but finds shorthand ones", () => { 33 | const body = "ok I fixed [DAN-145] and opened https://artsyproduct.atlassian.net/browse/PLATFORM-46 for follow-up" 34 | expect(getJiraTicketIDsFromText(body)).toEqual(["DAN-145"]) 35 | }) 36 | 37 | it("gets them out of the commits in danger", () => { 38 | const commits = [ 39 | { 40 | message: "OK [PLAT-123] fixed", 41 | }, 42 | { 43 | message: "bah, broke", 44 | }, 45 | { 46 | message: "Also did (PLAT-124)", 47 | }, 48 | { 49 | message: "[DAN-32] Got some stuff", 50 | }, 51 | ] 52 | 53 | expect(getJiraTicketIDsFromCommits(commits)).toEqual(["PLAT-123", "PLAT-124", "DAN-32"]) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "artsy-danger", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "https://github.com/peril-settings", 6 | "author": "Orta Therox ", 7 | "license": "MIT", 8 | "scripts": { 9 | "precommit": "lint-staged", 10 | "type-check": "tsc --noEmit" 11 | }, 12 | "dependencies": { 13 | "@graphql-inspector/core": "^1.27.0", 14 | "@types/graphql": "^14.0.3", 15 | "graphql-schema-utils": "^0.6.5", 16 | "graphql-tools": "^4.0.3", 17 | "lodash": "^4.17.21", 18 | "node-fetch": "^2.6.7" 19 | }, 20 | "devDependencies": { 21 | "@slack/client": "^4.8.0", 22 | "@types/jest": "^23.3.9", 23 | "@types/jira-client": "^6.4.0", 24 | "@types/lodash": "^4.14.121", 25 | "@types/node": "^14.6.4", 26 | "@types/node-fetch": "^2.1.3", 27 | "danger": "^7.0.9", 28 | "danger-plugin-spellcheck": "^2.1.0", 29 | "danger-plugin-yarn": "^1.3.0", 30 | "github-webhook-event-types": "^1.2.1", 31 | "husky": "^0.14.3", 32 | "jest": "^26.4.2", 33 | "jira-client": "^6.4.1", 34 | "lint-staged": "^7.2.2", 35 | "prettier": "^2.1.1", 36 | "ts-jest": "^26.3.0", 37 | "ts-node": "^7.0.1", 38 | "typescript": "3.4.1" 39 | }, 40 | "prettier": { 41 | "printWidth": 120, 42 | "semi": false, 43 | "singleQuote": false, 44 | "trailingComma": "es5", 45 | "bracketSpacing": true 46 | }, 47 | "jest": { 48 | "transform": { 49 | "^.+\\.tsx?$": "ts-jest" 50 | }, 51 | "testRegex": "(.test)\\.(ts|tsx)$", 52 | "moduleFileExtensions": [ 53 | "ts", 54 | "tsx", 55 | "js", 56 | "jsx", 57 | "json" 58 | ] 59 | }, 60 | "lint-staged": { 61 | "*.@(ts|tsx)": [ 62 | "yarn prettier --write", 63 | "git add" 64 | ], 65 | "*.test.@(ts|tsx)": [ 66 | "jest" 67 | ], 68 | "*.json": [ 69 | "yarn prettier --write", 70 | "git add" 71 | ], 72 | "*.md": [ 73 | "yarn prettier --write", 74 | "git add" 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tasks/dailyLicenseCheck.ts: -------------------------------------------------------------------------------- 1 | import { danger } from "danger" 2 | 3 | export default async () => { 4 | const api = danger.github.api 5 | const org = "artsy" 6 | const year = new Date().getFullYear().toString() 7 | const { data: repos } = await api.repos.listForOrg({ org, type: "public", per_page: 100 }) 8 | console.log(`Found ${repos.length} repos`) 9 | 10 | const noLicense: string[] = [] 11 | const noThisYear: string[] = [] 12 | 13 | for (const repo of repos) { 14 | const slug = `${org}/${repo.name}` 15 | // Skip forks 16 | if (repo.fork) { 17 | continue 18 | } 19 | 20 | try { 21 | const { data: contents } = await api.repos.getContents({ owner: org, repo: repo.name, path: "LICENSE" }) 22 | const license = Buffer.from(contents.content, "base64").toString("utf8") 23 | 24 | // Skip repos that haven't been updated this year 25 | const updatedThisYear = repo.pushed_at && repo.pushed_at.includes(year) 26 | if (updatedThisYear && !license.includes(year)) { 27 | // Say that it needs changing 28 | noThisYear.push(`[${slug}](https://github.com/${slug})`) 29 | } 30 | } catch (error) { 31 | noLicense.push(`[${slug}](https://github.com/${slug})`) 32 | } 33 | } 34 | 35 | const open = noThisYear.length > 0 || noLicense.length > 0 36 | const notThisYearContent = `\n## List of repos which have a license without ${year} in them.\n\n` 37 | const noLicenseAndOSS = `\n## List of repos which don't have a license.\n\n` 38 | const contentWithRepos = [notThisYearContent, noThisYear.join(", "), noLicenseAndOSS, noLicense.join(", ")].join("\n") 39 | const noOpenRepos = "This issue will be updated daily" 40 | 41 | const body = open ? contentWithRepos : noOpenRepos 42 | const title = "Public Repos which have a license that's not up-to-date" 43 | 44 | console.log(`Posting`) 45 | await danger.github.utils.createUpdatedIssueWithID("License-Check", body, { 46 | title, 47 | open, 48 | owner: "artsy", 49 | repo: "potential", 50 | }) 51 | console.log(`Posted`) 52 | } 53 | -------------------------------------------------------------------------------- /org/addReviewer.ts: -------------------------------------------------------------------------------- 1 | import { danger, peril } from "danger" 2 | import { PRReviewMetadata } from "../tasks/prReviewReminder" 3 | import { PullRequest } from "github-webhook-event-types" 4 | import { PullRequestPullRequestPullRequestUser as User } from "github-webhook-event-types/source/PullRequest" 5 | 6 | // This interface gets around the fact that github-webhook-event-types doesn't include a requested_reviewer field 7 | // on pull_request.review_requested event types (requested_reviewer only exists on certain events). 8 | // See the GitHub API documentation here: https://developer.github.com/v3/activity/events/types/#webhook-payload-example-28 9 | export interface RequestedReview extends PullRequest { 10 | requested_reviewer: User 11 | } 12 | 13 | // Remind reviewers if a review hasn't been received in 1 business day 14 | // https://github.com/artsy/README/issues/177 15 | export const rfc177_2 = (reviewRequestEvent: RequestedReview) => { 16 | const now = new Date() 17 | if (reviewRequestEvent.requested_reviewer) { 18 | scheduleReviewReminders(now, reviewRequestEvent.requested_reviewer.login) 19 | } 20 | } 21 | 22 | export const scheduleReviewReminders = (now: Date, requestedReviewer: string) => { 23 | // Get the day of the week & make it more human-readable 24 | const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] 25 | const day = days[now.getDay()] 26 | 27 | const pr = danger.github.pr 28 | 29 | const metadata: PRReviewMetadata = { 30 | repoName: pr.base.repo.name, 31 | prNumber: pr.number, 32 | requestedReviewer, 33 | owner: pr.base.repo.owner.login, 34 | } 35 | 36 | const runReviewReminder = (time: string, metadata: PRReviewMetadata) => 37 | peril.runTask("pr-review-reminder", time, metadata) 38 | 39 | // Send review reminders on the next business day 40 | if (day === "Friday") { 41 | runReviewReminder("in 3 days", metadata) 42 | } else if (day === "Saturday") { 43 | runReviewReminder("in 2 days", metadata) 44 | } else { 45 | runReviewReminder("in 1 day", metadata) 46 | } 47 | } 48 | 49 | export default rfc177_2 50 | -------------------------------------------------------------------------------- /tasks/closedSourceRationaleCheck.ts: -------------------------------------------------------------------------------- 1 | import { danger } from "danger" 2 | 3 | export const artsyOrg = "artsy" 4 | export const targetPath = "README.md" 5 | export const targetText = "Rationale for Closed Source" 6 | export const issueTitle = "Missing rationale for closed source" 7 | export const issueContent = `This repo is closed source but seems to be missing rationale in the README. If this repo should remain closed, you can pass this test with something like this:\n\n${targetText}: This repo is closed source because .` 8 | 9 | interface Repo { 10 | name: string 11 | readme: string 12 | } 13 | 14 | const getPrivateRepos = async (): Promise => { 15 | const { data: repos } = await danger.github.api.repos.listForOrg({ org: artsyOrg, type: "private", per_page: 100 }) 16 | return repos.map(repo => ({ name: repo.name, readme: "" })) 17 | } 18 | 19 | const getReadme = async (name: string): Promise => { 20 | return await danger.github.utils.fileContents(targetPath, name) 21 | } 22 | 23 | const getInvalidRepos = async (privateRepos: Repo[]): Promise => { 24 | const promises = privateRepos.map(async repo => { 25 | repo.readme = await getReadme(repo.name) 26 | }) 27 | await Promise.all(promises) 28 | 29 | privateRepos.forEach(repo => { 30 | if (repo.readme === "") { 31 | console.error(`Repo has empty readme! ${repo.name}`) 32 | } 33 | }) 34 | 35 | const missingRepos = privateRepos.filter(repo => !repo.readme.includes(targetText)) 36 | return missingRepos 37 | } 38 | 39 | const createIssuesFor = async (missingRepos: Repo[]): Promise => { 40 | const openedIssues = missingRepos.map(repo => { 41 | const config = { open: true, owner: artsyOrg, repo: repo.name, title: issueTitle } 42 | return danger.github.utils.createUpdatedIssueWithID(repo.name, issueContent, config) 43 | }) 44 | 45 | return Promise.all(openedIssues) 46 | } 47 | 48 | export default async () => { 49 | const privateRepos = await getPrivateRepos() 50 | const invalidRepos = await getInvalidRepos(privateRepos) 51 | const issueURLs = await createIssuesFor(invalidRepos) 52 | 53 | console.log(issueURLs) 54 | } 55 | -------------------------------------------------------------------------------- /tests/addReviewer.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => ({ 2 | peril: { runTask: jest.fn() }, 3 | danger: jest.fn(), 4 | })) 5 | import { peril, danger } from "danger" 6 | const dm = danger as any 7 | const pm = peril as any 8 | 9 | import { scheduleReviewReminders, rfc177_2 } from "../org/addReviewer" 10 | 11 | beforeEach(() => { 12 | dm.github = { 13 | pr: { 14 | number: 10, 15 | base: { 16 | repo: { 17 | name: "exampleRepo", 18 | owner: { 19 | login: "artsy", 20 | }, 21 | }, 22 | }, 23 | }, 24 | } 25 | }) 26 | 27 | afterEach(() => { 28 | // @ts-ignore 29 | peril.runTask.mockReset() 30 | }) 31 | 32 | describe("scheduling reminder tasks", () => { 33 | it("calls runTask in 1 day when called on Monday", () => { 34 | const monday = new Date(2019, 3, 29) 35 | scheduleReviewReminders(monday, "you") 36 | expect(pm.runTask).toBeCalledWith( 37 | "pr-review-reminder", 38 | "in 1 day", 39 | expect.objectContaining({ requestedReviewer: "you" }) 40 | ) 41 | }) 42 | 43 | it("calls runTask in 2 days when called on Saturday", () => { 44 | const saturday = new Date(2019, 3, 27) 45 | scheduleReviewReminders(saturday, "your cat") 46 | expect(pm.runTask).toBeCalledWith( 47 | "pr-review-reminder", 48 | "in 2 days", 49 | expect.objectContaining({ requestedReviewer: "your cat" }) 50 | ) 51 | }) 52 | 53 | it("calls runTask in 3 days when called on Friday", () => { 54 | const friday = new Date(2019, 3, 26) 55 | scheduleReviewReminders(friday, "your dog") 56 | expect(pm.runTask).toBeCalledWith( 57 | "pr-review-reminder", 58 | "in 3 days", 59 | expect.objectContaining({ requestedReviewer: "your dog" }) 60 | ) 61 | }) 62 | 63 | it("doesn't call runTask without a reviewer", () => { 64 | const reviewEvent = { 65 | action: "review_requested", 66 | number: 1, 67 | pull_request: {} as any, 68 | repository: {} as any, 69 | sender: {} as any, 70 | requested_reviewer: undefined as any, 71 | } 72 | rfc177_2(reviewEvent) 73 | expect(pm.runTask).not.toBeCalled() 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /tasks/standupReminder.ts: -------------------------------------------------------------------------------- 1 | import { WebClient } from "@slack/client" 2 | import { peril } from "danger" 3 | import fetch from "node-fetch" 4 | import * as querystring from "querystring" 5 | 6 | export default async () => { 7 | const opsGenieOnCallStaffEmails = await emailsFromOpsGenie() 8 | 9 | await sendMessageForEmails(opsGenieOnCallStaffEmails) 10 | } 11 | 12 | export const emailsFromOpsGenie = async (today = new Date()) => { 13 | const targetDate = new Date(today.getTime()) 14 | const qs = querystring.stringify({ date: targetDate.toISOString() }) 15 | const url = `https://api.opsgenie.com/v2/schedules/${peril.env.OPSGENIE_SCHEDULE_ID}/on-calls?${qs}` 16 | const req = await fetch(url, { 17 | method: "GET", 18 | headers: { 19 | Accept: "application/json", 20 | "Content-Type": "application/json", 21 | Authorization: `GenieKey ${peril.env.OPSGENIE_API_KEY}`, 22 | }, 23 | }) 24 | 25 | const body = await req.json() 26 | return body.data.onCallParticipants.map((participant: any) => { 27 | return participant.name 28 | }) 29 | } 30 | 31 | export const sendMessageForEmails = async (emails: string[]) => { 32 | console.log(`The following emails are on call: ${emails}. Now looking up Slack IDs.`) 33 | 34 | const slackToken = peril.env.SLACK_WEB_API_TOKEN 35 | const web = new WebClient(slackToken) 36 | const onCallStaffUsers = await Promise.all(emails.map(email => web.users.lookupByEmail({ email }))) 37 | 38 | const onCallStaffMentions = onCallStaffUsers 39 | .filter(r => r.ok) // Filter out any failed lookups. 40 | .map((response: any) => response.user.id as string) 41 | .map(id => `<@${id}>`) // See: https://api.slack.com/docs/message-formatting#linking_to_channels_and_users 42 | 43 | const { slackMessage } = await import("./slackDevChannel") 44 | 45 | await slackMessage( 46 | `${onCallStaffMentions.join( 47 | ", " 48 | )} based on our on-call schedule, you’ll be running the Monday standup at 12:00 noon NYC time. Here are the docs: https://github.com/artsy/README/blob/master/events/open-standup.md Add new standup notes here: https://www.notion.so/artsy/Standup-Notes-28a5dfe4864645788de1ef936f39687c` 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /org/mergeOnGreen.ts: -------------------------------------------------------------------------------- 1 | import { danger } from "danger" 2 | import { Status } from "github-webhook-event-types" 3 | 4 | export const rfc10 = async (status: Status) => { 5 | const api = danger.github.api 6 | 7 | const { labelMap } = await import("./markAsMergeOnGreen") 8 | 9 | if (status.state !== "success") { 10 | return console.log( 11 | `Not a successful state (note that you can define state in the settings.json) - got ${status.state}` 12 | ) 13 | } 14 | 15 | // Check to see if all other statuses on the same commit are also green. E.g. is this the last green. 16 | const owner = status.repository.owner.login 17 | const repo = status.repository.name 18 | const allGreen = await api.repos.getCombinedStatusForRef({ owner, repo, ref: status.commit.sha }) 19 | if (allGreen.data.state !== "success") { 20 | return console.log("Not all statuses are green") 21 | } 22 | 23 | // See https://github.com/maintainers/early-access-feedback/issues/114 for more context on getting a PR from a SHA 24 | const repoString = status.repository.full_name 25 | const searchResponse = await api.search.issues({ q: `${status.commit.sha} type:pr is:open repo:${repoString}` }) 26 | 27 | // https://developer.github.com/v3/search/#search-issues 28 | const prsWithCommit = searchResponse.data.items.map((i: any) => i.number) as number[] 29 | for (const number of prsWithCommit) { 30 | // Get the PR labels 31 | const issue = await api.issues.get({ owner, repo, number }) 32 | 33 | // Get the PR combined status 34 | const issueLabelNames = issue.data.labels.map((l) => l.name) 35 | const mergeLabel = Object.values(labelMap).find((label) => issueLabelNames.includes(label.name)) 36 | 37 | if (!mergeLabel) { 38 | return console.log("PR does not have Merge on Green-type label") 39 | } 40 | 41 | let commitTitle = mergeLabel.commitGenerator(number) 42 | 43 | if (issue.data.title) { 44 | // Strip any "@user =>" prefixes from the pr title 45 | const prTitle = issue.data.title.replace(/@(\w|-)+\s+=>\s+/, "") 46 | commitTitle = `${prTitle} (#${number})` 47 | } 48 | 49 | // Merge the PR 50 | await api.pulls.merge({ owner, repo, number, commit_title: commitTitle, merge_method: mergeLabel.mergeMethod }) 51 | console.log(`Merged Pull Request ${number}`) 52 | } 53 | } 54 | 55 | export default rfc10 56 | -------------------------------------------------------------------------------- /tests/ossPRsForbidForks.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | import ossPRsForbidForks from "../org/ossPRsForbidForks" 6 | 7 | describe("ossPRsForbidForks", () => { 8 | beforeEach(() => { 9 | dm.danger = { 10 | github: { 11 | api: { 12 | orgs: { 13 | checkMembership: jest.fn(), 14 | }, 15 | }, 16 | pr: { 17 | user: { 18 | login: "some_user", 19 | }, 20 | head: { 21 | repo: {}, 22 | }, 23 | base: { 24 | repo: {}, 25 | }, 26 | }, 27 | }, 28 | } 29 | dm.warn = jest.fn() 30 | dm.fail = jest.fn() 31 | }) 32 | 33 | describe("on oss repos", () => { 34 | beforeEach(() => { 35 | dm.danger.github.pr.base.repo.name = "eigen" 36 | }) 37 | 38 | it("fails builds for Artsy staff on forks", async () => { 39 | const mockCheckMembership: jest.Mock = dm.danger.github.api.orgs.checkMembership as any 40 | mockCheckMembership.mockImplementation(() => Promise.resolve()) 41 | dm.danger.github.pr.head.repo.fork = true 42 | await ossPRsForbidForks() 43 | expect(dm.fail).toHaveBeenCalled() 44 | }) 45 | 46 | it("warns builds for OSS contributors on forks", async () => { 47 | const mockCheckMembership: jest.Mock = dm.danger.github.api.orgs.checkMembership as any 48 | mockCheckMembership.mockRejectedValueOnce("some error") 49 | dm.danger.github.pr.head.repo.fork = true 50 | await ossPRsForbidForks() 51 | expect(dm.warn).toHaveBeenCalled() 52 | }) 53 | 54 | it("does nothing when submitted from a branch", async () => { 55 | const mockCheckMembership: jest.Mock = dm.danger.github.api.orgs.checkMembership as any 56 | mockCheckMembership.mockImplementation(() => Promise.resolve()) 57 | await ossPRsForbidForks() 58 | expect(dm.fail).not.toHaveBeenCalled() 59 | }) 60 | }) 61 | 62 | describe("on non-oss repos", () => { 63 | beforeEach(() => { 64 | dm.danger.github.pr.base.repo.name = "gravity" 65 | }) 66 | 67 | it("does nothing", async () => { 68 | await ossPRsForbidForks() 69 | expect(dm.fail).not.toHaveBeenCalled() 70 | expect(dm.warn).not.toHaveBeenCalled() 71 | }) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /org/addVersionLabel.ts: -------------------------------------------------------------------------------- 1 | import { danger } from "danger" 2 | 3 | export const labels = { 4 | "Version: Patch": { 5 | color: "E0E4CC", 6 | description: "A deploy for bug fixes or minor changes", 7 | }, 8 | "Version: Minor": { 9 | color: "A7DBD8", 10 | description: "A deploy for new features", 11 | }, 12 | "Version: Major": { 13 | color: "FA6900", 14 | description: "A deploy for breaking changes to clients", 15 | }, 16 | "Version: Trivial": { 17 | color: "A7DBD8", 18 | description: "Skip a deploy for this PR", 19 | }, 20 | Docs: { 21 | color: "#64EA72", 22 | description: "Documentation only changes", 23 | }, 24 | } 25 | 26 | // Add a version label to PRs that don't already have a version indicator 27 | // https://github.com/artsy/reaction/issues/1095 28 | // 29 | export default async () => { 30 | const pr = danger.github.pr 31 | 32 | const hasAutoRC = await danger.github.utils.fileContents(".autorc") 33 | if (!hasAutoRC) { 34 | console.log(`Skipping, because this repo does not have an .autorc file.`) 35 | return 36 | } 37 | 38 | let labelName: keyof typeof labels = "Version: Minor" 39 | 40 | // Someone's already made a decision on the version 41 | const hasVersionLabel = danger.github.issue.labels.find(label => Object.keys(labels).includes(label.name)) 42 | if (hasVersionLabel) { 43 | console.log(`Skipping, because this PR already has a version label.`) 44 | return 45 | } 46 | 47 | const config = { 48 | owner: pr.base.user.login, 49 | repo: pr.base.repo.name, 50 | } 51 | 52 | const api = danger.github.api 53 | const existingLabels = await api.issues.listLabelsForRepo(config) 54 | const versionExists = existingLabels.data.find(label => Object.keys(labels).includes(label.name)) 55 | 56 | // Check to see if the label exists for this repo. If not, make the full set 57 | if (!versionExists) { 58 | console.log(`Creating labels for release versions, because we're running on a new repo.`) 59 | for (let [label, labelProperties] of Object.entries(labels)) { 60 | await api.issues.createLabel({ 61 | name: label, 62 | ...config, 63 | ...labelProperties, 64 | }) 65 | } 66 | } 67 | 68 | // If it's a Netlify CMS PR, use the docs label 69 | if (pr.body.includes("Automatically generated by Netlify CMS")) { 70 | labelName = "Docs" 71 | } 72 | 73 | // If it's a Dependabot PR, use the trivial version label 74 | if (danger.github.issue.labels.find(label => label.name === "dependencies")) { 75 | labelName = "Version: Trivial" 76 | } 77 | 78 | // Add the label 79 | console.log(`Adding the \`${labelName}\` label to this PR.`) 80 | await api.issues.addLabels({ 81 | number: pr.number, 82 | ...config, 83 | labels: [labelName], 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /tests/rfc_7.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | import { rfc7 } from "../org/allPRs" 6 | 7 | beforeEach(() => { 8 | dm.danger = { 9 | git: {}, 10 | github: { 11 | api: { 12 | issues: { 13 | listLabelsForRepo: jest.fn(), 14 | addLabels: jest.fn(), 15 | }, 16 | }, 17 | thisPR: { 18 | owner: "artsy", 19 | repo: "eigen", 20 | number: 1234, 21 | }, 22 | issue: { labels: [] }, 23 | }, 24 | } 25 | }) 26 | 27 | it("bails without commit labels", () => { 28 | dm.danger.git.commits = ["Implementing something", "Adding tests", "Changelog entry"].map(message => ({ message })) 29 | return rfc7().then(() => { 30 | expect(dm.danger.github.api.issues.listLabelsForRepo).not.toHaveBeenCalled() 31 | }) 32 | }) 33 | 34 | describe("with commit labels", () => { 35 | beforeEach(() => { 36 | dm.danger.git.commits = [ 37 | "[Auctions] Implementing something", 38 | "[Auctions] Adding tests", 39 | "[Oops] Changelog entry", 40 | ].map(message => ({ message })) 41 | }) 42 | 43 | it("retrieves labels from the GitHub api", () => { 44 | dm.danger.github.api.issues.listLabelsForRepo.mockImplementationOnce(() => ({ data: [] })) 45 | return rfc7().then(() => { 46 | expect(dm.danger.github.api.issues.listLabelsForRepo).toHaveBeenCalledWith({ 47 | owner: "artsy", 48 | repo: "eigen", 49 | }) 50 | }) 51 | }) 52 | 53 | describe("with no matching GitHub labels", () => { 54 | it("does not add any labels", () => { 55 | dm.danger.github.api.issues.listLabelsForRepo.mockImplementationOnce(() => ({ 56 | data: [{ name: "wontfix" }, { name: "Messaging" }], 57 | })) 58 | return rfc7().then(() => { 59 | expect(dm.danger.github.api.issues.addLabels).not.toHaveBeenCalled() 60 | }) 61 | }) 62 | }) 63 | 64 | describe("with matching GitHub labels", () => { 65 | it("adds GitHub labels that match commit labels", () => { 66 | dm.danger.github.api.issues.listLabelsForRepo.mockImplementationOnce(() => ({ 67 | data: [{ name: "wontfix" }, { name: "Messaging" }, { name: "Auctions" }], 68 | })) 69 | return rfc7().then(() => { 70 | expect(dm.danger.github.api.issues.addLabels).toHaveBeenCalledWith({ 71 | owner: "artsy", 72 | repo: "eigen", 73 | number: 1234, 74 | labels: ["Auctions"], 75 | }) 76 | }) 77 | }) 78 | 79 | it("doesn't add existing GitHub labels on the issue", async () => { 80 | dm.danger.github.issue.labels = [{ name: "Auctions" }] 81 | dm.danger.github.api.issues.listLabelsForRepo.mockImplementationOnce(() => ({ 82 | data: [{ name: "wontfix" }, { name: "Messaging" }, { name: "Auctions" }], 83 | })) 84 | 85 | return rfc7().then(() => { 86 | expect(dm.danger.github.api.issues.addLabels).not.toHaveBeenCalled() 87 | }) 88 | }) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /tests/standupReminder.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => ({ 2 | peril: { 3 | env: {}, 4 | }, 5 | })) 6 | 7 | const mockSlackDevChannel = jest.fn() 8 | jest.mock("../tasks/slackDevChannel", () => ({ 9 | slackMessage: mockSlackDevChannel, 10 | })) 11 | 12 | const mockLookupByEmail = jest.fn() 13 | jest.mock("@slack/client", () => ({ 14 | WebClient: jest.fn().mockImplementation(() => ({ 15 | users: { 16 | lookupByEmail: mockLookupByEmail, 17 | }, 18 | })), 19 | })) 20 | 21 | const mockSuccessResponse = { 22 | data: { 23 | onCallParticipants: [ 24 | { 25 | name: "orta@example.com", 26 | }, 27 | { 28 | name: "ash@example.com", 29 | }, 30 | ], 31 | }, 32 | } 33 | const mockJsonPromise = Promise.resolve(mockSuccessResponse) 34 | const mockFetchPromise = Promise.resolve({ 35 | json: () => mockJsonPromise, 36 | }) 37 | 38 | jest.mock("node-fetch", () => { 39 | return { 40 | default: () => mockFetchPromise, 41 | } 42 | }) 43 | 44 | import { sendMessageForEmails, emailsFromOpsGenie } from "../tasks/standupReminder" 45 | 46 | const today = "2019-01-07" 47 | 48 | describe("Monday standup reminders", () => { 49 | beforeEach(() => { 50 | console.log = jest.fn() 51 | }) 52 | 53 | it("sends a message", async () => { 54 | await sendMessageForEmails([]) 55 | expect(mockSlackDevChannel).toHaveBeenCalled() 56 | }) 57 | 58 | describe("with mocked email lookup", () => { 59 | beforeEach(() => { 60 | mockLookupByEmail.mockImplementation(async obj => { 61 | if (obj.email.startsWith("ash@")) { 62 | return { ok: true, user: { id: "ASHID" } } 63 | } else if (obj.email.startsWith("orta@")) { 64 | return { ok: true, user: { id: "ORTAID" } } 65 | } else { 66 | return { ok: false } 67 | } 68 | }) 69 | }) 70 | 71 | it("fetches on-call participants from OpsGenie", async () => { 72 | var receivedMessage 73 | mockSlackDevChannel.mockImplementation(message => (receivedMessage = message)) 74 | 75 | const emails = await emailsFromOpsGenie(new Date(today)) 76 | 77 | await sendMessageForEmails(emails) 78 | expect(receivedMessage).toMatchInlineSnapshot( 79 | `"<@ORTAID>, <@ASHID> based on our on-call schedule, you’ll be running the Monday standup at 12:00 noon NYC time. Here are the docs: https://github.com/artsy/README/blob/master/events/open-standup.md Add new standup notes here: https://www.notion.so/artsy/Standup-Notes-28a5dfe4864645788de1ef936f39687c"` 80 | ) 81 | }) 82 | }) 83 | 84 | describe("with failed email lookup", () => { 85 | beforeEach(() => { 86 | mockLookupByEmail.mockImplementation(async obj => { 87 | if (obj.email.startsWith("ash@")) { 88 | return { ok: true, user: { id: "ASHID" } } 89 | } else { 90 | return { ok: false } 91 | } 92 | }) 93 | }) 94 | 95 | it("skips failed email lookups", async () => { 96 | var receivedMessage 97 | mockSlackDevChannel.mockImplementation(message => (receivedMessage = message)) 98 | 99 | const emails = await emailsFromOpsGenie(new Date(today)) 100 | 101 | await sendMessageForEmails(emails) 102 | expect(receivedMessage).toMatchInlineSnapshot( 103 | `"<@ASHID> based on our on-call schedule, you’ll be running the Monday standup at 12:00 noon NYC time. Here are the docs: https://github.com/artsy/README/blob/master/events/open-standup.md Add new standup notes here: https://www.notion.so/artsy/Standup-Notes-28a5dfe4864645788de1ef936f39687c"` 104 | ) 105 | }) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /org/markAsMergeOnGreen.ts: -------------------------------------------------------------------------------- 1 | import { danger } from "danger" 2 | import { IssueComment, PullRequestReview } from "github-webhook-event-types" 3 | import { IssueCommentIssue } from "github-webhook-event-types/source/IssueComment" 4 | 5 | export const labelMap = { 6 | "#mergeongreen": { 7 | name: "Merge On Green", 8 | color: "247A38", 9 | description: "A label to indicate that Peril should merge this PR when all statuses are green", 10 | mergeMethod: "merge", 11 | commitGenerator: (prNumber: number) => `Merge pull request #${prNumber} by Peril`, 12 | }, 13 | "#squashongreen": { 14 | name: "Squash On Green", 15 | color: "247A38", 16 | description: "A label to indicate that Peril should squash-merge this PR when all statuses are green", 17 | mergeMethod: "squash", 18 | commitGenerator: (prNumber: number) => undefined, // defaults to GitHub's generated commit messages 19 | }, 20 | } as const 21 | 22 | /** If a comment to an issue contains "Merge on Green", apply a label for it to be merged when green. */ 23 | export const rfc10 = async (issueCommentOrPrReview: IssueComment | PullRequestReview) => { 24 | const api = danger.github.api 25 | const org = issueCommentOrPrReview.repository.owner.login 26 | 27 | let issue: IssueCommentIssue = null! 28 | let text: string = null! 29 | let userLogin: string = "" 30 | 31 | if ("issue" in issueCommentOrPrReview) { 32 | issue = issueCommentOrPrReview.issue 33 | text = issueCommentOrPrReview.comment.body 34 | userLogin = issueCommentOrPrReview.comment.user.login 35 | 36 | // Only look at PR issue comments, this isn't in the type system 37 | if (!(issue as any).pull_request) { 38 | return console.log("Not a Pull Request") 39 | } 40 | } 41 | 42 | if ("review" in issueCommentOrPrReview) { 43 | const repo = issueCommentOrPrReview.repository 44 | const response = await api.issues.get({ 45 | owner: repo.owner.login, 46 | repo: repo.name, 47 | number: issueCommentOrPrReview.pull_request.number, 48 | }) 49 | 50 | issue = response.data as any 51 | text = issueCommentOrPrReview.review.body 52 | userLogin = issueCommentOrPrReview.review.user.login 53 | } 54 | 55 | // Bail if there's no text from the review 56 | if (!text) { 57 | console.log("Could not find text for the webhook to look for the merge on green message") 58 | return 59 | } 60 | 61 | // Don't do any work unless we have to 62 | const keywords = Object.keys(labelMap) 63 | const match = keywords.find((k) => text.toLowerCase().includes(k)) as keyof typeof labelMap | undefined 64 | if (!match) { 65 | return console.log(`Did not find any of the merging phrases in the comment beginning ${text.substring(0, 12)}.`) 66 | } 67 | 68 | const label = labelMap[match] 69 | 70 | // Check to see if the label has already been set 71 | if (issue.labels.find((l) => l.name === label.name)) { 72 | return console.log("Already has Merge on Green-type label") 73 | } 74 | 75 | // Check for org access, so that some rando doesn't 76 | // try to merge something without permission 77 | try { 78 | if (userLogin !== org) { 79 | await api.orgs.checkMembership({ org, username: userLogin }) 80 | } 81 | } catch (error) { 82 | // Someone does not have permission to force a merge 83 | return console.log("Sender does not have permission to merge") 84 | } 85 | 86 | const repo = { 87 | owner: org, 88 | repo: issueCommentOrPrReview.repository.name, 89 | id: issue.number, 90 | } 91 | 92 | console.log("Adding the label:", repo) 93 | await danger.github.utils.createOrAddLabel(label, repo) 94 | console.log("Updated the PR with a Merge on Green-type label") 95 | } 96 | 97 | export default rfc10 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Artsy Peril Settings 2 | 3 | - **State**: production 4 | - **Point people**: [@dblandin](https://github.com/dblandin) 5 | - **CI**: [![Build Status](https://travis-ci.org/artsy/peril-settings.svg?branch=master)](https://travis-ci.org/artsy/peril-settings) 6 | - **Peril Dashboard**: [peril-staging.artsy.net](https://peril-staging.artsy.net/) 7 | 8 | ### What is this project? 9 | 10 | This is the configuration repo for Peril on the Artsy org. There is a [settings file](peril.settings.json) and org-wide 11 | dangerfiles which are inside the [org folder](org/). 12 | 13 | Here's some links to the key concepts: 14 | 15 | - [Peril](https://github.com/artsy/peril) 16 | - [Danger JS](http://danger.systems/js/) 17 | - [Peril for Orgs](https://github.com/danger/peril/blob/master/docs/setup_for_org.md) 18 | - [Introducing Peril to the Artsy org](http://artsy.github.io/blog/2017/09/04/Introducing-Peril/) 19 | - [Peril's Dashboard 🔐](https://peril-staging.artsy.net/) 20 | 21 | An overview of what Peril does for Artsy is available in the README repo at [`/culture/peril.md`][docs]. 22 | 23 | ### TL:DR on this Repo? 24 | 25 | Peril is Danger running on a web-server, this repo is the configuration for that, currently the dangerfiles in [org](org/) 26 | run on every issue and pull request for all our repos. 27 | 28 | ### To Develop 29 | 30 | ```sh 31 | git clone https://github.com/artsy/peril-settings.git 32 | cd peril-settings 33 | yarn install 34 | yarn jest 35 | code . 36 | ``` 37 | 38 | You will need node and yarn installed beforehand. You can get them both by running `brew install yarn`. This will give 39 | you auto-completion and types for Danger/Peril mainly. 40 | 41 | ### RFCs 42 | 43 | It's likely that any time you want to make a change here you should consult the [Artsy RFC process](https://github.com/artsy/README/blob/master/playbooks/rfcs.md#readme) and apply it on [artsy/README](https://github.com/artsy/README/). 44 | 45 | ### Implementing an RFC 46 | 47 | #### Adding a rule 48 | 49 | A rule should include a link to its rfc: 50 | 51 | ```ts 52 | // Keep our Markdown documents awesome 53 | // https://github.com/artsy/peril-settings/issues/2 54 | // 55 | export default async (webhook: any) => { 56 | // [...] 57 | }) 58 | ``` 59 | 60 | This self-documents where a rule has come from, making it easy for others to understand how we came to specific rules. 61 | The closure passed to `rfc` can be async as well. 62 | 63 | #### Testing a rule 64 | 65 | We use Jest to test our Dangerfiles. It uses the same techniques as testing a 66 | [danger plugin](http://danger.systems/js/usage/extending-danger.html) where the global imports from danger are fake. 67 | 68 | 1. Create a file for your RFC: `tests/rfc_[x].test.ts`. 69 | 2. Add a `before` and `after` setting up and resetting mocks: 70 | 71 | ```ts 72 | jest.mock("danger", () => jest.fn()) 73 | import * as danger from "danger" 74 | const dm = danger as any 75 | 76 | beforeEach(() => { 77 | dm.danger = {} 78 | dm.fail = jest.fn() // as necessary 79 | }) 80 | 81 | afterEach(() => { 82 | dm.fail = undefined 83 | }) 84 | ``` 85 | 86 | 3. Set up your danger object and run the function exported in `all-prs.ts`: 87 | 88 | ```ts 89 | import rfcN from "../org/all-prs" 90 | 91 | it("warns when there's there's no assignee and no WIP in the title", async () => { 92 | dm.danger.github = { pr: { title: "Changes to the setup script", assignee: null } } 93 | await rfcN() 94 | 95 | expect(something).toHappen() 96 | // [...] 97 | }) 98 | }) 99 | ``` 100 | 101 | 4. Validate that the `fail`/`warn`/`message`/`markdown` is called. 102 | 103 | [docs]: https://github.com/artsy/README/blob/master/culture/peril.md 104 | -------------------------------------------------------------------------------- /tests/rfc_reaction_1095.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => ({ 2 | danger: { 3 | github: { 4 | utils: { 5 | fileContents: jest.fn(), 6 | }, 7 | pr: { 8 | number: 12, 9 | body: "", 10 | base: { 11 | user: { 12 | login: "artsy", 13 | }, 14 | repo: { 15 | name: "reaction", 16 | }, 17 | }, 18 | }, 19 | api: { 20 | issues: { 21 | listLabelsForRepo: jest.fn(), 22 | createLabel: jest.fn(), 23 | addLabels: jest.fn(), 24 | }, 25 | }, 26 | issue: { 27 | labels: ["Merge on Green"], 28 | }, 29 | }, 30 | }, 31 | })) 32 | 33 | import { danger } from "danger" 34 | 35 | const mockGetLabels: jest.Mock = danger.github.api.issues.listLabelsForRepo as any 36 | const mockCreateLabel: jest.Mock = danger.github.api.issues.createLabel as any 37 | const mockAddLabels: jest.Mock = danger.github.api.issues.addLabels as any 38 | const mockfileContents: jest.Mock = danger.github.utils.fileContents as any 39 | 40 | import addVersionLabel, { labels } from "../org/addVersionLabel" 41 | 42 | afterEach(() => { 43 | mockGetLabels.mockReset() 44 | mockCreateLabel.mockReset() 45 | mockAddLabels.mockReset() 46 | mockfileContents.mockReset() 47 | }) 48 | 49 | it("Does nothing if there is no autorc", async () => { 50 | mockfileContents.mockResolvedValueOnce("") 51 | 52 | await addVersionLabel() 53 | 54 | expect(danger.github.api.issues.listLabelsForRepo).not.toBeCalled() 55 | }) 56 | 57 | it("Does nothing if there's already a release label", async () => { 58 | danger.github.issue.labels = [{ name: "Version: Major" } as any] 59 | mockfileContents.mockResolvedValueOnce("{}") 60 | 61 | await addVersionLabel() 62 | 63 | expect(danger.github.api.issues.listLabelsForRepo).not.toBeCalled() 64 | }) 65 | 66 | it("Creates labels for this repo if there are no labels yet", async () => { 67 | // nothing on the issue 68 | danger.github.issue.labels = [] 69 | // nothing set up in the repo yet 70 | mockGetLabels.mockResolvedValueOnce({ data: [] }) 71 | mockfileContents.mockResolvedValueOnce("{}") 72 | 73 | await addVersionLabel() 74 | 75 | // adds the labels to the repo 76 | expect(mockCreateLabel).toBeCalledTimes(Object.keys(labels).length) 77 | // and adds the default 78 | expect(mockAddLabels).toBeCalled() 79 | }) 80 | 81 | it("Posts a version label if there are no labels already added", async () => { 82 | // nothing on the issue 83 | danger.github.issue.labels = [] 84 | // the repo already has labels set up 85 | mockGetLabels.mockResolvedValueOnce({ data: [{ name: "Version: Minor" } as any] }) 86 | mockfileContents.mockResolvedValueOnce("{}") 87 | 88 | await addVersionLabel() 89 | 90 | expect(mockCreateLabel).not.toBeCalled() 91 | expect(mockAddLabels).toBeCalled() 92 | }) 93 | 94 | it("Uses the docs label if the PR was created by netlify cms", async () => { 95 | danger.github.pr.body = "Automatically generated by Netlify CMS" 96 | 97 | // nothing on the issue 98 | danger.github.issue.labels = [] 99 | // the repo already has labels set up 100 | mockGetLabels.mockResolvedValueOnce({ data: [{ name: "Version: Minor" } as any] }) 101 | mockfileContents.mockResolvedValueOnce("{}") 102 | 103 | await addVersionLabel() 104 | 105 | expect(mockCreateLabel).not.toBeCalled() 106 | expect(mockAddLabels).toBeCalled() 107 | expect(mockAddLabels.mock.calls[0][0].labels).toEqual(["Docs"]) 108 | }) 109 | 110 | it("Uses the trivial label if it's a dependabot PR", async () => { 111 | danger.github.issue.labels = [{ name: "dependencies" } as any] 112 | mockGetLabels.mockResolvedValueOnce({ 113 | data: [{ name: "Version: Minor" }, { name: "Version: Trivial" }], 114 | }) 115 | mockfileContents.mockResolvedValueOnce("{}") 116 | 117 | await addVersionLabel() 118 | 119 | expect(mockCreateLabel).not.toBeCalled() 120 | expect(mockAddLabels).toBeCalled() 121 | expect(mockAddLabels.mock.calls[0][0].labels).toEqual(["Version: Trivial"]) 122 | }) 123 | -------------------------------------------------------------------------------- /tests/rfc_40.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => ({ 2 | peril: { runTask: jest.fn() }, 3 | danger: { github: { utils: { createOrAddLabel: jest.fn() } } }, 4 | })) 5 | import { peril, danger } from "danger" 6 | 7 | import addRFCLabel from "../org/rfc/addRFCToNewIssues" 8 | import scheduleRFC from "../org/rfc/scheduleRFCsForLabels" 9 | 10 | afterEach(() => { 11 | // @ts-ignore 12 | peril.runTask.mockReset() 13 | // @ts-ignore 14 | danger.github.utils.createOrAddLabel.mockReset() 15 | }) 16 | 17 | it("ignores issues which aren't RFCs", async () => { 18 | const issues: any = { 19 | issue: { 20 | title: "This awesome PR", 21 | state: "open", 22 | html_url: "123", 23 | user: { 24 | login: "orta", 25 | avatar_url: "https://123.com", 26 | }, 27 | }, 28 | } 29 | 30 | await addRFCLabel(issues) 31 | 32 | expect(peril.runTask).not.toBeCalled() 33 | expect(danger.github.utils.createOrAddLabel).not.toBeCalled() 34 | }) 35 | 36 | it("Triggers tasks when RFC is in the title and the issue is open", async () => { 37 | const issues: any = { 38 | repository: { 39 | owner: { 40 | login: "org", 41 | }, 42 | name: "repo", 43 | }, 44 | issue: { 45 | title: "[RFC] Let's make a change", 46 | html_url: "123", 47 | number: 123, 48 | state: "open", 49 | user: { 50 | login: "orta", 51 | avatar_url: "https://123.com", 52 | }, 53 | }, 54 | } 55 | 56 | await addRFCLabel(issues) 57 | 58 | expect(danger.github.utils.createOrAddLabel).toHaveBeenCalledWith(expect.anything(), { 59 | id: 123, 60 | owner: "org", 61 | repo: "repo", 62 | }) 63 | }) 64 | 65 | it("does not trigger tasks when RFC is in the title and the issue is closed", async () => { 66 | const issues: any = { 67 | repository: { 68 | owner: { 69 | login: "org", 70 | }, 71 | name: "repo", 72 | }, 73 | issue: { 74 | title: "[RFC] Let's make a change", 75 | html_url: "123", 76 | number: 123, 77 | state: "closed", 78 | user: { 79 | login: "orta", 80 | avatar_url: "https://123.com", 81 | }, 82 | }, 83 | } 84 | 85 | await addRFCLabel(issues) 86 | 87 | expect(peril.runTask).not.toBeCalled() 88 | expect(danger.github.utils.createOrAddLabel).not.toBeCalled() 89 | }) 90 | 91 | it("Triggers tasks when RFC is in the labels", async () => { 92 | const issues: any = { 93 | repository: { 94 | owner: { 95 | login: "org", 96 | }, 97 | name: "repo", 98 | }, 99 | issue: { 100 | title: "[RFC] Let's make a change", 101 | html_url: "123", 102 | state: "open", 103 | number: 123, 104 | labels: [{ name: "RFC" }], 105 | user: { 106 | login: "orta", 107 | avatar_url: "https://123.com", 108 | }, 109 | }, 110 | } 111 | 112 | await scheduleRFC(issues) 113 | 114 | expect(peril.runTask).toHaveBeenCalledWith("slack-dev-channel", "in 5 minutes", expect.anything()) 115 | expect(peril.runTask).toHaveBeenCalledWith("slack-dev-channel", "in 3 days", expect.anything()) 116 | expect(peril.runTask).toHaveBeenCalledWith("slack-dev-channel", "in 7 days", expect.anything()) 117 | 118 | // Also checks that the last 119 | const mockRunTask = peril.runTask as jest.Mock 120 | expect(mockRunTask.mock.calls[2][2].attachments[1]).toEqual({ 121 | title: "How to resolve an RFC", 122 | title_link: "https://github.com/artsy/README/blob/master/playbooks/rfcs.md#resolution", 123 | }) 124 | }) 125 | 126 | it("does not trigger tasks when RFC is not the labels", async () => { 127 | const issues: any = { 128 | repository: { 129 | owner: { 130 | login: "org", 131 | }, 132 | name: "repo", 133 | }, 134 | issue: { 135 | title: "[RCF] Let's make a change", 136 | html_url: "123", 137 | number: 123, 138 | labels: [], 139 | user: { 140 | login: "orta", 141 | avatar_url: "https://123.com", 142 | }, 143 | }, 144 | } 145 | 146 | await scheduleRFC(issues) 147 | 148 | expect(peril.runTask).not.toBeCalled() 149 | }) 150 | -------------------------------------------------------------------------------- /tests/prReviewReminder.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | import prReviewReminder from "../tasks/prReviewReminder" 6 | 7 | beforeEach(() => { 8 | dm.danger = { 9 | github: { 10 | api: { 11 | pulls: { 12 | listReviews: jest.fn().mockReturnValue({ 13 | data: [ 14 | { 15 | user: { 16 | login: "mdole", 17 | }, 18 | }, 19 | ], 20 | }), 21 | get: jest.fn().mockReturnValue({ 22 | data: { 23 | requested_reviewers: [ 24 | { 25 | login: "artsy", 26 | }, 27 | ], 28 | state: "open", 29 | }, 30 | }), 31 | }, 32 | issues: { 33 | createComment: jest.fn(), 34 | }, 35 | }, 36 | }, 37 | } 38 | }) 39 | 40 | describe("reviewer checks", () => { 41 | it("adds a comment to the PR when only one reviewer has been requested and a review by that user is not present", async () => { 42 | const createComment = dm.danger.github.api.issues.createComment 43 | 44 | const metadata = { 45 | repoName: "artsy", 46 | prNumber: 1, 47 | requestedReviewer: "artsy", 48 | owner: "artsy", 49 | } 50 | 51 | await prReviewReminder(metadata) 52 | expect(createComment).toHaveBeenCalledWith( 53 | expect.objectContaining({ 54 | body: "@artsy it's been a full business day since your review was requested!\nPlease add your review.", 55 | }) 56 | ) 57 | }) 58 | 59 | it("adds a comment to one reviewer when two reviewers are specified but one has already left a review", async () => { 60 | const createComment = dm.danger.github.api.issues.createComment 61 | const metadata = { 62 | repoName: "artsy", 63 | prNumber: 1, 64 | requestedReviewer: "artsy", 65 | owner: "artsy", 66 | } 67 | 68 | dm.danger.github.api.pulls.get = jest.fn().mockReturnValue({ 69 | data: { 70 | requested_reviewers: [ 71 | { 72 | login: "mdole", 73 | }, 74 | { 75 | login: "artsy", 76 | }, 77 | ], 78 | state: "open", 79 | }, 80 | }) 81 | await prReviewReminder(metadata) 82 | expect(createComment).toHaveBeenCalledTimes(1) 83 | expect(createComment).toHaveBeenCalledWith( 84 | expect.objectContaining({ 85 | body: "@artsy it's been a full business day since your review was requested!\nPlease add your review.", 86 | }) 87 | ) 88 | }) 89 | 90 | it("does not add a comment when reviewer has already reviewed", async () => { 91 | const createComment = dm.danger.github.api.issues.createComment 92 | const metadata = { 93 | repoName: "artsy", 94 | prNumber: 1, 95 | requestedReviewer: "artsy", 96 | owner: "artsy", 97 | } 98 | 99 | dm.danger.github.api.pulls = { 100 | get: jest.fn().mockReturnValue({ 101 | data: { 102 | requested_reviewers: [ 103 | { 104 | login: "mdole", 105 | }, 106 | { 107 | login: "artsy", 108 | }, 109 | ], 110 | state: "open", 111 | }, 112 | }), 113 | listReviews: jest.fn().mockReturnValue({ 114 | data: [ 115 | { 116 | user: { 117 | login: "artsy", 118 | }, 119 | }, 120 | ], 121 | }), 122 | } 123 | 124 | await prReviewReminder(metadata) 125 | expect(createComment).not.toHaveBeenCalled() 126 | }) 127 | 128 | it("does not add a comment when pr is closed", async () => { 129 | const createComment = dm.danger.github.api.issues.createComment 130 | const metadata = { 131 | repoName: "artsy", 132 | prNumber: 1, 133 | requestedReviewer: "artsy", 134 | owner: "artsy", 135 | } 136 | 137 | dm.danger.github.api.pulls.get = jest.fn().mockReturnValue({ 138 | data: { 139 | requested_reviewers: [ 140 | { 141 | login: "mdole", 142 | }, 143 | { 144 | login: "artsy", 145 | }, 146 | ], 147 | state: "closed", 148 | }, 149 | }) 150 | await prReviewReminder(metadata) 151 | expect(createComment).not.toHaveBeenCalled() 152 | }) 153 | }) 154 | -------------------------------------------------------------------------------- /tests/rfc_16.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | import { rfc16 } from "../org/allPRs" 6 | 7 | beforeEach(() => { 8 | dm.danger = {} 9 | dm.warn = jest.fn() 10 | }) 11 | 12 | const pr = { 13 | base: { 14 | user: { 15 | login: "danger", 16 | }, 17 | repo: { 18 | name: "danger-js", 19 | }, 20 | }, 21 | state: "open", 22 | body: "Hello World", 23 | } 24 | 25 | it("warns when code has changed but no changelog entry was made", () => { 26 | dm.danger.github = { 27 | api: { 28 | git: { 29 | getTree: () => Promise.resolve({ data: { tree: [{ path: "code.js" }, { path: "CHANGELOG.md" }] } }), 30 | }, 31 | }, 32 | pr, 33 | } 34 | dm.danger.git = { 35 | modified_files: ["src/index.html"], 36 | created_files: [], 37 | } 38 | return rfc16().then(() => { 39 | expect(dm.warn).toBeCalled() 40 | }) 41 | }) 42 | 43 | it("does nothing when there is no changelog file", () => { 44 | dm.danger.github = { 45 | api: { 46 | git: { 47 | getTree: () => Promise.resolve({ data: { tree: [{ path: "code.js" }] } }), 48 | }, 49 | }, 50 | pr, 51 | } 52 | dm.danger.git = { 53 | modified_files: [], 54 | created_files: [], 55 | } 56 | return rfc16().then(() => { 57 | expect(dm.warn).not.toBeCalled() 58 | }) 59 | }) 60 | 61 | it("does nothing when there is a .autorc file in the root of the repo", async () => { 62 | const paths = ["code.js", ".autorc", "CHANGELOG.md"] 63 | const data = { 64 | tree: paths.map((path) => ({ path })), 65 | } 66 | 67 | dm.danger.github = { 68 | api: { 69 | git: { 70 | getTree: () => Promise.resolve({ data }), 71 | }, 72 | }, 73 | pr, 74 | } 75 | 76 | dm.danger.git = { 77 | modified_files: ["src/index.html"], 78 | created_files: [], 79 | } 80 | 81 | await rfc16() 82 | expect(dm.warn).not.toBeCalled() 83 | }) 84 | 85 | it("does nothing when only `test` files were changed", () => { 86 | dm.danger.github = { 87 | api: { 88 | git: { 89 | getTree: () => Promise.resolve({ data: { tree: [{ path: "CHANGELOG.md" }] } }), 90 | }, 91 | }, 92 | pr, 93 | } 94 | dm.danger.git = { 95 | modified_files: ["tests/AuctionCalculatorSpec.scala"], 96 | created_files: [], 97 | } 98 | return rfc16().then(() => { 99 | expect(dm.warn).not.toBeCalled() 100 | }) 101 | }) 102 | 103 | it("does nothing when the changelog was changed", () => { 104 | dm.danger.github = { 105 | api: { 106 | git: { 107 | getTree: () => Promise.resolve({ data: { tree: [{ path: "code.js" }, { path: "CHANGELOG.md" }] } }), 108 | }, 109 | }, 110 | pr, 111 | } 112 | dm.danger.git = { 113 | modified_files: ["src/index.html", "CHANGELOG.md"], 114 | created_files: [], 115 | } 116 | return rfc16().then(() => { 117 | expect(dm.warn).not.toBeCalled() 118 | }) 119 | }) 120 | 121 | it("does not warns with a closed PR", () => { 122 | dm.danger.github = { 123 | api: { 124 | git: { 125 | getTree: () => Promise.resolve({ data: { tree: [{ path: "code.js" }, { path: "CHANGELOG.md" }] } }), 126 | }, 127 | }, 128 | pr: { ...pr, state: "closed" }, 129 | } 130 | dm.danger.git = { 131 | modified_files: ["src/index.html"], 132 | created_files: [], 133 | } 134 | return rfc16().then(() => { 135 | expect(dm.warn).not.toBeCalled() 136 | }) 137 | }) 138 | 139 | it("is skipped via #trivial", () => { 140 | dm.danger.github = { 141 | api: { 142 | git: { 143 | getTree: () => Promise.resolve({ data: { tree: [{ path: "code.js" }, { path: "CHANGELOG.md" }] } }), 144 | }, 145 | }, 146 | pr: { ...pr, body: "Skip this, #trivial" }, 147 | } 148 | dm.danger.git = { 149 | modified_files: ["src/index.html"], 150 | created_files: [], 151 | } 152 | return rfc16().then(() => { 153 | expect(dm.warn).not.toBeCalled() 154 | }) 155 | }) 156 | 157 | it("skips for eigen", () => { 158 | const prForEigen = { 159 | base: { 160 | user: { 161 | login: "danger", 162 | }, 163 | repo: { 164 | name: "eigen", 165 | }, 166 | }, 167 | state: "open", 168 | body: "Hello World", 169 | } 170 | 171 | dm.danger.github = { 172 | api: { 173 | git: { 174 | getTree: () => Promise.resolve({ data: { tree: [{ path: "code.js" }, { path: "CHANGELOG.md" }] } }), 175 | }, 176 | }, 177 | pr: { ...prForEigen, body: "Normal PR title" }, 178 | } 179 | 180 | dm.danger.git = { 181 | modified_files: ["src/index.html"], 182 | created_files: [], 183 | } 184 | 185 | return rfc16().then(() => { 186 | expect(dm.warn).not.toBeCalled() 187 | }) 188 | }) 189 | -------------------------------------------------------------------------------- /spellcheck.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "/.*.md", 4 | "/.*.rb", 5 | "/.*.x", 6 | "/.*.yml", 7 | "password", 8 | "216k+", 9 | "acjay", 10 | "Ahh", 11 | "akka", 12 | "anandaroop", 13 | "anymore", 14 | "apis", 15 | "apps", 16 | "art.sy", 17 | "artifact", 18 | "artifacts", 19 | "artsy.net", 20 | "artsy's", 21 | "Artsy’s", 22 | "artsy", 23 | "ashfurrow", 24 | "ashkan18", 25 | "async", 26 | "awesommmmee", 27 | "babelify", 28 | "babelrc", 29 | "base64", 30 | "behavior", 31 | "bhoggard", 32 | "bitbucket", 33 | "blog", 34 | "browserify", 35 | "camelcase", 36 | "cavvia", 37 | "changelog", 38 | "circleci", 39 | "cocoadocs", 40 | "cocoapods", 41 | "codebase", 42 | "coffeeify", 43 | "coffeescript", 44 | "colocate", 45 | "common.yml", 46 | "composable", 47 | "config", 48 | "configmap", 49 | "css", 50 | "css3", 51 | "cto", 52 | "cli", 53 | "dotenv", 54 | "intellisense", 55 | "stylelint", 56 | "scss", 57 | "package.json", 58 | "visualstudios.com", 59 | "webpack-y", 60 | "zeit", 61 | "queryrenderer", 62 | "danger-js", 63 | "dangerfile", 64 | "dangerfiles", 65 | "dangerjs", 66 | "datadog", 67 | "dataloader", 68 | "dblock", 69 | "de-silo'd", 70 | "deserialization", 71 | "destructuring", 72 | "devs", 73 | "devtools", 74 | "dockerfile", 75 | "dockerize", 76 | "dockerizes", 77 | "dockerizing", 78 | "ecma", 79 | "eidolon", 80 | "eigen", 81 | "elasticsearch", 82 | "env", 83 | "erikdstock", 84 | "es2016", 85 | "es6", 86 | "eslint", 87 | "ESLint's", 88 | "ezel", 89 | "facebook", 90 | "fastlane", 91 | "Favor", 92 | "flexbox", 93 | "frontend", 94 | "frp", 95 | "gatling.io", 96 | "gil", 97 | "gists", 98 | "github", 99 | "gitlab", 100 | "graphiql", 101 | "graphql", 102 | "graphql", 103 | "hah", 104 | "heroku-like", 105 | "heroku", 106 | "hokusai", 107 | "homebrew", 108 | "honor", 109 | "html", 110 | "html5", 111 | "hypergrowth", 112 | "imagemagick", 113 | "Informationals", 114 | "inline", 115 | "IntelliJ", 116 | "interop", 117 | "isac", 118 | "jekyll", 119 | "jira", 120 | "javascriptures", 121 | "joeyaghion", 122 | "jonallured", 123 | "js", 124 | "json", 125 | "jsx", 126 | "jwt", 127 | "jwts", 128 | "katsushika", 129 | "keybinding", 130 | "keybindings", 131 | "kickoff", 132 | "kpis", 133 | "kubectl", 134 | "kubernetes", 135 | "labor", 136 | "lifecycle", 137 | "listicles", 138 | "linter", 139 | "linters", 140 | "linux", 141 | "localhost", 142 | "lockfile", 143 | "macos", 144 | "maven", 145 | "memcached", 146 | "microservice", 147 | "microservices", 148 | "modeling", 149 | "mongo", 150 | "mongodb", 151 | "Movie", 152 | "moya", 153 | "mutablilty", 154 | "mvc", 155 | "mvvm", 156 | "mzikherman", 157 | "node.js", 158 | "nodejs", 159 | "nodemon", 160 | "non-Artsy", 161 | "npm", 162 | "npm", 163 | "nullability", 164 | "OAuth", 165 | "objc.io", 166 | "oftentimes", 167 | "ok", 168 | "olde", 169 | "omakase", 170 | "onboarding", 171 | "online", 172 | "optimised", 173 | "org's", 174 | "org", 175 | "orta's", 176 | "orta", 177 | "oss", 178 | "oss'd", 179 | "pageviews", 180 | "pg", 181 | "playbook", 182 | "playbooks", 183 | "plugin", 184 | "plugins", 185 | "podspec", 186 | "postgres", 187 | "postgres", 188 | "post-Artsy", 189 | "pr", 190 | "pre-es6", 191 | "prepping", 192 | "procfile", 193 | "protobuf", 194 | "prs", 195 | "react's", 196 | "readme", 197 | "readmes", 198 | "redis", 199 | "redux", 200 | "refetch", 201 | "renderProps", 202 | "repo", 203 | "rfc", 204 | "rfcs", 205 | "rmagick", 206 | "rubocop", 207 | "sarahscott", 208 | "sbt-revolver", 209 | "scalafmt", 210 | "semver", 211 | "sendgrid", 212 | "serializer", 213 | "setups", 214 | "sidekiq", 215 | "sonatype", 216 | "spec", 217 | "standup", 218 | "standups", 219 | "starsirius", 220 | "subteams", 221 | "sweir27", 222 | "testbed", 223 | "textmate", 224 | "timeframe", 225 | "tldr:", 226 | "tldr", 227 | "todos", 228 | "toolchains", 229 | "timezones", 230 | "transpilation", 231 | "travis", 232 | "trello", 233 | "ts", 234 | "tslint", 235 | "typescript-eslint", 236 | "typings", 237 | "uiview", 238 | "unclutter", 239 | "URIs", 240 | "url", 241 | "udacity", 242 | "v2", 243 | "v8", 244 | "vars", 245 | "voila", 246 | "vscode", 247 | "Vue", 248 | "webhook", 249 | "webhooks", 250 | "webpack", 251 | "websocket", 252 | "workflow", 253 | "wotan", 254 | "xcode", 255 | "yayoi", 256 | "yuki", 257 | "zwirner", 258 | "zeplin" 259 | ], 260 | "ignoreFiles": ["CHANGELOG.md"] 261 | } 262 | -------------------------------------------------------------------------------- /org/jira/pr.ts: -------------------------------------------------------------------------------- 1 | // This is RFC 74 2 | 3 | const companyPrefix = "artsyproduct" 4 | const wipLabels = ["in review", "in progress", "review"] 5 | const mergedLabels = ["merged", "monitor/qa", "monitoring/qa"] 6 | const ignoredStatuses = ["done", "closed"] 7 | 8 | import { danger, peril } from "danger" 9 | import { PullRequest } from "github-webhook-event-types" 10 | import * as JiraApi from "jira-client" 11 | 12 | import * as IssueJSON from "../../fixtures/jira_issue_example.json" 13 | type Issue = typeof IssueJSON 14 | 15 | import * as TransitionsJSON from "../../fixtures/jira_examples_transitions.json" 16 | type Transition = typeof TransitionsJSON 17 | 18 | const { sentence } = danger.utils 19 | 20 | export default async (webhook: PullRequest) => { 21 | // Grab some util functions for Jira manipulation 22 | const { getJiraTicketIDsFromCommits, getJiraTicketIDsFromText, uniq, makeJiraTransition } = await import("./utils") 23 | const prBody = danger.github.pr.body 24 | 25 | // Grab tickets from the PR body, and the commit messages 26 | const tickets = uniq([...getJiraTicketIDsFromText(prBody), ...getJiraTicketIDsFromCommits(danger.git.commits)]) 27 | 28 | // Bail if we have no work to do 29 | if (!tickets.length) { 30 | console.log("No Jira ticket references found") 31 | return 32 | } 33 | 34 | // We know we have something to work with now 35 | const jira: JiraApi.default = new (JiraApi as any)({ 36 | protocol: "https", 37 | host: `${companyPrefix}.atlassian.net`, 38 | apiVersion: "2", 39 | strictSSL: true, 40 | username: peril.env.JIRA_EMAIL, 41 | password: peril.env.JIRA_ACCESS_TOKEN, 42 | }) 43 | 44 | console.log(`Looking at ${sentence(tickets)}.`) 45 | tickets.forEach(async ticketID => { 46 | try { 47 | // So we have ticket references, will need to check each ticket 48 | // for whether it's in the right state. 49 | // 50 | // https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-issue-issueIdOrKey-get 51 | 52 | const issue: Issue = (await jira.findIssue(ticketID)) as any 53 | 54 | const currentStatusName = issue.fields.status.name 55 | 56 | if (ignoredStatuses.includes(currentStatusName.toLowerCase())) { 57 | // This Jira ticket is already in status that we don't want to move 58 | console.log(`Ignored moving ${issue.key}, its in ${currentStatusName}`) 59 | return 60 | } 61 | 62 | // Figure out what we want to move it to 63 | const labelsToLookFor = danger.github.pr.merged ? mergedLabels : wipLabels 64 | 65 | // Get all the potential statuses, see if any are in our list 66 | const transitions: Transition = (await jira.listTransitions(issue.id)) as any 67 | console.log(`Found: ${sentence(transitions.transitions.map(t => t.name))}`) 68 | 69 | const newStatus = transitions.transitions.find(t => labelsToLookFor.includes(t.name.toLowerCase())) 70 | const currentStatus = transitions.transitions.find(t => issue.fields.status.name === t.name) 71 | if (!newStatus) { 72 | const labels = sentence(labelsToLookFor) 73 | console.log(`Could not find a transition status with one of these names: ${labels}`) 74 | return 75 | } 76 | 77 | if (!currentStatus) { 78 | const status = issue.fields.status.name 79 | const foundTransitions = sentence(transitions.transitions.map(t => t.name)) 80 | console.log(`Could not find a transition status for the current status: ${status}, found ${foundTransitions}`) 81 | return 82 | } 83 | 84 | // Get the order of their indexes, our status options usually look like 85 | // Define, Ready, In Progress, Merged, Monitoring/QA, Done, Closed and In Review 86 | // 87 | const newStatusOrder = transitions.transitions.indexOf(newStatus) 88 | const currentStatusOrder = transitions.transitions.indexOf(currentStatus) 89 | 90 | if (newStatusOrder <= currentStatusOrder) { 91 | console.log(`Skipping making a transition because issue is already at the same state or further along`) 92 | return 93 | } 94 | 95 | // Switch to the new status, e.g. Ready - and leave a comment 96 | const type = danger.github.pr.merged ? "merged" : "submitted" 97 | const message = `PR has been ${type}: ${(danger.github.pr as any).html_url}` 98 | console.log(`Converting ${ticketID} to ${newStatus.name}`) 99 | await jira.transitionIssue(issue.id, makeJiraTransition(message, newStatus)) 100 | 101 | console.log("Leaving a comment") 102 | await jira.addComment(issue.id, message) 103 | } catch (err) { 104 | console.log(`Had an issue changing the status of ${ticketID}`) 105 | console.log(err.message) 106 | console.log(err) 107 | } 108 | }) 109 | 110 | // Let's people know that Peril's done some work 111 | await danger.github.utils.createOrAddLabel( 112 | { 113 | name: "Jira Synced", 114 | color: "0366d6", 115 | description: "Indicates that Peril has connected this PR to Jira", 116 | }, 117 | { 118 | owner: danger.github.pr.base.repo.owner.login, 119 | repo: danger.github.pr.base.repo.name, 120 | id: danger.github.pr.number, 121 | } 122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /tasks/compareSchemas.ts: -------------------------------------------------------------------------------- 1 | import { danger, peril } from "danger" 2 | 3 | // This extends the schema objects generated by graphql-js 4 | import "graphql-schema-utils" 5 | import { GraphQLObjectType } from "graphql" 6 | import { makeExecutableSchema } from "graphql-tools" 7 | 8 | import { IncomingWebhook, MessageAttachment } from "@slack/client" 9 | import { GraphQLDiff } from "../ambient" 10 | 11 | // Some potential options for other folks looking to map schema changes 12 | const org = "artsy" 13 | const graphQLFile = "_schema.graphql" 14 | 15 | export default async () => { 16 | const api = danger.github.api 17 | // 18 | // First up, use the github search API To grab all files that match the original RFC 19 | // https://github.com/search?q=org%3Aartsy+filename%3A_schema.graphql+path%3A%2F+language%3AGraphQL&type=Code 20 | // so that we don't need to have a hard-coded list, you support the RFC, then you get this for free. 21 | const queryForGraphQLFiles = `org:${org} filename:${graphQLFile} path:/ language:GraphQL` 22 | const searchResponse = await api.search.code({ q: queryForGraphQLFiles }) 23 | console.log(`Found ${searchResponse.data.items.length} repos with a ${graphQLFile}`) 24 | 25 | const reposWithSchema: string[] = searchResponse.data.items.map((i: any) => i.repository.name) 26 | reposWithSchema.forEach(async repoName => { 27 | const now = new Date() 28 | now.setDate(now.getDate() - 7) 29 | const weekAgoISO = now.toISOString() 30 | 31 | // Grab the commits that are on the graphql schema in the last week, so we can get the last 32 | // if there's over 100 commits _to the schema_ in a week, I'd be impressed. 33 | const commitsSinceLastWeek = await api.repos.listCommits({ 34 | owner: org, 35 | repo: repoName, 36 | per_page: 100, 37 | path: graphQLFile, 38 | since: weekAgoISO, 39 | }) 40 | 41 | const commitLength = commitsSinceLastWeek.data.length 42 | if (!commitLength) { 43 | console.log(`Skipping GraphQL diff for ${org}/${repoName}, due to no activity.`) 44 | return 45 | } 46 | console.log(`Found ${commitLength} commits for ${org}/${repoName}`) 47 | 48 | const lastCommit = commitsSinceLastWeek.data[commitLength - 1] 49 | console.log(`Looking at the difference between master and ${lastCommit.sha} for ${org}/${repoName}`) 50 | 51 | // Grab the SDL files 52 | const masterSDL = await danger.github.utils.fileContents("_schema.graphql", `${org}/${repoName}`, "master") 53 | const oldSDL = await danger.github.utils.fileContents("_schema.graphql", `${org}/${repoName}`, lastCommit.sha) 54 | 55 | // It could be new, and the old ref might not have it yet 56 | if (!oldSDL) { 57 | console.log(`No _schema.graphql found back in ${lastCommit.sha}`) 58 | return 59 | } 60 | 61 | // We want to make a GraphQL schema but we don't care about all the resolvers at all 62 | // as we'll never run queries. 63 | const makeSchema = (sdl: string) => 64 | makeExecutableSchema({ 65 | typeDefs: [sdl], 66 | resolverValidationOptions: { 67 | requireResolversForResolveType: false, 68 | requireResolversForAllFields: false, 69 | requireResolversForNonScalar: false, 70 | requireResolversForArgs: false, 71 | }, 72 | }) 73 | 74 | // SDL -> Schema 75 | const masterSchema = makeSchema(masterSDL) 76 | const oldSchema = makeSchema(oldSDL) 77 | 78 | // Get the diff between these two schemas 79 | const diffs: GraphQLDiff[] = (oldSchema as any).diff(masterSchema) 80 | 81 | // Note: When looking at diff objects, 82 | // thisType = old schema 83 | // otherType = new schema 84 | 85 | const codeJoin = (arr: string[]) => arr.map(a => "`" + a + "`").join(", ") 86 | const messages: MessageAttachment[] = [] 87 | 88 | // What got added, this is the majority of our messages 89 | const addedTypeMessages = diffs.filter(d => d.diffType == "TypeMissing" && d.otherType).map(d => d.otherType.name) 90 | if (addedTypeMessages.length) { 91 | messages.push({ color: "good", text: "Added:" + codeJoin(addedTypeMessages) }) 92 | } 93 | 94 | // What was removed, highlighted in red as this stuff can break things 95 | const removedTypesMessages = diffs.filter(d => d.diffType == "TypeMissing" && d.thisType).map(d => d.thisType.name) 96 | if (removedTypesMessages.length) { 97 | messages.push({ color: "danger", text: "Removed: " + codeJoin(removedTypesMessages) }) 98 | } 99 | 100 | // Grab root query field changes, as they tend to be really useful to know about 101 | const newRootQueriesTypesMessages = diffs.filter(d => d.diffType == "FieldMissing" && d.thisType.name === "Query") 102 | if (newRootQueriesTypesMessages.length) { 103 | const oldQuery = newRootQueriesTypesMessages[0].thisType as GraphQLObjectType 104 | const newQuery = newRootQueriesTypesMessages[0].otherType as GraphQLObjectType 105 | const newFields = Object.keys(newQuery.getFields()) 106 | const oldFields = Object.keys(oldQuery.getFields()) 107 | const diff = newFields.filter(f => !oldFields.includes(f)) 108 | messages.push({ color: "good", text: "New root query fields: " + codeJoin(diff) }) 109 | } 110 | 111 | // TODO: There are probably more things we can show in here 112 | 113 | // If there are any messages to send, wrap them up in a slack message with a link to the full compare url. 114 | if (messages.length) { 115 | var url = peril.env.SLACK_RFC_WEBHOOK_URL || "" 116 | var webhook = new IncomingWebhook(url) 117 | 118 | const compareURL = `https://github.com/${org}/${repoName}/compare/${lastCommit.sha}...master` 119 | await webhook.send({ 120 | channel: "C1HH3KNJG", 121 | unfurl_links: false, 122 | text: `GraphQL Schema changes on \`${repoName}\``, 123 | attachments: [...messages, { title: "Diff for last week", title_link: compareURL }], 124 | }) 125 | } 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /fixtures/jira_examples_transitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "expand": "transitions", 3 | "transitions": [ 4 | { 5 | "id": "21", 6 | "name": "Define", 7 | "to": { 8 | "self": "https://artsyproduct.atlassian.net/rest/api/2/status/10078", 9 | "description": "Needs definition before work can begin", 10 | "iconUrl": "https://artsyproduct.atlassian.net/images/icons/statuses/generic.png", 11 | "name": "Define", 12 | "id": "10078", 13 | "statusCategory": { 14 | "self": "https://artsyproduct.atlassian.net/rest/api/2/statuscategory/2", 15 | "id": 2, 16 | "key": "new", 17 | "colorName": "blue-gray", 18 | "name": "To Do" 19 | } 20 | }, 21 | "hasScreen": false, 22 | "isGlobal": true, 23 | "isInitial": false, 24 | "isConditional": false 25 | }, 26 | { 27 | "id": "31", 28 | "name": "Ready", 29 | "to": { 30 | "self": "https://artsyproduct.atlassian.net/rest/api/2/status/10077", 31 | "description": "", 32 | "iconUrl": "https://artsyproduct.atlassian.net/images/icons/statuses/generic.png", 33 | "name": "Ready", 34 | "id": "10077", 35 | "statusCategory": { 36 | "self": "https://artsyproduct.atlassian.net/rest/api/2/statuscategory/2", 37 | "id": 2, 38 | "key": "new", 39 | "colorName": "blue-gray", 40 | "name": "To Do" 41 | } 42 | }, 43 | "hasScreen": false, 44 | "isGlobal": true, 45 | "isInitial": false, 46 | "isConditional": false 47 | }, 48 | { 49 | "id": "41", 50 | "name": "In Progress", 51 | "to": { 52 | "self": "https://artsyproduct.atlassian.net/rest/api/2/status/3", 53 | "description": "This issue is being actively worked on at the moment by the assignee.", 54 | "iconUrl": "https://artsyproduct.atlassian.net/images/icons/statuses/inprogress.png", 55 | "name": "In Progress", 56 | "id": "3", 57 | "statusCategory": { 58 | "self": "https://artsyproduct.atlassian.net/rest/api/2/statuscategory/4", 59 | "id": 4, 60 | "key": "indeterminate", 61 | "colorName": "yellow", 62 | "name": "In Progress" 63 | } 64 | }, 65 | "hasScreen": false, 66 | "isGlobal": true, 67 | "isInitial": false, 68 | "isConditional": false 69 | }, 70 | { 71 | "id": "51", 72 | "name": "Merged", 73 | "to": { 74 | "self": "https://artsyproduct.atlassian.net/rest/api/2/status/10074", 75 | "description": "This status is managed internally by Jira Software", 76 | "iconUrl": "https://artsyproduct.atlassian.net/", 77 | "name": "Merged", 78 | "id": "10074", 79 | "statusCategory": { 80 | "self": "https://artsyproduct.atlassian.net/rest/api/2/statuscategory/4", 81 | "id": 4, 82 | "key": "indeterminate", 83 | "colorName": "yellow", 84 | "name": "In Progress" 85 | } 86 | }, 87 | "hasScreen": false, 88 | "isGlobal": true, 89 | "isInitial": false, 90 | "isConditional": false 91 | }, 92 | { 93 | "id": "61", 94 | "name": "Monitoring/QA", 95 | "to": { 96 | "self": "https://artsyproduct.atlassian.net/rest/api/2/status/10044", 97 | "description": "This status is managed internally by Jira Software", 98 | "iconUrl": "https://artsyproduct.atlassian.net/", 99 | "name": "Monitoring/QA", 100 | "id": "10044", 101 | "statusCategory": { 102 | "self": "https://artsyproduct.atlassian.net/rest/api/2/statuscategory/4", 103 | "id": 4, 104 | "key": "indeterminate", 105 | "colorName": "yellow", 106 | "name": "In Progress" 107 | } 108 | }, 109 | "hasScreen": false, 110 | "isGlobal": true, 111 | "isInitial": false, 112 | "isConditional": false 113 | }, 114 | { 115 | "id": "71", 116 | "name": "Done", 117 | "to": { 118 | "self": "https://artsyproduct.atlassian.net/rest/api/2/status/10001", 119 | "description": "", 120 | "iconUrl": "https://artsyproduct.atlassian.net/", 121 | "name": "Done", 122 | "id": "10001", 123 | "statusCategory": { 124 | "self": "https://artsyproduct.atlassian.net/rest/api/2/statuscategory/3", 125 | "id": 3, 126 | "key": "done", 127 | "colorName": "green", 128 | "name": "Done" 129 | } 130 | }, 131 | "hasScreen": false, 132 | "isGlobal": true, 133 | "isInitial": false, 134 | "isConditional": false 135 | }, 136 | { 137 | "id": "81", 138 | "name": "Closed", 139 | "to": { 140 | "self": "https://artsyproduct.atlassian.net/rest/api/2/status/6", 141 | "description": "The issue was closed without work because it was de-prioritized or not relevant.", 142 | "iconUrl": "https://artsyproduct.atlassian.net/images/icons/statuses/closed.png", 143 | "name": "Closed", 144 | "id": "6", 145 | "statusCategory": { 146 | "self": "https://artsyproduct.atlassian.net/rest/api/2/statuscategory/3", 147 | "id": 3, 148 | "key": "done", 149 | "colorName": "green", 150 | "name": "Done" 151 | } 152 | }, 153 | "hasScreen": false, 154 | "isGlobal": true, 155 | "isInitial": false, 156 | "isConditional": false 157 | }, 158 | { 159 | "id": "91", 160 | "name": "In Review", 161 | "to": { 162 | "self": "https://artsyproduct.atlassian.net/rest/api/2/status/10018", 163 | "description": "", 164 | "iconUrl": "https://artsyproduct.atlassian.net/", 165 | "name": "In Review", 166 | "id": "10018", 167 | "statusCategory": { 168 | "self": "https://artsyproduct.atlassian.net/rest/api/2/statuscategory/4", 169 | "id": 4, 170 | "key": "indeterminate", 171 | "colorName": "yellow", 172 | "name": "In Progress" 173 | } 174 | }, 175 | "hasScreen": false, 176 | "isGlobal": true, 177 | "isInitial": false, 178 | "isConditional": false 179 | } 180 | ] 181 | } 182 | -------------------------------------------------------------------------------- /org/allPRs.ts: -------------------------------------------------------------------------------- 1 | import { danger, warn, fail, GitHubCommit, markdown } from "danger" 2 | 3 | import yarn from "danger-plugin-yarn" 4 | 5 | // "Highlight package dependencies on Node projects" 6 | const rfc1 = async () => { 7 | await yarn() 8 | } 9 | 10 | import spellcheck from "danger-plugin-spellcheck" 11 | // "Keep our Markdown documents awesome", 12 | const rfc2 = async () => { 13 | await spellcheck({ settings: "artsy/peril-settings@spellcheck.json" }) 14 | } 15 | 16 | // "No PR is too small to warrant a paragraph or two of summary" 17 | // https://github.com/artsy/peril-settings/issues/5 18 | export const rfc5 = () => { 19 | const pr = danger.github.pr 20 | if (pr.body === null || pr.body.length === 0) { 21 | fail("Please add a description to your PR.") 22 | } 23 | } 24 | 25 | // "Hook commit contexts to GitHub PR/Issue labels" 26 | // https://github.com/artsy/peril-settings/issues/7 27 | export const rfc7 = async () => { 28 | const pr = danger.github.thisPR 29 | const commitLabels: string[] = danger.git.commits 30 | .map((c) => c.message) 31 | .filter((m) => m.startsWith("[") && m.includes("]")) 32 | .map((m) => (m.match(/\[(.*)\]/) as any)[1]) // Guaranteed to match based on filter above. 33 | 34 | if (commitLabels.length > 0) { 35 | const api = danger.github.api 36 | const githubLabels = await api.issues.listLabelsForRepo({ owner: pr.owner, repo: pr.repo }) 37 | const matchingLabels = githubLabels.data 38 | .map((l) => l.name) 39 | .filter((l) => commitLabels.find((cl) => l === cl)) 40 | .filter((l) => !danger.github.issue.labels.find((label) => label.name === l)) 41 | 42 | if (matchingLabels.length > 0) { 43 | await api.issues.addLabels({ owner: pr.owner, repo: pr.repo, number: pr.number, labels: matchingLabels }) 44 | } 45 | } 46 | } 47 | 48 | // Always ensure we assign someone, so that our Slackbot work correctly 49 | // https://github.com/artsy/peril-settings/issues/13 50 | export const rfc13 = async () => { 51 | const pr = danger.github.pr 52 | const isRenovate = pr.user.login.toLowerCase().includes("renovate") 53 | const wipPR = pr.title.includes("WIP ") || pr.title.includes("[WIP]") 54 | if (!isRenovate && !wipPR && pr.assignee === null) { 55 | // Validate they are in the org, before asking to assign 56 | try { 57 | await danger.github.api.orgs.checkMembership({ org: "artsy", username: danger.github.pr.user.login }) 58 | warn("Please assign someone to merge this PR, and optionally include people who should review.") 59 | } catch (error) { 60 | // They couldn't assign someone if they tried. 61 | return console.log("Sender does not have permission to assign to this PR") 62 | } 63 | } 64 | } 65 | 66 | // Require changelog entries on PRs with code changes 67 | // https://github.com/artsy/peril-settings/issues/16 68 | export const rfc16 = async () => { 69 | const pr = danger.github.pr 70 | 71 | if (pr.base.repo.name === "eigen") { 72 | console.log("In eigen we don't want this. We added a checkbox in our PR template.") 73 | return 74 | } 75 | 76 | if (pr.body.includes("#trivial")) { 77 | console.log("Skipping changelog check because the PR is marked as trivial") 78 | return 79 | } 80 | 81 | const changelogs = ["CHANGELOG.md", "changelog.md", "CHANGELOG.yml"] 82 | const isOpen = danger.github.pr.state === "open" 83 | 84 | // Get all the files in the root folder of the repo 85 | // e.g. https://api.github.com/repos/artsy/eigen/git/trees/master 86 | 87 | const rootContentsAPI = await danger.github.api.git.getTree({ 88 | owner: pr.base.user.login, 89 | repo: pr.base.repo.name, 90 | tree_sha: pr.base.sha, 91 | }) 92 | 93 | const rootContents = rootContentsAPI.data 94 | 95 | // We have some auto-generated Changelogs 96 | const isAutoGenerated = rootContents.tree.find((file: { path: string }) => file.path == ".autorc") 97 | if (isAutoGenerated) { 98 | console.log("Changelog is auto generated, so skipping any Changelog warnings") 99 | return 100 | } 101 | 102 | const hasChangelog = rootContents.tree.find((file: { path: string }) => changelogs.includes(file.path)) 103 | if (isOpen && hasChangelog) { 104 | const files = [...danger.git.modified_files, ...danger.git.created_files] 105 | 106 | const hasCodeChanges = files.find((file) => !file.match(/(test|spec)/i)) 107 | const hasChangelogChanges = files.find((file) => changelogs.includes(file)) 108 | 109 | if (hasCodeChanges && !hasChangelogChanges) { 110 | warn( 111 | "It looks like code was changed without adding anything to the Changelog.
You can add #trivial in the PR body to skip the check." 112 | ) 113 | } 114 | } 115 | } 116 | 117 | // Warn PR authors if they assign more than one person to a PR 118 | // https://github.com/artsy/README/issues/177 119 | export const rfc177 = () => { 120 | const pr = danger.github.pr 121 | if (pr.assignees && pr.assignees.length > 1) { 122 | warn("Please only assign one person to a PR") 123 | } 124 | } 125 | 126 | // RFC 238: https://github.com/artsy/README/issues/238 127 | // Summarize deploy PR's with included PR's 128 | export const deploySummary = async () => { 129 | // Returns `true` if this is a PR to the `release` branch. 130 | const isRelease = () => { 131 | return danger.github.pr.base.ref === "release" 132 | } 133 | 134 | // Map of PR's included in the deploy. 135 | // (will be outputted as a comment) 136 | interface PRInfoMap { 137 | [prNumber: number]: PRInfo 138 | } 139 | // Info per PR that is included 140 | interface PRInfo { 141 | title: string 142 | url: string 143 | } 144 | // Memoized map of PR's fetched, and their info. 145 | const prMap: PRInfoMap = {} 146 | 147 | // For a given commit, will build a GraphQL fragment to retrieve 148 | // the associated pull request. 149 | // These be combined into one query. 150 | const fragmentForCommit = (commit: GitHubCommit) => { 151 | const sha = commit.sha 152 | 153 | const fragment = ` 154 | sha_${sha}: object(expression: "${sha}") { 155 | ... on Commit { 156 | associatedPullRequests(first:1) { 157 | edges { 158 | node { 159 | title 160 | url 161 | number 162 | } 163 | } 164 | } 165 | } 166 | } 167 | ` 168 | return fragment 169 | } 170 | 171 | if (!isRelease()) return 172 | 173 | const repo = danger.github.thisPR.repo 174 | const owner = danger.github.thisPR.owner 175 | const fragments = danger.github.commits.map((c) => fragmentForCommit(c)).join("") 176 | const query = ` 177 | { 178 | repository(owner: "${owner}", name: "${repo}") { 179 | ${fragments} 180 | } 181 | } 182 | ` 183 | 184 | const resp = await danger.github.api.request({ 185 | method: "POST", 186 | url: "/graphql", 187 | query, 188 | }) 189 | 190 | const { data } = resp 191 | const info = data.data.repository 192 | Object.entries(info).forEach(([_sha, pulls]: [string, any]) => { 193 | if ( 194 | pulls.associatedPullRequests && 195 | pulls.associatedPullRequests.edges && 196 | pulls.associatedPullRequests.edges.length 197 | ) { 198 | const pull = pulls.associatedPullRequests.edges[0].node 199 | 200 | if (prMap[pull.number]) return 201 | 202 | prMap[pull.number] = { 203 | title: pull.title, 204 | url: pull.url, 205 | } 206 | } 207 | }) 208 | 209 | if (!Object.keys(prMap).length) return 210 | 211 | const message = 212 | "### This deploy contains the following PRs:\n\n" + 213 | Object.entries(prMap) 214 | .map(([_number, info]) => { 215 | return `- ${info.title} (${info.url})\n` 216 | }) 217 | .join("") 218 | 219 | return markdown(message) 220 | } 221 | 222 | // The default run 223 | export default async () => { 224 | rfc1() 225 | await rfc2() 226 | rfc5() 227 | await rfc7() 228 | await rfc13() 229 | await rfc16() 230 | await rfc177() 231 | await deploySummary() 232 | } 233 | -------------------------------------------------------------------------------- /tests/rfc_10.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("danger", () => jest.fn()) 2 | import * as danger from "danger" 3 | const dm = danger as any 4 | 5 | import mergeOnGreen from "../org/mergeOnGreen" 6 | import markAsMergeOnGreen from "../org/markAsMergeOnGreen" 7 | 8 | beforeEach(() => { 9 | dm.danger = { 10 | github: { 11 | api: { 12 | repos: { 13 | getCombinedStatusForRef: jest.fn(), 14 | }, 15 | search: { 16 | issues: jest.fn(), 17 | }, 18 | issues: { 19 | get: jest.fn(), 20 | getLabels: jest.fn(), 21 | createLabel: jest.fn(), 22 | addLabels: jest.fn(), 23 | }, 24 | pulls: { 25 | merge: jest.fn(), 26 | }, 27 | orgs: { 28 | checkMembership: jest.fn(), 29 | }, 30 | }, 31 | utils: { 32 | createOrAddLabel: jest.fn(), 33 | }, 34 | }, 35 | } 36 | ;(global as any).console = { 37 | log: jest.fn(), 38 | } 39 | }) 40 | 41 | const repo = { repository: { owner: { login: "danger" } } } 42 | 43 | describe("for adding the label", () => { 44 | describe("in response to an issue comment", () => { 45 | it("bails when the comment is not on a pr", async () => { 46 | await markAsMergeOnGreen({ 47 | comment: { body: "Hi", user: { login: "danger" } }, 48 | issue: {}, 49 | ...repo, 50 | } as any) 51 | expect(console.log).toBeCalledWith("Not a Pull Request") 52 | }) 53 | 54 | it("bails when the issue body doesn't contain the key words", async () => { 55 | await markAsMergeOnGreen({ 56 | comment: { body: "Hi", user: { login: "danger" } }, 57 | issue: { pull_request: {} }, 58 | ...repo, 59 | } as any) 60 | 61 | expect(console.log).toBeCalledWith(expect.stringMatching("Did not find")) 62 | }) 63 | 64 | it("bails when the issue already has merge on green", async () => { 65 | await markAsMergeOnGreen({ 66 | comment: { body: "#mergeongreen", user: { login: "danger" } }, 67 | issue: { labels: [{ name: "Merge On Green" }], pull_request: {} }, 68 | ...repo, 69 | } as any) 70 | expect(console.log).toBeCalledWith("Already has Merge on Green-type label") 71 | }) 72 | 73 | it("bails when the issue already has merge on green", async () => { 74 | await markAsMergeOnGreen({ 75 | comment: { body: "#squashongreen", user: { login: "danger" } }, 76 | issue: { labels: [{ name: "Squash On Green" }], pull_request: {} }, 77 | ...repo, 78 | } as any) 79 | expect(console.log).toBeCalledWith("Already has Merge on Green-type label") 80 | }) 81 | }) 82 | 83 | describe("in response to a review", () => { 84 | const pull_request = { number: 1 } 85 | 86 | it("bails when the issue body doesn't contain the key words", async () => { 87 | dm.danger.github.api.issues.get.mockReturnValueOnce(Promise.resolve({ data: { labels: [] } })) 88 | 89 | await markAsMergeOnGreen({ 90 | review: { body: "Hi", user: { login: "danger" } }, 91 | pull_request: pull_request, 92 | ...repo, 93 | } as any) 94 | 95 | expect(console.log).toBeCalledWith(expect.stringMatching("Did not find")) 96 | }) 97 | 98 | it("bails when the issue already has merge on green", async () => { 99 | dm.danger.github.api.issues.get.mockReturnValueOnce( 100 | Promise.resolve({ data: { labels: [{ name: "Merge On Green" }] } }) 101 | ) 102 | 103 | await markAsMergeOnGreen({ 104 | review: { body: "Looks great! #MergeOnGreen", user: { login: "danger" } }, 105 | pull_request: pull_request, 106 | ...repo, 107 | } as any) 108 | expect(console.log).toBeCalledWith("Already has Merge on Green-type label") 109 | }) 110 | }) 111 | 112 | it("creates the label when the label doesn't exist on the repo", async () => { 113 | dm.danger.github.api.orgs.checkMembership.mockReturnValueOnce(Promise.resolve({ data: {} })) 114 | dm.danger.github.api.issues.getLabels.mockReturnValueOnce(Promise.resolve({ data: [] })) 115 | 116 | await markAsMergeOnGreen({ 117 | comment: { 118 | body: "#squashongreen", 119 | user: { sender: { login: "orta" } }, 120 | }, 121 | issue: { labels: [], pull_request: {} }, 122 | ...repo, 123 | } as any) 124 | 125 | expect(dm.danger.github.utils.createOrAddLabel).toBeCalled() 126 | }) 127 | 128 | it("creates the label when the label doesn't exist on the repo", async () => { 129 | dm.danger.github.api.orgs.checkMembership.mockReturnValueOnce(Promise.resolve({ data: {} })) 130 | dm.danger.github.api.issues.getLabels.mockReturnValueOnce(Promise.resolve({ data: [] })) 131 | 132 | await markAsMergeOnGreen({ 133 | comment: { 134 | body: "#mergeongreen", 135 | user: { sender: { login: "orta" } }, 136 | }, 137 | issue: { labels: [], pull_request: {} }, 138 | ...repo, 139 | } as any) 140 | 141 | expect(dm.danger.github.utils.createOrAddLabel).toBeCalled() 142 | }) 143 | }) 144 | 145 | describe("for handling merging when green", () => { 146 | it("bails when its not a success", async () => { 147 | await mergeOnGreen({ state: "fail" } as any) 148 | expect(console.log).toBeCalled() 149 | }) 150 | 151 | it("bails when the whole status is not a success", async () => { 152 | dm.danger.github.api.repos.getCombinedStatusForRef.mockReturnValueOnce( 153 | Promise.resolve({ data: { state: "failed " } }) 154 | ) 155 | 156 | await mergeOnGreen({ 157 | state: "success", 158 | repository: { owner: { login: "danger" }, name: "doggo" }, 159 | commit: { sha: "123abc" }, 160 | } as any) 161 | 162 | expect(console.log).toBeCalledWith("Not all statuses are green") 163 | }) 164 | 165 | it("does nothing when the PR does not have merge on green", async () => { 166 | // Has the right status 167 | dm.danger.github.api.repos.getCombinedStatusForRef.mockReturnValueOnce( 168 | Promise.resolve({ data: { state: "success" } }) 169 | ) 170 | 171 | // Gets a corresponding issue 172 | dm.danger.github.api.search.issues.mockReturnValueOnce(Promise.resolve({ data: { items: [{ number: 1 }] } })) 173 | 174 | // Returns an issue without the merge on green label 175 | dm.danger.github.api.issues.get.mockReturnValueOnce( 176 | Promise.resolve({ data: { labels: [{ name: "Dog Snoozer" }] } }) 177 | ) 178 | 179 | await mergeOnGreen({ 180 | state: "success", 181 | repository: { owner: { login: "danger" }, name: "doggo" }, 182 | commit: { sha: "123abc" }, 183 | } as any) 184 | 185 | expect(console.log).toBeCalledWith("PR does not have Merge on Green-type label") 186 | }) 187 | 188 | it("triggers a PR merge when there is a Merge on Green label", async () => { 189 | // Has the right status 190 | dm.danger.github.api.repos.getCombinedStatusForRef.mockReturnValueOnce( 191 | Promise.resolve({ data: { state: "success" } }) 192 | ) 193 | 194 | // Gets a corresponding issue 195 | dm.danger.github.api.search.issues.mockReturnValueOnce(Promise.resolve({ data: { items: [{ number: 1 }] } })) 196 | 197 | // Returns an issue without the merge on green label 198 | dm.danger.github.api.issues.get.mockReturnValueOnce( 199 | Promise.resolve({ data: { labels: [{ name: "Merge On Green" }] } }) 200 | ) 201 | 202 | await mergeOnGreen({ 203 | state: "success", 204 | repository: { owner: { login: "danger" }, name: "doggo" }, 205 | commit: { sha: "123abc" }, 206 | } as any) 207 | 208 | expect(dm.danger.github.api.pulls.merge).toBeCalledWith({ 209 | commit_title: "Merge pull request #1 by Peril", 210 | number: 1, 211 | owner: "danger", 212 | repo: "doggo", 213 | merge_method: "merge", 214 | }) 215 | }) 216 | 217 | it("triggers a PR squash when there is a Squash on Green label", async () => { 218 | // Has the right status 219 | dm.danger.github.api.repos.getCombinedStatusForRef.mockReturnValueOnce( 220 | Promise.resolve({ data: { state: "success" } }) 221 | ) 222 | 223 | // Gets a corresponding issue 224 | dm.danger.github.api.search.issues.mockReturnValueOnce(Promise.resolve({ data: { items: [{ number: 1 }] } })) 225 | 226 | // Returns an issue without the merge on green label 227 | dm.danger.github.api.issues.get.mockReturnValueOnce( 228 | Promise.resolve({ data: { labels: [{ name: "Squash On Green" }] } }) 229 | ) 230 | 231 | await mergeOnGreen({ 232 | state: "success", 233 | repository: { owner: { login: "danger" }, name: "doggo" }, 234 | commit: { sha: "123abc" }, 235 | } as any) 236 | 237 | expect(dm.danger.github.api.pulls.merge).toBeCalledWith({ 238 | commit_title: undefined, 239 | number: 1, 240 | owner: "danger", 241 | repo: "doggo", 242 | merge_method: "squash", 243 | }) 244 | }) 245 | }) 246 | -------------------------------------------------------------------------------- /fixtures/jira_issue_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations", 3 | "id": "13382", 4 | "self": "https://artsyproduct.atlassian.net/rest/api/2/issue/13382", 5 | "key": "PLATFORM-600", 6 | "fields": { 7 | "issuetype": { 8 | "self": "https://artsyproduct.atlassian.net/rest/api/2/issuetype/10002", 9 | "id": "10002", 10 | "description": "A task that needs to be done.", 11 | "iconUrl": "https://artsyproduct.atlassian.net/secure/viewavatar?size=xsmall&avatarId=10318&avatarType=issuetype", 12 | "name": "Task", 13 | "subtask": false, 14 | "avatarId": 10318 15 | }, 16 | "timespent": null, 17 | "customfield_10030": null, 18 | "project": { 19 | "self": "https://artsyproduct.atlassian.net/rest/api/2/project/10040", 20 | "id": "10040", 21 | "key": "PLATFORM", 22 | "name": "Platform", 23 | "projectTypeKey": "software", 24 | "avatarUrls": { 25 | "48x48": "https://artsyproduct.atlassian.net/secure/projectavatar?pid=10040&avatarId=10521", 26 | "24x24": "https://artsyproduct.atlassian.net/secure/projectavatar?size=small&pid=10040&avatarId=10521", 27 | "16x16": "https://artsyproduct.atlassian.net/secure/projectavatar?size=xsmall&pid=10040&avatarId=10521", 28 | "32x32": "https://artsyproduct.atlassian.net/secure/projectavatar?size=medium&pid=10040&avatarId=10521" 29 | } 30 | }, 31 | "customfield_10032": null, 32 | "customfield_10033": null, 33 | "fixVersions": [], 34 | "aggregatetimespent": null, 35 | "customfield_10034": null, 36 | "customfield_10035": null, 37 | "resolution": null, 38 | "customfield_10037": null, 39 | "customfield_10027": null, 40 | "resolutiondate": null, 41 | "workratio": -1, 42 | "lastViewed": "2018-08-03T20:03:25.993-0400", 43 | "watches": { 44 | "self": "https://artsyproduct.atlassian.net/rest/api/2/issue/PLATFORM-600/watchers", 45 | "watchCount": 2, 46 | "isWatching": true 47 | }, 48 | "created": "2018-06-30T11:06:22.305-0400", 49 | "priority": { 50 | "self": "https://artsyproduct.atlassian.net/rest/api/2/priority/3", 51 | "iconUrl": "https://artsyproduct.atlassian.net/images/icons/priorities/medium.svg", 52 | "name": "Medium", 53 | "id": "3" 54 | }, 55 | "customfield_10026": null, 56 | "labels": ["CLIENT-INFRA", "infrastructure"], 57 | "customfield_10017": null, 58 | "aggregatetimeoriginalestimate": null, 59 | "timeestimate": null, 60 | "issuelinks": [], 61 | "assignee": null, 62 | "updated": "2018-07-23T16:16:59.087-0400", 63 | "status": { 64 | "self": "https://artsyproduct.atlassian.net/rest/api/2/status/10077", 65 | "description": "", 66 | "iconUrl": "https://artsyproduct.atlassian.net/images/icons/statuses/generic.png", 67 | "name": "Ready", 68 | "id": "10077", 69 | "statusCategory": { 70 | "self": "https://artsyproduct.atlassian.net/rest/api/2/statuscategory/2", 71 | "id": 2, 72 | "key": "new", 73 | "colorName": "blue-gray", 74 | "name": "To Do" 75 | } 76 | }, 77 | "components": [], 78 | "timeoriginalestimate": null, 79 | "description": "Justin mentioned that there's about 200kb of queries inside the reaction source code, that'd remove some of the download time and speed up client-side queries\n\nGiven the rate of deployment for Reaction, this likely is requires metaphysics to support storing them in a db", 80 | "customfield_10010": [], 81 | "customfield_10011": "0|i00hx3:", 82 | "customfield_10012": "2018-07-02T12:00:57.690-0400", 83 | "customfield_10013": null, 84 | "customfield_10014": null, 85 | "timetracking": {}, 86 | "security": null, 87 | "customfield_10008": null, 88 | "aggregatetimeestimate": null, 89 | "customfield_10009": null, 90 | "attachment": [], 91 | "summary": "Move Reaction to use persisted queries", 92 | "creator": { 93 | "self": "https://artsyproduct.atlassian.net/rest/api/2/user?username=orta", 94 | "name": "orta", 95 | "key": "orta", 96 | "accountId": "5a620c0f8fa9a1334131b4f7", 97 | "emailAddress": "orta@artsymail.com", 98 | "avatarUrls": { 99 | "48x48": "https://avatar-cdn.atlassian.com/b841949525ebe49d4e0d1fe125cde0d7?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fb841949525ebe49d4e0d1fe125cde0d7%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", 100 | "24x24": "https://avatar-cdn.atlassian.com/b841949525ebe49d4e0d1fe125cde0d7?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fb841949525ebe49d4e0d1fe125cde0d7%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", 101 | "16x16": "https://avatar-cdn.atlassian.com/b841949525ebe49d4e0d1fe125cde0d7?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fb841949525ebe49d4e0d1fe125cde0d7%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", 102 | "32x32": "https://avatar-cdn.atlassian.com/b841949525ebe49d4e0d1fe125cde0d7?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fb841949525ebe49d4e0d1fe125cde0d7%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" 103 | }, 104 | "displayName": "Orta Therox", 105 | "active": true, 106 | "timeZone": "America/New_York" 107 | }, 108 | "subtasks": [], 109 | "reporter": { 110 | "self": "https://artsyproduct.atlassian.net/rest/api/2/user?username=orta", 111 | "name": "orta", 112 | "key": "orta", 113 | "accountId": "5a620c0f8fa9a1334131b4f7", 114 | "emailAddress": "orta@artsymail.com", 115 | "avatarUrls": { 116 | "48x48": "https://avatar-cdn.atlassian.com/b841949525ebe49d4e0d1fe125cde0d7?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fb841949525ebe49d4e0d1fe125cde0d7%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", 117 | "24x24": "https://avatar-cdn.atlassian.com/b841949525ebe49d4e0d1fe125cde0d7?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fb841949525ebe49d4e0d1fe125cde0d7%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", 118 | "16x16": "https://avatar-cdn.atlassian.com/b841949525ebe49d4e0d1fe125cde0d7?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fb841949525ebe49d4e0d1fe125cde0d7%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", 119 | "32x32": "https://avatar-cdn.atlassian.com/b841949525ebe49d4e0d1fe125cde0d7?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fb841949525ebe49d4e0d1fe125cde0d7%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" 120 | }, 121 | "displayName": "Orta Therox", 122 | "active": true, 123 | "timeZone": "America/New_York" 124 | }, 125 | "customfield_10000": "{}", 126 | "aggregateprogress": { 127 | "progress": 0, 128 | "total": 0 129 | }, 130 | "customfield_10001": null, 131 | "customfield_10004": null, 132 | "customfield_10038": null, 133 | "environment": null, 134 | "duedate": null, 135 | "progress": { 136 | "progress": 0, 137 | "total": 0 138 | }, 139 | "votes": { 140 | "self": "https://artsyproduct.atlassian.net/rest/api/2/issue/PLATFORM-600/votes", 141 | "votes": 0, 142 | "hasVoted": false 143 | }, 144 | "comment": { 145 | "comments": [ 146 | { 147 | "self": "https://artsyproduct.atlassian.net/rest/api/2/issue/13382/comment/12706", 148 | "id": "12706", 149 | "author": { 150 | "self": "https://artsyproduct.atlassian.net/rest/api/2/user?username=alloy", 151 | "name": "alloy", 152 | "key": "eloy", 153 | "accountId": "5a620c04a9eb266e7c57da8e", 154 | "emailAddress": "eloy@artsymail.com", 155 | "avatarUrls": { 156 | "48x48": "https://avatar-cdn.atlassian.com/9545a5eeef53ef54296a9b1516405a6a?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9545a5eeef53ef54296a9b1516405a6a%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", 157 | "24x24": "https://avatar-cdn.atlassian.com/9545a5eeef53ef54296a9b1516405a6a?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9545a5eeef53ef54296a9b1516405a6a%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", 158 | "16x16": "https://avatar-cdn.atlassian.com/9545a5eeef53ef54296a9b1516405a6a?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9545a5eeef53ef54296a9b1516405a6a%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", 159 | "32x32": "https://avatar-cdn.atlassian.com/9545a5eeef53ef54296a9b1516405a6a?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9545a5eeef53ef54296a9b1516405a6a%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" 160 | }, 161 | "displayName": "Eloy Durán", 162 | "active": true, 163 | "timeZone": "Europe/Amsterdam" 164 | }, 165 | "body": "This somewhat depends on https://artsyproduct.atlassian.net/browse/PLATFORM-571, although Reaction could theoretically start using persisted queries by merging the query maps manually like is done with the queries of Emission, but not sure if I should add it to that epic.", 166 | "updateAuthor": { 167 | "self": "https://artsyproduct.atlassian.net/rest/api/2/user?username=alloy", 168 | "name": "alloy", 169 | "key": "eloy", 170 | "accountId": "5a620c04a9eb266e7c57da8e", 171 | "emailAddress": "eloy@artsymail.com", 172 | "avatarUrls": { 173 | "48x48": "https://avatar-cdn.atlassian.com/9545a5eeef53ef54296a9b1516405a6a?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9545a5eeef53ef54296a9b1516405a6a%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", 174 | "24x24": "https://avatar-cdn.atlassian.com/9545a5eeef53ef54296a9b1516405a6a?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9545a5eeef53ef54296a9b1516405a6a%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", 175 | "16x16": "https://avatar-cdn.atlassian.com/9545a5eeef53ef54296a9b1516405a6a?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9545a5eeef53ef54296a9b1516405a6a%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", 176 | "32x32": "https://avatar-cdn.atlassian.com/9545a5eeef53ef54296a9b1516405a6a?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9545a5eeef53ef54296a9b1516405a6a%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" 177 | }, 178 | "displayName": "Eloy Durán", 179 | "active": true, 180 | "timeZone": "Europe/Amsterdam" 181 | }, 182 | "created": "2018-07-02T12:00:57.690-0400", 183 | "updated": "2018-07-02T12:01:46.113-0400", 184 | "jsdPublic": true 185 | } 186 | ], 187 | "maxResults": 1, 188 | "total": 1, 189 | "startAt": 0 190 | }, 191 | "worklog": { 192 | "startAt": 0, 193 | "maxResults": 20, 194 | "total": 0, 195 | "worklogs": [] 196 | } 197 | } 198 | } 199 | --------------------------------------------------------------------------------