@markjaquith/agency 1.9.3 → 1.9.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "1.9.3",
3
+ "version": "1.9.5",
4
4
  "description": "Manages personal agents files",
5
5
  "keywords": [
6
6
  "agents",
@@ -0,0 +1,177 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { chmod, mkdir } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import { pr } from "./pr"
5
+ import {
6
+ cleanupTempDir,
7
+ createBranch,
8
+ createTempDir,
9
+ initGitRepo,
10
+ runTestEffect,
11
+ } from "../test-utils"
12
+
13
+ const restoreEnv = (key: string, value: string | undefined) => {
14
+ if (value === undefined) {
15
+ delete process.env[key]
16
+ return
17
+ }
18
+
19
+ process.env[key] = value
20
+ }
21
+
22
+ const readGhArgs = async (recordPath: string): Promise<string[]> => {
23
+ const file = Bun.file(recordPath)
24
+
25
+ if (!(await file.exists())) {
26
+ return []
27
+ }
28
+
29
+ const content = await file.text()
30
+ return content.trim().split("\n").filter(Boolean)
31
+ }
32
+
33
+ describe("pr command", () => {
34
+ let tempDir: string
35
+ let recordPath: string
36
+ let originalCwd: string
37
+ let originalPath: string | undefined
38
+ let originalAgencyConfigPath: string | undefined
39
+ let originalGhArgsFile: string | undefined
40
+
41
+ beforeEach(async () => {
42
+ tempDir = await createTempDir()
43
+ recordPath = join(tempDir, "gh-args.txt")
44
+ originalCwd = process.cwd()
45
+ originalPath = process.env.PATH
46
+ originalAgencyConfigPath = process.env.AGENCY_CONFIG_PATH
47
+ originalGhArgsFile = process.env.AGENCY_TEST_GH_ARGS_FILE
48
+
49
+ process.chdir(tempDir)
50
+ process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
51
+ process.env.AGENCY_TEST_GH_ARGS_FILE = recordPath
52
+
53
+ const binDir = join(tempDir, "bin")
54
+ const ghPath = join(binDir, "gh")
55
+ await mkdir(binDir)
56
+ await Bun.write(
57
+ ghPath,
58
+ `#!/bin/sh
59
+ : > "$AGENCY_TEST_GH_ARGS_FILE"
60
+ for arg in "$@"; do
61
+ printf '%s\n' "$arg" >> "$AGENCY_TEST_GH_ARGS_FILE"
62
+ done
63
+ `,
64
+ )
65
+ await chmod(ghPath, 0o755)
66
+ process.env.PATH = `${binDir}:${originalPath ?? ""}`
67
+
68
+ await initGitRepo(tempDir)
69
+ })
70
+
71
+ afterEach(async () => {
72
+ process.chdir(originalCwd)
73
+ restoreEnv("PATH", originalPath)
74
+ restoreEnv("AGENCY_CONFIG_PATH", originalAgencyConfigPath)
75
+ restoreEnv("AGENCY_TEST_GH_ARGS_FILE", originalGhArgsFile)
76
+ await cleanupTempDir(tempDir)
77
+ })
78
+
79
+ test("passes through to gh pr unchanged outside agency context", async () => {
80
+ await createBranch(tempDir, "feature")
81
+
82
+ await runTestEffect(pr({ args: ["status"], silent: false }))
83
+
84
+ expect(await readGhArgs(recordPath)).toEqual(["pr", "status"])
85
+ })
86
+
87
+ test("passes through outside agency context with custom emit pattern", async () => {
88
+ const configPath = process.env.AGENCY_CONFIG_PATH
89
+
90
+ if (!configPath) {
91
+ throw new Error("AGENCY_CONFIG_PATH is not set")
92
+ }
93
+
94
+ await Bun.write(
95
+ configPath,
96
+ JSON.stringify({
97
+ sourceBranchPattern: "agency--%branch%",
98
+ emitBranch: "%branch%--PR",
99
+ }),
100
+ )
101
+ await createBranch(tempDir, "feature")
102
+
103
+ await runTestEffect(pr({ args: ["status"], silent: false }))
104
+
105
+ expect(await readGhArgs(recordPath)).toEqual(["pr", "status"])
106
+ })
107
+
108
+ test("appends emitted branch in agency context", async () => {
109
+ await createBranch(tempDir, "agency--feature")
110
+
111
+ await runTestEffect(pr({ args: ["view", "--web"], silent: false }))
112
+
113
+ expect(await readGhArgs(recordPath)).toEqual([
114
+ "pr",
115
+ "view",
116
+ "--web",
117
+ "feature",
118
+ ])
119
+ })
120
+
121
+ test("appends emitted branch with gh pr value flags when no selector is provided", async () => {
122
+ await createBranch(tempDir, "agency--feature")
123
+
124
+ await runTestEffect(
125
+ pr({
126
+ args: ["view", "--json", "number", "--jq", ".number"],
127
+ silent: false,
128
+ }),
129
+ )
130
+
131
+ expect(await readGhArgs(recordPath)).toEqual([
132
+ "pr",
133
+ "view",
134
+ "--json",
135
+ "number",
136
+ "--jq",
137
+ ".number",
138
+ "feature",
139
+ ])
140
+ })
141
+
142
+ test("does not append emitted branch when an explicit selector is provided", async () => {
143
+ await createBranch(tempDir, "agency--feature")
144
+
145
+ await runTestEffect(
146
+ pr({
147
+ args: [
148
+ "view",
149
+ "123",
150
+ "--json",
151
+ "statusCheckRollup",
152
+ "--jq",
153
+ ".statusCheckRollup[]",
154
+ ],
155
+ silent: false,
156
+ }),
157
+ )
158
+
159
+ expect(await readGhArgs(recordPath)).toEqual([
160
+ "pr",
161
+ "view",
162
+ "123",
163
+ "--json",
164
+ "statusCheckRollup",
165
+ "--jq",
166
+ ".statusCheckRollup[]",
167
+ ])
168
+ })
169
+
170
+ test("does not append emitted branch for subcommands without a selector", async () => {
171
+ await createBranch(tempDir, "agency--feature")
172
+
173
+ await runTestEffect(pr({ args: ["status"], silent: false }))
174
+
175
+ expect(await readGhArgs(recordPath)).toEqual(["pr", "status"])
176
+ })
177
+ })
@@ -2,7 +2,7 @@ import { Effect } from "effect"
2
2
  import type { BaseCommandOptions } from "../utils/command"
3
3
  import { GitService } from "../services/GitService"
4
4
  import { ConfigService } from "../services/ConfigService"
5
- import { resolveBranchPairWithAgencyJson } from "../utils/pr-branch"
5
+ import { resolveAgencyBranchPairWithAgencyJson } from "../utils/pr-branch"
6
6
  import { ensureGitRepo } from "../utils/effect"
7
7
 
8
8
  interface PrOptions extends BaseCommandOptions {
@@ -10,6 +10,120 @@ interface PrOptions extends BaseCommandOptions {
10
10
  args: string[]
11
11
  }
12
12
 
13
+ const PR_SUBCOMMANDS_WITH_OPTIONAL_SELECTOR = new Set([
14
+ "checkout",
15
+ "checks",
16
+ "close",
17
+ "comment",
18
+ "diff",
19
+ "edit",
20
+ "lock",
21
+ "merge",
22
+ "ready",
23
+ "reopen",
24
+ "review",
25
+ "unlock",
26
+ "update-branch",
27
+ "view",
28
+ ])
29
+
30
+ const GH_PR_FLAGS_WITH_VALUE = new Set([
31
+ "--add-assignee",
32
+ "--add-label",
33
+ "--add-project",
34
+ "--add-reviewer",
35
+ "--app",
36
+ "--assignee",
37
+ "--author",
38
+ "--base",
39
+ "--body",
40
+ "--body-file",
41
+ "--head",
42
+ "--hostname",
43
+ "--json",
44
+ "--jq",
45
+ "--label",
46
+ "--limit",
47
+ "--match-head-commit",
48
+ "--milestone",
49
+ "--project",
50
+ "--remove-assignee",
51
+ "--remove-label",
52
+ "--remove-project",
53
+ "--remove-reviewer",
54
+ "--repo",
55
+ "--reviewer",
56
+ "--search",
57
+ "--state",
58
+ "--template",
59
+ "--title",
60
+ ])
61
+
62
+ const GH_PR_SHORT_FLAGS_WITH_VALUE = new Set([
63
+ "-A",
64
+ "-B",
65
+ "-H",
66
+ "-L",
67
+ "-R",
68
+ "-a",
69
+ "-b",
70
+ "-l",
71
+ "-m",
72
+ "-p",
73
+ "-q",
74
+ "-r",
75
+ "-s",
76
+ "-t",
77
+ ])
78
+
79
+ const hasExplicitPrSelector = (args: readonly string[]): boolean => {
80
+ const subcommandArgs = args.slice(1)
81
+
82
+ for (let i = 0; i < subcommandArgs.length; i++) {
83
+ const arg = subcommandArgs[i]
84
+
85
+ if (!arg) {
86
+ continue
87
+ }
88
+
89
+ if (arg === "--") {
90
+ return subcommandArgs.slice(i + 1).some(Boolean)
91
+ }
92
+
93
+ if (arg.startsWith("--")) {
94
+ const flagName = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg
95
+
96
+ if (!arg.includes("=") && GH_PR_FLAGS_WITH_VALUE.has(flagName)) {
97
+ i++
98
+ }
99
+
100
+ continue
101
+ }
102
+
103
+ if (arg.startsWith("-") && arg.length > 1) {
104
+ if (GH_PR_SHORT_FLAGS_WITH_VALUE.has(arg)) {
105
+ i++
106
+ }
107
+
108
+ continue
109
+ }
110
+
111
+ return true
112
+ }
113
+
114
+ return false
115
+ }
116
+
117
+ const shouldAppendEmitBranch = (args: readonly string[]): boolean => {
118
+ const subcommand = args[0]
119
+
120
+ return (
121
+ typeof subcommand === "string" &&
122
+ PR_SUBCOMMANDS_WITH_OPTIONAL_SELECTOR.has(subcommand) &&
123
+ !hasExplicitPrSelector(args)
124
+ )
125
+ }
126
+
13
127
  export const pr = (options: PrOptions) =>
14
128
  Effect.gen(function* () {
15
129
  const git = yield* GitService
@@ -20,23 +134,27 @@ export const pr = (options: PrOptions) =>
20
134
  // Load config
21
135
  const config = yield* configService.loadConfig()
22
136
 
23
- // Get current branch and resolve the branch pair
137
+ // Get current branch and resolve the branch pair when agency context exists
24
138
  const currentBranch = yield* git.getCurrentBranch(gitRoot)
25
- const branches = yield* resolveBranchPairWithAgencyJson(
139
+ const branches = yield* resolveAgencyBranchPairWithAgencyJson(
26
140
  gitRoot,
27
141
  currentBranch,
28
142
  config.sourceBranchPattern,
29
143
  config.emitBranch,
30
144
  )
31
145
 
32
- // Build the gh pr command with the emit branch
33
- const ghArgs = ["gh", "pr", ...options.args, branches.emitBranch]
146
+ // Build the gh pr command with the emit branch only when gh accepts a selector.
147
+ const ghArgs =
148
+ branches && shouldAppendEmitBranch(options.args)
149
+ ? ["gh", "pr", ...options.args, branches.emitBranch]
150
+ : ["gh", "pr", ...options.args]
34
151
 
35
152
  // Run gh pr with stdio inherited so output goes directly to terminal
36
153
  const exitCode = yield* Effect.tryPromise({
37
154
  try: async () => {
38
155
  const proc = Bun.spawn(ghArgs, {
39
156
  cwd: gitRoot,
157
+ env: process.env,
40
158
  stdin: "inherit",
41
159
  stdout: "inherit",
42
160
  stderr: "inherit",
@@ -59,19 +177,20 @@ export const pr = (options: PrOptions) =>
59
177
  export const help = `
60
178
  Usage: agency pr <subcommand> [flags]
61
179
 
62
- Wrapper for 'gh pr' that automatically appends the emitted branch name.
180
+ Wrapper for 'gh pr' that automatically uses the emitted branch in agency context.
63
181
 
64
- This command passes all arguments to 'gh pr' with the emitted branch name
65
- appended, making it easy to work with PRs for your feature branch without
66
- needing to remember or type the emit branch name.
182
+ For gh pr subcommands that accept a PR selector, this command appends the
183
+ emitted branch name when you do not provide a selector. Outside agency context,
184
+ or when you provide a selector, it passes arguments through to 'gh pr' unchanged.
67
185
 
68
186
  Examples:
69
187
  agency pr view --web # gh pr view --web <emit-branch>
70
188
  agency pr checks # gh pr checks <emit-branch>
71
- agency pr status # gh pr status <emit-branch>
189
+ agency pr diff # gh pr diff <emit-branch>
72
190
 
73
191
  Notes:
74
192
  - Requires gh CLI to be installed and authenticated
75
193
  - Uses source and emit patterns from ~/.config/agency/agency.json
76
194
  - Respects emitBranch field in agency.json when present
195
+ - Falls through to gh pr unchanged outside agency context
77
196
  `
@@ -115,6 +115,39 @@ describe("push command", () => {
115
115
  expect(remoteBranches).toContain("feature")
116
116
  })
117
117
 
118
+ test("forwards verbose output from emit step", async () => {
119
+ const originalLog = console.log
120
+ const logMessages: string[] = []
121
+ console.log = (msg: string) => {
122
+ logMessages.push(msg)
123
+ }
124
+
125
+ try {
126
+ await runTestEffect(
127
+ push({
128
+ baseBranch: "main",
129
+ verbose: true,
130
+ skipFilter: true,
131
+ }),
132
+ )
133
+ } finally {
134
+ console.log = originalLog
135
+ }
136
+
137
+ expect(logMessages).toContain("Step 1: Emitting...")
138
+ expect(
139
+ logMessages.some(
140
+ (msg) => msg.includes("Using base branch:") && msg.includes("main"),
141
+ ),
142
+ ).toBe(true)
143
+ expect(
144
+ logMessages.some((msg) =>
145
+ msg.includes("Skipping git-filter-repo (skipFilter=true)"),
146
+ ),
147
+ ).toBe(true)
148
+ expect(logMessages.some((msg) => msg.includes("Emitted"))).toBe(true)
149
+ })
150
+
118
151
  test("works with custom branch name", async () => {
119
152
  await runTestEffect(
120
153
  push({
@@ -122,41 +122,36 @@ const pushCore = (gitRoot: string, options: PushOptions) =>
122
122
  verboseLog(`Starting push workflow from ${highlight.branch(sourceBranch)}`)
123
123
 
124
124
  // Step 1: Create emit branch (agency emit)
125
- let emitHadIgnorableFailure = false
126
-
127
- if (!options.skipFilter) {
128
- verboseLog("Step 1: Emitting...")
129
- // Use emit command - prefer emit option, fallback to branch for backward compatibility
130
- const prEffectWithOptions = emit({
131
- baseBranch: options.baseBranch,
132
- emit: options.emit || options.branch,
133
- silent: true, // Suppress emit command output, we'll provide our own
134
- force: options.force,
135
- verbose: options.verbose,
136
- skipFilter: options.skipFilter,
137
- })
138
-
139
- const prResult = yield* Effect.either(prEffectWithOptions)
140
- if (Either.isLeft(prResult)) {
141
- const error =
142
- prResult.left instanceof Error
143
- ? prResult.left
144
- : new Error(String(prResult.left))
145
- const message = error.message || ""
146
- const ignorable =
147
- message === "" ||
148
- message.includes("already exists") ||
149
- message.includes("check if branch exists")
150
-
151
- if (!ignorable) {
152
- return yield* Effect.fail(
153
- new Error(`Failed to create emit branch: ${message}`),
154
- )
155
- }
156
-
157
- emitHadIgnorableFailure = true
158
- verboseLog("Emit reported existing branch; continuing")
125
+ verboseLog("Step 1: Emitting...")
126
+ // Use the emit command for the entire emit step so push and emit stay in sync.
127
+ const prEffectWithOptions = emit({
128
+ baseBranch: options.baseBranch,
129
+ emit: options.emit || options.branch,
130
+ silent: options.silent,
131
+ force: options.force,
132
+ verbose: options.verbose,
133
+ skipFilter: options.skipFilter,
134
+ })
135
+
136
+ const prResult = yield* Effect.either(prEffectWithOptions)
137
+ if (Either.isLeft(prResult)) {
138
+ const error =
139
+ prResult.left instanceof Error
140
+ ? prResult.left
141
+ : new Error(String(prResult.left))
142
+ const message = error.message || ""
143
+ const ignorable =
144
+ message === "" ||
145
+ message.includes("already exists") ||
146
+ message.includes("check if branch exists")
147
+
148
+ if (!ignorable) {
149
+ return yield* Effect.fail(
150
+ new Error(`Failed to create emit branch: ${message}`),
151
+ )
159
152
  }
153
+
154
+ verboseLog("Emit reported existing branch; continuing")
160
155
  }
161
156
 
162
157
  // Compute the emit branch name (emit() command now stays on source branch)
@@ -164,15 +159,6 @@ const pushCore = (gitRoot: string, options: PushOptions) =>
164
159
  const emitBranchName =
165
160
  options.emit || options.branch || branchInfo.emitBranch
166
161
 
167
- // If skipFilter, we skipped emit() so we must create the emit branch manually
168
- if (options.skipFilter) {
169
- yield* git.createOrResetBranch(gitRoot, emitBranchName, sourceBranch)
170
- // Switch back to source branch for consistency
171
- yield* git.checkoutBranch(gitRoot, sourceBranch)
172
- }
173
-
174
- log(done(`Emitted ${highlight.branch(emitBranchName)}`))
175
-
176
162
  // Step 2: Push to remote (git push)
177
163
  const remote = yield* getRemoteName(gitRoot)
178
164
  verboseLog(
@@ -439,3 +439,70 @@ export const resolveBranchPairWithAgencyJson = (
439
439
  // Strategy 4: Fall back to pattern-based resolution
440
440
  return resolveBranchPair(currentBranch, sourcePattern, emitPattern)
441
441
  })
442
+
443
+ /**
444
+ * Resolve branch pair only when the current branch is in agency context.
445
+ * Unlike resolveBranchPairWithAgencyJson, this intentionally does not treat
446
+ * arbitrary legacy branches as source branches.
447
+ */
448
+ export const resolveAgencyBranchPairWithAgencyJson = (
449
+ gitRoot: string,
450
+ currentBranch: string,
451
+ sourcePattern: string,
452
+ emitPattern: string,
453
+ ): Effect.Effect<BranchPair | null, never, GitService | FileSystemService> =>
454
+ Effect.gen(function* () {
455
+ const fromCurrentAgencyJson = yield* tryResolveFromCurrentAgencyJson(
456
+ gitRoot,
457
+ currentBranch,
458
+ )
459
+ if (fromCurrentAgencyJson) {
460
+ return fromCurrentAgencyJson
461
+ }
462
+
463
+ const fromOtherBranchAgencyJson =
464
+ yield* tryResolveFromOtherBranchAgencyJson(gitRoot, currentBranch)
465
+ if (fromOtherBranchAgencyJson) {
466
+ return fromOtherBranchAgencyJson
467
+ }
468
+
469
+ const fromPatternedSource = yield* tryResolveFromPatternedSourceBranch(
470
+ gitRoot,
471
+ currentBranch,
472
+ sourcePattern,
473
+ emitPattern,
474
+ )
475
+ if (fromPatternedSource) {
476
+ return fromPatternedSource
477
+ }
478
+
479
+ if (extractCleanBranch(currentBranch, sourcePattern)) {
480
+ return resolveBranchPair(currentBranch, sourcePattern, emitPattern)
481
+ }
482
+
483
+ if (emitPattern !== "%branch%") {
484
+ const cleanFromEmit = extractCleanFromEmit(currentBranch, emitPattern)
485
+
486
+ if (cleanFromEmit) {
487
+ const git = yield* GitService
488
+ const possibleSourceBranch = makeSourceBranchName(
489
+ cleanFromEmit,
490
+ sourcePattern,
491
+ )
492
+ const sourceExists = yield* pipe(
493
+ git.branchExists(gitRoot, possibleSourceBranch),
494
+ Effect.catchAll(() => Effect.succeed(false)),
495
+ )
496
+
497
+ if (sourceExists) {
498
+ return {
499
+ sourceBranch: possibleSourceBranch,
500
+ emitBranch: currentBranch,
501
+ isOnEmitBranch: true,
502
+ }
503
+ }
504
+ }
505
+ }
506
+
507
+ return null
508
+ })