├── .github ├── renovate.json ├── stale.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── index.d.ts ├── index.js ├── package-lock.json ├── package.json └── test └── metadata.test.js /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>probot/.github" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for https://github.com/probot/stale 2 | _extends: .github 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | "on": 3 | push: 4 | branches: 5 | - master 6 | - next 7 | - beta 8 | - "*.x" 9 | jobs: 10 | release: 11 | name: release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: lts/* 18 | cache: npm 19 | - run: npm ci 20 | - run: npx semantic-release 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | NPM_TOKEN: ${{ secrets.PROBOTBOT_NPM_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | "on": 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | types: 8 | - opened 9 | - synchronize 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: 16 18 | cache: npm 19 | - run: npm install 20 | - run: npm test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2017 Brandon Keepers 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Probot: metadata 2 | 3 | A [Probot](https://github.com/probot/probot) extension to store metadata on Issues and Pull Requests. 4 | 5 | ## Usage 6 | 7 | ```js 8 | const metadata = require('probot-metadata'); 9 | 10 | // where `context` is a Probot `Context` 11 | await metadata(context).set(key, value) 12 | 13 | const value = await metadata(context).get(key) 14 | ``` 15 | 16 | ## Example 17 | 18 | ```js 19 | const metadata = require('probot-metadata'); 20 | 21 | module.exports = robot => { 22 | robot.on('issue_comment.created', async context => { 23 | match = context.payload.comment.body.match('/snooze (.*)') 24 | if(match) { 25 | metadata(context).set('snooze', match[1]) 26 | } 27 | }) 28 | } 29 | ``` 30 | 31 | ## How it works 32 | 33 | This extension is what you might call "a hack". GitHub doesn't have an API for storing metadata on Issues and Pull Requests, but it does have rather large comment fields. GitHub renders the comments as Markdown and will strip any unsupported HTML (including HTML comments like ``), but still serves up the raw comment body through the API. This extension takes advantage of this "feature" to store JSON values on Issues and Pull Requests as HTML comments. 34 | 35 | It will update the body of the original post and append an HTML comment with JSON values for each key. For example: 36 | 37 | ```markdown 38 | This is the body of the original post 39 | 40 | 41 | ``` 42 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "probot"; 2 | 3 | type StringOrNumber = number | string; 4 | type Key = 5 | | { [key: string]: StringOrNumber } 6 | | StringOrNumber[] 7 | | StringOrNumber; 8 | type Value = Key; 9 | 10 | declare function metadata( 11 | context: Context, 12 | issue?: { body: string; [key: string]: any } 13 | ): { 14 | get(key?: Key): Promise; 15 | set(key: Key, value: Value): Promise; 16 | }; 17 | 18 | export = metadata; 19 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const regex = /(\n\n|\r\n)/ 2 | 3 | module.exports = (context, issue = null) => { 4 | const github = context.octokit || context.github 5 | const prefix = context.payload.installation.id 6 | 7 | if (!issue) issue = context.issue() 8 | 9 | return { 10 | async get (key = null) { 11 | let body = issue.body 12 | 13 | if (!body) { 14 | body = (await github.issues.get(issue)).data.body || '' 15 | } 16 | 17 | const match = body.match(regex) 18 | 19 | if (match) { 20 | const data = JSON.parse(match[2])[prefix] 21 | return key ? data && data[key] : data 22 | } 23 | }, 24 | 25 | async set (key, value) { 26 | let body = issue.body 27 | let data = {} 28 | 29 | if (!body) body = (await github.issues.get(issue)).data.body || '' 30 | 31 | const match = body.match(regex) 32 | 33 | if (match) { 34 | data = JSON.parse(match[2]) 35 | } 36 | 37 | body = body.replace(regex, '') 38 | 39 | if (!data[prefix]) data[prefix] = {} 40 | 41 | if (typeof key === 'object') { 42 | Object.assign(data[prefix], key) 43 | } else { 44 | data[prefix][key] = value 45 | } 46 | 47 | body = `${body}\n\n` 48 | 49 | const { owner, repo, issue_number } = issue 50 | return github.issues.update({ owner, repo, issue_number, body }) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "probot-metadata", 3 | "version": "1.1.0", 4 | "description": "A Probot extension to store metadata on Issues and Pull Requests", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest && standard" 8 | }, 9 | "repository": "github:probot/metadata", 10 | "keywords": [ 11 | "probot" 12 | ], 13 | "author": "Brandon Keepers", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "jest": "^29.0.0", 17 | "nock": "^13.0.4", 18 | "probot": "^12.0.0", 19 | "semantic-release": "^20.0.0", 20 | "standard": "^10.0.3" 21 | }, 22 | "peerDependencies": { 23 | "probot": ">=10" 24 | }, 25 | "standard": { 26 | "env": "jest" 27 | }, 28 | "jest": { 29 | "testEnvironment": "node" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/metadata.test.js: -------------------------------------------------------------------------------- 1 | const { Context, ProbotOctokit } = require('probot') 2 | const nock = require('nock') 3 | 4 | nock.disableNetConnect() 5 | 6 | const metadata = require('..') 7 | 8 | describe('metadata', () => { 9 | let context, event 10 | 11 | beforeEach(() => { 12 | event = { 13 | payload: { 14 | issue: { number: 42 }, 15 | repository: { 16 | owner: { login: 'foo' }, 17 | name: 'bar' 18 | }, 19 | installation: { id: 1 } 20 | } 21 | } 22 | 23 | context = new Context( 24 | event, 25 | new ProbotOctokit({ 26 | throttle: { enabled: false }, 27 | retry: { enabled: false } 28 | }) 29 | ) 30 | }) 31 | 32 | describe('on issue without metdata', () => { 33 | describe('set', () => { 34 | test('sets a key', async () => { 35 | const mock = nock('https://api.github.com') 36 | .get('/repos/foo/bar/issues/42') 37 | .reply(200, { 38 | body: 'original post' 39 | }) 40 | .patch('/repos/foo/bar/issues/42', (requestBody) => { 41 | expect(requestBody.body).toEqual(`original post\n\n`) 42 | return true 43 | }) 44 | .reply(204) 45 | 46 | await metadata(context).set('key', 'value') 47 | expect(mock.activeMocks()).toStrictEqual([]) 48 | }) 49 | 50 | test('sets an object', async () => { 51 | const mock = nock('https://api.github.com') 52 | .get('/repos/foo/bar/issues/42') 53 | .reply(200, { 54 | body: 'original post' 55 | }) 56 | .patch('/repos/foo/bar/issues/42', (requestBody) => { 57 | expect(requestBody.body).toEqual(`original post\n\n`) 58 | return true 59 | }) 60 | .reply(204) 61 | 62 | await metadata(context).set({ key: 'value' }) 63 | 64 | expect(mock.activeMocks()).toStrictEqual([]) 65 | }) 66 | }) 67 | 68 | describe('get', () => { 69 | test('returns undefined', async () => { 70 | const mock = nock('https://api.github.com') 71 | .get('/repos/foo/bar/issues/42') 72 | .reply(200, { 73 | body: 'original post' 74 | }) 75 | 76 | expect(await metadata(context).get('key')).toEqual(undefined) 77 | 78 | expect(mock.activeMocks()).toStrictEqual([]) 79 | }) 80 | 81 | test('returns undefined without key', async () => { 82 | const mock = nock('https://api.github.com') 83 | .get('/repos/foo/bar/issues/42') 84 | .reply(200, { 85 | body: 'original post' 86 | }) 87 | 88 | expect(await metadata(context).get()).toEqual(undefined) 89 | 90 | expect(mock.activeMocks()).toStrictEqual([]) 91 | }) 92 | }) 93 | }) 94 | 95 | describe('on issue with existing metadata', () => { 96 | describe('set', () => { 97 | test('sets new metadata', async () => { 98 | const mock = nock('https://api.github.com') 99 | .get('/repos/foo/bar/issues/42') 100 | .reply(200, { 101 | body: 'original post\n\n' 102 | }) 103 | .patch('/repos/foo/bar/issues/42', (requestBody) => { 104 | expect(requestBody.body).toEqual('original post\n\n') 105 | return true 106 | }) 107 | .reply(204) 108 | 109 | await metadata(context).set('hello', 'world') 110 | expect(mock.activeMocks()).toStrictEqual([]) 111 | }) 112 | 113 | test('overwrites exiting metadata', async () => { 114 | const mock = nock('https://api.github.com') 115 | .get('/repos/foo/bar/issues/42') 116 | .reply(200, { 117 | body: 'original post\n\n' 118 | }) 119 | .patch('/repos/foo/bar/issues/42', (requestBody) => { 120 | expect(requestBody.body).toEqual('original post\n\n') 121 | return true 122 | }) 123 | .reply(204) 124 | 125 | await metadata(context).set('key', 'new value') 126 | expect(mock.activeMocks()).toStrictEqual([]) 127 | }) 128 | 129 | test('merges object with existing metadata', async () => { 130 | const mock = nock('https://api.github.com') 131 | .get('/repos/foo/bar/issues/42') 132 | .reply(200, { 133 | body: 'original post\n\n' 134 | }) 135 | .patch('/repos/foo/bar/issues/42', (requestBody) => { 136 | expect(requestBody.body).toEqual('original post\n\n') 137 | return true 138 | }) 139 | .reply(204) 140 | 141 | await metadata(context).set({ hello: 'world' }) 142 | expect(mock.activeMocks()).toStrictEqual([]) 143 | }) 144 | }) 145 | 146 | describe('get', () => { 147 | test('returns value', async () => { 148 | const mock = nock('https://api.github.com') 149 | .get('/repos/foo/bar/issues/42') 150 | .reply(200, { 151 | body: 'original post\n\n' 152 | }) 153 | 154 | expect(await metadata(context).get('key')).toEqual('value') 155 | 156 | expect(mock.activeMocks()).toStrictEqual([]) 157 | }) 158 | 159 | test('returns undefined for unknown key', async () => { 160 | const mock = nock('https://api.github.com') 161 | .get('/repos/foo/bar/issues/42') 162 | .reply(200, { 163 | body: 'original post\n\n' 164 | }) 165 | 166 | expect(await metadata(context).get('unknown')).toEqual(undefined) 167 | expect(mock.activeMocks()).toStrictEqual([]) 168 | }) 169 | }) 170 | }) 171 | 172 | describe('on issue with metadata for a different installation', () => { 173 | describe('set', () => { 174 | test('sets new metadata', async () => { 175 | const mock = nock('https://api.github.com') 176 | .get('/repos/foo/bar/issues/42') 177 | .reply(200, { 178 | body: 'original post\n\n' 179 | }) 180 | .patch('/repos/foo/bar/issues/42', (requestBody) => { 181 | expect(requestBody.body).toEqual('original post\n\n') 182 | return true 183 | }) 184 | .reply(204) 185 | 186 | await metadata(context).set('hello', 'world') 187 | expect(mock.activeMocks()).toStrictEqual([]) 188 | }) 189 | 190 | test('sets an object', async () => { 191 | const mock = nock('https://api.github.com') 192 | .get('/repos/foo/bar/issues/42') 193 | .reply(200, { 194 | body: 'original post\n\n' 195 | }) 196 | .patch('/repos/foo/bar/issues/42', (requestBody) => { 197 | expect(requestBody.body).toEqual('original post\n\n') 198 | return true 199 | }) 200 | .reply(204) 201 | 202 | await metadata(context).set({ hello: 'world' }) 203 | expect(mock.activeMocks()).toStrictEqual([]) 204 | }) 205 | }) 206 | 207 | describe('get', () => { 208 | test('returns undefined for unknown key', async () => { 209 | const mock = nock('https://api.github.com') 210 | .get('/repos/foo/bar/issues/42') 211 | .reply(200, { 212 | body: 'original post\n\n' 213 | }) 214 | 215 | expect(await metadata(context).get('unknown')).toEqual(undefined) 216 | 217 | expect(mock.activeMocks()).toStrictEqual([]) 218 | }) 219 | 220 | test('returns undefined without a key', async () => { 221 | const mock = nock('https://api.github.com') 222 | .get('/repos/foo/bar/issues/42') 223 | .reply(200, { 224 | body: 'original post\n\n' 225 | }) 226 | 227 | expect(await metadata(context).get()).toEqual(undefined) 228 | expect(mock.activeMocks()).toStrictEqual([]) 229 | }) 230 | }) 231 | }) 232 | 233 | describe('on an issue with no content in the body', () => { 234 | describe('set', () => { 235 | test('sets new metadata', async () => { 236 | const mock = nock('https://api.github.com') 237 | .get('/repos/foo/bar/issues/42') 238 | .reply(200, { 239 | body: null 240 | }) 241 | .patch('/repos/foo/bar/issues/42', (requestBody) => { 242 | expect(requestBody.body).toEqual('\n\n') 243 | return true 244 | }) 245 | .reply(204) 246 | 247 | await metadata(context).set('hello', 'world') 248 | expect(mock.activeMocks()).toStrictEqual([]) 249 | }) 250 | 251 | test('sets an object', async () => { 252 | const mock = nock('https://api.github.com') 253 | .get('/repos/foo/bar/issues/42') 254 | .reply(200, { 255 | body: null 256 | }) 257 | .patch('/repos/foo/bar/issues/42', (requestBody) => { 258 | expect(requestBody.body).toEqual('\n\n') 259 | return true 260 | }) 261 | .reply(204) 262 | 263 | await metadata(context).set({ hello: 'world' }) 264 | expect(mock.activeMocks()).toStrictEqual([]) 265 | }) 266 | }) 267 | 268 | describe('get', () => { 269 | test('returns undefined for unknown key', async () => { 270 | const mock = nock('https://api.github.com') 271 | .get('/repos/foo/bar/issues/42') 272 | .reply(200, { 273 | body: null 274 | }) 275 | 276 | expect(await metadata(context).get('unknown')).toEqual(undefined) 277 | expect(mock.activeMocks()).toStrictEqual([]) 278 | }) 279 | 280 | test('returns undefined without a key', async () => { 281 | const mock = nock('https://api.github.com') 282 | .get('/repos/foo/bar/issues/42') 283 | .reply(200, { 284 | body: null 285 | }) 286 | 287 | expect(await metadata(context).get()).toEqual(undefined) 288 | expect(mock.activeMocks()).toStrictEqual([]) 289 | }) 290 | }) 291 | }) 292 | 293 | describe('when given body in issue params', () => { 294 | const issue = { 295 | owner: 'foo', 296 | repo: 'bar', 297 | issue_number: 42, 298 | body: 'hello world\n\n' 299 | } 300 | 301 | describe('get', () => { 302 | test('returns the value without an API call', async () => { 303 | expect(await metadata(context, issue).get('hello')).toEqual('world') 304 | }) 305 | }) 306 | 307 | describe('set', () => { 308 | test('updates the value without an API call', async () => { 309 | const mock = nock('https://api.github.com') 310 | .patch('/repos/foo/bar/issues/42', (requestBody) => { 311 | expect(requestBody.body).toEqual('hello world\n\n') 312 | return true 313 | }) 314 | .reply(204) 315 | 316 | await metadata(context, issue).set('foo', 'bar') 317 | expect(mock.activeMocks()).toStrictEqual([]) 318 | }) 319 | }) 320 | }) 321 | }) 322 | --------------------------------------------------------------------------------