├── .github ├── ISSUE_TEMPLATE │ ├── 01_show_idea.yml │ └── 02_show.yml └── workflows │ ├── handle-show-announcement.yml │ ├── handle-show-done.yml │ ├── handle-show-issues.yml │ ├── handle-show-start.yml │ ├── handle-twitch-events.yml │ ├── parse-new-issue.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── get-schow-schedules.js ├── handle-show-announcement.js ├── handle-show-done.js ├── handle-show-start.js ├── lib └── twitter-request.js ├── netlify └── functions │ ├── ping.js │ └── twitch.js ├── package-lock.json ├── package.json ├── parse-new-show-issue.js ├── scripts └── twitch-subscriptions.js ├── test ├── fixtures │ ├── issues.closed.json │ ├── issues.opened.json │ ├── list-issues-for-update-show-sections.json │ ├── list-issues.json │ ├── new-issue-body.md │ ├── open-show-issues.json │ └── parsed-issue.json ├── get-show-schedules-test.js ├── handle-show-announcement-test.js ├── handle-show-done-test.js ├── handle-show-start-test.js ├── parse-new-show-issue-test.js └── update-show-sections-in-readmes-test.js └── update-show-sections-in-readmes.js /.github/ISSUE_TEMPLATE/01_show_idea.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "💡 Show idea" 3 | description: "What would you like to be explored during a helpdesk show" 4 | labels: idea 5 | body: 6 | - type: checkboxes 7 | id: search 8 | attributes: 9 | label: Please avoid duplicates 10 | options: 11 | - label: I checked [all past ideas and shows](https://github.com/gr2m/helpdesk/issues?q=is%3Aissue) and none of them matched my idea. 12 | required: true 13 | - type: textarea 14 | id: description 15 | attributes: 16 | label: Description 17 | description: Please describe your idea 18 | validations: 19 | required: true 20 | - type: checkboxes 21 | id: join 22 | attributes: 23 | label: Would you be interested in joining the show? 24 | options: 25 | - label: "yes" 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02_show.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "📽 Show" 3 | description: "🛑 Only to be used by Gregor" 4 | title: "DO NOT EDIT - Await parsing by GitHub Actions" 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Please do not submit an issue using this template unless you are @gr2m 11 | 12 | - type: input 13 | id: title 14 | attributes: 15 | label: Title of the show 16 | description: without any date/time prefix 17 | placeholder: "e.g. issue forms" 18 | 19 | - type: dropdown 20 | id: type 21 | attributes: 22 | label: What type of show will it be 23 | options: 24 | - automating helpdesk 25 | - anything else 26 | validations: 27 | required: true 28 | 29 | - type: input 30 | id: date 31 | attributes: 32 | label: Date of the show 33 | description: in the format `YYYY-MM-DD` 34 | 35 | - type: input 36 | id: time 37 | attributes: 38 | label: Time when the show starts 39 | description: in the format `H:mm` (24h format) 40 | 41 | - type: input 42 | id: guests 43 | attributes: 44 | label: Guests 45 | description: comma-separated list of guests' logins 46 | 47 | - type: input 48 | id: location 49 | attributes: 50 | label: Show URL 51 | description: URL to the location where the show will taking place 52 | value: https://www.twitch.tv/gregorcodes 53 | 54 | - type: input 55 | id: tags 56 | attributes: 57 | label: Tags 58 | description: comma-separated list of tags 59 | value: automation 60 | 61 | - type: textarea 62 | id: summary 63 | attributes: 64 | label: Summary 65 | description: "What will this show be about" 66 | validations: 67 | required: true 68 | 69 | - type: textarea 70 | id: outline 71 | attributes: 72 | label: Outline 73 | description: "Numbered list of sections of the upcoming show" 74 | validations: 75 | required: true 76 | 77 | - type: textarea 78 | id: todos 79 | attributes: 80 | label: Preparations 81 | description: "List of things that should be prepared before the show" 82 | -------------------------------------------------------------------------------- /.github/workflows/handle-show-announcement.yml: -------------------------------------------------------------------------------- 1 | name: Handle show announcement 2 | "on": 3 | schedule: [] 4 | workflow_dispatch: {} 5 | jobs: 6 | announce: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: lts/* 13 | - run: npm ci 14 | - run: node handle-show-announcement.js 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GR2M_PAT }} 17 | TWITTER_ACCESS_TOKEN_KEY: ${{ secrets.TWITTER_ACCESS_TOKEN_KEY }} 18 | TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} 19 | TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} 20 | TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} 21 | TWITTER_ACCOUNT_ID: ${{ secrets.TWITTER_ACCOUNT_ID }} 22 | TWITTER_USER_ID: ${{ secrets.TWITTER_USER_ID }} 23 | -------------------------------------------------------------------------------- /.github/workflows/handle-show-done.yml: -------------------------------------------------------------------------------- 1 | name: Handle show done 2 | "on": 3 | repository_dispatch: 4 | types: 5 | - twitch 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | if: github.event.client_payload.type == 'stream.offline' 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: lts/* 15 | - run: npm ci 16 | - run: node handle-show-done.js 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GR2M_PAT }} 19 | TWITTER_ACCESS_TOKEN_KEY: ${{ secrets.TWITTER_ACCESS_TOKEN_KEY }} 20 | TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} 21 | TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} 22 | TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} 23 | TWITTER_ACCOUNT_ID: ${{ secrets.TWITTER_ACCOUNT_ID }} 24 | TWITTER_USER_ID: ${{ secrets.TWITTER_USER_ID }} 25 | -------------------------------------------------------------------------------- /.github/workflows/handle-show-issues.yml: -------------------------------------------------------------------------------- 1 | name: Handle show issues 2 | "on": 3 | issues: 4 | types: 5 | - closed 6 | - edited 7 | - labeled 8 | - reopened 9 | - unlabeled 10 | workflow_dispatch: {} 11 | concurrency: 12 | group: update-shows 13 | cancel-in-progress: true 14 | jobs: 15 | update: 16 | runs-on: ubuntu-latest 17 | if: >- 18 | github.event_name == 'workflow_dispatch' || 19 | contains(github.event.issue.labels.*.name, 'show') 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: actions/setup-node@v3 23 | with: 24 | node-version: lts/* 25 | - run: npm ci 26 | - run: node update-show-sections-in-readmes.js 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GR2M_PAT }} 29 | - run: node get-schow-schedules.js 30 | id: show-schedules 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | - uses: gr2m/set-cron-schedule-action@v2 34 | with: 35 | token: ${{ secrets.GR2M_PAT_WITH_WORKFLOW_SCOPE }} 36 | cron: ${{ steps.show-schedules.outputs.schedule_start }} 37 | workflow: handle-show-start.yml 38 | - uses: gr2m/set-cron-schedule-action@v2 39 | with: 40 | token: ${{ secrets.GR2M_PAT_WITH_WORKFLOW_SCOPE }} 41 | cron: ${{ steps.show-schedules.outputs.schedule_announcement }} 42 | workflow: handle-show-announcement.yml 43 | -------------------------------------------------------------------------------- /.github/workflows/handle-show-start.yml: -------------------------------------------------------------------------------- 1 | name: Handle show start 2 | "on": 3 | schedule: [] 4 | workflow_dispatch: {} 5 | jobs: 6 | announce: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: lts/* 13 | - run: npm ci 14 | - run: node handle-show-start.js 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GR2M_PAT }} 17 | TWITTER_ACCESS_TOKEN_KEY: ${{ secrets.TWITTER_ACCESS_TOKEN_KEY }} 18 | TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} 19 | TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} 20 | TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} 21 | TWITTER_ACCOUNT_ID: ${{ secrets.TWITTER_ACCOUNT_ID }} 22 | TWITTER_USER_ID: ${{ secrets.TWITTER_USER_ID }} 23 | -------------------------------------------------------------------------------- /.github/workflows/handle-twitch-events.yml: -------------------------------------------------------------------------------- 1 | name: Handle Twitch events 2 | on: 3 | repository_dispatch: 4 | types: 5 | - twitch 6 | jobs: 7 | debug: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - run: cat $GITHUB_EVENT_PATH 11 | -------------------------------------------------------------------------------- /.github/workflows/parse-new-issue.yml: -------------------------------------------------------------------------------- 1 | name: Parse new issue 2 | "on": 3 | issues: 4 | types: 5 | - opened 6 | - edited 7 | jobs: 8 | updateIssue: 9 | runs-on: ubuntu-latest 10 | if: >- 11 | github.event.issue.title == 'DO NOT EDIT - Await parsing by GitHub 12 | Actions' && github.event.sender.login == 'gr2m' 13 | steps: 14 | - uses: actions/checkout@v3 15 | - run: cat $GITHUB_EVENT_PATH 16 | - uses: stefanbuck/github-issue-parser@v2 17 | id: issue-parser 18 | with: 19 | template-path: .github/ISSUE_TEMPLATE/02_show.yml 20 | - run: | 21 | cat < 11 | 12 | 13 | ## Upcoming shows 14 | 15 | 16 | 17 | ## Past shows 18 | 19 | - [Refactoring a 10+ years old code base: nock (Part VII)](https://github.com/gr2m/helpdesk/issues/60) 20 | - [Refactoring a 10+ years old code base: nock (Part VI)](https://github.com/gr2m/helpdesk/issues/59) 21 | - [Refactoring a 10+ years old code base: nock (Part V)](https://github.com/gr2m/helpdesk/issues/58) 22 | - [Refactoring a 10+ years old code base: nock (Part IV)](https://github.com/gr2m/helpdesk/issues/57) 23 | - [Refactoring a 10+ years old code base: nock (Part III)](https://github.com/gr2m/helpdesk/issues/55) 24 | - [Refactoring a 10+ years old code base: `nock` (Part II)](https://github.com/gr2m/helpdesk/issues/54) 25 | - [Refactoring a 10+ years old code base: `nock`](https://github.com/gr2m/helpdesk/issues/53) 26 | - [Automating gr2m/helpdesk: Twitch Events](https://github.com/gr2m/helpdesk/issues/52) 27 | - [Creating tests for actions for faster iteration Part III](https://github.com/gr2m/helpdesk/issues/49) 28 | - [Creating tests for actions for faster iteration Part II](https://github.com/gr2m/helpdesk/issues/47) 29 | - [Creating tests for actions for faster iteration](https://github.com/gr2m/helpdesk/issues/46) 30 | - [Automating gr2m/helpdesk: Issue Forms part III](https://github.com/gr2m/helpdesk/issues/45) 31 | - [Automating gr2m/helpdesk: issue forms part II](https://github.com/gr2m/helpdesk/issues/42) 32 | - [Running scheduled GitHub App tasks using Actions](https://github.com/gr2m/helpdesk/issues/38) 33 | - [Automating gr2m/helpdesk: issue forms](https://github.com/gr2m/helpdesk/issues/34) 34 | - [How to update lock files silently (Part III)](https://github.com/gr2m/helpdesk/issues/32) 35 | - [Automating gr2m/helpdesk: comment on issue when show begins](https://github.com/gr2m/helpdesk/issues/31) 36 | - [Advanced TypeScript for the future Octokit SDK](https://github.com/gr2m/helpdesk/issues/29) with [@orta](https://github.com/orta) 37 | - [Automating gr2m/helpdesk, Episode VI](https://github.com/gr2m/helpdesk/issues/27) 38 | - [Automating gr2m/helpdesk, Episode V](https://github.com/gr2m/helpdesk/issues/25) 39 | - [How to update lock files silently (Part II)](https://github.com/gr2m/helpdesk/issues/24) 40 | - [How to update lock files silently](https://github.com/gr2m/helpdesk/issues/22) 41 | - [Automating gr2m/helpdesk, Episode IV](https://github.com/gr2m/helpdesk/issues/21) 42 | - [transfer issues + comments between repositories while retaining authorship, labels, and milestones](https://github.com/gr2m/helpdesk/issues/20) 43 | - [30 Minutes to Merge: Automating nose booping using Actions](https://github.com/gr2m/helpdesk/issues/18) with [@github](https://github.com/github) 44 | - [Automating gr2m/helpdesk, Episode III](https://github.com/gr2m/helpdesk/issues/17) 45 | - [copy GitHub repositories with issues, labels, milestones, and assignees](https://github.com/gr2m/helpdesk/issues/16) 46 | - [Automating gr2m/helpdesk, Episode II](https://github.com/gr2m/helpdesk/issues/14) 47 | - [Slash commands & rebasing pull requests](https://github.com/gr2m/helpdesk/issues/13) with [@davidguttman](https://github.com/davidguttman) 48 | - [Learn with Jason: GitHub Automation with Octokit](https://github.com/gr2m/helpdesk/issues/11) with [@jlengstorf](https://github.com/jlengstorf) 49 | - [Automating gr2m/helpdesk, Episode I](https://github.com/gr2m/helpdesk/issues/10) 50 | - [Script Kit meets Octokit](https://github.com/gr2m/helpdesk/issues/8) with [@johnlindquist](https://github.com/johnlindquist) 51 | - [GitHub Action Artifacts](https://github.com/gr2m/helpdesk/issues/7) with [@reconbot](https://github.com/reconbot) 52 | - [Octokit automation: OpenAPI](https://github.com/gr2m/helpdesk/issues/5) 53 | - [Create a `cowsay` GitHub Action with JavaScript](https://github.com/gr2m/helpdesk/issues/4) 54 | - [GitHub Enterprise repository auditing](https://github.com/gr2m/helpdesk/issues/1) with [@jeffwilcox](https://github.com/jeffwilcox) 55 | 56 | 57 | 58 | 59 | ## How I use this repository 60 | 61 | I keep track of how I use this repository over time as I hope to automate most of it eventually 😂 62 | 63 | A show usually starts out with an idea on twitter, such as [the idea to audit repository access using GitHub Actions](https://mobile.twitter.com/jeffwilcox/status/1385711936541663233). If we agree to make a show about it, I turn it into an issue with a `🏷 show` label such as [📅 4/29 @ 1pm PT - GitHub Enterprise repository auditing with @jeffwilcox](https://github.com/gr2m/helpdesk/issues/1). If the idea needs some more research, I turn it into an an issue with an `🏷 idea` label such as [💡 How to use Environments + Secrets](https://github.com/gr2m/helpdesk/issues/6). 64 | 65 | I add all `🏷 show` issues to this README as well as on https://github.com/gr2m/gr2m. 66 | 67 | I use the issue for preparation, to make sure the guests (if any) and I are on the same page, and to have a rough outline of steps I want to go through during the show. I try to keep the shows to 30 minutes and getting stuck in an unforeseen problem could blow that time limit very quickly, so I like to be prepared. I invite everyone interested in the show to subscribe to the issue. 68 | 69 | When the show goes live, I add a comment with a link to [twitch.tv/gregorcodes](https://www.twitch.tv/gregorcodes), and also send a tweet that the show is going live shortly. 70 | 71 | After the show I add a comment to the Twitch recording for people who missed the live show. I also add notes from the show. 72 | 73 | Then I upload the recording to YouTube and add another comment with a link to the video on YouTube, as this one won't be removed after 14 days. 74 | 75 | After that, I close the issue, and move the show to the "Past" section in this README as well as on https://github.com/gr2m/gr2m 76 | 77 | ## Progress on automating this repository 78 | 79 | My goal is to automate everything about my helpdesk show that can be automated. You can find a list of past and upcoming shows about automating helpdesk at https://github.com/gr2m/helpdesk/issues?q=label%3A%22automating+helpdesk%22 80 | 81 | - [x] automate "Upcoming shows" / "Past shows" sections in the repository README — [#10](https://github.com/gr2m/helpdesk/issues/10) 82 | - [x] automate "Upcoming shows" / "Past shows" sections on [my profile page](https://github.com/gr2m/) — [#10](https://github.com/gr2m/helpdesk/issues/10) 83 | - [x] add a comment to the issue when I go live on twitch 84 | - [x] Schedule tweet 30 minutes before the show goes live 85 | - [x] Schedule tweet when the show goes live 86 | - [ ] add comment with a link to the twitch recording once it's available 87 | - [ ] Send out tweet when the video is available on YouTube 88 | - [ ] add a comment with a link to the video on YouTube once it's available in maximal resolution 89 | - [ ] figure out a way to populate show notes from twitch comments 90 | -------------------------------------------------------------------------------- /get-schow-schedules.js: -------------------------------------------------------------------------------- 1 | import { inspect } from "util"; 2 | import core from "@actions/core"; 3 | import { Octokit } from "@octokit/core"; 4 | import { paginateRest } from "@octokit/plugin-paginate-rest"; 5 | import dayjs from "dayjs"; 6 | import customParseFormat from "dayjs/plugin/customParseFormat.js"; 7 | import utc from "dayjs/plugin/utc.js"; 8 | import timezone from "dayjs/plugin/timezone.js"; 9 | 10 | dayjs.extend(customParseFormat); 11 | dayjs.extend(utc); 12 | dayjs.extend(timezone); 13 | 14 | if (process.env.GITHUB_ACTIONS && process.env.NODE_ENV !== "test") { 15 | // Create Octokit constructor with .paginate API and custom user agent 16 | const MyOctokit = Octokit.plugin(paginateRest).defaults({ 17 | userAgent: "gr2m-helpdesk", 18 | }); 19 | const octokit = new MyOctokit({ 20 | auth: process.env.GITHUB_TOKEN, 21 | }); 22 | run(process.env, octokit, core); 23 | } 24 | 25 | /** 26 | * @param {object} env 27 | * @param {Octokit} octokit 28 | * @param {core} core 29 | */ 30 | export async function run(env, octokit, core) { 31 | // load open issues with the `show` label 32 | const showIssues = await octokit.paginate( 33 | "GET /repos/{owner}/{repo}/issues", 34 | { 35 | owner: "gr2m", 36 | repo: "helpdesk", 37 | labels: "show", 38 | state: "open", 39 | per_page: 100, 40 | } 41 | ); 42 | 43 | const upcomingShowsCrons = showIssues 44 | .map((issue) => { 45 | const dayString = issue.body 46 | .match(/📅.*/) 47 | .pop() 48 | .replace(/📅\s*/, "") 49 | .replace(/^\w+, /, "") 50 | .trim(); 51 | const timeString = issue.body 52 | .match(/🕐[^(\r\n]+/) 53 | .pop() 54 | .replace(/🕐\s*/, "") 55 | .replace("Pacific Time", "") 56 | .trim(); 57 | 58 | // workaround: cannot parse "June 3, 2021 1:00pm" but can parse "June 3, 2021 12:00pm" 59 | // workaround: cannot set default timezone, so parse the date/time string first, then use `.tz()` with the expected date/time format 60 | let timeStringWithoutAmPm = timeString.replace(/(am|pm)\b/, ""); 61 | 62 | const tmp = dayjs( 63 | [dayString, timeStringWithoutAmPm].join(" "), 64 | // "MMMM D, YYYY H:mma", // see workaround 65 | "MMMM D, YYYY H:mm", 66 | true 67 | ); 68 | 69 | let hours = parseInt(timeStringWithoutAmPm, 10); 70 | 71 | if (hours < 9) { 72 | timeStringWithoutAmPm = timeStringWithoutAmPm.replace( 73 | hours, 74 | hours + 12 75 | ); 76 | } 77 | 78 | let time = dayjs.tz( 79 | tmp.format("YYYY-MM-DD HH:mm"), 80 | "America/Los_Angeles" 81 | ); 82 | 83 | // see workaround above. Parsing am/pm is not working 84 | if (time.get("hour") < 8) { 85 | time = time.add(12, "hours"); 86 | } 87 | 88 | if (time.toISOString() < dayjs().toISOString()) 89 | // ignore open issues for shows that are in the past 90 | return; 91 | 92 | return { 93 | start: time 94 | .subtract(time.utcOffset() + 3, "minutes") 95 | .format("m H D M [*]"), 96 | announcement: time 97 | .subtract(time.utcOffset() + 33, "minutes") 98 | .format("m H D M [*]"), 99 | }; 100 | }) 101 | .filter(Boolean); 102 | 103 | core.info( 104 | `CRON schedule for upcoming shows is: ${inspect(upcomingShowsCrons)}` 105 | ); 106 | core.setOutput( 107 | "schedule_start", 108 | upcomingShowsCrons.map((schedule) => schedule.start).join("\n") 109 | ); 110 | core.setOutput( 111 | "schedule_announcement", 112 | upcomingShowsCrons.map((schedule) => schedule.announcement).join("\n") 113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /handle-show-announcement.js: -------------------------------------------------------------------------------- 1 | import core from "@actions/core"; 2 | import { Octokit } from "@octokit/core"; 3 | import { paginateRest } from "@octokit/plugin-paginate-rest"; 4 | import dayjs from "dayjs"; 5 | import customParseFormat from "dayjs/plugin/customParseFormat.js"; 6 | import utc from "dayjs/plugin/utc.js"; 7 | import timezone from "dayjs/plugin/timezone.js"; 8 | 9 | import { twitterRequest } from "./lib/twitter-request.js"; 10 | 11 | dayjs.extend(customParseFormat); 12 | dayjs.extend(utc); 13 | dayjs.extend(timezone); 14 | 15 | if (process.env.GITHUB_ACTIONS && process.env.NODE_ENV !== "test") { 16 | // Create Octokit constructor with .paginate API and custom user agent 17 | const MyOctokit = Octokit.plugin(paginateRest).defaults({ 18 | userAgent: "gr2m-helpdesk", 19 | }); 20 | const octokit = new MyOctokit({ 21 | auth: process.env.GITHUB_TOKEN, 22 | }); 23 | run(process.env, core, octokit, twitterRequest); 24 | } 25 | 26 | export async function run(env, core, octokit, twitterRequest) { 27 | // load open issues with the `show` label 28 | const showIssues = await octokit.paginate( 29 | "GET /repos/{owner}/{repo}/issues", 30 | { 31 | owner: "gr2m", 32 | repo: "helpdesk", 33 | labels: "show", 34 | state: "open", 35 | per_page: 100, 36 | } 37 | ); 38 | 39 | const currentShowIssue = showIssues.find((issue) => { 40 | const dayString = issue.body 41 | .match(/📅.*/) 42 | .pop() 43 | .replace(/📅\s*/, "") 44 | .replace(/^\w+, /, "") 45 | .trim(); 46 | const timeString = issue.body 47 | .match(/🕐[^(\r\n]+/) 48 | .pop() 49 | .replace(/🕐\s*/, "") 50 | .replace("Pacific Time", "") 51 | .trim(); 52 | 53 | // workaround: cannot parse "June 3, 2021 1:00pm" but can parse "June 3, 2021 12:00pm" 54 | // workaround: cannot set default timezone, so parse the date/time string first, then use `.tz()` with the expected date/time format 55 | let timeStringWithoutAmPm = timeString.replace(/(am|pm)\b/, ""); 56 | 57 | let hours = parseInt(timeStringWithoutAmPm, 10); 58 | 59 | if (hours < 9) { 60 | timeStringWithoutAmPm = timeStringWithoutAmPm.replace(hours, hours + 12); 61 | } 62 | 63 | const tmp = dayjs( 64 | [dayString, timeStringWithoutAmPm].join(" "), 65 | // "MMMM D, YYYY H:mma", // see workaround 66 | "MMMM D, YYYY H:mm", 67 | true 68 | ); 69 | 70 | let time = dayjs.tz(tmp.format("YYYY-MM-DD HH:mm"), "America/Los_Angeles"); 71 | 72 | const showIsWithinRange = 73 | time.subtract(30, "minutes") > dayjs().subtract(25, "minutes") && 74 | time.subtract(30, "minutes") < dayjs().add(25, "minutes"); 75 | 76 | return showIsWithinRange; 77 | }); 78 | 79 | if (!currentShowIssue) { 80 | core.setFailed("No current issue found to comment on"); 81 | process.exit(1); 82 | } 83 | 84 | const [, , title, , guest] = currentShowIssue.title.split(/ (- |with @)/g); 85 | 86 | const currentShow = { 87 | title, 88 | number: currentShowIssue.number, 89 | issue: currentShowIssue, 90 | guest, 91 | url: currentShowIssue.html_url, 92 | }; 93 | 94 | // add comment on issue 95 | const { 96 | data: { html_url: commentUrl }, 97 | } = await octokit.request( 98 | "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", 99 | { 100 | owner: "gr2m", 101 | repo: "helpdesk", 102 | issue_number: currentShow.number, 103 | body: "Going live in 30 minutes at https://twitch.tv/gregorcodes", 104 | } 105 | ); 106 | core.info(`Comment created at ${commentUrl}`); 107 | 108 | // Tweet out that the show is live: 109 | const auth = { 110 | consumerKey: env.TWITTER_CONSUMER_KEY, 111 | consumerSecret: env.TWITTER_CONSUMER_SECRET, 112 | accessTokenKey: env.TWITTER_ACCESS_TOKEN_KEY, 113 | accessTokenSecret: env.TWITTER_ACCESS_TOKEN_SECRET, 114 | }; 115 | 116 | const tweetText = `📯 Starting in 30 minutes 117 | 118 | 💁🏻‍♂️ ${currentShow.title} 119 | 🔴 Watch live at https://twitch.tv/gregorcodes 120 | 121 | ${currentShow.url}`; 122 | 123 | const data = await twitterRequest(`POST statuses/update.json`, { 124 | auth, 125 | status: tweetText, 126 | }); 127 | const tweetUrl = `https://twitter.com/gr2m/status/${data.id_str}`; 128 | 129 | core.info(`Tweeted at ${tweetUrl}`); 130 | 131 | // update TODOs in issue 132 | await octokit.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", { 133 | owner: "gr2m", 134 | repo: "helpdesk", 135 | issue_number: currentShow.number, 136 | body: currentShow.issue.body 137 | .replace( 138 | /- \[ \] ([^\n]+)/, 139 | `- [x] $1 (${tweetUrl})` 140 | ) 141 | .replace( 142 | /- \[ \] ([^\n]+)/, 143 | `- [x] $1 (${commentUrl})` 144 | ), 145 | }); 146 | 147 | core.info(`TODOs in issue updated: ${currentShow.url}`); 148 | } 149 | -------------------------------------------------------------------------------- /handle-show-done.js: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises"; 2 | 3 | import core from "@actions/core"; 4 | import { Octokit } from "@octokit/core"; 5 | import { paginateRest } from "@octokit/plugin-paginate-rest"; 6 | import dayjs from "dayjs"; 7 | import customParseFormat from "dayjs/plugin/customParseFormat.js"; 8 | import utc from "dayjs/plugin/utc.js"; 9 | import timezone from "dayjs/plugin/timezone.js"; 10 | 11 | import { twitterRequest } from "./lib/twitter-request.js"; 12 | 13 | dayjs.extend(customParseFormat); 14 | dayjs.extend(utc); 15 | dayjs.extend(timezone); 16 | 17 | if (process.env.GITHUB_ACTIONS && process.env.NODE_ENV !== "test") { 18 | // Create Octokit constructor with .paginate API and custom user agent 19 | const MyOctokit = Octokit.plugin(paginateRest).defaults({ 20 | userAgent: "gr2m-helpdesk", 21 | }); 22 | const octokit = new MyOctokit({ 23 | auth: process.env.GITHUB_TOKEN, 24 | }); 25 | run(process.env, core, octokit, twitterRequest); 26 | } 27 | 28 | /** 29 | * 30 | * @param {NodeJS.ProcessEnv} env 31 | * @param {core} core 32 | * @param {Octokit} octokit 33 | * @param {any} twitterRequest 34 | */ 35 | export async function run(env, core, octokit, twitterRequest) { 36 | // load open issues with the `show` label 37 | const showIssues = await octokit.paginate( 38 | "GET /repos/{owner}/{repo}/issues", 39 | { 40 | owner: "gr2m", 41 | repo: "helpdesk", 42 | labels: "show", 43 | state: "open", 44 | per_page: 100, 45 | } 46 | ); 47 | 48 | const currentShowIssue = showIssues.find((issue) => { 49 | const dayString = issue.body 50 | .match(/📅.*/) 51 | .pop() 52 | .replace(/📅\s*/, "") 53 | .replace(/^\w+, /, "") 54 | .trim(); 55 | const timeString = issue.body 56 | .match(/🕐[^(\r\n]+/) 57 | .pop() 58 | .replace(/🕐\s*/, "") 59 | .replace("Pacific Time", "") 60 | .trim(); 61 | 62 | // workaround: cannot parse "June 3, 2021 1:00pm" but can parse "June 3, 2021 12:00pm" 63 | // workaround: cannot set default timezone, so parse the date/time string first, then use `.tz()` with the expected date/time format 64 | let timeStringWithoutAmPm = timeString.replace(/(am|pm)\b/, ""); 65 | 66 | let hours = parseInt(timeStringWithoutAmPm, 10); 67 | 68 | if (hours < 9) { 69 | timeStringWithoutAmPm = timeStringWithoutAmPm.replace(hours, hours + 12); 70 | } 71 | 72 | const tmp = dayjs( 73 | [dayString, timeStringWithoutAmPm].join(" "), 74 | // "MMMM D, YYYY H:mma", // see workaround 75 | "MMMM D, YYYY H:mm", 76 | true 77 | ); 78 | 79 | let time = dayjs.tz(tmp.format("YYYY-MM-DD HH:mm"), "America/Los_Angeles"); 80 | 81 | const showIsWithinRange = 82 | time < dayjs().add(5, "hours") && time > dayjs().subtract(15, "minutes"); 83 | 84 | return showIsWithinRange; 85 | }); 86 | 87 | if (!currentShowIssue) { 88 | core.setFailed("No current issue found to comment on"); 89 | process.exit(1); 90 | } 91 | 92 | const [, , title, , guest] = currentShowIssue.title.split(/ (- |with @)/g); 93 | 94 | const currentShow = { 95 | title, 96 | number: currentShowIssue.number, 97 | issue: currentShowIssue, 98 | guest, 99 | url: currentShowIssue.html_url, 100 | }; 101 | 102 | // add comment on issue 103 | const { 104 | data: { html_url: commentUrl }, 105 | } = await octokit.request( 106 | "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", 107 | { 108 | owner: "gr2m", 109 | repo: "helpdesk", 110 | issue_number: currentShow.number, 111 | body: "Show is done for today, thank you all! Recording is coming up in a moment", 112 | } 113 | ); 114 | core.info(`Comment created at ${commentUrl}`); 115 | 116 | // update twitter profile 117 | // https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/post-account-update_profile 118 | const auth = { 119 | consumerKey: env.TWITTER_CONSUMER_KEY, 120 | consumerSecret: env.TWITTER_CONSUMER_SECRET, 121 | accessTokenKey: env.TWITTER_ACCESS_TOKEN_KEY, 122 | accessTokenSecret: env.TWITTER_ACCESS_TOKEN_SECRET, 123 | }; 124 | 125 | await twitterRequest(`POST account/update_profile.json`, { 126 | auth, 127 | name: "Gregor", 128 | url: "https://github.com/gr2m/", 129 | }); 130 | 131 | core.info("Twitter profile reverted to default"); 132 | 133 | // update TODOs in issue 134 | await octokit.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", { 135 | owner: "gr2m", 136 | repo: "helpdesk", 137 | issue_number: currentShow.number, 138 | body: currentShow.issue.body.replace( 139 | /- \[ \] ([^\n]+)/, 140 | "- [x] $1 (https://twitter.com/gr2m)" 141 | ), 142 | state: "closed", 143 | }); 144 | 145 | core.info(`TODOs in issue updated, issue closed: ${currentShow.url}`); 146 | } 147 | -------------------------------------------------------------------------------- /handle-show-start.js: -------------------------------------------------------------------------------- 1 | import core from "@actions/core"; 2 | import { Octokit } from "@octokit/core"; 3 | import { paginateRest } from "@octokit/plugin-paginate-rest"; 4 | import dayjs from "dayjs"; 5 | import customParseFormat from "dayjs/plugin/customParseFormat.js"; 6 | import utc from "dayjs/plugin/utc.js"; 7 | import timezone from "dayjs/plugin/timezone.js"; 8 | 9 | import { twitterRequest } from "./lib/twitter-request.js"; 10 | 11 | dayjs.extend(customParseFormat); 12 | dayjs.extend(utc); 13 | dayjs.extend(timezone); 14 | 15 | if (process.env.GITHUB_ACTIONS && process.env.NODE_ENV !== "test") { 16 | // Create Octokit constructor with .paginate API and custom user agent 17 | const MyOctokit = Octokit.plugin(paginateRest).defaults({ 18 | userAgent: "gr2m-helpdesk", 19 | }); 20 | const octokit = new MyOctokit({ 21 | auth: process.env.GITHUB_TOKEN, 22 | }); 23 | run(process.env, core, octokit, twitterRequest); 24 | } 25 | 26 | export async function run(env, core, octokit, twitterRequest) { 27 | // load open issues with the `show` label 28 | const showIssues = await octokit.paginate( 29 | "GET /repos/{owner}/{repo}/issues", 30 | { 31 | owner: "gr2m", 32 | repo: "helpdesk", 33 | labels: "show", 34 | state: "open", 35 | per_page: 100, 36 | } 37 | ); 38 | 39 | const currentShowIssue = showIssues.find((issue) => { 40 | const dayString = issue.body 41 | .match(/📅.*/) 42 | .pop() 43 | .replace(/📅\s*/, "") 44 | .replace(/^\w+, /, "") 45 | .trim(); 46 | const timeString = issue.body 47 | .match(/🕐[^(\r\n]+/) 48 | .pop() 49 | .replace(/🕐\s*/, "") 50 | .replace("Pacific Time", "") 51 | .trim(); 52 | 53 | // workaround: cannot parse "June 3, 2021 1:00pm" but can parse "June 3, 2021 12:00pm" 54 | // workaround: cannot set default timezone, so parse the date/time string first, then use `.tz()` with the expected date/time format 55 | let timeStringWithoutAmPm = timeString.replace(/(am|pm)\b/, ""); 56 | 57 | let hours = parseInt(timeStringWithoutAmPm, 10); 58 | 59 | if (hours < 9) { 60 | timeStringWithoutAmPm = timeStringWithoutAmPm.replace(hours, hours + 12); 61 | } 62 | 63 | const tmp = dayjs( 64 | [dayString, timeStringWithoutAmPm].join(" "), 65 | // "MMMM D, YYYY H:mma", // see workaround 66 | "MMMM D, YYYY H:mm", 67 | true 68 | ); 69 | 70 | let time = dayjs.tz(tmp.format("YYYY-MM-DD HH:mm"), "America/Los_Angeles"); 71 | 72 | const showIsWithinRange = 73 | time < dayjs().add(15, "minutes") && 74 | time > dayjs().subtract(15, "minutes"); 75 | return showIsWithinRange; 76 | }); 77 | 78 | if (!currentShowIssue) { 79 | core.setFailed("No current issue found to comment on"); 80 | process.exit(1); 81 | } 82 | 83 | const [, , title, , guest] = currentShowIssue.title.split(/ (- |with @)/g); 84 | 85 | const currentShow = { 86 | title, 87 | number: currentShowIssue.number, 88 | issue: currentShowIssue, 89 | guest, 90 | url: currentShowIssue.html_url, 91 | }; 92 | 93 | // add comment on issue 94 | const { 95 | data: { html_url: commentUrl }, 96 | } = await octokit.request( 97 | "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", 98 | { 99 | owner: "gr2m", 100 | repo: "helpdesk", 101 | issue_number: currentShow.number, 102 | body: "I'm now live on https://twitch.tv/gregorcodes", 103 | } 104 | ); 105 | core.info(`Comment created at ${commentUrl}`); 106 | 107 | // Tweet out that the show is live: 108 | const auth = { 109 | consumerKey: env.TWITTER_CONSUMER_KEY, 110 | consumerSecret: env.TWITTER_CONSUMER_SECRET, 111 | accessTokenKey: env.TWITTER_ACCESS_TOKEN_KEY, 112 | accessTokenSecret: env.TWITTER_ACCESS_TOKEN_SECRET, 113 | }; 114 | 115 | const tweetText = `🔴 Now live at https://twitch.tv/gregorcodes 116 | 117 | 💁🏻‍♂️ ${currentShow.title} 118 | 119 | ${currentShow.url}`; 120 | 121 | const data = await twitterRequest(`POST statuses/update.json`, { 122 | auth, 123 | status: tweetText, 124 | }); 125 | 126 | const tweetUrl = `https://twitter.com/gr2m/status/${data.id_str}`; 127 | core.info(`Tweeted at ${tweetUrl}`); 128 | 129 | // update twitter profile 130 | // https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/post-account-update_profile 131 | await twitterRequest(`POST account/update_profile.json`, { 132 | auth, 133 | name: "🔴 Gregor is now live on twitch.tv/gregorcodes", 134 | url: "https://twitch.tv/gregorcodes", 135 | }); 136 | 137 | core.info("Twitter profile updated to link to twitch.tv/gregorcodes"); 138 | 139 | // update TODOs in issue 140 | await octokit.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", { 141 | owner: "gr2m", 142 | repo: "helpdesk", 143 | issue_number: currentShow.number, 144 | body: currentShow.issue.body 145 | .replace( 146 | /- \[ \] ([^\n]+)/, 147 | `- [x] $1 (${tweetUrl})` 148 | ) 149 | .replace( 150 | /- \[ \] ([^\n]+)/, 151 | `- [x] $1 (${commentUrl})` 152 | ) 153 | .replace( 154 | /- \[ \] ([^\n]+)/, 155 | "- [x] $1 (https://twitter.com/gr2m)" 156 | ), 157 | }); 158 | 159 | core.info(`TODOs in issue updated: ${currentShow.url}`); 160 | } 161 | -------------------------------------------------------------------------------- /lib/twitter-request.js: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import OAuth from "oauth-1.0a"; 3 | import dotenv from "dotenv"; 4 | import fetch from "node-fetch"; 5 | 6 | dotenv.config(); 7 | 8 | // EXAMPLE 9 | // 10 | // const auth = { 11 | // consumerKey: process.env.TWITTER_CONSUMER_KEY, 12 | // consumerSecret: process.env.TWITTER_CONSUMER_SECRET, 13 | // accessTokenKey: process.env.TWITTER_ACCESS_TOKEN_KEY, 14 | // accessTokenSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET, 15 | // } 16 | 17 | // const scheduledTweets = await twitterRequest( 18 | // "GET accounts/:account_id/scheduled_tweets", 19 | // { 20 | // auth, 21 | // account_id: process.env.TWITTER_ACCOUNT_ID, 22 | // } 23 | // ); 24 | 25 | // await twitterRequest( 26 | // "DELETE accounts/:account_id/scheduled_tweets/:scheduled_tweet_id", 27 | // { 28 | // auth, 29 | // account_id: process.env.TWITTER_ACCOUNT_ID, 30 | // scheduled_tweet_id: scheduledTweets[0].id_str, // 1394340865699504000 31 | // } 32 | // ); 33 | 34 | const DEFAULTS = { 35 | subdomain: "api", 36 | version: 1.1, 37 | auth: {}, 38 | }; 39 | 40 | export async function twitterRequest(route, options = {}) { 41 | const { 42 | subdomain, 43 | version, 44 | auth: { consumerKey, consumerSecret, accessTokenKey, accessTokenSecret }, 45 | ...parameters 46 | } = { ...DEFAULTS, ...options }; 47 | 48 | const [method, pathTemplate] = route.split(" "); 49 | 50 | const path = pathTemplate.replace(/:\w+/g, (match) => { 51 | const key = match.substr(1); 52 | if (!parameters[key]) { 53 | throw new Error(`${match} option not set for ${route} request`); 54 | } 55 | 56 | const value = parameters[key]; 57 | delete parameters[key]; 58 | 59 | return value; 60 | }); 61 | 62 | const url = withQueryParameters( 63 | `https://${subdomain}.twitter.com/${version}/${path}`, 64 | parameters 65 | ); 66 | 67 | const client = createOAuthClient({ 68 | key: consumerKey, 69 | secret: consumerSecret, 70 | }); 71 | 72 | const authHeaders = client.toHeader( 73 | client.authorize( 74 | { 75 | method, 76 | url, 77 | }, 78 | { 79 | key: accessTokenKey, 80 | secret: accessTokenSecret, 81 | } 82 | ) 83 | ); 84 | 85 | const response = await fetch(url, { 86 | method, 87 | headers: { 88 | ...options.headers, 89 | ...authHeaders, 90 | }, 91 | }); 92 | 93 | const text = await response.text(); 94 | 95 | try { 96 | const { data, error, errors, ...result } = JSON.parse(text); 97 | 98 | if (error || errors) { 99 | throw new Error( 100 | JSON.stringify( 101 | { 102 | error: error || errors, 103 | method, 104 | url, 105 | parameters, 106 | }, 107 | null, 108 | 2 109 | ) 110 | ); 111 | } 112 | 113 | return data || result; 114 | } catch (error) { 115 | error.response = { 116 | headers: Object.fromEntries(response.headers.entries()), 117 | body: text, 118 | }; 119 | throw error; 120 | } 121 | } 122 | 123 | function createOAuthClient({ key, secret }) { 124 | const client = OAuth({ 125 | consumer: { key, secret }, 126 | signature_method: "HMAC-SHA1", 127 | hash_function(baseString, key) { 128 | return crypto.createHmac("sha1", key).update(baseString).digest("base64"); 129 | }, 130 | }); 131 | 132 | return client; 133 | } 134 | 135 | function withQueryParameters(url, parameters) { 136 | const separator = /\?/.test(url) ? "&" : "?"; 137 | const names = Object.keys(parameters); 138 | 139 | if (names.length === 0) { 140 | return url; 141 | } 142 | 143 | return ( 144 | url + 145 | separator + 146 | names 147 | .map((name) => { 148 | const encoded = encodeURIComponent(parameters[name]) 149 | .replace(/\(/g, "%28") 150 | .replace(/\)/g, "%29"); 151 | return `${name}=${encoded}`; 152 | }) 153 | .join("&") 154 | ); 155 | } 156 | -------------------------------------------------------------------------------- /netlify/functions/ping.js: -------------------------------------------------------------------------------- 1 | exports.handler = async function handler() { 2 | return { 3 | statusCode: 200, 4 | body: "pong", 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /netlify/functions/twitch.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const crypto = require("crypto"); 4 | 5 | const { Octokit } = require("@octokit/core"); 6 | const dotenv = require("dotenv"); 7 | dotenv.config(); 8 | 9 | const SUPPORTED_EVENT_TYPES = ["stream.online", "stream.offline"]; 10 | 11 | // Some useful twitch commands to test the twitch function: 12 | // 13 | // - First, install the Twitch CLI: https://github.com/twitchdev/twitch-cli#readme 14 | // - Send request to test subscription verification 15 | // ``` 16 | // twitch event verify-subscription subscribe -F http://localhost:8888/.netlify/functions/twitch 17 | // ``` 18 | // - Get user account ID by login 19 | // ``` 20 | // twitch api get users -q login=gregorcodes 21 | // ``` 22 | // - Trigger a "stream.online" event 23 | // ``` 24 | // twitch event trigger streamup -F http://localhost:8888/.netlify/functions/twitch -s secret -t 581268875 25 | // ``` 26 | // - Trigger a "stream.offline" event 27 | // ``` 28 | // twitch event trigger streamdown -F http://localhost:8888/.netlify/functions/twitch -s secret -t 581268875 29 | // ``` 30 | 31 | /** 32 | * @param {import("@netlify/functions").HandlerEvent} event 33 | * @param {import("@netlify/functions").HandlerContext} context 34 | * 35 | * @returns {Promise} 36 | */ 37 | exports.handler = async function handler(event, context) { 38 | if (event.httpMethod !== "POST") { 39 | return { 40 | statusCode: 405, 41 | body: `Method Not Allowed: ${event.httpMethod}`, 42 | }; 43 | } 44 | 45 | const body = JSON.parse(event.body); 46 | 47 | // responde to twitch subscribe challenge 48 | const messageType = event.headers["twitch-eventsub-message-type"]; 49 | if (messageType === "webhook_callback_verification") { 50 | console.log("Verifying Webhook"); 51 | return { 52 | statusCode: 200, 53 | body: body.challenge, 54 | }; 55 | } 56 | 57 | const messageId = event.headers["twitch-eventsub-message-id"]; 58 | const timestamp = event.headers["twitch-eventsub-message-timestamp"]; 59 | const signature = event.headers["twitch-eventsub-message-signature"]; 60 | 61 | if ( 62 | !signatureIsValid({ 63 | messageId, 64 | timestamp, 65 | signature, 66 | body: event.body, 67 | secret: process.env.TWITCH_APP_EVENTSUB_SECRET, 68 | }) 69 | ) { 70 | console.log("Signature could not be verified"); 71 | return { 72 | statusCode: 401, 73 | body: "Unauthorized", 74 | }; 75 | } 76 | console.log("Signature verified"); 77 | 78 | const { type } = body.subscription; 79 | 80 | if (!SUPPORTED_EVENT_TYPES.includes(type)) { 81 | console.log( 82 | `Received ${type} event, but only supporting: ${SUPPORTED_EVENT_TYPES.join( 83 | ", " 84 | )}` 85 | ); 86 | return { statusCode: 404, body: "unsupported event type" }; 87 | } 88 | 89 | if ( 90 | body.event.broadcaster_user_id !== process.env.TWITCH_GREGORCODES_USER_ID 91 | ) { 92 | console.log( 93 | `Received event for user @${body.event.broadcaster_user_login} (#${body.event.broadcaster_user_id}), which is not @gregorcodes` 94 | ); 95 | return { statusCode: 404, body: "unsupported user account" }; 96 | } 97 | 98 | const octokit = new Octokit({ 99 | auth: process.env.GITHUB_TOKEN, 100 | }); 101 | 102 | await octokit.request("POST /repos/{owner}/{repo}/dispatches", { 103 | owner: "gr2m", 104 | repo: "helpdesk", 105 | event_type: "twitch", 106 | client_payload: { 107 | type, 108 | }, 109 | }); 110 | 111 | console.log("Gregor is %s", type === "stream.online" ? "online" : "offline"); 112 | 113 | return { 114 | statusCode: 200, 115 | body: "ok", 116 | }; 117 | }; 118 | 119 | function signatureIsValid({ messageId, timestamp, signature, body, secret }) { 120 | if (!secret) { 121 | throw new Error("Twitch signing secret is empty."); 122 | } 123 | 124 | if (Math.abs(Date.now() - timestamp) > 600) { 125 | throw new Error("Timestamp is older than 10 minutes."); 126 | } 127 | 128 | const computedSignature = 129 | "sha256=" + 130 | crypto 131 | .createHmac("sha256", secret) 132 | .update(messageId + timestamp + body) 133 | .digest("hex"); 134 | 135 | return signature === computedSignature; 136 | } 137 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "helpdesk", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "helpdesk", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@actions/core": "^1.4.0", 13 | "@netlify/functions": "^0.7.2", 14 | "@octokit/core": "^3.4.0", 15 | "@octokit/plugin-paginate-rest": "^2.13.3", 16 | "dayjs": "^1.10.5", 17 | "dotenv": "^10.0.0", 18 | "oauth-1.0a": "^2.2.6", 19 | "prettier": "^2.3.2", 20 | "readme-box": "^1.0.0" 21 | }, 22 | "devDependencies": { 23 | "mockdate": "^3.0.5", 24 | "uvu": "^0.5.1" 25 | } 26 | }, 27 | "node_modules/@actions/core": { 28 | "version": "1.4.0", 29 | "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.4.0.tgz", 30 | "integrity": "sha512-CGx2ilGq5i7zSLgiiGUtBCxhRRxibJYU6Fim0Q1Wg2aQL2LTnF27zbqZOrxfvFQ55eSBW0L8uVStgtKMpa0Qlg==" 31 | }, 32 | "node_modules/@netlify/functions": { 33 | "version": "0.7.2", 34 | "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-0.7.2.tgz", 35 | "integrity": "sha512-xf45ZqQukMxmlkqNMC5BXdFMaVZ8VqF42MV5zA5nKVOh2V0mhYlcbTYlVbS/K2/rtvQ3W8lxxixYl4NT7kq6Bg==", 36 | "dependencies": { 37 | "is-promise": "^4.0.0" 38 | }, 39 | "engines": { 40 | "node": ">=8.3.0" 41 | } 42 | }, 43 | "node_modules/@octokit/auth-token": { 44 | "version": "2.4.5", 45 | "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.5.tgz", 46 | "integrity": "sha512-BpGYsPgJt05M7/L/5FoE1PiAbdxXFZkX/3kDYcsvd1v6UhlnE5e96dTDr0ezX/EFwciQxf3cNV0loipsURU+WA==", 47 | "dependencies": { 48 | "@octokit/types": "^6.0.3" 49 | } 50 | }, 51 | "node_modules/@octokit/core": { 52 | "version": "3.4.0", 53 | "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.4.0.tgz", 54 | "integrity": "sha512-6/vlKPP8NF17cgYXqucdshWqmMZGXkuvtcrWCgU5NOI0Pl2GjlmZyWgBMrU8zJ3v2MJlM6++CiB45VKYmhiWWg==", 55 | "dependencies": { 56 | "@octokit/auth-token": "^2.4.4", 57 | "@octokit/graphql": "^4.5.8", 58 | "@octokit/request": "^5.4.12", 59 | "@octokit/request-error": "^2.0.5", 60 | "@octokit/types": "^6.0.3", 61 | "before-after-hook": "^2.2.0", 62 | "universal-user-agent": "^6.0.0" 63 | } 64 | }, 65 | "node_modules/@octokit/endpoint": { 66 | "version": "6.0.11", 67 | "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.11.tgz", 68 | "integrity": "sha512-fUIPpx+pZyoLW4GCs3yMnlj2LfoXTWDUVPTC4V3MUEKZm48W+XYpeWSZCv+vYF1ZABUm2CqnDVf1sFtIYrj7KQ==", 69 | "dependencies": { 70 | "@octokit/types": "^6.0.3", 71 | "is-plain-object": "^5.0.0", 72 | "universal-user-agent": "^6.0.0" 73 | } 74 | }, 75 | "node_modules/@octokit/graphql": { 76 | "version": "4.6.1", 77 | "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.6.1.tgz", 78 | "integrity": "sha512-2lYlvf4YTDgZCTXTW4+OX+9WTLFtEUc6hGm4qM1nlZjzxj+arizM4aHWzBVBCxY9glh7GIs0WEuiSgbVzv8cmA==", 79 | "dependencies": { 80 | "@octokit/request": "^5.3.0", 81 | "@octokit/types": "^6.0.3", 82 | "universal-user-agent": "^6.0.0" 83 | } 84 | }, 85 | "node_modules/@octokit/openapi-types": { 86 | "version": "7.0.0", 87 | "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-7.0.0.tgz", 88 | "integrity": "sha512-gV/8DJhAL/04zjTI95a7FhQwS6jlEE0W/7xeYAzuArD0KVAVWDLP2f3vi98hs3HLTczxXdRK/mF0tRoQPpolEw==" 89 | }, 90 | "node_modules/@octokit/plugin-paginate-rest": { 91 | "version": "2.13.3", 92 | "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.13.3.tgz", 93 | "integrity": "sha512-46lptzM9lTeSmIBt/sVP/FLSTPGx6DCzAdSX3PfeJ3mTf4h9sGC26WpaQzMEq/Z44cOcmx8VsOhO+uEgE3cjYg==", 94 | "dependencies": { 95 | "@octokit/types": "^6.11.0" 96 | }, 97 | "peerDependencies": { 98 | "@octokit/core": ">=2" 99 | } 100 | }, 101 | "node_modules/@octokit/request": { 102 | "version": "5.4.15", 103 | "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.4.15.tgz", 104 | "integrity": "sha512-6UnZfZzLwNhdLRreOtTkT9n57ZwulCve8q3IT/Z477vThu6snfdkBuhxnChpOKNGxcQ71ow561Qoa6uqLdPtag==", 105 | "dependencies": { 106 | "@octokit/endpoint": "^6.0.1", 107 | "@octokit/request-error": "^2.0.0", 108 | "@octokit/types": "^6.7.1", 109 | "is-plain-object": "^5.0.0", 110 | "node-fetch": "^2.6.1", 111 | "universal-user-agent": "^6.0.0" 112 | } 113 | }, 114 | "node_modules/@octokit/request-error": { 115 | "version": "2.0.5", 116 | "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.0.5.tgz", 117 | "integrity": "sha512-T/2wcCFyM7SkXzNoyVNWjyVlUwBvW3igM3Btr/eKYiPmucXTtkxt2RBsf6gn3LTzaLSLTQtNmvg+dGsOxQrjZg==", 118 | "dependencies": { 119 | "@octokit/types": "^6.0.3", 120 | "deprecation": "^2.0.0", 121 | "once": "^1.4.0" 122 | } 123 | }, 124 | "node_modules/@octokit/types": { 125 | "version": "6.14.2", 126 | "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.14.2.tgz", 127 | "integrity": "sha512-wiQtW9ZSy4OvgQ09iQOdyXYNN60GqjCL/UdMsepDr1Gr0QzpW6irIKbH3REuAHXAhxkEk9/F2a3Gcs1P6kW5jA==", 128 | "dependencies": { 129 | "@octokit/openapi-types": "^7.0.0" 130 | } 131 | }, 132 | "node_modules/before-after-hook": { 133 | "version": "2.2.1", 134 | "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.1.tgz", 135 | "integrity": "sha512-/6FKxSTWoJdbsLDF8tdIjaRiFXiE6UHsEHE3OPI/cwPURCVi1ukP0gmLn7XWEiFk5TcwQjjY5PWsU+j+tgXgmw==" 136 | }, 137 | "node_modules/dayjs": { 138 | "version": "1.10.5", 139 | "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.5.tgz", 140 | "integrity": "sha512-BUFis41ikLz+65iH6LHQCDm4YPMj5r1YFLdupPIyM4SGcXMmtiLQ7U37i+hGS8urIuqe7I/ou3IS1jVc4nbN4g==" 141 | }, 142 | "node_modules/deprecation": { 143 | "version": "2.3.1", 144 | "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", 145 | "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" 146 | }, 147 | "node_modules/dequal": { 148 | "version": "2.0.2", 149 | "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.2.tgz", 150 | "integrity": "sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==", 151 | "dev": true, 152 | "engines": { 153 | "node": ">=6" 154 | } 155 | }, 156 | "node_modules/diff": { 157 | "version": "5.0.0", 158 | "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", 159 | "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", 160 | "dev": true, 161 | "engines": { 162 | "node": ">=0.3.1" 163 | } 164 | }, 165 | "node_modules/dotenv": { 166 | "version": "10.0.0", 167 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", 168 | "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", 169 | "engines": { 170 | "node": ">=10" 171 | } 172 | }, 173 | "node_modules/is-plain-object": { 174 | "version": "5.0.0", 175 | "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", 176 | "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", 177 | "engines": { 178 | "node": ">=0.10.0" 179 | } 180 | }, 181 | "node_modules/is-promise": { 182 | "version": "4.0.0", 183 | "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", 184 | "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" 185 | }, 186 | "node_modules/kleur": { 187 | "version": "4.1.4", 188 | "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.4.tgz", 189 | "integrity": "sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==", 190 | "dev": true, 191 | "engines": { 192 | "node": ">=6" 193 | } 194 | }, 195 | "node_modules/mockdate": { 196 | "version": "3.0.5", 197 | "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", 198 | "integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==", 199 | "dev": true 200 | }, 201 | "node_modules/mri": { 202 | "version": "1.1.6", 203 | "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.6.tgz", 204 | "integrity": "sha512-oi1b3MfbyGa7FJMP9GmLTttni5JoICpYBRlq+x5V16fZbLsnL9N3wFqqIm/nIG43FjUFkFh9Epzp/kzUGUnJxQ==", 205 | "dev": true, 206 | "engines": { 207 | "node": ">=4" 208 | } 209 | }, 210 | "node_modules/node-fetch": { 211 | "version": "2.6.1", 212 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", 213 | "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", 214 | "engines": { 215 | "node": "4.x || >=6.0.0" 216 | } 217 | }, 218 | "node_modules/oauth-1.0a": { 219 | "version": "2.2.6", 220 | "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", 221 | "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==" 222 | }, 223 | "node_modules/once": { 224 | "version": "1.4.0", 225 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 226 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 227 | "dependencies": { 228 | "wrappy": "1" 229 | } 230 | }, 231 | "node_modules/prettier": { 232 | "version": "2.3.2", 233 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz", 234 | "integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==", 235 | "bin": { 236 | "prettier": "bin-prettier.js" 237 | }, 238 | "engines": { 239 | "node": ">=10.13.0" 240 | } 241 | }, 242 | "node_modules/readme-box": { 243 | "version": "1.0.0", 244 | "resolved": "https://registry.npmjs.org/readme-box/-/readme-box-1.0.0.tgz", 245 | "integrity": "sha512-K21EJmOeXf112jmIFI95b8m4swqqOWY62wBJ/h8QexWKhDBXoWlQ6REAYiQCO9vjPOJLxNBbXe44IGIrRPkRpw==", 246 | "dependencies": { 247 | "@octokit/request": "^5.6.0" 248 | } 249 | }, 250 | "node_modules/readme-box/node_modules/@octokit/openapi-types": { 251 | "version": "7.3.2", 252 | "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-7.3.2.tgz", 253 | "integrity": "sha512-oJhK/yhl9Gt430OrZOzAl2wJqR0No9445vmZ9Ey8GjUZUpwuu/vmEFP0TDhDXdpGDoxD6/EIFHJEcY8nHXpDTA==" 254 | }, 255 | "node_modules/readme-box/node_modules/@octokit/request": { 256 | "version": "5.6.0", 257 | "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.0.tgz", 258 | "integrity": "sha512-4cPp/N+NqmaGQwbh3vUsYqokQIzt7VjsgTYVXiwpUP2pxd5YiZB2XuTedbb0SPtv9XS7nzAKjAuQxmY8/aZkiA==", 259 | "dependencies": { 260 | "@octokit/endpoint": "^6.0.1", 261 | "@octokit/request-error": "^2.1.0", 262 | "@octokit/types": "^6.16.1", 263 | "is-plain-object": "^5.0.0", 264 | "node-fetch": "^2.6.1", 265 | "universal-user-agent": "^6.0.0" 266 | } 267 | }, 268 | "node_modules/readme-box/node_modules/@octokit/request-error": { 269 | "version": "2.1.0", 270 | "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", 271 | "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", 272 | "dependencies": { 273 | "@octokit/types": "^6.0.3", 274 | "deprecation": "^2.0.0", 275 | "once": "^1.4.0" 276 | } 277 | }, 278 | "node_modules/readme-box/node_modules/@octokit/types": { 279 | "version": "6.16.4", 280 | "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.16.4.tgz", 281 | "integrity": "sha512-UxhWCdSzloULfUyamfOg4dJxV9B+XjgrIZscI0VCbp4eNrjmorGEw+4qdwcpTsu6DIrm9tQsFQS2pK5QkqQ04A==", 282 | "dependencies": { 283 | "@octokit/openapi-types": "^7.3.2" 284 | } 285 | }, 286 | "node_modules/sade": { 287 | "version": "1.7.4", 288 | "resolved": "https://registry.npmjs.org/sade/-/sade-1.7.4.tgz", 289 | "integrity": "sha512-y5yauMD93rX840MwUJr7C1ysLFBgMspsdTo4UVrDg3fXDvtwOyIqykhVAAm6fk/3au77773itJStObgK+LKaiA==", 290 | "dev": true, 291 | "dependencies": { 292 | "mri": "^1.1.0" 293 | }, 294 | "engines": { 295 | "node": ">= 6" 296 | } 297 | }, 298 | "node_modules/totalist": { 299 | "version": "2.0.0", 300 | "resolved": "https://registry.npmjs.org/totalist/-/totalist-2.0.0.tgz", 301 | "integrity": "sha512-+Y17F0YzxfACxTyjfhnJQEe7afPA0GSpYlFkl2VFMxYP7jshQf9gXV7cH47EfToBumFThfKBvfAcoUn6fdNeRQ==", 302 | "dev": true, 303 | "engines": { 304 | "node": ">=6" 305 | } 306 | }, 307 | "node_modules/universal-user-agent": { 308 | "version": "6.0.0", 309 | "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", 310 | "integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==" 311 | }, 312 | "node_modules/uvu": { 313 | "version": "0.5.1", 314 | "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.1.tgz", 315 | "integrity": "sha512-JGxttnOGDFs77FaZ0yMUHIzczzQ5R1IlDeNW6Wymw6gAscwMdAffVOP6TlxLIfReZyK8tahoGwWZaTCJzNFDkg==", 316 | "dev": true, 317 | "dependencies": { 318 | "dequal": "^2.0.0", 319 | "diff": "^5.0.0", 320 | "kleur": "^4.0.3", 321 | "sade": "^1.7.3", 322 | "totalist": "^2.0.0" 323 | }, 324 | "bin": { 325 | "uvu": "bin.js" 326 | }, 327 | "engines": { 328 | "node": ">=8" 329 | } 330 | }, 331 | "node_modules/wrappy": { 332 | "version": "1.0.2", 333 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 334 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" 335 | } 336 | }, 337 | "dependencies": { 338 | "@actions/core": { 339 | "version": "1.4.0", 340 | "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.4.0.tgz", 341 | "integrity": "sha512-CGx2ilGq5i7zSLgiiGUtBCxhRRxibJYU6Fim0Q1Wg2aQL2LTnF27zbqZOrxfvFQ55eSBW0L8uVStgtKMpa0Qlg==" 342 | }, 343 | "@netlify/functions": { 344 | "version": "0.7.2", 345 | "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-0.7.2.tgz", 346 | "integrity": "sha512-xf45ZqQukMxmlkqNMC5BXdFMaVZ8VqF42MV5zA5nKVOh2V0mhYlcbTYlVbS/K2/rtvQ3W8lxxixYl4NT7kq6Bg==", 347 | "requires": { 348 | "is-promise": "^4.0.0" 349 | } 350 | }, 351 | "@octokit/auth-token": { 352 | "version": "2.4.5", 353 | "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.5.tgz", 354 | "integrity": "sha512-BpGYsPgJt05M7/L/5FoE1PiAbdxXFZkX/3kDYcsvd1v6UhlnE5e96dTDr0ezX/EFwciQxf3cNV0loipsURU+WA==", 355 | "requires": { 356 | "@octokit/types": "^6.0.3" 357 | } 358 | }, 359 | "@octokit/core": { 360 | "version": "3.4.0", 361 | "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.4.0.tgz", 362 | "integrity": "sha512-6/vlKPP8NF17cgYXqucdshWqmMZGXkuvtcrWCgU5NOI0Pl2GjlmZyWgBMrU8zJ3v2MJlM6++CiB45VKYmhiWWg==", 363 | "requires": { 364 | "@octokit/auth-token": "^2.4.4", 365 | "@octokit/graphql": "^4.5.8", 366 | "@octokit/request": "^5.4.12", 367 | "@octokit/request-error": "^2.0.5", 368 | "@octokit/types": "^6.0.3", 369 | "before-after-hook": "^2.2.0", 370 | "universal-user-agent": "^6.0.0" 371 | } 372 | }, 373 | "@octokit/endpoint": { 374 | "version": "6.0.11", 375 | "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.11.tgz", 376 | "integrity": "sha512-fUIPpx+pZyoLW4GCs3yMnlj2LfoXTWDUVPTC4V3MUEKZm48W+XYpeWSZCv+vYF1ZABUm2CqnDVf1sFtIYrj7KQ==", 377 | "requires": { 378 | "@octokit/types": "^6.0.3", 379 | "is-plain-object": "^5.0.0", 380 | "universal-user-agent": "^6.0.0" 381 | } 382 | }, 383 | "@octokit/graphql": { 384 | "version": "4.6.1", 385 | "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.6.1.tgz", 386 | "integrity": "sha512-2lYlvf4YTDgZCTXTW4+OX+9WTLFtEUc6hGm4qM1nlZjzxj+arizM4aHWzBVBCxY9glh7GIs0WEuiSgbVzv8cmA==", 387 | "requires": { 388 | "@octokit/request": "^5.3.0", 389 | "@octokit/types": "^6.0.3", 390 | "universal-user-agent": "^6.0.0" 391 | } 392 | }, 393 | "@octokit/openapi-types": { 394 | "version": "7.0.0", 395 | "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-7.0.0.tgz", 396 | "integrity": "sha512-gV/8DJhAL/04zjTI95a7FhQwS6jlEE0W/7xeYAzuArD0KVAVWDLP2f3vi98hs3HLTczxXdRK/mF0tRoQPpolEw==" 397 | }, 398 | "@octokit/plugin-paginate-rest": { 399 | "version": "2.13.3", 400 | "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.13.3.tgz", 401 | "integrity": "sha512-46lptzM9lTeSmIBt/sVP/FLSTPGx6DCzAdSX3PfeJ3mTf4h9sGC26WpaQzMEq/Z44cOcmx8VsOhO+uEgE3cjYg==", 402 | "requires": { 403 | "@octokit/types": "^6.11.0" 404 | } 405 | }, 406 | "@octokit/request": { 407 | "version": "5.4.15", 408 | "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.4.15.tgz", 409 | "integrity": "sha512-6UnZfZzLwNhdLRreOtTkT9n57ZwulCve8q3IT/Z477vThu6snfdkBuhxnChpOKNGxcQ71ow561Qoa6uqLdPtag==", 410 | "requires": { 411 | "@octokit/endpoint": "^6.0.1", 412 | "@octokit/request-error": "^2.0.0", 413 | "@octokit/types": "^6.7.1", 414 | "is-plain-object": "^5.0.0", 415 | "node-fetch": "^2.6.1", 416 | "universal-user-agent": "^6.0.0" 417 | } 418 | }, 419 | "@octokit/request-error": { 420 | "version": "2.0.5", 421 | "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.0.5.tgz", 422 | "integrity": "sha512-T/2wcCFyM7SkXzNoyVNWjyVlUwBvW3igM3Btr/eKYiPmucXTtkxt2RBsf6gn3LTzaLSLTQtNmvg+dGsOxQrjZg==", 423 | "requires": { 424 | "@octokit/types": "^6.0.3", 425 | "deprecation": "^2.0.0", 426 | "once": "^1.4.0" 427 | } 428 | }, 429 | "@octokit/types": { 430 | "version": "6.14.2", 431 | "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.14.2.tgz", 432 | "integrity": "sha512-wiQtW9ZSy4OvgQ09iQOdyXYNN60GqjCL/UdMsepDr1Gr0QzpW6irIKbH3REuAHXAhxkEk9/F2a3Gcs1P6kW5jA==", 433 | "requires": { 434 | "@octokit/openapi-types": "^7.0.0" 435 | } 436 | }, 437 | "before-after-hook": { 438 | "version": "2.2.1", 439 | "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.1.tgz", 440 | "integrity": "sha512-/6FKxSTWoJdbsLDF8tdIjaRiFXiE6UHsEHE3OPI/cwPURCVi1ukP0gmLn7XWEiFk5TcwQjjY5PWsU+j+tgXgmw==" 441 | }, 442 | "dayjs": { 443 | "version": "1.10.5", 444 | "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.5.tgz", 445 | "integrity": "sha512-BUFis41ikLz+65iH6LHQCDm4YPMj5r1YFLdupPIyM4SGcXMmtiLQ7U37i+hGS8urIuqe7I/ou3IS1jVc4nbN4g==" 446 | }, 447 | "deprecation": { 448 | "version": "2.3.1", 449 | "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", 450 | "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" 451 | }, 452 | "dequal": { 453 | "version": "2.0.2", 454 | "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.2.tgz", 455 | "integrity": "sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==", 456 | "dev": true 457 | }, 458 | "diff": { 459 | "version": "5.0.0", 460 | "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", 461 | "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", 462 | "dev": true 463 | }, 464 | "dotenv": { 465 | "version": "10.0.0", 466 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", 467 | "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==" 468 | }, 469 | "is-plain-object": { 470 | "version": "5.0.0", 471 | "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", 472 | "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" 473 | }, 474 | "is-promise": { 475 | "version": "4.0.0", 476 | "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", 477 | "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" 478 | }, 479 | "kleur": { 480 | "version": "4.1.4", 481 | "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.4.tgz", 482 | "integrity": "sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==", 483 | "dev": true 484 | }, 485 | "mockdate": { 486 | "version": "3.0.5", 487 | "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", 488 | "integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==", 489 | "dev": true 490 | }, 491 | "mri": { 492 | "version": "1.1.6", 493 | "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.6.tgz", 494 | "integrity": "sha512-oi1b3MfbyGa7FJMP9GmLTttni5JoICpYBRlq+x5V16fZbLsnL9N3wFqqIm/nIG43FjUFkFh9Epzp/kzUGUnJxQ==", 495 | "dev": true 496 | }, 497 | "node-fetch": { 498 | "version": "2.6.1", 499 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", 500 | "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" 501 | }, 502 | "oauth-1.0a": { 503 | "version": "2.2.6", 504 | "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", 505 | "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==" 506 | }, 507 | "once": { 508 | "version": "1.4.0", 509 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 510 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 511 | "requires": { 512 | "wrappy": "1" 513 | } 514 | }, 515 | "prettier": { 516 | "version": "2.3.2", 517 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz", 518 | "integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==" 519 | }, 520 | "readme-box": { 521 | "version": "1.0.0", 522 | "resolved": "https://registry.npmjs.org/readme-box/-/readme-box-1.0.0.tgz", 523 | "integrity": "sha512-K21EJmOeXf112jmIFI95b8m4swqqOWY62wBJ/h8QexWKhDBXoWlQ6REAYiQCO9vjPOJLxNBbXe44IGIrRPkRpw==", 524 | "requires": { 525 | "@octokit/request": "^5.6.0" 526 | }, 527 | "dependencies": { 528 | "@octokit/openapi-types": { 529 | "version": "7.3.2", 530 | "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-7.3.2.tgz", 531 | "integrity": "sha512-oJhK/yhl9Gt430OrZOzAl2wJqR0No9445vmZ9Ey8GjUZUpwuu/vmEFP0TDhDXdpGDoxD6/EIFHJEcY8nHXpDTA==" 532 | }, 533 | "@octokit/request": { 534 | "version": "5.6.0", 535 | "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.0.tgz", 536 | "integrity": "sha512-4cPp/N+NqmaGQwbh3vUsYqokQIzt7VjsgTYVXiwpUP2pxd5YiZB2XuTedbb0SPtv9XS7nzAKjAuQxmY8/aZkiA==", 537 | "requires": { 538 | "@octokit/endpoint": "^6.0.1", 539 | "@octokit/request-error": "^2.1.0", 540 | "@octokit/types": "^6.16.1", 541 | "is-plain-object": "^5.0.0", 542 | "node-fetch": "^2.6.1", 543 | "universal-user-agent": "^6.0.0" 544 | } 545 | }, 546 | "@octokit/request-error": { 547 | "version": "2.1.0", 548 | "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", 549 | "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", 550 | "requires": { 551 | "@octokit/types": "^6.0.3", 552 | "deprecation": "^2.0.0", 553 | "once": "^1.4.0" 554 | } 555 | }, 556 | "@octokit/types": { 557 | "version": "6.16.4", 558 | "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.16.4.tgz", 559 | "integrity": "sha512-UxhWCdSzloULfUyamfOg4dJxV9B+XjgrIZscI0VCbp4eNrjmorGEw+4qdwcpTsu6DIrm9tQsFQS2pK5QkqQ04A==", 560 | "requires": { 561 | "@octokit/openapi-types": "^7.3.2" 562 | } 563 | } 564 | } 565 | }, 566 | "sade": { 567 | "version": "1.7.4", 568 | "resolved": "https://registry.npmjs.org/sade/-/sade-1.7.4.tgz", 569 | "integrity": "sha512-y5yauMD93rX840MwUJr7C1ysLFBgMspsdTo4UVrDg3fXDvtwOyIqykhVAAm6fk/3au77773itJStObgK+LKaiA==", 570 | "dev": true, 571 | "requires": { 572 | "mri": "^1.1.0" 573 | } 574 | }, 575 | "totalist": { 576 | "version": "2.0.0", 577 | "resolved": "https://registry.npmjs.org/totalist/-/totalist-2.0.0.tgz", 578 | "integrity": "sha512-+Y17F0YzxfACxTyjfhnJQEe7afPA0GSpYlFkl2VFMxYP7jshQf9gXV7cH47EfToBumFThfKBvfAcoUn6fdNeRQ==", 579 | "dev": true 580 | }, 581 | "universal-user-agent": { 582 | "version": "6.0.0", 583 | "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", 584 | "integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==" 585 | }, 586 | "uvu": { 587 | "version": "0.5.1", 588 | "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.1.tgz", 589 | "integrity": "sha512-JGxttnOGDFs77FaZ0yMUHIzczzQ5R1IlDeNW6Wymw6gAscwMdAffVOP6TlxLIfReZyK8tahoGwWZaTCJzNFDkg==", 590 | "dev": true, 591 | "requires": { 592 | "dequal": "^2.0.0", 593 | "diff": "^5.0.0", 594 | "kleur": "^4.0.3", 595 | "sade": "^1.7.3", 596 | "totalist": "^2.0.0" 597 | } 598 | }, 599 | "wrappy": { 600 | "version": "1.0.2", 601 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 602 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" 603 | } 604 | } 605 | } 606 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "helpdesk", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "description": "Answering all your (and some of my own) GitHub API/automation questions live at [twitch.tv/gregorcodes](https://www.twitch.tv/gregorcodes)", 7 | "scripts": { 8 | "test": "uvu test" 9 | }, 10 | "repository": "https://github.com/gr2m/helpdesk", 11 | "keywords": [], 12 | "author": "Gregor Martynus (https://twitter.com/gr2m)", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@actions/core": "^1.4.0", 16 | "@netlify/functions": "^0.7.2", 17 | "@octokit/core": "^3.4.0", 18 | "@octokit/plugin-paginate-rest": "^2.13.3", 19 | "dayjs": "^1.10.5", 20 | "dotenv": "^10.0.0", 21 | "oauth-1.0a": "^2.2.6", 22 | "prettier": "^2.3.2", 23 | "readme-box": "^1.0.0" 24 | }, 25 | "devDependencies": { 26 | "mockdate": "^3.0.5", 27 | "uvu": "^0.5.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /parse-new-show-issue.js: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises"; 2 | 3 | import { Octokit } from "@octokit/core"; 4 | import dayjs from "dayjs"; 5 | import prettier from "prettier"; 6 | 7 | if (process.env.GITHUB_ACTIONS && process.env.NODE_ENV !== "test") { 8 | const octokit = new Octokit({ 9 | auth: process.env.GITHUB_TOKEN, 10 | }); 11 | 12 | delete process.env.GITHUB_TOKEN; 13 | run(process.env, octokit); 14 | } 15 | 16 | /** 17 | * @param {object} env 18 | * @param {Octokit} octokit 19 | */ 20 | export async function run(env, octokit) { 21 | const event = JSON.parse(await readFile(env.GITHUB_EVENT_PATH)); 22 | const [owner, repo] = event.repository.full_name.split("/"); 23 | const parsedIssue = JSON.parse(env.PARSED_ISSUE_JSON); 24 | const [year, month, day] = parsedIssue.date.split("-"); 25 | const [hoursString, minutes] = parsedIssue.time.split(":"); 26 | const isoHours = parseInt(hoursString, 10); 27 | 28 | const hours = isoHours > 12 ? isoHours - 12 : isoHours; 29 | const amOrPm = isoHours > 11 ? "pm" : "am"; 30 | const isAutomatingHelpdesk = parsedIssue.type === "automating helpdesk"; 31 | const titlePrefix = isAutomatingHelpdesk ? "Automating gr2m/helpdesk: " : ""; 32 | const showTitle = titlePrefix + parsedIssue.title; 33 | const guests = parsedIssue.guests 34 | ? parsedIssue.guests 35 | .split(/,\s+/) 36 | .map((guest) => `@${guest}`) 37 | .join(", ") 38 | : "_no guests_"; 39 | 40 | const title = `📅 ${parseInt(month)}/${parseInt( 41 | day 42 | )} @ ${hours}:${minutes}${amOrPm} PT - ${showTitle}`; 43 | const body = `💁🏻 **${showTitle}** 44 | 📅 ${dayjs(parsedIssue.date).format("dddd, MMMM D, YYYY")} 45 | 🕐 ${hours}:${minutes}am Pacific Time 46 | 🎙️ ${guests} 47 | 📍 ${parsedIssue.location} 48 | 🏷️ ${parsedIssue.tags} 49 | 50 | --- 51 | 52 | Subscribe to this issues to get a notification before the show begins and a summary after the show concludes. 53 | 54 | ### ${showTitle} 55 | 56 | ${parsedIssue.summary} 57 | 58 | #### Outline 59 | 60 | ${parsedIssue.outline} 61 | 62 | #### TODOs 63 | 64 | Before the show 65 | 66 | ${parsedIssue.todos} 67 | - [ ] 30 minute announcement tweet 68 | - [ ] 30 minute announcement comment 69 | 70 | When show begins 71 | 72 | - [ ] start of show tweet 73 | - [ ] comment on issue 74 | - [ ] Set twitter profile url 75 | 76 | After the show 77 | 78 | - [ ] Reset twitter profile after the show 79 | - [ ] recording available tweet 80 | 81 | 82 | #### Recording 83 | 84 | _will be added after the show_ 85 | 86 | 87 | #### Shownotes 88 | 89 | _will be added after the show_`; 90 | 91 | octokit.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", { 92 | owner, 93 | repo, 94 | issue_number: event.issue.number, 95 | title, 96 | body: prettier.format(body, { parser: "markdown" }), 97 | labels: ["show"], 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /scripts/twitch-subscriptions.js: -------------------------------------------------------------------------------- 1 | import { URL } from "node:url"; 2 | import { inspect } from "node:util"; 3 | 4 | import fetch from "node-fetch"; 5 | import dotenv from "dotenv"; 6 | dotenv.config(); 7 | 8 | const SUPPORTED_COMMANDS = ["list", "create", "delete"]; 9 | 10 | run(); 11 | 12 | async function run() { 13 | const [command, ...args] = process.argv.slice(2); 14 | 15 | if (!SUPPORTED_COMMANDS.includes(command)) { 16 | console.log("command %s not supported", command); 17 | return; 18 | } 19 | 20 | const { access_token } = await authenticate(); 21 | 22 | if (command === "list") { 23 | const subscriptions = await getSubscriptions(access_token); 24 | console.log(inspect(subscriptions, { depth: Infinity })); 25 | 26 | return; 27 | } 28 | 29 | if (command === "create") { 30 | const callback = args[0]; 31 | 32 | if (!callback) { 33 | console.log("You must set a callback URL as 2nd argument"); 34 | console.log( 35 | "node scripts/twitch-subscriptions.js create https://admiring-fermat-27876f-01e5b8.netlify.live" 36 | ); 37 | return; 38 | } 39 | 40 | const baseBody = { 41 | version: "1", 42 | condition: { 43 | broadcaster_user_id: process.env.TWITCH_GREGORCODES_USER_ID, 44 | }, 45 | transport: { 46 | method: "webhook", 47 | callback: callback + "/.netlify/functions/twitch", 48 | secret: process.env.TWITCH_APP_EVENTSUB_SECRET, 49 | }, 50 | }; 51 | 52 | const headers = { 53 | authorization: `Bearer ${access_token}`, 54 | "client-id": process.env.TWITCH_APP_CLIENT_ID, 55 | "content-type": "application/json", 56 | }; 57 | 58 | await fetch("https://api.twitch.tv/helix/eventsub/subscriptions", { 59 | method: "POST", 60 | headers, 61 | body: JSON.stringify({ 62 | ...baseBody, 63 | type: "stream.online", 64 | }), 65 | }); 66 | await fetch("https://api.twitch.tv/helix/eventsub/subscriptions", { 67 | method: "POST", 68 | headers, 69 | body: JSON.stringify({ 70 | ...baseBody, 71 | type: "stream.offline", 72 | }), 73 | }); 74 | 75 | console.log("Subscriptions created"); 76 | return; 77 | } 78 | 79 | if (command === "delete") { 80 | const callback = args[0]; 81 | 82 | if (!callback) { 83 | console.log("You must set a callback URL as 2nd argument"); 84 | console.log( 85 | "node scripts/twitch-subscriptions.js delete https://admiring-fermat-27876f-01e5b8.netlify.live" 86 | ); 87 | return; 88 | } 89 | 90 | const subscriptions = await getSubscriptions(access_token); 91 | 92 | const subscriptionsForUrl = subscriptions.filter((subscription) => 93 | subscription.transport.callback.startsWith(callback) 94 | ); 95 | 96 | if (subscriptionsForUrl.length === 0) { 97 | console.log("No subscriptions found for %s", callback); 98 | return; 99 | } 100 | 101 | for (const subscription of subscriptionsForUrl) { 102 | const url = new URL("https://api.twitch.tv/helix/eventsub/subscriptions"); 103 | url.searchParams.append("id", subscription.id); 104 | await fetch(url, { 105 | method: "DELETE", 106 | headers: { 107 | authorization: `Bearer ${access_token}`, 108 | "client-id": process.env.TWITCH_APP_CLIENT_ID, 109 | }, 110 | }); 111 | 112 | console.log("Deleted subscription: %s", subscription.type); 113 | } 114 | return; 115 | } 116 | 117 | throw new Error("Unknown command: " + command); 118 | // curl -X GET 'https://api.twitch.tv/helix/eventsub/subscriptions' \ 119 | // -H 'Authorization: Bearer 2gbdx6oar67tqtcmt49t3wpcgycthx' \ 120 | // -H 'Client-Id: wbmytr93xzw8zbg0p1izqyzzc5mbiz' 121 | } 122 | 123 | // twitch api post eventsub/subscriptions -b '{ 124 | // "type": "stream.online", 125 | // "version": "1", 126 | // "condition": { 127 | // "broadcaster_user_id": "YOUR_BROADCASTER_ID" 128 | // }, 129 | // "transport": { 130 | // "method": "webhook", 131 | // "callback": "https://EXTERNAL_URL/webhook/callback", 132 | // "secret": "YOUR_SECRET" 133 | // } 134 | // }' 135 | 136 | async function authenticate() { 137 | const url = new URL("https://id.twitch.tv/oauth2/token"); 138 | url.searchParams.append("client_id", process.env.TWITCH_APP_CLIENT_ID); 139 | url.searchParams.append( 140 | "client_secret", 141 | process.env.TWITCH_APP_CLIENT_SECRET 142 | ); 143 | url.searchParams.append("grant_type", "client_credentials"); 144 | const response = await fetch(url, { 145 | method: "POST", 146 | }); 147 | return response.json(); 148 | } 149 | 150 | async function getSubscriptions(token) { 151 | const response = await fetch( 152 | "https://api.twitch.tv/helix/eventsub/subscriptions", 153 | { 154 | headers: { 155 | authorization: `Bearer ${token}`, 156 | "client-id": process.env.TWITCH_APP_CLIENT_ID, 157 | }, 158 | } 159 | ); 160 | 161 | const { data: subscriptions } = await response.json(); 162 | return subscriptions; 163 | } 164 | -------------------------------------------------------------------------------- /test/fixtures/issues.closed.json: -------------------------------------------------------------------------------- 1 | { 2 | "issue": { 3 | "title": "📅 8/26 @ 10:00am PT - Creating tests", 4 | "body": "- [ ] reset twitter profile\n", 5 | "number": 1, 6 | "html_url": "" 7 | }, 8 | "repository": { 9 | "full_name": "gr2m/helpdesk" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/issues.opened.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "issue": { 4 | "title": "DO NOT EDIT - Await parsing by GitHub Actions", 5 | "body": "...", 6 | "id": 123, 7 | "labels": [], 8 | "number": 1 9 | }, 10 | "repository": { 11 | "full_name": "gr2m/helpdesk" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/list-issues-for-update-show-sections.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "number": 49, 4 | "html_url": "https://github.com/gr2m/helpdesk/issues/49", 5 | "title": "📅 9/2 @ 10:00am PT - Creating tests for actions for faster iteration Part III", 6 | "state": "open" 7 | }, 8 | { 9 | "number": 47, 10 | "html_url": "https://github.com/gr2m/helpdesk/issues/47", 11 | "title": "📅 8/26 @ 10:00am PT - Creating tests for actions for faster iteration Part II", 12 | "state": "closed" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /test/fixtures/list-issues.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "number": 2, 4 | "title": "📅 8/26 @ 10:00am PT - Creating tests Part II", 5 | "body": "📅 Thursday, August 26, 2021\n🕐 10:00am Pacific Time\n- [ ] 30 minute announcement tweet\n- [ ] 30 minute announcement comment\n- [ ] start of show tweet\n- [ ] comment on issue\n- [ ] Set twitter profile url\n- [ ] reset twitter profile (https://twitter.com/gr2m)", 6 | "html_url": "" 7 | }, 8 | { 9 | "number": 1, 10 | "title": "📅 8/26 @ 10:00am PT - Creating tests", 11 | "body": "📅 Thursday, August 19, 2021\n🕐 10:00am Pacific Time\n- [ ] 30 minute announcement tweet\n- [ ] 30 minute announcement comment\n- [ ] start of show tweet\n- [ ] comment on issue\n- [ ] Set twitter profile url\n- [ ] reset twitter profile (https://twitter.com/gr2m)", 12 | "html_url": "" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /test/fixtures/new-issue-body.md: -------------------------------------------------------------------------------- 1 | 💁🏻 **Automating gr2m/helpdesk: Test show** 2 | 📅 Thursday, August 12, 2021 3 | 🕐 10:00am Pacific Time 4 | 🎙️ _no guests_ 5 | 📍 https://www.twitch.tv/gregorcodes 6 | 🏷️ Automation 7 | 8 | --- 9 | 10 | Subscribe to this issues to get a notification before the show begins and a summary after the show concludes. 11 | 12 | ### Automating gr2m/helpdesk: Test show 13 | 14 | 3rd and (hopefully) final part about the issue form automation. see the first two episodes 15 | 16 | - https://github.com/gr2m/helpdesk/issues/34 17 | - https://github.com/gr2m/helpdesk/issues/42 18 | 19 | #### Outline 20 | 21 | _tbd_ 22 | 23 | #### TODOs 24 | 25 | Before the show 26 | 27 | - [ ] 30 minute announcement tweet 28 | - [ ] 30 minute announcement comment 29 | 30 | When show begins 31 | 32 | - [ ] start of show tweet 33 | - [ ] comment on issue 34 | - [ ] Set twitter profile url 35 | 36 | After the show 37 | 38 | - [ ] Reset twitter profile after the show 39 | - [ ] recording available tweet 40 | 41 | 42 | 43 | #### Recording 44 | 45 | _will be added after the show_ 46 | 47 | 48 | 49 | #### Shownotes 50 | 51 | _will be added after the show_ 52 | -------------------------------------------------------------------------------- /test/fixtures/open-show-issues.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "body": "📅 Thursday, August 26, 2021\n🕐 10:00am Pacific Time\n" 4 | }, 5 | { 6 | "body": "📅 Thursday, August 19, 2021\n🕐 10:00am Pacific Time\n" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /test/fixtures/parsed-issue.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Test show", 3 | "type": "automating helpdesk", 4 | "date": "2021-08-12", 5 | "time": "10:00", 6 | "guests": "", 7 | "location": "https://www.twitch.tv/gregorcodes", 8 | "tags": "Automation", 9 | "summary": "3rd and (hopefully) final part about the issue form automation. see the first two episodes\n\n- https://github.com/gr2m/helpdesk/issues/34\n- https://github.com/gr2m/helpdesk/issues/42", 10 | "outline": "_tbd_", 11 | "todos": "" 12 | } 13 | -------------------------------------------------------------------------------- /test/get-show-schedules-test.js: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises"; 2 | import { dirname, join, basename } from "path"; 3 | import { fileURLToPath } from "url"; 4 | import { deepEqual } from "assert/strict"; 5 | 6 | import { test } from "uvu"; 7 | import MockDate from "mockdate"; 8 | 9 | import { run } from "../get-schow-schedules.js"; 10 | 11 | const __dirname = dirname(fileURLToPath(import.meta.url)); 12 | 13 | test("get-schow-schedules.js", async () => { 14 | // mock date 15 | MockDate.set("2021-08-20"); 16 | 17 | // mock environment variables 18 | const mockEnv = {}; 19 | 20 | // mock octokit 21 | const mockOctokit = { 22 | async paginate(route, parameters) { 23 | if (route === "GET /repos/{owner}/{repo}/issues") { 24 | deepEqual(parameters, { 25 | owner: "gr2m", 26 | repo: "helpdesk", 27 | labels: "show", 28 | state: "open", 29 | per_page: 100, 30 | }); 31 | return JSON.parse( 32 | await readFile( 33 | join(__dirname, "fixtures/open-show-issues.json"), 34 | "utf8" 35 | ) 36 | ); 37 | } 38 | 39 | throw new Error("Unexpected route: " + route); 40 | }, 41 | }; 42 | 43 | // mock core 44 | const outputLogs = []; 45 | const outputs = {}; 46 | const mockCore = { 47 | info(message) { 48 | outputLogs.push(message); 49 | }, 50 | setOutput(name, value) { 51 | outputs[name] = value; 52 | }, 53 | }; 54 | 55 | // run action 56 | await run(mockEnv, mockOctokit, mockCore); 57 | 58 | // assertions 59 | deepEqual(outputLogs, [ 60 | "CRON schedule for upcoming shows is: [ { start: '57 16 26 8 *', announcement: '27 16 26 8 *' } ]", 61 | ]); 62 | deepEqual(outputs, { 63 | schedule_announcement: "27 16 26 8 *", 64 | schedule_start: "57 16 26 8 *", 65 | }); 66 | }); 67 | 68 | test.run(); 69 | -------------------------------------------------------------------------------- /test/handle-show-announcement-test.js: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises"; 2 | import { dirname, join } from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | import { deepEqual } from "assert/strict"; 6 | 7 | import { test } from "uvu"; 8 | import MockDate from "mockdate"; 9 | 10 | import { run } from "../handle-show-announcement.js"; 11 | 12 | const __dirname = dirname(fileURLToPath(import.meta.url)); 13 | 14 | test("handle-show-announcement.js", async () => { 15 | const issues = JSON.parse( 16 | await readFile(join(__dirname, "fixtures/list-issues.json")) 17 | ); 18 | 19 | // mock date (30 minutes before the show) 20 | MockDate.set("2021-08-19T16:30:00.000Z"); 21 | 22 | // mock environment variables 23 | const mockEnv = { 24 | TWITTER_CONSUMER_KEY: "twitter_consumer_key", 25 | TWITTER_CONSUMER_SECRET: "twitter_consumer_secret", 26 | TWITTER_ACCESS_TOKEN_KEY: "twitter_access_token_key", 27 | TWITTER_ACCESS_TOKEN_SECRET: "twitter_access_token_secret", 28 | }; 29 | 30 | // mock octokit 31 | const mockOctokit = { 32 | paginate: async (route, parameters) => { 33 | if (route === "GET /repos/{owner}/{repo}/issues") { 34 | deepEqual(parameters, { 35 | owner: "gr2m", 36 | repo: "helpdesk", 37 | labels: "show", 38 | state: "open", 39 | per_page: 100, 40 | }); 41 | return issues; 42 | } 43 | 44 | throw new Error("Unexpected route: " + route); 45 | }, 46 | 47 | request: async (route, parameters) => { 48 | if ( 49 | route === "POST /repos/{owner}/{repo}/issues/{issue_number}/comments" 50 | ) { 51 | deepEqual(parameters, { 52 | owner: "gr2m", 53 | repo: "helpdesk", 54 | issue_number: 1, 55 | body: "Going live in 30 minutes at https://twitch.tv/gregorcodes", 56 | }); 57 | 58 | return { 59 | data: { 60 | html_url: "", 61 | }, 62 | }; 63 | } 64 | 65 | if (route === `PATCH /repos/{owner}/{repo}/issues/{issue_number}`) { 66 | const body = `📅 Thursday, August 19, 2021 67 | 🕐 10:00am Pacific Time 68 | - [x] 30 minute announcement tweet (https://twitter.com/gr2m/status/) 69 | - [x] 30 minute announcement comment () 70 | - [ ] start of show tweet 71 | - [ ] comment on issue 72 | - [ ] Set twitter profile url 73 | `; 74 | 75 | deepEqual(parameters.body, body); 76 | 77 | deepEqual(parameters, { 78 | owner: "gr2m", 79 | repo: "helpdesk", 80 | issue_number: 1, 81 | body, 82 | }); 83 | return; 84 | } 85 | 86 | throw new Error("Unexpected route: " + route); 87 | }, 88 | }; 89 | 90 | // mock twitterRequest 91 | const mockTwitterRequest = (route, parameters) => { 92 | if (route === "POST statuses/update.json") { 93 | deepEqual(parameters, { 94 | auth: { 95 | accessTokenKey: "twitter_access_token_key", 96 | accessTokenSecret: "twitter_access_token_secret", 97 | consumerKey: "twitter_consumer_key", 98 | consumerSecret: "twitter_consumer_secret", 99 | }, 100 | status: `📯 Starting in 30 minutes 101 | 102 | 💁🏻‍♂️ Creating tests 103 | 🔴 Watch live at https://twitch.tv/gregorcodes 104 | 105 | `, 106 | }); 107 | 108 | return { 109 | id_str: "", 110 | }; 111 | } 112 | throw new Error("Unexpected route: " + route); 113 | }; 114 | 115 | // mock core 116 | const outputLogs = []; 117 | const outputs = {}; 118 | const mockCore = { 119 | info(message) { 120 | outputLogs.push(message); 121 | }, 122 | setOutput(name, value) { 123 | outputs[name] = value; 124 | }, 125 | setFailed(message) { 126 | throw new Error(message); 127 | }, 128 | }; 129 | 130 | // run action 131 | await run(mockEnv, mockCore, mockOctokit, mockTwitterRequest); 132 | 133 | // assertions 134 | deepEqual(outputLogs, [ 135 | "Comment created at ", 136 | "Tweeted at https://twitter.com/gr2m/status/", 137 | "TODOs in issue updated: ", 138 | ]); 139 | }); 140 | 141 | test.run(); 142 | -------------------------------------------------------------------------------- /test/handle-show-done-test.js: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises"; 2 | import { dirname, join } from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | import { deepEqual } from "assert/strict"; 6 | 7 | import { test } from "uvu"; 8 | import MockDate from "mockdate"; 9 | 10 | import { run } from "../handle-show-done.js"; 11 | 12 | const __dirname = dirname(fileURLToPath(import.meta.url)); 13 | 14 | test("handle-show-done.js", async () => { 15 | const issues = JSON.parse( 16 | await readFile(join(__dirname, "fixtures/list-issues.json")) 17 | ); 18 | 19 | // mock date (time of the show) 20 | MockDate.set("2021-08-19T17:00:00.000Z"); 21 | 22 | // mock environment variables 23 | const mockEnv = { 24 | GITHUB_EVENT_PATH: join(__dirname, "fixtures/issues.closed.json"), 25 | TWITTER_CONSUMER_KEY: "twitter_consumer_key", 26 | TWITTER_CONSUMER_SECRET: "twitter_consumer_secret", 27 | TWITTER_ACCESS_TOKEN_KEY: "twitter_access_token_key", 28 | TWITTER_ACCESS_TOKEN_SECRET: "twitter_access_token_secret", 29 | }; 30 | 31 | // mock octokit 32 | const mockOctokit = { 33 | paginate: async (route, parameters) => { 34 | if (route === "GET /repos/{owner}/{repo}/issues") { 35 | deepEqual(parameters, { 36 | owner: "gr2m", 37 | repo: "helpdesk", 38 | labels: "show", 39 | state: "open", 40 | per_page: 100, 41 | }); 42 | return issues; 43 | } 44 | 45 | throw new Error("Unexpected route: " + route); 46 | }, 47 | 48 | request: async (route, parameters) => { 49 | if ( 50 | route === "POST /repos/{owner}/{repo}/issues/{issue_number}/comments" 51 | ) { 52 | deepEqual(parameters, { 53 | owner: "gr2m", 54 | repo: "helpdesk", 55 | issue_number: 1, 56 | body: "Show is done for today, thank you all! Recording is coming up in a moment", 57 | }); 58 | return { 59 | data: { html_url: "" }, 60 | }; 61 | } 62 | 63 | if (route === "PATCH /repos/{owner}/{repo}/issues/{issue_number}") { 64 | deepEqual(parameters, { 65 | body: "📅 Thursday, August 19, 2021\n🕐 10:00am Pacific Time\n- [ ] 30 minute announcement tweet\n- [ ] 30 minute announcement comment\n- [ ] start of show tweet\n- [ ] comment on issue\n- [ ] Set twitter profile url\n- [x] reset twitter profile (https://twitter.com/gr2m) (https://twitter.com/gr2m)", 66 | issue_number: 1, 67 | owner: "gr2m", 68 | repo: "helpdesk", 69 | state: "closed", 70 | }); 71 | return; 72 | } 73 | 74 | throw new Error("Unexpected route: " + route); 75 | }, 76 | }; 77 | 78 | // mock twitterRequest 79 | const mockTwitterRequest = (route, parameters) => { 80 | if (route === "POST account/update_profile.json") { 81 | deepEqual(parameters, { 82 | auth: { 83 | consumerKey: "twitter_consumer_key", 84 | consumerSecret: "twitter_consumer_secret", 85 | accessTokenKey: "twitter_access_token_key", 86 | accessTokenSecret: "twitter_access_token_secret", 87 | }, 88 | name: "Gregor", 89 | url: "https://github.com/gr2m/", 90 | }); 91 | return; 92 | } 93 | throw new Error("Unexpected route: " + route); 94 | }; 95 | 96 | // mock core 97 | const outputLogs = []; 98 | const outputs = {}; 99 | const mockCore = { 100 | info(message) { 101 | outputLogs.push(message); 102 | }, 103 | setOutput(name, value) { 104 | outputs[name] = value; 105 | }, 106 | setFailed(message) { 107 | throw new Error(message); 108 | }, 109 | }; 110 | 111 | // run action 112 | await run(mockEnv, mockCore, mockOctokit, mockTwitterRequest); 113 | 114 | // assertions 115 | deepEqual(outputLogs, [ 116 | "Comment created at ", 117 | "Twitter profile reverted to default", 118 | "TODOs in issue updated, issue closed: ", 119 | ]); 120 | }); 121 | 122 | test.run(); 123 | -------------------------------------------------------------------------------- /test/handle-show-start-test.js: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises"; 2 | import { dirname, join } from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | import { deepEqual } from "assert/strict"; 6 | 7 | import { test } from "uvu"; 8 | import MockDate from "mockdate"; 9 | 10 | import { run } from "../handle-show-start.js"; 11 | 12 | const __dirname = dirname(fileURLToPath(import.meta.url)); 13 | 14 | test("handle-show-start.js", async () => { 15 | const issues = JSON.parse( 16 | await readFile(join(__dirname, "fixtures/list-issues.json")) 17 | ); 18 | 19 | // mock date (time of the show) 20 | MockDate.set("2021-08-19T17:00:00.000Z"); 21 | 22 | // mock environment variables 23 | const mockEnv = { 24 | TWITTER_CONSUMER_KEY: "twitter_consumer_key", 25 | TWITTER_CONSUMER_SECRET: "twitter_consumer_secret", 26 | TWITTER_ACCESS_TOKEN_KEY: "twitter_access_token_key", 27 | TWITTER_ACCESS_TOKEN_SECRET: "twitter_access_token_secret", 28 | }; 29 | 30 | // mock octokit 31 | const mockOctokit = { 32 | paginate: async (route, parameters) => { 33 | if (route === "GET /repos/{owner}/{repo}/issues") { 34 | deepEqual(parameters, { 35 | owner: "gr2m", 36 | repo: "helpdesk", 37 | labels: "show", 38 | state: "open", 39 | per_page: 100, 40 | }); 41 | return issues; 42 | } 43 | 44 | throw new Error("Unexpected route: " + route); 45 | }, 46 | 47 | request: async (route, parameters) => { 48 | if ( 49 | route === "POST /repos/{owner}/{repo}/issues/{issue_number}/comments" 50 | ) { 51 | deepEqual(parameters, { 52 | owner: "gr2m", 53 | repo: "helpdesk", 54 | issue_number: 1, 55 | body: "I'm now live on https://twitch.tv/gregorcodes", 56 | }); 57 | 58 | return { 59 | data: { 60 | html_url: "", 61 | }, 62 | }; 63 | } 64 | 65 | if (route === `PATCH /repos/{owner}/{repo}/issues/{issue_number}`) { 66 | const body = `📅 Thursday, August 19, 2021 67 | 🕐 10:00am Pacific Time 68 | - [ ] 30 minute announcement tweet 69 | - [ ] 30 minute announcement comment 70 | - [x] start of show tweet (https://twitter.com/gr2m/status/) 71 | - [x] comment on issue () 72 | - [x] Set twitter profile url (https://twitter.com/gr2m) 73 | `; 74 | 75 | deepEqual(parameters.body, body); 76 | 77 | deepEqual(parameters, { 78 | owner: "gr2m", 79 | repo: "helpdesk", 80 | issue_number: 1, 81 | body, 82 | }); 83 | return; 84 | } 85 | 86 | throw new Error("Unexpected route: " + route); 87 | }, 88 | }; 89 | 90 | // mock twitterRequest 91 | const mockTwitterRequest = (route, parameters) => { 92 | if (route === "POST statuses/update.json") { 93 | deepEqual(parameters, { 94 | auth: { 95 | accessTokenKey: "twitter_access_token_key", 96 | accessTokenSecret: "twitter_access_token_secret", 97 | consumerKey: "twitter_consumer_key", 98 | consumerSecret: "twitter_consumer_secret", 99 | }, 100 | status: `🔴 Now live at https://twitch.tv/gregorcodes 101 | 102 | 💁🏻‍♂️ Creating tests 103 | 104 | `, 105 | }); 106 | 107 | return { 108 | id_str: "", 109 | }; 110 | } 111 | 112 | if (route === "POST account/update_profile.json") { 113 | deepEqual(parameters, { 114 | auth: { 115 | accessTokenKey: "twitter_access_token_key", 116 | accessTokenSecret: "twitter_access_token_secret", 117 | consumerKey: "twitter_consumer_key", 118 | consumerSecret: "twitter_consumer_secret", 119 | }, 120 | name: "🔴 Gregor is now live on twitch.tv/gregorcodes", 121 | url: "https://twitch.tv/gregorcodes", 122 | }); 123 | return; 124 | } 125 | 126 | throw new Error("Unexpected route: " + route); 127 | }; 128 | 129 | // mock core 130 | const outputLogs = []; 131 | const outputs = {}; 132 | const mockCore = { 133 | info(message) { 134 | outputLogs.push(message); 135 | }, 136 | setOutput(name, value) { 137 | outputs[name] = value; 138 | }, 139 | setFailed(message) { 140 | throw new Error(message); 141 | }, 142 | }; 143 | 144 | // run action 145 | await run(mockEnv, mockCore, mockOctokit, mockTwitterRequest); 146 | 147 | // assertions 148 | deepEqual(outputLogs, [ 149 | "Comment created at ", 150 | "Tweeted at https://twitter.com/gr2m/status/", 151 | "Twitter profile updated to link to twitch.tv/gregorcodes", 152 | "TODOs in issue updated: ", 153 | ]); 154 | }); 155 | 156 | test.run(); 157 | -------------------------------------------------------------------------------- /test/parse-new-show-issue-test.js: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises"; 2 | import { dirname, join } from "path"; 3 | import { fileURLToPath } from "url"; 4 | import { deepEqual } from "assert/strict"; 5 | 6 | import { test } from "uvu"; 7 | 8 | import { run } from "../parse-new-show-issue.js"; 9 | 10 | const __dirname = dirname(fileURLToPath(import.meta.url)); 11 | 12 | test("parse-new-show-issue.js", async () => { 13 | const mockEnv = { 14 | GITHUB_EVENT_PATH: join(__dirname, "fixtures", "issues.opened.json"), 15 | PARSED_ISSUE_JSON: await readFile( 16 | join(__dirname, "fixtures", "parsed-issue.json"), 17 | "utf-8" 18 | ), 19 | }; 20 | 21 | const expectedBody = await readFile( 22 | join(__dirname, "fixtures/new-issue-body.md"), 23 | "utf-8" 24 | ); 25 | 26 | const mockOctokit = { 27 | request(route, parameters) { 28 | if (route === "PATCH /repos/{owner}/{repo}/issues/{issue_number}") { 29 | deepEqual(parameters, { 30 | owner: "gr2m", 31 | repo: "helpdesk", 32 | issue_number: 1, 33 | title: "📅 8/12 @ 10:00am PT - Automating gr2m/helpdesk: Test show", 34 | body: expectedBody, 35 | labels: ["show"], 36 | }); 37 | return {}; 38 | } 39 | 40 | throw new Error("Unexpected route: " + route); 41 | }, 42 | }; 43 | 44 | run(mockEnv, mockOctokit); 45 | }); 46 | 47 | test.run(); 48 | -------------------------------------------------------------------------------- /test/update-show-sections-in-readmes-test.js: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises"; 2 | import { dirname, join } from "path"; 3 | import { fileURLToPath } from "url"; 4 | import { equal, deepEqual, ok } from "assert/strict"; 5 | 6 | import { test } from "uvu"; 7 | 8 | import { run } from "../update-show-sections-in-readmes.js"; 9 | 10 | const __dirname = dirname(fileURLToPath(import.meta.url)); 11 | 12 | test("update-show-sections-in-readmes.js without any issues", async () => { 13 | // mock environment variables 14 | const mockEnv = { 15 | GITHUB_TOKEN: "secret", 16 | }; 17 | 18 | // mock octokit 19 | const mockOctokit = { 20 | paginate: async (route, parameters) => { 21 | if (route === "GET /repos/{owner}/{repo}/issues") { 22 | deepEqual(parameters, { 23 | owner: "gr2m", 24 | repo: "helpdesk", 25 | labels: "show", 26 | state: "all", 27 | per_page: 100, 28 | }); 29 | return []; 30 | } 31 | 32 | throw new Error("Unexpected route: " + route); 33 | }, 34 | request: async (route, parameters) => { 35 | throw new Error("Unexpected route: " + route); 36 | }, 37 | }; 38 | 39 | // mock twitterRequest 40 | const mockReadmeBox = { 41 | async updateSection(newContent, parameters) { 42 | equal( 43 | newContent, 44 | ` 45 | 46 | ## Upcoming shows 47 | 48 | 49 | 50 | ## Past shows 51 | 52 | 53 | 54 | ` 55 | ); 56 | const { repo, ...rest } = parameters; 57 | ok(["helpdesk", "gr2m"].includes(repo), `${repo} could not be matched`); 58 | deepEqual(rest, { 59 | token: "secret", 60 | owner: "gr2m", 61 | section: "helpdesk-shows", 62 | branch: "main", 63 | message: "docs(README): update helpdesk shows", 64 | }); 65 | return; 66 | }, 67 | }; 68 | 69 | // mock core 70 | const outputLogs = []; 71 | const outputs = {}; 72 | const mockCore = { 73 | info(message) { 74 | outputLogs.push(message); 75 | }, 76 | setOutput(name, value) { 77 | outputs[name] = value; 78 | }, 79 | setFailed(message) { 80 | throw new Error(message); 81 | }, 82 | }; 83 | 84 | // run action 85 | await run(mockEnv, mockCore, mockOctokit, mockReadmeBox); 86 | 87 | // assertions 88 | deepEqual(outputLogs, [ 89 | "README updated in gr2m/helpdesk", 90 | "README updated in gr2m/gr2m", 91 | ]); 92 | }); 93 | 94 | test("update-show-sections-in-readmes.js with two issues", async () => { 95 | const issues = JSON.parse( 96 | await readFile( 97 | join(__dirname, "fixtures/list-issues-for-update-show-sections.json") 98 | ) 99 | ); 100 | 101 | // mock environment variables 102 | const mockEnv = { 103 | GITHUB_TOKEN: "secret", 104 | }; 105 | 106 | // mock octokit 107 | const mockOctokit = { 108 | paginate: async (route, parameters) => { 109 | if (route === "GET /repos/{owner}/{repo}/issues") { 110 | deepEqual(parameters, { 111 | owner: "gr2m", 112 | repo: "helpdesk", 113 | labels: "show", 114 | state: "all", 115 | per_page: 100, 116 | }); 117 | 118 | return issues; 119 | } 120 | 121 | throw new Error("Unexpected route: " + route); 122 | }, 123 | request: async (route, parameters) => { 124 | throw new Error("Unexpected route: " + route); 125 | }, 126 | }; 127 | 128 | // mock twitterRequest 129 | const mockReadmeBox = { 130 | async updateSection(newContent, parameters) { 131 | equal( 132 | newContent, 133 | ` 134 | 135 | ## Upcoming shows 136 | 137 | - 📅 9/2 @ 10:00am PT — [Creating tests for actions for faster iteration Part III](https://github.com/gr2m/helpdesk/issues/49) 138 | 139 | ## Past shows 140 | 141 | - [Creating tests for actions for faster iteration Part II](https://github.com/gr2m/helpdesk/issues/47) 142 | 143 | ` 144 | ); 145 | const { repo, ...rest } = parameters; 146 | ok(["helpdesk", "gr2m"].includes(repo), `${repo} could not be matched`); 147 | deepEqual(rest, { 148 | token: "secret", 149 | owner: "gr2m", 150 | section: "helpdesk-shows", 151 | branch: "main", 152 | message: "docs(README): update helpdesk shows", 153 | }); 154 | return; 155 | }, 156 | }; 157 | 158 | // mock core 159 | const outputLogs = []; 160 | const outputs = {}; 161 | const mockCore = { 162 | info(message) { 163 | outputLogs.push(message); 164 | }, 165 | setOutput(name, value) { 166 | outputs[name] = value; 167 | }, 168 | setFailed(message) { 169 | throw new Error(message); 170 | }, 171 | }; 172 | 173 | // run action 174 | await run(mockEnv, mockCore, mockOctokit, mockReadmeBox); 175 | 176 | // assertions 177 | deepEqual(outputLogs, [ 178 | "README updated in gr2m/helpdesk", 179 | "README updated in gr2m/gr2m", 180 | ]); 181 | }); 182 | 183 | test.run(); 184 | -------------------------------------------------------------------------------- /update-show-sections-in-readmes.js: -------------------------------------------------------------------------------- 1 | import core from "@actions/core"; 2 | import { Octokit } from "@octokit/core"; 3 | import { paginateRest } from "@octokit/plugin-paginate-rest"; 4 | import { ReadmeBox } from "readme-box"; 5 | 6 | if (process.env.GITHUB_ACTIONS && process.env.NODE_ENV !== "test") { 7 | // Create Octokit constructor with .paginate API and custom user agent 8 | const MyOctokit = Octokit.plugin(paginateRest).defaults({ 9 | userAgent: "gr2m-helpdesk", 10 | }); 11 | const octokit = new MyOctokit({ 12 | auth: process.env.GITHUB_TOKEN, 13 | }); 14 | run(process.env, core, octokit, ReadmeBox); 15 | } 16 | 17 | export async function run(env, core, octokit, ReadmeBox) { 18 | // load all open issues with the `show` label 19 | const issues = await octokit.paginate("GET /repos/{owner}/{repo}/issues", { 20 | owner: "gr2m", 21 | repo: "helpdesk", 22 | labels: "show", 23 | state: "all", 24 | per_page: 100, 25 | }); 26 | 27 | // split up the shows between upcoming (open issue) and past shows (closed issues) 28 | const upcomingShows = []; 29 | const pastShows = []; 30 | for (const issue of issues) { 31 | const [datetime, , title, , guest] = issue.title.split(/ (- |with @)/g); 32 | 33 | if (issue.state === "open") { 34 | upcomingShows.push({ 35 | datetime, 36 | title, 37 | guest, 38 | url: issue.html_url, 39 | }); 40 | } else { 41 | pastShows.push({ 42 | datetime, 43 | title, 44 | guest, 45 | url: issue.html_url, 46 | }); 47 | } 48 | } 49 | 50 | // Create markdown code for both show sectiosn 51 | const upcomingShowsText = upcomingShows 52 | .map(({ datetime, title, url, guest }) => { 53 | if (guest) { 54 | return `- ${datetime} — [${title}](${url}) with [@${guest}](https://github.com/${guest})`; 55 | } 56 | 57 | return `- ${datetime} — [${title}](${url})`; 58 | }) 59 | .join("\n"); 60 | 61 | const pastShowsText = pastShows 62 | .map(({ title, url, guest }) => { 63 | if (guest) { 64 | return `- [${title}](${url}) with [@${guest}](https://github.com/${guest})`; 65 | } 66 | 67 | return `- [${title}](${url})`; 68 | }) 69 | .join("\n"); 70 | 71 | const markdown = ` 72 | 73 | ## Upcoming shows 74 | 75 | ${upcomingShowsText} 76 | 77 | ## Past shows 78 | 79 | ${pastShowsText} 80 | 81 | `; 82 | 83 | // update the shows section in gr2m/helpdesk 84 | await ReadmeBox.updateSection(markdown, { 85 | owner: "gr2m", 86 | repo: "helpdesk", 87 | token: env.GITHUB_TOKEN, 88 | section: "helpdesk-shows", 89 | branch: "main", 90 | message: "docs(README): update helpdesk shows", 91 | }); 92 | 93 | core.info("README updated in gr2m/helpdesk"); 94 | 95 | // update the shows section in gr2m/gr2m 96 | await ReadmeBox.updateSection(markdown, { 97 | owner: "gr2m", 98 | repo: "gr2m", 99 | token: env.GITHUB_TOKEN, 100 | section: "helpdesk-shows", 101 | branch: "main", 102 | message: "docs(README): update helpdesk shows", 103 | }); 104 | 105 | core.info("README updated in gr2m/gr2m"); 106 | } 107 | --------------------------------------------------------------------------------