jj-octo 0.2.4 → 0.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/README.md CHANGED
@@ -56,6 +56,14 @@ bun update --global --latest jj-octo
56
56
 
57
57
  It uses only GitHub's REST API, so that it works in a [Claude Code](https://claude.com/claude-code) cloud session. There, GitHub is reached through a proxy that allows REST calls for the session's repository and blocks GraphQL, which `gh pr` depends on.
58
58
 
59
+ Where git reaches github.com through a proxy that adds the credentials itself, as in a Claude Code cloud session that keeps the token outside the sandbox, have `jj-octo` use that proxy too:
60
+
61
+ ```
62
+ jj config set --repo jj-octo.github-auth git-proxy
63
+ ```
64
+
65
+ `jj-octo` then sends its requests through the `http.proxy` that git uses for `https://github.com`, trusts the certificate authorities in git's `http.sslCAInfo` for it, and sends a placeholder token that the proxy replaces. It reads git's settings on each run, so it follows a proxy that moves to another port. `token`, the default, sends your own token.
66
+
59
67
  ## Development
60
68
 
61
69
  ```
package/dist/jj-octo.js CHANGED
@@ -18112,7 +18112,7 @@ var layer14 = /* @__PURE__ */ layerMergedContext(/* @__PURE__ */ succeed6(fetch)
18112
18112
  // package.json
18113
18113
  var package_default = {
18114
18114
  name: "jj-octo",
18115
- version: "0.2.4",
18115
+ version: "0.3.1",
18116
18116
  description: "A jj subcommand for working with GitHub that supports stacking",
18117
18117
  license: "MIT",
18118
18118
  repository: {
@@ -18478,6 +18478,10 @@ var PullRequests = responseOf()(ArraySchema(Struct({
18478
18478
  number: PullRequestNumberSchema,
18479
18479
  title: String4
18480
18480
  })));
18481
+ var ClosedPullRequests = responseOf()(ArraySchema(Struct({
18482
+ head: Struct({ sha: String4 }),
18483
+ merged_at: NullOr(String4)
18484
+ })));
18481
18485
  var StackedPullRequest = Struct({
18482
18486
  number: PullRequestNumberSchema,
18483
18487
  state: Literals(["closed", "open"])
@@ -18493,24 +18497,21 @@ var MergeRequest = Struct({
18493
18497
  });
18494
18498
  var StartedMerge = responseOf()(MergeRequest);
18495
18499
  var MergeResult = responseOf()(MergeRequest);
18496
- var token = gen2(function* () {
18497
- const fromEnvironment = yield* option2(Redacted2("GH_TOKEN").pipe(orElse(() => Redacted2("GITHUB_TOKEN"))));
18498
- if (isSome2(fromEnvironment)) {
18499
- return fromEnvironment.value;
18500
- }
18501
- const spawner = yield* ChildProcessSpawner;
18502
- const stored = (yield* spawner.string(make14("gh", ["auth", "token"]))).trim();
18503
- return stored === "" ? yield* fail6("no token") : make19(stored);
18504
- }).pipe(mapError2(() => new GitHubFailed({ message: "no GitHub token: set GH_TOKEN, or run `gh auth login`" })));
18505
- var GitHubLive = (repository) => {
18500
+ var GitHubLive = (repository, connection) => {
18506
18501
  const repo = (path) => `/repos/${repository.owner}/${repository.name}${path}`;
18507
18502
  return effect(GitHub, gen2(function* () {
18508
- const bearer = yield* token;
18509
- const client = (yield* HttpClient).pipe(mapRequest((request) => request.pipe(prependUrl("https://api.github.com"), acceptJson, bearerToken(bearer), setHeaders({
18503
+ const requestInit = match(connection.proxy, {
18504
+ onNone: () => ({}),
18505
+ onSome: ({ ca, url }) => match(ca, {
18506
+ onNone: () => ({ proxy: url }),
18507
+ onSome: (certificates) => ({ proxy: url, tls: { ca: certificates } })
18508
+ })
18509
+ });
18510
+ const client = (yield* HttpClient).pipe(mapRequest((request) => request.pipe(prependUrl("https://api.github.com"), acceptJson, bearerToken(connection.token), setHeaders({
18510
18511
  "user-agent": "jj-octo",
18511
18512
  "x-github-api-version": "2022-11-28"
18512
18513
  }))));
18513
- const send = (request) => client.execute(request).pipe(flatMap4((response) => response.status < 400 ? succeed6(response) : response.text.pipe(orElseSucceed2(() => ""), flatMap4((text) => fail6(new GitHubFailed({
18514
+ const send = (request) => client.execute(request).pipe(provideService2(RequestInit, requestInit), flatMap4((response) => response.status < 400 ? succeed6(response) : response.text.pipe(orElseSucceed2(() => ""), flatMap4((text) => fail6(new GitHubFailed({
18514
18515
  message: `${request.method} ${request.url} returned ${response.status}: ${text}`
18515
18516
  }))))), catchTag2("HttpClientError", (error) => fail6(new GitHubFailed({ message: error.message }))));
18516
18517
  const json = (schema) => (request) => send(request).pipe(flatMap4(schemaBodyJson(schema)), catchTags2({
@@ -18524,6 +18525,7 @@ var GitHubLive = (repository) => {
18524
18525
  open: stack.pull_requests.filter(({ state }) => state === "open").map(({ number }) => number)
18525
18526
  }))));
18526
18527
  const mergeRequest = (path) => json(MergeResult)(get4(repo(path)));
18528
+ const fromBranch = (branch, state) => repo(`/pulls?state=${state}&head=${repository.owner}:${encodeURIComponent(branch)}`);
18527
18529
  return {
18528
18530
  addToStack: (stack, pullRequests) => send(post(repo(`/stacks/${stack}/add`)).pipe(bodyOf({ pull_requests: [...pullRequests] }))).pipe(asVoid2),
18529
18531
  createPullRequest: ({ base, body, draft, head, title }) => json(CreatedPullRequest)(post(repo("/pulls")).pipe(bodyOf({ base, body, draft, head, title }))).pipe(map7(({ number }) => number)),
@@ -18552,7 +18554,8 @@ var GitHubLive = (repository) => {
18552
18554
  message: `merging #${pullRequest} ended ${settled.status}: ${settled.details.message}`
