From c377c5f4aaaf29a27cd8bf0b80881d3bb9b96940 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Dec 2025 02:17:34 +0000 Subject: [PATCH] Add GitLab support for merge request resolution alongside GitHub Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com> --- README.md | 53 ++- backend/CMakeLists.txt | 8 +- .../wizardmerge/git/git_platform_client.h | 118 +++++ .../include/wizardmerge/git/github_client.h | 99 ----- backend/src/controllers/PRController.cc | 24 +- backend/src/git/git_platform_client.cpp | 417 ++++++++++++++++++ backend/src/git/github_client.cpp | 273 ------------ backend/tests/test_git_platform_client.cpp | 116 +++++ backend/tests/test_github_client.cpp | 69 --- spec/WizardMergeSpec.tla | 61 ++- 10 files changed, 748 insertions(+), 490 deletions(-) create mode 100644 backend/include/wizardmerge/git/git_platform_client.h delete mode 100644 backend/include/wizardmerge/git/github_client.h create mode 100644 backend/src/git/git_platform_client.cpp delete mode 100644 backend/src/git/github_client.cpp create mode 100644 backend/tests/test_git_platform_client.cpp delete mode 100644 backend/tests/test_github_client.cpp diff --git a/README.md b/README.md index 0cd7291..9f9c251 100644 --- a/README.md +++ b/README.md @@ -101,44 +101,67 @@ ninja See [frontends/cli/README.md](frontends/cli/README.md) for details. -## Pull Request Conflict Resolution +## Pull Request / Merge Request Conflict Resolution -WizardMerge can automatically resolve conflicts in GitHub pull requests using advanced merge algorithms. +WizardMerge can automatically resolve conflicts in GitHub pull requests and GitLab merge requests using advanced merge algorithms. + +### Supported Platforms + +- **GitHub**: Pull requests via GitHub API +- **GitLab**: Merge requests via GitLab API ### Using the CLI ```sh -# Resolve conflicts in a pull request +# Resolve conflicts in a GitHub pull request ./wizardmerge-cli-frontend pr-resolve --url https://github.com/owner/repo/pull/123 -# With GitHub token for private repos +# Resolve conflicts in a GitLab merge request +./wizardmerge-cli-frontend pr-resolve --url https://gitlab.com/owner/repo/-/merge_requests/456 + +# With API token for private repos ./wizardmerge-cli-frontend pr-resolve --url https://github.com/owner/repo/pull/123 --token ghp_xxx +./wizardmerge-cli-frontend pr-resolve --url https://gitlab.com/owner/repo/-/merge_requests/456 --token glpat-xxx # Or use environment variable -export GITHUB_TOKEN=ghp_xxx -./wizardmerge-cli-frontend pr-resolve --url https://github.com/owner/repo/pull/123 +export GITHUB_TOKEN=ghp_xxx # For GitHub +export GITLAB_TOKEN=glpat-xxx # For GitLab +./wizardmerge-cli-frontend pr-resolve --url ``` ### Using the HTTP API ```sh -# POST /api/pr/resolve +# POST /api/pr/resolve - GitHub curl -X POST http://localhost:8080/api/pr/resolve \ -H "Content-Type: application/json" \ -d '{ "pr_url": "https://github.com/owner/repo/pull/123", - "github_token": "ghp_xxx", - "create_branch": true, - "branch_name": "wizardmerge-resolved-pr-123" + "api_token": "ghp_xxx" + }' + +# POST /api/pr/resolve - GitLab +curl -X POST http://localhost:8080/api/pr/resolve \ + -H "Content-Type: application/json" \ + -d '{ + "pr_url": "https://gitlab.com/owner/repo/-/merge_requests/456", + "api_token": "glpat-xxx" }' ``` The API will: -1. Parse the PR URL and fetch PR metadata from GitHub -2. Retrieve base and head versions of all modified files -3. Apply the three-way merge algorithm to each file -4. Auto-resolve conflicts using heuristics -5. Return merged content with conflict status +1. Parse the PR/MR URL and detect the platform (GitHub or GitLab) +2. Fetch PR/MR metadata using the platform-specific API +3. Retrieve base and head versions of all modified files +4. Apply the three-way merge algorithm to each file +5. Auto-resolve conflicts using heuristics +6. Return merged content with conflict status + +### Authentication + +- **GitHub**: Use personal access tokens with `repo` scope +- **GitLab**: Use personal access tokens with `read_api` and `read_repository` scopes +- Tokens can be passed via `--token` flag or environment variables (`GITHUB_TOKEN`, `GITLAB_TOKEN`) ## Research Foundation diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index 706eb92..228bf31 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -18,10 +18,10 @@ set(WIZARDMERGE_SOURCES # Add git sources only if CURL is available if(CURL_FOUND) - list(APPEND WIZARDMERGE_SOURCES src/git/github_client.cpp) - message(STATUS "CURL found - including GitHub API client") + list(APPEND WIZARDMERGE_SOURCES src/git/git_platform_client.cpp) + message(STATUS "CURL found - including Git platform API client (GitHub & GitLab)") else() - message(WARNING "CURL not found - GitHub API features will be unavailable") + message(WARNING "CURL not found - Git platform API features will be unavailable") endif() add_library(wizardmerge ${WIZARDMERGE_SOURCES}) @@ -71,7 +71,7 @@ if(GTest_FOUND) # Add github client tests only if CURL is available if(CURL_FOUND) - list(APPEND TEST_SOURCES tests/test_github_client.cpp) + list(APPEND TEST_SOURCES tests/test_git_platform_client.cpp) endif() add_executable(wizardmerge-tests ${TEST_SOURCES}) diff --git a/backend/include/wizardmerge/git/git_platform_client.h b/backend/include/wizardmerge/git/git_platform_client.h new file mode 100644 index 0000000..c2ba3fa --- /dev/null +++ b/backend/include/wizardmerge/git/git_platform_client.h @@ -0,0 +1,118 @@ +/** + * @file git_platform_client.h + * @brief Git platform API client for fetching pull/merge request information + * + * Supports GitHub and GitLab platforms + */ + +#ifndef WIZARDMERGE_GIT_PLATFORM_CLIENT_H +#define WIZARDMERGE_GIT_PLATFORM_CLIENT_H + +#include +#include +#include +#include + +namespace wizardmerge { +namespace git { + +/** + * @brief Supported git platforms + */ +enum class GitPlatform { + GitHub, + GitLab, + Unknown +}; + +/** + * @brief Information about a file in a pull/merge request + */ +struct PRFile { + std::string filename; + std::string status; // "added", "modified", "removed", "renamed" + int additions; + int deletions; + int changes; +}; + +/** + * @brief Pull/merge request information from GitHub or GitLab + */ +struct PullRequest { + GitPlatform platform; + int number; + std::string title; + std::string state; + std::string base_ref; // Base branch name + std::string head_ref; // Head branch name + std::string base_sha; + std::string head_sha; + std::string repo_owner; + std::string repo_name; + std::vector files; + bool mergeable; + std::string mergeable_state; +}; + +/** + * @brief Parse pull/merge request URL + * + * Extracts platform, owner, repo, and PR/MR number from URLs like: + * - https://github.com/owner/repo/pull/123 + * - https://gitlab.com/owner/repo/-/merge_requests/456 + * - github.com/owner/repo/pull/123 + * - gitlab.com/group/subgroup/project/-/merge_requests/789 + * + * @param url The pull/merge request URL + * @param platform Output git platform + * @param owner Output repository owner/group + * @param repo Output repository name/project + * @param pr_number Output PR/MR number + * @return true if successfully parsed, false otherwise + */ +bool parse_pr_url(const std::string& url, GitPlatform& platform, + std::string& owner, std::string& repo, int& pr_number); + +/** + * @brief Fetch pull/merge request information from GitHub or GitLab API + * + * @param platform Git platform (GitHub or GitLab) + * @param owner Repository owner/group + * @param repo Repository name/project + * @param pr_number Pull/merge request number + * @param token Optional API token for authentication + * @return Pull request information, or empty optional on error + */ +std::optional fetch_pull_request( + GitPlatform platform, + const std::string& owner, + const std::string& repo, + int pr_number, + const std::string& token = "" +); + +/** + * @brief Fetch file content from GitHub or GitLab at a specific commit + * + * @param platform Git platform (GitHub or GitLab) + * @param owner Repository owner/group + * @param repo Repository name/project + * @param sha Commit SHA + * @param path File path + * @param token Optional API token + * @return File content as vector of lines, or empty optional on error + */ +std::optional> fetch_file_content( + GitPlatform platform, + const std::string& owner, + const std::string& repo, + const std::string& sha, + const std::string& path, + const std::string& token = "" +); + +} // namespace git +} // namespace wizardmerge + +#endif // WIZARDMERGE_GIT_PLATFORM_CLIENT_H diff --git a/backend/include/wizardmerge/git/github_client.h b/backend/include/wizardmerge/git/github_client.h deleted file mode 100644 index 834b53a..0000000 --- a/backend/include/wizardmerge/git/github_client.h +++ /dev/null @@ -1,99 +0,0 @@ -/** - * @file github_client.h - * @brief GitHub API client for fetching pull request information - */ - -#ifndef WIZARDMERGE_GIT_GITHUB_CLIENT_H -#define WIZARDMERGE_GIT_GITHUB_CLIENT_H - -#include -#include -#include -#include - -namespace wizardmerge { -namespace git { - -/** - * @brief Information about a file in a pull request - */ -struct PRFile { - std::string filename; - std::string status; // "added", "modified", "removed", "renamed" - int additions; - int deletions; - int changes; -}; - -/** - * @brief Pull request information from GitHub - */ -struct PullRequest { - int number; - std::string title; - std::string state; - std::string base_ref; // Base branch name - std::string head_ref; // Head branch name - std::string base_sha; - std::string head_sha; - std::string repo_owner; - std::string repo_name; - std::vector files; - bool mergeable; - std::string mergeable_state; -}; - -/** - * @brief Parse GitHub pull request URL - * - * Extracts owner, repo, and PR number from URLs like: - * - https://github.com/owner/repo/pull/123 - * - github.com/owner/repo/pull/123 - * - * @param url The pull request URL - * @param owner Output repository owner - * @param repo Output repository name - * @param pr_number Output PR number - * @return true if successfully parsed, false otherwise - */ -bool parse_pr_url(const std::string& url, std::string& owner, - std::string& repo, int& pr_number); - -/** - * @brief Fetch pull request information from GitHub API - * - * @param owner Repository owner - * @param repo Repository name - * @param pr_number Pull request number - * @param token Optional GitHub API token for authentication - * @return Pull request information, or empty optional on error - */ -std::optional fetch_pull_request( - const std::string& owner, - const std::string& repo, - int pr_number, - const std::string& token = "" -); - -/** - * @brief Fetch file content from GitHub at a specific commit - * - * @param owner Repository owner - * @param repo Repository name - * @param sha Commit SHA - * @param path File path - * @param token Optional GitHub API token - * @return File content as vector of lines, or empty optional on error - */ -std::optional> fetch_file_content( - const std::string& owner, - const std::string& repo, - const std::string& sha, - const std::string& path, - const std::string& token = "" -); - -} // namespace git -} // namespace wizardmerge - -#endif // WIZARDMERGE_GIT_GITHUB_CLIENT_H diff --git a/backend/src/controllers/PRController.cc b/backend/src/controllers/PRController.cc index 07f85f2..55f09d1 100644 --- a/backend/src/controllers/PRController.cc +++ b/backend/src/controllers/PRController.cc @@ -4,7 +4,7 @@ */ #include "PRController.h" -#include "wizardmerge/git/github_client.h" +#include "wizardmerge/git/git_platform_client.h" #include "wizardmerge/merge/three_way_merge.h" #include #include @@ -41,30 +41,33 @@ void PRController::resolvePR( } std::string pr_url = json["pr_url"].asString(); - std::string github_token = json.get("github_token", "").asString(); + std::string api_token = json.get("api_token", json.get("github_token", "").asString()).asString(); bool create_branch = json.get("create_branch", false).asBool(); std::string branch_name = json.get("branch_name", "").asString(); - // Parse PR URL + // Parse PR/MR URL + GitPlatform platform; std::string owner, repo; int pr_number; - if (!parse_pr_url(pr_url, owner, repo, pr_number)) { + if (!parse_pr_url(pr_url, platform, owner, repo, pr_number)) { Json::Value error; - error["error"] = "Invalid pull request URL format"; + error["error"] = "Invalid pull/merge request URL format"; error["pr_url"] = pr_url; + error["note"] = "Supported platforms: GitHub (pull requests) and GitLab (merge requests)"; auto resp = HttpResponse::newHttpJsonResponse(error); resp->setStatusCode(k400BadRequest); callback(resp); return; } - // Fetch pull request information - auto pr_opt = fetch_pull_request(owner, repo, pr_number, github_token); + // Fetch pull/merge request information + auto pr_opt = fetch_pull_request(platform, owner, repo, pr_number, api_token); if (!pr_opt) { Json::Value error; - error["error"] = "Failed to fetch pull request information"; + error["error"] = "Failed to fetch pull/merge request information"; + error["platform"] = (platform == GitPlatform::GitHub) ? "GitHub" : "GitLab"; error["owner"] = owner; error["repo"] = repo; error["pr_number"] = pr_number; @@ -102,7 +105,7 @@ void PRController::resolvePR( // Fetch base version (empty for added files) std::vector base_content; if (file.status == "modified") { - auto base_opt = fetch_file_content(owner, repo, pr.base_sha, file.filename, github_token); + auto base_opt = fetch_file_content(platform, owner, repo, pr.base_sha, file.filename, api_token); if (!base_opt) { file_result["error"] = "Failed to fetch base version"; file_result["had_conflicts"] = false; @@ -114,7 +117,7 @@ void PRController::resolvePR( } // Fetch head version - auto head_opt = fetch_file_content(owner, repo, pr.head_sha, file.filename, github_token); + auto head_opt = fetch_file_content(platform, owner, repo, pr.head_sha, file.filename, api_token); if (!head_opt) { file_result["error"] = "Failed to fetch head version"; file_result["had_conflicts"] = false; @@ -160,6 +163,7 @@ void PRController::resolvePR( response["success"] = true; Json::Value pr_info; + pr_info["platform"] = (pr.platform == GitPlatform::GitHub) ? "GitHub" : "GitLab"; pr_info["number"] = pr.number; pr_info["title"] = pr.title; pr_info["state"] = pr.state; diff --git a/backend/src/git/git_platform_client.cpp b/backend/src/git/git_platform_client.cpp new file mode 100644 index 0000000..1e26baa --- /dev/null +++ b/backend/src/git/git_platform_client.cpp @@ -0,0 +1,417 @@ +/** + * @file git_platform_client.cpp + * @brief Implementation of git platform API client for GitHub and GitLab + */ + +#include "wizardmerge/git/git_platform_client.h" +#include +#include +#include +#include +#include +#include + +namespace wizardmerge { +namespace git { + +namespace { + +/** + * @brief Simple base64 decoder + */ +std::string base64_decode(const std::string& encoded) { + static const std::string base64_chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789+/"; + + std::string decoded; + std::vector T(256, -1); + for (int i = 0; i < 64; i++) T[base64_chars[i]] = i; + + int val = 0, valb = -8; + for (unsigned char c : encoded) { + if (T[c] == -1) break; + val = (val << 6) + T[c]; + valb += 6; + if (valb >= 0) { + decoded.push_back(char((val >> valb) & 0xFF)); + valb -= 8; + } + } + return decoded; +} + +namespace { + +// Callback for libcurl to write response data +size_t WriteCallback(void* contents, size_t size, size_t nmemb, void* userp) { + ((std::string*)userp)->append((char*)contents, size * nmemb); + return size * nmemb; +} + +/** + * @brief Perform HTTP GET request using libcurl + */ +bool http_get(const std::string& url, const std::string& token, std::string& response, GitPlatform platform = GitPlatform::GitHub) { + CURL* curl = curl_easy_init(); + if (!curl) { + std::cerr << "Failed to initialize CURL" << std::endl; + return false; + } + + response.clear(); + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "WizardMerge/1.0"); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + + // Setup headers based on platform + struct curl_slist* headers = nullptr; + + if (platform == GitPlatform::GitHub) { + headers = curl_slist_append(headers, "Accept: application/vnd.github.v3+json"); + if (!token.empty()) { + std::string auth_header = "Authorization: token " + token; + headers = curl_slist_append(headers, auth_header.c_str()); + } + } else if (platform == GitPlatform::GitLab) { + headers = curl_slist_append(headers, "Accept: application/json"); + if (!token.empty()) { + std::string auth_header = "PRIVATE-TOKEN: " + token; + headers = curl_slist_append(headers, auth_header.c_str()); + } + } + + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + CURLcode res = curl_easy_perform(curl); + + bool success = (res == CURLE_OK); + if (!success) { + std::cerr << "CURL error: " << curl_easy_strerror(res) << std::endl; + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + return success; +} + +/** + * @brief Split string by newlines + */ +std::vector split_lines(const std::string& content) { + std::vector lines; + std::istringstream stream(content); + std::string line; + + while (std::getline(stream, line)) { + lines.push_back(line); + } + + return lines; +} + +} // anonymous namespace + +bool parse_pr_url(const std::string& url, GitPlatform& platform, + std::string& owner, std::string& repo, int& pr_number) { + // Try GitHub pattern first: + // https://github.com/owner/repo/pull/123 + // github.com/owner/repo/pull/123 + + std::regex github_regex(R"((?:https?://)?(?:www\.)?github\.com/([^/]+)/([^/]+)/pull/(\d+))"); + std::smatch matches; + + if (std::regex_search(url, matches, github_regex)) { + if (matches.size() == 4) { + platform = GitPlatform::GitHub; + owner = matches[1].str(); + repo = matches[2].str(); + pr_number = std::stoi(matches[3].str()); + return true; + } + } + + // Try GitLab pattern: + // https://gitlab.com/owner/repo/-/merge_requests/456 + // gitlab.com/group/subgroup/project/-/merge_requests/789 + + std::regex gitlab_regex(R"((?:https?://)?(?:www\.)?gitlab\.com/([^/-]+(?:/[^/-]+)*?)/-/merge_requests/(\d+))"); + + if (std::regex_search(url, matches, gitlab_regex)) { + if (matches.size() == 3) { + platform = GitPlatform::GitLab; + std::string full_path = matches[1].str(); + + // Extract owner (group) and repo (project) from full path + // For GitLab, the path can be group/subgroup/project + // We need to split it to get the last part as repo + size_t last_slash = full_path.find_last_of('/'); + if (last_slash != std::string::npos) { + owner = full_path.substr(0, last_slash); + repo = full_path.substr(last_slash + 1); + } else { + // Single level, no subgroups + owner = full_path; + repo = ""; + } + + pr_number = std::stoi(matches[2].str()); + return true; + } + } + + platform = GitPlatform::Unknown; + return false; +} + +std::optional fetch_pull_request( + GitPlatform platform, + const std::string& owner, + const std::string& repo, + int pr_number, + const std::string& token +) { + PullRequest pr; + pr.platform = platform; + pr.number = pr_number; + pr.repo_owner = owner; + pr.repo_name = repo; + + std::string pr_url, files_url; + + if (platform == GitPlatform::GitHub) { + // GitHub API URLs + pr_url = "https://api.github.com/repos/" + owner + "/" + repo + "/pulls/" + std::to_string(pr_number); + files_url = "https://api.github.com/repos/" + owner + "/" + repo + "/pulls/" + std::to_string(pr_number) + "/files"; + } else if (platform == GitPlatform::GitLab) { + // GitLab API URLs - encode project path + std::string project_path = owner; + if (!repo.empty()) { + project_path += "/" + repo; + } + // URL encode the project path + CURL* curl = curl_easy_init(); + char* encoded = curl_easy_escape(curl, project_path.c_str(), project_path.length()); + std::string encoded_project = encoded; + curl_free(encoded); + curl_easy_cleanup(curl); + + pr_url = "https://gitlab.com/api/v4/projects/" + encoded_project + "/merge_requests/" + std::to_string(pr_number); + files_url = "https://gitlab.com/api/v4/projects/" + encoded_project + "/merge_requests/" + std::to_string(pr_number) + "/changes"; + } else { + std::cerr << "Unknown platform" << std::endl; + return std::nullopt; + } + + // Fetch PR/MR info + std::string response; + if (!http_get(pr_url, token, response, platform)) { + std::cerr << "Failed to fetch pull/merge request info" << std::endl; + return std::nullopt; + } + + // Parse JSON response + Json::Value root; + Json::CharReaderBuilder reader; + std::string errs; + std::istringstream s(response); + + if (!Json::parseFromStream(reader, s, &root, &errs)) { + std::cerr << "Failed to parse PR/MR JSON: " << errs << std::endl; + return std::nullopt; + } + + pr.title = root.get("title", "").asString(); + pr.state = root.get("state", "").asString(); + + if (platform == GitPlatform::GitHub) { + if (root.isMember("base") && root["base"].isObject()) { + pr.base_ref = root["base"].get("ref", "").asString(); + pr.base_sha = root["base"].get("sha", "").asString(); + } + + if (root.isMember("head") && root["head"].isObject()) { + pr.head_ref = root["head"].get("ref", "").asString(); + pr.head_sha = root["head"].get("sha", "").asString(); + } + + pr.mergeable = root.get("mergeable", false).asBool(); + pr.mergeable_state = root.get("mergeable_state", "unknown").asString(); + } else if (platform == GitPlatform::GitLab) { + pr.base_ref = root.get("target_branch", "").asString(); + pr.head_ref = root.get("source_branch", "").asString(); + pr.base_sha = root.get("diff_refs", Json::Value::null).get("base_sha", "").asString(); + pr.head_sha = root.get("diff_refs", Json::Value::null).get("head_sha", "").asString(); + + // GitLab uses different merge status + std::string merge_status = root.get("merge_status", "").asString(); + pr.mergeable = (merge_status == "can_be_merged"); + pr.mergeable_state = merge_status; + } + + // Fetch PR/MR files + std::string files_response; + + if (!http_get(files_url, token, files_response, platform)) { + std::cerr << "Failed to fetch pull/merge request files" << std::endl; + return std::nullopt; + } + + Json::Value files_root; + std::istringstream files_stream(files_response); + + if (!Json::parseFromStream(reader, files_stream, &files_root, &errs)) { + std::cerr << "Failed to parse files JSON: " << errs << std::endl; + return std::nullopt; + } + + if (Json::parseFromStream(reader, files_stream, &files_root, &errs)) { + if (platform == GitPlatform::GitHub && files_root.isArray()) { + // GitHub format: array of file objects + for (const auto& file : files_root) { + PRFile pr_file; + pr_file.filename = file.get("filename", "").asString(); + pr_file.status = file.get("status", "").asString(); + pr_file.additions = file.get("additions", 0).asInt(); + pr_file.deletions = file.get("deletions", 0).asInt(); + pr_file.changes = file.get("changes", 0).asInt(); + + pr.files.push_back(pr_file); + } + } else if (platform == GitPlatform::GitLab && files_root.isMember("changes")) { + // GitLab format: object with "changes" array + const Json::Value& changes = files_root["changes"]; + if (changes.isArray()) { + for (const auto& file : changes) { + PRFile pr_file; + pr_file.filename = file.get("new_path", file.get("old_path", "").asString()).asString(); + + // Determine status from new_file, deleted_file, renamed_file flags + bool new_file = file.get("new_file", false).asBool(); + bool deleted_file = file.get("deleted_file", false).asBool(); + bool renamed_file = file.get("renamed_file", false).asBool(); + + if (new_file) { + pr_file.status = "added"; + } else if (deleted_file) { + pr_file.status = "removed"; + } else if (renamed_file) { + pr_file.status = "renamed"; + } else { + pr_file.status = "modified"; + } + + // GitLab doesn't provide addition/deletion counts in the changes endpoint + pr_file.additions = 0; + pr_file.deletions = 0; + pr_file.changes = 0; + + pr.files.push_back(pr_file); + } + } + } + } + + return pr; +} + +std::optional> fetch_file_content( + GitPlatform platform, + const std::string& owner, + const std::string& repo, + const std::string& sha, + const std::string& path, + const std::string& token +) { + std::string url; + + if (platform == GitPlatform::GitHub) { + // GitHub API URL + url = "https://api.github.com/repos/" + owner + "/" + repo + "/contents/" + path + "?ref=" + sha; + } else if (platform == GitPlatform::GitLab) { + // GitLab API URL - encode project path and file path + std::string project_path = owner; + if (!repo.empty()) { + project_path += "/" + repo; + } + + CURL* curl = curl_easy_init(); + char* encoded_project = curl_easy_escape(curl, project_path.c_str(), project_path.length()); + char* encoded_path = curl_easy_escape(curl, path.c_str(), path.length()); + + url = "https://gitlab.com/api/v4/projects/" + std::string(encoded_project) + + "/repository/files/" + std::string(encoded_path) + "/raw?ref=" + sha; + + curl_free(encoded_project); + curl_free(encoded_path); + curl_easy_cleanup(curl); + } else { + std::cerr << "Unknown platform" << std::endl; + return std::nullopt; + } + + std::string response; + + if (!http_get(url, token, response, platform)) { + std::cerr << "Failed to fetch file content for " << path << " at " << sha << std::endl; + return std::nullopt; + } + + // Handle response based on platform + if (platform == GitPlatform::GitHub) { + // GitHub returns JSON with base64-encoded content + Json::Value root; + Json::CharReaderBuilder reader; + std::string errs; + std::istringstream s(response); + + if (!Json::parseFromStream(reader, s, &root, &errs)) { + std::cerr << "Failed to parse content JSON: " << errs << std::endl; + return std::nullopt; + } + + // GitHub API returns content as base64 encoded + if (!root.isMember("content") || !root.isMember("encoding")) { + std::cerr << "Invalid response format for file content" << std::endl; + return std::nullopt; + } + + std::string encoding = root["encoding"].asString(); + if (encoding != "base64") { + std::cerr << "Unsupported encoding: " << encoding << std::endl; + return std::nullopt; + } + + // Decode base64 content + std::string encoded_content = root["content"].asString(); + + // Remove newlines from base64 string + encoded_content.erase(std::remove(encoded_content.begin(), encoded_content.end(), '\n'), encoded_content.end()); + encoded_content.erase(std::remove(encoded_content.begin(), encoded_content.end(), '\r'), encoded_content.end()); + + // Decode base64 + std::string decoded_content = base64_decode(encoded_content); + + if (decoded_content.empty()) { + std::cerr << "Failed to decode base64 content" << std::endl; + return std::nullopt; + } + + // Split content into lines + return split_lines(decoded_content); + } else if (platform == GitPlatform::GitLab) { + // GitLab returns raw file content directly + return split_lines(response); + } + + return std::nullopt; +} + +} // namespace git +} // namespace wizardmerge diff --git a/backend/src/git/github_client.cpp b/backend/src/git/github_client.cpp deleted file mode 100644 index 3757048..0000000 --- a/backend/src/git/github_client.cpp +++ /dev/null @@ -1,273 +0,0 @@ -/** - * @file github_client.cpp - * @brief Implementation of GitHub API client - */ - -#include "wizardmerge/git/github_client.h" -#include -#include -#include -#include -#include -#include - -namespace wizardmerge { -namespace git { - -namespace { - -/** - * @brief Simple base64 decoder - */ -std::string base64_decode(const std::string& encoded) { - static const std::string base64_chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "abcdefghijklmnopqrstuvwxyz" - "0123456789+/"; - - std::string decoded; - std::vector T(256, -1); - for (int i = 0; i < 64; i++) T[base64_chars[i]] = i; - - int val = 0, valb = -8; - for (unsigned char c : encoded) { - if (T[c] == -1) break; - val = (val << 6) + T[c]; - valb += 6; - if (valb >= 0) { - decoded.push_back(char((val >> valb) & 0xFF)); - valb -= 8; - } - } - return decoded; -} - -namespace { - -// Callback for libcurl to write response data -size_t WriteCallback(void* contents, size_t size, size_t nmemb, void* userp) { - ((std::string*)userp)->append((char*)contents, size * nmemb); - return size * nmemb; -} - -/** - * @brief Perform HTTP GET request using libcurl - */ -bool http_get(const std::string& url, const std::string& token, std::string& response) { - CURL* curl = curl_easy_init(); - if (!curl) { - std::cerr << "Failed to initialize CURL" << std::endl; - return false; - } - - response.clear(); - - curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); - curl_easy_setopt(curl, CURLOPT_USERAGENT, "WizardMerge/1.0"); - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); - - // Setup headers - struct curl_slist* headers = nullptr; - headers = curl_slist_append(headers, "Accept: application/vnd.github.v3+json"); - - if (!token.empty()) { - std::string auth_header = "Authorization: token " + token; - headers = curl_slist_append(headers, auth_header.c_str()); - } - - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - - CURLcode res = curl_easy_perform(curl); - - bool success = (res == CURLE_OK); - if (!success) { - std::cerr << "CURL error: " << curl_easy_strerror(res) << std::endl; - } - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - - return success; -} - -/** - * @brief Split string by newlines - */ -std::vector split_lines(const std::string& content) { - std::vector lines; - std::istringstream stream(content); - std::string line; - - while (std::getline(stream, line)) { - lines.push_back(line); - } - - return lines; -} - -} // anonymous namespace - -bool parse_pr_url(const std::string& url, std::string& owner, - std::string& repo, int& pr_number) { - // Match patterns like: - // https://github.com/owner/repo/pull/123 - // github.com/owner/repo/pull/123 - // owner/repo/pull/123 - - std::regex pr_regex(R"((?:https?://)?(?:www\.)?github\.com/([^/]+)/([^/]+)/pull/(\d+))"); - std::smatch matches; - - if (std::regex_search(url, matches, pr_regex)) { - if (matches.size() == 4) { - owner = matches[1].str(); - repo = matches[2].str(); - pr_number = std::stoi(matches[3].str()); - return true; - } - } - - return false; -} - -std::optional fetch_pull_request( - const std::string& owner, - const std::string& repo, - int pr_number, - const std::string& token -) { - // Fetch PR info - std::string pr_url = "https://api.github.com/repos/" + owner + "/" + repo + "/pulls/" + std::to_string(pr_number); - std::string response; - - if (!http_get(pr_url, token, response)) { - std::cerr << "Failed to fetch pull request info" << std::endl; - return std::nullopt; - } - - // Parse JSON response - Json::Value root; - Json::CharReaderBuilder reader; - std::string errs; - std::istringstream s(response); - - if (!Json::parseFromStream(reader, s, &root, &errs)) { - std::cerr << "Failed to parse PR JSON: " << errs << std::endl; - return std::nullopt; - } - - PullRequest pr; - pr.number = pr_number; - pr.title = root.get("title", "").asString(); - pr.state = root.get("state", "").asString(); - pr.repo_owner = owner; - pr.repo_name = repo; - - if (root.isMember("base") && root["base"].isObject()) { - pr.base_ref = root["base"].get("ref", "").asString(); - pr.base_sha = root["base"].get("sha", "").asString(); - } - - if (root.isMember("head") && root["head"].isObject()) { - pr.head_ref = root["head"].get("ref", "").asString(); - pr.head_sha = root["head"].get("sha", "").asString(); - } - - pr.mergeable = root.get("mergeable", false).asBool(); - pr.mergeable_state = root.get("mergeable_state", "unknown").asString(); - - // Fetch PR files - std::string files_url = "https://api.github.com/repos/" + owner + "/" + repo + "/pulls/" + std::to_string(pr_number) + "/files"; - std::string files_response; - - if (!http_get(files_url, token, files_response)) { - std::cerr << "Failed to fetch pull request files" << std::endl; - return std::nullopt; - } - - Json::Value files_root; - std::istringstream files_stream(files_response); - - if (!Json::parseFromStream(reader, files_stream, &files_root, &errs)) { - std::cerr << "Failed to parse files JSON: " << errs << std::endl; - return std::nullopt; - } - - if (files_root.isArray()) { - for (const auto& file : files_root) { - PRFile pr_file; - pr_file.filename = file.get("filename", "").asString(); - pr_file.status = file.get("status", "").asString(); - pr_file.additions = file.get("additions", 0).asInt(); - pr_file.deletions = file.get("deletions", 0).asInt(); - pr_file.changes = file.get("changes", 0).asInt(); - - pr.files.push_back(pr_file); - } - } - - return pr; -} - -std::optional> fetch_file_content( - const std::string& owner, - const std::string& repo, - const std::string& sha, - const std::string& path, - const std::string& token -) { - // Use GitHub API to get file content at specific commit - std::string url = "https://api.github.com/repos/" + owner + "/" + repo + "/contents/" + path + "?ref=" + sha; - std::string response; - - if (!http_get(url, token, response)) { - std::cerr << "Failed to fetch file content for " << path << " at " << sha << std::endl; - return std::nullopt; - } - - // Parse JSON response - Json::Value root; - Json::CharReaderBuilder reader; - std::string errs; - std::istringstream s(response); - - if (!Json::parseFromStream(reader, s, &root, &errs)) { - std::cerr << "Failed to parse content JSON: " << errs << std::endl; - return std::nullopt; - } - - // GitHub API returns content as base64 encoded - if (!root.isMember("content") || !root.isMember("encoding")) { - std::cerr << "Invalid response format for file content" << std::endl; - return std::nullopt; - } - - std::string encoding = root["encoding"].asString(); - if (encoding != "base64") { - std::cerr << "Unsupported encoding: " << encoding << std::endl; - return std::nullopt; - } - - // Decode base64 content - std::string encoded_content = root["content"].asString(); - - // Remove newlines from base64 string - encoded_content.erase(std::remove(encoded_content.begin(), encoded_content.end(), '\n'), encoded_content.end()); - encoded_content.erase(std::remove(encoded_content.begin(), encoded_content.end(), '\r'), encoded_content.end()); - - // Decode base64 - std::string decoded_content = base64_decode(encoded_content); - - if (decoded_content.empty()) { - std::cerr << "Failed to decode base64 content" << std::endl; - return std::nullopt; - } - - // Split content into lines - return split_lines(decoded_content); -} - -} // namespace git -} // namespace wizardmerge diff --git a/backend/tests/test_git_platform_client.cpp b/backend/tests/test_git_platform_client.cpp new file mode 100644 index 0000000..d9094c7 --- /dev/null +++ b/backend/tests/test_git_platform_client.cpp @@ -0,0 +1,116 @@ +/** + * @file test_git_platform_client.cpp + * @brief Unit tests for git platform client functionality (GitHub and GitLab) + */ + +#include "wizardmerge/git/git_platform_client.h" +#include + +using namespace wizardmerge::git; + +/** + * Test PR URL parsing with various GitHub formats + */ +TEST(GitPlatformClientTest, ParseGitHubPRUrl_ValidUrls) { + GitPlatform platform; + std::string owner, repo; + int pr_number; + + // Test full HTTPS URL + ASSERT_TRUE(parse_pr_url("https://github.com/owner/repo/pull/123", platform, owner, repo, pr_number)); + EXPECT_EQ(platform, GitPlatform::GitHub); + EXPECT_EQ(owner, "owner"); + EXPECT_EQ(repo, "repo"); + EXPECT_EQ(pr_number, 123); + + // Test without https:// + ASSERT_TRUE(parse_pr_url("github.com/user/project/pull/456", platform, owner, repo, pr_number)); + EXPECT_EQ(platform, GitPlatform::GitHub); + EXPECT_EQ(owner, "user"); + EXPECT_EQ(repo, "project"); + EXPECT_EQ(pr_number, 456); + + // Test with www + ASSERT_TRUE(parse_pr_url("https://www.github.com/testuser/testrepo/pull/789", platform, owner, repo, pr_number)); + EXPECT_EQ(platform, GitPlatform::GitHub); + EXPECT_EQ(owner, "testuser"); + EXPECT_EQ(repo, "testrepo"); + EXPECT_EQ(pr_number, 789); +} + +/** + * Test GitLab MR URL parsing with various formats + */ +TEST(GitPlatformClientTest, ParseGitLabMRUrl_ValidUrls) { + GitPlatform platform; + std::string owner, repo; + int pr_number; + + // Test full HTTPS URL + ASSERT_TRUE(parse_pr_url("https://gitlab.com/owner/repo/-/merge_requests/123", platform, owner, repo, pr_number)); + EXPECT_EQ(platform, GitPlatform::GitLab); + EXPECT_EQ(owner, "owner"); + EXPECT_EQ(repo, "repo"); + EXPECT_EQ(pr_number, 123); + + // Test with group/subgroup/project + ASSERT_TRUE(parse_pr_url("https://gitlab.com/group/subgroup/project/-/merge_requests/456", platform, owner, repo, pr_number)); + EXPECT_EQ(platform, GitPlatform::GitLab); + EXPECT_EQ(owner, "group/subgroup"); + EXPECT_EQ(repo, "project"); + EXPECT_EQ(pr_number, 456); + + // Test without https:// + ASSERT_TRUE(parse_pr_url("gitlab.com/mygroup/myproject/-/merge_requests/789", platform, owner, repo, pr_number)); + EXPECT_EQ(platform, GitPlatform::GitLab); + EXPECT_EQ(owner, "mygroup"); + EXPECT_EQ(repo, "myproject"); + EXPECT_EQ(pr_number, 789); +} + +/** + * Test PR/MR URL parsing with invalid formats + */ +TEST(GitPlatformClientTest, ParsePRUrl_InvalidUrls) { + GitPlatform platform; + std::string owner, repo; + int pr_number; + + // Missing PR number + EXPECT_FALSE(parse_pr_url("https://github.com/owner/repo/pull/", platform, owner, repo, pr_number)); + + // Invalid format + EXPECT_FALSE(parse_pr_url("https://github.com/owner/repo", platform, owner, repo, pr_number)); + + // Not a GitHub or GitLab URL + EXPECT_FALSE(parse_pr_url("https://bitbucket.org/owner/repo/pull-requests/123", platform, owner, repo, pr_number)); + + // Empty string + EXPECT_FALSE(parse_pr_url("", platform, owner, repo, pr_number)); + + // Wrong path for GitLab + EXPECT_FALSE(parse_pr_url("https://gitlab.com/owner/repo/pull/123", platform, owner, repo, pr_number)); +} + +/** + * Test PR/MR URL with special characters in owner/repo names + */ +TEST(GitPlatformClientTest, ParsePRUrl_SpecialCharacters) { + GitPlatform platform; + std::string owner, repo; + int pr_number; + + // GitHub: Underscores and hyphens + ASSERT_TRUE(parse_pr_url("https://github.com/my-owner_123/my-repo_456/pull/999", platform, owner, repo, pr_number)); + EXPECT_EQ(platform, GitPlatform::GitHub); + EXPECT_EQ(owner, "my-owner_123"); + EXPECT_EQ(repo, "my-repo_456"); + EXPECT_EQ(pr_number, 999); + + // GitLab: Complex group paths + ASSERT_TRUE(parse_pr_url("https://gitlab.com/org-name/team-1/my_project/-/merge_requests/100", platform, owner, repo, pr_number)); + EXPECT_EQ(platform, GitPlatform::GitLab); + EXPECT_EQ(owner, "org-name/team-1"); + EXPECT_EQ(repo, "my_project"); + EXPECT_EQ(pr_number, 100); +} diff --git a/backend/tests/test_github_client.cpp b/backend/tests/test_github_client.cpp deleted file mode 100644 index 2ad82c3..0000000 --- a/backend/tests/test_github_client.cpp +++ /dev/null @@ -1,69 +0,0 @@ -/** - * @file test_github_client.cpp - * @brief Unit tests for GitHub client functionality - */ - -#include "wizardmerge/git/github_client.h" -#include - -using namespace wizardmerge::git; - -/** - * Test PR URL parsing with various formats - */ -TEST(GitHubClientTest, ParsePRUrl_ValidUrls) { - std::string owner, repo; - int pr_number; - - // Test full HTTPS URL - ASSERT_TRUE(parse_pr_url("https://github.com/owner/repo/pull/123", owner, repo, pr_number)); - EXPECT_EQ(owner, "owner"); - EXPECT_EQ(repo, "repo"); - EXPECT_EQ(pr_number, 123); - - // Test without https:// - ASSERT_TRUE(parse_pr_url("github.com/user/project/pull/456", owner, repo, pr_number)); - EXPECT_EQ(owner, "user"); - EXPECT_EQ(repo, "project"); - EXPECT_EQ(pr_number, 456); - - // Test with www - ASSERT_TRUE(parse_pr_url("https://www.github.com/testuser/testrepo/pull/789", owner, repo, pr_number)); - EXPECT_EQ(owner, "testuser"); - EXPECT_EQ(repo, "testrepo"); - EXPECT_EQ(pr_number, 789); -} - -/** - * Test PR URL parsing with invalid formats - */ -TEST(GitHubClientTest, ParsePRUrl_InvalidUrls) { - std::string owner, repo; - int pr_number; - - // Missing PR number - EXPECT_FALSE(parse_pr_url("https://github.com/owner/repo/pull/", owner, repo, pr_number)); - - // Invalid format - EXPECT_FALSE(parse_pr_url("https://github.com/owner/repo", owner, repo, pr_number)); - - // Not a GitHub URL - EXPECT_FALSE(parse_pr_url("https://gitlab.com/owner/repo/pull/123", owner, repo, pr_number)); - - // Empty string - EXPECT_FALSE(parse_pr_url("", owner, repo, pr_number)); -} - -/** - * Test PR URL with special characters in owner/repo names - */ -TEST(GitHubClientTest, ParsePRUrl_SpecialCharacters) { - std::string owner, repo; - int pr_number; - - // Underscores and hyphens - ASSERT_TRUE(parse_pr_url("https://github.com/my-owner_123/my-repo_456/pull/999", owner, repo, pr_number)); - EXPECT_EQ(owner, "my-owner_123"); - EXPECT_EQ(repo, "my-repo_456"); - EXPECT_EQ(pr_number, 999); -} diff --git a/spec/WizardMergeSpec.tla b/spec/WizardMergeSpec.tla index 9aca5ec..f1ea17c 100644 --- a/spec/WizardMergeSpec.tla +++ b/spec/WizardMergeSpec.tla @@ -15,11 +15,12 @@ EXTENDS Naturals, FiniteSets * Identical changes from both sides * Whitespace-only differences - Command-line interface (wizardmerge-cli) - - Pull request URL processing and conflict resolution: - * Parse GitHub PR URLs - * Fetch PR data via GitHub API - * Apply merge algorithm to PR files - * HTTP API endpoint for PR resolution + - Pull request/merge request URL processing and conflict resolution: + * Parse GitHub PR URLs and GitLab MR URLs + * Fetch PR/MR data via GitHub and GitLab APIs + * Apply merge algorithm to PR/MR files + * HTTP API endpoint for PR/MR resolution + * Support for multiple git platforms (GitHub and GitLab) NOT YET IMPLEMENTED (Future phases): - Dependency graph construction (SDG analysis) @@ -27,26 +28,32 @@ EXTENDS Naturals, FiniteSets - Edge classification (safe vs. violated) - Fine-grained DCB (Definition-Code Block) tracking - Mirror mapping and matching - - Git branch creation for resolved PRs + - Git branch creation for resolved PRs/MRs + - Support for additional platforms (Bitbucket, etc.) The current implementation in backend/src/merge/three_way_merge.cpp provides a foundation for the full dependency-aware algorithm specified here. Future phases will enhance it with the SDG analysis, edge classification, and dependency-aware conflict resolution described in this specification. - PR Resolution Workflow (Phase 1.2): - The PR resolution feature extends the core merge algorithm to work with - GitHub pull requests. The workflow is: - 1. Accept PR URL: Parse URL to extract owner, repo, and PR number - 2. Fetch PR metadata: Use GitHub API to retrieve PR information + PR/MR Resolution Workflow (Phase 1.2): + The PR/MR resolution feature extends the core merge algorithm to work with + both GitHub pull requests and GitLab merge requests. The workflow is: + 1. Accept PR/MR URL: Parse URL to detect platform and extract owner, repo, and number + 2. Fetch PR/MR metadata: Use platform-specific API to retrieve information 3. Fetch file versions: Retrieve base and head versions of modified files 4. Apply merge algorithm: For each file, perform three-way merge 5. Auto-resolve conflicts: Apply heuristic resolution where possible 6. Return results: Provide merged content and conflict status - This workflow enables batch processing of PR conflicts using the same + Platform Support: + - GitHub: Uses GitHub API v3 with "Authorization: token" header + - GitLab: Uses GitLab API v4 with "PRIVATE-TOKEN" header + - Both platforms support public and private repositories with proper authentication + + This workflow enables batch processing of PR/MR conflicts using the same dependency-aware merge principles, with future integration planned for - automatic branch creation and PR updates. + automatic branch creation and PR/MR updates. *) (* @@ -337,32 +344,46 @@ Inv == THEOREM Spec => []Inv (***************************************************************************) -(* Pull Request Resolution Specification (Phase 1.2) *) +(* Pull Request/Merge Request Resolution Specification (Phase 1.2) *) (***************************************************************************) (* - This section extends the core merge specification to model the PR resolution - workflow. It describes how WizardMerge processes GitHub pull requests to - identify and resolve conflicts across multiple files. + This section extends the core merge specification to model the PR/MR resolution + workflow. It describes how WizardMerge processes GitHub pull requests and + GitLab merge requests to identify and resolve conflicts across multiple files. + + Supported Platforms: + - GitHub: Uses "pull request" terminology with "/pull/" URL path + - GitLab: Uses "merge request" terminology with "/-/merge_requests/" URL path *) CONSTANTS (* - PR_FILES: the set of all files in the pull request + GitPlatform: the platform type - GitHub or GitLab + *) + GitPlatform, + + (* + PR_FILES: the set of all files in the pull/merge request *) PR_FILES, (* - FileStatus: maps each file to its modification status in the PR + FileStatus: maps each file to its modification status in the PR/MR Possible values: "modified", "added", "removed", "renamed" *) FileStatus, (* - BaseSHA, HeadSHA: commit identifiers for base and head of the PR + BaseSHA, HeadSHA: commit identifiers for base and head of the PR/MR *) BaseSHA, HeadSHA +(* + Platform types - GitHub uses pull requests, GitLab uses merge requests +*) +ASSUME GitPlatform \in {"GitHub", "GitLab"} + (* A file is resolvable if it was modified (not removed) and we can fetch both its base and head versions.