├── .editorconfig ├── .prettierignore ├── .gitattributes ├── .eslintignore ├── CODEOWNERS ├── .prettierrc ├── .github ├── dependabot.yml └── workflows │ ├── labeller.yml │ ├── publish.yml │ ├── ci.yml │ └── mend.yaml ├── jest.config.js ├── __tests__ ├── main.test.ts └── github.test.ts ├── tsconfig.json ├── LICENSE ├── action.yml ├── package.json ├── .gitignore ├── src ├── main.ts └── github.ts ├── .eslintrc.json └── README.md /.editorconfig: -------------------------------------------------------------------------------- 1 | tab_width = 2 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | jest.config.js 5 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Setting ownership to the modules team 2 | * @puppetlabs/modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "endOfLine": "auto", 7 | "bracketSpacing": false 8 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | 8 | - package-ecosystem: npm 9 | directory: / 10 | schedule: 11 | interval: daily 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest' 9 | }, 10 | verbose: true 11 | } -------------------------------------------------------------------------------- /.github/workflows/labeller.yml: -------------------------------------------------------------------------------- 1 | 2 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 3 | name: community-labeller 4 | 5 | on: 6 | issues: 7 | types: 8 | - opened 9 | pull_request_target: 10 | types: 11 | - opened 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: puppetlabs/community-labeller@v0 19 | name: Label issue or pull request 20 | with: 21 | label_name: community 22 | label_color: '5319e7' 23 | org_membership: puppetlabs 24 | token: ${{ secrets.CUSTOM_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: publish 4 | 5 | on: 6 | release: 7 | types: [created, published, edited] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | with: 16 | ref: ${{ github.event.release.tag_name }} 17 | 18 | - name: Set up dependencies 19 | run: npm ci 20 | 21 | - name: Build 22 | run: npm run build 23 | 24 | - uses: JasonEtco/build-and-tag-action@v2 25 | env: 26 | GITHUB_TOKEN: ${{ github.token }} -------------------------------------------------------------------------------- /__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import {getOrgMembershipList} from '../src/main' 3 | 4 | describe('Misc tests', () => { 5 | let inputs = {} as any 6 | let inSpy: jest.SpyInstance 7 | 8 | beforeAll(() => { 9 | process.stdout.write = jest.fn() 10 | }) 11 | 12 | beforeEach(() => { 13 | inputs = {} 14 | inSpy = jest.spyOn(core, 'getInput') 15 | inSpy.mockImplementation((name) => inputs[name]) 16 | }) 17 | 18 | afterEach(() => { 19 | jest.resetAllMocks() 20 | jest.clearAllMocks() 21 | }) 22 | 23 | test('getOrgMembershipList returns a clean list of strings', () => { 24 | 25 | inputs["org_membership"] = "test, test2, test3" 26 | const orgs = getOrgMembershipList() 27 | 28 | expect(orgs).toEqual(["test", "test2", "test3"]) 29 | expect(orgs.length).toEqual(3) 30 | expect(orgs[1]).not.toBe(" test2") 31 | }) 32 | }) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | "strict": true, /* Enable all strict type-checking options. */ 8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 9 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | }, 11 | "exclude": ["node_modules", "**/*.test.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 GitHub, Inc. and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: ci 4 | 5 | on: 6 | push: 7 | tags: 8 | - v* 9 | pull_request: 10 | branches: 11 | - main 12 | workflow_dispatch: 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | 22 | - uses: actions/setup-node@v3 23 | with: 24 | node-version: '16' 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Lint 30 | run: npm run lint 31 | 32 | - name: Test 33 | run: npm run test 34 | 35 | - name: Build 36 | run: npm run build 37 | 38 | publish: 39 | runs-on: ubuntu-latest 40 | needs: [ build ] 41 | steps: 42 | 43 | - uses: actions/checkout@v3 44 | with: 45 | fetch-depth: 0 46 | 47 | - name: Create Release 48 | if: startsWith(github.ref, 'refs/tags/v') 49 | id: create_release 50 | uses: ncipollo/release-action@v1 51 | with: 52 | name: community-labeller 53 | generateReleaseNotes: true 54 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'community-labeller' 2 | description: 'A GitHub action to label issues and pull requests with a community label' 3 | author: 'puppetlabs' 4 | inputs: 5 | label_name: 6 | description: 'The name of the label' 7 | default: 'community' 8 | required: false 9 | label_color: 10 | description: 'The color of the label' 11 | default: '5319E7' 12 | required: false 13 | org_membership: 14 | description: 'Contributions from users that are not members of the specified organisations will be labeled with the configured label. The value can be a single organisation or a comma-separated list of organisations.' 15 | default: 'puppetlabs' 16 | required: false 17 | logins_to_ignore: 18 | description: 'Contributions from the specified users will not be labeled by this action. The value can be a single login or a comma-separated list of logins.' 19 | required: false 20 | fail_if_member: 21 | description: 'Pipeline will fail, if the user is member of specified organisations and no label has been added manually.' 22 | default: 'false' 23 | required: false 24 | token: 25 | description: 'The GitHub token to use for authentication.' 26 | required: true 27 | runs: 28 | using: 'node16' 29 | main: 'dist/index.js' 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "community-labeller", 3 | "version": "0.1.4", 4 | "private": true, 5 | "description": "A GitHub action to label issues and pull requests with a community label", 6 | "main": "dist/index.js", 7 | "scripts": { 8 | "build": "npx ncc build ./src/main.ts", 9 | "test": "jest", 10 | "start": "npx ncc run ./src/main.ts", 11 | "format-check": "prettier --check '**/*.ts'", 12 | "lint": "eslint src/**/*.ts" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/puppetlabs/community-labeller.git" 17 | }, 18 | "keywords": [ 19 | "actions", 20 | "node", 21 | "setup" 22 | ], 23 | "author": "puppetlabs", 24 | "license": "MIT", 25 | "dependencies": { 26 | "@actions/core": "^1.9.1", 27 | "@actions/github": "^5.0.3", 28 | "@octokit/request-error": "^2.1.0", 29 | "@octokit/types": "^6.34.0", 30 | "@octokit/webhooks-types": "^5.6.0" 31 | }, 32 | "devDependencies": { 33 | "@types/jest": "^27.4.0", 34 | "@types/node": "^18.15.0", 35 | "@typescript-eslint/parser": "^5.27.1", 36 | "@vercel/ncc": "^0.34.0", 37 | "eslint": "^8.17.0", 38 | "eslint-config-prettier": "^8.5.0", 39 | "eslint-plugin-github": "^4.3.6", 40 | "eslint-plugin-jest": "^26.2.2", 41 | "eslint-plugin-prettier": "^4.0.0", 42 | "jest": "^27.5.1", 43 | "js-yaml": "^4.1.0", 44 | "nock": "^13.2.9", 45 | "prettier": "^2.6.2", 46 | "ts-jest": "^27.1.4", 47 | "typescript": "^4.9.5" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/mend.yaml: -------------------------------------------------------------------------------- 1 | name: mend_scan 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: checkout repo content 12 | uses: actions/checkout@v2 # checkout the repository content to github runner. 13 | with: 14 | fetch-depth: 1 15 | - name: setup javascript 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 16 19 | # setup a package lock if one doesn't exist, otherwise do nothing 20 | - name: check package lock 21 | run: '[ -f "package-lock.json" ] && echo "package lock file exists, skipping" || npm i --package-lock-only' 22 | # install java 23 | - uses: actions/setup-java@v3 24 | with: 25 | distribution: 'temurin' # See 'Supported distributions' for available options 26 | java-version: '17' 27 | # download mend 28 | - name: download_mend 29 | run: curl -o wss-unified-agent.jar https://unified-agent.s3.amazonaws.com/wss-unified-agent.jar 30 | - name: run mend 31 | run: java -jar wss-unified-agent.jar 32 | env: 33 | WS_APIKEY: ${{ secrets.MEND_API_KEY }} 34 | WS_WSS_URL: https://saas-eu.whitesourcesoftware.com/agent 35 | WS_USERKEY: ${{ secrets.MEND_TOKEN }} 36 | WS_PRODUCTNAME: content-and-tooling 37 | WS_PROJECTNAME: ${{ github.event.repository.name }} 38 | WS_FILESYSTEMSCAN: true 39 | WS_CHECKPOLICIES: true 40 | WS_FORCEUPDATE: true 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | __tests__/runner/* 99 | lib/**/* 100 | 101 | dist 102 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import {GitHubClient} from './github' 3 | 4 | /* When an issue or pull request is opened we want to look at the associated user and 5 | see if they are a member of the X organisations and Y teams. If they are not we should 6 | label the issue with the label `community`. */ 7 | export async function run(): Promise { 8 | try { 9 | core.info('Starting community labeller') 10 | // Retrieve our inputs 11 | const labelName = core.getInput('label_name', {required: true}) 12 | const labelColor = core.getInput('label_color', {required: true}) 13 | const loginsToIgnore = core.getInput('logins_to_ignore', {required: false}) 14 | const failIfMember = core.getInput('fail_if_member', {required: false}) 15 | const orgs = getOrgMembershipList() 16 | const token = core.getInput('token', {required: true}) 17 | const client = new GitHubClient(token) 18 | const labels = client.getLabels() 19 | const missingCommunityLabel = labels && !labels.includes(labelName) 20 | 21 | if ( 22 | (await client.checkOrgMembership(orgs)) || 23 | client.isExcludedLogin(loginsToIgnore) 24 | ) { 25 | if (failIfMember === 'true' && !labels) { 26 | core.setFailed('The PR is missing a label!') 27 | } else { 28 | core.info("Looks like this issue doesn't need labeling! 👍") 29 | } 30 | return 31 | } 32 | 33 | if (missingCommunityLabel) { 34 | core.setFailed('Community PRs must be labelled') 35 | return 36 | } 37 | 38 | await client.createLabel(labelName, labelColor) 39 | await client.addLabel(labelName) 40 | } catch (error) { 41 | if (error instanceof Error) core.setFailed(error.message) 42 | } 43 | } 44 | 45 | /* getOrgMembershipList() returns a clean list of orgs that were passed in 46 | as a comma separated string. */ 47 | export function getOrgMembershipList(): string[] { 48 | const orgs = core.getInput('org_membership', {required: true}) 49 | const sanitised = orgs.replace(/\s/g, '') 50 | return sanitised.split(',') 51 | } 52 | 53 | run() 54 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint", "prettier"], 3 | "extends": ["plugin:github/recommended", "prettier"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "rules": { 11 | "i18n-text/no-en": "off", 12 | "eslint-comments/no-use": "off", 13 | "import/no-namespace": "off", 14 | "no-unused-vars": "off", 15 | "@typescript-eslint/no-unused-vars": "error", 16 | "@typescript-eslint/explicit-member-accessibility": [ 17 | "error", 18 | {"accessibility": "no-public"} 19 | ], 20 | "@typescript-eslint/no-require-imports": "error", 21 | "@typescript-eslint/array-type": "error", 22 | "@typescript-eslint/await-thenable": "error", 23 | "@typescript-eslint/ban-ts-comment": "error", 24 | "camelcase": "off", 25 | "@typescript-eslint/consistent-type-assertions": "error", 26 | "@typescript-eslint/explicit-function-return-type": [ 27 | "error", 28 | {"allowExpressions": true} 29 | ], 30 | "@typescript-eslint/func-call-spacing": ["error", "never"], 31 | "@typescript-eslint/no-array-constructor": "error", 32 | "@typescript-eslint/no-empty-interface": "error", 33 | "@typescript-eslint/no-explicit-any": "error", 34 | "@typescript-eslint/no-extraneous-class": "error", 35 | "@typescript-eslint/no-for-in-array": "error", 36 | "@typescript-eslint/no-inferrable-types": "error", 37 | "@typescript-eslint/no-misused-new": "error", 38 | "@typescript-eslint/no-namespace": "error", 39 | "@typescript-eslint/no-non-null-assertion": "warn", 40 | "@typescript-eslint/no-unnecessary-qualifier": "error", 41 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 42 | "@typescript-eslint/no-useless-constructor": "error", 43 | "@typescript-eslint/no-var-requires": "error", 44 | "@typescript-eslint/prefer-for-of": "warn", 45 | "@typescript-eslint/prefer-function-type": "warn", 46 | "@typescript-eslint/prefer-includes": "error", 47 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 48 | "@typescript-eslint/promise-function-async": "error", 49 | "@typescript-eslint/require-array-sort-compare": "error", 50 | "@typescript-eslint/restrict-plus-operands": "error", 51 | "semi": "off", 52 | "@typescript-eslint/semi": ["error", "never"], 53 | "@typescript-eslint/type-annotation-spacing": "error", 54 | "@typescript-eslint/unbound-method": "error", 55 | "prettier/prettier": "error", 56 | "comma-dangle": "off" 57 | }, 58 | "env": { 59 | "node": true, 60 | "es6": true, 61 | "jest/globals": true 62 | }, 63 | "settings": { 64 | "import/resolver": { 65 | "node": { 66 | "paths": ["src"], 67 | "extensions": [".js", ".ts", ".d.ts", ".tsx"] 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # community-labeller 2 | 3 | This is a basic action that will label issues and pull request with a given label if the contributor is not a member of a given list of organisations. 4 | 5 | ## Warning 6 | 7 | This tool is in the process of being removed and it will be archived in the near future. As such we recommend you do not implement it in any current workflows and, 8 | if you already have it, to remove as soon as possible. One it this repo is toy-chested, it is unlikely that any workflows containing it will work unless properly amended. 9 | 10 | ## Inputs 11 | 12 | | name | required | description | default | 13 | |------|----------|-------------|---------| 14 | | label_name | false | The name of the label. | community | 15 | | label_color | false | The color of the label. If the label already exists in the repository, this setting will have no effect. | 5319E7 | 16 | | org_membership | false | Contributions from users that are not members of the specified organisations will be labeled with the configured label. The value can be a single organisation or a comma-separated list of organisations. | puppetlabs | 17 | | logins_to_ignore | false | Contributions from the specified users will not be labeled by this action. The value can be a single login or a comma-separated list of logins. | `N/A` | 18 | | fail_if_member | false | Pipeline will fail, if the user is member of specified organisations and no label has been added manually. | false | 19 | | token | true | A token with enough privilege to view org memberships and repo content. | `N/A` | 20 | 21 | ## Security 22 | 23 | ### Scopes 24 | 25 | This action requires a token with `read:org`. The standard GITHUB_TOKEN will not work. 26 | 27 | ### Workflow events 28 | 29 | The labeller needs to access the secret associated with the repository. To enable this safely for both internal and external contributors, we reccoment using the `pull_request_target` event for labeling pull requests. 30 | See the [security note on that event](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target) before using it for anything else, or combining the labeler action with any other steps. 31 | 32 | ## Usage 33 | 34 | ``` yaml 35 | name: community-labeller 36 | 37 | on: 38 | issues: 39 | types: 40 | - opened 41 | pull_request_target: 42 | types: 43 | - opened 44 | 45 | jobs: 46 | label: 47 | runs-on: ubuntu-latest 48 | steps: 49 | 50 | - uses: puppetlabs/community-labeller@v0 51 | name: Label issues or pull requests 52 | with: 53 | label_name: community 54 | label_color: '5319e7' 55 | org_membership: puppetlabs 56 | token: ${{ secrets.CUSTOM_TOKEN }} 57 | ``` 58 | 59 | ## Contributing 60 | 61 | This action has been developed with node `v16`. 62 | 63 | ``` bash 64 | # Install the dependencies 65 | npm install 66 | 67 | # Run tslint 68 | npm lint 69 | 70 | ## Run tests 71 | npm test 72 | ``` 73 | 74 | ## Releasing 75 | 76 | To create a realease you can run the following commands ensuring that you are on main: 77 | 78 | ``` bash 79 | npm version "v1.0.0" 80 | git push --follow-tags 81 | ``` 82 | 83 | Once the release has been created you will need to publish it by following the instructions [provided by GitHub](https://docs.github.com/en/actions/creating-actions/publishing-actions-in-github-marketplace). 84 | -------------------------------------------------------------------------------- /src/github.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import { 4 | IssuesEvent, 5 | Label, 6 | PullRequestEvent 7 | } from '@octokit/webhooks-types/schema' 8 | import {GitHub} from '@actions/github/lib/utils' 9 | import {RequestError} from '@octokit/request-error' 10 | 11 | /* This exists to mock the context object that is passed to the action. 12 | We need this when running locally. */ 13 | // Object.defineProperty(github, 'context', { 14 | // value: { 15 | // eventName: 'issues', 16 | // repo: { 17 | // owner: 'puppetlabs', 18 | // repo: 'iac' 19 | // }, 20 | // issue: { 21 | // number: 331 22 | // }, 23 | // payload: { 24 | // action: 'opened', 25 | // sender: { 26 | // login: 'petergmurphy' 27 | // }, 28 | // pull_request: { 29 | // labels: [] 30 | // }, 31 | // issue: { 32 | // labels: [] 33 | // } 34 | // } 35 | // } 36 | // }) 37 | /* This class wraps the octokit client and provides convenience methods for 38 | working with the GitHub API and the current context of the workflow where 39 | this action is executed. */ 40 | export class GitHubClient { 41 | private readonly client: InstanceType 42 | private readonly payload: PullRequestEvent | IssuesEvent 43 | //private readonly senderLogin: string 44 | 45 | constructor(token: string) { 46 | this.client = github.getOctokit(token) 47 | this.payload = this.getPayload() //github.context.payload 48 | //this.senderLogin = this.payload.sender!.login 49 | } 50 | 51 | // Gets the payload of the current event 52 | private getPayload(): PullRequestEvent | IssuesEvent { 53 | if (github.context.eventName === 'issues') { 54 | return github.context.payload as IssuesEvent 55 | } else if ( 56 | github.context.eventName === 'pull_request' || 57 | github.context.eventName === 'pull_request_target' 58 | ) { 59 | return github.context.payload as PullRequestEvent 60 | } else { 61 | throw new Error( 62 | 'Invalid event. Please check your workflow configuration.' 63 | ) 64 | } 65 | } 66 | 67 | // Create a label in the current repository 68 | async createLabel(name: string, color: string): Promise { 69 | try { 70 | await this.client.rest.issues.createLabel({ 71 | owner: github.context.repo.owner, 72 | repo: github.context.repo.repo, 73 | name, 74 | color 75 | }) 76 | 77 | core.info(`Successfully created the '${name}' label ✨`) 78 | } catch (error) { 79 | const requestError = error as RequestError 80 | if (requestError.status === 422) { 81 | core.info( 82 | `It looks like the label '${name}' already exists in this repository so we don't need to create it! 🙌` 83 | ) 84 | return 85 | } 86 | throw error 87 | } 88 | } 89 | 90 | // Checks if the issue already has the label 91 | getLabels(): string[] | false { 92 | let labels!: Label[] 93 | 94 | if (github.context.eventName === 'issues') { 95 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 96 | labels = (this.payload as IssuesEvent).issue.labels! 97 | } else if ( 98 | github.context.eventName === 'pull_request' || 99 | github.context.eventName === 'pull_request_target' 100 | ) { 101 | labels = (this.payload as PullRequestEvent).pull_request.labels 102 | } else { 103 | return false 104 | } 105 | 106 | if (labels.length > 0) { 107 | return labels.map((label) => label.name) 108 | } 109 | 110 | return false 111 | } 112 | 113 | /* Adds the label to the issue/pull request. It will attempt to create the label 114 | if it does not exist. */ 115 | async addLabel(name: string): Promise { 116 | const issueNumber = github.context.issue.number 117 | await this.client.rest.issues.addLabels({ 118 | owner: github.context.repo.owner, 119 | repo: github.context.repo.repo, 120 | issue_number: issueNumber, 121 | labels: [name] 122 | }) 123 | core.info( 124 | `Successfully added the '${name}' label to issue ${issueNumber} ✨` 125 | ) 126 | } 127 | 128 | // Checks if the event sender is a member of the specified org(s) 129 | async checkOrgMembership(orgs: string[]): Promise { 130 | const username: string = this.payload.sender.login 131 | 132 | for (const org of orgs) { 133 | try { 134 | await this.client.rest.orgs.checkMembershipForUser({ 135 | org, 136 | username 137 | }) 138 | 139 | return true 140 | } catch (error) { 141 | const requestError = error as RequestError 142 | if ( 143 | requestError.status === 404 && 144 | requestError.message.includes('User does not exist') 145 | ) { 146 | core.debug(`User: ${username}, is not a member of the ${org} org`) 147 | } else { 148 | throw error 149 | } 150 | } 151 | } 152 | 153 | return false 154 | } 155 | 156 | // Checks if the user is one that should be ignored 157 | isExcludedLogin(logins: string): boolean { 158 | const defaultLogins = ['github-actions[bot]', 'dependabot[bot]'] 159 | const providedLogins = logins.split(',') 160 | const allLogins = [...new Set([...defaultLogins, ...providedLogins])] 161 | if (allLogins.includes(this.payload.sender.login)) { 162 | core.debug( 163 | `Action will not continue for user '${this.payload.sender.login}' because it is has been marked as ignored.` 164 | ) 165 | return true 166 | } 167 | 168 | return false 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /__tests__/github.test.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import nock from 'nock' 4 | import {GitHubClient} from '../src/github' 5 | import os from 'os' 6 | 7 | const apiUrl = 'https://api.github.com' 8 | 9 | describe('With a new github client', () => { 10 | let inputs = {} as any 11 | let client: GitHubClient 12 | let inSpy: jest.SpyInstance 13 | 14 | beforeAll(() => { 15 | process.env['GITHUB_PATH'] = '' 16 | 17 | Object.defineProperty(github, 'context', { 18 | value: { 19 | eventName: 'issues', 20 | repo: { 21 | owner: 'puppetlabs', 22 | repo: 'iac' 23 | }, 24 | issue: { 25 | number: 331 26 | }, 27 | payload: { 28 | action: 'opened', 29 | sender: { 30 | login: 'dependabot[bot]' 31 | }, 32 | issue: { 33 | labels: [ 34 | { 35 | id: 1362934389, 36 | node_id: 'MDU6TGFiZWwxMzYyOTM0Mzg5', 37 | url: 'https://api.github.com/repos/Codertocat/Hello-World/labels/bug', 38 | name: 'community', 39 | color: 'd73a4a', 40 | default: true 41 | } 42 | ] 43 | } 44 | } 45 | } 46 | }) 47 | 48 | client = new GitHubClient('1234') 49 | console.log('::stop-commands::stoptoken') 50 | process.stdout.write = jest.fn() 51 | }) 52 | 53 | beforeEach(() => { 54 | inputs = {} 55 | inSpy = jest.spyOn(core, 'getInput') 56 | inSpy.mockImplementation((name) => inputs[name]) 57 | }) 58 | 59 | afterEach(() => { 60 | jest.resetAllMocks() 61 | jest.clearAllMocks() 62 | }) 63 | 64 | afterAll(async () => { 65 | console.log('::stoptoken::') // Re-enable executing of runner commands when running tests in actions 66 | }, 100000) 67 | 68 | test('checkOrgMembership should return true when the user is a member of the org', async () => { 69 | nock(apiUrl).get('/orgs/test/members/dependabot%5Bbot%5D').reply(200, {}) 70 | 71 | const result = await client.checkOrgMembership(['test']) 72 | expect(result).toBe(true) 73 | }) 74 | 75 | test('checkOrgMembership should return false when the user is not a member of the org', async () => { 76 | nock(apiUrl) 77 | .get('/orgs/test/members/dependabot%5Bbot%5D') 78 | .reply(404, {message: 'User does not exist'}) 79 | 80 | const result = await client.checkOrgMembership(['test']) 81 | expect(result).toBe(false) 82 | }) 83 | 84 | test('checkOrgMembership should handle a list of orgs', async () => { 85 | const orgs = ['test', 'test2', 'test3'] 86 | nock(apiUrl) 87 | .get(`/orgs/test/members/dependabot%5Bbot%5D`) 88 | .reply(200, {}) 89 | .get(`/orgs/test2/members/dependabot%5Bbot%5D`) 90 | .reply(200, {}) 91 | .get(`/orgs/test3/members/dependabot%5Bbot%5D`) 92 | .reply(200, {}) 93 | 94 | const result = await client.checkOrgMembership(orgs) 95 | expect(result).toBe(true) 96 | }) 97 | 98 | test('isExcludedLogin returns true if sender.login is in the ignore list', () => { 99 | const result = client.isExcludedLogin('') 100 | expect(result).toBe(true) 101 | }) 102 | 103 | test('isExcludedLogin returns false if sender.login is not in the ignore list', () => { 104 | github.context.payload.sender!.login = 'mr-test' 105 | const result = client.isExcludedLogin('') 106 | expect(result).toBe(false) 107 | }) 108 | 109 | test('isExcludedLogin can handle additional excluded users', () => { 110 | github.context.payload.sender!.login = 'mr-test' 111 | const result = client.isExcludedLogin('mr-test') 112 | expect(result).toBe(true) 113 | }) 114 | 115 | test('createLabel should create a label', async () => { 116 | nock(apiUrl) 117 | .post('/repos/puppetlabs/iac/labels', { 118 | name: 'test', 119 | color: 'ffffff' 120 | }) 121 | .reply(200, {}) 122 | 123 | await client.createLabel('test', 'ffffff') 124 | assertInOutput(`Successfully created the 'test' label ✨${os.EOL}`, 1) 125 | }) 126 | 127 | test('createlabel should not create a label if it already exists', async () => { 128 | nock(apiUrl) 129 | .post('/repos/puppetlabs/iac/labels', { 130 | name: 'test', 131 | color: 'ffffff' 132 | }) 133 | .reply(422, { 134 | message: 'Validation Failed', 135 | errors: [ 136 | { 137 | resource: 'Label', 138 | field: 'name', 139 | code: 'already_exists' 140 | } 141 | ] 142 | }) 143 | 144 | await client.createLabel('test', 'ffffff') 145 | assertInOutput( 146 | `It looks like the label 'test' already exists in this repository so we don't need to create it! 🙌${os.EOL}`, 147 | 1 148 | ) 149 | }) 150 | 151 | test('addLabel should add a label to an issue', async () => { 152 | nock(apiUrl) 153 | .post('/repos/puppetlabs/iac/issues/331/labels', {labels: ['test']}) 154 | .reply(200, {}) 155 | 156 | await client.addLabel('test') 157 | assertInOutput( 158 | `Successfully added the 'test' label to issue 331 ✨${os.EOL}`, 159 | 1 160 | ) 161 | }) 162 | 163 | test('getLabels returns true if the label already exists on the issue or pr', () => { 164 | const result = client.getLabels() 165 | expect(result).toEqual(['community']) 166 | }) 167 | }) 168 | 169 | describe('With a new github client without label', () => { 170 | let client: GitHubClient 171 | 172 | beforeAll(() => { 173 | process.env['GITHUB_PATH'] = '' 174 | 175 | Object.defineProperty(github, 'context', { 176 | value: { 177 | eventName: 'issues', 178 | repo: { 179 | owner: 'puppetlabs', 180 | repo: 'iac' 181 | }, 182 | issue: { 183 | number: 331 184 | }, 185 | payload: { 186 | action: 'opened', 187 | sender: { 188 | login: 'dependabot[bot]' 189 | }, 190 | issue: { 191 | labels: [ 192 | ] 193 | } 194 | } 195 | } 196 | }) 197 | 198 | client = new GitHubClient('1234') 199 | console.log('::stop-commands::stoptoken') 200 | process.stdout.write = jest.fn() 201 | }) 202 | 203 | test('getLabels returns false if the label does not exist on the issue or pr', () => { 204 | const result = client.getLabels() 205 | expect(result).toBe(false) 206 | }) 207 | }) 208 | 209 | describe('With an invalid event in the payload', () => { 210 | beforeAll(() => { 211 | Object.defineProperty(github, 'context', { 212 | value: { 213 | eventName: 'test' 214 | } 215 | }) 216 | }) 217 | 218 | test('Creating a new client should throw', () => { 219 | expect(() => new GitHubClient('1234')).toThrow() 220 | }) 221 | }) 222 | 223 | function assertInOutput(text: string, position: number): void { 224 | expect(process.stdout.write).toHaveBeenNthCalledWith(position, text) 225 | } 226 | --------------------------------------------------------------------------------