pierre-review 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/api/plugins/error-handler.js +40 -0
- package/dist/api/routes/health.js +7 -0
- package/dist/api/routes/me.js +34 -0
- package/dist/api/routes/mergers.js +7 -0
- package/dist/api/routes/open-prs.js +21 -0
- package/dist/api/routes/prs.js +50 -0
- package/dist/api/routes/repos.js +188 -0
- package/dist/api/routes/threads.js +20 -0
- package/dist/api/routes/timeline.js +73 -0
- package/dist/api/routes/users.js +28 -0
- package/dist/app.js +48 -0
- package/dist/ascii.js +12 -0
- package/dist/cli.js +138 -0
- package/dist/config.js +41 -0
- package/dist/db/cleanup.js +22 -0
- package/dist/db/client.js +13 -0
- package/dist/db/migrate.js +15 -0
- package/dist/db/migrations/0000_purple_ben_grimm.sql +155 -0
- package/dist/db/migrations/0001_colorful_ozymandias.sql +31 -0
- package/dist/db/migrations/0002_famous_sersi.sql +9 -0
- package/dist/db/migrations/0003_clever_shinobi_shaw.sql +3 -0
- package/dist/db/migrations/0004_pale_scalphunter.sql +1 -0
- package/dist/db/migrations/0005_daffy_guardian.sql +2 -0
- package/dist/db/migrations/meta/0000_snapshot.json +1116 -0
- package/dist/db/migrations/meta/0001_snapshot.json +1321 -0
- package/dist/db/migrations/meta/0002_snapshot.json +1375 -0
- package/dist/db/migrations/meta/0003_snapshot.json +1396 -0
- package/dist/db/migrations/meta/0004_snapshot.json +1416 -0
- package/dist/db/migrations/meta/0005_snapshot.json +1430 -0
- package/dist/db/migrations/meta/_journal.json +48 -0
- package/dist/db/queries.js +837 -0
- package/dist/db/run-migrations.js +10 -0
- package/dist/db/schema.js +248 -0
- package/dist/db/triage.js +168 -0
- package/dist/github/auth.js +19 -0
- package/dist/github/client.js +30 -0
- package/dist/github/local-user.js +92 -0
- package/dist/github/queries.js +249 -0
- package/dist/index.js +49 -0
- package/dist/sync/bot-detection.js +24 -0
- package/dist/sync/commit-files.js +50 -0
- package/dist/sync/derive-thread-state.js +38 -0
- package/dist/sync/scheduler.js +28 -0
- package/dist/sync/sync-manager.js +150 -0
- package/dist/sync/sync-repo.js +122 -0
- package/dist/sync/upsert.js +528 -0
- package/package.json +46 -0
- package/public/assets/index-6p3C9xk7.css +10 -0
- package/public/assets/index-C-CZcLLq.js +1360 -0
- package/public/index.html +25 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// One query per repo per sync: PRs, reviews, review threads (+comments),
|
|
2
|
+
// general PR comments, and commits in a single round trip.
|
|
3
|
+
export const REPO_ACTIVITY_QUERY = /* GraphQL */ `
|
|
4
|
+
query RepoActivity($owner: String!, $name: String!, $cursor: String) {
|
|
5
|
+
repository(owner: $owner, name: $name) {
|
|
6
|
+
id
|
|
7
|
+
nameWithOwner
|
|
8
|
+
defaultBranchRef {
|
|
9
|
+
name
|
|
10
|
+
}
|
|
11
|
+
pullRequests(
|
|
12
|
+
first: 25
|
|
13
|
+
after: $cursor
|
|
14
|
+
orderBy: { field: UPDATED_AT, direction: DESC }
|
|
15
|
+
) {
|
|
16
|
+
pageInfo {
|
|
17
|
+
hasNextPage
|
|
18
|
+
endCursor
|
|
19
|
+
}
|
|
20
|
+
nodes {
|
|
21
|
+
id
|
|
22
|
+
number
|
|
23
|
+
title
|
|
24
|
+
body
|
|
25
|
+
isDraft
|
|
26
|
+
state
|
|
27
|
+
createdAt
|
|
28
|
+
mergedAt
|
|
29
|
+
closedAt
|
|
30
|
+
updatedAt
|
|
31
|
+
url
|
|
32
|
+
baseRefName
|
|
33
|
+
mergeable
|
|
34
|
+
mergeStateStatus
|
|
35
|
+
author {
|
|
36
|
+
login
|
|
37
|
+
... on User {
|
|
38
|
+
id
|
|
39
|
+
name
|
|
40
|
+
avatarUrl
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
mergedBy {
|
|
44
|
+
login
|
|
45
|
+
... on User {
|
|
46
|
+
id
|
|
47
|
+
name
|
|
48
|
+
avatarUrl
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
labels(first: 20) {
|
|
52
|
+
nodes {
|
|
53
|
+
name
|
|
54
|
+
color
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
reviewRequests(first: 20) {
|
|
58
|
+
nodes {
|
|
59
|
+
requestedReviewer {
|
|
60
|
+
__typename
|
|
61
|
+
... on User {
|
|
62
|
+
id
|
|
63
|
+
login
|
|
64
|
+
}
|
|
65
|
+
... on Team {
|
|
66
|
+
id
|
|
67
|
+
name
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
headCommit: commits(last: 1) {
|
|
73
|
+
nodes {
|
|
74
|
+
commit {
|
|
75
|
+
oid
|
|
76
|
+
statusCheckRollup {
|
|
77
|
+
state
|
|
78
|
+
contexts(first: 100) {
|
|
79
|
+
nodes {
|
|
80
|
+
__typename
|
|
81
|
+
... on CheckRun {
|
|
82
|
+
name
|
|
83
|
+
status
|
|
84
|
+
conclusion
|
|
85
|
+
detailsUrl
|
|
86
|
+
}
|
|
87
|
+
... on StatusContext {
|
|
88
|
+
context
|
|
89
|
+
state
|
|
90
|
+
targetUrl
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
commits(last: 100) {
|
|
99
|
+
nodes {
|
|
100
|
+
commit {
|
|
101
|
+
oid
|
|
102
|
+
committedDate
|
|
103
|
+
message
|
|
104
|
+
author {
|
|
105
|
+
user {
|
|
106
|
+
login
|
|
107
|
+
id
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
committer {
|
|
111
|
+
user {
|
|
112
|
+
login
|
|
113
|
+
id
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
reviews(first: 50) {
|
|
120
|
+
nodes {
|
|
121
|
+
id
|
|
122
|
+
fullDatabaseId
|
|
123
|
+
state
|
|
124
|
+
body
|
|
125
|
+
submittedAt
|
|
126
|
+
author {
|
|
127
|
+
login
|
|
128
|
+
... on User {
|
|
129
|
+
id
|
|
130
|
+
name
|
|
131
|
+
avatarUrl
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
reviewThreads(first: 50) {
|
|
137
|
+
nodes {
|
|
138
|
+
id
|
|
139
|
+
isResolved
|
|
140
|
+
isOutdated
|
|
141
|
+
isCollapsed
|
|
142
|
+
path
|
|
143
|
+
line
|
|
144
|
+
comments(first: 50) {
|
|
145
|
+
nodes {
|
|
146
|
+
id
|
|
147
|
+
fullDatabaseId
|
|
148
|
+
body
|
|
149
|
+
createdAt
|
|
150
|
+
diffHunk
|
|
151
|
+
author {
|
|
152
|
+
login
|
|
153
|
+
... on User {
|
|
154
|
+
id
|
|
155
|
+
name
|
|
156
|
+
avatarUrl
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
comments(first: 50) {
|
|
164
|
+
nodes {
|
|
165
|
+
id
|
|
166
|
+
fullDatabaseId
|
|
167
|
+
body
|
|
168
|
+
createdAt
|
|
169
|
+
author {
|
|
170
|
+
login
|
|
171
|
+
... on User {
|
|
172
|
+
id
|
|
173
|
+
name
|
|
174
|
+
avatarUrl
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
rateLimit {
|
|
183
|
+
remaining
|
|
184
|
+
resetAt
|
|
185
|
+
cost
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
`;
|
|
189
|
+
// Lightweight lookup used when adding a repo (just need the node id + canonical
|
|
190
|
+
// owner/name casing).
|
|
191
|
+
export const REPO_ID_QUERY = /* GraphQL */ `
|
|
192
|
+
query RepoId($owner: String!, $name: String!) {
|
|
193
|
+
repository(owner: $owner, name: $name) {
|
|
194
|
+
id
|
|
195
|
+
name
|
|
196
|
+
owner {
|
|
197
|
+
login
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
`;
|
|
202
|
+
// Live repository search for the Add-repo picker. `query` is the raw user term —
|
|
203
|
+
// GitHub "best match" ordering, identical to the github.com search box; it matches
|
|
204
|
+
// on name/description/topics, so no `owner/` prefix is required. `viewer` is folded
|
|
205
|
+
// into the same round trip so the route can float the user's own / org repos to the
|
|
206
|
+
// top without a second request. Open-PR count comes free via pullRequests.totalCount.
|
|
207
|
+
export const REPO_SEARCH_QUERY = /* GraphQL */ `
|
|
208
|
+
query RepoSearch($searchQuery: String!, $first: Int!, $cursor: String) {
|
|
209
|
+
search(query: $searchQuery, type: REPOSITORY, first: $first, after: $cursor) {
|
|
210
|
+
repositoryCount
|
|
211
|
+
pageInfo {
|
|
212
|
+
hasNextPage
|
|
213
|
+
endCursor
|
|
214
|
+
}
|
|
215
|
+
nodes {
|
|
216
|
+
... on Repository {
|
|
217
|
+
id
|
|
218
|
+
name
|
|
219
|
+
nameWithOwner
|
|
220
|
+
description
|
|
221
|
+
url
|
|
222
|
+
isPrivate
|
|
223
|
+
stargazerCount
|
|
224
|
+
owner {
|
|
225
|
+
login
|
|
226
|
+
avatarUrl
|
|
227
|
+
}
|
|
228
|
+
pullRequests(states: OPEN) {
|
|
229
|
+
totalCount
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
viewer {
|
|
235
|
+
login
|
|
236
|
+
organizations(first: 100) {
|
|
237
|
+
nodes {
|
|
238
|
+
login
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
rateLimit {
|
|
243
|
+
remaining
|
|
244
|
+
resetAt
|
|
245
|
+
cost
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
`;
|
|
249
|
+
//# sourceMappingURL=queries.js.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { buildApp } from './app.js';
|
|
3
|
+
import { config } from './config.js';
|
|
4
|
+
import { cleanupRedundantReviewEvents } from './db/cleanup.js';
|
|
5
|
+
import { runMigrations } from './db/run-migrations.js';
|
|
6
|
+
// Boot the server: migrate → cache the local user → build the Fastify app →
|
|
7
|
+
// start the scheduler → listen. Returns the listening Fastify instance and the
|
|
8
|
+
// resolved port so a caller (the CLI) can print the URL / open the browser.
|
|
9
|
+
export async function start() {
|
|
10
|
+
// Apply any pending migrations before serving.
|
|
11
|
+
runMigrations();
|
|
12
|
+
// Drop redundant empty-review-wrapper timeline events left by older syncs.
|
|
13
|
+
const removed = cleanupRedundantReviewEvents();
|
|
14
|
+
if (removed > 0)
|
|
15
|
+
console.log(`cleanup: removed ${removed} redundant review_submitted events`);
|
|
16
|
+
// Cache the locally-authenticated GitHub user up front so triage ("my turn")
|
|
17
|
+
// knows who "you" are. Non-fatal if gh isn't available.
|
|
18
|
+
const { ensureLocalUser } = await import('./github/local-user.js');
|
|
19
|
+
const me = ensureLocalUser();
|
|
20
|
+
const app = await buildApp();
|
|
21
|
+
if (me)
|
|
22
|
+
app.log.info(`local user: ${me.login}`);
|
|
23
|
+
else
|
|
24
|
+
app.log.warn('local user unknown (gh api user failed) — "my turn" disabled');
|
|
25
|
+
// Scheduler is wired in Phase 3; guarded so the skeleton runs without it.
|
|
26
|
+
if (!config.disableScheduler) {
|
|
27
|
+
try {
|
|
28
|
+
const { startScheduler } = await import('./sync/scheduler.js');
|
|
29
|
+
startScheduler(app.log);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
app.log.warn({ err }, 'scheduler not started');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
await app.listen({ port: config.port, host: config.host });
|
|
36
|
+
return { app, port: config.port };
|
|
37
|
+
}
|
|
38
|
+
// Run-as-main guard: only auto-boot when this module is the process entrypoint
|
|
39
|
+
// (e.g. `node dist/index.js` via the `start` script). When the CLI imports
|
|
40
|
+
// `start()`, this stays dormant so the server boots exactly once.
|
|
41
|
+
const isMain = process.argv[1] !== undefined &&
|
|
42
|
+
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
43
|
+
if (isMain) {
|
|
44
|
+
start().catch((err) => {
|
|
45
|
+
console.error('Failed to start backend:', err);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Known bot logins (without the [bot] suffix). Anything weird gets a manual
|
|
2
|
+
// override via PATCH /api/users/:id.
|
|
3
|
+
const KNOWN_BOTS = new Set([
|
|
4
|
+
'dependabot',
|
|
5
|
+
'renovate',
|
|
6
|
+
'github-actions',
|
|
7
|
+
'codecov',
|
|
8
|
+
'sonarcloud',
|
|
9
|
+
'snyk-bot',
|
|
10
|
+
'mergify',
|
|
11
|
+
'imgbot',
|
|
12
|
+
'allcontributors',
|
|
13
|
+
'pre-commit-ci',
|
|
14
|
+
'sonarqubecloud',
|
|
15
|
+
'coderabbitai',
|
|
16
|
+
]);
|
|
17
|
+
export function isLikelyBot(login) {
|
|
18
|
+
const lower = login.toLowerCase();
|
|
19
|
+
if (lower.endsWith('[bot]'))
|
|
20
|
+
return true;
|
|
21
|
+
const stripped = lower.replace(/\[bot\]$/, '');
|
|
22
|
+
return KNOWN_BOTS.has(stripped);
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=bot-detection.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { inArray } from 'drizzle-orm';
|
|
2
|
+
import { db, schema } from '../db/client.js';
|
|
3
|
+
import { ghRestGet } from '../github/client.js';
|
|
4
|
+
const { commitFiles } = schema;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve changed-file paths for the given commit SHAs, using the permanent
|
|
7
|
+
* `commit_files` cache and filling misses via REST. SHAs are immutable, so the
|
|
8
|
+
* cache never expires — re-syncs are free.
|
|
9
|
+
*/
|
|
10
|
+
export async function ensureCommitFiles(owner, name, shas) {
|
|
11
|
+
const result = new Map();
|
|
12
|
+
const unique = [...new Set(shas)];
|
|
13
|
+
if (unique.length === 0)
|
|
14
|
+
return result;
|
|
15
|
+
// Load whatever is already cached.
|
|
16
|
+
const cached = db
|
|
17
|
+
.select()
|
|
18
|
+
.from(commitFiles)
|
|
19
|
+
.where(inArray(commitFiles.sha, unique))
|
|
20
|
+
.all();
|
|
21
|
+
for (const row of cached)
|
|
22
|
+
result.set(row.sha, row.paths);
|
|
23
|
+
// Fetch cache misses with bounded concurrency. These REST calls dominate
|
|
24
|
+
// sync latency on a PR that just got several commits addressing threads;
|
|
25
|
+
// running them serially blocks the whole page loop. SHAs are immutable and
|
|
26
|
+
// the cache is idempotent, so parallelism is safe.
|
|
27
|
+
const missing = unique.filter((sha) => !result.has(sha));
|
|
28
|
+
const CONCURRENCY = 5;
|
|
29
|
+
const fetchOne = async (sha) => {
|
|
30
|
+
try {
|
|
31
|
+
const commit = await ghRestGet(`/repos/${owner}/${name}/commits/${sha}`);
|
|
32
|
+
const paths = (commit.files ?? []).map((f) => f.filename);
|
|
33
|
+
db.insert(commitFiles)
|
|
34
|
+
.values({ sha, paths })
|
|
35
|
+
.onConflictDoUpdate({ target: commitFiles.sha, set: { paths } })
|
|
36
|
+
.run();
|
|
37
|
+
result.set(sha, paths);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// A missing/forbidden commit shouldn't abort the whole sync; treat as
|
|
41
|
+
// "no known files" (derivation falls back to other signals).
|
|
42
|
+
result.set(sha, []);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
for (let i = 0; i < missing.length; i += CONCURRENCY) {
|
|
46
|
+
await Promise.all(missing.slice(i, i + CONCURRENCY).map(fetchOne));
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=commit-files.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a review thread into one of four states. The signal that makes this
|
|
3
|
+
* tool useful beyond GitHub's own UI.
|
|
4
|
+
*
|
|
5
|
+
* - `resolved` — GitHub marked the thread resolved.
|
|
6
|
+
* - `likely_addressed` — a commit touched the thread's file *after* the last
|
|
7
|
+
* comment. Heuristic: false positives when a file is
|
|
8
|
+
* touched for unrelated reasons, false negatives when
|
|
9
|
+
* feedback was addressed by deleting/renaming a file.
|
|
10
|
+
* - `replied_unresolved` — someone other than the original commenter replied,
|
|
11
|
+
* but no subsequent commit touched the file.
|
|
12
|
+
* - `untouched` — none of the above.
|
|
13
|
+
*
|
|
14
|
+
* @param prCommitsByDate PR commits sorted ascending by committedDate.
|
|
15
|
+
* @param commitFilesBySha SHA -> changed file paths.
|
|
16
|
+
*/
|
|
17
|
+
export function deriveThreadState(thread, prCommitsByDate, commitFilesBySha) {
|
|
18
|
+
if (thread.isResolved)
|
|
19
|
+
return 'resolved';
|
|
20
|
+
// A thread with no comments can't be classified further.
|
|
21
|
+
const lastComment = thread.comments.at(-1);
|
|
22
|
+
if (!lastComment)
|
|
23
|
+
return 'untouched';
|
|
24
|
+
const latestCommentAt = Date.parse(lastComment.createdAt);
|
|
25
|
+
const hasSubsequentCommitToFile = prCommitsByDate.some((c) => {
|
|
26
|
+
if (Date.parse(c.committedDate) <= latestCommentAt)
|
|
27
|
+
return false;
|
|
28
|
+
return (commitFilesBySha.get(c.oid) ?? []).includes(thread.path);
|
|
29
|
+
});
|
|
30
|
+
if (hasSubsequentCommitToFile)
|
|
31
|
+
return 'likely_addressed';
|
|
32
|
+
const firstAuthor = thread.comments[0]?.author?.login;
|
|
33
|
+
const hasReply = thread.comments.some((c) => c.author?.login && c.author.login !== firstAuthor);
|
|
34
|
+
if (hasReply)
|
|
35
|
+
return 'replied_unresolved';
|
|
36
|
+
return 'untouched';
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=derive-thread-state.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import cron, {} from 'node-cron';
|
|
2
|
+
import { config } from '../config.js';
|
|
3
|
+
import { syncAllRepos } from './sync-manager.js';
|
|
4
|
+
let task = null;
|
|
5
|
+
// node-cron incremental sync loop. Idempotent upserts make overlapping windows
|
|
6
|
+
// safe; syncAllRepos skips any repo already mid-sync.
|
|
7
|
+
export function startScheduler(log) {
|
|
8
|
+
if (task)
|
|
9
|
+
return;
|
|
10
|
+
if (!cron.validate(config.syncCron)) {
|
|
11
|
+
log.warn(`invalid SYNC_CRON "${config.syncCron}"; scheduler disabled`);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const logger = {
|
|
15
|
+
info: (m, ...a) => log.info(a.length ? { a } : {}, m),
|
|
16
|
+
warn: (m, ...a) => log.warn(a.length ? { a } : {}, m),
|
|
17
|
+
error: (m, ...a) => log.error(a.length ? { a } : {}, m),
|
|
18
|
+
};
|
|
19
|
+
task = cron.schedule(config.syncCron, () => {
|
|
20
|
+
void syncAllRepos(logger);
|
|
21
|
+
});
|
|
22
|
+
log.info(`scheduler started (cron "${config.syncCron}")`);
|
|
23
|
+
}
|
|
24
|
+
export function stopScheduler() {
|
|
25
|
+
task?.stop();
|
|
26
|
+
task = null;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=scheduler.js.map
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { db, schema } from '../db/client.js';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { syncRepo } from './sync-repo.js';
|
|
5
|
+
const { repos, syncState } = schema;
|
|
6
|
+
// In-memory record of which repos are mid-sync (status isn't persisted as
|
|
7
|
+
// "running" — it lives only for the lifetime of the process).
|
|
8
|
+
const running = new Set();
|
|
9
|
+
// Repos currently undergoing a user-initiated FULL ("deep") sync. The deep button
|
|
10
|
+
// fires a forced full sync on every repo at once; they finish at different times.
|
|
11
|
+
// While ANY deep sync is still in flight we skip the scheduled incremental run
|
|
12
|
+
// entirely — otherwise the cron starts a fresh incremental on each repo the moment
|
|
13
|
+
// its deep sync finishes, resetting that repo's progress bar to 0% mid-session.
|
|
14
|
+
const deepSyncing = new Set();
|
|
15
|
+
// True while a deep (forced-full) sync is in progress on any repo.
|
|
16
|
+
export function isDeepSyncActive() {
|
|
17
|
+
return deepSyncing.size > 0;
|
|
18
|
+
}
|
|
19
|
+
// Live progress for in-flight syncs, surfaced via getSyncStatus so the UI can
|
|
20
|
+
// show a determinate bar. Lives only for the duration of the run.
|
|
21
|
+
const progressByRepo = new Map();
|
|
22
|
+
function setSyncProgress(repoId, p) {
|
|
23
|
+
progressByRepo.set(repoId, p);
|
|
24
|
+
}
|
|
25
|
+
function clearSyncProgress(repoId) {
|
|
26
|
+
progressByRepo.delete(repoId);
|
|
27
|
+
}
|
|
28
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
export function isSyncRunning(repoId) {
|
|
30
|
+
return running.has(repoId);
|
|
31
|
+
}
|
|
32
|
+
export function getSyncStatus(repoId) {
|
|
33
|
+
const state = db
|
|
34
|
+
.select()
|
|
35
|
+
.from(syncState)
|
|
36
|
+
.where(eq(syncState.repoId, repoId))
|
|
37
|
+
.get();
|
|
38
|
+
let status = 'idle';
|
|
39
|
+
if (running.has(repoId))
|
|
40
|
+
status = 'running';
|
|
41
|
+
else if (state?.lastSyncStatus === 'error')
|
|
42
|
+
status = 'error';
|
|
43
|
+
else if (state?.lastSyncStatus === 'ok')
|
|
44
|
+
status = 'ok';
|
|
45
|
+
return {
|
|
46
|
+
repoId,
|
|
47
|
+
status,
|
|
48
|
+
progress: status === 'running' ? progressByRepo.get(repoId) ?? null : null,
|
|
49
|
+
lastFullSyncAt: state?.lastFullSyncAt?.toISOString() ?? null,
|
|
50
|
+
lastIncrementalSyncAt: state?.lastIncrementalSyncAt?.toISOString() ?? null,
|
|
51
|
+
lastSyncError: state?.lastSyncError ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function getRepoRow(repoId) {
|
|
55
|
+
return (db
|
|
56
|
+
.select({ id: repos.id, owner: repos.owner, name: repos.name })
|
|
57
|
+
.from(repos)
|
|
58
|
+
.where(eq(repos.id, repoId))
|
|
59
|
+
.get() ?? null);
|
|
60
|
+
}
|
|
61
|
+
// Decide window: incremental if we've ever synced, otherwise a full backfill.
|
|
62
|
+
function planSync(repoId) {
|
|
63
|
+
const state = db
|
|
64
|
+
.select()
|
|
65
|
+
.from(syncState)
|
|
66
|
+
.where(eq(syncState.repoId, repoId))
|
|
67
|
+
.get();
|
|
68
|
+
if (state?.lastIncrementalSyncAt) {
|
|
69
|
+
const since = new Date(state.lastIncrementalSyncAt.getTime() - config.syncOverlapMinutes * 60 * 1000);
|
|
70
|
+
return { mode: 'incremental', since };
|
|
71
|
+
}
|
|
72
|
+
return { mode: 'full', since: new Date(Date.now() - config.backfillDays * DAY_MS) };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Run a sync for one repo. When `background` is true (the default for the API),
|
|
76
|
+
* returns immediately and the sync continues; the running flag and sync_state
|
|
77
|
+
* reflect progress. Returns false if a sync is already in flight.
|
|
78
|
+
*/
|
|
79
|
+
export function runSyncForRepo(repoId, log, opts = {}) {
|
|
80
|
+
if (running.has(repoId))
|
|
81
|
+
return false;
|
|
82
|
+
const repo = getRepoRow(repoId);
|
|
83
|
+
if (!repo)
|
|
84
|
+
return false;
|
|
85
|
+
const plan = opts.forceFull
|
|
86
|
+
? { mode: 'full', since: new Date(Date.now() - config.backfillDays * DAY_MS) }
|
|
87
|
+
: planSync(repoId);
|
|
88
|
+
running.add(repoId);
|
|
89
|
+
// Track forced-full runs so the scheduler stands down for the whole deep-sync
|
|
90
|
+
// session (added synchronously here, before any await, so a cron tick that
|
|
91
|
+
// fires right after this call already sees the deep sync as active).
|
|
92
|
+
if (opts.forceFull)
|
|
93
|
+
deepSyncing.add(repoId);
|
|
94
|
+
setSyncProgress(repoId, { percent: 0, prsProcessed: 0, pages: 0, mode: plan.mode });
|
|
95
|
+
const task = syncRepo({
|
|
96
|
+
owner: repo.owner,
|
|
97
|
+
name: repo.name,
|
|
98
|
+
...plan,
|
|
99
|
+
log,
|
|
100
|
+
onProgress: (p) => setSyncProgress(repoId, { ...p, mode: plan.mode }),
|
|
101
|
+
})
|
|
102
|
+
.catch((err) => {
|
|
103
|
+
log.error(`background sync ${repo.owner}/${repo.name} failed: ${err instanceof Error ? err.message : err}`);
|
|
104
|
+
})
|
|
105
|
+
.finally(() => {
|
|
106
|
+
running.delete(repoId);
|
|
107
|
+
deepSyncing.delete(repoId);
|
|
108
|
+
clearSyncProgress(repoId);
|
|
109
|
+
});
|
|
110
|
+
if (!opts.background)
|
|
111
|
+
return Boolean(task);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
/** Incrementally sync every configured repo (used by the scheduler). */
|
|
115
|
+
export async function syncAllRepos(log) {
|
|
116
|
+
// Stand down entirely while a deep (forced-full) sync is in progress. Resuming
|
|
117
|
+
// a repo incrementally the instant its deep sync finishes would reset its
|
|
118
|
+
// progress bar mid-session; idempotent upserts + the overlap window mean the
|
|
119
|
+
// next scheduled tick loses nothing by waiting.
|
|
120
|
+
if (deepSyncing.size > 0) {
|
|
121
|
+
log.info(`scheduled sync skipped: deep sync in progress (${deepSyncing.size} repo(s))`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const all = db.select({ id: repos.id }).from(repos).all();
|
|
125
|
+
for (const r of all) {
|
|
126
|
+
if (running.has(r.id))
|
|
127
|
+
continue;
|
|
128
|
+
running.add(r.id);
|
|
129
|
+
try {
|
|
130
|
+
const repo = getRepoRow(r.id);
|
|
131
|
+
const plan = planSync(r.id);
|
|
132
|
+
setSyncProgress(r.id, { percent: 0, prsProcessed: 0, pages: 0, mode: plan.mode });
|
|
133
|
+
await syncRepo({
|
|
134
|
+
owner: repo.owner,
|
|
135
|
+
name: repo.name,
|
|
136
|
+
...plan,
|
|
137
|
+
log,
|
|
138
|
+
onProgress: (p) => setSyncProgress(r.id, { ...p, mode: plan.mode }),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
log.error(`scheduled sync of repo ${r.id} failed: ${err instanceof Error ? err.message : err}`);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
running.delete(r.id);
|
|
146
|
+
clearSyncProgress(r.id);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=sync-manager.js.map
|