respawn-session 0.0.5 → 0.0.9
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 +57 -7
- package/package.json +1 -1
- package/src/agents/claude.ts +78 -5
- package/src/agents/types.ts +1 -0
- package/src/cli.ts +72 -12
- package/src/commands/autosave.ts +100 -0
- package/src/commands/import.ts +28 -1
- package/src/commands/link.ts +109 -0
- package/src/commands/resume.ts +51 -12
- package/src/commands/save.ts +4 -2
- package/src/github.ts +60 -3
package/README.md
CHANGED
|
@@ -25,7 +25,9 @@ Initialize once on each machine. This creates `~/.respawn/index.json` and instal
|
|
|
25
25
|
respawn init
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
Work normally in Claude Code or Codex. `respawn init` makes sessions autosave when the agent stops.
|
|
28
|
+
Work normally in Claude Code or Codex. `respawn init` makes sessions autosave when the agent stops. If the branch has a GitHub PR, autosave also updates a hidden PR comment so `respawn <pr-number>` works after the worktree or branch is gone.
|
|
29
|
+
|
|
30
|
+
To save immediately from inside an active agent session, run:
|
|
29
31
|
|
|
30
32
|
```sh
|
|
31
33
|
respawn save
|
|
@@ -37,10 +39,11 @@ Resume the latest saved session for a branch:
|
|
|
37
39
|
respawn angela/fix-bugs
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
Resume from a PR that was
|
|
42
|
+
Resume from a PR that was autosaved or tagged:
|
|
41
43
|
|
|
42
44
|
```sh
|
|
43
45
|
respawn 123
|
|
46
|
+
respawn internetbackyard/gnomos-app#514
|
|
44
47
|
respawn https://github.com/org/repo/pull/123
|
|
45
48
|
```
|
|
46
49
|
|
|
@@ -80,25 +83,53 @@ After that, Claude Code and Codex Stop hooks run:
|
|
|
80
83
|
respawn autosave
|
|
81
84
|
```
|
|
82
85
|
|
|
83
|
-
Autosave hashes the transcript and skips unchanged sessions, so repeated Stop events do not create duplicate gists.
|
|
86
|
+
Autosave hashes the transcript and skips unchanged sessions, so repeated Stop events do not create duplicate gists. When the current branch has a GitHub PR, autosave also writes or updates the hidden respawn PR comment. That is the normal path for:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
respawn 517
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
after you delete the worktree.
|
|
84
93
|
|
|
85
94
|
### PR Tagging
|
|
86
95
|
|
|
87
|
-
Use this when you want
|
|
96
|
+
Use this when you want to force-save and attach the current session to the current PR manually:
|
|
88
97
|
|
|
89
98
|
```sh
|
|
90
99
|
respawn tag
|
|
91
100
|
```
|
|
92
101
|
|
|
93
|
-
That writes or updates
|
|
102
|
+
That writes or updates the same hidden metadata comment on the current GitHub PR. The comment stores session pointers, not the transcript body. Transcripts still live in your private gists.
|
|
94
103
|
|
|
95
104
|
Later, resume from the PR:
|
|
96
105
|
|
|
97
106
|
```sh
|
|
98
107
|
respawn 123
|
|
108
|
+
respawn internetbackyard/gnomos-app#514
|
|
99
109
|
respawn https://github.com/org/repo/pull/123
|
|
100
110
|
```
|
|
101
111
|
|
|
112
|
+
### Link Imported Sessions To PRs
|
|
113
|
+
|
|
114
|
+
This is for old sessions from before autosave tagged PRs automatically. After `respawn import`, sync saved sessions to matching PRs in a repo:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
respawn import internetbackyard/gnomos-app
|
|
118
|
+
respawn link internetbackyard/gnomos-app --dry-run
|
|
119
|
+
respawn link internetbackyard/gnomos-app
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Link matches sessions to PRs by branch name first, then by PR head SHA when available. It only writes PR metadata comments; it does not upload transcripts.
|
|
123
|
+
|
|
124
|
+
Always run the dry-run first. It prints the exact PRs it would touch:
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
Would link 1 PRs in internetbackyard/gnomos-app; 0 sessions unmatched
|
|
128
|
+
#514 feat/int-1194-tool-actor-context (1 session)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
If the PR you want is not listed, `respawn` does not have enough local evidence to link it automatically yet. The usual cause is an old transcript from a deleted worktree that has not been imported with `respawn import owner/repo`.
|
|
132
|
+
|
|
102
133
|
### List Saved Sessions
|
|
103
134
|
|
|
104
135
|
Show every saved session in your local index:
|
|
@@ -117,18 +148,33 @@ respawn import
|
|
|
117
148
|
|
|
118
149
|
Import scans Claude Code and Codex transcripts, groups them by their recorded cwd, and saves sessions whose cwd is still an available git worktree. It skips transcripts that are already in `~/.respawn/index.json` and skips deleted or non-git worktrees.
|
|
119
150
|
|
|
151
|
+
If the worktree was deleted, give `respawn` the repo explicitly:
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
respawn import internetbackyard/gnomos-app
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
For deleted worktrees, `respawn` can still import Claude Code project transcripts when the original cwd contains the repo name and the transcript has embedded branch metadata. Those imported rows use `sha: "unknown"`, but `respawn link` can still match them to PRs by branch.
|
|
158
|
+
|
|
120
159
|
## Commands
|
|
121
160
|
|
|
122
161
|
| Command | What it does |
|
|
123
162
|
| --- | --- |
|
|
124
163
|
| `respawn init` | Creates the local index and installs autosave hooks |
|
|
125
164
|
| `respawn save` | Saves the active Claude Code or Codex transcript |
|
|
126
|
-
| `respawn autosave` | Saves only if the transcript changed |
|
|
165
|
+
| `respawn autosave` | Saves only if the transcript changed and tags the current PR when one exists |
|
|
127
166
|
| `respawn tag` | Saves and attaches session metadata to the current PR |
|
|
128
167
|
| `respawn import` | Backfills existing local Claude Code and Codex sessions |
|
|
168
|
+
| `respawn import owner/repo` | Backfills deleted-worktree transcripts for a repo when branch metadata exists |
|
|
169
|
+
| `respawn link owner/repo` | Links imported sessions to matching PRs |
|
|
170
|
+
| `respawn link owner/repo --dry-run` | Previews PR links without writing comments |
|
|
129
171
|
| `respawn <branch>` | Restores the newest session for a branch |
|
|
172
|
+
| `respawn owner/repo:branch` | Restores a branch session without being in that repo |
|
|
173
|
+
| `respawn --repo owner/repo <branch>` | Restores a branch session for an explicit repo |
|
|
130
174
|
| `respawn <pr-number>` | Restores the newest session from a tagged PR |
|
|
175
|
+
| `respawn owner/repo#123` | Restores a tagged PR without being in that repo |
|
|
131
176
|
| `respawn <pr-url>` | Restores the newest session from a tagged PR URL |
|
|
177
|
+
| `respawn --repo owner/repo 123` | Restores a tagged PR for an explicit repo |
|
|
132
178
|
| `respawn list` | Lists locally indexed sessions |
|
|
133
179
|
| `respawn version` | Prints the installed CLI version |
|
|
134
180
|
| `respawn update` | Updates the global npm install to the latest release |
|
|
@@ -143,7 +189,7 @@ npm install -g respawn-session@latest
|
|
|
143
189
|
|
|
144
190
|
`respawn save` detects the active agent in this order:
|
|
145
191
|
|
|
146
|
-
1. Claude Code via `CLAUDE_SESSION_ID`, then `~/.claude/sessions/*.json`
|
|
192
|
+
1. Claude Code via `CLAUDE_SESSION_ID`, then `~/.claude/sessions/*.json` and `~/.claude/projects/**/*.jsonl`
|
|
147
193
|
2. Codex via `CODEX_TUI_SESSION_LOG_PATH`, `CODEX_SESSION_ID`, or the newest `~/.codex/sessions/**.jsonl` transcript for the current cwd
|
|
148
194
|
|
|
149
195
|
It then runs:
|
|
@@ -162,6 +208,10 @@ The local index lives at:
|
|
|
162
208
|
|
|
163
209
|
Branches can have multiple saved sessions. `respawn <branch>` restores the newest `savedAt` entry for the current repo and branch. `respawn list` shows every saved entry so older sessions remain discoverable.
|
|
164
210
|
|
|
211
|
+
`respawn autosave` is what makes the main workflow work: it saves the transcript and, when `gh pr view` can resolve the current branch's PR, writes the session pointer to that PR. Later `respawn <pr-number>` reads that pointer, restores the transcript, checks out the PR, and resumes the agent.
|
|
212
|
+
|
|
213
|
+
`respawn import owner/repo` exists for old deleted worktrees. It scans local transcripts whose recorded cwd contains that repo name and imports the ones with embedded branch metadata. This is best-effort recovery for sessions that were created before `respawn` was installed.
|
|
214
|
+
|
|
165
215
|
`respawn tag` writes a hidden metadata comment to the current PR. The comment stores session pointers, not the transcript body. Transcripts still live in your private gists. This lets `respawn <pr-url|number>` recover the newest tagged session after a branch is merged or deleted.
|
|
166
216
|
|
|
167
217
|
## Agent Paths
|
package/package.json
CHANGED
package/src/agents/claude.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
4
|
import type { ImportableTranscript, LocatedTranscript, LocateOptions } from "./types";
|
|
5
5
|
|
|
6
6
|
export function encodeClaudeProjectPath(cwd: string): string {
|
|
7
|
-
return cwd.replace(/[^A-Za-z0-
|
|
7
|
+
return cwd.replace(/[^A-Za-z0-9_-]/g, "-");
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function transcriptPath(
|
|
@@ -41,10 +41,11 @@ export function resumeCmd(sessionId: string): string[] {
|
|
|
41
41
|
|
|
42
42
|
export function listTranscripts(options: LocateOptions = {}): ImportableTranscript[] {
|
|
43
43
|
const home = options.home ?? homedir();
|
|
44
|
+
const projectTranscripts = listProjectTranscripts(home);
|
|
44
45
|
const sessionsDir = join(home, ".claude", "sessions");
|
|
45
|
-
if (!existsSync(sessionsDir)) return
|
|
46
|
+
if (!existsSync(sessionsDir)) return projectTranscripts;
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
const registryTranscripts = readdirSync(sessionsDir, { withFileTypes: true })
|
|
48
49
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
49
50
|
.map((entry) => readSessionRecord(join(sessionsDir, entry.name)))
|
|
50
51
|
.filter(
|
|
@@ -68,6 +69,11 @@ export function listTranscripts(options: LocateOptions = {}): ImportableTranscri
|
|
|
68
69
|
};
|
|
69
70
|
})
|
|
70
71
|
.filter((transcript): transcript is ImportableTranscript => transcript !== null);
|
|
72
|
+
|
|
73
|
+
return uniqueByPath([
|
|
74
|
+
...projectTranscripts,
|
|
75
|
+
...registryTranscripts,
|
|
76
|
+
]);
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
type ClaudeSessionRecord = {
|
|
@@ -76,6 +82,45 @@ type ClaudeSessionRecord = {
|
|
|
76
82
|
updatedAt?: number;
|
|
77
83
|
};
|
|
78
84
|
|
|
85
|
+
type ClaudeProjectMeta = {
|
|
86
|
+
sessionId?: string;
|
|
87
|
+
cwd?: string;
|
|
88
|
+
gitBranch?: string;
|
|
89
|
+
timestamp?: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function listProjectTranscripts(home: string): ImportableTranscript[] {
|
|
93
|
+
const projectsDir = join(home, ".claude", "projects");
|
|
94
|
+
if (!existsSync(projectsDir)) return [];
|
|
95
|
+
|
|
96
|
+
return readdirSync(projectsDir, { withFileTypes: true })
|
|
97
|
+
.filter((entry) => entry.isDirectory())
|
|
98
|
+
.flatMap((project) =>
|
|
99
|
+
readdirSync(join(projectsDir, project.name), { withFileTypes: true })
|
|
100
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
101
|
+
.map((entry) => join(projectsDir, project.name, entry.name)),
|
|
102
|
+
)
|
|
103
|
+
.map((path): ImportableTranscript | null => {
|
|
104
|
+
const meta = readProjectTranscriptMeta(path);
|
|
105
|
+
const sessionId = meta?.sessionId ?? basename(path, ".jsonl");
|
|
106
|
+
if (!sessionId || !meta?.cwd) return null;
|
|
107
|
+
return {
|
|
108
|
+
agent: "claude" as const,
|
|
109
|
+
path,
|
|
110
|
+
sessionId,
|
|
111
|
+
cwd: meta.cwd,
|
|
112
|
+
branch:
|
|
113
|
+
meta.gitBranch && meta.gitBranch !== "HEAD"
|
|
114
|
+
? meta.gitBranch
|
|
115
|
+
: undefined,
|
|
116
|
+
savedAt: meta.timestamp
|
|
117
|
+
? new Date(meta.timestamp).toISOString()
|
|
118
|
+
: statSync(path).mtime.toISOString(),
|
|
119
|
+
};
|
|
120
|
+
})
|
|
121
|
+
.filter((transcript): transcript is ImportableTranscript => transcript !== null);
|
|
122
|
+
}
|
|
123
|
+
|
|
79
124
|
function locateFromSessionRegistry(
|
|
80
125
|
options: LocateOptions = {},
|
|
81
126
|
): LocatedTranscript | null {
|
|
@@ -110,3 +155,31 @@ function readSessionRecord(path: string): ClaudeSessionRecord | null {
|
|
|
110
155
|
return null;
|
|
111
156
|
}
|
|
112
157
|
}
|
|
158
|
+
|
|
159
|
+
function readProjectTranscriptMeta(path: string): ClaudeProjectMeta | null {
|
|
160
|
+
try {
|
|
161
|
+
const lines = readFileSync(path, "utf8").split("\n").slice(0, 100);
|
|
162
|
+
const meta: ClaudeProjectMeta = {};
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
if (!line.trim()) continue;
|
|
165
|
+
const parsed = JSON.parse(line) as ClaudeProjectMeta;
|
|
166
|
+
meta.sessionId ??= parsed.sessionId;
|
|
167
|
+
meta.cwd ??= parsed.cwd;
|
|
168
|
+
meta.gitBranch ??= parsed.gitBranch;
|
|
169
|
+
meta.timestamp ??= parsed.timestamp;
|
|
170
|
+
if (meta.cwd && meta.sessionId && meta.gitBranch) return meta;
|
|
171
|
+
}
|
|
172
|
+
return meta.cwd ? meta : null;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function uniqueByPath(transcripts: ImportableTranscript[]): ImportableTranscript[] {
|
|
179
|
+
const seen = new Set<string>();
|
|
180
|
+
return transcripts.filter((transcript) => {
|
|
181
|
+
if (seen.has(transcript.path)) return false;
|
|
182
|
+
seen.add(transcript.path);
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
}
|
package/src/agents/types.ts
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { initRespawn } from "./commands/init";
|
|
3
|
+
import { autosaveSession } from "./commands/autosave";
|
|
3
4
|
import { importSessions } from "./commands/import";
|
|
5
|
+
import { linkRepo } from "./commands/link";
|
|
4
6
|
import { listSessions } from "./commands/list";
|
|
5
7
|
import { resumePrSession, resumeSession } from "./commands/resume";
|
|
6
8
|
import { saveSession } from "./commands/save";
|
|
@@ -13,36 +15,51 @@ export type Route =
|
|
|
13
15
|
| { name: "autosave" }
|
|
14
16
|
| { name: "list" }
|
|
15
17
|
| { name: "init" }
|
|
16
|
-
| { name: "import" }
|
|
18
|
+
| { name: "import"; repo?: string }
|
|
19
|
+
| { name: "link"; repo: string; dryRun: boolean }
|
|
17
20
|
| { name: "tag" }
|
|
18
21
|
| { name: "version" }
|
|
19
22
|
| { name: "update" }
|
|
20
|
-
| { name: "resume"; branch: string }
|
|
21
|
-
| { name: "resume-pr"; prRef: string };
|
|
23
|
+
| { name: "resume"; branch: string; repo?: string }
|
|
24
|
+
| { name: "resume-pr"; prRef: string; repo?: string };
|
|
22
25
|
|
|
23
26
|
export function route(args: string[]): Route {
|
|
24
|
-
const
|
|
27
|
+
const { repo, rest } = parseGlobalOptions(args);
|
|
28
|
+
const [command] = rest;
|
|
25
29
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
26
30
|
return { name: "help" };
|
|
27
31
|
}
|
|
28
32
|
if (command === "version" || command === "--version" || command === "-v") {
|
|
29
33
|
return { name: "version" };
|
|
30
34
|
}
|
|
35
|
+
if (command === "link") {
|
|
36
|
+
const linkArgs = rest.slice(1).filter((arg) => arg !== "--dry-run");
|
|
37
|
+
return {
|
|
38
|
+
name: "link",
|
|
39
|
+
repo: linkArgs[0] ?? "",
|
|
40
|
+
dryRun: rest.includes("--dry-run"),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
31
43
|
if (
|
|
32
44
|
command === "save" ||
|
|
33
45
|
command === "autosave" ||
|
|
34
46
|
command === "list" ||
|
|
35
47
|
command === "init" ||
|
|
36
|
-
command === "import" ||
|
|
37
48
|
command === "tag" ||
|
|
38
49
|
command === "update"
|
|
39
50
|
) {
|
|
40
51
|
return { name: command };
|
|
41
52
|
}
|
|
53
|
+
if (command === "import") {
|
|
54
|
+
return {
|
|
55
|
+
name: "import",
|
|
56
|
+
repo: repo ?? rest.slice(1).find((arg) => !arg.startsWith("-")),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
42
59
|
if (isPrRef(command)) {
|
|
43
|
-
return
|
|
60
|
+
return parsePrRoute(command, repo);
|
|
44
61
|
}
|
|
45
|
-
return
|
|
62
|
+
return parseBranchRoute(command, repo);
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
export async function main(args = Bun.argv.slice(2)): Promise<void> {
|
|
@@ -56,7 +73,7 @@ export async function main(args = Bun.argv.slice(2)): Promise<void> {
|
|
|
56
73
|
return;
|
|
57
74
|
}
|
|
58
75
|
if (selected.name === "autosave") {
|
|
59
|
-
console.log((await
|
|
76
|
+
console.log((await autosaveSession()).message);
|
|
60
77
|
return;
|
|
61
78
|
}
|
|
62
79
|
if (selected.name === "list") {
|
|
@@ -68,7 +85,14 @@ export async function main(args = Bun.argv.slice(2)): Promise<void> {
|
|
|
68
85
|
return;
|
|
69
86
|
}
|
|
70
87
|
if (selected.name === "import") {
|
|
71
|
-
console.log((await importSessions()).message);
|
|
88
|
+
console.log((await importSessions({ repo: selected.repo })).message);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (selected.name === "link") {
|
|
92
|
+
if (!selected.repo) throw new Error("Usage: respawn link owner/repo [--dry-run]");
|
|
93
|
+
console.log(
|
|
94
|
+
(await linkRepo(selected.repo, { dryRun: selected.dryRun })).message,
|
|
95
|
+
);
|
|
72
96
|
return;
|
|
73
97
|
}
|
|
74
98
|
if (selected.name === "tag") {
|
|
@@ -86,8 +110,8 @@ export async function main(args = Bun.argv.slice(2)): Promise<void> {
|
|
|
86
110
|
|
|
87
111
|
const result =
|
|
88
112
|
selected.name === "resume-pr"
|
|
89
|
-
? await resumePrSession(selected.prRef)
|
|
90
|
-
: await resumeSession(selected.branch);
|
|
113
|
+
? await resumePrSession(selected.prRef, { repo: selected.repo })
|
|
114
|
+
: await resumeSession(selected.branch, { repo: selected.repo });
|
|
91
115
|
const [cmd, ...cmdArgs] = result.command;
|
|
92
116
|
const proc = Bun.spawn([cmd, ...cmdArgs], {
|
|
93
117
|
stdin: "inherit",
|
|
@@ -105,16 +129,52 @@ function helpText(): string {
|
|
|
105
129
|
" respawn tag",
|
|
106
130
|
" respawn <branch>",
|
|
107
131
|
" respawn <pr-url|number>",
|
|
132
|
+
" respawn owner/repo:branch",
|
|
133
|
+
" respawn owner/repo#123",
|
|
134
|
+
" respawn --repo owner/repo <branch|number>",
|
|
108
135
|
" respawn list",
|
|
109
136
|
" respawn init",
|
|
110
137
|
" respawn import",
|
|
138
|
+
" respawn import owner/repo",
|
|
139
|
+
" respawn link owner/repo [--dry-run]",
|
|
111
140
|
" respawn version",
|
|
112
141
|
" respawn update",
|
|
113
142
|
].join("\n");
|
|
114
143
|
}
|
|
115
144
|
|
|
116
145
|
function isPrRef(value: string): boolean {
|
|
117
|
-
return
|
|
146
|
+
return (
|
|
147
|
+
/^\d+$/.test(value) ||
|
|
148
|
+
/^[^/\s]+\/[^#:\s]+#\d+$/.test(value) ||
|
|
149
|
+
/github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(value)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseGlobalOptions(args: string[]): { repo?: string; rest: string[] } {
|
|
154
|
+
if (args[0] === "--repo") {
|
|
155
|
+
return { repo: args[1], rest: args.slice(2) };
|
|
156
|
+
}
|
|
157
|
+
return { rest: args };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parsePrRoute(value: string, repo?: string): Route {
|
|
161
|
+
const url = value.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)$/);
|
|
162
|
+
if (url) return { name: "resume-pr", repo: url[1], prRef: url[2] };
|
|
163
|
+
|
|
164
|
+
const qualified = value.match(/^([^/\s]+\/[^#:\s]+)#(\d+)$/);
|
|
165
|
+
if (qualified) {
|
|
166
|
+
return { name: "resume-pr", repo: qualified[1], prRef: qualified[2] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { name: "resume-pr", prRef: value, repo };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseBranchRoute(value: string, repo?: string): Route {
|
|
173
|
+
const qualified = value.match(/^([^/\s]+\/[^#:\s]+):(.+)$/);
|
|
174
|
+
if (qualified) {
|
|
175
|
+
return { name: "resume", repo: qualified[1], branch: qualified[2] };
|
|
176
|
+
}
|
|
177
|
+
return { name: "resume", branch: value, repo };
|
|
118
178
|
}
|
|
119
179
|
|
|
120
180
|
if (import.meta.main) {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
currentPr,
|
|
3
|
+
getRespawnTag,
|
|
4
|
+
parseGitHubRepo,
|
|
5
|
+
upsertRespawnComment,
|
|
6
|
+
} from "../github";
|
|
7
|
+
import type { RespawnPrTag } from "../github";
|
|
8
|
+
import { saveSession, type SaveDeps, type SaveResult } from "./save";
|
|
9
|
+
|
|
10
|
+
export type AutosaveDeps = SaveDeps & {
|
|
11
|
+
saveSession?: typeof saveSession;
|
|
12
|
+
currentPr?: typeof currentPr;
|
|
13
|
+
getRespawnTag?: typeof getRespawnTag;
|
|
14
|
+
upsertRespawnComment?: typeof upsertRespawnComment;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type AutosaveResult = SaveResult & {
|
|
18
|
+
tag?: RespawnPrTag;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function autosaveSession(
|
|
22
|
+
deps: AutosaveDeps = {},
|
|
23
|
+
): Promise<AutosaveResult> {
|
|
24
|
+
const {
|
|
25
|
+
saveSession: save,
|
|
26
|
+
currentPr: current,
|
|
27
|
+
getRespawnTag: getTag,
|
|
28
|
+
upsertRespawnComment: upsert,
|
|
29
|
+
...saveDeps
|
|
30
|
+
} = deps;
|
|
31
|
+
|
|
32
|
+
const saved = await (save ?? saveSession)({
|
|
33
|
+
...saveDeps,
|
|
34
|
+
mode: "autosave",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const pr = await maybeCurrentPr(current ?? currentPr);
|
|
38
|
+
if (!pr) return saved;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const repo = parseGitHubRepo(saved.session.repo);
|
|
42
|
+
const repoName = `${repo.owner}/${repo.name}`;
|
|
43
|
+
const existing = await (getTag ?? getRespawnTag)(String(pr.number), repoName);
|
|
44
|
+
const sessions = appendSession(existing?.sessions ?? [], saved.session);
|
|
45
|
+
const tag: RespawnPrTag = {
|
|
46
|
+
version: 1,
|
|
47
|
+
repo: repoName,
|
|
48
|
+
pr: pr.number,
|
|
49
|
+
branch: pr.headRefName,
|
|
50
|
+
sessions,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
await (upsert ?? upsertRespawnComment)({
|
|
54
|
+
owner: repo.owner,
|
|
55
|
+
name: repo.name,
|
|
56
|
+
pr: pr.number,
|
|
57
|
+
tag,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
...saved,
|
|
62
|
+
message: `${saved.message}; tagged PR #${pr.number} with session ${saved.session.sessionId}`,
|
|
63
|
+
tag,
|
|
64
|
+
};
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return {
|
|
67
|
+
...saved,
|
|
68
|
+
message: `${saved.message}; PR tag failed: ${errorMessage(error)}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function maybeCurrentPr(
|
|
74
|
+
detect: typeof currentPr,
|
|
75
|
+
): Promise<Awaited<ReturnType<typeof currentPr>> | null> {
|
|
76
|
+
try {
|
|
77
|
+
return await detect();
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function appendSession(
|
|
84
|
+
sessions: RespawnPrTag["sessions"],
|
|
85
|
+
session: RespawnPrTag["sessions"][number],
|
|
86
|
+
): RespawnPrTag["sessions"] {
|
|
87
|
+
const key = sessionKey(session);
|
|
88
|
+
if (sessions.some((existing) => sessionKey(existing) === key)) {
|
|
89
|
+
return sessions;
|
|
90
|
+
}
|
|
91
|
+
return [...sessions, session];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sessionKey(session: RespawnPrTag["sessions"][number]): string {
|
|
95
|
+
return `${session.agent}:${session.sessionId}:${session.gistUrl}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function errorMessage(error: unknown): string {
|
|
99
|
+
return error instanceof Error ? error.message : String(error);
|
|
100
|
+
}
|
package/src/commands/import.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type SavedSession,
|
|
11
11
|
} from "../index-file";
|
|
12
12
|
import { createGist } from "../storage/gist";
|
|
13
|
+
import { parseGitHubRepo } from "../github";
|
|
13
14
|
|
|
14
15
|
export type ImportResult = {
|
|
15
16
|
imported: number;
|
|
@@ -20,6 +21,7 @@ export type ImportResult = {
|
|
|
20
21
|
|
|
21
22
|
export type ImportDeps = {
|
|
22
23
|
indexPath?: string;
|
|
24
|
+
repo?: string;
|
|
23
25
|
listTranscripts?: typeof listAllTranscripts;
|
|
24
26
|
gitInfoForCwd?: typeof gitInfoForCwd;
|
|
25
27
|
createGist?: typeof createGist;
|
|
@@ -38,7 +40,9 @@ export async function importSessions(
|
|
|
38
40
|
let skipped = 0;
|
|
39
41
|
|
|
40
42
|
for (const transcript of transcripts) {
|
|
41
|
-
const info =
|
|
43
|
+
const info =
|
|
44
|
+
(await gitInfo(transcript.cwd)) ??
|
|
45
|
+
fallbackGitInfo(transcript, deps.repo);
|
|
42
46
|
if (!info) {
|
|
43
47
|
skipped += 1;
|
|
44
48
|
continue;
|
|
@@ -77,6 +81,29 @@ export async function importSessions(
|
|
|
77
81
|
};
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
function fallbackGitInfo(
|
|
85
|
+
transcript: ImportableTranscript,
|
|
86
|
+
repo?: string,
|
|
87
|
+
): GitInfo | null {
|
|
88
|
+
if (!repo || !transcript.branch || !cwdLooksLikeRepo(transcript.cwd, repo)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
repo,
|
|
93
|
+
branch: transcript.branch,
|
|
94
|
+
sha: "unknown",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cwdLooksLikeRepo(cwd: string, repo: string): boolean {
|
|
99
|
+
try {
|
|
100
|
+
const repoName = parseGitHubRepo(repo).name;
|
|
101
|
+
return cwd.split(/[\\/]/).includes(repoName);
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
80
107
|
async function isDuplicate(
|
|
81
108
|
indexPath: string,
|
|
82
109
|
info: GitInfo,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
listPullRequests,
|
|
3
|
+
parseGitHubRepo,
|
|
4
|
+
repoKey,
|
|
5
|
+
upsertRespawnComment,
|
|
6
|
+
type PrInfo,
|
|
7
|
+
type RespawnPrTag,
|
|
8
|
+
} from "../github";
|
|
9
|
+
import {
|
|
10
|
+
defaultIndexPath,
|
|
11
|
+
readIndex,
|
|
12
|
+
type SavedSession,
|
|
13
|
+
} from "../index-file";
|
|
14
|
+
|
|
15
|
+
export type LinkResult = {
|
|
16
|
+
linked: number;
|
|
17
|
+
dryRun: boolean;
|
|
18
|
+
unmatchedSessions: number;
|
|
19
|
+
message: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type LinkDeps = {
|
|
23
|
+
indexPath?: string;
|
|
24
|
+
dryRun?: boolean;
|
|
25
|
+
listPullRequests?: typeof listPullRequests;
|
|
26
|
+
upsertRespawnComment?: typeof upsertRespawnComment;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export async function linkRepo(
|
|
30
|
+
repo: string,
|
|
31
|
+
deps: LinkDeps = {},
|
|
32
|
+
): Promise<LinkResult> {
|
|
33
|
+
const index = await readIndex(deps.indexPath ?? defaultIndexPath());
|
|
34
|
+
const key = repoKey(repo);
|
|
35
|
+
const sessions = index.sessions.filter((session) => safeRepoKey(session.repo) === key);
|
|
36
|
+
const prs = await (deps.listPullRequests ?? listPullRequests)(key);
|
|
37
|
+
const used = new Set<SavedSession>();
|
|
38
|
+
const details: string[] = [];
|
|
39
|
+
let linked = 0;
|
|
40
|
+
|
|
41
|
+
for (const pr of prs) {
|
|
42
|
+
const matches = uniqueSessions(
|
|
43
|
+
sessions.filter((session) => sessionMatchesPr(session, pr)),
|
|
44
|
+
);
|
|
45
|
+
if (matches.length === 0) continue;
|
|
46
|
+
|
|
47
|
+
for (const session of matches) used.add(session);
|
|
48
|
+
linked += 1;
|
|
49
|
+
details.push(
|
|
50
|
+
` #${pr.number} ${pr.headRefName} (${matches.length} ${plural(matches.length, "session")})`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (!deps.dryRun) {
|
|
54
|
+
const parsed = parseGitHubRepo(key);
|
|
55
|
+
await (deps.upsertRespawnComment ?? upsertRespawnComment)({
|
|
56
|
+
owner: parsed.owner,
|
|
57
|
+
name: parsed.name,
|
|
58
|
+
pr: pr.number,
|
|
59
|
+
tag: {
|
|
60
|
+
version: 1,
|
|
61
|
+
repo: key,
|
|
62
|
+
pr: pr.number,
|
|
63
|
+
branch: pr.headRefName,
|
|
64
|
+
sessions: matches.sort((a, b) => a.savedAt.localeCompare(b.savedAt)),
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const unmatchedSessions = sessions.filter((session) => !used.has(session)).length;
|
|
71
|
+
const prefix = deps.dryRun ? "Would link" : "Linked";
|
|
72
|
+
return {
|
|
73
|
+
linked,
|
|
74
|
+
dryRun: Boolean(deps.dryRun),
|
|
75
|
+
unmatchedSessions,
|
|
76
|
+
message: [
|
|
77
|
+
`${prefix} ${linked} PRs in ${key}; ${unmatchedSessions} ${plural(unmatchedSessions, "session")} unmatched`,
|
|
78
|
+
...details,
|
|
79
|
+
].join("\n"),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sessionMatchesPr(session: SavedSession, pr: PrInfo): boolean {
|
|
84
|
+
if (session.branch === pr.headRefName) return true;
|
|
85
|
+
if (pr.headRefOid && session.sha === pr.headRefOid) return true;
|
|
86
|
+
return Boolean(pr.commits?.some((commit) => commit.oid === session.sha));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function uniqueSessions(sessions: SavedSession[]): SavedSession[] {
|
|
90
|
+
const seen = new Set<string>();
|
|
91
|
+
return sessions.filter((session) => {
|
|
92
|
+
const key = `${session.agent}:${session.sessionId}:${session.gistUrl}`;
|
|
93
|
+
if (seen.has(key)) return false;
|
|
94
|
+
seen.add(key);
|
|
95
|
+
return true;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function safeRepoKey(repo: string): string | null {
|
|
100
|
+
try {
|
|
101
|
+
return repoKey(repo);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function plural(count: number, noun: string): string {
|
|
108
|
+
return `${noun}${count === 1 ? "" : "s"}`;
|
|
109
|
+
}
|
package/src/commands/resume.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { resumeCmd, targetTranscriptPath } from "../agents";
|
|
4
4
|
import { checkoutBranch, currentRepo } from "../git";
|
|
5
|
-
import { checkoutPr, getRespawnTag } from "../github";
|
|
5
|
+
import { checkoutPr, getRespawnTag, repoKey } from "../github";
|
|
6
6
|
import {
|
|
7
7
|
defaultIndexPath,
|
|
8
8
|
findLatestSession,
|
|
@@ -12,6 +12,7 @@ import { downloadGist } from "../storage/gist";
|
|
|
12
12
|
|
|
13
13
|
export type ResumeDeps = {
|
|
14
14
|
indexPath?: string;
|
|
15
|
+
repo?: string;
|
|
15
16
|
currentRepo?: typeof currentRepo;
|
|
16
17
|
downloadGist?: typeof downloadGist;
|
|
17
18
|
checkoutBranch?: typeof checkoutBranch;
|
|
@@ -19,6 +20,7 @@ export type ResumeDeps = {
|
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
export type ResumePrDeps = {
|
|
23
|
+
repo?: string;
|
|
22
24
|
currentRepo?: typeof currentRepo;
|
|
23
25
|
getRespawnTag?: typeof getRespawnTag;
|
|
24
26
|
downloadGist?: typeof downloadGist;
|
|
@@ -34,25 +36,28 @@ export async function resumeSession(
|
|
|
34
36
|
path: string;
|
|
35
37
|
session: SavedSession;
|
|
36
38
|
}> {
|
|
37
|
-
const repo = await (deps.currentRepo ?? currentRepo)();
|
|
39
|
+
const repo = deps.repo ?? (await (deps.currentRepo ?? currentRepo)());
|
|
38
40
|
const session = await findLatestSession(deps.indexPath ?? defaultIndexPath(), {
|
|
39
|
-
repo,
|
|
41
|
+
repo: repo,
|
|
40
42
|
branch,
|
|
41
43
|
});
|
|
42
|
-
|
|
44
|
+
const matchedSession =
|
|
45
|
+
session ??
|
|
46
|
+
(await findLatestByRepoKey(deps.indexPath ?? defaultIndexPath(), repo, branch));
|
|
47
|
+
if (!matchedSession) {
|
|
43
48
|
throw new Error(`No saved respawn session found for ${repo}@${branch}`);
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
const transcript = await (deps.downloadGist ?? downloadGist)(
|
|
47
|
-
const path = (deps.targetTranscriptPath ?? targetTranscriptPath)(
|
|
51
|
+
const transcript = await (deps.downloadGist ?? downloadGist)(matchedSession.gistUrl);
|
|
52
|
+
const path = (deps.targetTranscriptPath ?? targetTranscriptPath)(matchedSession);
|
|
48
53
|
await mkdir(dirname(path), { recursive: true });
|
|
49
54
|
await writeFile(path, transcript);
|
|
50
55
|
await (deps.checkoutBranch ?? checkoutBranch)(branch);
|
|
51
56
|
|
|
52
57
|
return {
|
|
53
|
-
command: resumeCmd(
|
|
58
|
+
command: resumeCmd(matchedSession.agent, matchedSession.sessionId),
|
|
54
59
|
path,
|
|
55
|
-
session,
|
|
60
|
+
session: matchedSession,
|
|
56
61
|
};
|
|
57
62
|
}
|
|
58
63
|
|
|
@@ -64,9 +69,9 @@ export async function resumePrSession(
|
|
|
64
69
|
path: string;
|
|
65
70
|
session: SavedSession;
|
|
66
71
|
}> {
|
|
67
|
-
const repo = await (deps.currentRepo ?? currentRepo)();
|
|
68
|
-
const tag = await (deps.getRespawnTag ?? getRespawnTag)(prRef);
|
|
69
|
-
if (!tag || tag.repo
|
|
72
|
+
const repo = deps.repo ?? (await (deps.currentRepo ?? currentRepo)());
|
|
73
|
+
const tag = await (deps.getRespawnTag ?? getRespawnTag)(prRef, deps.repo);
|
|
74
|
+
if (!tag || !reposMatch(tag.repo, repo)) {
|
|
70
75
|
throw new Error(`No respawn PR tag found for ${repo}#${prRef}`);
|
|
71
76
|
}
|
|
72
77
|
|
|
@@ -81,7 +86,7 @@ export async function resumePrSession(
|
|
|
81
86
|
const path = (deps.targetTranscriptPath ?? targetTranscriptPath)(session);
|
|
82
87
|
await mkdir(dirname(path), { recursive: true });
|
|
83
88
|
await writeFile(path, transcript);
|
|
84
|
-
await (deps.checkoutPr ?? checkoutPr)(prRef);
|
|
89
|
+
await (deps.checkoutPr ?? checkoutPr)(prRef, deps.repo);
|
|
85
90
|
|
|
86
91
|
return {
|
|
87
92
|
command: resumeCmd(session.agent, session.sessionId),
|
|
@@ -89,3 +94,37 @@ export async function resumePrSession(
|
|
|
89
94
|
session,
|
|
90
95
|
};
|
|
91
96
|
}
|
|
97
|
+
|
|
98
|
+
async function findLatestByRepoKey(
|
|
99
|
+
indexPath: string,
|
|
100
|
+
repo: string,
|
|
101
|
+
branch: string,
|
|
102
|
+
): Promise<SavedSession | null> {
|
|
103
|
+
const { readIndex } = await import("../index-file");
|
|
104
|
+
const key = repoKey(repo);
|
|
105
|
+
const index = await readIndex(indexPath);
|
|
106
|
+
return (
|
|
107
|
+
index.sessions
|
|
108
|
+
.filter(
|
|
109
|
+
(session) =>
|
|
110
|
+
session.branch === branch && safeRepoKey(session.repo) === key,
|
|
111
|
+
)
|
|
112
|
+
.sort((a, b) => a.savedAt.localeCompare(b.savedAt))
|
|
113
|
+
.at(-1) ?? null
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function safeRepoKey(repo: string): string | null {
|
|
118
|
+
try {
|
|
119
|
+
return repoKey(repo);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function reposMatch(a: string, b: string): boolean {
|
|
126
|
+
if (a === b) return true;
|
|
127
|
+
const aKey = safeRepoKey(a);
|
|
128
|
+
const bKey = safeRepoKey(b);
|
|
129
|
+
return Boolean(aKey && bKey && aKey === bKey);
|
|
130
|
+
}
|
package/src/commands/save.ts
CHANGED
|
@@ -21,11 +21,13 @@ export type SaveDeps = {
|
|
|
21
21
|
now?: () => Date;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
export
|
|
24
|
+
export type SaveResult = {
|
|
25
25
|
message: string;
|
|
26
26
|
saved: boolean;
|
|
27
27
|
session: SavedSession;
|
|
28
|
-
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export async function saveSession(deps: SaveDeps = {}): Promise<SaveResult> {
|
|
29
31
|
const locate = deps.locateActiveTranscript ?? locateActiveTranscript;
|
|
30
32
|
const transcript = locate();
|
|
31
33
|
if (!transcript) {
|
package/src/github.ts
CHANGED
|
@@ -13,6 +13,10 @@ export type PrInfo = {
|
|
|
13
13
|
number: number;
|
|
14
14
|
url: string;
|
|
15
15
|
headRefName: string;
|
|
16
|
+
headRefOid?: string;
|
|
17
|
+
state?: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
commits?: Array<{ oid: string }>;
|
|
16
20
|
};
|
|
17
21
|
|
|
18
22
|
export type RespawnPrTag = {
|
|
@@ -30,15 +34,32 @@ type GhComment = {
|
|
|
30
34
|
|
|
31
35
|
export function parseGitHubRepo(remote: string): GitHubRepo {
|
|
32
36
|
const trimmed = remote.trim();
|
|
37
|
+
const prUrl = trimmed.match(
|
|
38
|
+
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+$/,
|
|
39
|
+
);
|
|
40
|
+
if (prUrl) return { owner: prUrl[1], name: prUrl[2].replace(/\.git$/, "") };
|
|
41
|
+
|
|
33
42
|
const ssh = trimmed.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
34
43
|
if (ssh) return { owner: ssh[1], name: ssh[2] };
|
|
35
44
|
|
|
36
45
|
const https = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
37
46
|
if (https) return { owner: https[1], name: https[2] };
|
|
38
47
|
|
|
48
|
+
const short = trimmed.match(/^([^/\s]+)\/([^/\s]+)$/);
|
|
49
|
+
if (short) return { owner: short[1], name: short[2].replace(/\.git$/, "") };
|
|
50
|
+
|
|
39
51
|
throw new Error(`Unsupported GitHub remote: ${remote}`);
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
export function repoKey(remote: string): string {
|
|
55
|
+
const repo = parseGitHubRepo(remote);
|
|
56
|
+
return `${repo.owner}/${repo.name}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function prNumberFromRef(prRef: string): string {
|
|
60
|
+
return prRef.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/)?.[1] ?? prRef;
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
export function encodeRespawnComment(tag: RespawnPrTag): string {
|
|
43
64
|
return `${markerStart}\n${JSON.stringify(tag, null, 2)}\n${markerEnd}`;
|
|
44
65
|
}
|
|
@@ -69,11 +90,33 @@ export async function currentPr(run: RunCommand = runCommand): Promise<PrInfo> {
|
|
|
69
90
|
return parsed;
|
|
70
91
|
}
|
|
71
92
|
|
|
93
|
+
export async function listPullRequests(
|
|
94
|
+
repo: string,
|
|
95
|
+
run: RunCommand = runCommand,
|
|
96
|
+
): Promise<PrInfo[]> {
|
|
97
|
+
const raw = await run("gh", [
|
|
98
|
+
"pr",
|
|
99
|
+
"list",
|
|
100
|
+
"--repo",
|
|
101
|
+
repo,
|
|
102
|
+
"--state",
|
|
103
|
+
"all",
|
|
104
|
+
"--limit",
|
|
105
|
+
"1000",
|
|
106
|
+
"--json",
|
|
107
|
+
"number,url,headRefName,headRefOid,state,title",
|
|
108
|
+
]);
|
|
109
|
+
return JSON.parse(raw) as PrInfo[];
|
|
110
|
+
}
|
|
111
|
+
|
|
72
112
|
export async function getRespawnTag(
|
|
73
113
|
prRef: string,
|
|
114
|
+
repo?: string,
|
|
74
115
|
run: RunCommand = runCommand,
|
|
75
116
|
): Promise<RespawnPrTag | null> {
|
|
76
|
-
const
|
|
117
|
+
const args = ["pr", "view", prNumberFromRef(prRef), "--json", "comments"];
|
|
118
|
+
if (repo) args.push("--repo", repo);
|
|
119
|
+
const raw = await run("gh", args);
|
|
77
120
|
const parsed = JSON.parse(raw) as { comments?: GhComment[] };
|
|
78
121
|
return findRespawnComment(parsed.comments ?? [])?.tag ?? null;
|
|
79
122
|
}
|
|
@@ -87,10 +130,13 @@ export async function upsertRespawnComment(
|
|
|
87
130
|
},
|
|
88
131
|
run: RunCommand = runCommand,
|
|
89
132
|
): Promise<RespawnPrTag> {
|
|
133
|
+
const repo = `${input.owner}/${input.name}`;
|
|
90
134
|
const raw = await run("gh", [
|
|
91
135
|
"pr",
|
|
92
136
|
"view",
|
|
93
137
|
String(input.pr),
|
|
138
|
+
"--repo",
|
|
139
|
+
repo,
|
|
94
140
|
"--json",
|
|
95
141
|
"comments",
|
|
96
142
|
]);
|
|
@@ -108,7 +154,15 @@ export async function upsertRespawnComment(
|
|
|
108
154
|
`body=${body}`,
|
|
109
155
|
]);
|
|
110
156
|
} else {
|
|
111
|
-
await run("gh", [
|
|
157
|
+
await run("gh", [
|
|
158
|
+
"pr",
|
|
159
|
+
"comment",
|
|
160
|
+
String(input.pr),
|
|
161
|
+
"--repo",
|
|
162
|
+
repo,
|
|
163
|
+
"--body",
|
|
164
|
+
body,
|
|
165
|
+
]);
|
|
112
166
|
}
|
|
113
167
|
|
|
114
168
|
return input.tag;
|
|
@@ -116,9 +170,12 @@ export async function upsertRespawnComment(
|
|
|
116
170
|
|
|
117
171
|
export async function checkoutPr(
|
|
118
172
|
prRef: string,
|
|
173
|
+
repo?: string,
|
|
119
174
|
run: RunCommand = runCommand,
|
|
120
175
|
): Promise<void> {
|
|
121
|
-
|
|
176
|
+
const args = ["pr", "checkout", prNumberFromRef(prRef)];
|
|
177
|
+
if (repo) args.push("--repo", repo);
|
|
178
|
+
await run("gh", args);
|
|
122
179
|
}
|
|
123
180
|
|
|
124
181
|
function findRespawnComment(
|