mirror of
https://github.com/johndoe6345789/WizardMerge.git
synced 2026-04-24 13:44:55 +00:00
Add GitLab support for merge request resolution alongside GitHub
Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
53
README.md
53
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 <pr_or_mr_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
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
118
backend/include/wizardmerge/git/git_platform_client.h
Normal file
118
backend/include/wizardmerge/git/git_platform_client.h
Normal 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
|
||||
@@ -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
|
||||
@@ -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 <json/json.h>
|
||||
#include <iostream>
|
||||
@@ -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<std::string> 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;
|
||||
|
||||
417
backend/src/git/git_platform_client.cpp
Normal file
417
backend/src/git/git_platform_client.cpp
Normal 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
|
||||
@@ -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
|
||||
116
backend/tests/test_git_platform_client.cpp
Normal file
116
backend/tests/test_git_platform_client.cpp
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user