Add Gitea Support

This is a rough, initial attempt at adding Gitea support. Some features have been tested similarly to GitHub.

Signed-off-by: Odin Vex <44311901+OdinVex@users.noreply.github.com>
This commit is contained in:
Odin Vex 2022-10-15 19:31:57 -04:00
parent ff418c49eb
commit 8fd8644bd3
No known key found for this signature in database
GPG Key ID: 636378AA2232808D
9 changed files with 393 additions and 2 deletions

BIN
rsrc/gitea.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -29,6 +29,7 @@
<file>general@2x.png</file>
<file>github.png</file>
<file>github_dark.png</file>
<file>gitea.png</file>
<file>gitlab.png</file>
<file>hotkeys.png</file>
<file>hotkeys@2x.png</file>

View File

@ -24,6 +24,7 @@ AccountDialog::AccountDialog(Account *account, QWidget *parent)
mHost = new QComboBox(this);
mHost->setMinimumWidth(mHost->sizeHint().width() * 2);
mHost->addItem("GitHub", Account::GitHub);
mHost->addItem("Gitea", Account::Gitea);
mHost->addItem("Bitbucket", Account::Bitbucket);
mHost->addItem("Beanstalk", Account::Beanstalk);
mHost->addItem("GitLab", Account::GitLab);

View File

@ -11,6 +11,7 @@
#include "Beanstalk.h"
#include "Bitbucket.h"
#include "GitHub.h"
#include "Gitea.h"
#include "GitLab.h"
#include "cred/CredentialHelper.h"
#include <QFileInfo>
@ -136,6 +137,9 @@ QIcon Account::icon(Kind kind) {
case Account::GitHub:
name = "github";
break;
case Account::Gitea:
name = "gitea";
break;
case Account::Bitbucket:
name = "bitbucket";
break;
@ -184,6 +188,10 @@ QString Account::helpText(Kind kind) {
"command-line/'>personal access token</a> in the password field "
"instead.");
case Gitea:
return tr(
"<b>Note:</b> Only Basic authentication is currently supported ");
case GitLab:
return tr("<b>Note:</b> Basic authentication is not supported. Use a "
"<a href='https://docs.gitlab.com/ee/user/profile/personal_"
@ -200,6 +208,8 @@ QString Account::defaultUrl(Kind kind) {
switch (kind) {
case GitHub:
return GitHub::defaultUrl();
case Gitea:
return Gitea::defaultUrl();
case Bitbucket:
return Bitbucket::defaultUrl();
case Beanstalk:

View File

@ -25,7 +25,7 @@ class Account : public QObject {
Q_OBJECT
public:
enum Kind { GitHub, Bitbucket, Beanstalk, GitLab };
enum Kind { GitHub, Gitea, Bitbucket, Beanstalk, GitLab };
struct Comment {
QString body;

View File

@ -11,6 +11,7 @@
#include "Beanstalk.h"
#include "Bitbucket.h"
#include "GitHub.h"
#include "Gitea.h"
#include "GitLab.h"
#include <QCoreApplication>
#include <QSettings>
@ -60,6 +61,9 @@ Account *Accounts::createAccount(Account::Kind kind, const QString &username,
case Account::GitHub:
account = new GitHub(username);
break;
case Account::Gitea:
account = new Gitea(username);
break;
case Account::Bitbucket:
account = new Bitbucket(username);
break;

View File

@ -5,6 +5,7 @@ add_library(
Beanstalk.cpp
Bitbucket.cpp
GitHub.cpp
Gitea.cpp
GitLab.cpp
Repository.cpp)
@ -12,6 +13,8 @@ target_link_libraries(host conf cred Qt5::Core Qt5::Gui Qt5::Network)
target_compile_definitions(
host PRIVATE GITHUB_CLIENT_ID="${GITHUB_CLIENT_ID}"
GITHUB_CLIENT_SECRET="${GITHUB_CLIENT_SECRET}")
GITHUB_CLIENT_SECRET="${GITHUB_CLIENT_SECRET}"
GITEA_CLIENT_ID="${GITEA_CLIENT_ID}"
GITEA_CLIENT_SECRET="${GITEA_CLIENT_SECRET}")
set_target_properties(host PROPERTIES AUTOMOC ON)

321
src/host/Gitea.cpp Normal file
View File

@ -0,0 +1,321 @@
//
// Copyright (c) 2016, Scientific Toolworks, Inc.
//
// This software is licensed under the MIT License. The LICENSE.md file
// describes the conditions under which this software may be distributed.
//
// Author: Jason Haslam
//
#include "Gitea.h"
#include "Repository.h"
#include <QCoreApplication>
#include <QDesktopServices>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QRandomGenerator>
#include <QRegularExpression>
#include <QUrl>
#include <QUrlQuery>
namespace {
const char *kPasswordProperty = "password";
const QString kScope = "repo";
const QString kAuthUrl =
QStringLiteral("https://try.gitea.io/login/oauth/authorize");
const QString kAccessUrl =
QStringLiteral("https://try.gitea.io/login/oauth/access_token");
const QString kGraphQlUrl = QStringLiteral("https://try.gitea.io/graphql");
} // namespace
Gitea::Gitea(const QString &username) : Account(username) {
QObject::connect(
mMgr, &QNetworkAccessManager::finished, this,
[this](QNetworkReply *reply) {
QString password = reply->property(kPasswordProperty).toString();
if (password.isEmpty())
return;
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
setErrorReply(*reply);
mProgress->finish();
return;
}
// Handle repositories.
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
QJsonArray array = doc.array();
for (int i = 0; i < array.size(); ++i) {
QJsonObject obj = array.at(i).toObject();
// Add username to HTTPS URL.
QUrl httpsUrl(obj.value("clone_url").toString());
httpsUrl.setUserName(this->username());
QString name = obj.value("name").toString();
QString fullName = obj.value("full_name").toString();
Repository *repo = addRepository(name, fullName);
repo->setUrl(Repository::Https, httpsUrl.toString());
repo->setUrl(Repository::Ssh, obj.value("ssh_url").toString());
}
// Check for additional pages.
QString link = reply->rawHeader("Link");
if (link.isEmpty()) {
mProgress->finish();
return;
}
QMap<QString, QString> map;
QRegularExpression re("<(.*)>; rel=\"(\\w+)\"");
foreach (const QString &record, link.split(", ")) {
QRegularExpressionMatch match = re.match(record);
if (match.isValid() && match.hasMatch())
map.insert(match.captured(2), match.captured(1));
}
QString next = map.value("next");
if (next.isEmpty()) {
mProgress->finish();
return;
}
// Request next page.
QNetworkRequest request(next);
if (setHeaders(request, password)) {
QNetworkReply *reply = mMgr->get(request);
reply->setProperty(kPasswordProperty, password);
startProgress();
}
});
}
Account::Kind Gitea::kind() const { return Account::Gitea; }
QString Gitea::name() const { return QStringLiteral("Gitea"); }
QString Gitea::host() const { return QStringLiteral("try.gitea.io"); }
void Gitea::connect(const QString &password) {
clearRepos();
QString suffix = hasCustomUrl() ? "/api/v1" : QString();
QNetworkRequest request(url() + suffix + "/user/repos");
if (setHeaders(request, password)) {
QNetworkReply *reply = mMgr->get(request);
reply->setProperty(kPasswordProperty,
!password.isEmpty() ? password : this->password());
startProgress();
}
}
void Gitea::requestForkParents(Repository *repo) {
QString query = QString("query {"
" repository(owner:\"%1\", name:\"%2\") {"
" isFork"
" parent {"
" isFork"
" nameWithOwner"
" defaultBranchRef {"
" name"
" }"
" parent {"
" isFork"
" nameWithOwner"
" defaultBranchRef {"
" name"
" }"
" parent{"
" isFork"
" nameWithOwner"
" defaultBranchRef {"
" name"
" }"
" }"
" }"
" }"
" }"
"}")
.arg(repo->owner(), repo->name());
graphql(query, [this](const QJsonObject &data) {
QMap<QString, QString> map;
QJsonObject repository = data.value("repository").toObject();
while (repository.value("isFork").toBool()) {
repository = repository.value("parent").toObject();
QString nameWithOwner = repository.value("nameWithOwner").toString();
QString branch = repository.value("defaultBranchRef")
.toObject()
.value("name")
.toString();
map.insert(nameWithOwner, branch);
}
emit forkParentsReady(map);
});
}
void Gitea::createPullRequest(Repository *repo, const QString &ownerRepo,
const QString &title, const QString &body,
const QString &head, const QString &base,
bool canModify) {
QJsonDocument doc;
doc.setObject({{"title", title},
{"body", body},
{"head", QString("%1:%2").arg(repo->owner(), head)},
{"base", base},
{"maintainer_can_modify", canModify}});
QUrl url(QString("https://try.gitea.io/repos/%1/pulls").arg(ownerRepo));
rest(url, doc, [this, title](const QJsonObject &obj) {
foreach (const QJsonValue &error, obj.value("errors").toArray())
emit pullRequestError(title,
error.toObject().value("message").toString());
});
}
void Gitea::requestComments(Repository *repo, const QString &oid) {
QString query = QString("query {"
" repository(owner: \"%1\", name: \"%2\") {"
" object(oid: \"%3\") {"
" ... on Commit {"
" comments(first: 50) {"
" nodes {"
" path"
" position"
" publishedAt"
" body"
" author {"
" login"
" }"
" }"
" }"
" }"
" }"
" }"
"}")
.arg(repo->owner(), repo->name(), oid);
graphql(query, [this, repo, oid](const QJsonObject &data) {
QJsonArray nodes = data.value("repository")
.toObject()
.value("object")
.toObject()
.value("comments")
.toObject()
.value("nodes")
.toArray();
if (nodes.isEmpty())
return;
CommitComments comments;
foreach (const QJsonValue &value, nodes) {
QJsonObject obj = value.toObject();
QString path = obj.value("path").toString();
int position = obj.value("position").toInt() - 1;
QString raw = obj.value("body").toString();
QString body = raw.trimmed().replace("\r\n", "\n");
QJsonObject author = obj.value("author").toObject();
QString login = author.value("login").toString();
QString published = obj["publishedAt"].toString();
QDateTime date = QDateTime::fromString(published, Qt::ISODate);
Comments &map =
path.isEmpty() ? comments.comments : comments.files[path][position];
map.insert(date, {body, login});
}
emit commentsReady(repo, oid, comments);
});
}
void Gitea::authorize() {
mState = QString();
for (int i = 0; i < 32; i++) {
int value = QRandomGenerator::global()->bounded('a', 'z' + 1);
mState.append(QChar::fromLatin1(value));
}
QUrlQuery query;
query.addQueryItem("client_id", GITEA_CLIENT_ID);
query.addQueryItem("scope", kScope);
query.addQueryItem("state", mState);
QUrl url(kAuthUrl);
url.setQuery(query);
// Open in default browser.
QDesktopServices::openUrl(url);
}
bool Gitea::isAuthorizeSupported() {
QByteArray id(GITEA_CLIENT_ID);
QByteArray secret(GITEA_CLIENT_SECRET);
QByteArray env = qgetenv("GITTYUP_OAUTH");
return (!id.isEmpty() && !secret.isEmpty() && !env.isEmpty());
}
QString Gitea::defaultUrl() {
return QStringLiteral("https://try.gitea.io");
}
void Gitea::graphql(const QString &query, const Callback &callback) {
if (mAccessToken.isEmpty())
return;
QJsonDocument doc;
doc.setObject({{"query", query}});
QNetworkRequest request(kGraphQlUrl);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Authorization",
QString("bearer %1").arg(mAccessToken).toUtf8());
QNetworkReply *reply = mMgr->post(request, doc.toJson());
QObject::connect(reply, &QNetworkReply::finished, [reply, callback] {
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
callback(doc.object().value("data").toObject());
reply->deleteLater();
});
}
void Gitea::rest(const QUrl &url, const QJsonDocument &doc,
const Callback &callback) {
if (mAccessToken.isEmpty())
return;
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Authorization",
QString("token %1").arg(mAccessToken).toUtf8());
QNetworkReply *reply;
if (doc.isEmpty()) {
reply = mMgr->get(request);
} else {
reply = mMgr->post(request, doc.toJson());
}
QObject::connect(reply, &QNetworkReply::finished, [reply, callback] {
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
callback(doc.object());
reply->deleteLater();
});
}

51
src/host/Gitea.h Normal file
View File

@ -0,0 +1,51 @@
//
// Copyright (c) 2016, Scientific Toolworks, Inc.
//
// This software is licensed under the MIT License. The LICENSE.md file
// describes the conditions under which this software may be distributed.
//
// Author: Jason Haslam
//
#ifndef GITEA_H
#define GITEA_H
#include "Account.h"
#include <QJsonDocument>
class Gitea : public Account {
Q_OBJECT
public:
Gitea(const QString &username);
Kind kind() const override;
QString name() const override;
QString host() const override;
void connect(const QString &password = QString()) override;
void requestForkParents(Repository *repo) override;
virtual void createPullRequest(Repository *repo, const QString &ownerRepo,
const QString &title, const QString &body,
const QString &head, const QString &base,
bool canModify) override;
void requestComments(Repository *repo, const QString &oid) override;
void authorize() override;
bool isAuthorizeSupported() override;
static QString defaultUrl();
private:
using Callback = std::function<void(const QJsonObject &)>;
void graphql(const QString &query, const Callback &callback);
void rest(const QUrl &url, const QJsonDocument &doc = QJsonDocument(),
const Callback &callback = Callback());
QString mState;
};
#endif