Skip to content
Merged

Dev #196

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.2",
"@seliseblocks/genesis-os": "^4.3.4",
"@seliseblocks/genesis-os": "^4.3.7",
"@tanstack/react-query": "^5.62.11",
"@tanstack/react-query-devtools": "^5.62.11",
"@tanstack/react-table": "^8.20.5",
Expand Down
48 changes: 41 additions & 7 deletions server/Api/Controllers/GithubController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Devops.DomainService.Shared.Interfaces;
using Devops.DomainService.VersionControlSystems.Interfaces;
using Devops.DomainService.VersionControlSystems.Models.Request;
using Devops.DomainService.VersionControlSystems.Models.Response;
using Devops.DomainService.VersionControlSystems.Services;
using Microsoft.AspNetCore.Mvc;
namespace Api.Controllers;
Expand Down Expand Up @@ -106,14 +107,47 @@ public async Task<ActionResult> GithubBranchExists([FromQuery] string repoId)
});
}

[HttpGet("clone")]
[ProtectedEndPoint("blocks-release::github::clone")]
public async Task<ActionResult> Clone([FromQuery] string repo)
/// <summary>
/// The calling user's git credential for HTTPS push/fetch — what
/// `blocks git push` and Studio's post-run push authenticate with.
/// 404 when GitHub isn't connected, so the CLI can distinguish "connect
/// GitHub" from a genuine failure. Never logged; the body is the token.
/// </summary>
[HttpGet("credential")]
[ProtectedEndPoint("blocks-release::github::credential")]
public async Task<ActionResult> GetCredential()
{
var result = await _githubService.Clone(repo);
if (result)
return Ok("Successfully cloned repo");
return BadRequest("Failed to clone repo");
var credential = await _githubService.GetPushCredential();
if (credential is null)
{
return NotFound(new BaseApiResponse
{
IsSuccess = false,
Message = "GitHub is not connected for this user, or the connection is no longer valid.",
StatusCode = HttpStatusCode.NotFound,
});
}

return Ok(credential);
}

/// <summary>Creates a GitHub repository for a project that has none yet — see <see cref="CreateRepositoryRequest"/>.</summary>
[HttpPost("repos")]
[ProtectedEndPoint("blocks-release::github::create-repo")]
public async Task<ActionResult> CreateRepo([FromBody] CreateRepositoryRequest request)
{
if (request is null || string.IsNullOrWhiteSpace(request.Name))
{
return BadRequest(new BaseApiResponse { IsSuccess = false, Message = "A repository name is required.", StatusCode = HttpStatusCode.BadRequest });
}

var (repo, error) = await _githubService.CreateRepository(request);
if (repo is null)
{
return BadRequest(new BaseApiResponse { IsSuccess = false, Message = error, StatusCode = HttpStatusCode.BadRequest });
}

return Ok(new BaseApiResponse { IsSuccess = true, Data = repo, StatusCode = HttpStatusCode.OK });
}

[HttpPost("webhook")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,18 @@ public interface IVersionControlService
public Task<BaseApiResponse> SearchUserRepositories(SearchRepositoryListRequest repoSearchQuery);
public Task<List<Branch>> GetBranches(string repo);
public Task<(bool, string)> GetRepoBranchByName(string repo, string branch);
public Task<bool> Clone(string repo);

/// <summary>
/// The calling Blocks user's git credential, or <c>null</c> when they have
/// not connected GitHub or the stored token no longer validates. See
/// <see cref="GitPushCredentialResponse"/> for what the caller owes it.
/// </summary>
public Task<GitPushCredentialResponse?> GetPushCredential();

/// <summary>
/// Creates a repository on GitHub for the calling user (or one of their
/// recorded organisations). Returns the repository, or an error message
/// naming why GitHub refused — a name collision is the common one.
/// </summary>
public Task<(GithubRepositoryResponse? repo, string? error)> CreateRepository(CreateRepositoryRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Devops.DomainService.VersionControlSystems.Models.Request;

/// <summary>
/// What a caller needs to say to get a brand-new GitHub repository for a
/// project that has none yet — the `blocks git init` case, where the code
/// exists locally (a Studio workspace, or a `blocks new web` scaffold) and
/// there is nowhere to push it.
/// </summary>
public class CreateRepositoryRequest
{
/// <summary>Repository name only — no owner. GitHub derives the slug.</summary>
public string Name { get; set; }

public string? Description { get; set; }

/// <summary>Defaults to private: generated app source is the owner's, not the world's.</summary>
public bool Private { get; set; } = true;

/// <summary>
/// Create under this organisation instead of the user's own account.
/// Must be one of the orgs recorded on the stored token, or the call is
/// refused before GitHub is contacted.
/// </summary>
public string? Organization { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace Devops.DomainService.VersionControlSystems.Models.Response;

/// <summary>
/// The credential a git client uses to authenticate an HTTPS push or fetch
/// against GitHub on behalf of the calling Blocks user.
/// <para>
/// This is the stored OAuth access token — the decision taken for Studio's
/// background runs was to push as the app owner, and an OAuth-app token is
/// the only credential this integration holds. Two consequences the caller
/// must respect: the token does not expire on its own, and its <c>repo</c>
/// scope reaches every repository the owner can write to, not just the one
/// being pushed. It is therefore handed to git through an askpass/credential
/// helper for the lifetime of one process only, and never written to
/// <c>.git/config</c>, a remote URL, or any file. A GitHub App with
/// installation tokens would remove both caveats; this shape leaves room for
/// that by carrying <see cref="ExpiresAt"/> already.
/// </para>
/// </summary>
public class GitPushCredentialResponse
{
/// <summary>Basic-auth username. GitHub accepts any value when the password is a token; this is the conventional one.</summary>
public string Username { get; set; } = "x-access-token";

public string Token { get; set; }

/// <summary>GitHub login of the account the token belongs to — for `user.name` and for telling the owner whose account is pushing.</summary>
public string Login { get; set; }

/// <summary>Null for an OAuth-app token, which never expires until revoked.</summary>
public DateTime? ExpiresAt { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,96 @@ public async Task<List<Branch>> GetBranches(string repo)
return (false, null);
}

public async Task<bool> Clone(string repo)
public async Task<GitPushCredentialResponse?> GetPushCredential()
{
var token = await _tokenRepository.getToken();
if (token is null || string.IsNullOrWhiteSpace(token.AccessToken))
{
return null;
}

return true;
// Validated on every call, not trusted from storage: a revoked token
// handed to a git client fails inside `git push` with a message the
// owner can't act on. Failing here instead lets the CLI say
// "reconnect GitHub" rather than "authentication failed".
if (!await ValidateAccessToken(token))
{
return null;
}

return new GitPushCredentialResponse
{
Token = token.AccessToken,
Login = token.UserName,
ExpiresAt = null,
};
}

public async Task<(GithubRepositoryResponse? repo, string? error)> CreateRepository(CreateRepositoryRequest request)
{
if (request is null || string.IsNullOrWhiteSpace(request.Name))
{
return (null, "A repository name is required.");
}

var token = await _tokenRepository.getToken();
if (token is null)
{
return (null, "GitHub is not connected for this user.");
}

// An org the token doesn't list is refused here rather than by
// GitHub, whose 404 for "no access to this org" is indistinguishable
// from "org doesn't exist".
string url;
if (!string.IsNullOrWhiteSpace(request.Organization))
{
var known = token.Organizations?.Any(o => string.Equals(o.OrgUserName, request.Organization, StringComparison.OrdinalIgnoreCase)) ?? false;
if (!known)
{
return (null, $"Organisation '{request.Organization}' is not one this GitHub account belongs to.");
}

url = $"{CloudBuildConstants.GITHUB_API_BASE_URI}/orgs/{request.Organization}/repos";
}
else
{
url = $"{CloudBuildConstants.GITHUB_API_BASE_URI}/user/repos";
}

var headers = new Dictionary<string, string>
{
{ "Accept", "application/vnd.github.v3+json" },
{ "User-Agent", "BlocksDevOps" },
{ "Authorization", $"Bearer {token.AccessToken}" },
};

// auto_init deliberately false: the caller already has the code and
// a first commit. A GitHub-made README would give the remote a
// history the local one doesn't share, and the very first push
// would be rejected as non-fast-forward.
var payload = new
{
name = request.Name,
description = request.Description ?? string.Empty,
@private = request.Private,
auto_init = false,
};

var (repo, response) = await _httpHelperServices.MakeHttpRequest<GithubRepositoryResponse>(
CloudBuildConstants.GITHUB_API_BASE_URI, url, HttpMethod.Post, payload, headers, null);

if (response.StatusCode == HttpStatusCode.Created && repo is not null)
{
return (repo, null);
}

return response.StatusCode switch
{
HttpStatusCode.UnprocessableEntity => (null, $"GitHub refused to create '{request.Name}' — a repository with that name probably already exists."),
HttpStatusCode.Unauthorized => (null, "GitHub rejected the stored token. Reconnect GitHub and try again."),
HttpStatusCode.Forbidden => (null, "The connected GitHub account is not allowed to create repositories here."),
_ => (null, $"GitHub returned {(int)response.StatusCode} while creating the repository."),
};
}
}
48 changes: 41 additions & 7 deletions server/XUnitTest/Api/Controllers/GithubControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,20 +108,54 @@ public async Task GithubBranchExists_RepoFound_ReturnsBranchResult()
body.IsSuccess.Should().BeTrue();
}

// ---- Clone ----
// ---- GetCredential ----

[Fact]
public async Task Clone_Success_ReturnsOk()
public async Task GetCredential_Connected_ReturnsOkWithCredential()
{
_github.Setup(g => g.Clone("repo")).ReturnsAsync(true);
(await CreateController().Clone("repo")).Should().BeOfType<OkObjectResult>();
_github.Setup(g => g.GetPushCredential()).ReturnsAsync(new GitPushCredentialResponse { Token = "tok", Login = "octo" });
var result = await CreateController().GetCredential();
result.Should().BeOfType<OkObjectResult>()
.Which.Value.Should().BeOfType<GitPushCredentialResponse>()
.Which.Token.Should().Be("tok");
}

[Fact]
public async Task Clone_Failure_ReturnsBadRequest()
public async Task GetCredential_NotConnected_ReturnsNotFound_SoTheCliCanSayReconnect()
{
_github.Setup(g => g.Clone("repo")).ReturnsAsync(false);
(await CreateController().Clone("repo")).Should().BeOfType<BadRequestObjectResult>();
_github.Setup(g => g.GetPushCredential()).ReturnsAsync((GitPushCredentialResponse)null);
(await CreateController().GetCredential()).Should().BeOfType<NotFoundObjectResult>();
}

// ---- CreateRepo ----

[Fact]
public async Task CreateRepo_NoName_ReturnsBadRequest_WithoutCallingGithub()
{
(await CreateController().CreateRepo(new CreateRepositoryRequest { Name = "" })).Should().BeOfType<BadRequestObjectResult>();
_github.Verify(g => g.CreateRepository(It.IsAny<CreateRepositoryRequest>()), Times.Never);
}

[Fact]
public async Task CreateRepo_Created_ReturnsOkWithRepo()
{
_github.Setup(g => g.CreateRepository(It.IsAny<CreateRepositoryRequest>()))
.ReturnsAsync((new GithubRepositoryResponse { fullName = "octo/app" }, (string)null));
var result = await CreateController().CreateRepo(new CreateRepositoryRequest { Name = "app" });
var body = result.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeOfType<BaseApiResponse>().Which;
body.IsSuccess.Should().BeTrue();
body.Data.Should().BeOfType<GithubRepositoryResponse>().Which.fullName.Should().Be("octo/app");
}

[Fact]
public async Task CreateRepo_GithubRefused_ReturnsBadRequestWithTheReason()
{
_github.Setup(g => g.CreateRepository(It.IsAny<CreateRepositoryRequest>()))
.ReturnsAsync(((GithubRepositoryResponse)null, "already exists"));
var result = await CreateController().CreateRepo(new CreateRepositoryRequest { Name = "app" });
result.Should().BeOfType<BadRequestObjectResult>()
.Which.Value.Should().BeOfType<BaseApiResponse>()
.Which.Message.Should().Contain("already exists");
}

// ---- CreateWebhook ----
Expand Down
Loading
Loading