-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpoll.js
More file actions
180 lines (154 loc) · 5.83 KB
/
Copy pathpoll.js
File metadata and controls
180 lines (154 loc) · 5.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
// Poll logic — pure module, no Chrome API dependency.
// Input: settings + fetcher. Output: results + badge count.
// Supports multiple tokens: polls each token concurrently and merges results.
import { computeAttentionSet } from './attention.js';
import { applyRepoFilter } from './utils.js';
/**
* Fetch helper that throws on non-OK responses.
* When useEtag is true and an etagCache map is provided, sends If-None-Match
* and returns cached data on 304.
*/
async function ghFetch(path, token, fetcher = fetch, { useEtag = false, etagCache = null } = {}) {
const url = `https://api.github.com${path}`;
const headers = { Authorization: `token ${token}`, Accept: 'application/vnd.github.v3+json' };
if (useEtag && etagCache) {
const cached = etagCache.get(url);
if (cached?.etag) {
headers['If-None-Match'] = cached.etag;
}
}
const res = await fetcher(url, { headers });
if (useEtag && etagCache && res.status === 304) {
const cached = etagCache.get(url);
if (cached?.data) return cached.data;
// 304 but no cached data — re-fetch without ETag
const retry = await fetcher(url, {
headers: { Authorization: `token ${token}`, Accept: 'application/vnd.github.v3+json' },
});
if (!retry.ok) throw new Error(`GitHub API ${retry.status}`);
return retry.json();
}
if (!res.ok) throw new Error(`GitHub API ${res.status}`);
const data = await res.json();
if (useEtag && etagCache) {
const etag = typeof res.headers?.get === 'function' ? res.headers.get('etag') : null;
if (etag) {
etagCache.set(url, { etag, data });
}
}
return data;
}
/**
* Migrate old storage format to new tokens array.
*/
export function migrateTokens(stored) {
if (stored.tokens && Array.isArray(stored.tokens) && stored.tokens.length > 0) {
return stored.tokens;
}
if (stored.token) {
return [{ name: 'Default', token: stored.token }];
}
return [];
}
/**
* Poll a single token: fetch user, PRs, timelines, compute attention sets.
* Returns { results, username }
*/
async function pollSingleToken(tokenEntry, settings, fetcher, etagCache = null) {
const { token, name } = tokenEntry;
const user = await ghFetch('/user', token, fetcher);
const username = user.login;
const prs = await ghFetch(`/search/issues?q=involves:${username}+is:pr+is:open&per_page=50`, token, fetcher);
const CONCURRENCY = 6;
const items = prs.items || [];
const results = [];
for (let i = 0; i < items.length; i += CONCURRENCY) {
const batch = items.slice(i, i + CONCURRENCY);
const batchResults = await Promise.all(
batch.map(async (pr) => {
const [owner, repo] = pr.repository_url.replace('https://api.github.com/repos/', '').split('/');
const number = pr.number;
let timeline;
try {
// Paginate timeline (max 3 pages = 300 events)
timeline = [];
for (let page = 1; page <= 3; page++) {
const batch = await ghFetch(
`/repos/${owner}/${repo}/issues/${number}/timeline?per_page=100&page=${page}`,
token,
fetcher,
{ useEtag: true, etagCache },
);
timeline.push(...batch);
if (batch.length < 100) break; // no more pages
}
} catch {
timeline = [];
}
const attention = computeAttentionSet(timeline, username, pr.user.login, settings.debounceMinutes);
let lastEventAt = 0;
for (const event of timeline) {
const ts = new Date(event.created_at || event.submitted_at || 0).getTime();
if (ts > lastEventAt) lastEventAt = ts;
}
return {
id: pr.id,
number,
title: pr.title,
url: pr.html_url,
repo: `${owner}/${repo}`,
author: pr.user.login,
attentionSet: attention.set,
myStatus: attention.myStatus,
myRole: attention.myRole,
incomingDetail: attention.incomingDetail,
lastEventAt,
account: name || username,
};
}),
);
results.push(...batchResults);
}
return { results, username };
}
/**
* Core poll logic: fetch PRs for all tokens, merge, deduplicate, return results.
* @param {object} settings - { token?, tokens?, debounceMinutes, pollMinutes, notifications }
* @param {object} opts - { fetcher, repoFilterMode, repoFilterList, dismissed }
* @returns {{ results, username, usernames, needsAttention, filteredResults, error? }}
*/
export async function poll(settings, opts = {}) {
const { fetcher = fetch, repoFilterMode = 'all', repoFilterList = '', dismissed = {}, etagCache = null } = opts;
// Resolve tokens array (backward compat)
const tokens = migrateTokens(settings);
if (tokens.length === 0) {
return { results: [], username: null, usernames: [], needsAttention: 0, filteredResults: [], error: null };
}
// Poll all tokens concurrently
const tokenResults = await Promise.all(tokens.map((entry) => pollSingleToken(entry, settings, fetcher, etagCache)));
// Merge and deduplicate by PR URL (first occurrence wins — keeps attention from first token that sees it)
const seen = new Set();
const mergedResults = [];
const usernames = [];
for (const { results, username } of tokenResults) {
if (!usernames.includes(username)) usernames.push(username);
for (const pr of results) {
if (!seen.has(pr.url)) {
seen.add(pr.url);
mergedResults.push(pr);
}
}
}
// Apply repo filter
const filteredResults = applyRepoFilter(mergedResults, repoFilterMode, repoFilterList);
// Badge count: red status and not dismissed
const needsAttention = filteredResults.filter((r) => r.myStatus === 'red' && !dismissed[r.url]).length;
return {
results: mergedResults,
username: usernames[0] || null,
usernames,
needsAttention,
filteredResults,
error: null,
};
}