jj-octo 0.1.0 → 0.2.0

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.
Files changed (3) hide show
  1. package/README.md +6 -2
  2. package/dist/jj-octo.js +281 -179
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -27,12 +27,14 @@ version = "X.Y.Z"
27
27
  allow_low_downloads = true
28
28
  ```
29
29
 
30
- Then make it a jj subcommand. The alias isn't committed, so run this once in each checkout:
30
+ Then set up each checkout, since jj doesn't read config from a committed file:
31
31
 
32
32
  ```
33
- jj config set --repo aliases.gh '["util", "exec", "--", "jj-octo"]'
33
+ jj-octo init
34
34
  ```
35
35
 
36
+ It makes the checkout a jj repository colocated with git, makes GitHub's default branch `trunk()`, and sets the `jj gh` alias.
37
+
36
38
  ### For every repository
37
39
 
38
40
  ```
@@ -40,6 +42,8 @@ bun install --global jj-octo
40
42
  jj config set --user aliases.gh '["util", "exec", "--", "jj-octo"]'
41
43
  ```
42
44
 
45
+ Then run `jj-octo init` once in each checkout, as above.
46
+
43
47
  To update it later:
44
48
 
45
49
  ```
package/dist/jj-octo.js CHANGED
@@ -18116,7 +18116,7 @@ var layer14 = /* @__PURE__ */ layerMergedContext(/* @__PURE__ */ succeed6(fetch)
18116
18116
  // package.json
18117
18117
  var package_default = {
18118
18118
  name: "jj-octo",
18119
- version: "0.1.0",
18119
+ version: "0.2.0",
18120
18120
  description: "A jj subcommand for working with GitHub that supports stacking",
18121
18121
  license: "MIT",
18122
18122
  repository: {
@@ -18169,117 +18169,61 @@ var withHelp = (summary, ...paragraphs) => (command) => command.pipe(withShortDe
18169
18169
 
18170
18170
  `)));
18171
18171
  var revisionFlag = (description) => String8("revision").pipe(withAlias2("r"), withDefault5("@"), withDescription2(`${description}, the working copy by default`));
18172
- // src/clients/github/openapi.ts
18173
- var responseOf = () => (schema) => schema;
18174
- var bodyOf = (body) => bodyJsonUnsafe(body);
18175
-
18176
- // src/clients/github/types.ts
18177
- var PullRequestNumberSchema = Number6.pipe(brand2("PullRequestNumber"));
18178
- var StackNumberSchema = Number6.pipe(brand2("StackNumber"));
18179
-
18180
- // src/clients/github/client.ts
18181
- class GitHubFailed extends TaggedError2("GitHubFailed") {
18172
+ // src/clients/git.ts
18173
+ class GitFailed extends TaggedError2("GitFailed") {
18182
18174
  }
18183
18175
 
18184
- class GitHub extends Service()("jj-octo/GitHub") {
18176
+ class Git extends Service()("jj-octo/Git") {
18185
18177
  }
18186
- var CreatedPullRequest = responseOf()(Struct({ number: PullRequestNumberSchema }));
18187
- var CreatedStack = responseOf()(Struct({ number: StackNumberSchema }));
18188
- var PullRequests = responseOf()(ArraySchema(Struct({
18189
- base: Struct({ ref: String4 }),
18190
- body: NullOr(String4),
18191
- draft: optionalKey2(Boolean2),
18192
- number: PullRequestNumberSchema,
18193
- title: String4
18194
- })));
18195
- var StackedPullRequest = Struct({
18196
- number: PullRequestNumberSchema,
18197
- state: Literals(["closed", "open"])
18198
- });
18199
- var Stacks = responseOf()(ArraySchema(Struct({ number: StackNumberSchema, pull_requests: ArraySchema(StackedPullRequest) })));
18200
- var RepositoryDetails = responseOf()(Struct({ default_branch: String4 }));
18201
- var MergeRequest = Struct({
18202
- details: Struct({
18203
- message: String4,
18204
- uuid: optionalKey2(String4)
18205
- }),
18206
- status: Literals(["enqueued", "failed", "merged", "pending"])
18207
- });
18208
- var StartedMerge = responseOf()(MergeRequest);
18209
- var MergeResult = responseOf()(MergeRequest);
18210
- var token = gen2(function* () {
18211
- const fromEnvironment = yield* option2(Redacted2("GH_TOKEN").pipe(orElse(() => Redacted2("GITHUB_TOKEN"))));
18212
- if (isSome2(fromEnvironment)) {
18213
- return fromEnvironment.value;
18214
- }
18178
+ var gitLayer = ({ cwd, env } = {}) => effect(Git, gen2(function* () {
18215
18179
  const spawner = yield* ChildProcessSpawner;
18216
- const stored = (yield* spawner.string(make14("gh", ["auth", "token"]))).trim();
18217
- return stored === "" ? yield* fail6("no token") : make19(stored);
18218
- }).pipe(mapError2(() => new GitHubFailed({ message: "no GitHub token: set GH_TOKEN, or run `gh auth login`" })));
18219
- var GitHubLive = (repository) => {
18220
- const repo = (path) => `/repos/${repository.owner}/${repository.name}${path}`;
18221
- return effect(GitHub, gen2(function* () {
18222
- const bearer = yield* token;
18223
- const client = (yield* HttpClient).pipe(mapRequest((request) => request.pipe(prependUrl("https://api.github.com"), acceptJson, bearerToken(bearer), setHeaders({
18224
- "user-agent": "jj-octo",
18225
- "x-github-api-version": "2022-11-28"
18226
- }))));
18227
- const send = (request) => client.execute(request).pipe(flatMap4((response) => response.status < 400 ? succeed6(response) : response.text.pipe(orElseSucceed2(() => ""), flatMap4((text) => fail6(new GitHubFailed({
18228
- message: `${request.method} ${request.url} returned ${response.status}: ${text}`
18229
- }))))), catchTag2("HttpClientError", (error) => fail6(new GitHubFailed({ message: error.message }))));
18230
- const json = (schema) => (request) => send(request).pipe(flatMap4(schemaBodyJson(schema)), catchTags2({
18231
- HttpClientError: (error) => fail6(new GitHubFailed({ message: error.message })),
18232
- SchemaError: (error) => fail6(new GitHubFailed({
18233
- message: `unexpected response to ${request.url}: ${error.message}`
18234
- }))
18235
- }));
18236
- const stackOf = (pullRequest) => json(Stacks)(get4(repo(`/stacks?pull_request=${pullRequest}`))).pipe(map7((stacks) => map(fromNullishOr(stacks[0]), (stack) => ({
18237
- number: stack.number,
18238
- open: stack.pull_requests.filter(({ state }) => state === "open").map(({ number }) => number)
18239
- }))));
18240
- const mergeRequest = (path) => json(MergeResult)(get4(repo(path)));
18241
- return {
18242
- addToStack: (stack, pullRequests) => send(post(repo(`/stacks/${stack}/add`)).pipe(bodyOf({ pull_requests: [...pullRequests] }))).pipe(asVoid2),
18243
- createPullRequest: ({ base, body, draft, head, title }) => json(CreatedPullRequest)(post(repo("/pulls")).pipe(bodyOf({ base, body, draft, head, title }))).pipe(map7(({ number }) => number)),
18244
- createStack: (pullRequests) => json(CreatedStack)(post(repo("/stacks")).pipe(bodyOf({ pull_requests: [...pullRequests] }))).pipe(map7(({ number }) => number)),
18245
- defaultBranch: json(RepositoryDetails)(get4(repo(""))).pipe(map7(({ default_branch }) => default_branch)),
18246
- mergePullRequest: (pullRequest) => send(put(repo(`/pulls/${pullRequest}/merge`)).pipe(bodyOf({ merge_method: "squash" }))).pipe(asVoid2),
18247
- mergeStack: (pullRequest) => gen2(function* () {
18248
- const started = yield* json(StartedMerge)(put(repo(`/pulls/${pullRequest}/merge-async`)).pipe(bodyOf({
18249
- merge_action: "default",
18250
- merge_method: "squash"
18251
- })));
18252
- const { uuid } = started.details;
18253
- if (uuid === undefined) {
18254
- return yield* new GitHubFailed({
18255
- message: `merging #${pullRequest} returned no request to follow`
18256
- });
18257
- }
18258
- const settled = yield* mergeRequest(`/pulls/${pullRequest}/merge-async/${uuid}`).pipe(repeat2({
18259
- schedule: spaced("5 seconds"),
18260
- until: ({ status }) => status !== "pending"
18261
- }));
18262
- if (settled.status === "merged" || settled.status === "enqueued") {
18263
- return settled.status;
18264
- }
18265
- return yield* new GitHubFailed({
18266
- message: `merging #${pullRequest} ended ${settled.status}: ${settled.details.message}`
18267
- });
18268
- }),
18269
- 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 }) => ({
18270
- base: base.ref,
18271
- body: body ?? "",
18272
- draft: draft ?? false,
18273
- number,
18274
- title
18275
- })))),
18276
- setBase: (pullRequest, base) => send(patch(repo(`/pulls/${pullRequest}`)).pipe(bodyOf({ base }))).pipe(asVoid2),
18277
- setDescription: (pullRequest, { body, title }) => send(patch(repo(`/pulls/${pullRequest}`)).pipe(bodyOf({ body, title }))).pipe(asVoid2),
18278
- stackOf,
18279
- unstack: (stack) => send(post(repo(`/stacks/${stack}/unstack`))).pipe(asVoid2)
18280
- };
18281
- }));
18282
- };
18180
+ const fs = yield* FileSystem;
18181
+ const path = yield* Path;
18182
+ return {
18183
+ mergeText: ({ base, local, remote }) => scoped2(gen2(function* () {
18184
+ const directory = yield* fs.makeTempDirectoryScoped();
18185
+ const written = { base: `${base}
18186
+ `, github: `${remote}
18187
+ `, local: `${local}
18188
+ ` };
18189
+ yield* all2(Object.entries(written).map(([name, text]) => fs.writeFileString(path.join(directory, name), text)));
18190
+ const handle = yield* spawner.spawn(make14("git", [
18191
+ "merge-file",
18192
+ "-p",
18193
+ "-L",
18194
+ "local",
18195
+ "-L",
18196
+ "base",
18197
+ "-L",
18198
+ "GitHub",
18199
+ "local",
18200
+ "base",
18201
+ "github"
18202
+ ], { cwd: directory }));
18203
+ const [text, stderr, code] = yield* all2([
18204
+ mkString(decodeText(handle.stdout)),
18205
+ mkString(decodeText(handle.stderr)),
18206
+ handle.exitCode
18207
+ ], { concurrency: "unbounded" });
18208
+ if (code > 127) {
18209
+ return yield* new GitFailed({ message: `git merge-file failed: ${stderr.trim()}` });
18210
+ }
18211
+ return { conflicted: code > 0, text: text.trimEnd() };
18212
+ })).pipe(catchTag2("PlatformError", (error) => fail6(new GitFailed({ message: `git merge-file failed: ${error.message}` })))),
18213
+ read: (args) => scoped2(gen2(function* () {
18214
+ const handle = yield* spawner.spawn(make14("git", args, { cwd, env, extendEnv: true }));
18215
+ const [stdout, stderr, code] = yield* all2([
18216
+ mkString(decodeText(handle.stdout)),
18217
+ mkString(decodeText(handle.stderr)),
18218
+ handle.exitCode
18219
+ ], { concurrency: "unbounded" });
18220
+ return code === 0 ? stdout : yield* new GitFailed({
18221
+ message: `git ${args.join(" ")} failed${stderr.trim() === "" ? "" : `: ${stderr.trim()}`}`
18222
+ });
18223
+ })).pipe(catchTag2("PlatformError", (error) => fail6(new GitFailed({ message: `git ${args.join(" ")} failed: ${error.message}` }))))
18224
+ };
18225
+ }));
18226
+ var GitLive = gitLayer();
18283
18227
 
