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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/dist/api/plugins/error-handler.js +40 -0
  4. package/dist/api/routes/health.js +7 -0
  5. package/dist/api/routes/me.js +34 -0
  6. package/dist/api/routes/mergers.js +7 -0
  7. package/dist/api/routes/open-prs.js +21 -0
  8. package/dist/api/routes/prs.js +50 -0
  9. package/dist/api/routes/repos.js +188 -0
  10. package/dist/api/routes/threads.js +20 -0
  11. package/dist/api/routes/timeline.js +73 -0
  12. package/dist/api/routes/users.js +28 -0
  13. package/dist/app.js +48 -0
  14. package/dist/ascii.js +12 -0
  15. package/dist/cli.js +138 -0
  16. package/dist/config.js +41 -0
  17. package/dist/db/cleanup.js +22 -0
  18. package/dist/db/client.js +13 -0
  19. package/dist/db/migrate.js +15 -0
  20. package/dist/db/migrations/0000_purple_ben_grimm.sql +155 -0
  21. package/dist/db/migrations/0001_colorful_ozymandias.sql +31 -0
  22. package/dist/db/migrations/0002_famous_sersi.sql +9 -0
  23. package/dist/db/migrations/0003_clever_shinobi_shaw.sql +3 -0
  24. package/dist/db/migrations/0004_pale_scalphunter.sql +1 -0
  25. package/dist/db/migrations/0005_daffy_guardian.sql +2 -0
  26. package/dist/db/migrations/meta/0000_snapshot.json +1116 -0
  27. package/dist/db/migrations/meta/0001_snapshot.json +1321 -0
  28. package/dist/db/migrations/meta/0002_snapshot.json +1375 -0
  29. package/dist/db/migrations/meta/0003_snapshot.json +1396 -0
  30. package/dist/db/migrations/meta/0004_snapshot.json +1416 -0
  31. package/dist/db/migrations/meta/0005_snapshot.json +1430 -0
  32. package/dist/db/migrations/meta/_journal.json +48 -0
  33. package/dist/db/queries.js +837 -0
  34. package/dist/db/run-migrations.js +10 -0
  35. package/dist/db/schema.js +248 -0
  36. package/dist/db/triage.js +168 -0
  37. package/dist/github/auth.js +19 -0
  38. package/dist/github/client.js +30 -0
  39. package/dist/github/local-user.js +92 -0
  40. package/dist/github/queries.js +249 -0
  41. package/dist/index.js +49 -0
  42. package/dist/sync/bot-detection.js +24 -0
  43. package/dist/sync/commit-files.js +50 -0
  44. package/dist/sync/derive-thread-state.js +38 -0
  45. package/dist/sync/scheduler.js +28 -0
  46. package/dist/sync/sync-manager.js +150 -0
  47. package/dist/sync/sync-repo.js +122 -0
  48. package/dist/sync/upsert.js +528 -0
  49. package/package.json +46 -0
  50. package/public/assets/index-6p3C9xk7.css +10 -0
  51. package/public/assets/index-C-CZcLLq.js +1360 -0
  52. 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