agents-relay 1.0.137 → 1.0.139
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/dist/dashboard-html.js +2 -1
- package/dist/pool.js +17 -8
- package/dist/relayd.js +12 -9
- package/dist/store-github.js +83 -34
- package/dist/store.js +59 -12
- package/package.json +1 -1
package/dist/dashboard-html.js
CHANGED
|
@@ -372,7 +372,8 @@ function usageProvider(provider){const card=element('section','usage-provider');
|
|
|
372
372
|
function formatCredit(value,currency){if(value===null||value===undefined||!Number.isFinite(Number(value)))return '—';const number=Number(value);if(currency==='USD')return '$'+number.toFixed(2);if(currency==='CNY')return '¥'+number.toFixed(2);return number.toFixed(2)+' '+currency;}
|
|
373
373
|
function creditProvider(provider){const row=element('section','usage-provider');row.append(element('h3','',text(provider.label)));const credits=provider.credits||{};if(!credits.available||!Array.isArray(credits.balances)||!credits.balances.length){row.append(element('p','empty',text(credits.reason||'Credit balance is unavailable.')));return row;}const balance=credits.balances[0];row.append(element('div','detail-value',text(formatCredit(balance.remaining,balance.currency))));return row;}
|
|
374
374
|
function githubBucket(label,bucket){const row=element('div','usage-row');const heading=element('div','usage-label');heading.append(element('span','',text(label)),element('span','',text(bucket.remaining+' remaining')));row.append(heading);row.append(element('div','subtle',text(bucket.used+' used / '+bucket.limit+' limit')));const reset=resetText({resetsAt:bucket.reset});if(reset)row.append(element('div','subtle',text(reset)));return row;}
|
|
375
|
-
function
|
|
375
|
+
function githubCounterLine(label,values){const entries=Object.entries(values||{}).sort((a,b)=>Number(b[1])-Number(a[1])).slice(0,8);return entries.length?element('div','subtle',text(label+' · '+entries.map(([key,count])=>key+' '+count).join(' · '))):null;}
|
|
376
|
+
function githubUsageCard(snapshot,status){const card=element('section','usage-provider');card.append(element('h3','',text('GitHub API')));const resources=snapshot?.resources||{};const bars=element('div','usage-bars');if(resources.core)bars.append(githubBucket('REST core',resources.core));if(resources.graphql)bars.append(githubBucket('GraphQL',resources.graphql));if(resources.search)bars.append(githubBucket('Search',resources.search));if(!bars.children.length)bars.append(element('p','empty',text('GitHub quota telemetry is unavailable.')));if(status?.limited)bars.append(element('div','error-state',text('Relay cooldown active'+(status.cooldownUntil?' until '+new Date(status.cooldownUntil).toLocaleString():''))));else bars.append(element('div','subtle',text('Relay cooldown inactive')));if(status?.calls)bars.append(element('div','subtle',text('Calls since start · GraphQL '+(status.calls.graphql||0)+' · REST '+(status.calls.rest||0)+' · quota '+(status.calls.rateLimit||0))));const operations=githubCounterLine('GraphQL operations',status?.graphql?.operations);if(operations)bars.append(operations);const contexts=githubCounterLine('GraphQL contexts',status?.graphql?.contexts);if(contexts)bars.append(contexts);if(status?.readPaths)bars.append(element('div','subtle',text('Read paths · workspace scans '+(status.readPaths.workspaceScans||0)+' · PR refreshes '+(status.readPaths.prRefreshes||0))));card.append(bars);return card;}
|
|
376
377
|
async function drawUsage(){try{const overview=await loadJSON('/api/usage');usageRoot.replaceChildren();usageMeta.textContent='Real account telemetry when available';const providers=Array.isArray(overview.providers)?overview.providers:[];const credits=Array.isArray(overview.credits)?overview.credits:[];const grid=element('div','usage-providers');if(overview.github||overview.githubStatus)grid.append(githubUsageCard(overview.github,overview.githubStatus));for(const provider of providers)grid.append(usageProvider(provider));for(const provider of credits)grid.append(creditProvider(provider));if(!grid.children.length){usageRoot.append(element('p','empty',text('Usage telemetry is unavailable.')));return;}usageRoot.append(grid);}catch(error){usageMeta.textContent='Unavailable';usageRoot.replaceChildren(element('p','error-state',text('Usage unavailable: '+error.message)));}}
|
|
377
378
|
async function drawRules(){rulesStatus.textContent='';try{const rules=await loadJSON('/api/model-router-rules');rulesText.value=typeof rules.text==='string'?rules.text:'';rulesMeta.textContent=rules.hash&&rules.hash!=='none'?'Loaded · '+rules.hash.slice(0,12):'No custom rules saved';}catch(error){rulesMeta.textContent='Unavailable';rulesStatus.textContent='Could not load rules: '+error.message;}}
|
|
378
379
|
async function saveRules(){rulesSave.disabled=true;rulesStatus.textContent='Saving…';try{const response=await fetch('/api/model-router-rules',{method:'PUT',headers:{'content-type':'text/plain; charset=utf-8'},body:rulesText.value});if(!response.ok)throw new Error(response.status+' '+await response.text());const rules=await response.json();rulesMeta.textContent='Saved · '+rules.hash.slice(0,12);rulesStatus.textContent='Saved. New routing decisions will load these rules.';}catch(error){rulesStatus.textContent='Save failed: '+error.message;}finally{rulesSave.disabled=false;}}
|
package/dist/pool.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GitHubStore, listManagedGitHubJobs } from './store.js';
|
|
1
|
+
import { GitHubStore, listManagedGitHubJobs, withGitHubRequestContext } from './store.js';
|
|
2
2
|
import { eventFor } from './events.js';
|
|
3
3
|
const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
4
4
|
export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
@@ -28,6 +28,7 @@ export class RepositoryWorkerPool {
|
|
|
28
28
|
reconcileLocks = new Map();
|
|
29
29
|
targetedReconcileFlights = new Map();
|
|
30
30
|
jobTargets = new Map();
|
|
31
|
+
targetSeeds = new Map();
|
|
31
32
|
constructor(client, repository, trustedAuthors, concurrency, makeReconciler, isRateLimited = () => false) {
|
|
32
33
|
this.client = client;
|
|
33
34
|
this.trustedAuthors = trustedAuthors;
|
|
@@ -60,7 +61,7 @@ export class RepositoryWorkerPool {
|
|
|
60
61
|
for (const job of terminalManagedJobs(discovered)) {
|
|
61
62
|
await this.reconcileExclusive(job.id, async () => {
|
|
62
63
|
const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
|
|
63
|
-
await this.makeReconciler(store, 0).reconcile(job.id);
|
|
64
|
+
await withGitHubRequestContext(this.client, 'pool.terminal-reconcile', () => this.makeReconciler(store, 0).reconcile(job.id));
|
|
64
65
|
});
|
|
65
66
|
}
|
|
66
67
|
const jobs = runnableManagedJobs(discovered);
|
|
@@ -75,7 +76,7 @@ export class RepositoryWorkerPool {
|
|
|
75
76
|
continue;
|
|
76
77
|
const allowance = Math.max(1, Math.min(capacity, ready || (expiredRunning ? 1 : 0) || 1));
|
|
77
78
|
await this.reconcileExclusive(job.id, async () => {
|
|
78
|
-
await this.makeReconciler(store, allowance).reconcile(job.id);
|
|
79
|
+
await withGitHubRequestContext(this.client, 'pool.discovered-reconcile', () => this.makeReconciler(store, allowance).reconcile(job.id));
|
|
79
80
|
});
|
|
80
81
|
capacity -= allowance;
|
|
81
82
|
}
|
|
@@ -92,13 +93,18 @@ export class RepositoryWorkerPool {
|
|
|
92
93
|
try {
|
|
93
94
|
do {
|
|
94
95
|
this.pending = false;
|
|
95
|
-
await this.reconcileDiscovered(await aggregateManagedGitHubJobs(this.client, this.repositories, this.trustedAuthors));
|
|
96
|
+
await this.reconcileDiscovered(await withGitHubRequestContext(this.client, 'pool.workspace-scan', () => aggregateManagedGitHubJobs(this.client, this.repositories, this.trustedAuthors)));
|
|
96
97
|
} while (this.pending);
|
|
97
98
|
}
|
|
98
99
|
finally {
|
|
99
100
|
this.running = false;
|
|
100
101
|
}
|
|
101
102
|
}
|
|
103
|
+
seedTargetSnapshot(repository, prNumber, jobId, seed) {
|
|
104
|
+
if (!this.repositories.includes(repository))
|
|
105
|
+
return;
|
|
106
|
+
this.targetSeeds.set(`${repository}#${prNumber}:${jobId}`, seed);
|
|
107
|
+
}
|
|
102
108
|
async reconcileTarget(repository, prNumber, jobId) {
|
|
103
109
|
if (this.isRateLimited() || !this.repositories.includes(repository))
|
|
104
110
|
return;
|
|
@@ -108,8 +114,11 @@ export class RepositoryWorkerPool {
|
|
|
108
114
|
if (existing)
|
|
109
115
|
return existing;
|
|
110
116
|
const flight = this.reconcileExclusive(jobId, async () => {
|
|
111
|
-
const
|
|
112
|
-
|
|
117
|
+
const seedKey = `${repository}#${prNumber}:${jobId}`;
|
|
118
|
+
const seed = this.targetSeeds.get(seedKey) ?? null;
|
|
119
|
+
this.targetSeeds.delete(seedKey);
|
|
120
|
+
const store = new GitHubStore(this.client, repository, prNumber, this.trustedAuthors, seed);
|
|
121
|
+
await withGitHubRequestContext(this.client, seed ? 'pool.targeted-reconcile.seeded' : 'pool.targeted-reconcile', () => this.makeReconciler(store, Math.max(1, this.concurrency)).reconcile(jobId));
|
|
113
122
|
});
|
|
114
123
|
this.targetedReconcileFlights.set(key, flight);
|
|
115
124
|
try {
|
|
@@ -130,9 +139,9 @@ export class RepositoryWorkerPool {
|
|
|
130
139
|
}
|
|
131
140
|
const store = new GitHubStore(this.client, target.repository, target.prNumber, this.trustedAuthors);
|
|
132
141
|
const reconciler = this.makeReconciler(store, Math.max(1, this.concurrency));
|
|
133
|
-
await reconciler.handleEvent(event);
|
|
142
|
+
await withGitHubRequestContext(this.client, `pool.event.${event.type}`, () => reconciler.handleEvent(event));
|
|
134
143
|
if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type))
|
|
135
|
-
await reconciler.reconcile(event.job_id);
|
|
144
|
+
await withGitHubRequestContext(this.client, `pool.event-reconcile.${event.type}`, () => reconciler.reconcile(event.job_id));
|
|
136
145
|
}
|
|
137
146
|
async watch(bus) {
|
|
138
147
|
return bus.subscribeAll(async (event) => {
|
package/dist/relayd.js
CHANGED
|
@@ -9,7 +9,7 @@ import { serveDashboard } from './dashboard.js';
|
|
|
9
9
|
import { NatsEventBus } from './events.js';
|
|
10
10
|
import { RepositoryWorkerPool, aggregateManagedGitHubJobs } from './pool.js';
|
|
11
11
|
import { Reconciler } from './reconciler.js';
|
|
12
|
-
import { dashboardManagedJobs, GitHubStore, InMemoryStore,
|
|
12
|
+
import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestSnapshotGraphql, withGitHubRequestContext } from './store.js';
|
|
13
13
|
import { githubAuthContext } from './github-auth.js';
|
|
14
14
|
import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
|
|
15
15
|
import { dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
|
|
@@ -119,7 +119,7 @@ export async function runDaemon(argv) {
|
|
|
119
119
|
return cachedOverviews;
|
|
120
120
|
try {
|
|
121
121
|
githubReadPaths.workspaceScans += 1;
|
|
122
|
-
const refreshed = await aggregateManagedGitHubJobs(client, scanRepositories, trusted);
|
|
122
|
+
const refreshed = await withGitHubRequestContext(client, 'relayd.workspace-refresh', () => aggregateManagedGitHubJobs(client, scanRepositories, trusted));
|
|
123
123
|
const scanned = new Set(scanRepositories);
|
|
124
124
|
cachedOverviews = dashboardManagedJobs([...cachedOverviews.filter(item => !scanned.has(item.job.repository)), ...refreshed]);
|
|
125
125
|
try {
|
|
@@ -143,21 +143,21 @@ export async function runDaemon(argv) {
|
|
|
143
143
|
catch { /* cache is best effort */ }
|
|
144
144
|
};
|
|
145
145
|
const pullRequestRefreshFlights = new Map();
|
|
146
|
-
const
|
|
146
|
+
const refreshPullRequestSnapshot = async (repository, prNumber) => {
|
|
147
147
|
if (client.rateLimitStatus().limited)
|
|
148
|
-
return cachedOverviews.filter(item => item.job.repository === repository && item.job.prNumber === prNumber);
|
|
148
|
+
return { overviews: cachedOverviews.filter(item => item.job.repository === repository && item.job.prNumber === prNumber), seed: { prNode: {}, comments: null } };
|
|
149
149
|
const key = `${repository}#${prNumber}`;
|
|
150
150
|
const existing = pullRequestRefreshFlights.get(key);
|
|
151
151
|
if (existing)
|
|
152
152
|
return existing;
|
|
153
153
|
const flight = (async () => {
|
|
154
154
|
githubReadPaths.prRefreshes += 1;
|
|
155
|
-
const
|
|
155
|
+
const snapshot = await withGitHubRequestContext(client, 'relayd.pr-refresh', () => loadManagedGitHubPullRequestSnapshotGraphql(client, repository, prNumber, trusted));
|
|
156
156
|
cachedOverviews = cachedOverviews.filter(item => item.job.repository !== repository || item.job.prNumber !== prNumber);
|
|
157
|
-
cachedOverviews.push(...
|
|
157
|
+
cachedOverviews.push(...snapshot.overviews);
|
|
158
158
|
cachedOverviews = dashboardManagedJobs(cachedOverviews);
|
|
159
159
|
await persistCache();
|
|
160
|
-
return
|
|
160
|
+
return snapshot;
|
|
161
161
|
})();
|
|
162
162
|
pullRequestRefreshFlights.set(key, flight);
|
|
163
163
|
try {
|
|
@@ -168,6 +168,7 @@ export async function runDaemon(argv) {
|
|
|
168
168
|
pullRequestRefreshFlights.delete(key);
|
|
169
169
|
}
|
|
170
170
|
};
|
|
171
|
+
const refreshPullRequest = async (repository, prNumber) => (await refreshPullRequestSnapshot(repository, prNumber)).overviews;
|
|
171
172
|
const initial = cachedOverviews;
|
|
172
173
|
const fallback = initial[0]?.job;
|
|
173
174
|
const dashboardStore = fallback ? new GitHubStore(client, fallback.repository, fallback.prNumber, trusted) : new InMemoryStore();
|
|
@@ -206,8 +207,10 @@ export async function runDaemon(argv) {
|
|
|
206
207
|
refresh: async (event) => {
|
|
207
208
|
if (!event.repository || !event.pullRequest)
|
|
208
209
|
return [];
|
|
209
|
-
const
|
|
210
|
-
|
|
210
|
+
const snapshot = await refreshPullRequestSnapshot(event.repository, event.pullRequest);
|
|
211
|
+
for (const item of snapshot.overviews)
|
|
212
|
+
pool.seedTargetSnapshot(event.repository, event.pullRequest, item.job.id, snapshot.seed);
|
|
213
|
+
return snapshot.overviews.map(item => item.job.id);
|
|
211
214
|
},
|
|
212
215
|
wake: async (event) => {
|
|
213
216
|
if (!event.repository || !event.pullRequest)
|
package/dist/store-github.js
CHANGED
|
@@ -7,11 +7,21 @@ export class GitHubStore {
|
|
|
7
7
|
repository;
|
|
8
8
|
prNumber;
|
|
9
9
|
trustedAuthors;
|
|
10
|
-
|
|
10
|
+
prNodeCache = null;
|
|
11
|
+
commentsCache = null;
|
|
12
|
+
commentsFlight = null;
|
|
13
|
+
clearReadSnapshot() { this.prNodeCache = null; this.commentsCache = null; this.commentsFlight = null; }
|
|
14
|
+
constructor(client, repository, prNumber, trustedAuthors = new Set(), seed = null) {
|
|
11
15
|
this.client = client;
|
|
12
16
|
this.repository = repository;
|
|
13
17
|
this.prNumber = prNumber;
|
|
14
18
|
this.trustedAuthors = trustedAuthors;
|
|
19
|
+
if (seed) {
|
|
20
|
+
if (Object.keys(seed.prNode).length > 0)
|
|
21
|
+
this.prNodeCache = Promise.resolve(seed.prNode);
|
|
22
|
+
if (seed.comments)
|
|
23
|
+
this.commentsCache = [...seed.comments];
|
|
24
|
+
}
|
|
15
25
|
}
|
|
16
26
|
taskKey(task) { return `${this.repository}#${this.prNumber}:${task.jobId ?? ''}/${task.id}`; }
|
|
17
27
|
taskKeyFromBody(body) { const task = parseTask(body); return task ? this.taskKey(task) : null; }
|
|
@@ -49,43 +59,64 @@ export class GitHubStore {
|
|
|
49
59
|
return await this.client.graphql(query, variables);
|
|
50
60
|
}
|
|
51
61
|
async prNode() {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
if (this.prNodeCache)
|
|
63
|
+
return this.prNodeCache;
|
|
64
|
+
const flight = (async () => {
|
|
65
|
+
const { owner, name } = this.repoParts();
|
|
66
|
+
const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id state merged mergedAt closedAt body headRefName headRefOid}}}', { owner, name, number: this.prNumber });
|
|
67
|
+
const repository = data.repository;
|
|
68
|
+
const pr = repository?.pullRequest;
|
|
69
|
+
if (!pr)
|
|
70
|
+
throw new Error(`Pull request #${this.prNumber} not found in ${this.repository}`);
|
|
71
|
+
return pr;
|
|
72
|
+
})();
|
|
73
|
+
this.prNodeCache = flight;
|
|
74
|
+
try {
|
|
75
|
+
return await flight;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (this.prNodeCache === flight)
|
|
79
|
+
this.prNodeCache = null;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
59
82
|
}
|
|
60
83
|
async pullRequestBody() {
|
|
61
84
|
const pr = await this.prNode();
|
|
62
85
|
return typeof pr.body === 'string' ? pr.body : '';
|
|
63
86
|
}
|
|
64
87
|
async list() {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
updatedAt: String(value.updatedAt ?? ''),
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
88
|
+
if (this.commentsCache)
|
|
89
|
+
return this.overlayTaskWrites(this.commentsCache);
|
|
90
|
+
if (this.commentsFlight)
|
|
91
|
+
return this.overlayTaskWrites(await this.commentsFlight);
|
|
92
|
+
const flight = (async () => {
|
|
93
|
+
const { owner, name } = this.repoParts();
|
|
94
|
+
const all = [];
|
|
95
|
+
let cursor = null;
|
|
96
|
+
do {
|
|
97
|
+
const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){comments(first:100,after:$cursor){nodes{id databaseId url body updatedAt author{login}} pageInfo{hasNextPage endCursor}}}}}', { owner, name, number: this.prNumber, cursor });
|
|
98
|
+
const repository = data.repository;
|
|
99
|
+
const pr = repository?.pullRequest;
|
|
100
|
+
const comments = pr?.comments;
|
|
101
|
+
const nodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
|
|
102
|
+
for (const value of nodes) {
|
|
103
|
+
const author = value.author;
|
|
104
|
+
all.push({ id: Number(value.databaseId ?? 0), nodeId: typeof value.id === 'string' ? value.id : undefined, url: typeof value.url === 'string' ? value.url : undefined, body: String(value.body ?? ''), updatedAt: String(value.updatedAt ?? ''), authorLogin: typeof author?.login === 'string' ? author.login : null });
|
|
105
|
+
}
|
|
106
|
+
const pageInfo = comments?.pageInfo;
|
|
107
|
+
cursor = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null;
|
|
108
|
+
} while (cursor);
|
|
109
|
+
return all;
|
|
110
|
+
})();
|
|
111
|
+
this.commentsFlight = flight;
|
|
112
|
+
try {
|
|
113
|
+
this.commentsCache = await flight;
|
|
114
|
+
return this.overlayTaskWrites(this.commentsCache);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
if (this.commentsFlight === flight)
|
|
118
|
+
this.commentsFlight = null;
|
|
119
|
+
}
|
|
89
120
|
}
|
|
90
121
|
async comments() { return this.list(); }
|
|
91
122
|
async pullRequestState() {
|
|
@@ -104,12 +135,18 @@ export class GitHubStore {
|
|
|
104
135
|
});
|
|
105
136
|
if (result.merged !== true)
|
|
106
137
|
throw new Error(typeof result.message === 'string' ? result.message : 'Pull request #' + this.prNumber + ' was not merged');
|
|
138
|
+
this.prNodeCache = null;
|
|
107
139
|
}
|
|
108
140
|
async closePullRequest() {
|
|
109
141
|
await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ state: 'closed' }) });
|
|
142
|
+
this.prNodeCache = null;
|
|
110
143
|
}
|
|
111
144
|
async updatePullRequestBody(body) {
|
|
112
145
|
await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ body }) });
|
|
146
|
+
if (this.prNodeCache) {
|
|
147
|
+
const current = await this.prNodeCache;
|
|
148
|
+
this.prNodeCache = Promise.resolve({ ...current, body });
|
|
149
|
+
}
|
|
113
150
|
}
|
|
114
151
|
async pullRequestHead() {
|
|
115
152
|
const pr = await this.prNode();
|
|
@@ -139,6 +176,7 @@ export class GitHubStore {
|
|
|
139
176
|
const job = jobs.find(x => x.id === jobId);
|
|
140
177
|
if (!job) {
|
|
141
178
|
const conflicting = bodyJob ?? commentJobs[0];
|
|
179
|
+
this.clearReadSnapshot();
|
|
142
180
|
if (conflicting)
|
|
143
181
|
throw new Error(`JOB_ID_MISMATCH: PR #${this.prNumber} is bound to ${conflicting.id}, not ${jobId}`);
|
|
144
182
|
throw new Error(`JOB_MARKER_MISSING: Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}`);
|
|
@@ -188,7 +226,7 @@ export class GitHubStore {
|
|
|
188
226
|
const data = await this.graphql('mutation($subjectId:ID!,$body:String!){addComment(input:{subjectId:$subjectId,body:$body}){commentEdge{node{id databaseId url body updatedAt author{login}}}}}', { subjectId: pr.id, body });
|
|
189
227
|
const edge = data.addComment?.commentEdge;
|
|
190
228
|
const node = edge?.node;
|
|
191
|
-
|
|
229
|
+
const persisted = {
|
|
192
230
|
id: Number(node?.databaseId ?? 0),
|
|
193
231
|
nodeId: typeof node?.id === 'string' ? node.id : undefined,
|
|
194
232
|
url: typeof node?.url === 'string' ? node.url : undefined,
|
|
@@ -196,13 +234,16 @@ export class GitHubStore {
|
|
|
196
234
|
updatedAt: typeof node?.updatedAt === 'string' ? node.updatedAt : new Date().toISOString(),
|
|
197
235
|
authorLogin: typeof node?.author?.login === 'string' ? String((node?.author).login) : [...this.trustedAuthors][0] ?? null,
|
|
198
236
|
};
|
|
237
|
+
if (this.commentsCache)
|
|
238
|
+
this.commentsCache.push(persisted);
|
|
239
|
+
return persisted;
|
|
199
240
|
}
|
|
200
241
|
async updateComment(comment, body) {
|
|
201
242
|
if (!comment.nodeId)
|
|
202
243
|
throw new Error('GitHub comment node id is required for GraphQL update');
|
|
203
244
|
const data = await this.graphql('mutation($id:ID!,$body:String!){updateIssueComment(input:{id:$id,body:$body}){issueComment{id databaseId url body updatedAt author{login}}}}', { id: comment.nodeId, body });
|
|
204
245
|
const node = data.updateIssueComment?.issueComment;
|
|
205
|
-
|
|
246
|
+
const persisted = {
|
|
206
247
|
id: Number(node?.databaseId ?? comment.id),
|
|
207
248
|
nodeId: typeof node?.id === 'string' ? node.id : comment.nodeId,
|
|
208
249
|
url: typeof node?.url === 'string' ? node.url : comment.url,
|
|
@@ -210,11 +251,19 @@ export class GitHubStore {
|
|
|
210
251
|
updatedAt: typeof node?.updatedAt === 'string' ? node.updatedAt : new Date().toISOString(),
|
|
211
252
|
authorLogin: typeof node?.author?.login === 'string' ? String((node?.author).login) : comment.authorLogin,
|
|
212
253
|
};
|
|
254
|
+
if (this.commentsCache) {
|
|
255
|
+
const index = this.commentsCache.findIndex(item => item.nodeId === comment.nodeId || (item.id > 0 && item.id === comment.id));
|
|
256
|
+
if (index >= 0)
|
|
257
|
+
this.commentsCache[index] = persisted;
|
|
258
|
+
}
|
|
259
|
+
return persisted;
|
|
213
260
|
}
|
|
214
261
|
async deleteComment(comment) {
|
|
215
262
|
if (!comment.nodeId)
|
|
216
263
|
throw new Error('GitHub comment node id is required for GraphQL delete');
|
|
217
264
|
await this.graphql('mutation($id:ID!){deleteIssueComment(input:{id:$id}){clientMutationId}}', { id: comment.nodeId });
|
|
265
|
+
if (this.commentsCache)
|
|
266
|
+
this.commentsCache = this.commentsCache.filter(item => item.nodeId !== comment.nodeId && !(item.id > 0 && item.id === comment.id));
|
|
218
267
|
}
|
|
219
268
|
async saveJob(job) {
|
|
220
269
|
const currentBody = await this.pullRequestBody();
|
package/dist/store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createSign } from 'node:crypto';
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
4
|
import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, TASK_MARKER } from './markers.js';
|
|
4
5
|
import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
|
|
5
6
|
import { parsePackagePublicationMarker } from './github-webhook.js';
|
|
@@ -11,28 +12,39 @@ function publicationFromComments(rows, trustedAuthors) {
|
|
|
11
12
|
});
|
|
12
13
|
return allowed.map(item => parsePackagePublicationMarker(String(item.body ?? ''))).filter((item) => item !== null).at(-1) ?? null;
|
|
13
14
|
}
|
|
14
|
-
export async function
|
|
15
|
+
export async function loadManagedGitHubPullRequestSnapshotGraphql(client, repository, prNumber, trustedAuthors) {
|
|
15
16
|
const [owner, name] = repository.split('/', 2);
|
|
16
17
|
if (!owner || !name || !Number.isInteger(prNumber) || prNumber <= 0 || !client.graphql)
|
|
17
|
-
return [];
|
|
18
|
-
const query = 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){state isDraft merged mergedAt closedAt body comments(last:100){nodes{databaseId url author{login} body}}}}}';
|
|
18
|
+
return { overviews: [], seed: { prNode: {}, comments: null } };
|
|
19
|
+
const query = 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id state isDraft merged mergedAt closedAt body headRefName headRefOid comments(last:100){totalCount nodes{id databaseId url updatedAt author{login} body}}}}}';
|
|
19
20
|
const data = await client.graphql(query, { owner, name, number: prNumber });
|
|
20
21
|
const repo = data.repository;
|
|
21
22
|
const pr = repo?.pullRequest;
|
|
22
23
|
if (!pr)
|
|
23
|
-
return [];
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
24
|
+
return { overviews: [], seed: { prNode: {}, comments: null } };
|
|
25
|
+
const commentConnection = pr.comments;
|
|
26
|
+
const commentRows = (Array.isArray(commentConnection?.nodes) ? commentConnection.nodes : []);
|
|
27
|
+
const totalCount = Number(commentConnection?.totalCount ?? commentRows.length);
|
|
28
|
+
const seedComments = Number.isFinite(totalCount) && totalCount <= commentRows.length ? commentRows.map(row => {
|
|
27
29
|
const author = row.author;
|
|
30
|
+
return {
|
|
31
|
+
id: Number(row.databaseId ?? 0),
|
|
32
|
+
nodeId: typeof row.id === 'string' ? row.id : undefined,
|
|
33
|
+
url: typeof row.url === 'string' ? row.url : undefined,
|
|
34
|
+
body: String(row.body ?? ''),
|
|
35
|
+
updatedAt: String(row.updatedAt ?? ''),
|
|
36
|
+
authorLogin: typeof author?.login === 'string' ? author.login : null,
|
|
37
|
+
};
|
|
38
|
+
}) : null;
|
|
39
|
+
const trusted = commentRows.filter(item => {
|
|
40
|
+
const author = item.author;
|
|
28
41
|
return typeof author?.login === 'string' && trustedAuthors.has(author.login);
|
|
29
42
|
});
|
|
30
43
|
const bodyJob = parseJob(typeof pr.body === 'string' ? pr.body : '');
|
|
31
44
|
const commentJobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
|
|
32
45
|
const jobs = bodyJob ? [bodyJob] : commentJobs;
|
|
33
|
-
const publication = publicationFromComments(
|
|
34
|
-
const tasks = trusted.map(
|
|
35
|
-
const row = item;
|
|
46
|
+
const publication = publicationFromComments(commentRows, trustedAuthors);
|
|
47
|
+
const tasks = trusted.map(row => {
|
|
36
48
|
const task = parseTask(String(row.body ?? ''));
|
|
37
49
|
if (!task)
|
|
38
50
|
return null;
|
|
@@ -41,12 +53,18 @@ export async function loadManagedGitHubPullRequestGraphql(client, repository, pr
|
|
|
41
53
|
}).filter((item) => item !== null);
|
|
42
54
|
const state = String(pr.state ?? '').toUpperCase();
|
|
43
55
|
const githubState = pr.merged === true ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN';
|
|
44
|
-
|
|
56
|
+
const overviews = jobs.map(job => {
|
|
45
57
|
job.tasks = tasks.filter(task => task.jobId === job.id || (!task.jobId && jobs.length === 1));
|
|
46
58
|
if (!job.description && typeof pr.body === 'string' && pr.body)
|
|
47
59
|
job.description = pr.body;
|
|
60
|
+
if (typeof pr.headRefOid === 'string' && pr.headRefOid)
|
|
61
|
+
job.headSha = pr.headRefOid;
|
|
48
62
|
return { job, githubState, draft: pr.isDraft === true, mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : null, closedAt: typeof pr.closedAt === 'string' ? pr.closedAt : null, publication };
|
|
49
63
|
});
|
|
64
|
+
return { overviews, seed: { prNode: pr, comments: seedComments } };
|
|
65
|
+
}
|
|
66
|
+
export async function loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors) {
|
|
67
|
+
return (await loadManagedGitHubPullRequestSnapshotGraphql(client, repository, prNumber, trustedAuthors)).overviews;
|
|
50
68
|
}
|
|
51
69
|
export async function loadManagedGitHubJobGraphql(client, repository, prNumber, jobId, trustedAuthors) {
|
|
52
70
|
return (await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors)).find(item => item.job.id === jobId) ?? null;
|
|
@@ -97,6 +115,7 @@ export class InMemoryStore {
|
|
|
97
115
|
return [...this.values.values()].map(parseTask).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, jobs.length === 1));
|
|
98
116
|
}
|
|
99
117
|
}
|
|
118
|
+
export async function withGitHubRequestContext(client, context, work) { return client.withRequestContext ? client.withRequestContext(context, work) : work(); }
|
|
100
119
|
async function githubGraphql(client, query, variables) {
|
|
101
120
|
if (!client.graphql)
|
|
102
121
|
throw new Error('GitHub GraphQL client is required');
|
|
@@ -248,12 +267,36 @@ export class GhApiClient {
|
|
|
248
267
|
rateLimitFlight = null;
|
|
249
268
|
rateLimitCacheTtlMs = 5 * 60_000;
|
|
250
269
|
apiDiagnostics = { rest: 0, graphql: 0, rateLimit: 0 };
|
|
270
|
+
graphqlDiagnostics = { operations: {}, contexts: {} };
|
|
271
|
+
requestContext = new AsyncLocalStorage();
|
|
251
272
|
constructor(appAuth = null) {
|
|
252
273
|
this.appAuth = appAuth;
|
|
253
274
|
}
|
|
275
|
+
async withRequestContext(context, work) { return await this.requestContext.run(context, work); }
|
|
276
|
+
graphqlOperation(query) {
|
|
277
|
+
if (/mutation\s*\([^)]*\)\s*\{addComment\(/.test(query))
|
|
278
|
+
return 'comment.add';
|
|
279
|
+
if (/updateIssueComment\(/.test(query))
|
|
280
|
+
return 'comment.update';
|
|
281
|
+
if (/deleteIssueComment\(/.test(query))
|
|
282
|
+
return 'comment.delete';
|
|
283
|
+
if (/viewer\s*\{login/.test(query))
|
|
284
|
+
return 'viewer.login';
|
|
285
|
+
if (/comments\(first:100,after:\$cursor\)/.test(query))
|
|
286
|
+
return 'pr.comments';
|
|
287
|
+
if (/comments\(last:100\)/.test(query))
|
|
288
|
+
return 'pr.snapshot';
|
|
289
|
+
if (/headRefName/.test(query) && /headRefOid/.test(query))
|
|
290
|
+
return 'pr.node';
|
|
291
|
+
if (/repository\(owner:\$owner,name:\$name\)\{id\}/.test(query))
|
|
292
|
+
return 'repository.id';
|
|
293
|
+
if (/pullRequests\(/.test(query))
|
|
294
|
+
return 'repository.pull-requests';
|
|
295
|
+
return /^\s*mutation/.test(query) ? 'mutation.other' : 'query.other';
|
|
296
|
+
}
|
|
254
297
|
rateLimitStatus() {
|
|
255
298
|
const limited = Date.now() < this.cooldownUntil;
|
|
256
|
-
return { limited, cooldownUntil: limited ? new Date(this.cooldownUntil).toISOString() : null, calls: { ...this.apiDiagnostics } };
|
|
299
|
+
return { limited, cooldownUntil: limited ? new Date(this.cooldownUntil).toISOString() : null, calls: { ...this.apiDiagnostics }, graphql: { operations: { ...this.graphqlDiagnostics.operations }, contexts: { ...this.graphqlDiagnostics.contexts } } };
|
|
257
300
|
}
|
|
258
301
|
async rateLimitSnapshot(force = false) {
|
|
259
302
|
const now = Date.now();
|
|
@@ -368,6 +411,10 @@ export class GhApiClient {
|
|
|
368
411
|
async graphql(query, variables = {}) {
|
|
369
412
|
this.assertAvailable();
|
|
370
413
|
this.apiDiagnostics.graphql += 1;
|
|
414
|
+
const operation = this.graphqlOperation(query);
|
|
415
|
+
const context = this.requestContext.getStore() ?? 'unscoped';
|
|
416
|
+
this.graphqlDiagnostics.operations[operation] = (this.graphqlDiagnostics.operations[operation] ?? 0) + 1;
|
|
417
|
+
this.graphqlDiagnostics.contexts[context] = (this.graphqlDiagnostics.contexts[context] ?? 0) + 1;
|
|
371
418
|
const payload = JSON.stringify({ query, variables });
|
|
372
419
|
const stdout = await this.spawnGh(['api', 'graphql', '--input', '-'], payload, await this.commandEnv());
|
|
373
420
|
const parsed = JSON.parse(stdout);
|