diff --git a/.opencode/bun.lock b/.opencode/bun.lock index e2b8a4eebd..40e7c71afb 100644 --- a/.opencode/bun.lock +++ b/.opencode/bun.lock @@ -5,7 +5,7 @@ "": { "dependencies": { "@octokit/rest": "^22.0.1", - "@opencode-ai/plugin": "0.0.0-dev-202512161535", + "@opencode-ai/plugin": "0.0.0-dev-202512161610", }, }, }, @@ -34,9 +34,9 @@ "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@0.0.0-dev-202512161535", "", { "dependencies": { "@opencode-ai/sdk": "0.0.0-dev-202512161535", "zod": "4.1.8" } }, "sha512-aHPm0T9EtKUYs5mTZKpYwcDTk3jP/YMSZGPfCAwruKitnktRFu0TxJQdEJwDfUokI4f1nkoGDkBgsbHw+pBHsA=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@0.0.0-dev-202512161610", "", { "dependencies": { "@opencode-ai/sdk": "0.0.0-dev-202512161610", "zod": "4.1.8" } }, "sha512-5TDOK75WgWeS/Lul+6OkDT0ESYAFhemCD67OjFcNCONpVgicqoiAgDunmQ2TpsZ+bl0S5kxw4wFGKkFjzBIZ2g=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@0.0.0-dev-202512161535", "", {}, "sha512-koVbuyuhNnEWMJtkIxSTcg8HQ34c4ShvBHv4dwebvVB2+ftjN/wcqPDx4RAwaxyFaY050qf1qobHHMXWWzDRwQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@0.0.0-dev-202512161610", "", {}, "sha512-bnAwQ4DNdHqSoqMJfnZbH16qp0WnFSJpYWTmOdr/9hRu5SDjdmPx/QUlZGBg0yovuHJXqd1Fb/FLgljZ9QqGRA=="], "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], diff --git a/.opencode/package.json b/.opencode/package.json index 685332a7e0..78c21b45fe 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -1,6 +1,6 @@ { "dependencies": { "@octokit/rest": "^22.0.1", - "@opencode-ai/plugin": "0.0.0-dev-202512161535" + "@opencode-ai/plugin": "0.0.0-dev-202512161610" } } diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 480a38230b..c7d4033958 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -128,6 +128,19 @@ const AGENT_USERNAME = "opencode-agent[bot]" const AGENT_REACTION = "eyes" const WORKFLOW_FILE = ".github/workflows/opencode.yml" +// Parses GitHub remote URLs in various formats: +// - https://github.com/owner/repo.git +// - https://github.com/owner/repo +// - git@github.com:owner/repo.git +// - git@github.com:owner/repo +// - ssh://git@github.com/owner/repo.git +// - ssh://git@github.com/owner/repo +export function parseGitHubRemote(url: string): { owner: string; repo: string } | null { + const match = url.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) + if (!match) return null + return { owner: match[1], repo: match[2] } +} + export const GithubCommand = cmd({ command: "github", describe: "manage GitHub agent", @@ -197,20 +210,12 @@ export const GithubInstallCommand = cmd({ // Get repo info const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim() - // match https or git pattern - // ie. https://github.com/sst/opencode.git - // ie. https://github.com/sst/opencode - // ie. git@github.com:sst/opencode.git - // ie. git@github.com:sst/opencode - // ie. ssh://git@github.com/sst/opencode.git - // ie. ssh://git@github.com/sst/opencode - const parsed = info.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/) + const parsed = parseGitHubRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) throw new UI.CancelledError() } - const [, owner, repo] = parsed - return { owner, repo, root: Instance.worktree } + return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree } } async function promptProvider() { diff --git a/packages/opencode/test/cli/github-remote.test.ts b/packages/opencode/test/cli/github-remote.test.ts new file mode 100644 index 0000000000..80102d986e --- /dev/null +++ b/packages/opencode/test/cli/github-remote.test.ts @@ -0,0 +1,80 @@ +import { test, expect } from "bun:test" +import { parseGitHubRemote } from "../../src/cli/cmd/github" + +test("parses https URL with .git suffix", () => { + expect(parseGitHubRemote("https://github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" }) +}) + +test("parses https URL without .git suffix", () => { + expect(parseGitHubRemote("https://github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" }) +}) + +test("parses git@ URL with .git suffix", () => { + expect(parseGitHubRemote("git@github.com:sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" }) +}) + +test("parses git@ URL without .git suffix", () => { + expect(parseGitHubRemote("git@github.com:sst/opencode")).toEqual({ owner: "sst", repo: "opencode" }) +}) + +test("parses ssh:// URL with .git suffix", () => { + expect(parseGitHubRemote("ssh://git@github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" }) +}) + +test("parses ssh:// URL without .git suffix", () => { + expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" }) +}) + +test("parses http URL", () => { + expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" }) +}) + +test("parses URL with hyphenated owner and repo names", () => { + expect(parseGitHubRemote("https://github.com/my-org/my-repo.git")).toEqual({ owner: "my-org", repo: "my-repo" }) +}) + +test("parses URL with underscores in names", () => { + expect(parseGitHubRemote("git@github.com:my_org/my_repo.git")).toEqual({ owner: "my_org", repo: "my_repo" }) +}) + +test("parses URL with numbers in names", () => { + expect(parseGitHubRemote("https://github.com/org123/repo456")).toEqual({ owner: "org123", repo: "repo456" }) +}) + +test("parses repos with dots in the name", () => { + expect(parseGitHubRemote("https://github.com/socketio/socket.io.git")).toEqual({ + owner: "socketio", + repo: "socket.io", + }) + expect(parseGitHubRemote("https://github.com/vuejs/vue.js")).toEqual({ + owner: "vuejs", + repo: "vue.js", + }) + expect(parseGitHubRemote("git@github.com:mrdoob/three.js.git")).toEqual({ + owner: "mrdoob", + repo: "three.js", + }) + expect(parseGitHubRemote("https://github.com/jashkenas/backbone.git")).toEqual({ + owner: "jashkenas", + repo: "backbone", + }) +}) + +test("returns null for non-github URLs", () => { + expect(parseGitHubRemote("https://gitlab.com/owner/repo.git")).toBeNull() + expect(parseGitHubRemote("git@gitlab.com:owner/repo.git")).toBeNull() + expect(parseGitHubRemote("https://bitbucket.org/owner/repo")).toBeNull() +}) + +test("returns null for invalid URLs", () => { + expect(parseGitHubRemote("not-a-url")).toBeNull() + expect(parseGitHubRemote("")).toBeNull() + expect(parseGitHubRemote("github.com")).toBeNull() + expect(parseGitHubRemote("https://github.com/")).toBeNull() + expect(parseGitHubRemote("https://github.com/owner")).toBeNull() +}) + +test("returns null for URLs with extra path segments", () => { + expect(parseGitHubRemote("https://github.com/owner/repo/tree/main")).toBeNull() + expect(parseGitHubRemote("https://github.com/owner/repo/blob/main/file.ts")).toBeNull() +})