Add GitLab support for merge request resolution alongside GitHub

Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-27 02:17:34 +00:00
parent 0e2a19c89f
commit c377c5f4aa
10 changed files with 748 additions and 490 deletions

View File

@@ -101,44 +101,67 @@ ninja
See [frontends/cli/README.md](frontends/cli/README.md) for details. 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 ### Using the CLI
```sh ```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 ./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://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 # Or use environment variable
export GITHUB_TOKEN=ghp_xxx export GITHUB_TOKEN=ghp_xxx # For GitHub
./wizardmerge-cli-frontend pr-resolve --url https://github.com/owner/repo/pull/123 export GITLAB_TOKEN=glpat-xxx # For GitLab
./wizardmerge-cli-frontend pr-resolve --url <pr_or_mr_url>
``` ```
### Using the HTTP API ### Using the HTTP API
```sh ```sh
# POST /api/pr/resolve # POST /api/pr/resolve - GitHub
curl -X POST http://localhost:8080/api/pr/resolve \ curl -X POST http://localhost:8080/api/pr/resolve \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"pr_url": "https://github.com/owner/repo/pull/123", "pr_url": "https://github.com/owner/repo/pull/123",
"github_token": "ghp_xxx", "api_token": "ghp_xxx"
"create_branch": true, }'
"branch_name": "wizardmerge-resolved-pr-123"
# 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: The API will:
1. Parse the PR URL and fetch PR metadata from GitHub 1. Parse the PR/MR URL and detect the platform (GitHub or GitLab)
2. Retrieve base and head versions of all modified files 2. Fetch PR/MR metadata using the platform-specific API
3. Apply the three-way merge algorithm to each file 3. Retrieve base and head versions of all modified files
4. Auto-resolve conflicts using heuristics 4. Apply the three-way merge algorithm to each file
5. Return merged content with conflict status 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 ## Research Foundation

View File

@@ -18,10 +18,10 @@ set(WIZARDMERGE_SOURCES
# Add git sources only if CURL is available # Add git sources only if CURL is available
if(CURL_FOUND) if(CURL_FOUND)
list(APPEND WIZARDMERGE_SOURCES src/git/github_client.cpp) list(APPEND WIZARDMERGE_SOURCES src/git/git_platform_client.cpp)
message(STATUS "CURL found - including GitHub API client") message(STATUS "CURL found - including Git platform API client (GitHub & GitLab)")
else() 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() endif()
add_library(wizardmerge ${WIZARDMERGE_SOURCES}) add_library(wizardmerge ${WIZARDMERGE_SOURCES})
@@ -71,7 +71,7 @@ if(GTest_FOUND)
# Add github client tests only if CURL is available # Add github client tests only if CURL is available
if(CURL_FOUND) if(CURL_FOUND)
list(APPEND TEST_SOURCES tests/test_github_client.cpp) list(APPEND TEST_SOURCES tests/test_git_platform_client.cpp)
endif() endif()
add_executable(wizardmerge-tests ${TEST_SOURCES}) add_executable(wizardmerge-tests ${TEST_SOURCES})

View File

@@ -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 <string>
#include <vector>
#include <map>
#include <optional>
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<PRFile> 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<PullRequest> 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<std::vector<std::string>> 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

View File

@@ -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 <string>
#include <vector>
#include <map>
#include <optional>
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<PRFile> 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<PullRequest> 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<std::vector<std::string>> 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

View File

@@ -4,7 +4,7 @@
*/ */
#include "PRController.h" #include "PRController.h"
#include "wizardmerge/git/github_client.h" #include "wizardmerge/git/git_platform_client.h"
#include "wizardmerge/merge/three_way_merge.h" #include "wizardmerge/merge/three_way_merge.h"
#include <json/json.h> #include <json/json.h>
#include <iostream> #include <iostream>
@@ -41,30 +41,33 @@ void PRController::resolvePR(
} }
std::string pr_url = json["pr_url"].asString(); 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(); bool create_branch = json.get("create_branch", false).asBool();
std::string branch_name = json.get("branch_name", "").asString(); std::string branch_name = json.get("branch_name", "").asString();
// Parse PR URL // Parse PR/MR URL
GitPlatform platform;
std::string owner, repo; std::string owner, repo;
int pr_number; 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; Json::Value error;
error["error"] = "Invalid pull request URL format"; error["error"] = "Invalid pull/merge request URL format";
error["pr_url"] = pr_url; error["pr_url"] = pr_url;
error["note"] = "Supported platforms: GitHub (pull requests) and GitLab (merge requests)";
auto resp = HttpResponse::newHttpJsonResponse(error); auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest); resp->setStatusCode(k400BadRequest);
callback(resp); callback(resp);
return; return;
} }
// Fetch pull request information // Fetch pull/merge request information
auto pr_opt = fetch_pull_request(owner, repo, pr_number, github_token); auto pr_opt = fetch_pull_request(platform, owner, repo, pr_number, api_token);
if (!pr_opt) { if (!pr_opt) {
Json::Value error; 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["owner"] = owner;
error["repo"] = repo; error["repo"] = repo;
error["pr_number"] = pr_number; error["pr_number"] = pr_number;
@@ -102,7 +105,7 @@ void PRController::resolvePR(
// Fetch base version (empty for added files) // Fetch base version (empty for added files)
std::vector<std::string> base_content; std::vector<std::string> base_content;
if (file.status == "modified") { 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) { if (!base_opt) {
file_result["error"] = "Failed to fetch base version"; file_result["error"] = "Failed to fetch base version";
file_result["had_conflicts"] = false; file_result["had_conflicts"] = false;
@@ -114,7 +117,7 @@ void PRController::resolvePR(
} }
// Fetch head version // 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) { if (!head_opt) {
file_result["error"] = "Failed to fetch head version"; file_result["error"] = "Failed to fetch head version";
file_result["had_conflicts"] = false; file_result["had_conflicts"] = false;
@@ -160,6 +163,7 @@ void PRController::resolvePR(
response["success"] = true; response["success"] = true;
Json::Value pr_info; Json::Value pr_info;
pr_info["platform"] = (pr.platform == GitPlatform::GitHub) ? "GitHub" : "GitLab";
pr_info["number"] = pr.number; pr_info["number"] = pr.number;
pr_info["title"] = pr.title; pr_info["title"] = pr.title;
pr_info["state"] = pr.state; pr_info["state"] = pr.state;

View File

@@ -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 <regex>
#include <sstream>
#include <iostream>
#include <algorithm>
#include <curl/curl.h>
#include <json/json.h>
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<int> 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<std::string> split_lines(const std::string& content) {
std::vector<std::string> 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<PullRequest> 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<std::vector<std::string>> 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

View File

@@ -1,273 +0,0 @@
/**
* @file github_client.cpp
* @brief Implementation of GitHub API client
*/
#include "wizardmerge/git/github_client.h"
#include <regex>
#include <sstream>
#include <iostream>
#include <algorithm>
#include <curl/curl.h>
#include <json/json.h>
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<int> 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<std::string> split_lines(const std::string& content) {
std::vector<std::string> 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<PullRequest> 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<std::vector<std::string>> 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

View File

@@ -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 <gtest/gtest.h>
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);
}

View File

@@ -1,69 +0,0 @@
/**
* @file test_github_client.cpp
* @brief Unit tests for GitHub client functionality
*/
#include "wizardmerge/git/github_client.h"
#include <gtest/gtest.h>
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);
}

View File

@@ -15,11 +15,12 @@ EXTENDS Naturals, FiniteSets
* Identical changes from both sides * Identical changes from both sides
* Whitespace-only differences * Whitespace-only differences
- Command-line interface (wizardmerge-cli) - Command-line interface (wizardmerge-cli)
- Pull request URL processing and conflict resolution: - Pull request/merge request URL processing and conflict resolution:
* Parse GitHub PR URLs * Parse GitHub PR URLs and GitLab MR URLs
* Fetch PR data via GitHub API * Fetch PR/MR data via GitHub and GitLab APIs
* Apply merge algorithm to PR files * Apply merge algorithm to PR/MR files
* HTTP API endpoint for PR resolution * HTTP API endpoint for PR/MR resolution
* Support for multiple git platforms (GitHub and GitLab)
NOT YET IMPLEMENTED (Future phases): NOT YET IMPLEMENTED (Future phases):
- Dependency graph construction (SDG analysis) - Dependency graph construction (SDG analysis)
@@ -27,26 +28,32 @@ EXTENDS Naturals, FiniteSets
- Edge classification (safe vs. violated) - Edge classification (safe vs. violated)
- Fine-grained DCB (Definition-Code Block) tracking - Fine-grained DCB (Definition-Code Block) tracking
- Mirror mapping and matching - 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 The current implementation in backend/src/merge/three_way_merge.cpp provides
a foundation for the full dependency-aware algorithm specified here. Future a foundation for the full dependency-aware algorithm specified here. Future
phases will enhance it with the SDG analysis, edge classification, and phases will enhance it with the SDG analysis, edge classification, and
dependency-aware conflict resolution described in this specification. dependency-aware conflict resolution described in this specification.
PR Resolution Workflow (Phase 1.2): PR/MR Resolution Workflow (Phase 1.2):
The PR resolution feature extends the core merge algorithm to work with The PR/MR resolution feature extends the core merge algorithm to work with
GitHub pull requests. The workflow is: both GitHub pull requests and GitLab merge requests. The workflow is:
1. Accept PR URL: Parse URL to extract owner, repo, and PR number 1. Accept PR/MR URL: Parse URL to detect platform and extract owner, repo, and number
2. Fetch PR metadata: Use GitHub API to retrieve PR information 2. Fetch PR/MR metadata: Use platform-specific API to retrieve information
3. Fetch file versions: Retrieve base and head versions of modified files 3. Fetch file versions: Retrieve base and head versions of modified files
4. Apply merge algorithm: For each file, perform three-way merge 4. Apply merge algorithm: For each file, perform three-way merge
5. Auto-resolve conflicts: Apply heuristic resolution where possible 5. Auto-resolve conflicts: Apply heuristic resolution where possible
6. Return results: Provide merged content and conflict status 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 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 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 This section extends the core merge specification to model the PR/MR resolution
workflow. It describes how WizardMerge processes GitHub pull requests to workflow. It describes how WizardMerge processes GitHub pull requests and
identify and resolve conflicts across multiple files. 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 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, 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" Possible values: "modified", "added", "removed", "renamed"
*) *)
FileStatus, 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 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 A file is resolvable if it was modified (not removed) and we can fetch
both its base and head versions. both its base and head versions.