Skip to content

Commit 22c7716

Browse files
Copilotbartlomiejuclaude
authored
ci: Add GHA automation for daily issue and PR insights (denoland#32449)
1. On each run, the script reads the last-run timestamp from hashy (or computes one from the `HOURS_LOOKBACK` input for manual runs) 2. Fetches all issues and PRs created since that timestamp via the GitHub API 3. Filters for "no response" items: issues with 0 comments, PRs with no comments and no reviews 4. Posts a Slack message with counts and linked lists of no-response items 5. Saves the current timestamp to hashy for the next run --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bartlomieju <13602871+bartlomieju@users.noreply.github.com> Co-authored-by: Bartek Iwańczuk <biwanczuk@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d143d7c commit 22c7716

File tree

2 files changed

+347
-0
lines changed

2 files changed

+347
-0
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: issue_pr_insights
2+
3+
on:
4+
schedule:
5+
- cron: '0 9 * * 1-5'
6+
workflow_dispatch:
7+
inputs:
8+
hours_lookback:
9+
description: 'Number of hours to look back for issues and PRs'
10+
required: true
11+
type: number
12+
13+
jobs:
14+
insights:
15+
runs-on: ubuntu-latest
16+
permissions:
17+
contents: read
18+
issues: read
19+
pull-requests: read
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v4
23+
- name: Setup Deno
24+
uses: denoland/setup-deno@v2
25+
- name: Post insights to Slack
26+
run: deno -A tools/issue_pr_insights.ts
27+
env:
28+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29+
SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
30+
SLACK_CHANNEL: ${{ secrets.SLACK_CHANNEL }}
31+
HOURS_LOOKBACK: ${{ inputs.hours_lookback }}

tools/issue_pr_insights.ts

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
// Copyright 2018-2026 the Deno authors. MIT license.
2+
3+
// deno-lint-ignore-file no-console camelcase
4+
5+
import { LogLevel, WebClient } from "npm:@slack/web-api@7.8.0";
6+
7+
const HASHY_URL = "https://hashy.deno.deno.net";
8+
const HASHY_KEY = "issue_pr_insights_last_run";
9+
const GITHUB_API = "https://api.github.com";
10+
const REPO_OWNER = "denoland";
11+
const REPO_NAME = "deno";
12+
13+
function getEnvOrExit(name: string): string {
14+
const value = Deno.env.get(name);
15+
if (!value) {
16+
console.error(`${name} is required`);
17+
Deno.exit(1);
18+
}
19+
return value;
20+
}
21+
22+
const token = getEnvOrExit("SLACK_TOKEN");
23+
const channel = getEnvOrExit("SLACK_CHANNEL");
24+
const githubToken = getEnvOrExit("GITHUB_TOKEN");
25+
const hoursInput = Deno.env.get("HOURS_LOOKBACK");
26+
27+
const client = new WebClient(token, {
28+
logLevel: LogLevel.DEBUG,
29+
});
30+
31+
const headers: Record<string, string> = {
32+
"Accept": "application/vnd.github+json",
33+
"Authorization": `Bearer ${githubToken}`,
34+
};
35+
36+
interface GitHubItem {
37+
number: number;
38+
title: string;
39+
html_url: string;
40+
created_at: string;
41+
/** Comment count from the GitHub API. For issues this counts all
42+
* comments; for PRs this only counts issue-style comments and does
43+
* NOT include review comments. */
44+
comments: number;
45+
pull_request?: unknown;
46+
}
47+
48+
async function getLastRunTimestamp(): Promise<string | null> {
49+
if (hoursInput) {
50+
const hours = parseInt(hoursInput, 10);
51+
if (isNaN(hours) || hours <= 0) {
52+
console.error(
53+
`Invalid HOURS_LOOKBACK value: "${hoursInput}". ` +
54+
"Must be a positive number.",
55+
);
56+
Deno.exit(1);
57+
}
58+
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
59+
return since.toISOString();
60+
}
61+
62+
try {
63+
const res = await fetch(`${HASHY_URL}/hashes/${HASHY_KEY}`, {
64+
signal: AbortSignal.timeout(5000),
65+
});
66+
if (res.ok) {
67+
const text = await res.text();
68+
if (text) {
69+
return text;
70+
}
71+
}
72+
return null;
73+
} catch {
74+
return null;
75+
}
76+
}
77+
78+
async function saveLastRunTimestamp(timestamp: string): Promise<void> {
79+
try {
80+
await fetch(`${HASHY_URL}/hashes/${HASHY_KEY}`, {
81+
method: "PUT",
82+
body: timestamp,
83+
signal: AbortSignal.timeout(5000),
84+
});
85+
console.log(`Saved last run timestamp: ${timestamp}`);
86+
} catch {
87+
console.error("Failed to save last run timestamp");
88+
}
89+
}
90+
91+
async function fetchGitHubItems(
92+
type: "issues" | "pulls",
93+
since: string,
94+
): Promise<GitHubItem[]> {
95+
const items: GitHubItem[] = [];
96+
let page = 1;
97+
const perPage = 100;
98+
99+
while (true) {
100+
const params = new URLSearchParams({
101+
state: "all",
102+
sort: "created",
103+
direction: "desc",
104+
per_page: String(perPage),
105+
page: String(page),
106+
});
107+
if (type === "issues") {
108+
params.set("since", since);
109+
}
110+
111+
const url =
112+
`${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/${type}?${params}`;
113+
const res = await fetch(url, { headers });
114+
115+
if (!res.ok) {
116+
console.error(
117+
`GitHub API error (${type} page ${page}): ` +
118+
`${res.status} ${res.statusText}`,
119+
);
120+
break;
121+
}
122+
123+
const data = await res.json() as GitHubItem[];
124+
if (data.length === 0) break;
125+
126+
for (const item of data) {
127+
if (new Date(item.created_at) >= new Date(since)) {
128+
items.push(item);
129+
} else if (type === "pulls") {
130+
// For pulls (sorted by created desc), once we pass the since date, stop
131+
return items;
132+
}
133+
}
134+
135+
if (data.length < perPage) break;
136+
page++;
137+
}
138+
139+
return items;
140+
}
141+
142+
async function hasReviewComments(prNumber: number): Promise<boolean> {
143+
const url = `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}` +
144+
`/pulls/${prNumber}/reviews`;
145+
const res = await fetch(url, { headers });
146+
if (!res.ok) return false;
147+
const reviews = await res.json() as unknown[];
148+
return reviews.length > 0;
149+
}
150+
151+
async function filterNoResponsePRs(
152+
prs: GitHubItem[],
153+
): Promise<GitHubItem[]> {
154+
const results: GitHubItem[] = [];
155+
for (const pr of prs) {
156+
if (pr.comments > 0) continue;
157+
if (await hasReviewComments(pr.number)) continue;
158+
results.push(pr);
159+
}
160+
return results;
161+
}
162+
163+
function formatItemList(items: GitHubItem[], max: number): string {
164+
if (items.length === 0) return "_None_\n";
165+
let text = "";
166+
for (const item of items.slice(0, max)) {
167+
text += `• <${item.html_url}|#${item.number}> ${item.title}\n`;
168+
}
169+
if (items.length > max) {
170+
text += `_...and ${items.length - max} more_\n`;
171+
}
172+
return text;
173+
}
174+
175+
interface SectionBlock {
176+
type: "section";
177+
text: { type: "mrkdwn"; text: string };
178+
}
179+
180+
interface DividerBlock {
181+
type: "divider";
182+
}
183+
184+
type Block = SectionBlock | DividerBlock;
185+
186+
function createBlocks(
187+
sinceDate: string,
188+
newIssues: GitHubItem[],
189+
newPRs: GitHubItem[],
190+
noResponseIssues: GitHubItem[],
191+
noResponsePRs: GitHubItem[],
192+
): Block[] {
193+
const sinceStr = new Date(sinceDate).toUTCString();
194+
const blocks: Block[] = [];
195+
196+
blocks.push({
197+
type: "section",
198+
text: {
199+
type: "mrkdwn",
200+
text: `*📊 Daily Issue & PR Insights*\n_Since ${sinceStr}_`,
201+
},
202+
});
203+
204+
blocks.push({ type: "divider" });
205+
206+
blocks.push({
207+
type: "section",
208+
text: {
209+
type: "mrkdwn",
210+
text: `*New Issues:* ${newIssues.length}\n*New PRs:* ${newPRs.length}`,
211+
},
212+
});
213+
214+
blocks.push({ type: "divider" });
215+
216+
let noResponseText =
217+
`*Issues with no response (${noResponseIssues.length}):*\n`;
218+
noResponseText += formatItemList(noResponseIssues, 15);
219+
blocks.push({
220+
type: "section",
221+
text: { type: "mrkdwn", text: noResponseText },
222+
});
223+
224+
let noResponsePRText = `*PRs with no response (${noResponsePRs.length}):*\n`;
225+
noResponsePRText += formatItemList(noResponsePRs, 15);
226+
blocks.push({
227+
type: "section",
228+
text: { type: "mrkdwn", text: noResponsePRText },
229+
});
230+
231+
return blocks;
232+
}
233+
234+
async function postErrorMessage(message: string): Promise<void> {
235+
await client.chat.postMessage({
236+
token,
237+
channel,
238+
blocks: [
239+
{
240+
type: "section",
241+
text: {
242+
type: "mrkdwn",
243+
text: `*⚠️ Issue & PR Insights Error*\n${message}`,
244+
},
245+
},
246+
],
247+
unfurl_links: false,
248+
unfurl_media: false,
249+
});
250+
}
251+
252+
async function main() {
253+
const now = new Date().toISOString();
254+
255+
const sinceDate = await getLastRunTimestamp();
256+
if (!sinceDate) {
257+
console.error("Could not determine last run timestamp");
258+
await postErrorMessage(
259+
"Could not determine last run timestamp. " +
260+
"The hashy service may be down or " +
261+
"there is no stored last-run value.",
262+
);
263+
// Still save the current timestamp so next run has a reference point
264+
await saveLastRunTimestamp(now);
265+
return;
266+
}
267+
268+
console.log(`Fetching issues and PRs since: ${sinceDate}`);
269+
270+
// The /issues endpoint returns both issues and PRs. We filter PRs out
271+
// by checking for `pull_request` field absence.
272+
const [allIssueItems, allPRs] = await Promise.all([
273+
fetchGitHubItems("issues", sinceDate),
274+
fetchGitHubItems("pulls", sinceDate),
275+
]);
276+
277+
// Filter out pull requests from the issues endpoint results
278+
const newIssues = allIssueItems.filter((item) => !item.pull_request);
279+
const newPRs = allPRs;
280+
281+
const noResponseIssues = newIssues.filter((i) => i.comments === 0);
282+
const noResponsePRs = await filterNoResponsePRs(newPRs);
283+
284+
console.log(`New issues: ${newIssues.length}`);
285+
console.log(`New PRs: ${newPRs.length}`);
286+
console.log(`Issues with no response: ${noResponseIssues.length}`);
287+
console.log(`PRs with no response: ${noResponsePRs.length}`);
288+
289+
const blocks = createBlocks(
290+
sinceDate,
291+
newIssues,
292+
newPRs,
293+
noResponseIssues,
294+
noResponsePRs,
295+
);
296+
297+
try {
298+
const result = await client.chat.postMessage({
299+
token,
300+
channel,
301+
blocks,
302+
unfurl_links: false,
303+
unfurl_media: false,
304+
});
305+
console.log("Message posted:", result.ok);
306+
} catch (error) {
307+
console.error("Failed to post Slack message:", error);
308+
}
309+
310+
// Save the current run timestamp (only when not using manual hours input)
311+
if (!hoursInput) {
312+
await saveLastRunTimestamp(now);
313+
}
314+
}
315+
316+
await main();

0 commit comments

Comments
 (0)