eve-hub 0.16.13 → 0.16.14
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/github-connector.mjs +9 -0
- package/package.json +1 -1
- package/prepare.mjs +2 -1
- package/schedules/dash-run.mjs +74 -18
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The Vercel Connect connector that holds the workspace human's GitHub token (the eve-hub-github
|
|
2
|
+
// App). The bots' `git__gh` (prepare.mjs) and main's dash pass (schedules/dash-run.mjs) ask it for
|
|
3
|
+
// the same subject, so both act as the human. Its own module so a schedule can import it without
|
|
4
|
+
// prepare.mjs's build-time imports.
|
|
5
|
+
export const GITHUB_CONNECTOR = "github/eve-hub-github";
|
|
6
|
+
|
|
7
|
+
// The subject eve's connections resolve for a hub turn (channels/eve-roster-user.ts: principal
|
|
8
|
+
// `roster:<human>`, authenticator `roster`, no issuer of its own, so eve uses the authenticator).
|
|
9
|
+
export const rosterSubject = (human) => ({ type: "user", id: `roster:${human}`, issuer: "roster" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eve-hub",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.14",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
|
|
6
6
|
"type": "module",
|
package/prepare.mjs
CHANGED
|
@@ -28,6 +28,8 @@ import { botDomains, domainFolders, readBotMemory, readMemory, validateMemory }
|
|
|
28
28
|
import { agentRunsOn, readWorkspaceFile, removeWorkspaceAgent } from "./workspace.mjs";
|
|
29
29
|
import { architectureText } from "./team.mjs";
|
|
30
30
|
import { sandboxReady } from "./sandbox-migrate.mjs";
|
|
31
|
+
import { GITHUB_CONNECTOR } from "./github-connector.mjs";
|
|
32
|
+
export { GITHUB_CONNECTOR };
|
|
31
33
|
|
|
32
34
|
// eve's git extension, for each bot of a build from a workspace repo that edits files: its `git__gh` tool runs
|
|
33
35
|
// git, gh and gh-signed-commit against the workspace's org with the human's own GitHub token,
|
|
@@ -36,7 +38,6 @@ import { sandboxReady } from "./sandbox-migrate.mjs";
|
|
|
36
38
|
// hub turn runs as the workspace's human (channels/eve-roster-user.ts), so pushes, PRs and commits
|
|
37
39
|
// are theirs. The shell holds no credential. A build without a repo (an uploaded deployment)
|
|
38
40
|
// mounts none.
|
|
39
|
-
export const GITHUB_CONNECTOR = "github/eve-hub-github";
|
|
40
41
|
export const GIT_EXTENSION_FILE = "extensions/git.ts";
|
|
41
42
|
// The read-only preset's stub (lib/agent-template.mjs): eve's write_file, disabled.
|
|
42
43
|
const READ_ONLY_FILE = "tools/write_file.ts";
|
package/schedules/dash-run.mjs
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
// One tick of main's dash schedule (schedules/dash.ts): the
|
|
1
|
+
// One tick of main's dash schedule (schedules/dash.ts): the GitHub probe and
|
|
2
2
|
// the run around it. A failed GitHub call never fails the tick: the probe is
|
|
3
3
|
// retried once on a 5xx or a timeout, and if it still fails every PR keeps
|
|
4
4
|
// what was last known (nothing said of any), threads and waits are still
|
|
5
5
|
// scanned and posted, and the post carries a one-line note of what was
|
|
6
6
|
// skipped. Only then is the Dash's checkedAt left alone, so the sidebar shows
|
|
7
|
-
// the Dash stale while GitHub stays unreachable (
|
|
7
|
+
// the Dash stale while GitHub stays unreachable (no token, offline).
|
|
8
|
+
//
|
|
9
|
+
// The probe is one GraphQL query over HTTPS, as the workspace human: the
|
|
10
|
+
// search is `author:@me`, so it needs their own user token, never an App's.
|
|
11
|
+
// A hosted hub's function has no `gh`, so it asks Vercel Connect for the
|
|
12
|
+
// token the bots' `git__gh` gets (github-connector.mjs); a local hub asks
|
|
13
|
+
// `gh auth token`. The token goes in the request header and nowhere else.
|
|
8
14
|
//
|
|
9
15
|
// Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
|
|
10
16
|
// tests (plain node) import the same module.
|
|
11
17
|
import { execFile } from "node:child_process";
|
|
12
18
|
import { createHash } from "node:crypto";
|
|
19
|
+
import { NoValidTokenError, UserAuthorizationRequiredError, getToken } from "@vercel/connect";
|
|
20
|
+
import { GITHUB_CONNECTOR, rosterSubject } from "../github-connector.mjs";
|
|
13
21
|
import { runOnce } from "./hub.mjs";
|
|
14
22
|
import { REFS_PER_QUERY, dashPrs, dashQuery, dashScan, dashWatchList, failedRefs, prsFromSearch } from "./dash-rules.mjs";
|
|
15
23
|
|
|
@@ -17,12 +25,16 @@ import { REFS_PER_QUERY, dashPrs, dashQuery, dashScan, dashWatchList, failedRefs
|
|
|
17
25
|
// SCHEDULE_DISPATCH_TIMEOUT_MS): two 20 s tries and the registry calls fit.
|
|
18
26
|
export const GH_TIMEOUT_MS = 20_000;
|
|
19
27
|
export const GH_RETRY_DELAY_MS = 1_000;
|
|
28
|
+
// The token comes first, once a tick, and leaves the two tries their 20 s.
|
|
29
|
+
export const TOKEN_TIMEOUT_MS = 5_000;
|
|
30
|
+
export const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql";
|
|
20
31
|
|
|
21
32
|
// A failure worth one more try: GitHub's 5xx, or no answer in time.
|
|
22
33
|
export const retryable = (err) => !!(err?.timedOut || /HTTP 5\d\d|timed? ?out|ETIMEDOUT|ECONNRESET|unexpected EOF/i.test(String(err?.message ?? "")));
|
|
23
34
|
|
|
24
|
-
// What a failure is called in the tick's note: "gh 504", "gh timeout", …
|
|
35
|
+
// What a failure is called in the tick's note: "gh 504", "gh timeout", "GitHub approval needed", …
|
|
25
36
|
export function ghReason(err) {
|
|
37
|
+
if (err instanceof UserAuthorizationRequiredError || err instanceof NoValidTokenError) return "GitHub approval needed";
|
|
26
38
|
const msg = String(err?.message ?? "");
|
|
27
39
|
const http = msg.match(/HTTP (\d{3})/);
|
|
28
40
|
if (http) return `gh ${http[1]}`;
|
|
@@ -32,19 +44,23 @@ export function ghReason(err) {
|
|
|
32
44
|
return "gh failed";
|
|
33
45
|
}
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
47
|
+
const timedOut = (what) => Object.assign(new Error(`gh: ${what} timed out`), { timedOut: true });
|
|
48
|
+
|
|
49
|
+
// The workspace human's GitHub user token. Hosted: Vercel Connect's, for the
|
|
50
|
+
// subject `git__gh` resolves (`roster:<human>`); its errors pass through, so
|
|
51
|
+
// a missing approval reads as one. Local: `gh auth token`.
|
|
52
|
+
export function githubToken(human, { hosted = Boolean(process.env.VERCEL), connect = getToken, bin = "gh", timeoutMs = TOKEN_TIMEOUT_MS } = {}) {
|
|
53
|
+
if (hosted) {
|
|
54
|
+
if (!human) return Promise.reject(new Error("gh: the hub names no human to ask GitHub as"));
|
|
55
|
+
let timer;
|
|
56
|
+
const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(timedOut("Vercel Connect")), timeoutMs); });
|
|
57
|
+
return Promise.race([connect(GITHUB_CONNECTOR, { subject: rosterSubject(human) }), deadline]).finally(() => clearTimeout(timer));
|
|
58
|
+
}
|
|
40
59
|
return new Promise((resolve, reject) => {
|
|
41
|
-
execFile(bin, ["
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
} catch {}
|
|
46
|
-
if (body?.data?.search) return resolve(body);
|
|
47
|
-
const err = new Error(`gh: ${String(stderr || error?.message || "no answer").trim().replace(/^gh: /, "").slice(0, 300)}`);
|
|
60
|
+
execFile(bin, ["auth", "token"], { timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
61
|
+
const token = String(stdout ?? "").trim();
|
|
62
|
+
if (!error && token) return resolve(token);
|
|
63
|
+
const err = new Error(`gh: ${String(stderr || error?.message || "no token").trim().replace(/^gh: /, "").slice(0, 300)}`);
|
|
48
64
|
if (error?.killed || error?.signal) err.timedOut = true;
|
|
49
65
|
if (error?.code === "ENOENT") err.code = "ENOENT";
|
|
50
66
|
reject(err);
|
|
@@ -52,6 +68,44 @@ export function ghGraphql(query, { timeoutMs = GH_TIMEOUT_MS, bin = "gh" } = {})
|
|
|
52
68
|
});
|
|
53
69
|
}
|
|
54
70
|
|
|
71
|
+
// One GraphQL query: the parsed answer, or an Error ("gh: HTTP 502: …", or
|
|
72
|
+
// `timedOut` set when GitHub didn't answer in time). A PR asked about by name
|
|
73
|
+
// that's gone fails only its own alias and the rest of the answer is good, so
|
|
74
|
+
// any answer with the search in it counts.
|
|
75
|
+
export async function ghGraphql(query, { token, timeoutMs = GH_TIMEOUT_MS, fetchImpl = fetch } = {}) {
|
|
76
|
+
let res;
|
|
77
|
+
let text;
|
|
78
|
+
try {
|
|
79
|
+
res = await fetchImpl(GITHUB_GRAPHQL_URL, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { authorization: `bearer ${token}`, "content-type": "application/json", "user-agent": "eve-hub-dash" },
|
|
82
|
+
body: JSON.stringify({ query }),
|
|
83
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
84
|
+
});
|
|
85
|
+
text = await res.text();
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
if (cause?.name === "TimeoutError" || cause?.name === "AbortError") throw timedOut("GitHub");
|
|
88
|
+
throw new Error(`gh: ${String(cause?.message ?? cause).slice(0, 200)}${cause?.cause?.code ? ` (${cause.cause.code})` : ""}`);
|
|
89
|
+
}
|
|
90
|
+
let body = null;
|
|
91
|
+
try {
|
|
92
|
+
body = JSON.parse(text);
|
|
93
|
+
} catch {}
|
|
94
|
+
if (body?.data?.search) return body;
|
|
95
|
+
const why = String(body?.errors?.[0]?.message ?? body?.message ?? text ?? "").trim() || "no answer";
|
|
96
|
+
throw new Error(`gh: ${res.ok ? "" : `HTTP ${res.status}: `}${why.slice(0, 300)}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The tick's probe: the token asked for once (again only after it failed), each query retried once.
|
|
100
|
+
export function githubProbe(tokenOf, { delayMs = GH_RETRY_DELAY_MS, fetchImpl = fetch, timeoutMs = GH_TIMEOUT_MS } = {}) {
|
|
101
|
+
let token = null;
|
|
102
|
+
const once = () => (token ??= tokenOf().catch((err) => {
|
|
103
|
+
token = null;
|
|
104
|
+
throw err;
|
|
105
|
+
}));
|
|
106
|
+
return (query) => withRetry(async () => ghGraphql(query, { token: await once(), fetchImpl, timeoutMs }), { delayMs });
|
|
107
|
+
}
|
|
108
|
+
|
|
55
109
|
// `call` retried once (after `delayMs`) when it fails in a way worth retrying.
|
|
56
110
|
export async function withRetry(call, { delayMs = GH_RETRY_DELAY_MS } = {}) {
|
|
57
111
|
try {
|
|
@@ -66,16 +120,18 @@ export async function withRetry(call, { delayMs = GH_RETRY_DELAY_MS } = {}) {
|
|
|
66
120
|
const hashOf = (text) => createHash("sha256").update(text).digest("base64url").slice(0, 22);
|
|
67
121
|
const plural = (n) => `${n} PR${n === 1 ? "" : "s"}`;
|
|
68
122
|
|
|
69
|
-
// One tick. `probe(query)` -> the GraphQL answer (rejects on failure)
|
|
123
|
+
// One tick. `probe(query)` -> the GraphQL answer (rejects on failure); by default GitHub's, as the snapshot's human
|
|
124
|
+
// (`token(human)`: githubToken).
|
|
70
125
|
// Returns { ok, sizes }: ok is false when the PRs couldn't be fetched at all; sizes is each PR's title and
|
|
71
126
|
// diff size ("owner/repo#12" -> { title, add, del, files }) as GitHub said this tick, for the plan's cards.
|
|
72
|
-
export async function runDash(hub, { probe =
|
|
127
|
+
export async function runDash(hub, { probe, token = githubToken, now = Date.now(), log = console } = {}) {
|
|
73
128
|
let ok = true;
|
|
74
129
|
let prFacts = {};
|
|
75
130
|
const sizes = {};
|
|
76
131
|
await runOnce(hub, "dash", async (snap, saved, at) => {
|
|
77
132
|
const { refs, reviewed, keep: waited } = dashWatchList(snap, saved);
|
|
78
133
|
const tracked = new Set(Object.keys(dashPrs(saved)));
|
|
134
|
+
const ask = probe ?? githubProbe(() => token(snap?.humans?.[0]?.name));
|
|
79
135
|
const facts = {};
|
|
80
136
|
let prs = null;
|
|
81
137
|
let skip = new Set();
|
|
@@ -83,7 +139,7 @@ export async function runDash(hub, { probe = (q) => withRetry(() => ghGraphql(q)
|
|
|
83
139
|
try {
|
|
84
140
|
const chunks = [];
|
|
85
141
|
for (let i = 0; i < Math.max(refs.length, 1); i += REFS_PER_QUERY) chunks.push(refs.slice(i, i + REFS_PER_QUERY));
|
|
86
|
-
const answers = await Promise.all(chunks.map((c) =>
|
|
142
|
+
const answers = await Promise.all(chunks.map((c) => ask(dashQuery(c))));
|
|
87
143
|
const found = answers.flatMap(prsFromSearch);
|
|
88
144
|
const searched = new Set(answers.flatMap((a) => prsFromSearch({ data: { search: a?.data?.search } })).map((p) => p.key));
|
|
89
145
|
prs = found.filter((p) => !reviewed.has(p.key) || searched.has(p.key) || tracked.has(p.key) || waited.has(p.key));
|