-
Notifications
You must be signed in to change notification settings - Fork 3.9k
feat: add pull request creation endpoint with GitHub CLI integration #8861
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 1 commit
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e5cf43a
feat: add pull request creation endpoint with GitHub CLI integration
sestinj b906ece
Potential fix for code scanning alert no. 71: Uncontrolled command line
sestinj 201ffa7
Update extensions/cli/src/commands/serve.ts
sestinj 66fef4c
fix: address feedback
sestinj d3cba54
Merge branch 'nate/add-pr-endpoint-cli' of https://github.com/continu…
sestinj File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,332 @@ | ||
| import * as nodeUtil from "util"; | ||
|
|
||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import * as gitUtil from "../util/git.js"; | ||
|
|
||
| // Create mock exec with promisify.custom support inside the factory | ||
| vi.mock("child_process", () => { | ||
| const execMockFn: any = vi.fn(); | ||
| (execMockFn as any)[(nodeUtil as any).promisify.custom] = (cmd: string) => | ||
| new Promise((resolve, reject) => { | ||
| execMockFn(cmd, (err: any, stdout: any, stderr: any) => { | ||
| if (err) reject(err); | ||
| else resolve({ stdout, stderr }); | ||
| }); | ||
| }); | ||
| return { exec: execMockFn }; | ||
| }); | ||
|
|
||
| // Import after mocking to get the mocked version | ||
| const childProcess = await import("child_process"); | ||
| const execMock = vi.mocked(childProcess.exec); | ||
|
|
||
| // Mock logger | ||
| vi.mock("../util/logger.js", () => ({ | ||
| logger: { | ||
| debug: vi.fn(), | ||
| info: vi.fn(), | ||
| warn: vi.fn(), | ||
| error: vi.fn(), | ||
| }, | ||
| })); | ||
|
|
||
| // Import after mocks | ||
| const { createPullRequest } = await import("./pr.js"); | ||
|
|
||
| describe("pr endpoint", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| execMock.mockClear(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe("validation", () => { | ||
| it("should fail if not in a git repository", async () => { | ||
| vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(false); | ||
|
|
||
| const result = await createPullRequest({}); | ||
|
|
||
| expect(result.success).toBe(false); | ||
| expect(result.error).toContain("Not in a git repository"); | ||
| }); | ||
|
|
||
| it("should fail if current branch cannot be determined", async () => { | ||
| vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); | ||
| vi.spyOn(gitUtil, "getGitBranch").mockReturnValue(null); | ||
|
|
||
| const result = await createPullRequest({}); | ||
|
|
||
| expect(result.success).toBe(false); | ||
| expect(result.error).toContain("Could not determine current branch"); | ||
| }); | ||
|
|
||
| it("should fail if on main branch", async () => { | ||
| vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); | ||
| vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("main"); | ||
|
|
||
| const result = await createPullRequest({}); | ||
|
|
||
| expect(result.success).toBe(false); | ||
| expect(result.error).toContain("You're currently on the main branch"); | ||
| }); | ||
|
|
||
| it("should fail if on master branch", async () => { | ||
| vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); | ||
| vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("master"); | ||
|
|
||
| const result = await createPullRequest({}); | ||
|
|
||
| expect(result.success).toBe(false); | ||
| expect(result.error).toContain("You're currently on the master branch"); | ||
| }); | ||
|
|
||
| it("should fail if no remote URL found", async () => { | ||
| vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); | ||
| vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("feature-branch"); | ||
| vi.spyOn(gitUtil, "getGitRemoteUrl").mockReturnValue(null); | ||
|
|
||
| const result = await createPullRequest({}); | ||
|
|
||
| expect(result.success).toBe(false); | ||
| expect(result.error).toContain("Could not find git remote URL"); | ||
| }); | ||
|
|
||
| it("should fail if remote is not a GitHub repository", async () => { | ||
| vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); | ||
| vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("feature-branch"); | ||
| vi.spyOn(gitUtil, "getGitRemoteUrl").mockReturnValue( | ||
| "https://gitlab.com/owner/repo.git", | ||
| ); | ||
|
|
||
| const result = await createPullRequest({}); | ||
|
|
||
| expect(result.success).toBe(false); | ||
| expect(result.error).toContain( | ||
| "doesn't appear to be a GitHub repository", | ||
| ); | ||
| }); | ||
|
|
||
| it("should fail if GitHub CLI is not installed", async () => { | ||
| vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); | ||
| vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("feature-branch"); | ||
| vi.spyOn(gitUtil, "getGitRemoteUrl").mockReturnValue( | ||
| "https://github.com/owner/repo.git", | ||
| ); | ||
|
|
||
| // Mock exec to fail for gh --version | ||
| execMock.mockImplementation((cmd: string, callback: any) => { | ||
RomneyDa marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| callback(new Error("Command not found"), "", ""); | ||
| return {} as any; | ||
| }); | ||
|
|
||
| const result = await createPullRequest({}); | ||
|
|
||
| expect(result.success).toBe(false); | ||
| expect(result.error).toContain("GitHub CLI (gh) is not installed"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("GitHub URL parsing", () => { | ||
| it("should handle HTTPS GitHub URLs", async () => { | ||
| vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); | ||
| vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("feature-branch"); | ||
| vi.spyOn(gitUtil, "getGitRemoteUrl").mockReturnValue( | ||
| "https://github.com/owner/repo.git", | ||
| ); | ||
|
|
||
| // Mock exec to handle different commands | ||
| execMock.mockImplementation((cmd: string, callback: any) => { | ||
| if (cmd.includes("gh --version")) { | ||
| callback(null, "gh version 2.0.0", ""); | ||
| } else if (cmd.includes("gh pr create")) { | ||
| callback( | ||
| null, | ||
| "https://github.com/owner/repo/pull/123\nPR created successfully", | ||
| "", | ||
| ); | ||
| } else if (cmd.includes("git log")) { | ||
| callback(null, "- feat: add new feature", ""); | ||
| } | ||
| return {} as any; | ||
| }); | ||
|
|
||
| const result = await createPullRequest({}); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(result.message).toContain("Pull request created successfully"); | ||
| }); | ||
|
|
||
| it("should handle SSH GitHub URLs", async () => { | ||
| vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); | ||
| vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("feature-branch"); | ||
| vi.spyOn(gitUtil, "getGitRemoteUrl").mockReturnValue( | ||
| "[email protected]:owner/repo.git", | ||
| ); | ||
|
|
||
| // Mock exec to handle different commands | ||
| execMock.mockImplementation((cmd: string, callback: any) => { | ||
| if (cmd.includes("gh --version")) { | ||
| callback(null, "gh version 2.0.0", ""); | ||
| } else if (cmd.includes("gh pr create")) { | ||
| callback( | ||
| null, | ||
| "https://github.com/owner/repo/pull/456\nPR created successfully", | ||
| "", | ||
| ); | ||
| } else if (cmd.includes("git log")) { | ||
| callback(null, "- feat: add new feature", ""); | ||
| } | ||
| return {} as any; | ||
| }); | ||
|
|
||
| const result = await createPullRequest({}); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(result.message).toContain("Pull request created successfully"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("PR creation", () => { | ||
| beforeEach(() => { | ||
| vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); | ||
| vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("feature/new-endpoint"); | ||
| vi.spyOn(gitUtil, "getGitRemoteUrl").mockReturnValue( | ||
| "https://github.com/owner/repo.git", | ||
| ); | ||
| }); | ||
|
|
||
| it("should create a PR with custom title and body", async () => { | ||
| let ghCommand = ""; | ||
| execMock.mockImplementation((cmd: string, callback: any) => { | ||
| if (cmd.includes("gh --version")) { | ||
| callback(null, "gh version 2.0.0", ""); | ||
| } else if (cmd.includes("gh pr create")) { | ||
| ghCommand = cmd; | ||
| callback( | ||
| null, | ||
| "https://github.com/owner/repo/pull/1\nPR created successfully", | ||
| "", | ||
| ); | ||
| } else if (cmd.includes("git log")) { | ||
| callback(null, "- feat: add new feature", ""); | ||
| } | ||
| return {} as any; | ||
| }); | ||
|
|
||
| const result = await createPullRequest({ | ||
| title: "Custom Title", | ||
| body: "Custom Body", | ||
| }); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(result.prUrl).toContain("github.com/owner/repo/pull"); | ||
| expect(ghCommand).toContain("--title"); | ||
| expect(ghCommand).toContain("Custom Title"); | ||
| expect(ghCommand).toContain("--body"); | ||
| expect(ghCommand).toContain("Custom Body"); | ||
| }); | ||
|
|
||
| it("should create a draft PR when draft option is true", async () => { | ||
| let ghCommand = ""; | ||
| execMock.mockImplementation((cmd: string, callback: any) => { | ||
| if (cmd.includes("gh --version")) { | ||
| callback(null, "gh version 2.0.0", ""); | ||
| } else if (cmd.includes("gh pr create")) { | ||
| ghCommand = cmd; | ||
| callback( | ||
| null, | ||
| "https://github.com/owner/repo/pull/2\nPR created successfully", | ||
| "", | ||
| ); | ||
| } else if (cmd.includes("git log")) { | ||
| callback(null, "- feat: add new feature", ""); | ||
| } | ||
| return {} as any; | ||
| }); | ||
|
|
||
| const result = await createPullRequest({ draft: true }); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(ghCommand).toContain("--draft"); | ||
| }); | ||
|
|
||
| it("should use custom base branch", async () => { | ||
| let ghCommand = ""; | ||
| execMock.mockImplementation((cmd: string, callback: any) => { | ||
| if (cmd.includes("gh --version")) { | ||
| callback(null, "gh version 2.0.0", ""); | ||
| } else if (cmd.includes("gh pr create")) { | ||
| ghCommand = cmd; | ||
| callback( | ||
| null, | ||
| "https://github.com/owner/repo/pull/3\nPR created successfully", | ||
| "", | ||
| ); | ||
| } else if (cmd.includes("git log")) { | ||
| callback(null, "- feat: add new feature", ""); | ||
| } | ||
| return {} as any; | ||
| }); | ||
|
|
||
| const result = await createPullRequest({ base: "develop" }); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(ghCommand).toContain("--base"); | ||
| expect(ghCommand).toContain("develop"); | ||
| }); | ||
|
|
||
| it("should open in browser when web option is true", async () => { | ||
| let ghCommand = ""; | ||
| execMock.mockImplementation((cmd: string, callback: any) => { | ||
| if (cmd.includes("gh --version")) { | ||
| callback(null, "gh version 2.0.0", ""); | ||
| } else if (cmd.includes("gh pr create")) { | ||
| ghCommand = cmd; | ||
| callback( | ||
| null, | ||
| "https://github.com/owner/repo/pull/4\nPR created successfully", | ||
| "", | ||
| ); | ||
| } else if (cmd.includes("git log")) { | ||
| callback(null, "- feat: add new feature", ""); | ||
| } | ||
| return {} as any; | ||
| }); | ||
|
|
||
| const result = await createPullRequest({ web: true }); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(ghCommand).toContain("--web"); | ||
| }); | ||
|
|
||
| it("should generate title from branch name", async () => { | ||
| let ghCommand = ""; | ||
| execMock.mockImplementation((cmd: string, callback: any) => { | ||
| if (cmd.includes("gh --version")) { | ||
| callback(null, "gh version 2.0.0", ""); | ||
| } else if (cmd.includes("gh pr create")) { | ||
| ghCommand = cmd; | ||
| callback( | ||
| null, | ||
| "https://github.com/owner/repo/pull/5\nPR created successfully", | ||
| "", | ||
| ); | ||
| } else if (cmd.includes("git log")) { | ||
| callback(null, "- feat: add new feature", ""); | ||
| } | ||
| return {} as any; | ||
| }); | ||
|
|
||
| const result = await createPullRequest({}); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| // Should convert "feature/new-endpoint" to "New Endpoint" | ||
| expect(ghCommand).toContain("--title"); | ||
| expect(ghCommand).toContain("New Endpoint"); | ||
| }); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.