18284
18228
  // src/clients/jj.ts
18285
18229
  class JjFailed extends TaggedError2("JjFailed") {
@@ -18308,27 +18252,6 @@ var jjLayer = ({ cwd, env, quiet = false } = {}) => effect(Jj, gen2(function* ()
18308
18252
  }));
18309
18253
  var JjLive = jjLayer();
18310
18254
 
18311
- // src/clients/github/repository.ts
18312
- var repositoryFromUrl = (url) => {
18313
- const match = /github\.com[:/](?<owner>[^/]+)\/(?<name>[^/]+?)(?:\.git)?\/?$/u.exec(url.trim());
18314
- const owner = match?.groups?.["owner"];
18315
- const name = match?.groups?.["name"];
18316
- return owner === undefined || name === undefined ? none2() : some2({ name, owner });
18317
- };
18318
-
18319
- // src/clients/github/origin.ts
18320
- var GitHubFromOrigin = unwrap(gen2(function* () {
18321
- const jj = yield* Jj;
18322
- const remotes = yield* jj.read(["git", "remote", "list"]);
18323
- const origin = remotes.split(`
18324
- `).find((line) => line.startsWith("origin "));
18325
- const repository = repositoryFromUrl(origin?.slice("origin ".length) ?? "");
18326
- if (isNone2(repository)) {
18327
- return yield* new GitHubFailed({ message: "the `origin` remote isn't a GitHub repository" });
18328
- }
18329
- return GitHubLive(repository.value);
18330
- }));
18331
-
18332
18255
  // src/cli/gh/stack/changes.ts
