@edgehero/pi-dispatch 4.2.0 → 4.3.1
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/.env.example +14 -4
- package/package.json +3 -2
- package/src/azure-host.mjs +28 -1
- package/src/azure-prompt.mjs +26 -3
- package/src/config.mjs +11 -3
- package/src/doctor.mjs +24 -2
- package/src/env-allowlist.mjs +11 -4
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +114 -23
- package/src/forgejo-prompt.mjs +26 -3
- package/src/forges.mjs +40 -12
- package/src/github-host.mjs +21 -1
- package/src/github-prompt.mjs +26 -3
- package/src/gitlab-host.mjs +24 -1
- package/src/gitlab-prompt.mjs +26 -3
- package/src/host-pi.mjs +8 -5
- package/src/image-preflight.mjs +10 -0
- package/src/index.mjs +6 -0
- package/src/model-catalog.mjs +2 -0
- package/src/models-json.mjs +65 -53
- package/src/parked.mjs +18 -0
- package/src/processor.mjs +55 -2
- package/src/provider-steering.mjs +1 -0
- package/src/queue.mjs +39 -1
- package/src/reserved-env.mjs +1 -0
- package/src/run-container.mjs +4 -0
- package/src/run-history.mjs +22 -2
- package/src/session-store.mjs +45 -2
- package/src/start.mjs +19 -0
- package/src/triggers-file.mjs +4 -0
- package/src/triggers.mjs +35 -6
package/.env.example
CHANGED
|
@@ -33,15 +33,16 @@ ANTHROPIC_API_KEY=
|
|
|
33
33
|
|
|
34
34
|
# --- Which provider/model to run by default (override per job with --provider / --model) ---
|
|
35
35
|
PI_PROVIDER=anthropic
|
|
36
|
-
#
|
|
37
|
-
|
|
36
|
+
# Name an exact model version (claude-sonnet-5-5, or a dated id such as claude-sonnet-4-5-20250929); an alias
|
|
37
|
+
# that moves to a newer model can change cost.
|
|
38
|
+
PI_MODEL=claude-sonnet-5-5
|
|
38
39
|
# optional: the only models a job may call, comma-separated provider/model (at most 16). A trigger's own
|
|
39
40
|
# run.models replaces this list for its jobs. The default model must be on it, or every job that uses the
|
|
40
41
|
# default is refused before it spends. Env only: the settings overlay cannot set or widen it.
|
|
41
42
|
# Unset = any model. Needs a job image that declares the modelPolicy capability.
|
|
42
43
|
# No spaces, not even after a comma: a shell that sources this file cuts the value at the first space
|
|
43
44
|
# and leaves it unset, so jobs would run with no list (the worker refuses a spaced value at boot).
|
|
44
|
-
# PI_ALLOWED_MODELS=anthropic/claude-sonnet-
|
|
45
|
+
# PI_ALLOWED_MODELS=anthropic/claude-sonnet-5-5,anthropic/claude-haiku-5-5
|
|
45
46
|
|
|
46
47
|
# --- Spend + concurrency guards (money bounds; all have conservative defaults) ---
|
|
47
48
|
# per-job turn cap -- pi has none of its own, so the harness imposes one
|
|
@@ -465,8 +466,17 @@ FORGEJO_URL=
|
|
|
465
466
|
# A REPOSITORY-scoped token ("Specific repositories") carrying only write:repository and write:issue.
|
|
466
467
|
# Narrower than GitLab's equivalent -- Forgejo has no all-or-nothing `api` scope. What it cannot do is
|
|
467
468
|
# expire: there is no App or installation token, so rotation is the whole mitigation
|
|
468
|
-
# (CONST-TOKEN-SCOPED-PER-JOB).
|
|
469
|
+
# (CONST-TOKEN-SCOPED-PER-JOB). Every forgejo job carries this token, so its account should be a WRITE
|
|
470
|
+
# collaborator of each repository, never an admin (an admin's write:repository token can change settings
|
|
471
|
+
# and branch protection: OQ-043).
|
|
469
472
|
FORGEJO_TOKEN=
|
|
473
|
+
# The author gate's own lookup token, read ONLY by the receiver and never handed to a job (PI_FORWARD_ENV and
|
|
474
|
+
# run.secrets refuse it). Forgejo answers a lookup of another user's repository permission only to a site
|
|
475
|
+
# admin or a repository admin (a write collaborator gets 403, and then no trigger ever runs), so mint this
|
|
476
|
+
# from an account that IS an admin (or the owner) of each repository, scoped read:repository and nothing
|
|
477
|
+
# else. Unset, the lookup uses FORGEJO_TOKEN, which then has to belong to an admin: doctor warns.
|
|
478
|
+
# docs/forgejo.md walks it.
|
|
479
|
+
FORGEJO_PERMISSION_TOKEN=
|
|
470
480
|
# only "pat", the default. See GITLAB_AUTH_SOURCE above for why it exists at all.
|
|
471
481
|
# FORGEJO_AUTH_SOURCE=
|
|
472
482
|
# The harness account's NUMERIC id. Required when the token above is repository-scoped, because such a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The pi-dispatch worker and CLI: runs the pi coding agent as a self-hosted service, one locked down Docker or Podman container per job, with spend caps checked before anything is spent, plus init, up, doctor and service install.",
|
|
6
6
|
"keywords": [
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"./git-dirty": "./src/git-dirty.mjs",
|
|
56
56
|
"./queue": "./src/queue.mjs",
|
|
57
57
|
"./capabilities": "./src/capabilities.mjs",
|
|
58
|
+
"./parked": "./src/parked.mjs",
|
|
58
59
|
"./connection": "./src/connection.mjs",
|
|
59
60
|
"./valkey-auth": "./src/valkey-auth.mjs",
|
|
60
61
|
"./job-id": "./src/job-id.mjs",
|
|
@@ -114,7 +115,7 @@
|
|
|
114
115
|
"start": "node src/cli.mjs worker"
|
|
115
116
|
},
|
|
116
117
|
"dependencies": {
|
|
117
|
-
"@earendil-works/pi-ai": "1.
|
|
118
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
118
119
|
"@octokit/auth-app": "8.2.0",
|
|
119
120
|
"@octokit/rest": "22.0.1",
|
|
120
121
|
"bullmq": "5.80.4",
|
package/src/azure-host.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { configError } from "./config.mjs";
|
|
25
|
+
import { PARKED_LABEL } from "./parked.mjs";
|
|
25
26
|
import { InfraRetry } from "./processor.mjs";
|
|
26
27
|
import { fetchFailureReason } from "./gitlab-identity.mjs";
|
|
27
28
|
import { isDeterminateFetchFailure } from "./transient.mjs";
|
|
@@ -201,7 +202,33 @@ export function makeAzureHost({ orgUrl, fetchFn = fetch } = {}) {
|
|
|
201
202
|
return { headRef: stripRefsHeads(pr?.sourceRefName), headRepo: sameRepo ? job?.repo : null };
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Add or remove the parked marker on a work item, as a TAG (issue #625, parked.mjs): Azure work items have
|
|
207
|
+
* tags, not labels, and the receiver reads `System.Tags` into the same `[{ name }]` shape. A tags field is written
|
|
208
|
+
* whole, so this reads it, edits the one tag and writes the result; Azure creates a tag the project lacks. The caller treats any throw as fail-open:
|
|
209
|
+
* the session store's sidecar, not this label, decides whether a reply resumes anything.
|
|
210
|
+
*/
|
|
211
|
+
async function setParkedLabel(ref, target, on, token) {
|
|
212
|
+
const { project } = scopeOf(ref);
|
|
213
|
+
const path = `/${encodeURIComponent(project)}/_apis/wit/workitems/${encodeURIComponent(target?.number)}`;
|
|
214
|
+
const item = await get(`${path}?fields=System.Tags&api-version=${API_VERSION}`, token);
|
|
215
|
+
const tags = String(item?.fields?.["System.Tags"] ?? "").split(";").map((tag) => tag.trim()).filter((tag) => tag !== "" && tag !== PARKED_LABEL);
|
|
216
|
+
const value = (on ? [...tags, PARKED_LABEL] : tags).join("; ");
|
|
217
|
+
let res;
|
|
218
|
+
try {
|
|
219
|
+
res = await fetchFn(`${root}${path}?api-version=${API_VERSION}`, {
|
|
220
|
+
method: "PATCH",
|
|
221
|
+
headers: { Authorization: authHeader(token), "content-type": "application/json-patch+json" },
|
|
222
|
+
body: JSON.stringify([{ op: "add", path: "/fields/System.Tags", value }]),
|
|
223
|
+
redirect: "error",
|
|
224
|
+
});
|
|
225
|
+
} catch (err) {
|
|
226
|
+
throw new InfraRetry(`azure-host: PATCH ${path} failed (${fetchFailureReason(err)})`);
|
|
227
|
+
}
|
|
228
|
+
if (!res.ok) throw new InfraRetry(`azure-host: PATCH ${path} returned ${res.status}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
205
232
|
}
|
|
206
233
|
|
|
207
234
|
/**
|
package/src/azure-prompt.mjs
CHANGED
|
@@ -68,6 +68,31 @@ function prReplicaLines(replica, replicas) {
|
|
|
68
68
|
];
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
|
|
73
|
+
* request exist, so the words are the ones this envelope always had. On a work item neither is certain: the
|
|
74
|
+
* previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
|
|
75
|
+
* "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
|
|
76
|
+
* human who asked waits on the work item for a reply. So the issue form names the issue branch, checks for its pull
|
|
77
|
+
* request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
|
|
78
|
+
*/
|
|
79
|
+
function resumedActionLines(target, n) {
|
|
80
|
+
if (target?.type === "pull_request") {
|
|
81
|
+
return [
|
|
82
|
+
"Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
|
|
83
|
+
"`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
|
|
84
|
+
"not. Do not open a second pull request -- your push updates the existing one.",
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
const branch = issueBranch(n);
|
|
88
|
+
return [
|
|
89
|
+
"Address the activity quoted below, then reply on the work item saying what you did or why you could not.",
|
|
90
|
+
`If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
|
|
91
|
+
`branch only. Check first for an active pull request from it (\`az repos pr list --source-branch ${branch} --status active\`):`,
|
|
92
|
+
`if one exists your push updates it, so do not create another; only if none exists, run \`az repos pr create --source-branch ${branch}\`.`,
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
function buildResumedPrompt(flow, target, comment, instructions) {
|
|
72
97
|
const n = normalizeNumber(target?.number);
|
|
73
98
|
const noun = target?.type === "pull_request" ? "pull request" : "work item";
|
|
@@ -81,9 +106,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
|
|
|
81
106
|
`\`az repos pr show --id ${n}\` (or \`az boards work-item show --id ${n}\`) before doing anything,`,
|
|
82
107
|
`then follow the "${flow}" skill from the top.`,
|
|
83
108
|
"",
|
|
84
|
-
|
|
85
|
-
"`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
|
|
86
|
-
"not. Do not open a second pull request -- your push updates the existing one.",
|
|
109
|
+
...resumedActionLines(target, n),
|
|
87
110
|
"",
|
|
88
111
|
// Above the never-merge paragraph, so the harness has the last word before the data region.
|
|
89
112
|
...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
|
package/src/config.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { homedir, hostname, tmpdir } from "node:os";
|
|
|
10
10
|
import { delimiter, isAbsolute, posix } from "node:path";
|
|
11
11
|
import { DEFAULT_BACKEND, backendRefusals, parseBackendFloor, parseBackendList } from "./backends.mjs";
|
|
12
12
|
import { egressArmed, egressProxyName } from "./egress.mjs";
|
|
13
|
-
import { MINTED_TOKEN_VARS } from "./forges.mjs";
|
|
13
|
+
import { MINTED_TOKEN_VARS, RECEIVER_ONLY_VARS } from "./forges.mjs";
|
|
14
14
|
import { SWEEP_INTERVAL_HOURS, SWEEP_INTERVAL_MAX_HOURS } from "./retention-sweep.mjs";
|
|
15
15
|
import { parseSecretProfiles } from "./secret-profiles.mjs";
|
|
16
16
|
import { WAIT_AFTER_MAX_DEFAULT_MS, WAIT_INTERVAL_FLOOR_MS, parseWaitProfiles } from "./wait-for.mjs";
|
|
@@ -31,7 +31,7 @@ import { DOLLAR_ENV_NAMES, DOLLAR_WINDOW_KEYS, checkDollarInvariant, optionalUsd
|
|
|
31
31
|
*/
|
|
32
32
|
export const DEFAULT_VALKEY_URL = "redis://127.0.0.1:6379";
|
|
33
33
|
/** The model a job runs on when nothing names one (`PI_MODEL` unset): one copy, for `loadConfig` and doctor. */
|
|
34
|
-
export const DEFAULT_MODEL = "claude-sonnet-
|
|
34
|
+
export const DEFAULT_MODEL = "claude-sonnet-5-5";
|
|
35
35
|
/** The provider a job runs on when nothing names one (`PI_PROVIDER` unset). */
|
|
36
36
|
export const DEFAULT_PROVIDER = "anthropic";
|
|
37
37
|
|
|
@@ -178,6 +178,14 @@ function forwardEnvList(raw, egressArmed = false) {
|
|
|
178
178
|
`PI_FORWARD_ENV must not forward ${minted.join(", ")} -- the worker mints a per-job token (CONST-TOKEN-SCOPED-PER-JOB) and a forwarded operator token would silently override it`,
|
|
179
179
|
);
|
|
180
180
|
}
|
|
181
|
+
// OQ-043: a receiver-only credential (FORGEJO_PERMISSION_TOKEN) is an ADMIN account's token, kept apart from the job's
|
|
182
|
+
// precisely so no job holds it. Forwarding it would undo that with one config line.
|
|
183
|
+
const receiverOnly = names.filter((n) => RECEIVER_ONLY_VARS.has(n));
|
|
184
|
+
if (receiverOnly.length > 0) {
|
|
185
|
+
throw configError(
|
|
186
|
+
`PI_FORWARD_ENV must not forward ${receiverOnly.join(", ")} -- only the receiver reads it, for the author gate's permission lookup, and it belongs to an account that is an admin of the repositories, which is exactly what a job must not hold (OQ-043)`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
181
189
|
const workerOnly = names.filter((n) => WORKER_ONLY_SECRET_VARS.has(n));
|
|
182
190
|
if (workerOnly.length > 0) {
|
|
183
191
|
throw configError(
|
|
@@ -394,7 +402,7 @@ export function globalExtensionsEnabled(env) {
|
|
|
394
402
|
* spend caps above, the per-job token scoping, or the admin-extension recursion block.
|
|
395
403
|
*/
|
|
396
404
|
export function loadConfig(env = process.env, { fileExists = existsSync } = {}) {
|
|
397
|
-
const model = env.PI_MODEL ?? DEFAULT_MODEL; //
|
|
405
|
+
const model = env.PI_MODEL ?? DEFAULT_MODEL; // an exact model version, not a moving alias; deterministic per CONST-PI-VERSION-PINNED
|
|
398
406
|
// #227. Hoisted above the object because `defaultBackend` INDEXES `backends`, and a property cannot read
|
|
399
407
|
// a sibling of the literal it is in. (Calling the parser twice would be harmless -- `egressEnabled` is
|
|
400
408
|
// called twice a few properties down for the same reason -- so this is about the index, not the throw.)
|
package/src/doctor.mjs
CHANGED
|
@@ -604,7 +604,7 @@ export const GITHUB_SERVICE_KEYS = Object.freeze(["GITHUB_AUTH_SOURCE", "GITHUB_
|
|
|
604
604
|
* `.env`. TEMP is TMPDIR's twin in the worker's temp root; PI_CODING_AGENT_DIR is where the worker reads auth.json. */
|
|
605
605
|
export const WORKER_SERVICE_KEYS = Object.freeze(["PI_JOB_IMAGE", "PI_JOB_MEMORY", "PI_JOB_CPUS", "PI_CONCURRENCY", ...Object.values(HOST_BUDGET_KEYS), "PI_TRIGGERS_FILE", "PI_LOGS_DIR", "PI_LOG_RETENTION_DAYS", "PI_SETTINGS_FILE", "PI_SESSIONS_DIR", "PI_SESSIONS_TTL_DAYS", "PI_SESSION_MAX_AGE_DAYS", "PI_SESSION_MAX_CONTEXT_PCT", "PI_SESSION_MAX_RESUME_CHAIN", "PI_GLOBAL_PI_DIR", "PI_GLOBAL_ALLOW_EXTENSIONS", "PI_FORWARD_ENV", "PI_AUTH_FROM_PI", "PI_CODING_AGENT_DIR", "PI_BACKEND_FLOOR", "PI_SECRET_PROFILES", "PI_SECRET_RESOLVER_ROOTS", "PI_WAIT_PROFILES", "PI_WAIT_AFTER_MAX_MS", "PI_SANDBOX_RETENTION_HOURS", "PI_ALLOWED_MODELS", "PI_DISPATCH_RUN_ROOTS", "GITHUB_PAT_VAR", "TEMP", ...Object.values(DOLLAR_ENV_NAMES)]);
|
|
606
606
|
/** Issue #471: the receiver's keys doctor judges its boot by (the receiver's unit reads the same `.env`). */
|
|
607
|
-
export const RECEIVER_SERVICE_KEYS = Object.freeze(["WEBHOOK_SECRET", "RECEIVER_PORT", "GITLAB_TOKEN", "GITLAB_URL", "GITLAB_WEBHOOK_MODE", "GITLAB_WEBHOOK_SECRET", "FORGEJO_URL", "FORGEJO_TOKEN", "FORGEJO_WEBHOOK_SECRET", "AZURE_ORG_URL", "AZURE_TOKEN", "AZURE_WEBHOOK_MODE", "AZURE_WEBHOOK_SECRET", "AZURE_WEBHOOK_HEADER"]);
|
|
607
|
+
export const RECEIVER_SERVICE_KEYS = Object.freeze(["WEBHOOK_SECRET", "RECEIVER_PORT", "GITLAB_TOKEN", "GITLAB_URL", "GITLAB_WEBHOOK_MODE", "GITLAB_WEBHOOK_SECRET", "FORGEJO_URL", "FORGEJO_TOKEN", "FORGEJO_PERMISSION_TOKEN", "FORGEJO_WEBHOOK_SECRET", "AZURE_ORG_URL", "AZURE_TOKEN", "AZURE_WEBHOOK_MODE", "AZURE_WEBHOOK_SECRET", "AZURE_WEBHOOK_HEADER"]);
|
|
608
608
|
/**
|
|
609
609
|
* Issue #471 (follow-up): the variables the CLIs doctor spawns read from their environment, by the CLI that reads them.
|
|
610
610
|
* The worker starts podman, docker and gh with its OWN environment (no `env` option: `image-preflight.mjs`,
|
|
@@ -727,7 +727,7 @@ export const DOCTOR_SHELL_KEYS = Object.freeze({
|
|
|
727
727
|
|
|
728
728
|
/** Issue #471: the service keys whose values a disagreement line never prints (set or unset only); URLs print as
|
|
729
729
|
* `urlShown` does. Every other key's two values are shown, quoted. */
|
|
730
|
-
const SECRET_SERVICE_KEYS = new Set([VALKEY_PASSWORD_KEY, "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_PRIVATE_KEY", "WEBHOOK_SECRET", "GITLAB_TOKEN", "GITLAB_WEBHOOK_SECRET", "FORGEJO_TOKEN", "FORGEJO_WEBHOOK_SECRET", "AZURE_TOKEN", "AZURE_WEBHOOK_SECRET"]);
|
|
730
|
+
const SECRET_SERVICE_KEYS = new Set([VALKEY_PASSWORD_KEY, "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_PRIVATE_KEY", "WEBHOOK_SECRET", "GITLAB_TOKEN", "GITLAB_WEBHOOK_SECRET", "FORGEJO_TOKEN", "FORGEJO_PERMISSION_TOKEN", "FORGEJO_WEBHOOK_SECRET", "AZURE_TOKEN", "AZURE_WEBHOOK_SECRET"]);
|
|
731
731
|
const URL_SERVICE_KEYS = new Set(["VALKEY_URL", "GITLAB_URL", "FORGEJO_URL", "AZURE_ORG_URL"]);
|
|
732
732
|
|
|
733
733
|
/** `keys` narrowed to `SERVICE_ENV_KEYS` plus the provider's own key variables. */
|
|
@@ -830,6 +830,7 @@ export const EMPTY_READ_AS_UNSET = Object.freeze({
|
|
|
830
830
|
GITLAB_WEBHOOK_SECRET: "empty",
|
|
831
831
|
FORGEJO_URL: "empty",
|
|
832
832
|
FORGEJO_TOKEN: "empty",
|
|
833
|
+
FORGEJO_PERMISSION_TOKEN: "blank",
|
|
833
834
|
FORGEJO_WEBHOOK_SECRET: "empty",
|
|
834
835
|
AZURE_ORG_URL: "empty",
|
|
835
836
|
AZURE_TOKEN: "empty",
|
|
@@ -2005,6 +2006,27 @@ export async function collectChecks(shellVars, seams) {
|
|
|
2005
2006
|
} else {
|
|
2006
2007
|
checks.push({ ok: true, label: `forgejo triggers configured (${urlShown(env.FORGEJO_URL)})` });
|
|
2007
2008
|
}
|
|
2009
|
+
// Measured on Forgejo 14.0.5: the author gate's permission lookup answers 403 unless the token's account is a site
|
|
2010
|
+
// admin, a repository admin, or the actor themselves. Which token asks is the receiver's choice (OQ-043):
|
|
2011
|
+
// FORGEJO_PERMISSION_TOKEN when set, a read:repository token of an admin account that no job ever holds; else
|
|
2012
|
+
// FORGEJO_TOKEN, every job's token, whose account must then be an admin. Doctor cannot ask Forgejo either way
|
|
2013
|
+
// (forgejo triggers name no repository, each delivery does), so it says which token is in use: a FACT LINE when the
|
|
2014
|
+
// dedicated one is set, with the requirement in the label (issue #462's form), and a WARNING on the fallback,
|
|
2015
|
+
// because the fallback is the configuration that puts an admin's token into every job.
|
|
2016
|
+
const permissionToken = typeof env.FORGEJO_PERMISSION_TOKEN === "string" && env.FORGEJO_PERMISSION_TOKEN.trim() !== "";
|
|
2017
|
+
if (permissionToken) {
|
|
2018
|
+
checks.push({
|
|
2019
|
+
ok: true,
|
|
2020
|
+
label: "forgejo author-gate lookups use FORGEJO_PERMISSION_TOKEN: its account must be an admin (or the owner) of every repository the triggers serve and the token needs read:repository only, while FORGEJO_TOKEN, which every job carries, stays a write collaborator's (docs/forgejo.md)",
|
|
2021
|
+
});
|
|
2022
|
+
} else {
|
|
2023
|
+
checks.push({
|
|
2024
|
+
ok: false,
|
|
2025
|
+
warn: true,
|
|
2026
|
+
label: "FORGEJO_PERMISSION_TOKEN is unset, so forgejo author-gate lookups use FORGEJO_TOKEN: Forgejo answers them only to a repository admin, so that account must be one, and then every forgejo job carries an admin's token that can change repository settings and branch protection (OQ-043)",
|
|
2027
|
+
fix: "keep FORGEJO_TOKEN's account a write collaborator, and set FORGEJO_PERMISSION_TOKEN to a read:repository token of an account that is an admin (or the owner) of the repositories; only the receiver reads it (docs/forgejo.md)",
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2008
2030
|
}
|
|
2009
2031
|
|
|
2010
2032
|
// Issue #508: with the egress policy on, a job reaches a forge only over https on port 443.
|
package/src/env-allowlist.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { join } from "node:path";
|
|
|
30
30
|
import { findEnvKeys } from "@earendil-works/pi-ai/compat";
|
|
31
31
|
import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
32
32
|
import { egressEnv } from "./egress.mjs";
|
|
33
|
-
import { forgeSpec } from "./forges.mjs";
|
|
33
|
+
import { RECEIVER_ONLY_VARS, forgeSpec } from "./forges.mjs";
|
|
34
34
|
import { apiKeyVariable } from "./provider-key.mjs";
|
|
35
35
|
import { isDeterminateFsCode } from "./transient.mjs";
|
|
36
36
|
import { KEYLESS_HOW, keylessVerdict, providerRenameHint, readOverlayModels, RENAMED_PROVIDERS } from "./model-endpoints.mjs";
|
|
@@ -367,7 +367,7 @@ function resolveEnvName(provider) {
|
|
|
367
367
|
* `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
|
|
368
368
|
* caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
|
|
369
369
|
*/
|
|
370
|
-
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
|
|
370
|
+
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], awaitInput = false, allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
|
|
371
371
|
// The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
|
|
372
372
|
// PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
|
|
373
373
|
// neither source yields one, which the processor turns into a policy refusal that refunds any reserve
|
|
@@ -433,6 +433,9 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
433
433
|
// runner can hold `/job/mcp.json` to the names the worker meant. Comma-safe for excludeTools' reason (the loader
|
|
434
434
|
// admits only lowercase letters, digits and `-`). Absent means none, never an empty string.
|
|
435
435
|
PI_MCP_SERVERS: mcpServers.length > 0 ? mcpServers.join(",") : undefined,
|
|
436
|
+
// The trigger's run.awaitInput (issue #625): "1" offers the job the tool a parked run ends on. Absent means not
|
|
437
|
+
// armed, never "0" or an empty string, for PI_PACKAGES' reason.
|
|
438
|
+
PI_AWAIT_INPUT: awaitInput === true ? "1" : undefined,
|
|
436
439
|
// The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
|
|
437
440
|
// PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
|
|
438
441
|
// (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
|
|
@@ -555,7 +558,7 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
555
558
|
delete env.PI_DISPATCH_PRICE_TABLE;
|
|
556
559
|
|
|
557
560
|
// Forge-backed jobs, and local cron jobs that opted in via run.github. Other local-folder jobs have
|
|
558
|
-
// no token (CONST-TOKEN-SCOPED-PER-JOB). The mint goes into
|
|
561
|
+
// no token (CONST-TOKEN-SCOPED-PER-JOB). The mint goes into EVERY one of its forge's variables because
|
|
559
562
|
// each CLI has its own preference -- gh prefers GH_TOKEN over GITHUB_TOKEN, glab prefers GITLAB_TOKEN
|
|
560
563
|
// -- and mirroring forecloses any precedence surprise inside the container.
|
|
561
564
|
//
|
|
@@ -580,8 +583,12 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
580
583
|
}
|
|
581
584
|
for (const name of spec.tokenVars) env[name] = githubToken;
|
|
582
585
|
const host = forgeHosts?.[forgeKind];
|
|
583
|
-
if (spec.
|
|
586
|
+
if (host) for (const name of spec.hostVars) env[name] = host;
|
|
584
587
|
}
|
|
585
588
|
|
|
589
|
+
// OQ-043: the receiver's own lookup credential (FORGEJO_PERMISSION_TOKEN) is an admin account's token and never a
|
|
590
|
+
// job's. PI_FORWARD_ENV and run.secrets both refuse it upstream; this is the line that holds if either is bypassed.
|
|
591
|
+
for (const name of RECEIVER_ONLY_VARS) delete env[name];
|
|
592
|
+
|
|
586
593
|
return env;
|
|
587
594
|
}
|
package/src/forgejo-auth.mjs
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* - host-held YES; it lives in the worker's env and reaches a container only as an env value.
|
|
17
17
|
* - env-injected YES; never written to /workspace, .git/config, argv, or a log.
|
|
18
18
|
* - not merge-capable YES in practice; branch protection is the barrier and no merge API is called.
|
|
19
|
+
* Provided this account stays a WRITE collaborator: the author gate's lookup needs a
|
|
20
|
+
* repository admin, and asks with the receiver's own FORGEJO_PERMISSION_TOKEN for that.
|
|
21
|
+
* Without it the lookup falls back to this token, whose account must then be an admin,
|
|
22
|
+
* and an admin's write:repository token can lift that protection (OQ-043).
|
|
19
23
|
* - short-lived NO. The operator mints it by hand. Forgejo offers no installation-token
|
|
20
24
|
* equivalent, so there is no path to a bounded expiry at all.
|
|
21
25
|
*
|
package/src/forgejo-host.mjs
CHANGED
|
@@ -4,30 +4,49 @@
|
|
|
4
4
|
*
|
|
5
5
|
* BRANCH PROTECTION IS THE REASON TO READ THIS FILE. `github-host.mjs` treats a 404 from
|
|
6
6
|
* `/repos/{o}/{r}/branches/{b}/protection` as "not protected", which is correct on GitHub, where that
|
|
7
|
-
* endpoint exists and 404 means "no protection on this branch". Forgejo has no such endpoint at all
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* pushing to a protected branch. Issue #61 records this, and `REQ-BRANCH-PROTECTION-PRECONDITION` already
|
|
11
|
-
* cites it by number as the failure that ordering exists to avoid.
|
|
7
|
+
* endpoint exists and 404 means "no protection on this branch". Forgejo has no such endpoint at all, so
|
|
8
|
+
* carrying GitHub's assumption across would make EVERY branch report unprotected. Issue #61 records this, and
|
|
9
|
+
* `REQ-BRANCH-PROTECTION-PRECONDITION` already cites it by number as the failure that ordering exists to avoid.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* The answer is the branch object's own `protected` field, from `GET /repos/{o}/{r}/branches/{branch}`. Forgejo
|
|
12
|
+
* computes it with the GLOB match itself: at v14.0.5 `GetBranch` (routers/api/v1/repo/branch.go) calls
|
|
13
|
+
* `GetFirstMatchProtectedBranchRule`, which loads every rule of the repository and returns the first whose
|
|
14
|
+
* `Match` covers the branch (models/git/protected_branch_list.go, protected_branch.go: a plain name compared
|
|
15
|
+
* case-insensitively, anything else as a glob), and `convert.ToBranch` sets `protected: true` exactly when a rule
|
|
16
|
+
* matched. So a `release/*` rule protects `release/1.0` here as it does in the forge. The route needs only read
|
|
17
|
+
* access to the code (`reqRepoReader(unit.TypeCode)`), which the job's `write` collaborator token has.
|
|
18
|
+
*
|
|
19
|
+
* This used to LIST `/branch_protections` and glob-match the rules here (reusing the GitLab host's
|
|
20
|
+
* `matchesBranch`). That listing is an admin route (`reqAdmin()`), so a job token of a `write` collaborator got
|
|
21
|
+
* 403 `{"message":"user should be an owner or a collaborator with admin write of a repository"}` (measured on
|
|
22
|
+
* Forgejo 14.0.5) and every job failed before spending: the check had quietly required an admin's token, which
|
|
23
|
+
* OQ-043 exists to keep out of jobs. The `protected` field is older than everything the listing supported
|
|
24
|
+
* (Gitea 1.11, and Forgejo is a fork of 1.18 and later), so no fallback to the listing is kept: it could only
|
|
25
|
+
* ever answer for a token that should not be in the job.
|
|
18
26
|
*
|
|
19
27
|
* A non-2xx is retryable, never `false`. Collapsing an error into "unprotected" is the same fail-open in a
|
|
20
28
|
* different costume.
|
|
21
29
|
*/
|
|
22
30
|
|
|
23
31
|
import { configError } from "./config.mjs";
|
|
32
|
+
import { PARKED_LABEL } from "./parked.mjs";
|
|
24
33
|
import { InfraRetry } from "./processor.mjs";
|
|
25
34
|
import { fetchFailureReason } from "./gitlab-identity.mjs";
|
|
26
|
-
import { matchesBranch } from "./gitlab-host.mjs";
|
|
27
35
|
import { isDeterminateFetchFailure } from "./transient.mjs";
|
|
28
36
|
|
|
29
37
|
const API_PREFIX = "/api/v1";
|
|
30
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A branch name as the tail of `/branches/*`. Forgejo routes that path with a wildcard (`m.Get("/*", repo.GetBranch)`),
|
|
41
|
+
* so a branch with a slash is sent with its slashes and each SEGMENT escaped, never as one `%2F`-joined token.
|
|
42
|
+
*/
|
|
43
|
+
function branchPath(branch) {
|
|
44
|
+
return String(branch).split("/").map(encodeURIComponent).join("/");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** How many pages of repository labels the parked marker's lookup reads before it gives up (50 a page at most). */
|
|
48
|
+
const MAX_LABEL_PAGES = 20;
|
|
49
|
+
|
|
31
50
|
/** Build the host surface. Returns the same four methods every forge host exposes. */
|
|
32
51
|
export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
|
|
33
52
|
const root = `${String(apiUrl ?? "").replace(/\/+$/, "")}${API_PREFIX}`;
|
|
@@ -73,7 +92,7 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
|
|
|
73
92
|
if (typeof branch !== "string" || branch === "") {
|
|
74
93
|
throw new InfraRetry(`forgejo-host: ${owner}/${name} reported no default_branch`);
|
|
75
94
|
}
|
|
76
|
-
const info = await get(`/repos/${owner}/${name}/branches/${
|
|
95
|
+
const info = await get(`/repos/${owner}/${name}/branches/${branchPath(branch)}`, token);
|
|
77
96
|
// Gitea's Branch carries its tip as `commit.id`; some versions and forks also expose `commit.sha`.
|
|
78
97
|
// Both are read, and neither being a string is a hard failure rather than an undefined SHA -- a job
|
|
79
98
|
// that cannot name the commit it is standing on must not proceed to clone something else.
|
|
@@ -85,24 +104,22 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
|
|
|
85
104
|
}
|
|
86
105
|
|
|
87
106
|
/**
|
|
88
|
-
* Whether the default branch is covered by any protection rule
|
|
107
|
+
* Whether the default branch is covered by any protection rule, as Forgejo itself decides it: the branch
|
|
108
|
+
* object's `protected` (see the module header for why this endpoint, and not the rule listing).
|
|
89
109
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
110
|
+
* A non-2xx (a missing branch's 404 included) is retryable through `get`, never `false`, and so is a body whose
|
|
111
|
+
* `protected` is not a boolean: an instance that stops sending the field must not read as a determinate answer.
|
|
92
112
|
*/
|
|
93
113
|
async function isDefaultBranchProtected(ref, token) {
|
|
94
114
|
const [owner, name] = splitRepo(ref);
|
|
95
115
|
const repo = await get(`/repos/${owner}/${name}`, token);
|
|
96
116
|
const branch = repo?.default_branch;
|
|
97
117
|
if (typeof branch !== "string" || branch === "") return false;
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
throw new InfraRetry(`forgejo-host: ${owner}/${name}
|
|
118
|
+
const info = await get(`/repos/${owner}/${name}/branches/${branchPath(branch)}`, token);
|
|
119
|
+
if (typeof info?.protected !== "boolean") {
|
|
120
|
+
throw new InfraRetry(`forgejo-host: ${owner}/${name} branch ${branch} reported no boolean protected`);
|
|
101
121
|
}
|
|
102
|
-
|
|
103
|
-
// older instances send. Reading only the new one would report every branch on an older Forgejo as
|
|
104
|
-
// unprotected -- the same class of silent fail-open this file exists to avoid.
|
|
105
|
-
return rules.some((rule) => matchesBranch(rule?.rule_name ?? rule?.branch_name, branch));
|
|
122
|
+
return info.protected;
|
|
106
123
|
}
|
|
107
124
|
|
|
108
125
|
/**
|
|
@@ -153,7 +170,81 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
|
|
|
153
170
|
return { headRef: pr?.head?.ref, headRepo: pr?.head?.repo?.full_name };
|
|
154
171
|
}
|
|
155
172
|
|
|
156
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Add or remove the parked marker on an issue (issue #625, parked.mjs). The caller treats any throw as fail-open: the
|
|
175
|
+
* session store's sidecar, not this label, decides whether a reply resumes anything.
|
|
176
|
+
*
|
|
177
|
+
* BY ID, AFTER A LOOKUP, because Forgejo does not refuse a duplicate label name. Measured on 14.0.5: a second
|
|
178
|
+
* `POST /repos/{o}/{r}/labels {"name":"pi-awaiting-input",...}` answers 201 and creates ANOTHER repository label of
|
|
179
|
+
* that name (the repository's labels became ai #1, pi-awaiting-input #2, pi-awaiting-input #3 after the second park);
|
|
180
|
+
* adding by name then attaches EVERY label of that name (the issue carried #2 and #3, two adds in its timeline); and
|
|
181
|
+
* removing by name took off only ONE (204, #3 stayed). So the marker outlived the resumed run for good, each park added
|
|
182
|
+
* a repository label, and every later plain reply was routed, to end as `not-parked`. The 409/422 this used to accept
|
|
183
|
+
* on the create never happens on Forgejo 14.
|
|
184
|
+
*
|
|
185
|
+
* Adding: list the repository's labels, create one only when none has the exact name, and attach the LOWEST id, so a
|
|
186
|
+
* repository that already holds duplicates stops growing them and the issue gets one marker. Removing: read the
|
|
187
|
+
* labels the issue actually carries and remove every one of that name by id, so an issue that holds two from before
|
|
188
|
+
* this fix is cleared too. The duplicate repository labels themselves are left alone: deleting a repository's labels
|
|
189
|
+
* is the operator's call, and a spare one carried by no issue routes nothing (docs/forgejo.md says how to tidy them).
|
|
190
|
+
*/
|
|
191
|
+
async function setParkedLabel(ref, target, on, token) {
|
|
192
|
+
const [owner, name] = splitRepo(ref);
|
|
193
|
+
const send = async (method, path, body, accept = []) => {
|
|
194
|
+
let res;
|
|
195
|
+
try {
|
|
196
|
+
res = await fetchFn(`${root}${path}`, {
|
|
197
|
+
method,
|
|
198
|
+
headers: { Authorization: `token ${token}`, "content-type": "application/json" },
|
|
199
|
+
...(body !== undefined && { body: JSON.stringify(body) }),
|
|
200
|
+
redirect: "error",
|
|
201
|
+
});
|
|
202
|
+
} catch (err) {
|
|
203
|
+
throw new InfraRetry(`forgejo-host: ${method} ${path} failed (${fetchFailureReason(err)})`);
|
|
204
|
+
}
|
|
205
|
+
if (!res.ok && !accept.includes(res.status)) throw new InfraRetry(`forgejo-host: ${method} ${path} returned ${res.status}`);
|
|
206
|
+
return res;
|
|
207
|
+
};
|
|
208
|
+
const issue = `/repos/${owner}/${name}/issues/${encodeURIComponent(target?.number)}/labels`;
|
|
209
|
+
const markers = (labels) => (Array.isArray(labels) ? labels : []).filter((l) => l?.name === PARKED_LABEL && Number.isSafeInteger(l?.id)).map((l) => l.id);
|
|
210
|
+
if (on) {
|
|
211
|
+
let ids = await repoLabelIds(owner, name, token, markers);
|
|
212
|
+
if (ids.length === 0) {
|
|
213
|
+
const res = await send("POST", `/repos/${owner}/${name}/labels`, { name: PARKED_LABEL, color: "#fbca04", description: "pi-dispatch: the last run is waiting for an answer" }, [409, 422]);
|
|
214
|
+
// A forge that DOES refuse the duplicate (409/422, a Gitea or a later Forgejo) raced another park to it: list
|
|
215
|
+
// again. A 201 hands back the label, so no second read is needed.
|
|
216
|
+
const created = res.ok ? await res.json().catch(() => null) : null;
|
|
217
|
+
ids = Number.isSafeInteger(created?.id) ? [created.id] : await repoLabelIds(owner, name, token, markers);
|
|
218
|
+
}
|
|
219
|
+
if (ids.length === 0) throw new InfraRetry(`forgejo-host: ${owner}/${name} has no ${PARKED_LABEL} label after creating it`);
|
|
220
|
+
await send("POST", issue, { labels: [Math.min(...ids)] });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// Already gone is success: 404 (no such issue) and, on the removal, 422 -- Forgejo's answer when no such label
|
|
224
|
+
// exists (measured on 14.0.5: {"message":"label does not exist [label_id: 0, repo_id: 1]"}). Matched on the status
|
|
225
|
+
// alone: in 14.0.5's DeleteIssueLabel the only 422 is "label does not exist", the binding's own 422 needs a request
|
|
226
|
+
// body and none is sent, and the message is free to change between versions. A stray 422 read as gone would leave
|
|
227
|
+
// a stale marker, which decides nothing.
|
|
228
|
+
const carried = markers(await get(issue, token, { notFound: [] }));
|
|
229
|
+
for (const id of carried) await send("DELETE", `${issue}/${id}`, undefined, [404, 422]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The ids of every repository label named `PARKED_LABEL`, in id order. Paged until an EMPTY page rather than until a
|
|
234
|
+
* short one, because the instance caps the page size (`MAX_RESPONSE_ITEMS`) below what was asked for, and a short page
|
|
235
|
+
* is then every page. Bounded, so a server that never returns an empty page cannot hold a job's teardown.
|
|
236
|
+
*/
|
|
237
|
+
async function repoLabelIds(owner, name, token, markers) {
|
|
238
|
+
const ids = [];
|
|
239
|
+
for (let page = 1; page <= MAX_LABEL_PAGES; page++) {
|
|
240
|
+
const labels = await get(`/repos/${owner}/${name}/labels?page=${page}&limit=50`, token);
|
|
241
|
+
if (!Array.isArray(labels) || labels.length === 0) return ids.sort((a, b) => a - b);
|
|
242
|
+
ids.push(...markers(labels));
|
|
243
|
+
}
|
|
244
|
+
throw new InfraRetry(`forgejo-host: ${owner}/${name} lists more than ${MAX_LABEL_PAGES} pages of labels`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
157
248
|
}
|
|
158
249
|
|
|
159
250
|
/**
|
package/src/forgejo-prompt.mjs
CHANGED
|
@@ -74,6 +74,31 @@ function prReplicaLines(replica, replicas) {
|
|
|
74
74
|
* toward the full envelope, and a bare "address the feedback" over a cold session is an agent with no idea
|
|
75
75
|
* what it was asked to do. `tea`, never `gh`: this envelope's whole reason for existing.
|
|
76
76
|
*/
|
|
77
|
+
/**
|
|
78
|
+
* What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
|
|
79
|
+
* request exist, so the words are the ones this envelope always had. On an issue neither is certain: the
|
|
80
|
+
* previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
|
|
81
|
+
* "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
|
|
82
|
+
* human who asked waits on the issue for a reply. So the issue form names the issue branch, checks for its pull
|
|
83
|
+
* request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
|
|
84
|
+
*/
|
|
85
|
+
function resumedActionLines(target, n) {
|
|
86
|
+
if (target?.type === "pull_request") {
|
|
87
|
+
return [
|
|
88
|
+
"Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
|
|
89
|
+
"`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
|
|
90
|
+
"not. Do not open a second pull request -- your push updates the existing one.",
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
const branch = issueBranch(n);
|
|
94
|
+
return [
|
|
95
|
+
"Address the activity quoted below, then reply on the issue saying what you did or why you could not.",
|
|
96
|
+
`If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
|
|
97
|
+
`branch only. Check first for an open pull request from it (\`tea pr list --state open\`, look for \`${branch}\`):`,
|
|
98
|
+
`if one exists your push updates it, so do not open a second; only if none exists, run \`tea pr create --head ${branch}\`.`,
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
function buildResumedPrompt(flow, target, comment, instructions) {
|
|
78
103
|
const n = normalizeNumber(target?.number);
|
|
79
104
|
const noun = target?.type === "pull_request" ? "pull request" : "issue";
|
|
@@ -86,9 +111,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
|
|
|
86
111
|
`If you do not recognise this ${noun}, treat this as a fresh start: read it with \`tea pr ${n}\``,
|
|
87
112
|
`(or \`tea issue ${n}\`) before doing anything, then follow the "${flow}" skill from the top.`,
|
|
88
113
|
"",
|
|
89
|
-
|
|
90
|
-
"`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
|
|
91
|
-
"not. Do not open a second pull request -- your push updates the existing one.",
|
|
114
|
+
...resumedActionLines(target, n),
|
|
92
115
|
"",
|
|
93
116
|
// Above the never-merge paragraph, so the harness has the last word before the data region.
|
|
94
117
|
...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
|