
GitHub Issue Notifier
Pricing
Pay per usage
Go to Store

GitHub Issue Notifier
0.0 (0)
Pricing
Pay per usage
2
Total users
2
Monthly users
1
Runs succeeded
>99%
Last modified
6 months ago
.actor/Dockerfile
# Specify the base Docker image. You can read more about# the available images at https://docs.apify.com/sdk/js/docs/guides/docker-images# You can also use any other image from Docker Hub.FROM apify/actor-node:18 AS builder
# Copy just package.json and package-lock.json# to speed up the build using Docker layer cache.COPY package*.json ./
# Install all dependencies. Don't audit to speed up the installation.RUN npm install --include=dev --audit=false
# Next, copy the source files using the user set# in the base image.COPY . ./
# Install all dependencies and build the project.# Don't audit to speed up the installation.RUN npm run build
# Create final imageFROM apify/actor-node:18
# Copy just package.json and package-lock.json# to speed up the build using Docker layer cache.COPY package*.json ./
# Install NPM packages, skip optional and development dependencies to# keep the image small. Avoid logging too much and print the dependency# tree for debuggingRUN npm --quiet set progress=false \ && npm install --omit=dev --omit=optional \ && echo "Installed NPM packages:" \ && (npm list --omit=dev --all || true) \ && echo "Node.js version:" \ && node --version \ && echo "NPM version:" \ && npm --version \ && rm -r ~/.npm
# Copy built JS files from builder imageCOPY /usr/src/app/dist ./dist
# Next, copy the remaining files and directories with the source code.# Since we do this after NPM install, quick build will be really fast# for most source file changes.COPY . ./
# Run the image.CMD npm run start:prod --silent
.actor/actor.json
{ "actorSpecification": 1, "name": "gh-issue-notifier", "title": "Scrape single page in TypeScript", "description": "Scrape data from single page with provided URL.", "version": "0.0", "meta": { "templateId": "ts-start" }, "input": "./input_schema.json", "dockerfile": "./Dockerfile"}
.actor/input_schema.json
{ "title": "Scrape data from a web page", "type": "object", "schemaVersion": 1, "properties": { "searchRepos": { "title": "URL or names of repositories", "type": "array", "description": "The URLs or repository names of repositories you want to monitor.", "editor": "stringList", "prefill": [] }, "searchKeywords": { "title": "Keywords to search for", "type": "array", "description": "The keywords that will be looked for in the titles and bodies of new issues.", "editor": "stringList", "prefill": [] }, "slackToken": { "title": "Slack token", "type": "string", "description": "Slack API token in a format xoxp-xxxxxxxxx-xxxx.", "editor": "textfield", "isSecret": true }, "slackChannel": { "title": "Slack channel", "type": "string", "description": "Channel where the notification with Github issues information will be sent (e.g. #general)", "prefill": "#general", "editor": "textfield" } }, "required": ["searchRepos", "searchKeywords"]}
src/hash.ts
1import crypto from 'crypto'2
3/**4 * Create crypto hash using SHA-256.5 * If object includes array fields, those fields are sorted using `.sort()`6 * @param object to hash7 * @returns hash string8 */9export const createObjectDigest = async (object: any) => {10 for (let field in object) {11 if (object[field] instanceof Array) {12 object[field] = object[field].sort()13 }14 }15 const objectAsMessage = JSON.stringify(object)16 return digestMessage(objectAsMessage)17}18
19async function digestMessage(message: string) {20 const msgUint8 = new TextEncoder().encode(message) // encode as (utf-8) Uint8Array21 const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8) // hash the message22 const hashArray = Array.from(new Uint8Array(hashBuffer)) // convert buffer to byte array23 const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') // convert bytes to hex string24 return hashHex25}
src/main.ts
1// Cheerio - The fast, flexible & elegant library for parsing and manipulating HTML and XML (Read more at https://cheerio.js.org/).2import * as cheerio from 'cheerio'3// Apify SDK - toolkit for building Apify Actors (Read more at https://docs.apify.com/sdk/js/).4import { Actor, log } from 'apify'5import { Octokit } from 'octokit'6import { RestEndpointMethodTypes } from '@octokit/plugin-rest-endpoint-methods'7import { sendSlackNotification } from './slackMessage.js'8import { createSlackMessageBlocks } from './slackBlocks.js'9import { parseRepoNameOrUrl } from './utils.js'10import { createObjectDigest } from './hash.js'11
12// Extracts element type from array type13export type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[]14 ? ElementType15 : never16export type Issue = RestEndpointMethodTypes['issues']['listForRepo']['response']17
18interface Input {19 searchRepos: string[]20 searchKeywords: string[]21 slackToken?: string22 slackChannel?: string23}24
25const KV_STORE_NAME = 'gh-issues-notifier-state'26
27/**28 * Checks whether or not the issue is relevant.29 * Looks for search keywords in the title or body of the issue.30 * @param issue to check31 * @param searchKeywords keywords to look for, case is ignored32 * @returns true if the issue is relevant33 */34const checkIssueRelevant = (issue: ArrElement<Issue['data']>, searchKeywords: string[]) => {35 if (!issue || issue.state !== 'open') {36 return false37 }38
39 const lowerCaseKeywords = searchKeywords.map((keyword: string) => keyword.toLowerCase())40
41 for (let keyword of lowerCaseKeywords) {42 if (issue.title.toLowerCase().includes(keyword)) {43 return true44 }45 }46
47 if (issue.body) {48 const $ = cheerio.load(issue.body)49 for (let keyword of lowerCaseKeywords) {50 if ($.text().toLowerCase().includes(keyword)) {51 return true52 }53 }54 }55
56 return false57}58
59/**60 * Discover all issues that are deemed relevant and were not reported yet.61 * Goes through all the provided GH repositories and looks at the issue text62 * @param repoUrls urls of repos to search63 * @param searchKeywords keywords to match in the search64 * @param checkedFilter lambda to filter out issues that were already reported65 * @throws when octokit issues fail to fetch66 * @returns list of repos, each as a list of issues67 */68const findRelevantNewIssues = async (69 repoUrls: string[],70 searchKeywords: string[],71 checkedFilter: (potentialNewIssue: ArrElement<Issue['data']>) => boolean72) => {73 let outputs: Issue['data'] = []74 for (let url of repoUrls) {75 let repoInfo76 try {77 repoInfo = parseRepoNameOrUrl(url)78 } catch (e) {79 if (e instanceof Error) {80 log.error(e.message)81 }82 continue83 }84 const repoIssues = await octokit.rest.issues.listForRepo({85 owner: repoInfo.user,86 repo: repoInfo.repoName,87 })88
89 const relevantIssues = repoIssues.data90 .filter(checkedFilter)91 .filter((issue) => checkIssueRelevant(issue, searchKeywords))92
93 if (relevantIssues.length === 0) {94 continue // no relevant issues, next repo95 }96 outputs = outputs.concat(relevantIssues)97 }98 return outputs99}100
101/**102 * Checks the data store for whether or not the issue was already reported.103 * @param potentialNewIssue Issue to check104 * @param lastCheckedAt timestamp of when the issues were last checked105 * @returns true if issue was not reported yet106 */107const checkIssueNotReported = (108 potentialNewIssue: ArrElement<Issue['data']>,109 lastCheckedAt: number110) => {111 return Date.parse(potentialNewIssue.created_at) > lastCheckedAt112}113
114const findAndReportIssues = async (input: Input) => {115 const reportedIssuesStore = await Actor.openKeyValueStore(KV_STORE_NAME)116 const actorInstanceHash = await createObjectDigest(input)117 const lastCheckedAt = +((await reportedIssuesStore.getValue(actorInstanceHash)) || '0')118 const currentCheckedAt = Date.now()119
120 // Closure filter function with last checked timestamp121 const issueAlreadyCheckedFilter = (potentialNewIssue: ArrElement<Issue['data']>) => {122 return checkIssueNotReported(potentialNewIssue, lastCheckedAt)123 }124
125 let newIssues: Awaited<ReturnType<typeof findRelevantNewIssues>>126 try {127 newIssues = await findRelevantNewIssues(searchRepos, searchKeywords, issueAlreadyCheckedFilter)128 } catch (_e) {129 log.error(130 'Error fetching issues from GitHub. One of your selected repos is likely misspelled or private.'131 )132 return133 }134
135 log.info(`Saving current actor information under hash [${actorInstanceHash}]`)136 reportedIssuesStore.setValue(actorInstanceHash, currentCheckedAt)137
138 if (newIssues.length > 0) {139 log.info(`Discovered ${newIssues.length} new issues.`)140 } else {141 log.info('No new issues discovered.')142 return143 }144
145 if (input.slackChannel && input.slackToken) {146 log.info(`Sending Slack notification with new issues.`)147 const messagePromises = newIssues.map((newIssue) => {148 const slackMessageBlocks = createSlackMessageBlocks(newIssue)149 return sendSlackNotification({150 token: input.slackToken!,151 channel: input.slackChannel!,152 blocks: slackMessageBlocks,153 })154 })155 await Promise.all(messagePromises)156 }157
158 await Actor.pushData(newIssues)159}160
161// The init() call configures the Actor for its environment. It's recommended to start every Actor with an init().162await Actor.init()163
164// Structure of input is defined in input_schema.json165const input = await Actor.getInput<Input>()166if (!input) throw new Error('Input is missing!')167const { searchRepos, searchKeywords } = input168
169if (!searchRepos || searchRepos.length === 0) {170 log.error('No repositories provided!')171}172if (!searchKeywords || searchKeywords.length === 0) {173 log.error('No search keywords provided!')174}175
176const octokit = new Octokit({})177
178await findAndReportIssues(input)179
180// Gracefully exit the Actor process. It's recommended to quit all Actors with an exit().181await Actor.exit()
src/slackBlocks.ts
1import type { Issue, ArrElement } from './main.js'2
3/**4 * Create Slack blocks detailing the newly discovered issue.5 * https://api.slack.com/block-kit/building6 * @param newIssue issue to make message for, as returned by octokit7 * @returns Slack message Blocks summarising the new issue8 */9export const createSlackMessageBlocks = (newIssue: ArrElement<Issue['data']>) => {10 let blocks: object[] = createTitleBlocks()11 blocks = blocks.concat(createIssueBlocks(newIssue))12 return blocks13}14
15/**16 * Create Slack blocks for the title of the Slack message17 * @returns slack blocks18 */19const createTitleBlocks = () => {20 return [21 {22 type: 'header',23 text: {24 type: 'plain_text',25 text: 'New GitHub issue was discovered! 🔎',26 emoji: true,27 },28 },29 ]30}31
32/**33 * Create Slack blocks for an individual issue.34 * @returns slack blocks35 */36const createIssueBlocks = (issue: ArrElement<Issue['data']>) => {37 return [38 {39 type: 'section',40 fields: [41 {42 type: 'mrkdwn',43 text: `<${issue.html_url ?? issue.url}|${issue.title}>`,44 },45 // {46 // type: 'mrkdwn',47 // text: '*Keywords:*\nTODO',48 // },49 ],50 },51 {52 type: 'section',53 fields: [54 {55 type: 'mrkdwn',56 text: `*Created:*\n${issue.created_at}`,57 },58 {59 type: 'mrkdwn',60 text: `*Author:*\n<${issue.user?.url}|${issue.user?.login}>`,61 },62 ],63 },64 ]65}
src/slackMessage.ts
1import { ApifyClient } from 'apify'2
3const SLACK_ACTOR_ID = 'katerinahronik/slack-message'4const MESSAGE_TEXT = 'New GitHub issue was discovered!'5
6type SlackIntegration = {7 channel: string8 token: string9 blocks: object[]10}11
12/**13 * Send a slack notification containing the provided message data.14 * Uses the Apify Actor katerinahronik/slack-message15 * @param slackIntegration connection data for the Slack integration16 * @returns promise of slack actor call17 */18export const sendSlackNotification = async (slackIntegration: SlackIntegration) => {19 const apifyClient = new ApifyClient({ token: process.env.APIFY_TOKEN })20 const slackActorClient = apifyClient.actor(SLACK_ACTOR_ID)21
22 const slackActorInput = {23 token: slackIntegration.token,24 channel: slackIntegration.channel,25 text: MESSAGE_TEXT,26 blocks: slackIntegration.blocks,27 }28
29 return slackActorClient.call(slackActorInput)30}
src/utils.ts
1export const parseRepoNameOrUrl = (nameOrUrl: string) => {2 const repo = nameOrUrl.replace('https://github.com/', '').replace('https://api.github.com/repos/', '')3 const splitRepoName = repo.split('/')4 if (splitRepoName.length !== 2) {5 throw new Error('Invalid repo name provided')6 }7 const [user, repoName] = splitRepoName8 return { user, repoName }9}
.dockerignore
# configurations.idea
# crawlee and apify storage foldersapify_storagecrawlee_storagestorage
# installed filesnode_modules
# git folder.git
# dist folderdist
.gitignore
storageapify_storagecrawlee_storagenode_modulesdisttsconfig.tsbuildinfostorage/*!storage/key_value_storesstorage/key_value_stores/*!storage/key_value_stores/defaultstorage/key_value_stores/default/*!storage/key_value_stores/default/INPUT.json
# Added by Apify CLI.venv
package.json
{ "name": "gh-issue-notifier", "version": "0.0.1", "type": "module", "description": "This is an example of an Apify actor.", "engines": { "node": ">=18.0.0" }, "dependencies": { "@octokit/openapi-types": "^18.0.0", "apify": "^3.1.4", "axios": "^1.4.0", "cheerio": "^1.0.0-rc.12", "octokit": "^3.1.0" }, "devDependencies": { "@apify/tsconfig": "^0.1.0", "@types/node": "^20.5.9", "ts-node": "^10.9.1", "typescript": "^5.0.4" }, "scripts": { "start": "npm run start:dev", "start:prod": "node dist/main.js", "start:dev": "node --loader ts-node/esm src/main.ts", "build": "tsc", "test": "echo \"Error: oops, the actor has no tests yet, sad!\" && exit 1" }, "author": "It's not you it's me", "license": "ISC"}
tsconfig.json
{ "extends": "@apify/tsconfig", "compilerOptions": { "module": "ES2022", "target": "ES2022", "outDir": "dist", "noUnusedLocals": false, "lib": ["DOM"] }, "ts-node": { "esm": true }, "include": ["./src/**/*"]}