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.
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
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 "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;
|
||||||
|
|||||||
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
|
* 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user