├── .eslintrc.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.json ├── LICENSE ├── README.md ├── action.yml ├── dist └── index.js ├── guestbook.md ├── package-lock.json ├── package.json ├── src ├── book.ts ├── index.ts └── utils.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "parser": "@typescript-eslint/parser", 6 | "parserOptions": { 7 | "ecmaVersion": 2020, 8 | "sourceType": "module" 9 | }, 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/eslint-recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | "plugin:import/errors", 15 | "plugin:import/warnings", 16 | "plugin:import/typescript", 17 | "plugin:prettier/recommended" 18 | ], 19 | "plugins": [ 20 | "@typescript-eslint", 21 | "simple-import-sort" 22 | ], 23 | "rules": { 24 | "import/first": "error", 25 | "import/newline-after-import": "error", 26 | "import/no-duplicates": "error", 27 | "simple-import-sort/imports": "error", 28 | "simple-import-sort/exports": "error", 29 | "sort-imports": "off" 30 | } 31 | } -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | main: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: ./ 16 | with: 17 | issue: 1 18 | approvers: | 19 | joshmgross 20 | guestbook-path: guestbook.md 21 | - name: Update guestbook 22 | run: | 23 | if [[ `git status --porcelain` ]]; then 24 | git config --local user.email "actions@github.com" 25 | git config --local user.name "${{ github.actor }}" 26 | git add guestbook.md 27 | git commit -m "✏ Update guestbook" 28 | git push 29 | fi 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # eslint cache 5 | .eslintcache 6 | 7 | # tsc 8 | lib -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "arrowParens": "avoid", 10 | "parser": "typescript" 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Josh Gross 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Guestbook 2 | An Action 🎬 to create a Guestbook 📖✒ in your Repository 📚 3 | 4 | ## Usage 5 | 6 | ### Pre-requisites 7 | Create a workflow `.yml` file in your repositories `.github/workflows` directory. An [example workflow](#example-workflow) is available below. For more information, reference the GitHub Help Documentation for [Creating a workflow file](https://help.github.com/en/articles/configuring-a-workflow#creating-a-workflow-file). 8 | 9 | Create an issue in your repository. Any comment in this issue approved (:+1:) by an authorized user will be added to the guestbook. See https://github.com/joshmgross/guestbook/issues/1 for an example issue. 10 | 11 | ### Inputs 12 | 13 | * `issue` - The issue number to retrieve guestbook entries (required) 14 | * `token` - Authorization token used to interact with the repository and update the guestbook. Defaults to `github.token` 15 | * `approvers` - List of users allowed to approve comments for the guestbook 16 | * `guestbook-path` - File path of the guestbook 17 | 18 | ### Example Workflow 19 | ```yaml 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: joshmgross/guestbook@main 23 | with: 24 | issue: 1 25 | approvers: | 26 | joshmgross 27 | - name: Update guestbook 28 | run: | 29 | if [[ `git status --porcelain` ]]; then 30 | git config --local user.email "actions@github.com" 31 | git config --local user.name "${{ github.actor }}" 32 | git add README.md 33 | git commit -m "✏ Update guestbook" 34 | git push 35 | fi 36 | ``` 37 | 38 | See [main.yml](.github/workflows/main.yml) for a full workflow file example. 39 | 40 | In your guestbook markdown file, add comments to denote the start and end of the guestbook. Everything within these comments will be replaced by approved comments from the issue specified. 41 | ```md 42 | 43 | 44 | 45 | ``` 46 | 47 | ## Example Guestbook 48 | 49 | See [guestbook.md](./guestbook.md) for an example guestbook. 50 | 51 | ## Prior Art 🎨 52 | 53 | Inspired by [@JasonEtco](https://github.com/JasonEtco)'s [readme-guestbook](https://github.com/JasonEtco/readme-guestbook) 54 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'The Guestbook' 2 | description: 'An Action 🎬 to create a Guestbook 📖✒ in your Repository 📚' 3 | author: 'joshmgross' 4 | inputs: 5 | issue: 6 | required: true 7 | description: > 8 | The issue number to retrieve guestbook entries 9 | token: 10 | description: > 11 | Authorization token used to interact with the repository and update the guestbook 12 | default: ${{ github.token }} 13 | approvers: 14 | required: true 15 | description: > 16 | List of users allowed to approve comments for the guestbook 17 | guestbook-path: 18 | description: > 19 | File path of the guestbook 20 | 21 | runs: 22 | using: 'node20' 23 | main: 'dist/index.js' 24 | 25 | branding: 26 | icon: 'book-open' 27 | color: 'purple' 28 | -------------------------------------------------------------------------------- /guestbook.md: -------------------------------------------------------------------------------- 1 | # Example Guestbook 2 | 3 | This guestbook is populated by approved :+1: comments in https://github.com/joshmgross/guestbook/issues/1. 4 | 5 | 6 | > Hello, this is an informative and useful comment illustrating my thoughts. 🧀 7 | -[@joshmgross](https://github.com/joshmgross) 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "guestbook", 3 | "version": "0.0.3", 4 | "description": "An Action 🎬 to create a Guestbook 📖✒ in your Repository 📚", 5 | "main": "dist/index.js", 6 | "private": true, 7 | "engines": { 8 | "node": ">=20.0.0 <21.0.0" 9 | }, 10 | "scripts": { 11 | "build": "tsc", 12 | "pack": "ncc build src/index.ts", 13 | "test": "echo \"Error: no test specified\" && exit 1", 14 | "lint": "eslint **/*.ts --cache --fix", 15 | "format": "prettier --write **/*.ts", 16 | "format-check": "prettier --check **/*.ts", 17 | "all": "npm run build && npm run format && npm run lint && npm run pack" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/joshmgross/guestbook.git" 22 | }, 23 | "keywords": [ 24 | "Actions", 25 | "node" 26 | ], 27 | "author": "joshmgross", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/joshmgross/guestbook/issues" 31 | }, 32 | "homepage": "https://github.com/joshmgross/guestbook#readme", 33 | "dependencies": { 34 | "@actions/core": "^1.10.1", 35 | "@actions/github": "^6.0.0" 36 | }, 37 | "devDependencies": { 38 | "@types/node": "^20.11.8", 39 | "@typescript-eslint/eslint-plugin": "^6.19.1", 40 | "@typescript-eslint/parser": "^6.19.1", 41 | "@vercel/ncc": "^0.38.1", 42 | "eslint": "^8.56.0", 43 | "eslint-config-prettier": "^9.1.0", 44 | "eslint-plugin-import": "^2.29.1", 45 | "eslint-plugin-prettier": "^5.1.3", 46 | "eslint-plugin-simple-import-sort": "^10.0.0", 47 | "prettier": "^3.2.4", 48 | "typescript": "^5.3.3" 49 | } 50 | } -------------------------------------------------------------------------------- /src/book.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | 3 | import { Comment } from "./index"; 4 | 5 | const startComment = ""; 6 | const endComment = ""; 7 | const commentSectionRegex = new RegExp(`${startComment}[\\s\\S]+${endComment}`); 8 | 9 | function getReadme(path: string): string { 10 | return fs.readFileSync(path).toString(); 11 | } 12 | 13 | function writeReadme(path: string, content: string): void { 14 | fs.writeFileSync(path, content); 15 | } 16 | 17 | function commentToMarkdown(comment: Comment): string { 18 | const quotedComment = comment.text 19 | .split("\n") 20 | .map(line => `> ${line}`) 21 | .join("\n"); 22 | 23 | const handleLink = `[@${comment.user}](https://github.com/${comment.user})`; 24 | 25 | return `${quotedComment}\n-${handleLink}`; 26 | } 27 | 28 | function createGuestbookList(comments: Comment[]): string { 29 | return comments.map(commentToMarkdown).join("\n\n"); 30 | } 31 | 32 | export function generateGuestbook(path: string, comments: Comment[]): void { 33 | const guestbook = getReadme(path); 34 | const guestbookList = createGuestbookList(comments); 35 | const guestbookContent = `${startComment}\n${guestbookList}\n${endComment}`; 36 | const updatedGuestbook = guestbook.replace(commentSectionRegex, guestbookContent); 37 | writeReadme(path, updatedGuestbook); 38 | } 39 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import { context, getOctokit } from "@actions/github"; 3 | 4 | import { generateGuestbook } from "./book"; 5 | import * as utils from "./utils"; 6 | 7 | export interface Comment { 8 | id: number; 9 | text: string; 10 | user: string; 11 | url: string; 12 | apiUrl: string; 13 | } 14 | 15 | const approveReaction = "+1"; 16 | const defaultGuestbookPath = "README.md"; 17 | 18 | async function run(): Promise { 19 | try { 20 | // Inputs and validation 21 | const token = core.getInput("token", { required: true }); 22 | const octokit = getOctokit(token); 23 | 24 | const issue = Number(core.getInput("issue", { required: true })); 25 | if (isNaN(issue) || issue <= 0) { 26 | core.setFailed("❌ Invalid input: issue must be a valid number."); 27 | return; 28 | } 29 | 30 | const approvers = core 31 | .getInput("approvers", { required: true }) 32 | .split("\n") 33 | .map(s => s.trim()) 34 | .filter(x => x !== ""); 35 | 36 | const guestbookPath = core.getInput("guestbook-path") || defaultGuestbookPath; 37 | 38 | utils.logInfo(`Retrieving issue commments from Issue #${issue}`); 39 | 40 | const issueRequestData = { 41 | issue_number: issue, 42 | owner: context.repo.owner, 43 | repo: context.repo.repo 44 | }; 45 | 46 | const issueComments: Comment[] = []; 47 | for await (const issueCommentResponse of octokit.paginate.iterator( 48 | octokit.rest.issues.listComments, 49 | issueRequestData 50 | )) { 51 | if (issueCommentResponse.status < 200 || issueCommentResponse.status > 299) { 52 | core.error( 53 | `❌ Received error response when retrieving guestbook issue comments: ${ 54 | issueCommentResponse.status 55 | } - ${JSON.stringify(issueCommentResponse.data)}.` 56 | ); 57 | break; 58 | } 59 | 60 | issueComments.push( 61 | ...issueCommentResponse.data.map(comment => { 62 | return { 63 | id: comment.id, 64 | text: comment.body, 65 | user: comment.user?.login || "ghost", 66 | url: comment.html_url, 67 | apiUrl: comment.url 68 | } as Comment; 69 | }) 70 | ); 71 | } 72 | 73 | if (issueComments.length == 0) { 74 | core.error("❌ No issues retrieved."); 75 | return; 76 | } 77 | 78 | utils.logInfo(`Retrieved ${issueComments.length} issue comments.`); 79 | 80 | const approvedComments: Comment[] = []; 81 | for (const comment of issueComments) { 82 | console.log(`@${comment.user} said "${comment.text}"`); 83 | 84 | const commentRequestData = { comment_id: comment.id, ...issueRequestData }; 85 | for await (const reactionsResponse of octokit.paginate.iterator( 86 | octokit.rest.reactions.listForIssueComment, 87 | commentRequestData 88 | )) { 89 | if (reactionsResponse.status < 200 || reactionsResponse.status > 299) { 90 | core.error( 91 | `❌ Received error response when retrieving comment reactions: ${ 92 | reactionsResponse.status 93 | } - ${JSON.stringify(reactionsResponse.data)}.` 94 | ); 95 | break; 96 | } 97 | 98 | let commentApproved = false; 99 | for (const reaction of reactionsResponse.data) { 100 | if ( 101 | reaction.user && 102 | reaction.content == approveReaction && 103 | approvers.includes(reaction.user.login) 104 | ) { 105 | approvedComments.push(comment); 106 | utils.logInfo(`Comment approved by ${reaction.user.login}. ${comment.url}`); 107 | commentApproved = true; 108 | break; 109 | } 110 | } 111 | 112 | if (commentApproved) { 113 | break; 114 | } 115 | } 116 | } 117 | 118 | utils.logInfo("✅ Approved comments 📝:"); 119 | utils.logInfo(JSON.stringify(approvedComments)); 120 | 121 | generateGuestbook(guestbookPath, approvedComments); 122 | 123 | utils.logInfo("🎉🎈🎊 Action complete 🎉🎈🎊"); 124 | } catch (error) { 125 | core.setFailed(`❌ Action failed with error: ${error}`); 126 | } 127 | } 128 | 129 | run(); 130 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | const ColorReset = "\x1b[0m"; 2 | 3 | enum TextEffect { 4 | Bright = "\x1b[1m", 5 | Dim = "\x1b[2m", 6 | Underscore = "\x1b[4m", 7 | Blink = "\x1b[5m", 8 | Reverse = "\x1b[7m", 9 | Hidden = "\x1b[8m" 10 | } 11 | 12 | enum ForegroundColor { 13 | Black = "\x1b[30m", 14 | Red = "\x1b[31m", 15 | Green = "\x1b[32m", 16 | Yellow = "\x1b[33m", 17 | Blue = "\x1b[34m", 18 | Magenta = "\x1b[35m", 19 | Cyan = "\x1b[36m", 20 | White = "\x1b[37m" 21 | } 22 | 23 | export function logInfo(message: string): void { 24 | const textFormat = `${TextEffect.Underscore}${ForegroundColor.Cyan}`; 25 | console.log(`${textFormat}${message}${ColorReset}`); 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "outDir": "./lib", /* Redirect output structure to the directory. */ 7 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 8 | 9 | /* Strict Type-Checking Options */ 10 | "strict": true, /* Enable all strict type-checking options. */ 11 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 12 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 13 | }, 14 | "exclude": ["node_modules"] 15 | } --------------------------------------------------------------------------------