├── .nvmrc ├── .eslintignore ├── __tests__ ├── no-tech-for-ice.md └── sign.test.ts ├── tsconfig.json ├── action.yml ├── jest.config.js ├── .eslintrc.json ├── package.json ├── .gitignore ├── README.md └── sign.ts /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.7.0 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !/.github 3 | __tests__/no-tech-for-ice.md -------------------------------------------------------------------------------- /__tests__/no-tech-for-ice.md: -------------------------------------------------------------------------------- 1 | Immigration and Customs Enforcement (ICE) is now able to conduct mass scale deportations because of newly acquired technology that allows them to monitor and track people like never before. 2 | 3 | 4 | * Jeff Rafter, @jeffrafter 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "esnext", 5 | "lib": ["es2015", "es2017"], 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "noImplicitAny": true, 12 | "removeComments": false, 13 | "preserveConstEnums": true 14 | }, 15 | "include": ["**/*.ts"], 16 | "exclude": ["node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: sign 2 | description: "If a user adds a comment it signs the specified file" 3 | author: "@jeffrafter" 4 | runs: 5 | using: "node12" 6 | main: "./sign.js" 7 | inputs: 8 | file-to-sign: 9 | description: Path to the file that you want signatures added onto 10 | default: README.md 11 | issue-number: 12 | description: If present only comments created on the specified issue will be used 13 | default: 1 14 | alphabetize: 15 | description: Alphabetize the names in the list by user name 16 | default: yes 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nock = require('nock') 2 | nock.disableNetConnect() 3 | 4 | const processStdoutWrite = process.stdout.write.bind(process.stdout) 5 | process.stdout.write = (str, encoding, cb) => { 6 | if (!str.match(/^##/)) { 7 | return processStdoutWrite(str, encoding, cb) 8 | } 9 | return false 10 | } 11 | 12 | module.exports = { 13 | clearMocks: true, 14 | moduleFileExtensions: ['js', 'ts'], 15 | testEnvironment: 'node', 16 | testMatch: ['**/*.test.ts'], 17 | transform: { 18 | '^.+\\.ts$': 'ts-jest', 19 | }, 20 | verbose: true, 21 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint", "prettier"], 4 | "extends": [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "prettier/@typescript-eslint", 8 | "plugin:prettier/recommended" 9 | ], 10 | "rules": { 11 | "prettier/prettier": [ 12 | "error", 13 | { 14 | "singleQuote": true, 15 | "trailingComma": "all", 16 | "bracketSpacing": false, 17 | "printWidth": 120, 18 | "tabWidth": 2, 19 | "semi": false 20 | } 21 | ], 22 | // octokit/rest requires parameters that are not in camelcase 23 | "camelcase": "off", 24 | "@typescript-eslint/camelcase": ["error", { "properties": "never" }] 25 | }, 26 | "env": { 27 | "node": true, 28 | "jest": true 29 | }, 30 | "parserOptions": { 31 | "ecmaVersion": 2018, 32 | "sourceType": "module" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sign-action", 3 | "version": "1.0.0", 4 | "description": "A bot which allows users to reply to an issue with a comment to sign a petition.", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/jeffrafter/sign-action.git" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "Hippocratic License", 12 | "bugs": { 13 | "url": "https://github.com/jeffrafter/sign-action/issues" 14 | }, 15 | "homepage": "https://github.com/jeffrafter/sign-action#readme", 16 | "scripts": { 17 | "build": "tsc", 18 | "test": "tsc --noEmit && jest", 19 | "lint": "eslint . --ext .ts" 20 | }, 21 | "devDependencies": { 22 | "@types/glob": "^7.1.1", 23 | "@types/jest": "^24.0.18", 24 | "@types/nock": "^11.1.0", 25 | "@types/node": "^12.7.5", 26 | "@typescript-eslint/eslint-plugin": "^2.2.0", 27 | "@typescript-eslint/parser": "^2.2.0", 28 | "eslint": "^6.3.0", 29 | "eslint-config-prettier": "^6.3.0", 30 | "eslint-plugin-prettier": "^3.1.0", 31 | "jest": "^24.9.0", 32 | "nock": "^11.3.4", 33 | "prettier": "^1.18.2", 34 | "ts-jest": "^24.0.2", 35 | "typescript": "^3.6.3" 36 | }, 37 | "dependencies": { 38 | "@actions/core": "^1.1.0", 39 | "@actions/github": "^1.1.0", 40 | "glob": "^7.1.4" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore test runner output 2 | __tests__/runner/* 3 | 4 | # Ignore node_modules by default (not for release) 5 | node_modules 6 | 7 | # ========================= 8 | # Node.js-Specific Ignores 9 | # ========================= 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # node-waf configuration 19 | .lock-wscript 20 | 21 | # Dependency directories 22 | jspm_packages/ 23 | 24 | # Typescript v1 declaration files 25 | typings/ 26 | 27 | # Optional npm cache directory 28 | .npm 29 | 30 | # Optional eslint cache 31 | .eslintcache 32 | 33 | # Optional REPL history 34 | .node_repl_history 35 | 36 | # Output of 'npm pack' 37 | *.tgz 38 | 39 | # Yarn Integrity file 40 | .yarn-integrity 41 | 42 | # dotenv environment variables file 43 | .env 44 | 45 | # ========================= 46 | # Operating System Files 47 | # ========================= 48 | 49 | # OSX 50 | # ========================= 51 | 52 | .DS_Store 53 | .AppleDouble 54 | .LSOverride 55 | 56 | # Thumbnails 57 | ._* 58 | 59 | # Files that might appear on external disk 60 | .Spotlight-V100 61 | .Trashes 62 | 63 | # Directories potentially created on remote AFP share 64 | .AppleDB 65 | .AppleDesktop 66 | Network Trash Folder 67 | Temporary Items 68 | .apdisk 69 | 70 | # Windows 71 | # ========================= 72 | 73 | # Windows image file caches 74 | Thumbs.db 75 | ehthumbs.db 76 | 77 | # Folder config file 78 | Desktop.ini 79 | 80 | # Recycle Bin used on file shares 81 | $RECYCLE.BIN/ 82 | 83 | # Windows Installer files 84 | *.cab 85 | *.msi 86 | *.msm 87 | *.msp 88 | 89 | # Windows shortcuts 90 | *.lnk 91 | -------------------------------------------------------------------------------- /__tests__/sign.test.ts: -------------------------------------------------------------------------------- 1 | import * as github from '@actions/github' 2 | import {WebhookPayload} from '@actions/github/lib/interfaces' 3 | import nock from 'nock' 4 | import run from '../sign' 5 | 6 | beforeEach(() => { 7 | jest.resetModules() 8 | 9 | process.env['GITHUB_REPOSITORY'] = 'example/repository' 10 | process.env['GITHUB_TOKEN'] = '12345' 11 | process.env['INPUT_FILE-TO-SIGN'] = '__tests__/no-tech-for-ice.md' 12 | process.env['INPUT_ISSUE-NUMBER'] = '1' 13 | process.env['INPUT_ALPHABETIZE'] = 'yes' 14 | 15 | // https://developer.github.com/v3/activity/events/types/#issuecommentevent 16 | github.context.payload = { 17 | action: 'created', 18 | issue: { 19 | number: 1, 20 | }, 21 | comment: { 22 | id: 1, 23 | user: { 24 | login: 'monalisa', 25 | }, 26 | body: 'Mona Lisa', 27 | }, 28 | } as WebhookPayload 29 | }) 30 | 31 | describe('sign action', () => { 32 | it('runs', async () => { 33 | const sha = 'abcdef' 34 | const content = 35 | 'Immigration and Customs Enforcement (ICE) is now able to conduct mass scale deportations because of newly acquired technology that allows them to monitor and track people like never before.\n\n\n* Jeff Rafter, @jeffrafter\n* Mona Lisa, @monalisa\n' 36 | 37 | // get ref 38 | nock('https://api.github.com') 39 | .get('/repos/example/repository/git/refs/heads/master') 40 | .reply(200, { 41 | object: { 42 | sha: sha, 43 | }, 44 | }) 45 | 46 | // get the tree 47 | nock('https://api.github.com') 48 | .get(`/repos/example/repository/git/trees/${sha}`) 49 | .reply(200, { 50 | tree: [{path: '__tests__/no-tech-for-ice.md'}], 51 | }) 52 | 53 | // post the tree 54 | const treeSha = 'abc456' 55 | nock('https://api.github.com') 56 | .post(`/repos/example/repository/git/trees`, body => { 57 | return ( 58 | body.base_tree === sha && 59 | body.tree[0].path === '__tests__/no-tech-for-ice.md' && 60 | body.tree[0].mode === '100644' && 61 | body.tree[0].type === 'blob' && 62 | body.tree[0].content === content 63 | ) 64 | }) 65 | .reply(200, { 66 | sha: treeSha, 67 | }) 68 | 69 | // create commit 70 | const commitSha = 'abc789' 71 | nock('https://api.github.com') 72 | .post(`/repos/example/repository/git/commits`, body => { 73 | return body.message === 'Add @monalisa' && body.tree === treeSha && body.parents[0] === sha 74 | }) 75 | .reply(200, { 76 | sha: commitSha, 77 | }) 78 | 79 | // update ref 80 | nock('https://api.github.com') 81 | .patch(`/repos/example/repository/git/refs/heads/master`, body => { 82 | return body.sha === commitSha 83 | }) 84 | .reply(200, { 85 | sha: commitSha, 86 | }) 87 | 88 | await run() 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | An action which allows users to reply to an issue with a comment to sign a petition. 2 | 3 | Users responding to an issue with a comment will automatically sign the petition: 4 | 5 | ![Example signature](https://user-images.githubusercontent.com/4064/66619936-90964d80-eb93-11e9-9237-f299eaf9f018.png) 6 | 7 | The action creates a new commit with the content of their comment and their user name: 8 | 9 | ``` 10 | * Jeff Rafter, @jeffrafter 11 | ``` 12 | 13 | To use this action add the following workflow to your repo at `.github/workflows/sign.yml`: 14 | 15 | ```yml 16 | name: Sign Petition 17 | on: 18 | issue_comment: 19 | types: [created] 20 | 21 | jobs: 22 | build: 23 | name: Sign Petition 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v1 27 | with: 28 | fetch-depth: 1 29 | - uses: jeffrafter/sign-action@v1 30 | with: 31 | file-to-sign: README.md 32 | issue-number: 1 33 | alphabetize: yes 34 | env: 35 | COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | ``` 38 | 39 | - `file-to-sign` - points to the file that you would like to add signatures to (defaults to `README.md`) 40 | - `issue-number` - limit comment processing to a single issue (defaults to 1; if empty all issue comments are treated as signatures) 41 | - `alphabetize` - if yes, all sinatures will be alphabetized by user name (defaults to `yes`) 42 | 43 | Your petition should take the format: 44 | 45 | Immigration and Customs Enforcement (ICE) is now able to conduct 46 | mass scale deportations because of newly acquired technology that 47 | allows them to monitor and track people like never before. 48 | 49 | Signed, 50 | 51 | 52 | * Jeff Rafter, @jeffrafter 53 | 54 | Note that the `` marker is required. 55 | 56 | To see this in use, checkout [sign-test](https://github.com/jeffrafter/sign-test/issues/1). 57 | 58 | ## Branch protections 59 | 60 | If you want to use the `COMMITTER_TOKEN` you have to [generate a personal access token](https://github.com/settings/tokens). This allows you to add branch-protections to protect master while still allowing the action to commit. 61 | 62 | ![Adding a secret COMMITTER_TOKEN](https://user-images.githubusercontent.com/4064/66620040-f4207b00-eb93-11e9-91c1-6b1c270050d3.png) 63 | 64 | ## Development 65 | 66 | Clone this repo. Then run tests: 67 | 68 | ```bash 69 | npm test 70 | ``` 71 | 72 | And lint: 73 | 74 | ``` 75 | npm run lint 76 | ``` 77 | 78 | If you want to release a new version first checkout or create the release branch 79 | 80 | ``` 81 | git checkout releases/v1 82 | ``` 83 | 84 | Then build the distribution (requires compiling the TypeScript), drop the node modules and reinstall only the production node modules, commit and push the tag: 85 | 86 | ```bash 87 | git reset --hard master 88 | rm -rf node_modules 89 | npm install 90 | npm run build 91 | rm -rf node_modules 92 | sed -i '' '/node_modules/d' .gitignore 93 | npm install --production 94 | git add . 95 | git commit -m "V1" 96 | git push -f origin releases/v1 97 | git push origin :refs/tags/v1 98 | git tag -fa v1 -m "V1" 99 | git push origin v1 100 | ``` 101 | 102 | Once complete you'll likely want to remove the production node modules and reinstall the dev dependencies. 103 | 104 | # Notes 105 | 106 | This action does not do any moderation of the signatures. 107 | 108 | Views are my own, unfortunately. 109 | 110 | # LICENCE 111 | 112 | https://firstdonoharm.dev/ 113 | 114 | Copyright 2019 Jeff Rafter 115 | 116 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 117 | 118 | - The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 119 | 120 | - The Software may not be used by individuals, corporations, governments, or other groups for systems or activities that actively and knowingly endanger, harm, or otherwise threaten the physical, mental, economic, or general well-being of individuals or groups in violation of the United Nations Universal Declaration of Human Rights (https://www.un.org/en/universal-declaration-human-rights/). 121 | 122 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 123 | 124 | This license is derived from the MIT License, as amended to limit the impact of the unethical use of open source software. 125 | -------------------------------------------------------------------------------- /sign.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import * as fs from 'fs' 4 | import * as path from 'path' 5 | import {GitCreateTreeParamsTree} from '@octokit/rest' 6 | 7 | interface TreeEntry { 8 | path?: string 9 | } 10 | 11 | const run = async (): Promise => { 12 | try { 13 | const token = process.env['COMMITTER_TOKEN'] || process.env['GITHUB_TOKEN'] 14 | if (!token) return 15 | 16 | // Create the octokit client 17 | const octokit: github.GitHub = new github.GitHub(token) 18 | const nwo = process.env['GITHUB_REPOSITORY'] || '/' 19 | const [owner, repo] = nwo.split('/') 20 | const issue = github.context.payload['issue'] 21 | if (!issue) return 22 | 23 | const comment = github.context.payload.comment 24 | const commentBody = comment.body.trim() 25 | const commentAuthor = comment.user.login 26 | 27 | // Is this limited to an issue number? 28 | const expectedIssueNumber = core.getInput('issue-number') 29 | if (expectedIssueNumber && expectedIssueNumber !== '' && expectedIssueNumber !== `${issue.number}`) { 30 | console.warn(`Comment for unexpected issue number ${issue.number}, not signing`) 31 | return 32 | } 33 | 34 | // Only match comments with single line word chars 35 | // Including "." and "-" for hypenated names and honorifics 36 | // Name must start with a word char 37 | if (!commentBody.match(/^\w[.\w\- ]+$/i)) { 38 | throw new Error( 39 | 'Comment does not appear to be a name. Only names with valid characters on a single line are accepted.', 40 | ) 41 | } 42 | console.log(`Signing ${commentBody} for ${commentAuthor}!`) 43 | 44 | // Grab the ref for a branch (master in this case) 45 | // If you already know the sha then you don't need to do this 46 | // https://developer.github.com/v3/git/refs/#get-a-reference 47 | const ref = 'heads/master' 48 | const refResponse = await octokit.git.getRef({ 49 | owner, 50 | repo, 51 | ref, 52 | }) 53 | const sha = refResponse.data.object.sha 54 | console.log({sha}) 55 | 56 | // Grab the current tree so we can see the list of paths 57 | // https://developer.github.com/v3/git/trees/#get-a-tree-recursively 58 | const baseTreeResponse = await octokit.git.getTree({ 59 | owner, 60 | repo, 61 | tree_sha: sha, 62 | }) 63 | const paths: Array = baseTreeResponse.data.tree.map((item: TreeEntry) => { 64 | return item.path 65 | }) 66 | console.log({paths}) 67 | 68 | // Keep track of the entries for this commit 69 | const tree: Array = [] 70 | 71 | // Grab the file we are adding the signature to 72 | const workspace = process.env['GITHUB_WORKSPACE'] || './' 73 | const fileToSign = core.getInput('file-to-sign') 74 | const fileToSignPath = path.join(workspace, fileToSign) 75 | 76 | let content = fs.readFileSync(fileToSignPath).toString('utf-8') 77 | let [letter, signatures] = content.split('') 78 | if (!signatures) { 79 | throw new Error('No marker found. Please add a signatures marker to your document') 80 | } 81 | 82 | // Is the signature already there? 83 | const re = new RegExp(`^\\* .* @${commentAuthor}$`, 'gm') 84 | if (signatures.match(re)) { 85 | throw new Error(`We're confused, there is already a signature for ${commentAuthor}`) 86 | } 87 | 88 | // Make sure there is a newline 89 | signatures = signatures.trim() 90 | if (signatures !== '' && !signatures.endsWith('\n')) signatures += '\n' 91 | 92 | // Put together the content with the signatures added 93 | const signature = `${commentBody}, @${commentAuthor}` 94 | signatures += `* ${signature}\n` 95 | 96 | // Sort the lines alphabetically by handle 97 | if (core.getInput('alphabetize') === 'yes') { 98 | console.log('Alphabetizing the signatures by user name') 99 | const signatureLines = signatures.trim().split('\n') 100 | signatureLines.sort((a: string, b: string) => { 101 | const handleA = a.match(/@.+$/) 102 | const handleB = b.match(/@.+$/) 103 | if (!handleA) return -1 104 | if (!handleB) return 1 105 | return handleA == handleB ? 0 : handleA < handleB ? -1 : 1 106 | }) 107 | signatures = signatureLines.join('\n') 108 | signatures += `\n` 109 | } 110 | 111 | // Join the pieces 112 | letter = `${letter}\n` 113 | content = `${letter}${signatures}` 114 | 115 | // Push the contents 116 | tree.push({ 117 | path: fileToSign, 118 | mode: '100644', 119 | type: 'blob', 120 | content: content, 121 | }) 122 | 123 | // Create the tree using the collected tree entries 124 | // https://developer.github.com/v3/git/trees/#create-a-tree 125 | const treeResponse = await octokit.git.createTree({ 126 | owner, 127 | repo, 128 | base_tree: sha, 129 | tree: tree, 130 | }) 131 | console.log({treeResponse: treeResponse.data}) 132 | 133 | // Commit that tree 134 | // https://developer.github.com/v3/git/commits/#create-a-commit 135 | const message = `Add @${commentAuthor}` 136 | const commitResponse = await octokit.git.createCommit({ 137 | owner, 138 | repo, 139 | message, 140 | tree: treeResponse.data.sha, 141 | parents: [sha], 142 | }) 143 | console.log(`Commit complete: ${commitResponse.data.sha}`) 144 | 145 | // The commit is complete but it is unreachable 146 | // We have to update master to point to it 147 | // https://developer.github.com/v3/git/refs/#update-a-reference 148 | const updateRefResponse = await octokit.git.updateRef({ 149 | owner, 150 | repo, 151 | ref: 'heads/master', 152 | sha: commitResponse.data.sha, 153 | force: false, 154 | }) 155 | console.log({updateRefResponse: updateRefResponse.data}) 156 | console.log('Done') 157 | 158 | // TODO: add a reaction to the comment 159 | } catch (error) { 160 | console.error(error.message) 161 | core.setFailed(`${error}`) 162 | } 163 | } 164 | 165 | run() 166 | 167 | export default run 168 | --------------------------------------------------------------------------------