@sweny-ai/core 0.1.175 → 0.1.177

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.
@@ -4,11 +4,6 @@
4
4
  * Replaces: source-control/github.ts + issue-tracking/github-issues.ts
5
5
  * ~800 lines → ~120 lines
6
6
  */
7
- let branchPusher;
8
- /** Register (or clear, with `undefined`) the sweny-side branch pusher. */
9
- export function setBranchPusher(fn) {
10
- branchPusher = fn;
11
- }
12
7
  class GitHubApiError extends Error {
13
8
  status;
14
9
  body;
@@ -19,8 +14,28 @@ class GitHubApiError extends Error {
19
14
  this.body = body;
20
15
  }
21
16
  }
17
+ /**
18
+ * The REST base: `ctx.githubApiUrl` (the operator's GITHUB_API_URL, for GHES)
19
+ * or https://api.github.com. Only a plain https URL: no credentials, query or
20
+ * fragment, since the token goes to it.
21
+ */
22
+ export function githubApiBase(apiUrl) {
23
+ if (!apiUrl)
24
+ return "https://api.github.com";
25
+ let u;
26
+ try {
27
+ u = new URL(apiUrl);
28
+ }
29
+ catch {
30
+ throw new Error(`[GitHub] GITHUB_API_URL is not a URL`);
31
+ }
32
+ if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash) {
33
+ throw new Error(`[GitHub] GITHUB_API_URL is not a plain https URL`);
34
+ }
35
+ return `${u.origin}${u.pathname.replace(/\/+$/, "")}`;
36
+ }
22
37
  async function gh(path, ctx, init) {
23
- const res = await fetch(`https://api.github.com${path}`, {
38
+ const res = await fetch(`${githubApiBase(ctx.githubApiUrl)}${path}`, {
24
39
  ...init,
25
40
  headers: {
26
41
  Authorization: `token ${ctx.config.GITHUB_TOKEN}`,
@@ -34,6 +49,49 @@ async function gh(path, ctx, init) {
34
49
  throw new GitHubApiError(res.status, await res.text());
35
50
  return res.json();
36
51
  }
52
+ /**
53
+ * Whether sweny may push the PR's head branch, from the API (#498). The agent
54
+ * names head and base, so head could be an existing integration branch
55
+ * ("release") that is neither base nor default. A head that does not exist
56
+ * remotely is pushed with an empty lease (it must still not exist). One that
57
+ * exists is pushed only when it is unprotected and already has an open PR from
58
+ * this repo into `base`, leased on the sha the API returned. Any API failure
59
+ * means no push.
60
+ */
61
+ export async function headPushLease(repo, head, base, ctx) {
62
+ const branch = head.replace(/^refs\/heads\//, "");
63
+ const target = base.replace(/^refs\/heads\//, "");
64
+ const seg = branch.split("/").map(encodeURIComponent).join("/");
65
+ let info;
66
+ try {
67
+ info = (await gh(`/repos/${repo}/branches/${seg}`, ctx));
68
+ }
69
+ catch (err) {
70
+ if (err instanceof GitHubApiError && err.status === 404)
71
+ return { remoteSha: "" };
72
+ return { skip: `cannot check head ${branch} on ${repo}: ${err instanceof Error ? err.message : String(err)}` };
73
+ }
74
+ const sha = info?.commit?.sha;
75
+ if (typeof sha !== "string" || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(sha)) {
76
+ return { skip: `cannot check head ${branch} on ${repo}: no branch sha in the API answer` };
77
+ }
78
+ if (info.protected !== false)
79
+ return { skip: `head ${branch} exists on ${repo} and is protected` };
80
+ const owner = repo.split("/")[0];
81
+ let open;
82
+ try {
83
+ open = await gh(`/repos/${repo}/pulls?head=${encodeURIComponent(`${owner}:${branch}`)}&base=${encodeURIComponent(target)}&state=open&per_page=1`, ctx);
84
+ }
85
+ catch (err) {
86
+ return {
87
+ skip: `cannot check open PRs for ${branch} on ${repo}: ${err instanceof Error ? err.message : String(err)}`,
88
+ };
89
+ }
90
+ if (!Array.isArray(open) || open.length === 0) {
91
+ return { skip: `head ${branch} already exists on ${repo} with no open PR into ${target}` };
92
+ }
93
+ return { remoteSha: sha };
94
+ }
37
95
  function isAlreadyExistsError(err) {
38
96
  if (!(err instanceof GitHubApiError) || err.status !== 422)
39
97
  return false;
@@ -187,15 +245,33 @@ export const github = {
187
245
  // recommended `persist-credentials: false` checkout still ships the
188
246
  // branch and the agent never holds a write token. Skipped unless the
189
247
  // branch exists locally and origin is this repo; never forced.
190
- if (branchPusher) {
191
- const push = await branchPusher({
192
- repo: input.repo,
193
- head: input.head,
194
- base: input.base ?? "main",
195
- token: ctx.config.GITHUB_TOKEN,
196
- });
197
- if (push.pushed)
198
- ctx.logger?.info?.(` github_create_pr: pushed ${input.head} to origin`);
248
+ if (ctx.pushBranch) {
249
+ // The default branch comes from the API, not the agent-written
250
+ // checkout's origin/HEAD. Unknown means no push.
251
+ let defaultBranch;
252
+ try {
253
+ const meta = (await gh(`/repos/${input.repo}`, ctx));
254
+ if (typeof meta?.default_branch === "string")
255
+ defaultBranch = meta.default_branch;
256
+ }
257
+ catch {
258
+ // The pusher refuses without it; the PR request below reports the API error.
259
+ }
260
+ const lease = await headPushLease(input.repo, input.head, input.base ?? "main", ctx);
261
+ const push = "skip" in lease
262
+ ? { pushed: false, attempted: false, reason: lease.skip }
263
+ : await ctx.pushBranch({
264
+ repo: input.repo,
265
+ head: input.head,
266
+ base: input.base ?? "main",
267
+ ...(defaultBranch ? { defaultBranch } : {}),
268
+ remoteSha: lease.remoteSha,
269
+ token: ctx.config.GITHUB_TOKEN,
270
+ });
271
+ if ("skip" in lease)
272
+ ctx.logger?.warn?.(` github_create_pr: not pushing: ${lease.skip}`);
273
+ else if (push.pushed)
274
+ ctx.logger?.info?.(` github_create_pr: pushed ${input.head} to ${input.repo}`);
199
275
  else if (push.attempted)
200
276
  ctx.logger?.warn?.(` github_create_pr: ${push.reason}; requesting the PR anyway`);
201
277
  else
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The process environment as it was before sweny read any workspace file.
3
+ *
4
+ * The workspace is agent-writable, and its `.env` (cli/config-file.ts
5
+ * `loadDotenv`) may set any variable the real environment left unset. The
6
+ * privileged git push (skills/git-push.ts) must not take its PATH, proxy or CA
7
+ * settings, or the server it sends the token to, from a value the workspace
8
+ * introduced. This module is imported by config-file.ts, so the snapshot is
9
+ * taken when the module graph loads, before `loadDotenv()` runs.
10
+ *
11
+ * Node only.
12
+ */
13
+ /** The environment captured at process startup, before any workspace file was read. */
14
+ export declare function startupEnv(): Readonly<NodeJS.ProcessEnv>;
15
+ /**
16
+ * The temp dir from the startup env. `os.tmpdir()` reads TMPDIR at call time,
17
+ * which a workspace `.env` can set; this never moves after startup.
18
+ */
19
+ export declare function startupTmpdir(): string;
20
+ /** Record that a workspace file (`.env`) set `key` in `process.env`. */
21
+ export declare function markWorkspaceEnv(key: string): void;
22
+ /** Test seam: forget that a workspace file set `key`. */
23
+ export declare function unmarkWorkspaceEnv(key: string): void;
24
+ /**
25
+ * The value of `key` the privileged path may use. From `process.env` itself
26
+ * (the CLI's run env) it is always the startup value. From a caller-built env
27
+ * it is the caller's value, unless a workspace file introduced the key (a copy
28
+ * of `process.env` taken after `loadDotenv`), which yields the startup value.
29
+ */
30
+ export declare function trustedEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The process environment as it was before sweny read any workspace file.
3
+ *
4
+ * The workspace is agent-writable, and its `.env` (cli/config-file.ts
5
+ * `loadDotenv`) may set any variable the real environment left unset. The
6
+ * privileged git push (skills/git-push.ts) must not take its PATH, proxy or CA
7
+ * settings, or the server it sends the token to, from a value the workspace
8
+ * introduced. This module is imported by config-file.ts, so the snapshot is
9
+ * taken when the module graph loads, before `loadDotenv()` runs.
10
+ *
11
+ * Node only.
12
+ */
13
+ import { tmpdir } from "node:os";
14
+ const SNAPSHOT = Object.freeze({ ...process.env });
15
+ /** os.tmpdir() as resolved from the startup env (TMPDIR, TEMP, TMP), before any workspace file was read. */
16
+ const TMP = tmpdir();
17
+ /** Variable names a workspace file wrote into `process.env` after startup. */
18
+ const introduced = new Set();
19
+ /** The environment captured at process startup, before any workspace file was read. */
20
+ export function startupEnv() {
21
+ return SNAPSHOT;
22
+ }
23
+ /**
24
+ * The temp dir from the startup env. `os.tmpdir()` reads TMPDIR at call time,
25
+ * which a workspace `.env` can set; this never moves after startup.
26
+ */
27
+ export function startupTmpdir() {
28
+ return TMP;
29
+ }
30
+ /** Record that a workspace file (`.env`) set `key` in `process.env`. */
31
+ export function markWorkspaceEnv(key) {
32
+ introduced.add(key);
33
+ }
34
+ /** Test seam: forget that a workspace file set `key`. */
35
+ export function unmarkWorkspaceEnv(key) {
36
+ introduced.delete(key);
37
+ }
38
+ /**
39
+ * The value of `key` the privileged path may use. From `process.env` itself
40
+ * (the CLI's run env) it is always the startup value. From a caller-built env
41
+ * it is the caller's value, unless a workspace file introduced the key (a copy
42
+ * of `process.env` taken after `loadDotenv`), which yields the startup value.
43
+ */
44
+ export function trustedEnvValue(env, key) {
45
+ if (env === process.env || introduced.has(key))
46
+ return SNAPSHOT[key];
47
+ return env[key];
48
+ }
package/dist/types.d.ts CHANGED
@@ -12,7 +12,43 @@ export interface ToolContext {
12
12
  config: Record<string, string>;
13
13
  /** Structured logger */
14
14
  logger: Logger;
15
- }
15
+ /** The run's working directory (`ExecuteOptions.cwd`, else `process.cwd()`). */
16
+ cwd?: string;
17
+ /**
18
+ * A staged or dry run: no side effects. The executor refuses every
19
+ * non-`read` tool before its handler runs.
20
+ */
21
+ staged?: boolean;
22
+ /**
23
+ * Pushes a PR's head branch from the sweny process (#473), bound to this
24
+ * run's checkout. Set by the Node executor; absent in the browser build,
25
+ * where `github_create_pr` only calls the API.
26
+ */
27
+ pushBranch?: BranchPusher;
28
+ /**
29
+ * The operator's GITHUB_API_URL (GHES), read through the startup env
30
+ * snapshot by the Node executor. Absent means https://api.github.com.
31
+ */
32
+ githubApiUrl?: string;
33
+ }
34
+ /** The sweny-side head-branch push behind `github_create_pr` (#473). Never throws. */
35
+ export type BranchPusher = (opts: {
36
+ repo: string;
37
+ head: string;
38
+ base: string;
39
+ /** The repo's default branch from the GitHub API; without it nothing is pushed. */
40
+ defaultBranch?: string;
41
+ /**
42
+ * The head branch's sha on the remote when it was checked through the API,
43
+ * or "" when it did not exist. The push leases on it. Without it nothing is pushed.
44
+ */
45
+ remoteSha?: string;
46
+ token?: string;
47
+ }) => Promise<{
48
+ pushed: boolean;
49
+ attempted: boolean;
50
+ reason?: string;
51
+ }>;
16
52
  /**
17
53
  * Side-effect class of a tool. `"read"` only reads. `"write"` can change
18
54
  * something outside the run: create, update, delete, post, send, invoke.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.175",
3
+ "version": "0.1.177",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"