respawn-session 0.0.1 → 0.0.2
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 +13 -33
- package/package.json +1 -1
- package/src/cli.ts +35 -4
- package/src/commands/init.ts +23 -16
- package/src/commands/resume.ts +43 -0
- package/src/commands/save.ts +44 -2
- package/src/commands/tag.ts +44 -0
- package/src/github.ts +133 -0
- package/src/index-file.ts +1 -0
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ gh auth status
|
|
|
19
19
|
|
|
20
20
|
## Usage
|
|
21
21
|
|
|
22
|
-
Initialize local storage and install
|
|
22
|
+
Initialize local storage and install autosave Stop hooks for Claude Code and Codex:
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
25
|
respawn init
|
|
@@ -31,59 +31,37 @@ Save the current active agent session from inside a git worktree:
|
|
|
31
31
|
respawn save
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
```sh
|
|
37
|
-
respawn angela/fix-bugs
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
List saved sessions:
|
|
41
|
-
|
|
42
|
-
```sh
|
|
43
|
-
respawn list
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
## Publishing
|
|
47
|
-
|
|
48
|
-
Before publishing, confirm the local runtime and GitHub CLI auth are ready:
|
|
34
|
+
Autosave is what the installed hooks run. It skips unchanged transcripts so repeated Stop events do not create duplicate gists:
|
|
49
35
|
|
|
50
36
|
```sh
|
|
51
|
-
|
|
52
|
-
gh auth status
|
|
37
|
+
respawn autosave
|
|
53
38
|
```
|
|
54
39
|
|
|
55
|
-
|
|
40
|
+
Resume the latest saved session for a branch:
|
|
56
41
|
|
|
57
42
|
```sh
|
|
58
|
-
|
|
59
|
-
npm publish --dry-run
|
|
43
|
+
respawn angela/fix-bugs
|
|
60
44
|
```
|
|
61
45
|
|
|
62
|
-
|
|
46
|
+
Attach the latest saved session to the current GitHub PR:
|
|
63
47
|
|
|
64
48
|
```sh
|
|
65
|
-
|
|
49
|
+
respawn tag
|
|
66
50
|
```
|
|
67
51
|
|
|
68
|
-
|
|
52
|
+
Resume from a tagged PR, even if the branch was deleted:
|
|
69
53
|
|
|
70
54
|
```sh
|
|
71
|
-
|
|
55
|
+
respawn 123
|
|
56
|
+
respawn https://github.com/org/repo/pull/123
|
|
72
57
|
```
|
|
73
58
|
|
|
74
|
-
|
|
59
|
+
List saved sessions:
|
|
75
60
|
|
|
76
61
|
```sh
|
|
77
|
-
npm install -g respawn-session
|
|
78
62
|
respawn list
|
|
79
63
|
```
|
|
80
64
|
|
|
81
|
-
One-off `npx` usage can download the package, but the bin still uses `#!/usr/bin/env bun`, so Bun must be installed:
|
|
82
|
-
|
|
83
|
-
```sh
|
|
84
|
-
npx respawn-session list
|
|
85
|
-
```
|
|
86
|
-
|
|
87
65
|
## How It Works
|
|
88
66
|
|
|
89
67
|
`respawn save` detects the active agent in this order:
|
|
@@ -107,6 +85,8 @@ The local index lives at:
|
|
|
107
85
|
|
|
108
86
|
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.
|
|
109
87
|
|
|
88
|
+
`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.
|
|
89
|
+
|
|
110
90
|
## Agent Paths
|
|
111
91
|
|
|
112
92
|
Claude Code transcripts are restored to:
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { initRespawn } from "./commands/init";
|
|
3
3
|
import { listSessions } from "./commands/list";
|
|
4
|
-
import { resumeSession } from "./commands/resume";
|
|
4
|
+
import { resumePrSession, resumeSession } from "./commands/resume";
|
|
5
5
|
import { saveSession } from "./commands/save";
|
|
6
|
+
import { tagCurrentPr } from "./commands/tag";
|
|
6
7
|
|
|
7
8
|
export type Route =
|
|
8
9
|
| { name: "help" }
|
|
9
10
|
| { name: "save" }
|
|
11
|
+
| { name: "autosave" }
|
|
10
12
|
| { name: "list" }
|
|
11
13
|
| { name: "init" }
|
|
12
|
-
| { name: "
|
|
14
|
+
| { name: "tag" }
|
|
15
|
+
| { name: "resume"; branch: string }
|
|
16
|
+
| { name: "resume-pr"; prRef: string };
|
|
13
17
|
|
|
14
18
|
export function route(args: string[]): Route {
|
|
15
19
|
const [command] = args;
|
|
16
20
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
17
21
|
return { name: "help" };
|
|
18
22
|
}
|
|
19
|
-
if (
|
|
23
|
+
if (
|
|
24
|
+
command === "save" ||
|
|
25
|
+
command === "autosave" ||
|
|
26
|
+
command === "list" ||
|
|
27
|
+
command === "init" ||
|
|
28
|
+
command === "tag"
|
|
29
|
+
) {
|
|
20
30
|
return { name: command };
|
|
21
31
|
}
|
|
32
|
+
if (isPrRef(command)) {
|
|
33
|
+
return { name: "resume-pr", prRef: command };
|
|
34
|
+
}
|
|
22
35
|
return { name: "resume", branch: command };
|
|
23
36
|
}
|
|
24
37
|
|
|
@@ -32,6 +45,10 @@ export async function main(args = Bun.argv.slice(2)): Promise<void> {
|
|
|
32
45
|
console.log((await saveSession()).message);
|
|
33
46
|
return;
|
|
34
47
|
}
|
|
48
|
+
if (selected.name === "autosave") {
|
|
49
|
+
console.log((await saveSession({ mode: "autosave" })).message);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
35
52
|
if (selected.name === "list") {
|
|
36
53
|
console.log((await listSessions()) || "No saved sessions");
|
|
37
54
|
return;
|
|
@@ -40,8 +57,15 @@ export async function main(args = Bun.argv.slice(2)): Promise<void> {
|
|
|
40
57
|
console.log(await initRespawn());
|
|
41
58
|
return;
|
|
42
59
|
}
|
|
60
|
+
if (selected.name === "tag") {
|
|
61
|
+
console.log((await tagCurrentPr()).message);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
43
64
|
|
|
44
|
-
const result =
|
|
65
|
+
const result =
|
|
66
|
+
selected.name === "resume-pr"
|
|
67
|
+
? await resumePrSession(selected.prRef)
|
|
68
|
+
: await resumeSession(selected.branch);
|
|
45
69
|
const [cmd, ...cmdArgs] = result.command;
|
|
46
70
|
const proc = Bun.spawn([cmd, ...cmdArgs], {
|
|
47
71
|
stdin: "inherit",
|
|
@@ -55,12 +79,19 @@ function helpText(): string {
|
|
|
55
79
|
return [
|
|
56
80
|
"Usage:",
|
|
57
81
|
" respawn save",
|
|
82
|
+
" respawn autosave",
|
|
83
|
+
" respawn tag",
|
|
58
84
|
" respawn <branch>",
|
|
85
|
+
" respawn <pr-url|number>",
|
|
59
86
|
" respawn list",
|
|
60
87
|
" respawn init",
|
|
61
88
|
].join("\n");
|
|
62
89
|
}
|
|
63
90
|
|
|
91
|
+
function isPrRef(value: string): boolean {
|
|
92
|
+
return /^\d+$/.test(value) || /github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
64
95
|
if (import.meta.main) {
|
|
65
96
|
main().catch((error) => {
|
|
66
97
|
console.error(error instanceof Error ? error.message : String(error));
|
package/src/commands/init.ts
CHANGED
|
@@ -22,10 +22,27 @@ export async function initRespawn(deps: InitDeps = {}): Promise<string> {
|
|
|
22
22
|
const indexPath = deps.indexPath ?? defaultIndexPath(home);
|
|
23
23
|
await writeIndex(indexPath, await readIndex(indexPath));
|
|
24
24
|
|
|
25
|
-
const
|
|
26
|
-
const
|
|
25
|
+
const command = "respawn autosave || true";
|
|
26
|
+
const claudePath = join(home, ".claude", "settings.json");
|
|
27
|
+
const codexPath = join(home, ".codex", "hooks.json");
|
|
28
|
+
await installStopHook(claudePath, command);
|
|
29
|
+
await installStopHook(codexPath, command);
|
|
30
|
+
|
|
31
|
+
return `Initialized respawn index at ${indexPath} and autosave Stop hooks at ${claudePath} and ${codexPath}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readSettings(path: string): Promise<ClaudeSettings> {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(await readFile(path, "utf8")) as ClaudeSettings;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function installStopHook(path: string, command: string): Promise<void> {
|
|
44
|
+
const settings = await readSettings(path);
|
|
27
45
|
const stopHooks = settings.hooks?.Stop ?? [];
|
|
28
|
-
const command = "respawn save || true";
|
|
29
46
|
const alreadyInstalled = stopHooks.some((group) =>
|
|
30
47
|
group.hooks?.some((hook) => hook.command === command),
|
|
31
48
|
);
|
|
@@ -35,19 +52,9 @@ export async function initRespawn(deps: InitDeps = {}): Promise<string> {
|
|
|
35
52
|
matcher: "",
|
|
36
53
|
hooks: [{ type: "command", command }],
|
|
37
54
|
});
|
|
38
|
-
settings.hooks = { ...settings.hooks, Stop: stopHooks };
|
|
39
|
-
await mkdir(dirname(settingsPath), { recursive: true });
|
|
40
|
-
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
41
55
|
}
|
|
42
56
|
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async function readSettings(path: string): Promise<ClaudeSettings> {
|
|
47
|
-
try {
|
|
48
|
-
return JSON.parse(await readFile(path, "utf8")) as ClaudeSettings;
|
|
49
|
-
} catch (error) {
|
|
50
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
51
|
-
throw error;
|
|
52
|
-
}
|
|
57
|
+
settings.hooks = { ...settings.hooks, Stop: stopHooks };
|
|
58
|
+
await mkdir(dirname(path), { recursive: true });
|
|
59
|
+
await writeFile(path, `${JSON.stringify(settings, null, 2)}\n`);
|
|
53
60
|
}
|
package/src/commands/resume.ts
CHANGED
|
@@ -2,6 +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
6
|
import {
|
|
6
7
|
defaultIndexPath,
|
|
7
8
|
findLatestSession,
|
|
@@ -17,6 +18,14 @@ export type ResumeDeps = {
|
|
|
17
18
|
targetTranscriptPath?: typeof targetTranscriptPath;
|
|
18
19
|
};
|
|
19
20
|
|
|
21
|
+
export type ResumePrDeps = {
|
|
22
|
+
currentRepo?: typeof currentRepo;
|
|
23
|
+
getRespawnTag?: typeof getRespawnTag;
|
|
24
|
+
downloadGist?: typeof downloadGist;
|
|
25
|
+
checkoutPr?: typeof checkoutPr;
|
|
26
|
+
targetTranscriptPath?: typeof targetTranscriptPath;
|
|
27
|
+
};
|
|
28
|
+
|
|
20
29
|
export async function resumeSession(
|
|
21
30
|
branch: string,
|
|
22
31
|
deps: ResumeDeps = {},
|
|
@@ -46,3 +55,37 @@ export async function resumeSession(
|
|
|
46
55
|
session,
|
|
47
56
|
};
|
|
48
57
|
}
|
|
58
|
+
|
|
59
|
+
export async function resumePrSession(
|
|
60
|
+
prRef: string,
|
|
61
|
+
deps: ResumePrDeps = {},
|
|
62
|
+
): Promise<{
|
|
63
|
+
command: string[];
|
|
64
|
+
path: string;
|
|
65
|
+
session: SavedSession;
|
|
66
|
+
}> {
|
|
67
|
+
const repo = await (deps.currentRepo ?? currentRepo)();
|
|
68
|
+
const tag = await (deps.getRespawnTag ?? getRespawnTag)(prRef);
|
|
69
|
+
if (!tag || tag.repo !== repo) {
|
|
70
|
+
throw new Error(`No respawn PR tag found for ${repo}#${prRef}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const session = [...tag.sessions]
|
|
74
|
+
.sort((a, b) => a.savedAt.localeCompare(b.savedAt))
|
|
75
|
+
.at(-1);
|
|
76
|
+
if (!session) {
|
|
77
|
+
throw new Error(`Respawn PR tag for ${repo}#${prRef} has no sessions`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const transcript = await (deps.downloadGist ?? downloadGist)(session.gistUrl);
|
|
81
|
+
const path = (deps.targetTranscriptPath ?? targetTranscriptPath)(session);
|
|
82
|
+
await mkdir(dirname(path), { recursive: true });
|
|
83
|
+
await writeFile(path, transcript);
|
|
84
|
+
await (deps.checkoutPr ?? checkoutPr)(prRef);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
command: resumeCmd(session.agent, session.sessionId),
|
|
88
|
+
path,
|
|
89
|
+
session,
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/commands/save.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
1
3
|
import { locateActiveTranscript } from "../agents";
|
|
2
4
|
import { currentBranch, currentRepo, currentSha } from "../git";
|
|
3
5
|
import {
|
|
4
6
|
defaultIndexPath,
|
|
7
|
+
readIndex,
|
|
5
8
|
recordSession,
|
|
6
9
|
type SavedSession,
|
|
7
10
|
} from "../index-file";
|
|
@@ -9,6 +12,7 @@ import { createGist } from "../storage/gist";
|
|
|
9
12
|
|
|
10
13
|
export type SaveDeps = {
|
|
11
14
|
indexPath?: string;
|
|
15
|
+
mode?: "save" | "autosave";
|
|
12
16
|
locateActiveTranscript?: typeof locateActiveTranscript;
|
|
13
17
|
currentRepo?: typeof currentRepo;
|
|
14
18
|
currentBranch?: typeof currentBranch;
|
|
@@ -19,6 +23,7 @@ export type SaveDeps = {
|
|
|
19
23
|
|
|
20
24
|
export async function saveSession(deps: SaveDeps = {}): Promise<{
|
|
21
25
|
message: string;
|
|
26
|
+
saved: boolean;
|
|
22
27
|
session: SavedSession;
|
|
23
28
|
}> {
|
|
24
29
|
const locate = deps.locateActiveTranscript ?? locateActiveTranscript;
|
|
@@ -32,6 +37,37 @@ export async function saveSession(deps: SaveDeps = {}): Promise<{
|
|
|
32
37
|
const repo = await (deps.currentRepo ?? currentRepo)();
|
|
33
38
|
const branch = await (deps.currentBranch ?? currentBranch)();
|
|
34
39
|
const sha = await (deps.currentSha ?? currentSha)();
|
|
40
|
+
const indexPath = deps.indexPath ?? defaultIndexPath();
|
|
41
|
+
const transcriptHash = await hashFile(transcript.path);
|
|
42
|
+
|
|
43
|
+
if (deps.mode === "autosave") {
|
|
44
|
+
const index = await readIndex(indexPath);
|
|
45
|
+
const unchanged = index.sessions.some(
|
|
46
|
+
(session) =>
|
|
47
|
+
session.repo === repo &&
|
|
48
|
+
session.branch === branch &&
|
|
49
|
+
session.agent === transcript.agent &&
|
|
50
|
+
session.sessionId === transcript.sessionId &&
|
|
51
|
+
session.transcriptHash === transcriptHash,
|
|
52
|
+
);
|
|
53
|
+
if (unchanged) {
|
|
54
|
+
return {
|
|
55
|
+
message: `No transcript changes to autosave for ${branch}`,
|
|
56
|
+
saved: false,
|
|
57
|
+
session: [...index.sessions]
|
|
58
|
+
.reverse()
|
|
59
|
+
.find(
|
|
60
|
+
(session) =>
|
|
61
|
+
session.repo === repo &&
|
|
62
|
+
session.branch === branch &&
|
|
63
|
+
session.agent === transcript.agent &&
|
|
64
|
+
session.sessionId === transcript.sessionId &&
|
|
65
|
+
session.transcriptHash === transcriptHash,
|
|
66
|
+
)!,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
35
71
|
const gistUrl = await (deps.createGist ?? createGist)(
|
|
36
72
|
transcript.path,
|
|
37
73
|
`respawn: ${repo}@${branch}`,
|
|
@@ -46,11 +82,17 @@ export async function saveSession(deps: SaveDeps = {}): Promise<{
|
|
|
46
82
|
agent: transcript.agent,
|
|
47
83
|
savedAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
48
84
|
relativePath: transcript.relativePath,
|
|
85
|
+
transcriptHash,
|
|
49
86
|
};
|
|
50
87
|
|
|
51
|
-
await recordSession(
|
|
88
|
+
await recordSession(indexPath, session);
|
|
52
89
|
return {
|
|
53
|
-
message:
|
|
90
|
+
message: `${deps.mode === "autosave" ? "Autosaved" : "Saved"} ${session.agent} session ${session.sessionId} for ${branch}`,
|
|
91
|
+
saved: true,
|
|
54
92
|
session,
|
|
55
93
|
};
|
|
56
94
|
}
|
|
95
|
+
|
|
96
|
+
async function hashFile(path: string): Promise<string> {
|
|
97
|
+
return createHash("sha256").update(await readFile(path)).digest("hex");
|
|
98
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
currentPr,
|
|
3
|
+
getRespawnTag,
|
|
4
|
+
parseGitHubRepo,
|
|
5
|
+
upsertRespawnComment,
|
|
6
|
+
} from "../github";
|
|
7
|
+
import type { RespawnPrTag } from "../github";
|
|
8
|
+
import { saveSession } from "./save";
|
|
9
|
+
|
|
10
|
+
export type TagDeps = {
|
|
11
|
+
saveSession?: typeof saveSession;
|
|
12
|
+
currentPr?: typeof currentPr;
|
|
13
|
+
getRespawnTag?: typeof getRespawnTag;
|
|
14
|
+
upsertRespawnComment?: typeof upsertRespawnComment;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export async function tagCurrentPr(deps: TagDeps = {}): Promise<{
|
|
18
|
+
message: string;
|
|
19
|
+
tag: RespawnPrTag;
|
|
20
|
+
}> {
|
|
21
|
+
const saved = await (deps.saveSession ?? saveSession)();
|
|
22
|
+
const pr = await (deps.currentPr ?? currentPr)();
|
|
23
|
+
const repo = parseGitHubRepo(saved.session.repo);
|
|
24
|
+
const existing = await (deps.getRespawnTag ?? getRespawnTag)(String(pr.number));
|
|
25
|
+
const tag: RespawnPrTag = {
|
|
26
|
+
version: 1,
|
|
27
|
+
repo: saved.session.repo,
|
|
28
|
+
pr: pr.number,
|
|
29
|
+
branch: saved.session.branch,
|
|
30
|
+
sessions: [...(existing?.sessions ?? []), saved.session],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
await (deps.upsertRespawnComment ?? upsertRespawnComment)({
|
|
34
|
+
owner: repo.owner,
|
|
35
|
+
name: repo.name,
|
|
36
|
+
pr: pr.number,
|
|
37
|
+
tag,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
message: `Tagged PR #${pr.number} with ${saved.session.agent} session ${saved.session.sessionId}`,
|
|
42
|
+
tag,
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { SavedSession } from "./index-file";
|
|
2
|
+
import { runCommand, type RunCommand } from "./shell";
|
|
3
|
+
|
|
4
|
+
const markerStart = "<!-- respawn-session";
|
|
5
|
+
const markerEnd = "-->";
|
|
6
|
+
|
|
7
|
+
export type GitHubRepo = {
|
|
8
|
+
owner: string;
|
|
9
|
+
name: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type PrInfo = {
|
|
13
|
+
number: number;
|
|
14
|
+
url: string;
|
|
15
|
+
headRefName: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type RespawnPrTag = {
|
|
19
|
+
version: 1;
|
|
20
|
+
repo: string;
|
|
21
|
+
pr: number;
|
|
22
|
+
branch: string;
|
|
23
|
+
sessions: SavedSession[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type GhComment = {
|
|
27
|
+
id?: string;
|
|
28
|
+
body?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function parseGitHubRepo(remote: string): GitHubRepo {
|
|
32
|
+
const trimmed = remote.trim();
|
|
33
|
+
const ssh = trimmed.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
34
|
+
if (ssh) return { owner: ssh[1], name: ssh[2] };
|
|
35
|
+
|
|
36
|
+
const https = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
37
|
+
if (https) return { owner: https[1], name: https[2] };
|
|
38
|
+
|
|
39
|
+
throw new Error(`Unsupported GitHub remote: ${remote}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function encodeRespawnComment(tag: RespawnPrTag): string {
|
|
43
|
+
return `${markerStart}\n${JSON.stringify(tag, null, 2)}\n${markerEnd}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function decodeRespawnComment(body: string): RespawnPrTag | null {
|
|
47
|
+
const start = body.indexOf(markerStart);
|
|
48
|
+
if (start === -1) return null;
|
|
49
|
+
const jsonStart = start + markerStart.length;
|
|
50
|
+
const end = body.indexOf(markerEnd, jsonStart);
|
|
51
|
+
if (end === -1) return null;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(body.slice(jsonStart, end).trim()) as RespawnPrTag;
|
|
55
|
+
return parsed.version === 1 ? parsed : null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function currentPr(run: RunCommand = runCommand): Promise<PrInfo> {
|
|
62
|
+
const raw = await run("gh", [
|
|
63
|
+
"pr",
|
|
64
|
+
"view",
|
|
65
|
+
"--json",
|
|
66
|
+
"number,url,headRefName",
|
|
67
|
+
]);
|
|
68
|
+
const parsed = JSON.parse(raw) as PrInfo;
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function getRespawnTag(
|
|
73
|
+
prRef: string,
|
|
74
|
+
run: RunCommand = runCommand,
|
|
75
|
+
): Promise<RespawnPrTag | null> {
|
|
76
|
+
const raw = await run("gh", ["pr", "view", prRef, "--json", "comments"]);
|
|
77
|
+
const parsed = JSON.parse(raw) as { comments?: GhComment[] };
|
|
78
|
+
return findRespawnComment(parsed.comments ?? [])?.tag ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function upsertRespawnComment(
|
|
82
|
+
input: {
|
|
83
|
+
owner: string;
|
|
84
|
+
name: string;
|
|
85
|
+
pr: number;
|
|
86
|
+
tag: RespawnPrTag;
|
|
87
|
+
},
|
|
88
|
+
run: RunCommand = runCommand,
|
|
89
|
+
): Promise<RespawnPrTag> {
|
|
90
|
+
const raw = await run("gh", [
|
|
91
|
+
"pr",
|
|
92
|
+
"view",
|
|
93
|
+
String(input.pr),
|
|
94
|
+
"--json",
|
|
95
|
+
"comments",
|
|
96
|
+
]);
|
|
97
|
+
const parsed = JSON.parse(raw) as { comments?: GhComment[] };
|
|
98
|
+
const existing = findRespawnComment(parsed.comments ?? []);
|
|
99
|
+
const body = encodeRespawnComment(input.tag);
|
|
100
|
+
|
|
101
|
+
if (existing?.id) {
|
|
102
|
+
await run("gh", [
|
|
103
|
+
"api",
|
|
104
|
+
`repos/${input.owner}/${input.name}/issues/comments/${existing.id}`,
|
|
105
|
+
"-X",
|
|
106
|
+
"PATCH",
|
|
107
|
+
"-f",
|
|
108
|
+
`body=${body}`,
|
|
109
|
+
]);
|
|
110
|
+
} else {
|
|
111
|
+
await run("gh", ["pr", "comment", String(input.pr), "--body", body]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return input.tag;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function checkoutPr(
|
|
118
|
+
prRef: string,
|
|
119
|
+
run: RunCommand = runCommand,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
await run("gh", ["pr", "checkout", prRef]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function findRespawnComment(
|
|
125
|
+
comments: GhComment[],
|
|
126
|
+
): { id?: string; tag: RespawnPrTag } | null {
|
|
127
|
+
for (const comment of comments) {
|
|
128
|
+
if (!comment.body) continue;
|
|
129
|
+
const tag = decodeRespawnComment(comment.body);
|
|
130
|
+
if (tag) return { id: comment.id, tag };
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|