GitHub Issue Notifier avatar
GitHub Issue Notifier

Pricing

Pay per usage

Go to Store
GitHub Issue Notifier

GitHub Issue Notifier

Developed by

Jan Kuželík

Maintained by Community

0.0 (0)

Pricing

Pay per usage

2

Monthly users

1

Runs succeeded

>99%

Last modified

5 months ago

.actor/Dockerfile

1# Specify the base Docker image. You can read more about
2# the available images at https://docs.apify.com/sdk/js/docs/guides/docker-images
3# You can also use any other image from Docker Hub.
4FROM apify/actor-node:18 AS builder
5
6# Copy just package.json and package-lock.json
7# to speed up the build using Docker layer cache.
8COPY package*.json ./
9
10# Install all dependencies. Don't audit to speed up the installation.
11RUN npm install --include=dev --audit=false
12
13# Next, copy the source files using the user set
14# in the base image.
15COPY . ./
16
17# Install all dependencies and build the project.
18# Don't audit to speed up the installation.
19RUN npm run build
20
21# Create final image
22FROM apify/actor-node:18
23
24# Copy just package.json and package-lock.json
25# to speed up the build using Docker layer cache.
26COPY package*.json ./
27
28# Install NPM packages, skip optional and development dependencies to
29# keep the image small. Avoid logging too much and print the dependency
30# tree for debugging
31RUN npm --quiet set progress=false \
32    && npm install --omit=dev --omit=optional \
33    && echo "Installed NPM packages:" \
34    && (npm list --omit=dev --all || true) \
35    && echo "Node.js version:" \
36    && node --version \
37    && echo "NPM version:" \
38    && npm --version \
39    && rm -r ~/.npm
40
41# Copy built JS files from builder image
42COPY --from=builder /usr/src/app/dist ./dist
43
44# Next, copy the remaining files and directories with the source code.
45# Since we do this after NPM install, quick build will be really fast
46# for most source file changes.
47COPY . ./
48
49
50# Run the image.
51CMD npm run start:prod --silent

.actor/actor.json

1{
2	"actorSpecification": 1,
3	"name": "gh-issue-notifier",
4	"title": "Scrape single page in TypeScript",
5	"description": "Scrape data from single page with provided URL.",
6	"version": "0.0",
7	"meta": {
8		"templateId": "ts-start"
9	},
10	"input": "./input_schema.json",
11	"dockerfile": "./Dockerfile"
12}

.actor/input_schema.json

1{
2  "title": "Scrape data from a web page",
3  "type": "object",
4  "schemaVersion": 1,
5  "properties": {
6    "searchRepos": {
7      "title": "URL or names of repositories",
8      "type": "array",
9      "description": "The URLs or repository names of repositories you want to monitor.",
10      "editor": "stringList",
11      "prefill": []
12    },
13    "searchKeywords": {
14      "title": "Keywords to search for",
15      "type": "array",
16      "description": "The keywords that will be looked for in the titles and bodies of new issues.",
17      "editor": "stringList",
18      "prefill": []
19    },
20    "slackToken": {
21      "title": "Slack token",
22      "type": "string",
23      "description": "Slack API token in a format xoxp-xxxxxxxxx-xxxx.",
24      "editor": "textfield",
25      "isSecret": true
26    },
27    "slackChannel": {
28      "title": "Slack channel",
29      "type": "string",
30      "description": "Channel where the notification with Github issues information will be sent (e.g. #general)",
31      "prefill": "#general",
32      "editor": "textfield"
33    }
34  },
35  "required": ["searchRepos", "searchKeywords"]
36}

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 hash
7 * @returns hash string
8 */
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) Uint8Array
21  const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8) // hash the message
22  const hashArray = Array.from(new Uint8Array(hashBuffer)) // convert buffer to byte array
23  const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') // convert bytes to hex string
24  return hashHex
25}

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 type
13export type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[]
14  ? ElementType
15  : never
16export type Issue = RestEndpointMethodTypes['issues']['listForRepo']['response']
17
18interface Input {
19  searchRepos: string[]
20  searchKeywords: string[]
21  slackToken?: string
22  slackChannel?: string
23}
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 check
31 * @param searchKeywords keywords to look for, case is ignored
32 * @returns true if the issue is relevant
33 */
34const checkIssueRelevant = (issue: ArrElement<Issue['data']>, searchKeywords: string[]) => {
35  if (!issue || issue.state !== 'open') {
36    return false
37  }
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 true
44    }
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 true
52      }
53    }
54  }
55
56  return false
57}
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 text
62 * @param repoUrls urls of repos to search
63 * @param searchKeywords keywords to match in the search
64 * @param checkedFilter lambda to filter out issues that were already reported
65 * @throws when octokit issues fail to fetch
66 * @returns list of repos, each as a list of issues
67 */
68const findRelevantNewIssues = async (
69  repoUrls: string[],
70  searchKeywords: string[],
71  checkedFilter: (potentialNewIssue: ArrElement<Issue['data']>) => boolean
72) => {
73  let outputs: Issue['data'] = []
74  for (let url of repoUrls) {
75    let repoInfo
76    try {
77      repoInfo = parseRepoNameOrUrl(url)
78    } catch (e) {
79      if (e instanceof Error) {
80        log.error(e.message)
81      }
82      continue
83    }
84    const repoIssues = await octokit.rest.issues.listForRepo({
85      owner: repoInfo.user,
86      repo: repoInfo.repoName,
87    })
88
89    const relevantIssues = repoIssues.data
90      .filter(checkedFilter)
91      .filter((issue) => checkIssueRelevant(issue, searchKeywords))
92
93    if (relevantIssues.length === 0) {
94      continue // no relevant issues, next repo
95    }
96    outputs = outputs.concat(relevantIssues)
97  }
98  return outputs
99}
100
101/**
102 * Checks the data store for whether or not the issue was already reported.
103 * @param potentialNewIssue Issue to check
104 * @param lastCheckedAt timestamp of when the issues were last checked
105 * @returns true if issue was not reported yet
106 */
107const checkIssueNotReported = (
108  potentialNewIssue: ArrElement<Issue['data']>,
109  lastCheckedAt: number
110) => {
111  return Date.parse(potentialNewIssue.created_at) > lastCheckedAt
112}
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 timestamp
121  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    return
133  }
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    return
143  }
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.json
165const input = await Actor.getInput<Input>()
166if (!input) throw new Error('Input is missing!')
167const { searchRepos, searchKeywords } = input
168
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/building
6 * @param newIssue issue to make message for, as returned by octokit
7 * @returns Slack message Blocks summarising the new issue
8 */
9export const createSlackMessageBlocks = (newIssue: ArrElement<Issue['data']>) => {
10  let blocks: object[] = createTitleBlocks()
11  blocks = blocks.concat(createIssueBlocks(newIssue))
12  return blocks
13}
14
15/**
16 * Create Slack blocks for the title of the Slack message
17 * @returns slack blocks
18 */
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 blocks
35 */
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: string
8  token: string
9  blocks: object[]
10}
11
12/**
13 * Send a slack notification containing the provided message data.
14 * Uses the Apify Actor katerinahronik/slack-message
15 * @param slackIntegration connection data for the Slack integration
16 * @returns promise of slack actor call
17 */
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] = splitRepoName
8  return { user, repoName }
9}

.dockerignore

1# configurations
2.idea
3
4# crawlee and apify storage folders
5apify_storage
6crawlee_storage
7storage
8
9# installed files
10node_modules
11
12# git folder
13.git
14
15# dist folder
16dist

.gitignore

1storage
2apify_storage
3crawlee_storage
4node_modules
5dist
6tsconfig.tsbuildinfo
7storage/*
8!storage/key_value_stores
9storage/key_value_stores/*
10!storage/key_value_stores/default
11storage/key_value_stores/default/*
12!storage/key_value_stores/default/INPUT.json
13
14# Added by Apify CLI
15.venv

package.json

1{
2  "name": "gh-issue-notifier",
3  "version": "0.0.1",
4  "type": "module",
5  "description": "This is an example of an Apify actor.",
6  "engines": {
7    "node": ">=18.0.0"
8  },
9  "dependencies": {
10    "@octokit/openapi-types": "^18.0.0",
11    "apify": "^3.1.4",
12    "axios": "^1.4.0",
13    "cheerio": "^1.0.0-rc.12",
14    "octokit": "^3.1.0"
15  },
16  "devDependencies": {
17    "@apify/tsconfig": "^0.1.0",
18    "@types/node": "^20.5.9",
19    "ts-node": "^10.9.1",
20    "typescript": "^5.0.4"
21  },
22  "scripts": {
23    "start": "npm run start:dev",
24    "start:prod": "node dist/main.js",
25    "start:dev": "node --loader ts-node/esm src/main.ts",
26    "build": "tsc",
27    "test": "echo \"Error: oops, the actor has no tests yet, sad!\" && exit 1"
28  },
29  "author": "It's not you it's me",
30  "license": "ISC"
31}

tsconfig.json

1{
2  "extends": "@apify/tsconfig",
3  "compilerOptions": {
4    "module": "ES2022",
5    "target": "ES2022",
6    "outDir": "dist",
7    "noUnusedLocals": false,
8    "lib": ["DOM"]
9  },
10  "ts-node": { "esm": true },
11  "include": ["./src/**/*"]
12}

Pricing

Pricing model

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage.