From 032f057fa1b9b6506229cc39980a6281869f4ecd Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Mon, 7 Sep 2026 18:17:07 +0300 Subject: [PATCH] DX-2999: add git create-issue and attachments to the SDKs and CLI Follows upstash/box-backend#248, which added POST /v2/box/:id/git/create-issue and an `attach` field on create-issue and create-pr. JS SDK: `git.createIssue()`, plus `attach` on `createPR` and `createIssue`. `attach` takes image or video paths relative to the working directory, written as `shot.png#alt text` for an image. Both responses carry an optional `warning`, which the backend sets when gh exits non-zero but still returns a URL, meaning the item exists while an attachment is missing or the pull request was already open. CLI: `box git create-issue --title ` and a repeatable `--attach` on both create commands. Text output prints the warning on its own line, because it would otherwise read as a clean success. The REPL, the git help text, and shell completions list the new subcommand. Python SDK: mirrors the above in `_async`, with `_sync` regenerated. The parity check is a hard CI failure for a JS symbol with no Python counterpart, so `create_issue` could not be left out. --- .changeset/git-create-issue-attach.md | 20 ++++++ .../src/__tests__/repl/commands/git.test.ts | 32 +++++++++ packages/cli/src/cli.ts | 17 +++++ packages/cli/src/commands/completion.ts | 2 +- packages/cli/src/commands/git.ts | 28 +++++++- packages/cli/src/repl/client.ts | 2 +- packages/cli/src/repl/commands/git.ts | 16 ++++- packages/python-sdk/CHANGELOG.md | 11 +++ packages/python-sdk/PARITY.md | 2 +- .../python-sdk/tests/_async/test_box_git.py | 47 +++++++++++++ packages/python-sdk/upstash_box/__init__.py | 2 + .../python-sdk/upstash_box/_async/client.py | 33 ++++++++- .../python-sdk/upstash_box/_sync/client.py | 33 ++++++++- packages/python-sdk/upstash_box/types.py | 12 ++++ packages/sdk/src/__tests__/box-git.test.ts | 68 +++++++++++++++++++ packages/sdk/src/client.ts | 17 +++++ packages/sdk/src/index.ts | 2 + packages/sdk/src/types.ts | 37 ++++++++++ 18 files changed, 369 insertions(+), 12 deletions(-) create mode 100644 .changeset/git-create-issue-attach.md diff --git a/.changeset/git-create-issue-attach.md b/.changeset/git-create-issue-attach.md new file mode 100644 index 00000000..1db97755 --- /dev/null +++ b/.changeset/git-create-issue-attach.md @@ -0,0 +1,20 @@ +--- +"@upstash/box": patch +"@upstash/box-cli": patch +--- + +Add `git.createIssue()` and file attachments for issues and pull requests. + +`box.git.createIssue({ title })` opens a GitHub issue from a box. Both +`createIssue` and `createPR` accept `attach`, a list of image or video files +relative to the working directory, which are uploaded to the new item. Alt text +for an image is written as `shot.png#alt text`; a video cannot take alt text. +Reference an attachment from the body as `![alt](./shot.png)` and GitHub +rewrites it to point at the uploaded asset. + +Both responses gain an optional `warning`, set when `gh` exits non-zero but +still returns a URL, which means the item exists while an attachment is missing, +or the pull request was already open. + +The CLI gains `box git create-issue --title <title>` and a repeatable +`--attach <file>` on both `create-pr` and `create-issue`. diff --git a/packages/cli/src/__tests__/repl/commands/git.test.ts b/packages/cli/src/__tests__/repl/commands/git.test.ts index abb1e4fc..99b5caf9 100644 --- a/packages/cli/src/__tests__/repl/commands/git.test.ts +++ b/packages/cli/src/__tests__/repl/commands/git.test.ts @@ -12,6 +12,7 @@ describe("handleGit", () => { commit: vi.fn().mockResolvedValue({ sha: "abc123", message: "fix bug" }), push: vi.fn().mockResolvedValue(undefined), createPR: vi.fn().mockResolvedValue({ number: 42, url: "https://github.com/pr/42" }), + createIssue: vi.fn().mockResolvedValue({ number: 9, url: "https://github.com/issues/9" }), // The repository probe is answered the way git answers it: a working // tree prints "true", and a bare repository prints "false" with the // same exit code. @@ -153,6 +154,37 @@ describe("handleGit", () => { const events = await collectEvents(handleGit(createMockBox() as any, "create-pr")); expect(events).toContainEqual({ type: "log", message: "Usage: git create-pr <title>" }); }); + + it("prints a warning when gh reported one", async () => { + const box = createMockBox(); + box.git.createPR = vi.fn().mockResolvedValue({ + number: 42, + url: "https://github.com/pr/42", + warning: "failed to upload later.png", + }); + const events = await collectEvents(handleGit(box as any, "create-pr Fix")); + expect(events).toContainEqual({ + type: "log", + message: "Warning: failed to upload later.png", + }); + }); + }); + + describe("create-issue", () => { + it("creates an issue and prints details", async () => { + const box = createMockBox(); + const events = await collectEvents(handleGit(box as any, "create-issue Broken search")); + expect(box.git.createIssue).toHaveBeenCalledWith({ title: "Broken search" }); + expect(events).toContainEqual({ + type: "log", + message: "Issue #9: https://github.com/issues/9", + }); + }); + + it("prints usage without title", async () => { + const events = await collectEvents(handleGit(createMockBox() as any, "create-issue")); + expect(events).toContainEqual({ type: "log", message: "Usage: git create-issue <title>" }); + }); }); describe("exec", () => { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4b7f6973..685638e7 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -88,6 +88,7 @@ import { gitCommitCommand, gitCheckoutCommand, gitPushCommand, + gitCreateIssueCommand, gitCreatePrCommand, gitConfigCommand, gitExecCommand, @@ -348,6 +349,13 @@ withGitCommon( await runCommand(async () => gitCheckoutCommand(branch, { ...globals(flags), ...flags })); }); +// --attach repeats, so each occurrence appends instead of overwriting. +const attachHelp = + "Image or video to upload; repeat for several. Alt text for an image: 'shot.png#alt text'"; +function collectAttach(value: string, previous: string[]): string[] { + return [...previous, value]; +} + withGitCommon(git.command("push").description("Push the current branch")) .option("--branch <branch>", "Branch to push") .action(async (flags: Record<string, unknown>) => { @@ -358,10 +366,19 @@ withGitCommon(git.command("create-pr").description("Open a pull request")) .requiredOption("--title <title>", "Pull request title") .option("--body <body>", "Pull request body") .option("--base <branch>", "Base branch") + .option("--attach <file>", attachHelp, collectAttach, []) .action(async (flags: Record<string, unknown>) => { await runCommand(async () => gitCreatePrCommand({ ...globals(flags), ...flags })); }); +withGitCommon(git.command("create-issue").description("Open an issue")) + .requiredOption("--title <title>", "Issue title") + .option("--body <body>", "Issue body") + .option("--attach <file>", attachHelp, collectAttach, []) + .action(async (flags: Record<string, unknown>) => { + await runCommand(async () => gitCreateIssueCommand({ ...globals(flags), ...flags })); + }); + withGitCommon(git.command("config").description("Show or set the git identity")) .option("--name <name>", "git user.name") .option("--email <email>", "git user.email") diff --git a/packages/cli/src/commands/completion.ts b/packages/cli/src/commands/completion.ts index 47b9a22d..ab550f31 100644 --- a/packages/cli/src/commands/completion.ts +++ b/packages/cli/src/commands/completion.ts @@ -10,7 +10,7 @@ const COMMANDS: [name: string, description: string, subcommands: string][] = [ [ "git", "Git operations inside the box", - "clone status diff commit checkout push create-pr config exec", + "clone status diff commit checkout push create-pr create-issue config exec", ], ["public-url", "Public URLs for ports inside the box", "list delete"], ["run", "Run the box's agent on a prompt", ""], diff --git a/packages/cli/src/commands/git.ts b/packages/cli/src/commands/git.ts index 8872894d..237f387c 100644 --- a/packages/cli/src/commands/git.ts +++ b/packages/cli/src/commands/git.ts @@ -15,6 +15,7 @@ export type GitFlags = GlobalFlags & { title?: string; body?: string; base?: string; + attach?: string[]; name?: string; email?: string; }; @@ -215,8 +216,33 @@ export async function gitCreatePrCommand(flags: GitFlags): Promise<void> { title: flags.title, ...(flags.body === undefined ? {} : { body: flags.body }), ...(flags.base === undefined ? {} : { base: flags.base }), + ...(flags.attach?.length ? { attach: flags.attach } : {}), }); - emit(pr, pr.url ? `Pull request: ${pr.url}` : "Pull request created", flags); + emit(pr, prMessage("Pull request", pr), flags); +} + +/** Open an issue. */ +export async function gitCreateIssueCommand(flags: GitFlags): Promise<void> { + if (!flags.title) throw new CliError("Usage: box git create-issue --title <title>"); + const box = await open(flags); + const issue = await box.git.createIssue({ + title: flags.title, + ...(flags.body === undefined ? {} : { body: flags.body }), + ...(flags.attach?.length ? { attach: flags.attach } : {}), + }); + emit(issue, prMessage("Issue", issue), flags); +} + +/** + * Text line for a created pull request or issue. + * + * `warning` is set when gh exited non-zero but still returned a URL, so the + * item exists while an attachment may be missing. Text output would otherwise + * read as a clean success; JSON output carries the field either way. + */ +function prMessage(kind: string, item: { url?: string; warning?: string }): string { + const line = item.url ? `${kind}: ${item.url}` : `${kind} created`; + return item.warning ? `${line}\nWarning: ${item.warning}` : line; } /** diff --git a/packages/cli/src/repl/client.ts b/packages/cli/src/repl/client.ts index f296d360..c0ace68d 100644 --- a/packages/cli/src/repl/client.ts +++ b/packages/cli/src/repl/client.ts @@ -30,7 +30,7 @@ const COMMANDS: Record<BoxREPLCommandName, Omit<BoxREPLCommand, "name">> = { }, git: { description: - "Git operations (clone, diff, status, commit, push, create-pr, exec, checkout, config)", + "Git operations (clone, diff, status, commit, push, create-pr, create-issue, exec, checkout, config)", handler: handleGit, }, snapshot: { diff --git a/packages/cli/src/repl/commands/git.ts b/packages/cli/src/repl/commands/git.ts index f326b255..a3e089b8 100644 --- a/packages/cli/src/repl/commands/git.ts +++ b/packages/cli/src/repl/commands/git.ts @@ -20,7 +20,7 @@ async function emptyGitMessage(box: Box, clean: string): Promise<string> { } /** - * Handle git subcommands: clone, diff, status, commit, push, create-pr, exec, checkout. + * Handle git subcommands: clone, diff, status, commit, push, create-pr, create-issue, exec, checkout. */ export async function* handleGit(box: Box, args: string): AsyncGenerator<BoxREPLEvent> { const parts = args.split(/\s+/); @@ -72,6 +72,18 @@ export async function* handleGit(box: Box, args: string): AsyncGenerator<BoxREPL } const pr = await box.git.createPR({ title }); yield { type: "log", message: `PR #${pr.number}: ${pr.url}` }; + if (pr.warning) yield { type: "log", message: `Warning: ${pr.warning}` }; + break; + } + case "create-issue": { + const title = parts.slice(1).join(" "); + if (!title) { + yield { type: "log", message: "Usage: git create-issue <title>" }; + return; + } + const issue = await box.git.createIssue({ title }); + yield { type: "log", message: `Issue #${issue.number}: ${issue.url}` }; + if (issue.warning) yield { type: "log", message: `Warning: ${issue.warning}` }; break; } case "exec": { @@ -143,7 +155,7 @@ export async function* handleGit(box: Box, args: string): AsyncGenerator<BoxREPL yield { type: "log", message: - "Usage: git <clone|diff|status|commit|push|create-pr|exec|checkout|config> [args...]", + "Usage: git <clone|diff|status|commit|push|create-pr|create-issue|exec|checkout|config> [args...]", }; } } diff --git a/packages/python-sdk/CHANGELOG.md b/packages/python-sdk/CHANGELOG.md index e07b62fa..3740dfec 100644 --- a/packages/python-sdk/CHANGELOG.md +++ b/packages/python-sdk/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to `upstash-box` (Python) are documented here. +## Unreleased + +- Add `git.create_issue()`, which opens a GitHub issue from a box. +- Add `attach` to `git.create_pr()` and `git.create_issue()`. It takes image or + video files, relative to the working directory, and uploads them to the new + item. Alt text for an image is written as `shot.png#alt text`; a video cannot + take alt text. +- Add `warning` to `PullRequest` and the new `Issue` model. It is set when `gh` + exits non-zero but still returns a URL, which means the item exists while an + attachment is missing, or the pull request was already open. + ## 0.3.1 - Fix `git.update_config()` sending its request to `/v2/box/{id}/git-config`, diff --git a/packages/python-sdk/PARITY.md b/packages/python-sdk/PARITY.md index 614eaeef..add19391 100644 --- a/packages/python-sdk/PARITY.md +++ b/packages/python-sdk/PARITY.md @@ -28,7 +28,7 @@ JS `Run`/`StreamRun` → Python `Run`/`StreamRun` (+ `AsyncRun`/`AsyncStreamRun` | `files.read/write/list/upload/download` | `files.read/write/list/upload/download` | | `files.stat/mkdir/rename/remove` | `files.stat/mkdir/rename/remove` | | `exec.session` (live WebSocket session) | `exec.session` | -| `git.clone/diff/status/commit/updateConfig/push/createPR/exec/checkout` | `git.clone/diff/status/commit/update_config/push/create_pr/exec/checkout` | +| `git.clone/diff/status/commit/updateConfig/push/createPR/createIssue/exec/checkout` | `git.clone/diff/status/commit/update_config/push/create_pr/create_issue/exec/checkout` | | `schedule.exec/agent/list/get/update/pause/resume/delete` | same (snake) | | `skills.add/remove/list` | `skills.add/remove/list` | | `labels.add/remove/list` | `labels.add/remove/list` | diff --git a/packages/python-sdk/tests/_async/test_box_git.py b/packages/python-sdk/tests/_async/test_box_git.py index 1821843f..63092d92 100644 --- a/packages/python-sdk/tests/_async/test_box_git.py +++ b/packages/python-sdk/tests/_async/test_box_git.py @@ -112,3 +112,50 @@ async def test_push_and_create_pr_and_exec_and_checkout(): assert await box.git.exec(args=["log", "--oneline"]) == "log" await box.git.checkout(branch="feature") await box.aclose() + + +@respx.mock +async def test_create_pr_and_issue_attachments(): + box = await make_async_box(respx.mock) + pr_route = respx.post(f"{BASE}/git/create-pr").mock( + return_value=httpx.Response( + 200, + json={ + "url": "u", + "number": 5, + "title": "t", + "base": "main", + "warning": "failed to upload later.png", + }, + ) + ) + issue_route = respx.post(f"{BASE}/git/create-issue").mock( + return_value=httpx.Response(200, json={"url": "iu", "number": 9, "title": "bug"}) + ) + + pr = await box.git.create_pr(title="t", attach=["shot.png#the login error"]) + assert pr.warning == "failed to upload later.png" + assert last_json_body(pr_route)["attach"] == ["shot.png#the login error"] + + issue = await box.git.create_issue(title="bug", body="steps", attach=["repro.png"]) + assert issue.number == 9 + assert issue.warning is None + body = last_json_body(issue_route) + assert body["title"] == "bug" + assert body["body"] == "steps" + assert body["attach"] == ["repro.png"] + + await box.aclose() + + +@respx.mock +async def test_create_pr_omits_empty_attach(): + box = await make_async_box(respx.mock) + route = respx.post(f"{BASE}/git/create-pr").mock( + return_value=httpx.Response( + 200, json={"url": "u", "number": 5, "title": "t", "base": "main"} + ) + ) + await box.git.create_pr(title="t", attach=[]) + assert "attach" not in last_json_body(route) + await box.aclose() diff --git a/packages/python-sdk/upstash_box/__init__.py b/packages/python-sdk/upstash_box/__init__.py index 29e518e9..06e24bf0 100644 --- a/packages/python-sdk/upstash_box/__init__.py +++ b/packages/python-sdk/upstash_box/__init__.py @@ -88,6 +88,7 @@ GitCommitResult, GitConfigInput, GitConfigResult, + Issue, ListOptions, LogEntry, McpServerConfig, @@ -228,6 +229,7 @@ "GitConfigResult", "LogEntry", "PublicURL", + "Issue", "PullRequest", "RunCost", "RunLog", diff --git a/packages/python-sdk/upstash_box/_async/client.py b/packages/python-sdk/upstash_box/_async/client.py index 8319060c..9d640412 100644 --- a/packages/python-sdk/upstash_box/_async/client.py +++ b/packages/python-sdk/upstash_box/_async/client.py @@ -68,6 +68,7 @@ FinishUsage, GitCommitResult, GitConfigResult, + Issue, ListOptions, LogEntry, ModelConfig, @@ -429,9 +430,23 @@ async def push(self, *, branch: Optional[str] = None) -> None: await self._box._git_push(branch) async def create_pr( - self, *, title: str, body: Optional[str] = None, base: Optional[str] = None + self, + *, + title: str, + body: Optional[str] = None, + base: Optional[str] = None, + attach: Optional[List[str]] = None, ) -> PullRequest: - return await self._box._git_create_pr(title, body, base) + return await self._box._git_create_pr(title, body, base, attach) + + async def create_issue( + self, + *, + title: str, + body: Optional[str] = None, + attach: Optional[List[str]] = None, + ) -> Issue: + return await self._box._git_create_issue(title, body, attach) async def exec(self, *, args: List[str]) -> str: return await self._box._git_exec(args) @@ -1713,14 +1728,26 @@ async def _git_push(self, branch) -> None: body["folder"] = folder await self._request("POST", f"/v2/box/{self.id}/git/push", body=body) - async def _git_create_pr(self, title, body_text, base) -> PullRequest: + async def _git_create_pr(self, title, body_text, base, attach=None) -> PullRequest: folder = self._get_folder() body: Dict[str, Any] = {"title": title, "body": body_text, "base": base} + if attach: + body["attach"] = attach if folder: body["folder"] = folder data = await self._request("POST", f"/v2/box/{self.id}/git/create-pr", body=body) return PullRequest.model_validate(data) + async def _git_create_issue(self, title, body_text, attach=None) -> Issue: + folder = self._get_folder() + body: Dict[str, Any] = {"title": title, "body": body_text} + if attach: + body["attach"] = attach + if folder: + body["folder"] = folder + data = await self._request("POST", f"/v2/box/{self.id}/git/create-issue", body=body) + return Issue.model_validate(data) + async def _git_exec(self, args) -> str: folder = self._get_folder() body: Dict[str, Any] = {"args": args} diff --git a/packages/python-sdk/upstash_box/_sync/client.py b/packages/python-sdk/upstash_box/_sync/client.py index 9c49635e..5f9a5878 100644 --- a/packages/python-sdk/upstash_box/_sync/client.py +++ b/packages/python-sdk/upstash_box/_sync/client.py @@ -67,6 +67,7 @@ FinishUsage, GitCommitResult, GitConfigResult, + Issue, ListOptions, LogEntry, ModelConfig, @@ -424,9 +425,23 @@ def push(self, *, branch: Optional[str] = None) -> None: self._box._git_push(branch) def create_pr( - self, *, title: str, body: Optional[str] = None, base: Optional[str] = None + self, + *, + title: str, + body: Optional[str] = None, + base: Optional[str] = None, + attach: Optional[List[str]] = None, ) -> PullRequest: - return self._box._git_create_pr(title, body, base) + return self._box._git_create_pr(title, body, base, attach) + + def create_issue( + self, + *, + title: str, + body: Optional[str] = None, + attach: Optional[List[str]] = None, + ) -> Issue: + return self._box._git_create_issue(title, body, attach) def exec(self, *, args: List[str]) -> str: return self._box._git_exec(args) @@ -1698,14 +1713,26 @@ def _git_push(self, branch) -> None: body["folder"] = folder self._request("POST", f"/v2/box/{self.id}/git/push", body=body) - def _git_create_pr(self, title, body_text, base) -> PullRequest: + def _git_create_pr(self, title, body_text, base, attach=None) -> PullRequest: folder = self._get_folder() body: Dict[str, Any] = {"title": title, "body": body_text, "base": base} + if attach: + body["attach"] = attach if folder: body["folder"] = folder data = self._request("POST", f"/v2/box/{self.id}/git/create-pr", body=body) return PullRequest.model_validate(data) + def _git_create_issue(self, title, body_text, attach=None) -> Issue: + folder = self._get_folder() + body: Dict[str, Any] = {"title": title, "body": body_text} + if attach: + body["attach"] = attach + if folder: + body["folder"] = folder + data = self._request("POST", f"/v2/box/{self.id}/git/create-issue", body=body) + return Issue.model_validate(data) + def _git_exec(self, args) -> str: folder = self._get_folder() body: Dict[str, Any] = {"args": args} diff --git a/packages/python-sdk/upstash_box/types.py b/packages/python-sdk/upstash_box/types.py index 63073741..277afe27 100644 --- a/packages/python-sdk/upstash_box/types.py +++ b/packages/python-sdk/upstash_box/types.py @@ -556,6 +556,18 @@ class PullRequest(_Model): number: int title: str base: str + # Set when gh exited non-zero but still returned a URL: the pull request + # exists, but an attachment failed to upload or it was already open. + warning: Optional[str] = None + + +class Issue(_Model): + url: str + number: int + title: str + # Set when gh exited non-zero but still returned a URL, which means an + # attachment failed to upload. + warning: Optional[str] = None class Snapshot(_Model): diff --git a/packages/sdk/src/__tests__/box-git.test.ts b/packages/sdk/src/__tests__/box-git.test.ts index 2260e6b0..cf90ddd6 100644 --- a/packages/sdk/src/__tests__/box-git.test.ts +++ b/packages/sdk/src/__tests__/box-git.test.ts @@ -194,6 +194,74 @@ describe("Box git operations", () => { expect(body.title).toBe("Fix"); expect(body.body).toBe("desc"); expect(body.base).toBe("main"); + expect(body.attach).toBeUndefined(); + }); + + it("sends attachments and surfaces a warning", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce( + mockResponse({ + url: "https://github.com/owner/repo/pull/42", + number: 42, + title: "Fix", + base: "main", + warning: "failed to upload later.png", + }), + ); + + const pr = await box.git.createPR({ + title: "Fix", + attach: ["shot.png#the login error", "clip.mp4"], + }); + expect(pr.warning).toBe("failed to upload later.png"); + + const body = JSON.parse(fetchMock.mock.calls[1]![1]?.body as string); + expect(body.attach).toEqual(["shot.png#the login error", "clip.mp4"]); + }); + + it("omits an empty attach list", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce( + mockResponse({ url: "u", number: 1, title: "t", base: "main" }), + ); + + await box.git.createPR({ title: "Fix", attach: [] }); + const body = JSON.parse(fetchMock.mock.calls[1]![1]?.body as string); + expect(body.attach).toBeUndefined(); + }); + }); + + describe("git.createIssue", () => { + it("creates an issue", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce( + mockResponse({ + url: "https://github.com/owner/repo/issues/9", + number: 9, + title: "Bug", + }), + ); + + const issue = await box.git.createIssue({ title: "Bug", body: "steps" }); + expect(issue.number).toBe(9); + expect(issue.url).toContain("issues/9"); + expect(issue.warning).toBeUndefined(); + + const call = fetchMock.mock.calls[1]!; + expect(call[0]).toContain("/git/create-issue"); + const body = JSON.parse(call[1]?.body as string); + expect(body.title).toBe("Bug"); + expect(body.body).toBe("steps"); + expect(body.base).toBeUndefined(); + }); + + it("sends attachments", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ url: "u", number: 9, title: "Bug" })); + + await box.git.createIssue({ title: "Bug", attach: ["repro.png#the repro"] }); + const body = JSON.parse(fetchMock.mock.calls[1]![1]?.body as string); + expect(body.attach).toEqual(["repro.png#the repro"]); }); }); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 0af4109a..22d08b39 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -28,11 +28,13 @@ import { type GitExecOptions, type GitExecResult, type GitCheckoutOptions, + type GitIssueOptions, type GitPROptions, type GitCommitOptions, type GitConfigUpdateOptions, type GitConfig, type GitCommitResult, + type Issue, type PullRequest, type LogEntry, type UploadFileEntry, @@ -694,6 +696,7 @@ export class Box<TProvider = unknown> { updateConfig: (options: GitConfigUpdateOptions) => Promise<GitConfig>; push: (options?: { branch?: string }) => Promise<void>; createPR: (options: GitPROptions) => Promise<PullRequest>; + createIssue: (options: GitIssueOptions) => Promise<Issue>; exec: (options: GitExecOptions) => Promise<GitExecResult>; checkout: (options: GitCheckoutOptions) => Promise<void>; }; @@ -888,6 +891,7 @@ export class Box<TProvider = unknown> { updateConfig: (options) => this._gitUpdateConfig(options), push: (options) => this._gitPush(options), createPR: (options) => this._gitCreatePR(options), + createIssue: (options) => this._gitCreateIssue(options), exec: (options) => this._gitExec(options), checkout: (options) => this._gitCheckout(options), }; @@ -3042,6 +3046,19 @@ export class Box<TProvider = unknown> { title: options.title, body: options.body, base: options.base, + ...(options.attach?.length ? { attach: options.attach } : {}), + ...(folder ? { folder } : {}), + }, + }); + } + + private async _gitCreateIssue(options: GitIssueOptions): Promise<Issue> { + const folder = this._getFolder(); + return this._request<Issue>("POST", `/v2/box/${this.id}/git/create-issue`, { + body: { + title: options.title, + body: options.body, + ...(options.attach?.length ? { attach: options.attach } : {}), ...(folder ? { folder } : {}), }, }); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 48369f6d..b1089cbe 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -42,6 +42,7 @@ export type { RunStatus, RunCost, RunLog, + Issue, PullRequest, WebhookConfig, WebhookPayload, @@ -56,6 +57,7 @@ export type { GitExecOptions, GitExecResult, GitCheckoutOptions, + GitIssueOptions, GitPROptions, GitCommitOptions, GitConfigUpdateOptions, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 798d214a..7ee14c11 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -744,6 +744,26 @@ export interface PullRequest { number: number; title: string; base: string; + /** + * Set when `gh` exited non-zero but still returned a URL. The pull request + * exists, but an attachment failed to upload or the pull request was already + * open, so it was not created by this call. Absent on a clean create. + */ + warning?: string; +} + +/** + * A GitHub issue created from a box. + */ +export interface Issue { + url: string; + number: number; + title: string; + /** + * Set when `gh` exited non-zero but still returned a URL, which means an + * attachment failed to upload. Absent on a clean create. + */ + warning?: string; } /** @@ -1032,6 +1052,23 @@ export interface GitPROptions { title: string; body?: string; base?: string; + /** + * Image or video files to upload, relative to the box's working directory. + * Add alt text for an image with `path#alt text`; a video cannot take alt + * text. Reference an attachment from `body` as `![alt](./shot.png)` and + * GitHub rewrites it to point at the uploaded asset. + */ + attach?: string[]; +} + +/** + * Options for opening a GitHub issue. + */ +export interface GitIssueOptions { + title: string; + body?: string; + /** Image or video files to upload. See {@link GitPROptions.attach}. */ + attach?: string[]; } export interface GitCommitOptions {