@bivy/bivy 0.0.0 → 0.1.0-staging.2
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 +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- package/public/qr.js +464 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { cleanRemoteUrl, credConfigArgs, gitNonInteractiveEnv } from "./git-auth.js";
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
/** Parse an "owner/repo" slug from a git remote URL (https or ssh form). */
|
|
10
|
+
export function parseRepoSlug(remoteUrl) {
|
|
11
|
+
const m = remoteUrl.trim().match(/github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
|
|
12
|
+
return m ? `${m[1]}/${m[2]}` : undefined;
|
|
13
|
+
}
|
|
14
|
+
/** `gh auth token` for the logged-in user, or undefined if gh isn't available. */
|
|
15
|
+
async function ghAuthToken() {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await exec("gh", ["auth", "token"]);
|
|
18
|
+
return stdout.trim() || undefined;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Infer "owner/repo" from the repo's origin remote. */
|
|
25
|
+
async function inferRepoSlug(repoDir) {
|
|
26
|
+
try {
|
|
27
|
+
const { stdout } = await exec("git", ["-C", repoDir, "remote", "get-url", "origin"]);
|
|
28
|
+
return parseRepoSlug(stdout);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve config from env, falling back to the local `gh`/git setup so a machine
|
|
36
|
+
* that already has `gh` logged in needs (almost) no env vars. Enabled when either
|
|
37
|
+
* the explicit env (token+repo) is present, OR `BIVY_GITHUB_TASKS=1` opts in to
|
|
38
|
+
* gh/git inference (so a node never starts polling unexpectedly).
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveGitHubTaskConfig(env = process.env) {
|
|
41
|
+
const repoDir = env.BIVY_GITHUB_REPO_DIR?.trim() || env.BIVY_WORKSPACE?.trim() || process.cwd();
|
|
42
|
+
const explicit = Boolean(env.BIVY_GITHUB_TOKEN?.trim() && env.BIVY_GITHUB_REPO?.trim());
|
|
43
|
+
const optedIn = env.BIVY_GITHUB_TASKS === "1";
|
|
44
|
+
if (!explicit && !optedIn)
|
|
45
|
+
return null; // off unless asked for
|
|
46
|
+
const token = env.BIVY_GITHUB_TOKEN?.trim() || (await ghAuthToken());
|
|
47
|
+
const repoSlug = env.BIVY_GITHUB_REPO?.trim() || (await inferRepoSlug(repoDir));
|
|
48
|
+
if (!token) {
|
|
49
|
+
console.warn("[github-tasks] enabled but no token — set BIVY_GITHUB_TOKEN or run `gh auth login`");
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (!repoSlug) {
|
|
53
|
+
console.warn("[github-tasks] enabled but could not determine the repo — set BIVY_GITHUB_REPO or run inside a GitHub checkout");
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const [owner, repo] = repoSlug.split("/");
|
|
57
|
+
if (!owner || !repo)
|
|
58
|
+
return null;
|
|
59
|
+
return {
|
|
60
|
+
token,
|
|
61
|
+
owner,
|
|
62
|
+
repo,
|
|
63
|
+
repoDir,
|
|
64
|
+
label: env.BIVY_GITHUB_LABEL?.trim() || "bivy",
|
|
65
|
+
claimLabel: env.BIVY_GITHUB_CLAIM_LABEL?.trim() || "bivy:in-progress",
|
|
66
|
+
pollMs: Math.max(Number(env.BIVY_GITHUB_POLL_MS) || 60_000, 10_000),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Pure, env-only config (no gh/git fallback). Kept for tests and explicit setups. */
|
|
70
|
+
export function loadGitHubTaskConfig(env = process.env) {
|
|
71
|
+
const token = env.BIVY_GITHUB_TOKEN?.trim();
|
|
72
|
+
const repoSlug = env.BIVY_GITHUB_REPO?.trim(); // "owner/repo"
|
|
73
|
+
const repoDir = env.BIVY_GITHUB_REPO_DIR?.trim() || env.BIVY_WORKSPACE?.trim();
|
|
74
|
+
if (!token || !repoSlug || !repoDir)
|
|
75
|
+
return null;
|
|
76
|
+
const [owner, repo] = repoSlug.split("/");
|
|
77
|
+
if (!owner || !repo)
|
|
78
|
+
return null;
|
|
79
|
+
return {
|
|
80
|
+
token,
|
|
81
|
+
owner,
|
|
82
|
+
repo,
|
|
83
|
+
repoDir,
|
|
84
|
+
label: env.BIVY_GITHUB_LABEL?.trim() || "bivy",
|
|
85
|
+
claimLabel: env.BIVY_GITHUB_CLAIM_LABEL?.trim() || "bivy:in-progress",
|
|
86
|
+
pollMs: Math.max(Number(env.BIVY_GITHUB_POLL_MS) || 60_000, 10_000),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export function parseIssue(raw) {
|
|
90
|
+
const o = (raw ?? {});
|
|
91
|
+
const labels = Array.isArray(o.labels)
|
|
92
|
+
? o.labels.map((l) => (typeof l === "string" ? l : l?.name)).filter((n) => Boolean(n))
|
|
93
|
+
: [];
|
|
94
|
+
return {
|
|
95
|
+
number: Number(o.number) || 0,
|
|
96
|
+
title: String(o.title ?? ""),
|
|
97
|
+
body: String(o.body ?? ""),
|
|
98
|
+
labels,
|
|
99
|
+
url: String(o.html_url ?? ""),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Open, labelled issues not already claimed (PRs are filtered upstream). */
|
|
103
|
+
export function selectActionableIssues(issues, claimLabel) {
|
|
104
|
+
return issues.filter((issue) => issue.number > 0 && !issue.labels.includes(claimLabel));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The default instructions appended to every issue-pickup prompt (after the
|
|
108
|
+
* issue's own number/title/body/link). User-editable — see the
|
|
109
|
+
* `githubIssuePrompt` node setting (Settings → Nodes) and `buildTaskPrompt`'s
|
|
110
|
+
* `instructions` param — so this is a strong starting point, not the only
|
|
111
|
+
* option. It intentionally puts the agent in charge of the whole loop
|
|
112
|
+
* (understand → implement → verify → commit/push/PR) rather than having the
|
|
113
|
+
* node do any of that for it.
|
|
114
|
+
*/
|
|
115
|
+
export const DEFAULT_ISSUE_INSTRUCTIONS = [
|
|
116
|
+
"Understand the issue and the surrounding codebase before making any changes — read the relevant files, follow existing conventions, and figure out the right approach before writing code.",
|
|
117
|
+
"",
|
|
118
|
+
"Do thorough, careful work: implement the change completely, check your own work as you go, and iterate rather than stopping at a first draft.",
|
|
119
|
+
"",
|
|
120
|
+
"Before you consider yourself done, run this project's tests, linter, and type-checker, and fix anything they turn up.",
|
|
121
|
+
"",
|
|
122
|
+
"When you're finished, commit your changes, push your branch, and open a pull request yourself (for example with `gh pr create`) with a clear, descriptive title and a description that explains what changed and why. Reference and close this issue in the pull request (e.g. \"Closes #<number>\").",
|
|
123
|
+
"",
|
|
124
|
+
"If you can't open the pull request yourself (no `gh`/GitHub access from this environment), that's fine — just make sure your changes are committed on this branch; it can still be turned into a pull request afterwards.",
|
|
125
|
+
].join("\n");
|
|
126
|
+
/**
|
|
127
|
+
* Build the agent prompt from an issue: fixed issue context (number, title,
|
|
128
|
+
* body, link) followed by `instructions` — the user-editable part, defaulting
|
|
129
|
+
* to `DEFAULT_ISSUE_INSTRUCTIONS` when unset/blank so a custom override that's
|
|
130
|
+
* been cleared falls back cleanly.
|
|
131
|
+
*/
|
|
132
|
+
export function buildTaskPrompt(issue, instructions) {
|
|
133
|
+
const links = [
|
|
134
|
+
issue.url ? `Issue: ${issue.url}` : null,
|
|
135
|
+
].filter(Boolean).join("\n");
|
|
136
|
+
return [
|
|
137
|
+
`You are working on GitHub issue #${issue.number}: ${issue.title}`,
|
|
138
|
+
"",
|
|
139
|
+
issue.body?.trim() || "(no description provided)",
|
|
140
|
+
"",
|
|
141
|
+
links ? `${links}\n` : "",
|
|
142
|
+
instructions?.trim() || DEFAULT_ISSUE_INSTRUCTIONS,
|
|
143
|
+
].join("\n");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Build the prompt used to resume a session found by `reconcileOrphanedIssueWork`
|
|
147
|
+
* (src/server.ts) on startup — one whose worktree still has unclaimed work
|
|
148
|
+
* (uncommitted edits, or commits with no PR yet) and wasn't reopened live this
|
|
149
|
+
* run. Any such session was, by construction, cut off mid-task by the node
|
|
150
|
+
* process dying (crash, OOM-kill, redeploy) rather than by the agent finishing
|
|
151
|
+
* or a human stopping it — see issue #125 ("Agent should resume its task
|
|
152
|
+
* automatically after a session restart"). Unlike `buildTaskPrompt`, this does
|
|
153
|
+
* NOT restate the issue as a new request: it tells the agent plainly that it
|
|
154
|
+
* was interrupted, so it picks up the existing worktree state (via `git
|
|
155
|
+
* status`/`git diff`/`git log`) instead of re-deriving the task from scratch or
|
|
156
|
+
* mistaking its own partial edits for someone else's.
|
|
157
|
+
*/
|
|
158
|
+
export function buildResumePrompt(issue) {
|
|
159
|
+
return [
|
|
160
|
+
`You were working on GitHub issue #${issue.number}${issue.title ? `: ${issue.title}` : ""} when your session was interrupted by a restart — not because you finished the task or a human told you to stop.`,
|
|
161
|
+
"",
|
|
162
|
+
"Resume exactly where you left off. Start by checking `git status`, `git diff`, and `git log` in this worktree to see what you'd already changed or committed, then finish the task from there.",
|
|
163
|
+
"",
|
|
164
|
+
"Before you consider yourself done, run this project's tests, linter, and type-checker, and fix anything they turn up.",
|
|
165
|
+
"",
|
|
166
|
+
"When you're finished, commit and push your changes yourself. If this branch already has an open pull request, leave it — just make sure it reflects the finished work; otherwise open one yourself, referencing this issue.",
|
|
167
|
+
].join("\n");
|
|
168
|
+
}
|
|
169
|
+
/** The nudge sent to a non-issue (interactive/chat) session whose turn was cut
|
|
170
|
+
* off by a process restart. Unlike the issue prompt it makes no assumptions
|
|
171
|
+
* about a git worktree or a task to report on — it just tells the agent it was
|
|
172
|
+
* interrupted and to finish what it was doing, so it works for any session. */
|
|
173
|
+
export function buildInteractiveResumePrompt() {
|
|
174
|
+
return [
|
|
175
|
+
"Your previous turn was interrupted by a restart before it finished — not because you completed the task or were told to stop.",
|
|
176
|
+
"",
|
|
177
|
+
"Pick up exactly where you left off and finish what you were doing. Review the recent conversation to recall your plan; if you're in a code workspace, check `git status` and `git diff` to see the work already in progress before continuing.",
|
|
178
|
+
].join("\n");
|
|
179
|
+
}
|
|
180
|
+
/** Parse optional bivy directives from issue body for agent/model selection.
|
|
181
|
+
* Supports lines like:
|
|
182
|
+
* bivy-agent: pi
|
|
183
|
+
* bivy-model: claude-3-5-sonnet-20241022
|
|
184
|
+
* agent: pi
|
|
185
|
+
* model: gpt-4o
|
|
186
|
+
* Returns { runtimeId?, model? }
|
|
187
|
+
*/
|
|
188
|
+
export function parseBivyDirectives(body) {
|
|
189
|
+
const out = {};
|
|
190
|
+
if (!body)
|
|
191
|
+
return out;
|
|
192
|
+
const lines = body.split(/\r?\n/);
|
|
193
|
+
for (const line of lines) {
|
|
194
|
+
const m = /^\s*(?:bivy-)?(?:agent|runtime)\s*[:=]\s*([^\s#]+)/i.exec(line);
|
|
195
|
+
if (m)
|
|
196
|
+
out.runtimeId = m[1].trim();
|
|
197
|
+
const mm = /^\s*(?:bivy-)?model\s*[:=]\s*([^\s#]+)/i.exec(line);
|
|
198
|
+
if (mm)
|
|
199
|
+
out.model = mm[1].trim();
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
async function gh(cfg, method, apiPath, body) {
|
|
204
|
+
return fetch(`https://api.github.com/repos/${cfg.owner}/${cfg.repo}${apiPath}`, {
|
|
205
|
+
method,
|
|
206
|
+
headers: {
|
|
207
|
+
authorization: `Bearer ${cfg.token}`,
|
|
208
|
+
accept: "application/vnd.github+json",
|
|
209
|
+
"user-agent": "bivy",
|
|
210
|
+
...(body ? { "content-type": "application/json" } : {}),
|
|
211
|
+
},
|
|
212
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
export async function listOpenLabelledIssues(cfg) {
|
|
216
|
+
const res = await gh(cfg, "GET", `/issues?state=open&labels=${encodeURIComponent(cfg.label)}&per_page=20`);
|
|
217
|
+
if (!res.ok)
|
|
218
|
+
return [];
|
|
219
|
+
const raw = (await res.json().catch(() => []));
|
|
220
|
+
// The issues endpoint also returns PRs; drop anything with a pull_request field.
|
|
221
|
+
return (Array.isArray(raw) ? raw : []).filter((r) => !r.pull_request).map(parseIssue);
|
|
222
|
+
}
|
|
223
|
+
export async function getIssue(cfg, issueNumber) {
|
|
224
|
+
const res = await gh(cfg, "GET", `/issues/${issueNumber}`);
|
|
225
|
+
if (!res.ok)
|
|
226
|
+
return undefined;
|
|
227
|
+
const raw = (await res.json().catch(() => undefined));
|
|
228
|
+
if (!raw || raw.pull_request)
|
|
229
|
+
return undefined;
|
|
230
|
+
return parseIssue(raw);
|
|
231
|
+
}
|
|
232
|
+
/** Fetch a triggering issue comment's body directly from GitHub (issue #153) —
|
|
233
|
+
* the control plane no longer retains it, only the comment URL, whose stable
|
|
234
|
+
* `issuecomment-<id>` anchor is enough for the authorized, claiming node to
|
|
235
|
+
* retrieve the live text just in time. */
|
|
236
|
+
export async function getIssueCommentBody(cfg, url) {
|
|
237
|
+
const id = url?.match(/#issuecomment-(\d+)$/)?.[1];
|
|
238
|
+
if (!id)
|
|
239
|
+
return undefined;
|
|
240
|
+
const res = await gh(cfg, "GET", `/issues/comments/${id}`);
|
|
241
|
+
if (!res.ok)
|
|
242
|
+
return undefined;
|
|
243
|
+
const raw = (await res.json().catch(() => undefined));
|
|
244
|
+
return typeof raw?.body === "string" ? raw.body : undefined;
|
|
245
|
+
}
|
|
246
|
+
export async function addLabel(cfg, issueNumber, label) {
|
|
247
|
+
await gh(cfg, "POST", `/issues/${issueNumber}/labels`, { labels: [label] });
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Remove a label from an issue, if present. Best-effort: GitHub 404s when the
|
|
251
|
+
* label isn't on the issue (already removed, never applied), which is a no-op
|
|
252
|
+
* for our purposes, not a failure — callers don't need to check the label was
|
|
253
|
+
* there first. Used to move an issue from its routing label (e.g. `bivy`) to
|
|
254
|
+
* the claim label on pickup, and to drop the claim label once a PR carries the
|
|
255
|
+
* "in progress" signal instead.
|
|
256
|
+
*/
|
|
257
|
+
export async function removeLabel(cfg, issueNumber, label) {
|
|
258
|
+
await gh(cfg, "DELETE", `/issues/${issueNumber}/labels/${encodeURIComponent(label)}`);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Create a repo label if it does not already exist. Idempotent: GitHub returns
|
|
262
|
+
* 422 ("already_exists") when the label is present, which we treat as success.
|
|
263
|
+
* Returns true if the label exists (created now or already there), false if the
|
|
264
|
+
* call failed for another reason (e.g. missing scope) so the caller can warn.
|
|
265
|
+
*/
|
|
266
|
+
export async function ensureLabel(cfg, name, color = "5319e7", description = "Bivy work-queue label") {
|
|
267
|
+
const res = await gh(cfg, "POST", "/labels", { name, color, description });
|
|
268
|
+
if (res.ok)
|
|
269
|
+
return true;
|
|
270
|
+
if (res.status === 422)
|
|
271
|
+
return true; // already exists
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Make sure the node's pickup + claim labels exist on the repo so a user can
|
|
276
|
+
* apply them straight away (E1: auto-create the per-node label instead of asking
|
|
277
|
+
* the user to create it by hand). Best-effort and idempotent.
|
|
278
|
+
*/
|
|
279
|
+
export async function ensureTaskLabels(cfg) {
|
|
280
|
+
const ok = await ensureLabel(cfg, cfg.label, "5319e7", "Pick up with Bivy on a node you own");
|
|
281
|
+
await ensureLabel(cfg, cfg.claimLabel, "fbca04", "Bivy is working this issue");
|
|
282
|
+
if (!ok) {
|
|
283
|
+
console.warn(`[github-tasks] could not ensure label "${cfg.label}" exists (token may lack repo scope) — apply it manually if pickup doesn't trigger`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
export async function commentIssue(cfg, issueNumber, body) {
|
|
287
|
+
await gh(cfg, "POST", `/issues/${issueNumber}/comments`, { body });
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The comment posted on an issue the moment Bivy picks it up — the "visibly
|
|
291
|
+
* signal pickup" half of the on-issue lifecycle (the other half is the label
|
|
292
|
+
* swap in `announcePickup`). Names the node when known so a human watching a
|
|
293
|
+
* multi-node setup knows which machine is doing the work.
|
|
294
|
+
*/
|
|
295
|
+
export function pickupMessage(nodeName) {
|
|
296
|
+
const node = nodeName?.trim();
|
|
297
|
+
return node
|
|
298
|
+
? `🤖 Bivy has picked this up and started working on it on node \`${node}\`.`
|
|
299
|
+
: "🤖 Bivy has picked this up and started working on it.";
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Signal pickup on the issue itself: apply the claim label (`bivy:in-progress`),
|
|
303
|
+
* move off the routing label that triggered pickup (e.g. `bivy`, left alone if
|
|
304
|
+
* it's already the same as the claim label), and leave a comment naming the
|
|
305
|
+
* node. Best-effort and idempotent — safe to call even when a caller (the
|
|
306
|
+
* direct poller's tick, the manual pickup endpoint) already added the claim
|
|
307
|
+
* label, and it's the only place the hosted GitHub-App path — which claims via
|
|
308
|
+
* the control plane, not a GitHub label — touches the issue's labels at all.
|
|
309
|
+
*/
|
|
310
|
+
export async function announcePickup(cfg, issueNumber, nodeName) {
|
|
311
|
+
await addLabel(cfg, issueNumber, cfg.claimLabel).catch(() => { });
|
|
312
|
+
if (cfg.label && cfg.label !== cfg.claimLabel) {
|
|
313
|
+
await removeLabel(cfg, issueNumber, cfg.label).catch(() => { });
|
|
314
|
+
}
|
|
315
|
+
await commentIssue(cfg, issueNumber, pickupMessage(nodeName)).catch(() => { });
|
|
316
|
+
}
|
|
317
|
+
export async function defaultBranch(cfg) {
|
|
318
|
+
const res = await gh(cfg, "GET", "");
|
|
319
|
+
if (!res.ok)
|
|
320
|
+
return "main";
|
|
321
|
+
const data = (await res.json().catch(() => ({})));
|
|
322
|
+
return data.default_branch || "main";
|
|
323
|
+
}
|
|
324
|
+
export async function openPullRequest(cfg, input) {
|
|
325
|
+
const res = await gh(cfg, "POST", "/pulls", input);
|
|
326
|
+
if (!res.ok)
|
|
327
|
+
return undefined;
|
|
328
|
+
const data = (await res.json().catch(() => ({})));
|
|
329
|
+
return data.html_url ? { url: data.html_url, number: Number(data.number) } : undefined;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Update an existing pull request's title and/or body. Used to refresh the
|
|
333
|
+
* agent-written summary when a follow-up @-mention adds more commits, so the PR
|
|
334
|
+
* description keeps describing what's actually on the branch. Returns whether the
|
|
335
|
+
* update succeeded (best-effort; a failure just leaves the previous text).
|
|
336
|
+
*/
|
|
337
|
+
export async function updatePullRequest(cfg, number, input) {
|
|
338
|
+
const res = await gh(cfg, "PATCH", `/pulls/${number}`, input);
|
|
339
|
+
return res.ok;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Look up an already-open pull request whose head is `branch` (in this repo), or
|
|
343
|
+
* undefined if there isn't one. Lets the node adopt a PR opened out-of-band — by
|
|
344
|
+
* the agent's `gh pr create`, the GitHub API, or the web UI — so the session's
|
|
345
|
+
* PR badge lights up regardless of how the PR was created. The `head` filter uses
|
|
346
|
+
* GitHub's `owner:ref` form; PRs are always from a branch in the owner's repo.
|
|
347
|
+
*/
|
|
348
|
+
export async function findOpenPullRequestForBranch(cfg, branch) {
|
|
349
|
+
const head = `${cfg.owner}:${branch}`;
|
|
350
|
+
const res = await gh(cfg, "GET", `/pulls?state=open&head=${encodeURIComponent(head)}&per_page=1`);
|
|
351
|
+
if (!res.ok)
|
|
352
|
+
return undefined;
|
|
353
|
+
const raw = (await res.json().catch(() => []));
|
|
354
|
+
const pr = Array.isArray(raw) ? raw[0] : undefined;
|
|
355
|
+
return pr?.html_url ? { url: pr.html_url, number: Number(pr.number) } : undefined;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* List every pull request whose head is `branch` (in this repo), across all
|
|
359
|
+
* states, newest-relevant first: open PRs before closed/merged, then by recency.
|
|
360
|
+
* Unlike `findOpenPullRequestForBranch` this also surfaces merged/closed PRs, so
|
|
361
|
+
* the UI can show a "Merged" badge and a session can carry more than one PR over
|
|
362
|
+
* its life. `merged_at` (present in the list representation) distinguishes a
|
|
363
|
+
* merged PR from one closed without merging.
|
|
364
|
+
*/
|
|
365
|
+
export async function findPullRequestsForBranch(cfg, branch) {
|
|
366
|
+
const head = `${cfg.owner}:${branch}`;
|
|
367
|
+
const res = await gh(cfg, "GET", `/pulls?state=all&head=${encodeURIComponent(head)}&per_page=20`);
|
|
368
|
+
if (!res.ok)
|
|
369
|
+
return [];
|
|
370
|
+
const raw = (await res.json().catch(() => []));
|
|
371
|
+
if (!Array.isArray(raw))
|
|
372
|
+
return [];
|
|
373
|
+
const withSort = raw
|
|
374
|
+
.filter((p) => p?.html_url)
|
|
375
|
+
.map((p) => ({
|
|
376
|
+
pr: {
|
|
377
|
+
url: p.html_url,
|
|
378
|
+
number: Number(p.number),
|
|
379
|
+
state: (p.merged_at ? "merged" : p.state === "closed" ? "closed" : "open"),
|
|
380
|
+
title: typeof p.title === "string" ? p.title : undefined,
|
|
381
|
+
},
|
|
382
|
+
updatedAt: typeof p.updated_at === "string" ? p.updated_at : "",
|
|
383
|
+
}));
|
|
384
|
+
// Open PRs first (the actionable one), then most-recently-updated. The `[0]`
|
|
385
|
+
// of the result is the natural "primary" for a single badge.
|
|
386
|
+
const rank = (s) => (s === "open" ? 0 : s === "merged" ? 1 : 2);
|
|
387
|
+
withSort.sort((a, b) => rank(a.pr.state) - rank(b.pr.state) || b.updatedAt.localeCompare(a.updatedAt));
|
|
388
|
+
return withSort.map((x) => x.pr);
|
|
389
|
+
}
|
|
390
|
+
/** The deterministic branch an issue's automation works on. One issue → one
|
|
391
|
+
* branch, so a prior run's PR is discoverable by branch name even after the
|
|
392
|
+
* branch is deleted on merge. */
|
|
393
|
+
export function issueBranchName(issueNumber) {
|
|
394
|
+
return `bivy/issue-${issueNumber}`;
|
|
395
|
+
}
|
|
396
|
+
/** The merged PR in a set, if any. A merged PR means the change already shipped,
|
|
397
|
+
* so re-running its issue would only open a duplicate. A *closed-unmerged* PR
|
|
398
|
+
* is deliberately not treated as resolved — that work was abandoned and is fine
|
|
399
|
+
* to redo. */
|
|
400
|
+
export function pickMergedPr(prs) {
|
|
401
|
+
return prs.find((p) => p.state === "merged");
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The merged pull request for `branch`, if the issue's work already shipped.
|
|
405
|
+
* Used as an idempotency guard so a re-picked/re-dispatched/re-mentioned issue
|
|
406
|
+
* doesn't open a *second* PR on top of already-merged work. Best-effort: returns
|
|
407
|
+
* undefined on any lookup failure so a transient API error never blocks new work.
|
|
408
|
+
*/
|
|
409
|
+
export async function findMergedPullRequestForBranch(cfg, branch) {
|
|
410
|
+
try {
|
|
411
|
+
return pickMergedPr(await findPullRequestsForBranch(cfg, branch));
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Fetch a single pull request by number, as a PrRef (state derived the same way
|
|
419
|
+
* as the list lookup). Used to reconcile PRs a session opened on *other* branches
|
|
420
|
+
* — harvested from its transcript by number — which the head-branch lookups above
|
|
421
|
+
* can't see. Returns undefined on any non-2xx (deleted PR, wrong repo, etc.).
|
|
422
|
+
*/
|
|
423
|
+
export async function getPullRequest(cfg, number) {
|
|
424
|
+
const res = await gh(cfg, "GET", `/pulls/${number}`);
|
|
425
|
+
if (!res.ok)
|
|
426
|
+
return undefined;
|
|
427
|
+
const p = (await res.json().catch(() => null));
|
|
428
|
+
if (!p?.html_url)
|
|
429
|
+
return undefined;
|
|
430
|
+
return {
|
|
431
|
+
url: p.html_url,
|
|
432
|
+
number: Number(p.number),
|
|
433
|
+
state: p.merged_at ? "merged" : p.state === "closed" ? "closed" : "open",
|
|
434
|
+
title: typeof p.title === "string" ? p.title : undefined,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/** Stage + commit everything in the worktree. Returns false if nothing changed. */
|
|
438
|
+
export async function commitAll(dir, message) {
|
|
439
|
+
await exec("git", ["-C", dir, "add", "-A"]);
|
|
440
|
+
const { stdout } = await exec("git", ["-C", dir, "status", "--porcelain"]);
|
|
441
|
+
if (!stdout.trim())
|
|
442
|
+
return false;
|
|
443
|
+
await exec("git", ["-C", dir, "-c", "user.email=bivy@local", "-c", "user.name=Bivy", "commit", "-qm", message]);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* The push/fetch URL for a repo — token-FREE. Auth is supplied out-of-band by the
|
|
448
|
+
* daemon credential helper (see src/git-auth.ts), which serves the token stashed
|
|
449
|
+
* by `writeRepoToken`. Never embed the token in the URL: it would land in process
|
|
450
|
+
* args, logs, and error messages. `remoteUrl` stays overridable for tests.
|
|
451
|
+
*/
|
|
452
|
+
export function remoteUrlFor(cfg) {
|
|
453
|
+
return cleanRemoteUrl(cfg.owner, cfg.repo);
|
|
454
|
+
}
|
|
455
|
+
/** Push the branch to origin. `remoteUrl` is overridable for tests. */
|
|
456
|
+
export async function pushBranch(cfg, dir, branch, remoteUrl = remoteUrlFor(cfg)) {
|
|
457
|
+
await exec("git", ["-C", dir, ...credConfigArgs(), "push", remoteUrl, `${branch}:${branch}`], { env: gitNonInteractiveEnv() });
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The patch this branch adds on top of `base`, for feeding the PR-description
|
|
461
|
+
* writer. Uses the three-dot form so it's the branch's own changes (not churn
|
|
462
|
+
* from base moving ahead). Truncated so a huge diff can't blow the model's
|
|
463
|
+
* context / cost budget. Returns "" on any error so the caller falls back to a
|
|
464
|
+
* deterministic title/body.
|
|
465
|
+
*/
|
|
466
|
+
export async function branchDiff(dir, base, maxBytes = 60_000) {
|
|
467
|
+
try {
|
|
468
|
+
const { stdout } = await exec("git", ["-C", dir, "diff", "--no-color", `${base}...HEAD`], { maxBuffer: 32 * 1024 * 1024 });
|
|
469
|
+
return stdout.length > maxBytes ? `${stdout.slice(0, maxBytes)}\n… (diff truncated)` : stdout;
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
return "";
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/** Repo-relative paths currently in an unmerged (conflicted) state. */
|
|
476
|
+
export async function unmergedPaths(dir) {
|
|
477
|
+
try {
|
|
478
|
+
const { stdout } = await exec("git", ["-C", dir, "diff", "--name-only", "--diff-filter=U"]);
|
|
479
|
+
return stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return [];
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/** True if the working-tree copy of `file` still carries git conflict markers. */
|
|
486
|
+
export function fileHasConflictMarkers(dir, file) {
|
|
487
|
+
try {
|
|
488
|
+
const content = fs.readFileSync(path.join(dir, file), "utf8");
|
|
489
|
+
return /^(<{7}|={7}|>{7})/m.test(content);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Fetch the latest `base` from origin and merge it into the current branch, so
|
|
497
|
+
* the branch is up to date before we open the PR (a PR opened against a base that
|
|
498
|
+
* has moved on is what surfaces as "Unable to merge — conflicts must be resolved"
|
|
499
|
+
* in the GitHub UI). On a conflict the merge is left in progress and the
|
|
500
|
+
* conflicted paths returned, so the agent can resolve them in the worktree; the
|
|
501
|
+
* caller then calls `completeMerge` (or `abortMerge`). `remoteUrl` is overridable
|
|
502
|
+
* for tests.
|
|
503
|
+
*/
|
|
504
|
+
export async function mergeBaseIntoBranch(cfg, dir, base, remoteUrl = remoteUrlFor(cfg)) {
|
|
505
|
+
try {
|
|
506
|
+
await exec("git", ["-C", dir, ...credConfigArgs(), "fetch", remoteUrl, base], { env: gitNonInteractiveEnv() });
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
return { status: "error", conflicts: [] };
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
await exec("git", ["-C", dir, "-c", "user.email=bivy@local", "-c", "user.name=Bivy", "merge", "--no-edit", "FETCH_HEAD"]);
|
|
513
|
+
return { status: "clean", conflicts: [] };
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
const conflicts = await unmergedPaths(dir);
|
|
517
|
+
if (!conflicts.length) {
|
|
518
|
+
// Non-zero exit with nothing unmerged = a real error (bad ref, etc.), not a
|
|
519
|
+
// content conflict the agent can fix. Abort any half-started merge.
|
|
520
|
+
await abortMerge(dir);
|
|
521
|
+
return { status: "error", conflicts: [] };
|
|
522
|
+
}
|
|
523
|
+
return { status: "conflicts", conflicts };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Finish an in-progress merge after conflicts were resolved in the worktree.
|
|
528
|
+
* Returns false (leaving the merge in progress) if any path is still unmerged or
|
|
529
|
+
* a resolved file still contains conflict markers, so the caller can abort rather
|
|
530
|
+
* than commit a broken tree. `files` is the set the agent was asked to resolve.
|
|
531
|
+
*/
|
|
532
|
+
export async function completeMerge(dir, files = []) {
|
|
533
|
+
await exec("git", ["-C", dir, "add", "-A"]);
|
|
534
|
+
if ((await unmergedPaths(dir)).length)
|
|
535
|
+
return false;
|
|
536
|
+
if (files.some((f) => fileHasConflictMarkers(dir, f)))
|
|
537
|
+
return false;
|
|
538
|
+
try {
|
|
539
|
+
await exec("git", ["-C", dir, "-c", "user.email=bivy@local", "-c", "user.name=Bivy", "commit", "--no-edit"]);
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/** Abort an in-progress merge, restoring the branch to its pre-merge state. */
|
|
547
|
+
export async function abortMerge(dir) {
|
|
548
|
+
try {
|
|
549
|
+
await exec("git", ["-C", dir, "merge", "--abort"]);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// no merge in progress / already clean — nothing to undo
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Extract a `{ title, body }` PR description from a model reply, tolerating stray
|
|
557
|
+
* prose or code fences around the JSON. Returns undefined if there's no usable
|
|
558
|
+
* object, so the caller falls back to deterministic issue-derived text.
|
|
559
|
+
*/
|
|
560
|
+
export function parsePrContent(text) {
|
|
561
|
+
if (!text?.trim())
|
|
562
|
+
return undefined;
|
|
563
|
+
const start = text.indexOf("{");
|
|
564
|
+
const end = text.lastIndexOf("}");
|
|
565
|
+
if (start < 0 || end <= start)
|
|
566
|
+
return undefined;
|
|
567
|
+
try {
|
|
568
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
569
|
+
const title = typeof parsed.title === "string" ? parsed.title.trim() : "";
|
|
570
|
+
const body = typeof parsed.body === "string" ? parsed.body.trim() : "";
|
|
571
|
+
if (!title && !body)
|
|
572
|
+
return undefined;
|
|
573
|
+
return { title, body };
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
return undefined;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
export class GitHubTaskPoller {
|
|
580
|
+
cfg;
|
|
581
|
+
runTask;
|
|
582
|
+
maxConcurrent;
|
|
583
|
+
timer;
|
|
584
|
+
inFlight = new Set();
|
|
585
|
+
constructor(cfg, runTask,
|
|
586
|
+
/** Node's cap on concurrently-running queue sessions (0/undefined = unlimited).
|
|
587
|
+
* Read fresh each tick so the Settings → Nodes value takes effect live. */
|
|
588
|
+
maxConcurrent) {
|
|
589
|
+
this.cfg = cfg;
|
|
590
|
+
this.runTask = runTask;
|
|
591
|
+
this.maxConcurrent = maxConcurrent;
|
|
592
|
+
}
|
|
593
|
+
start() {
|
|
594
|
+
// Create the pickup/claim labels up front so the user can apply them without
|
|
595
|
+
// first creating them by hand (E1). Best-effort; failures only warn.
|
|
596
|
+
void ensureTaskLabels(this.cfg).catch(() => { });
|
|
597
|
+
void this.tick();
|
|
598
|
+
this.timer = setInterval(() => void this.tick(), this.cfg.pollMs);
|
|
599
|
+
this.timer.unref?.();
|
|
600
|
+
console.log(`[github-tasks] polling ${this.cfg.owner}/${this.cfg.repo} for issues labelled "${this.cfg.label}" every ${Math.round(this.cfg.pollMs / 1000)}s`);
|
|
601
|
+
}
|
|
602
|
+
stop() {
|
|
603
|
+
if (this.timer)
|
|
604
|
+
clearInterval(this.timer);
|
|
605
|
+
}
|
|
606
|
+
async tick() {
|
|
607
|
+
let issues;
|
|
608
|
+
try {
|
|
609
|
+
issues = await listOpenLabelledIssues(this.cfg);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const max = this.maxConcurrent?.() ?? 0;
|
|
615
|
+
const running = [];
|
|
616
|
+
for (const issue of selectActionableIssues(issues, this.cfg.claimLabel)) {
|
|
617
|
+
if (this.inFlight.has(issue.number))
|
|
618
|
+
continue;
|
|
619
|
+
// Honor the node's concurrency cap: leave the rest labelled/unclaimed so a
|
|
620
|
+
// later tick (or an idle node) picks them up when a slot frees.
|
|
621
|
+
if (max > 0 && this.inFlight.size >= max)
|
|
622
|
+
break;
|
|
623
|
+
// Reserve the slot synchronously (no `await` since the last check) so a
|
|
624
|
+
// second issue considered later in this same loop sees an accurate
|
|
625
|
+
// `inFlight.size` — then kick it off without awaiting it here (only
|
|
626
|
+
// collecting the promise to await below). Awaiting a task to completion
|
|
627
|
+
// before starting the next one meant the cap was never really exercised
|
|
628
|
+
// within a single tick: issues ran one at a time regardless of `max`, and
|
|
629
|
+
// only overlapping `setInterval` ticks happened to run more than one
|
|
630
|
+
// concurrently.
|
|
631
|
+
this.inFlight.add(issue.number);
|
|
632
|
+
running.push(this.runIssue(issue));
|
|
633
|
+
}
|
|
634
|
+
await Promise.all(running);
|
|
635
|
+
}
|
|
636
|
+
async runIssue(issue) {
|
|
637
|
+
try {
|
|
638
|
+
// Claim first so a restart or another node won't re-pick it.
|
|
639
|
+
await addLabel(this.cfg, issue.number, this.cfg.claimLabel);
|
|
640
|
+
console.log(`[github-tasks] picking up issue #${issue.number}: ${issue.title}`);
|
|
641
|
+
await this.runTask(issue);
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
console.warn(`[github-tasks] issue #${issue.number} failed:`, error);
|
|
645
|
+
}
|
|
646
|
+
finally {
|
|
647
|
+
this.inFlight.delete(issue.number);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|