1
2import * as cheerio from 'cheerio'
3
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
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
29
30
31
32
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
61
62
63
64
65
66
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
95 }
96 outputs = outputs.concat(relevantIssues)
97 }
98 return outputs
99}
100
101
102
103
104
105
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
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
162await Actor.init()
163
164
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
181await Actor.exit()