18333
18256
  class NotAStack extends TaggedError2("NotAStack") {
18334
18257
  }
@@ -18442,6 +18365,221 @@ var chain = (revset, revision) => gen2(function* () {
18442
18365
  var stackChanges = (revision) => chain(stackRevset(revision), revision);
18443
18366
  var downstackChanges = (revision) => chain(downstackRevset(revision), revision);
18444
18367
 
18368
+ // src/cli/gh/stack/trunk.ts
18369
+ class TrunkMismatch extends TaggedError2("TrunkMismatch") {
18370
+ }
18371
+ var trunkRevset = (branch) => `${JSON.stringify(branch)}@origin`;
18372
+ var checkTrunk = (branch) => gen2(function* () {
18373
+ if ((yield* commitsAt(`trunk() & present(${trunkRevset(branch)})`)).length === 0) {
18374
+ yield* new TrunkMismatch({
18375
+ message: `trunk() isn't ${branch}@origin, GitHub's default branch; run \`jj gh init\``
18376
+ });
18377
+ }
18378
+ });
18379
+
18380
+ // src/cli/gh/init.ts
18381
+ var colocate = gen2(function* () {
18382
+ const fs = yield* FileSystem;
18383
+ const git = yield* Git;
18384
+ const jj = yield* Jj;
18385
+ const resolve = (output) => fs.realPath(output.trim()).pipe(orElseSucceed2(() => ""));
18386
+ const root = yield* resolve(yield* git.read(["rev-parse", "--show-toplevel"]));
18387
+ const jjRoot = yield* jj.read(["root"]).pipe(flatMap4(resolve), orElseSucceed2(() => ""));
18388
+ if (jjRoot !== root) {
18389
+ yield* jj.run(["git", "init", "--colocate", root]);
18390
+ }
18391
+ });
18392
+ var copyIdentity = gen2(function* () {
18393
+ const git = yield* Git;
18394
+ const jj = yield* Jj;
18395
+ for (const key of ["user.name", "user.email"]) {
18396
+ const own = (yield* jj.read(["config", "get", key])).trim();
18397
+ const value = (yield* git.read(["config", key]).pipe(orElseSucceed2(() => ""))).trim();
18398
+ if (own === "" && value !== "") {
18399
+ yield* jj.run(["config", "set", "--repo", key, value]);
18400
+ }
18401
+ }
18402
+ });
18403
+ var defaultBranch = gen2(function* () {
18404
+ const git = yield* Git;
18405
+ const output = yield* git.read(["ls-remote", "--symref", "origin", "HEAD"]);
18406
+ const branch = /^ref: refs\/heads\/(?<branch>\S+)\tHEAD$/mu.exec(output)?.groups?.["branch"];
18407
+ if (branch === undefined) {
18408
+ return yield* new GitFailed({ message: "origin names no default branch" });
18409
+ }
18410
+ return branch;
18411
+ });
18412
+ var setTrunk = gen2(function* () {
18413
+ const jj = yield* Jj;
18414
+ const branch = yield* defaultBranch;
18415
+ if ((yield* commitsAt(`present(${trunkRevset(branch)})`)).length === 0) {
18416
+ yield* jj.run(["git", "fetch", "--remote", "origin", "--branch", `exact:${branch}`]);
18417
+ }
18418
+ if ((yield* bookmarkTargets).get(branch)?.tracked !== true) {
18419
+ yield* jj.run(["bookmark", "track", `exact:${branch}`, "--remote", "origin"]);
18420
+ }
18421
+ yield* jj.run([
18422
+ "config",
18423
+ "set",
18424
+ "--repo",
18425
+ 'revset-aliases."trunk()"',
18426
+ JSON.stringify(trunkRevset(branch))
18427
+ ]);
18428
+ return branch;
18429
+ });
18430
+ var init = gen2(function* () {
18431
+ const jj = yield* Jj;
18432
+ yield* colocate;
18433
+ yield* copyIdentity;
18434
+ const anonymous = 'mutable() & (author_name(exact:"") | author_email(exact:""))';
18435
+ if ((yield* commitsAt(anonymous)).length > 0) {
18436
+ yield* jj.run(["metaedit", "--quiet", "--update-author", "-r", anonymous]);
18437
+ }
18438
+ const branch = yield* setTrunk;
18439
+ yield* jj.run([
18440
+ "config",
18441
+ "set",
18442
+ "--repo",
18443
+ "aliases.gh",
18444
+ JSON.stringify(["util", "exec", "--", "jj-octo"])
18445
+ ]);
18446
+ yield* error(`set up jj, with trunk() as ${branch}@origin`);
18447
+ });
18448
+ var initCommand = make30("init", {}, () => asUserError(init)).pipe(withHelp("Set up a git checkout for the other commands", "Makes it a jj repository colocated with git, unless it already is one, and copies git's name and email into the repository's jj config where jj has none.", "Reads the default branch from `origin`, fetches and tracks it, and sets `trunk()` to it in the repository's config. jj's own `trunk()` guesses from branch names, so without this a default branch it doesn't guess would be the base of pull requests but not of local rebases. `submit`, `merge` and `sync` check that the two agree.", "Sets the `jj gh` alias to run `jj-octo` from `PATH`, in the repository's config. Set it again afterwards to run another build.", "Needs no GitHub token. Running it again changes nothing that is already set up. Run it again after the default branch changes on GitHub."));
18449
+
18450
+ // src/clients/github/openapi.ts
18451
+ var responseOf = () => (schema) => schema;
18452
+ var bodyOf = (body) => bodyJsonUnsafe(body);
18453
+
18454
+ // src/clients/github/types.ts
18455
+ var PullRequestNumberSchema = Number6.pipe(brand2("PullRequestNumber"));
18456
+ var StackNumberSchema = Number6.pipe(brand2("StackNumber"));
18457
+
18458
+ // src/clients/github/client.ts
18459
+ class GitHubFailed extends TaggedError2("GitHubFailed") {
18460
+ }
18461
+
18462
+ class GitHub extends Service()("jj-octo/GitHub") {
18463
+ }
18464
+ var CreatedPullRequest = responseOf()(Struct({ number: PullRequestNumberSchema }));
18465
+ var CreatedStack = responseOf()(Struct({ number: StackNumberSchema }));
18466
+ var PullRequests = responseOf()(ArraySchema(Struct({
18467
+ base: Struct({ ref: String4 }),
18468
+ body: NullOr(String4),
18469
+ draft: optionalKey2(Boolean2),
18470
+ number: PullRequestNumberSchema,
18471
+ title: String4
18472
+ })));
18473
+ var StackedPullRequest = Struct({
18474
+ number: PullRequestNumberSchema,
18475
+ state: Literals(["closed", "open"])
18476
+ });
18477
+ var Stacks = responseOf()(ArraySchema(Struct({ number: StackNumberSchema, pull_requests: ArraySchema(StackedPullRequest) })));
18478
+ var RepositoryDetails = responseOf()(Struct({ default_branch: String4 }));
18479
+ var MergeRequest = Struct({
18480
+ details: Struct({
18481
+ message: String4,
18482
+ uuid: optionalKey2(String4)
18483
+ }),
18484
+ status: Literals(["enqueued", "failed", "merged", "pending"])
18485
+ });
18486
+ var StartedMerge = responseOf()(MergeRequest);
18487
+ var MergeResult = responseOf()(MergeRequest);
18488
+ var token = gen2(function* () {
18489
+ const fromEnvironment = yield* option2(Redacted2("GH_TOKEN").pipe(orElse(() => Redacted2("GITHUB_TOKEN"))));
18490
+ if (isSome2(fromEnvironment)) {
18491
+ return fromEnvironment.value;
18492
+ }
18493
+ const spawner = yield* ChildProcessSpawner;
18494
+ const stored = (yield* spawner.string(make14("gh", ["auth", "token"]))).trim();
18495
+ return stored === "" ? yield* fail6("no token") : make19(stored);
18496
+ }).pipe(mapError2(() => new GitHubFailed({ message: "no GitHub token: set GH_TOKEN, or run `gh auth login`" })));
18497
+ var GitHubLive = (repository) => {
18498
+ const repo = (path) => `/repos/${repository.owner}/${repository.name}${path}`;
18499
+ return effect(GitHub, gen2(function* () {
18500
+ const bearer = yield* token;
18501
+ const client = (yield* HttpClient).pipe(mapRequest((request) => request.pipe(prependUrl("https://api.github.com"), acceptJson, bearerToken(bearer), setHeaders({
18502
+ "user-agent": "jj-octo",
18503
+ "x-github-api-version": "2022-11-28"
18504
+ }))));
18505
+ const send = (request) => client.execute(request).pipe(flatMap4((response) => response.status < 400 ? succeed6(response) : response.text.pipe(orElseSucceed2(() => ""), flatMap4((text) => fail6(new GitHubFailed({
18506
+ message: `${request.method} ${request.url} returned ${response.status}: ${text}`
18507
+ }))))), catchTag2("HttpClientError", (error) => fail6(new GitHubFailed({ message: error.message }))));
18508
+ const json = (schema) => (request) => send(request).pipe(flatMap4(schemaBodyJson(schema)), catchTags2({
18509
+ HttpClientError: (error) => fail6(new GitHubFailed({ message: error.message })),
18510
+ SchemaError: (error) => fail6(new GitHubFailed({
18511
+ message: `unexpected response to ${request.url}: ${error.message}`
18512
+ }))
18513
+ }));
18514
+ const stackOf = (pullRequest) => json(Stacks)(get4(repo(`/stacks?pull_request=${pullRequest}`))).pipe(map7((stacks) => map(fromNullishOr(stacks[0]), (stack) => ({
18515
+ number: stack.number,
18516
+ open: stack.pull_requests.filter(({ state }) => state === "open").map(({ number }) => number)
18517
+ }))));
18518
+ const mergeRequest = (path) => json(MergeResult)(get4(repo(path)));
18519
+ return {
18520
+ addToStack: (stack, pullRequests) => send(post(repo(`/stacks/${stack}/add`)).pipe(bodyOf({ pull_requests: [...pullRequests] }))).pipe(asVoid2),
18521
+ createPullRequest: ({ base, body, draft, head, title }) => json(CreatedPullRequest)(post(repo("/pulls")).pipe(bodyOf({ base, body, draft, head, title }))).pipe(map7(({ number }) => number)),
18522
+ createStack: (pullRequests) => json(CreatedStack)(post(repo("/stacks")).pipe(bodyOf({ pull_requests: [...pullRequests] }))).pipe(map7(({ number }) => number)),
18523
+ defaultBranch: json(RepositoryDetails)(get4(repo(""))).pipe(map7(({ default_branch }) => default_branch)),
18524
+ mergePullRequest: (pullRequest) => send(put(repo(`/pulls/${pullRequest}/merge`)).pipe(bodyOf({ merge_method: "squash" }))).pipe(asVoid2),
18525
+ mergeStack: (pullRequest) => gen2(function* () {
18526
+ const started = yield* json(StartedMerge)(put(repo(`/pulls/${pullRequest}/merge-async`)).pipe(bodyOf({
18527
+ merge_action: "default",
18528
+ merge_method: "squash"
18529
+ })));
18530
+ const { uuid } = started.details;
18531
+ if (uuid === undefined) {
18532
+ return yield* new GitHubFailed({
18533
+ message: `merging #${pullRequest} returned no request to follow`
18534
+ });
18535
+ }
18536
+ const settled = yield* mergeRequest(`/pulls/${pullRequest}/merge-async/${uuid}`).pipe(repeat2({
18537
+ schedule: spaced("5 seconds"),
18538
+ until: ({ status }) => status !== "pending"
18539
+ }));
18540
+ if (settled.status === "merged" || settled.status === "enqueued") {
18541
+ return settled.status;
18542
+ }
18543
+ return yield* new GitHubFailed({
18544
+ message: `merging #${pullRequest} ended ${settled.status}: ${settled.details.message}`
18545
+ });
18546
+ }),
18547
+ 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 }) => ({
18548
+ base: base.ref,
18549
+ body: body ?? "",
18550
+ draft: draft ?? false,
18551
+ number,
18552
+ title
18553
+ })))),
18554
+ setBase: (pullRequest, base) => send(patch(repo(`/pulls/${pullRequest}`)).pipe(bodyOf({ base }))).pipe(asVoid2),
18555
+ setDescription: (pullRequest, { body, title }) => send(patch(repo(`/pulls/${pullRequest}`)).pipe(bodyOf({ body, title }))).pipe(asVoid2),
18556
+ stackOf,
18557
+ unstack: (stack) => send(post(repo(`/stacks/${stack}/unstack`))).pipe(asVoid2)
18558
+ };
18559
+ }));
18560
+ };
18561
+
18562
+ // src/clients/github/repository.ts
18563
+ var repositoryFromUrl = (url) => {
18564
+ const match = /github\.com[:/](?<owner>[^/]+)\/(?<name>[^/]+?)(?:\.git)?\/?$/u.exec(url.trim());
18565
+ const owner = match?.groups?.["owner"];
18566
+ const name = match?.groups?.["name"];
18567
+ return owner === undefined || name === undefined ? none2() : some2({ name, owner });
18568
+ };
18569
+
18570
+ // src/clients/github/origin.ts
18571
+ var GitHubFromOrigin = unwrap(gen2(function* () {
18572
+ const jj = yield* Jj;
18573
+ const remotes = yield* jj.read(["git", "remote", "list"]);
18574
+ const origin = remotes.split(`
18575
+ `).find((line) => line.startsWith("origin "));
18576
+ const repository = repositoryFromUrl(origin?.slice("origin ".length) ?? "");
18577
+ if (isNone2(repository)) {
18578
+ return yield* new GitHubFailed({ message: "the `origin` remote isn't a GitHub repository" });
18579
+ }
18580
+ return GitHubLive(repository.value);
18581
+ }));
18582
+
18445
18583
  // src/cli/gh/log.ts
18446
18584
  var logLines2 = (entries) => {
18447
18585
  const rows = entries.toReversed().map(({ bookmark, pullRequest, pushed }) => [
@@ -18469,50 +18607,6 @@ var log2 = (revision) => gen2(function* () {
18469
18607
  var logCommand = make30("log", { revision: revisionFlag("A change of the stack to show") }, ({ revision }) => asUserError(log2(revision).pipe(provide3(GitHubFromOrigin))).pipe(flatMap4((printed) => log(printed.join(`
18470
18608
  `))))).pipe(withHelp("Show changes and their pull requests", "Prints one line per change, top first: its bookmark, its pull request with whether that is a draft or ready for review, and whether the change is pushed, changed since the push, or not pushed.", "Fails if the changes aren't a chain, if one has conflicts, or if one doesn't have exactly one bookmark. `submit` makes the same check before it changes anything.", "Changes nothing, locally or on GitHub."));
18471
18609
 
18472
- // src/clients/git.ts
18473
- class GitFailed extends TaggedError2("GitFailed") {
18474
- }
18475
-
18476
- class Git extends Service()("jj-octo/Git") {
18477
- }
18478
- var GitLive = effect(Git, gen2(function* () {
18479
- const spawner = yield* ChildProcessSpawner;
18480
- const fs = yield* FileSystem;
18481
- const path = yield* Path;
18482
- return {
18483
- mergeText: ({ base, local, remote }) => scoped2(gen2(function* () {
18484
- const directory = yield* fs.makeTempDirectoryScoped();
18485
- const written = { base: `${base}
18486
- `, github: `${remote}
18487
- `, local: `${local}
18488
- ` };
18489
- yield* all2(Object.entries(written).map(([name, text]) => fs.writeFileString(path.join(directory, name), text)));
18490
- const handle = yield* spawner.spawn(make14("git", [
18491
- "merge-file",
18492
- "-p",
18493
- "-L",
18494
- "local",
18495
- "-L",
18496
- "base",
18497
- "-L",
18498
- "GitHub",
18499
- "local",
18500
- "base",
18501
- "github"
18502
- ], { cwd: directory }));
18503
- const [text, stderr, code] = yield* all2([
18504
- mkString(decodeText(handle.stdout)),
18505
- mkString(decodeText(handle.stderr)),
18506
- handle.exitCode
18507
- ], { concurrency: "unbounded" });
18508
- if (code > 127) {
18509
- return yield* new GitFailed({ message: `git merge-file failed: ${stderr.trim()}` });
18510
- }
18511
- return { conflicted: code > 0, text: text.trimEnd() };
18512
- })).pipe(catchTag2("PlatformError", (error) => fail6(new GitFailed({ message: `git merge-file failed: ${error.message}` }))))
18513
- };
18514
- }));
18515
-
18516
18610
  // src/cli/gh/stack/plan.ts
18517
18611
  var basesFor = (branches, trunk) => [
18518
18612
  trunk,
@@ -18687,7 +18781,10 @@ var publish = (stack, { draft, overwriteDescriptions }) => gen2(function* () {
18687
18781
  }
18688
18782
  yield* register(dissolve ? none2() : existing, numbers);
18689
18783
  });
18690
- var submit = (revision, options) => downstackChanges(revision).pipe(flatMap4((stack) => publish(stack, options)));
18784
+ var submit = (revision, options) => gen2(function* () {
18785
+ yield* checkTrunk(yield* (yield* GitHub).defaultBranch);
18786
+ yield* publish(yield* downstackChanges(revision), options);
18787
+ });
18691
18788
  var republish = (revision) => gen2(function* () {
18692
18789
  if ((yield* changesIn(stackRevset(revision))).length === 0) {
18693
18790
  return;
@@ -18820,7 +18917,10 @@ var sync4 = (required = []) => gen2(function* () {
18820
18917
  });
18821
18918
  }
18822
18919
  });
18823
- var syncCommand = make30("sync", {}, () => asUserError(sync4())).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.", "Pushes nothing. Run `jj gh submit` to update the pull requests of a change and the ones below it."));
18920
+ var syncCommand = make30("sync", {}, () => asUserError(gen2(function* () {
18921
+ yield* checkTrunk(yield* (yield* GitHub).defaultBranch);
18922
+ yield* sync4();
18923
+ }).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."));
18824
18924
 
18825
18925
  // src/cli/gh/merge.ts
18826
18926
  class NotMergeable extends TaggedError2("NotMergeable") {
@@ -18836,7 +18936,7 @@ var openPullRequestOf = (bookmark) => gen2(function* () {
18836
18936
  }
18837
18937
  return opened.value;
18838
18938
  });
18839
- var mergeOnGitHub = ({ base, number }) => gen2(function* () {
18939
+ var mergeOnGitHub = ({ base, number }, trunk) => gen2(function* () {
18840
18940
  const github = yield* GitHub;
18841
18941
  const stack = yield* github.stackOf(number);
18842
18942
  if (isSome2(stack)) {
@@ -18844,7 +18944,7 @@ var mergeOnGitHub = ({ base, number }) => gen2(function* () {
18844
18944
  yield* error(`merging ${numbered(open.slice(0, open.indexOf(number) + 1))}`);
18845
18945
  return yield* github.mergeStack(number);
18846
18946
  }
18847
- if (base !== (yield* github.defaultBranch)) {
18947
+ if (base !== trunk) {
18848
18948
  return yield* new NotMergeable({
18849
18949
  message: `#${number} isn't in a GitHub stack, and its base is ${base}, so merging it wouldn't reach the trunk; run \`jj gh submit\` to register the stack, or merge the changes below it first`
18850
18950
  });
@@ -18854,6 +18954,8 @@ var mergeOnGitHub = ({ base, number }) => gen2(function* () {
18854
18954
  return "merged";
18855
18955
  });
18856
18956
  var merge5 = (revision) => gen2(function* () {
18957
+ const trunk = yield* (yield* GitHub).defaultBranch;
18958
+ yield* checkTrunk(trunk);
18857
18959
  const changes = yield* downstackChanges(revision);
18858
18960
  const top = changes.at(-1)?.bookmark ?? "";
18859
18961
  const targets = yield* bookmarkTargets;
@@ -18866,7 +18968,7 @@ var merge5 = (revision) => gen2(function* () {
18866
18968
  const pullRequest = yield* openPullRequestOf(top);
18867
18969
  const above = yield* bookmarksAt(upstackRevset(bookmarkRevset(top)));
18868
18970
  const stackChanges = yield* changeIdsAt(stackRevset(revision));
18869
- if ((yield* mergeOnGitHub(pullRequest)) === "enqueued") {
18971
+ if ((yield* mergeOnGitHub(pullRequest, trunk)) === "enqueued") {
18870
18972
  yield* error("queued to merge; run `jj gh sync` once it has");
18871
18973
  } else {
18872
18974
  yield* error(`merged up to #${pullRequest.number}`);
@@ -18889,7 +18991,7 @@ var submitCommand = make30("submit", {
18889
18991
  }, ({ overwriteDescriptions, publish, revision }) => asUserError(submit(revision, { draft: !publish, overwriteDescriptions }).pipe(provide3(GitHubFromOrigin)))).pipe(withHelp("Push a change and the ones below it, and open their pull requests", "Pushes the bookmark of each of those changes. An empty working copy means the change below it, so from the top of a stack this submits all of it. Opens a pull request for each branch that has none, with the title and body from the change's description. Points each pull request at the branch below it, and the bottom one at the default branch.", "New pull requests are opened as drafts, or ready for review with `--publish`. The flag doesn't change a pull request that already exists.", "Keeps each pull request's title and body the same as its change's description. A description edited on GitHub is written into the change, one edited locally is sent to the pull request, and edits on both sides are merged line by line. Edits to the same lines stop the submit before anything is pushed.", "Registers the pull requests as one GitHub stack. A stack of one change is a single pull request, which GitHub has no stack for.", "If changes were reordered, inserted or dropped since the last submit, the GitHub stack is dissolved and registered again under a new number. The pull requests stay open, including the one of a dropped change."));
18890
18992
 
18891
18993
  // src/cli/gh/command.ts
18892
- var jjGhCommand = make30("jj gh").pipe(withSubcommands([logCommand, mergeCommand, submitCommand, syncCommand]), withHelp("Commands for publishing jj changes as GitHub pull requests", "A stack is a chain of changes, each published as one pull request on the branch its bookmark names. Each pull request targets the branch of the change below it, so it shows only that change's diff. A single change is a stack of one, and is published as an ordinary pull request.", "The stack of a change is every change connected to it through mutable changes. An empty working copy with no description isn't part of it. `log` shows the whole stack. `submit` and `merge` act on a change and the ones below it, which must be a chain: no merges, no conflicts, and exactly one bookmark on each change.", "The repository is the one the `origin` remote points to. The token is read from `GH_TOKEN` or `GITHUB_TOKEN`, and otherwise from `gh auth token`."));
18994
+ var jjGhCommand = make30("jj gh").pipe(withSubcommands([initCommand, logCommand, mergeCommand, submitCommand, syncCommand]), withHelp("Commands for publishing jj changes as GitHub pull requests", "A stack is a chain of changes, each published as one pull request on the branch its bookmark names. Each pull request targets the branch of the change below it, so it shows only that change's diff. A single change is a stack of one, and is published as an ordinary pull request.", "The stack of a change is every change connected to it through mutable changes. An empty working copy with no description isn't part of it. `log` shows the whole stack. `submit` and `merge` act on a change and the ones below it, which must be a chain: no merges, no conflicts, and exactly one bookmark on each change.", "The repository is the one the `origin` remote points to. The token is read from `GH_TOKEN` or `GITHUB_TOKEN`, and otherwise from `gh auth token`."));
18893
18995
 
18894
18996
  // src/main.ts
18895
18997
  runMain2(run5(jjGhCommand, { version: package_default.version }).pipe(provide3([GitLive, JjLive]), provide3(layer13({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jj-octo",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A jj subcommand for working with GitHub that supports stacking",
5
5
  "license": "MIT",
6
6
  "repository": {