18553
18555
  });
18554
18556
  }),
18555
- openPullRequest: (branch) => json(PullRequests)(get4(repo(`/pulls?state=open&head=${repository.owner}:${encodeURIComponent(branch)}`))).pipe(map7((found) => map(fromNullishOr(found[0]), ({ base, body, draft, number, title }) => ({
18557
+ mergedHeads: (branch) => json(ClosedPullRequests)(get4(fromBranch(branch, "closed"))).pipe(map7((closed) => closed.filter(({ merged_at }) => merged_at !== null).map(({ head }) => head.sha))),
18558
+ openPullRequest: (branch) => json(PullRequests)(get4(fromBranch(branch, "open"))).pipe(map7((found) => map(fromNullishOr(found[0]), ({ base, body, draft, number, title }) => ({
18556
18559
  base: base.ref,
18557
18560
  body: body ?? "",
18558
18561
  draft: draft ?? false,
@@ -18567,6 +18570,60 @@ var GitHubLive = (repository) => {
18567
18570
  }));
18568
18571
  };
18569
18572
 
18573
+ // src/clients/github/connection.ts
18574
+ var authKey = "jj-octo.github-auth";
18575
+ var ownToken = gen2(function* () {
18576
+ const fromEnvironment = yield* option2(Redacted2("GH_TOKEN").pipe(orElse(() => Redacted2("GITHUB_TOKEN"))));
18577
+ if (isSome2(fromEnvironment)) {
18578
+ return fromEnvironment.value;
18579
+ }
18580
+ const spawner = yield* ChildProcessSpawner;
18581
+ const stored = (yield* spawner.string(make14("gh", ["auth", "token"]))).trim();
18582
+ return stored === "" ? yield* fail6("no token") : make19(stored);
18583
+ }).pipe(mapError2(() => new GitHubFailed({ message: "no GitHub token: set GH_TOKEN, or run `gh auth login`" })));
18584
+ var gitSetting = (name) => gen2(function* () {
18585
+ const git = yield* Git;
18586
+ const value = (yield* git.read(["config", "--get-urlmatch", name, "https://github.com"]).pipe(orElseSucceed2(() => ""))).trim();
18587
+ return value === "" ? none2() : some2(value);
18588
+ });
18589
+ var gitProxy = gen2(function* () {
18590
+ const fs = yield* FileSystem;
18591
+ const url = yield* gitSetting("http.proxy");
18592
+ if (isNone2(url)) {
18593
+ return yield* new GitHubFailed({
18594
+ message: `${authKey} is git-proxy, but git has no http.proxy for https://github.com`
18595
+ });
18596
+ }
18597
+ const caFile = yield* gitSetting("http.sslCAInfo");
18598
+ const ca = isNone2(caFile) ? none2() : some2(yield* fs.readFileString(caFile.value).pipe(mapError2((error) => new GitHubFailed({
18599
+ message: `can't read git's http.sslCAInfo ${caFile.value}: ${error.message}`
18600
+ }))));
18601
+ const connection = {
18602
+ proxy: some2({ ca, url: url.value }),
18603
+ token: make19("proxy-injected")
18604
+ };
18605
+ return connection;
18606
+ });
18607
+ var connection = gen2(function* () {
18608
+ const jj = yield* Jj;
18609
+ const auth = (yield* jj.read(["config", "get", authKey]).pipe(orElseSucceed2(() => ""))).trim();
18610
+ switch (auth) {
18611
+ case "":
18612
+ case "token": {
18613
+ const own = { proxy: none2(), token: yield* ownToken };
18614
+ return own;
18615
+ }
18616
+ case "git-proxy": {
18617
+ return yield* gitProxy;
18618
+ }
18619
+ default: {
18620
+ return yield* new GitHubFailed({
18621
+ message: `${authKey} is ${JSON.stringify(auth)}; set it to token or git-proxy`
18622
+ });
18623
+ }
18624
+ }
18625
+ });
18626
+
18570
18627
  // src/clients/github/repository.ts
18571
18628
  var repositoryFromUrl = (url) => {
18572
18629
  const match = /github\.com[:/](?<owner>[^/]+)\/(?<name>[^/]+?)(?:\.git)?\/?$/u.exec(url.trim());
@@ -18585,7 +18642,7 @@ var GitHubFromOrigin = unwrap(gen2(function* () {
18585
18642
  if (isNone2(repository)) {
18586
18643
  return yield* new GitHubFailed({ message: "the `origin` remote isn't a GitHub repository" });
18587
18644
  }
18588
- return GitHubLive(repository.value);
18645
+ return GitHubLive(repository.value, yield* connection);
18589
18646
  }));
18590
18647
 
18591
18648
  // src/cli/gh/log.ts
@@ -18898,20 +18955,31 @@ var rebaseStack = (root, required) => gen2(function* () {
18898
18955
  yield* error(`left "${name}" and the changes on it where they were: they conflict with the trunk`);
18899
18956
  }
18900
18957
  });
18901
- var sync4 = (required = [], mergedChanges = []) => gen2(function* () {
18958
+ var mergedOnGitHub = (bookmarks) => gen2(function* () {
18959
+ const github = yield* GitHub;
18960
+ const merged = yield* forEach2(bookmarks.filter(({ remote }) => remote !== undefined), ({ change, local, name }) => github.mergedHeads(name).pipe(map7((heads) => heads.includes(local) ? [{ bookmark: name, changeId: change }] : [])), { concurrency: "unbounded" });
18961
+ return merged.flat();
18962
+ });
18963
+ var sync4 = (known = []) => gen2(function* () {
18902
18964
  const jj = yield* Jj;
18903
18965
  const before = yield* bookmarksBeforeFetch;
18966
+ const merged = [
18967
+ ...known,
18968
+ ...yield* mergedOnGitHub(before.filter(({ name }) => known.every(({ bookmark }) => bookmark !== name)))
18969
+ ];
18904
18970
  yield* jj.run(["git", "fetch"]);
18905
- for (const { bookmark } of mergedChanges) {
18971
+ for (const { bookmark } of merged) {
18906
18972
  yield* jj.run(["bookmark", "forget", "--include-remotes", `exact:${bookmark}`]);
18907
18973
  }
18908
- if (mergedChanges.length > 0) {
18909
- const revset = mergedChanges.map(({ changeId }) => `change_id(${changeId})`).join(" | ");
18910
- yield* jj.run(["abandon", `(${revset}) & mutable()`]);
18974
+ const mergedRevset = `(${["none()", ...merged.map(({ changeId }) => `change_id(${changeId})`)].join(" | ")}) & mutable()`;
18975
+ const required = yield* changeIdsAt(`(${mergedRevset}):: ~ (${mergedRevset})`);
18976
+ if (merged.length > 0) {
18977
+ yield* jj.run(["abandon", mergedRevset]);
18978
+ yield* error(`abandoned the changes whose pull requests merged: ${merged.map(({ bookmark }) => bookmark).join(", ")}`);
18911
18979
  }
18912
18980
  const fetched = yield* bookmarkTargets;
18913
18981
  const notCombined = [];
18914
- for (const bookmark of before.filter(({ name }) => mergedChanges.every((change) => change.bookmark !== name))) {
18982
+ for (const bookmark of before.filter(({ name }) => merged.every((change) => change.bookmark !== name))) {
18915
18983
  if (!(yield* combine4(bookmark, fetched.get(bookmark.name)?.origin))) {
18916
18984
  notCombined.push(bookmark.name);
18917
18985
  }
@@ -18919,8 +18987,8 @@ var sync4 = (required = [], mergedChanges = []) => gen2(function* () {
18919
18987
  for (const root of yield* commitsAt(BEHIND_ROOTS)) {
18920
18988
  yield* rebaseStack(root, required);
18921
18989
  }
18922
- const merged = (yield* bookmarksAt("::trunk()")).filter((name) => before.some((bookmark) => bookmark.name === name));
18923
- for (const name of merged) {
18990
+ const onTrunk = (yield* bookmarksAt("::trunk()")).filter((name) => before.some((bookmark) => bookmark.name === name));
18991
+ for (const name of onTrunk) {
18924
18992
  yield* jj.run(["bookmark", "forget", "--include-remotes", `exact:${name}`]);
18925
18993
  }
18926
18994
  if (notCombined.length > 0) {
@@ -18937,7 +19005,7 @@ var sync4 = (required = [], mergedChanges = []) => gen2(function* () {
18937
19005
  var syncCommand = make30("sync", {}, () => asUserError(gen2(function* () {
18938
19006
  yield* checkTrunk(yield* (yield* GitHub).defaultBranch);
18939
19007
  yield* sync4();
18940
- }).pipe(provide3(GitHubFromOrigin)))).pipe(withHelp("Fetch, and rebase every change onto the trunk", "Fetches, then rebases every change that isn't merged yet onto the trunk, keeping each on the changes below it. A change that has merged is empty after the rebase, so it's abandoned and its bookmark forgotten.", "A stack that would conflict with the trunk is left where it was, and named. Rebase it yourself with `jj rebase` when you want to resolve it.", "Commits added to a branch on GitHub are squashed into the branch's local change, whether or not that change was also edited locally. Where GitHub only rebased a branch, the local change is kept.", "Fails before fetching if `trunk()` isn't GitHub's default branch, which `jj gh init` sets.", "Pushes nothing. Run `jj gh submit` to update the pull requests of a change and the ones below it."));
19008
+ }).pipe(provide3(GitHubFromOrigin)))).pipe(withHelp("Fetch, and rebase every change onto the trunk", "Fetches, then rebases every change that isn't merged yet onto the trunk, keeping each on the changes below it. A change whose pull request GitHub merged is abandoned and its bookmark forgotten, unless it was edited after the merge.", "A stack that would conflict with the trunk is left where it was, and named. Rebase it yourself with `jj rebase` when you want to resolve it. The rest of a stack whose changes merged is rebased even where it conflicts, and the conflict is recorded in the change.", "Commits added to a branch on GitHub are squashed into the branch's local change, whether or not that change was also edited locally. Where GitHub only rebased a branch, the local change is kept.", "Fails before fetching if `trunk()` isn't GitHub's default branch, which `jj gh init` sets.", "Pushes nothing. Run `jj gh submit` to update the pull requests of a change and the ones below it."));
18941
19009
 
18942
19010
  // src/cli/gh/merge.ts
18943
19011
  class NotMergeable extends TaggedError2("NotMergeable") {
@@ -18984,12 +19052,11 @@ var merge5 = (revision) => gen2(function* () {
18984
19052
  }
18985
19053
  const pullRequest = yield* openPullRequestOf(top);
18986
19054
  const above = yield* bookmarksAt(upstackRevset(bookmarkRevset(top)));
18987
- const stackChanges = yield* changeIdsAt(stackRevset(revision));
18988
19055
  if ((yield* mergeOnGitHub(pullRequest, trunk)) === "enqueued") {
18989
19056
  yield* error("queued to merge; run `jj gh sync` once it has");
18990
19057
  } else {
18991
19058
  yield* error(`merged up to #${pullRequest.number}`);
18992
- yield* sync4(stackChanges, changes);
19059
+ yield* sync4(changes);
18993
19060
  for (const bookmark of above) {
18994
19061
  if ((yield* commitsAt(bookmarkRevset(bookmark))).length > 0) {
18995
19062
  yield* republish(bookmarkRevset(bookmark));
@@ -18998,7 +19065,7 @@ var merge5 = (revision) => gen2(function* () {
18998
19065
  }
18999
19066
  }
19000
19067
  });
19001
- var mergeCommand = make30("merge", { revision: revisionFlag("The top change to merge") }, ({ revision }) => asUserError(merge5(revision).pipe(provide3(GitHubFromOrigin)))).pipe(withHelp("Merge the pull requests of a change and the ones below it", "Squash-merges them, and prints which before it does. An empty working copy means the change below it. Fails before merging if any of the changes has edits that aren't pushed.", "A pull request in a GitHub stack is merged together with the pull requests below it. Either all of them merge or none does. A pull request outside a stack is merged alone, and only if its base is the trunk.", "Then syncs, as `jj gh sync` does, and pushes what is left of the change's stack again, up to the highest branch already on GitHub. Unlike `jj gh sync`, it abandons the merged changes and forgets their bookmarks, rather than relying on each coming out empty on the trunk, which one doesn't where a change merged above it edited the same lines. It also rebases the rest of the stack even where it conflicts with the trunk, and the conflict is recorded in the change.", "If GitHub queues the merge instead, nothing is synced. Run `jj gh sync` once it has merged."));
19068
+ var mergeCommand = make30("merge", { revision: revisionFlag("The top change to merge") }, ({ revision }) => asUserError(merge5(revision).pipe(provide3(GitHubFromOrigin)))).pipe(withHelp("Merge the pull requests of a change and the ones below it", "Squash-merges them, and prints which before it does. An empty working copy means the change below it. Fails before merging if any of the changes has edits that aren't pushed.", "A pull request in a GitHub stack is merged together with the pull requests below it. Either all of them merge or none does. A pull request outside a stack is merged alone, and only if its base is the trunk.", "Then syncs, as `jj gh sync` does: it abandons the merged changes, forgets their bookmarks, and rebases the rest of the stack onto the trunk even where it conflicts, recording the conflict in the change. Then it pushes what is left of the change's stack again, up to the highest branch already on GitHub.", "If GitHub queues the merge instead, nothing is synced. Run `jj gh sync` once it has merged."));
19002
19069
 
19003
19070
  // src/cli/gh/submit.ts
19004
19071
  var submitCommand = make30("submit", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jj-octo",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "A jj subcommand for working with GitHub that supports stacking",
5
5
  "license": "MIT",
6
6
  "repository": {