Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/git-create-issue-attach.md
Original file line number Diff line number Diff line change
@@ -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`.
32 changes: 32 additions & 0 deletions packages/cli/src/__tests__/repl/commands/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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", () => {
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
gitCommitCommand,
gitCheckoutCommand,
gitPushCommand,
gitCreateIssueCommand,
gitCreatePrCommand,
gitConfigCommand,
gitExecCommand,
Expand Down Expand Up @@ -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>) => {
Expand All @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", ""],
Expand Down
28 changes: 27 additions & 1 deletion packages/cli/src/commands/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type GitFlags = GlobalFlags & {
title?: string;
body?: string;
base?: string;
attach?: string[];
name?: string;
email?: string;
};
Expand Down Expand Up @@ -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;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/repl/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
16 changes: 14 additions & 2 deletions packages/cli/src/repl/commands/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+/);
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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...]",
};
}
}
11 changes: 11 additions & 0 deletions packages/python-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion packages/python-sdk/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
47 changes: 47 additions & 0 deletions packages/python-sdk/tests/_async/test_box_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 2 additions & 0 deletions packages/python-sdk/upstash_box/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
GitCommitResult,
GitConfigInput,
GitConfigResult,
Issue,
ListOptions,
LogEntry,
McpServerConfig,
Expand Down Expand Up @@ -228,6 +229,7 @@
"GitConfigResult",
"LogEntry",
"PublicURL",
"Issue",
"PullRequest",
"RunCost",
"RunLog",
Expand Down
33 changes: 30 additions & 3 deletions packages/python-sdk/upstash_box/_async/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
FinishUsage,
GitCommitResult,
GitConfigResult,
Issue,
ListOptions,
LogEntry,
ModelConfig,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading