wip
This commit is contained in:
commit
03ebf47b9f
33 changed files with 1657 additions and 0 deletions
136
src/core/Commands/Artifact.cs
Normal file
136
src/core/Commands/Artifact.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using System.CommandLine;
|
||||
using System.IO.Compression;
|
||||
|
||||
using Geekeey.Core.Properties;
|
||||
|
||||
using Microsoft.Extensions.FileSystemGlobbing;
|
||||
|
||||
namespace Geekeey.Core.Commands;
|
||||
|
||||
internal sealed class Artifact : Command
|
||||
{
|
||||
public Artifact() : base("artifact", Resources.ArtifactCommandDescription)
|
||||
{
|
||||
Add(new Pull());
|
||||
Add(new Push());
|
||||
}
|
||||
|
||||
private static async Task CreateArtifactFromDirectoryAsync(Stream stream, DirectoryInfo directory, Matcher matcher, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
|
||||
|
||||
var files = matcher.GetResultsInFullPath(directory.FullName);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var path = Path.GetRelativePath(directory.FullName, file);
|
||||
var name = path.Replace(Path.DirectorySeparatorChar, '/');
|
||||
|
||||
var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
|
||||
entry.LastWriteTime = File.GetLastWriteTime(file);
|
||||
|
||||
await using var zos = await entry.OpenAsync(cancellationToken);
|
||||
await using var fis = File.OpenRead(file);
|
||||
await fis.CopyToAsync(zos, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ExtractArtifactToDirectoryAsync(Stream stream, DirectoryInfo directory, Matcher matcher, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
|
||||
var entries = archive.Entries.Where(entry => matcher.Match(entry.FullName).HasMatches);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var path = Path.Combine(directory.FullName, entry.FullName);
|
||||
var file = new FileInfo(path);
|
||||
|
||||
file.Directory?.Create();
|
||||
|
||||
await using var zis = await entry.OpenAsync(cancellationToken);
|
||||
await using var fos = File.Create(file.FullName);
|
||||
await zis.CopyToAsync(fos, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Pull : Command
|
||||
{
|
||||
private static readonly Option<string> Name = new("--name")
|
||||
{
|
||||
Required = true
|
||||
};
|
||||
|
||||
private static readonly Option<Matcher?> Pattern = new("--pattern")
|
||||
{
|
||||
CustomParser = Parsers.Matcher
|
||||
};
|
||||
|
||||
private static readonly Option<int?> RunId = new("--run-id")
|
||||
{
|
||||
Required = false
|
||||
};
|
||||
|
||||
public Pull() : base("pull")
|
||||
{
|
||||
Add(Name);
|
||||
Add(Pattern);
|
||||
Add(RunId);
|
||||
SetAction(HandleAsync);
|
||||
}
|
||||
|
||||
private Task<int> HandleAsync(ParseResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
// Implementation for checkout command goes here.
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Push : Command
|
||||
{
|
||||
private static readonly Option<string> Name = new("--name")
|
||||
{
|
||||
Required = true
|
||||
};
|
||||
|
||||
private static readonly Option<Matcher?> Pattern = new("--pattern")
|
||||
{
|
||||
CustomParser = Parsers.Matcher
|
||||
};
|
||||
|
||||
private static readonly Option<bool> IncludeHidden = new("--include-hidden")
|
||||
{
|
||||
Required = false
|
||||
};
|
||||
|
||||
private static readonly Option<int> RetentionDays = new("--retention-days")
|
||||
{
|
||||
Required = false
|
||||
};
|
||||
|
||||
private static readonly Option<bool> Overwrite = new("--overwrite")
|
||||
{
|
||||
Required = false
|
||||
};
|
||||
|
||||
public Push() : base("push")
|
||||
{
|
||||
Add(Name);
|
||||
Add(Pattern);
|
||||
Add(IncludeHidden);
|
||||
Add(RetentionDays);
|
||||
Add(Overwrite);
|
||||
SetAction(HandleAsync);
|
||||
}
|
||||
|
||||
private Task<int> HandleAsync(ParseResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
// Implementation for checkout command goes here.
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/core/Commands/Checkout.cs
Normal file
53
src/core/Commands/Checkout.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using System.CommandLine;
|
||||
using System.Text;
|
||||
using Geekeey.Core.Properties;
|
||||
|
||||
namespace Geekeey.Core.Commands;
|
||||
|
||||
internal sealed class Checkout : Command
|
||||
{
|
||||
private static readonly Option<Uri> Repository = new("--repository") { Required = true, CustomParser = Parsers.Uri };
|
||||
private static readonly Option<DirectoryInfo> Destination = new("--path") { Required = true };
|
||||
private static readonly Option<string> Reference = new("--reference") { Required = true };
|
||||
private static readonly Option<bool> TreeLessClone = new("--tree-less-clone") { Description = "Use tree-less (blob-less) clone to reduce bandwidth" };
|
||||
|
||||
public Checkout() : base("checkout", Resources.CheckoutCommandDescription)
|
||||
{
|
||||
Add(Repository);
|
||||
Add(Destination);
|
||||
Add(Reference);
|
||||
Add(TreeLessClone);
|
||||
SetAction(HandleAsync);
|
||||
}
|
||||
|
||||
private async Task<int> HandleAsync(ParseResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
var server = result.GetRequiredValue(Program.Server);
|
||||
var access = result.GetRequiredValue(Program.Token);
|
||||
|
||||
// relative repository urls are resolved against the server url
|
||||
var repository = result.GetRequiredValue(Repository);
|
||||
if (!repository.IsAbsoluteUri)
|
||||
{
|
||||
repository = new Uri(new Uri(server), repository);
|
||||
}
|
||||
|
||||
var workspace = result.GetRequiredValue(Destination);
|
||||
|
||||
await $"git init -q {workspace.FullName}";
|
||||
await $"git -C {workspace.FullName} config set --local protocol.version 2";
|
||||
await $"git -C {workspace.FullName} config set --local gc.auto 0";
|
||||
|
||||
await $"git -C {workspace.FullName} config set --local --append url.{repository}.insteadOf git@{repository.Host}";
|
||||
await $"git -C {workspace.FullName} config set --local --append url.{repository}.insteadOf ssh://git@{repository.Host}";
|
||||
await $"git -C {workspace.FullName} config set --local --append url.{repository}.insteadOf git://{repository.Host}";
|
||||
|
||||
var header = $"Authorization: Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes(access))}";
|
||||
await $"git -C {workspace.FullName} config set --local http.{repository}.extraheader '{header}'";
|
||||
|
||||
await $"git -C {workspace.FullName} remote add origin {repository}";
|
||||
await $"git -C {workspace.FullName} ls-remote origin {result.GetRequiredValue(Reference)}";
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
57
src/core/Commands/Release.cs
Normal file
57
src/core/Commands/Release.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using System.CommandLine;
|
||||
using Geekeey.Core.Properties;
|
||||
using Microsoft.Extensions.FileSystemGlobbing;
|
||||
|
||||
namespace Geekeey.Core.Commands;
|
||||
|
||||
internal sealed class Release : Command
|
||||
{
|
||||
private static readonly Option<Uri> Repository = new("--repository") { Required = true, CustomParser = Parsers.Uri };
|
||||
private static readonly Option<string> Version = new("--version") { Required = true };
|
||||
|
||||
private static readonly Option<Pattern> Draft = new("--draft") { CustomParser = Parsers.Pattern };
|
||||
private static readonly Option<Pattern> PreRelease = new("--prerelease") { CustomParser = Parsers.Pattern };
|
||||
|
||||
private static readonly Option<string> Title = new("--title");
|
||||
private static readonly Option<string> Notes = new("--notes");
|
||||
private static readonly Option<Matcher> Attachments = new("--attachments") { CustomParser = Parsers.Matcher };
|
||||
|
||||
public Release() : base("release", Resources.ReleaseCommandDescription)
|
||||
{
|
||||
Add(Repository);
|
||||
Add(Version);
|
||||
|
||||
Add(Draft);
|
||||
Add(PreRelease);
|
||||
|
||||
Add(Title);
|
||||
Add(Notes);
|
||||
Add(Attachments);
|
||||
|
||||
SetAction(HandleAsync);
|
||||
}
|
||||
|
||||
private Task<int> HandleAsync(ParseResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
var server = result.GetRequiredValue(Program.Server);
|
||||
var access = result.GetRequiredValue(Program.Token);
|
||||
|
||||
// relative repository urls are resolved against the server url
|
||||
var repository = result.GetRequiredValue(Repository);
|
||||
if (!repository.IsAbsoluteUri)
|
||||
{
|
||||
repository = new Uri(new Uri(server), repository);
|
||||
}
|
||||
|
||||
var version = result.GetRequiredValue(Version);
|
||||
var draft = result.GetValue(Draft);
|
||||
var prerelease = result.GetValue(PreRelease);
|
||||
|
||||
var title = result.GetValue(Title);
|
||||
var notes = result.GetValue(Notes);
|
||||
var attachments = result.GetValue(Attachments);
|
||||
|
||||
// Implementation for checkout command goes here.
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
14
src/core/GitHubEnvironmentContext.cs
Normal file
14
src/core/GitHubEnvironmentContext.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to GitHub Actions environment context from environment variables.
|
||||
/// </summary>
|
||||
internal sealed class GitHubEnvironmentContext : IGitHubEnvironmentContext
|
||||
{
|
||||
public string? BaseRef => Environment.GetEnvironmentVariable("GITHUB_BASE_REF");
|
||||
|
||||
public string? HeadRef => Environment.GetEnvironmentVariable("GITHUB_HEAD_REF");
|
||||
}
|
||||
22
src/core/IGitHubEnvironmentContext.cs
Normal file
22
src/core/IGitHubEnvironmentContext.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to GitHub Actions environment context.
|
||||
/// </summary>
|
||||
public interface IGitHubEnvironmentContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the base reference for pull requests.
|
||||
/// Corresponds to the GITHUB_BASE_REF environment variable.
|
||||
/// </summary>
|
||||
string? BaseRef { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the head reference for pull requests.
|
||||
/// Corresponds to the GITHUB_HEAD_REF environment variable.
|
||||
/// </summary>
|
||||
string? HeadRef { get; }
|
||||
}
|
||||
101
src/core/Parsers.cs
Normal file
101
src/core/Parsers.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using System.CommandLine.Parsing;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.FileSystemGlobbing;
|
||||
|
||||
namespace Geekeey.Core;
|
||||
|
||||
internal static class Parsers
|
||||
{
|
||||
public static Uri? Uri(ArgumentResult result)
|
||||
{
|
||||
if (!result.Tokens.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!System.Uri.TryCreate(result.Tokens.Single().Value, UriKind.RelativeOrAbsolute, out var uri))
|
||||
{
|
||||
result.AddError("Not a valid URI.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
public static Pattern? Pattern(ArgumentResult result)
|
||||
{
|
||||
if (!result.Tokens.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bool.TryParse(result.Tokens.Single().Value, out var value))
|
||||
{
|
||||
return new Pattern(value);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var regex = new Regex(result.Tokens.Single().Value, RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
return new Pattern(regex);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
result.AddError("Not a valid boolean or regex pattern.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static Matcher? Matcher(ArgumentResult result)
|
||||
{
|
||||
if (!result.Tokens.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var matcher = new Matcher();
|
||||
|
||||
foreach (var token in result.Tokens)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (token.Value.StartsWith('!'))
|
||||
{
|
||||
matcher.AddExclude(token.Value[1..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
matcher.AddInclude(token.Value);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
result.AddError($"Not a valid glob pattern: {token.Value}. {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return matcher;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Pattern
|
||||
{
|
||||
private readonly bool _value;
|
||||
private readonly Regex? _pattern;
|
||||
|
||||
public Pattern(bool value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public Pattern(Regex pattern)
|
||||
{
|
||||
_pattern = pattern;
|
||||
}
|
||||
|
||||
public bool Match(string version)
|
||||
{
|
||||
return _pattern?.IsMatch(version) ?? _value;
|
||||
}
|
||||
}
|
||||
26
src/core/Program.cs
Normal file
26
src/core/Program.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
using System.CommandLine;
|
||||
using Geekeey.Core.Commands;
|
||||
|
||||
namespace Geekeey.Core;
|
||||
|
||||
internal sealed class Program : RootCommand
|
||||
{
|
||||
public static readonly Option<string> Server = new("--server") { Required = true, Recursive = true };
|
||||
public static readonly Option<string> Token = new("--token") { Required = true, Recursive = true };
|
||||
|
||||
private Program()
|
||||
{
|
||||
Add(Server);
|
||||
Add(Token);
|
||||
|
||||
Add(new Checkout());
|
||||
Add(new Release());
|
||||
Add(new Artifact());
|
||||
}
|
||||
|
||||
public static Task<int> Main(string[] args)
|
||||
{
|
||||
var program = new Program();
|
||||
return program.Parse(args).InvokeAsync();
|
||||
}
|
||||
}
|
||||
35
src/core/Properties/Resources.resx
Normal file
35
src/core/Properties/Resources.resx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<data name="ArtifactCommandDescription" xml:space="preserve">
|
||||
<value>X</value>
|
||||
</data>
|
||||
<data name="CheckoutCommandDescription" xml:space="preserve">
|
||||
<value>Checkout a specific version or branch of a git repository.</value>
|
||||
</data>
|
||||
<data name="ReleaseCommandDescription" xml:space="preserve">
|
||||
<value>Release a version to the release page on the repository.</value>
|
||||
</data>
|
||||
</root>
|
||||
90
src/core/Shellify.cs
Normal file
90
src/core/Shellify.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
internal static class Shellify
|
||||
{
|
||||
[Flags]
|
||||
private enum ShellSplitState
|
||||
{
|
||||
None = 0,
|
||||
InSingleQuote = 1 << 0,
|
||||
InDoubleQuote = 1 << 1,
|
||||
Escape = 1 << 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a shell-escaped string into arguments, handling quotes and escapes.
|
||||
/// </summary>
|
||||
public static List<string> ShellSplit(string input)
|
||||
{
|
||||
var args = new List<string>();
|
||||
if (string.IsNullOrEmpty(input)) return args;
|
||||
var current = new System.Text.StringBuilder();
|
||||
var state = ShellSplitState.None;
|
||||
foreach (char c in input)
|
||||
{
|
||||
if (state.HasFlag(ShellSplitState.Escape))
|
||||
{
|
||||
current.Append(c);
|
||||
state &= ~ShellSplitState.Escape;
|
||||
}
|
||||
else if (c == '\\')
|
||||
{
|
||||
state |= ShellSplitState.Escape;
|
||||
}
|
||||
else if (c == '\'' && !state.HasFlag(ShellSplitState.InDoubleQuote))
|
||||
{
|
||||
state ^= ShellSplitState.InSingleQuote;
|
||||
}
|
||||
else if (c == '"' && !state.HasFlag(ShellSplitState.InSingleQuote))
|
||||
{
|
||||
state ^= ShellSplitState.InDoubleQuote;
|
||||
}
|
||||
else if (char.IsWhiteSpace(c) && !state.HasFlag(ShellSplitState.InSingleQuote) && !state.HasFlag(ShellSplitState.InDoubleQuote))
|
||||
{
|
||||
if (current.Length > 0)
|
||||
{
|
||||
args.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
args.Add(current.ToString());
|
||||
return args;
|
||||
}
|
||||
|
||||
public static TaskAwaiter<string> GetAwaiter(this string command)
|
||||
{
|
||||
return RunProcessAsync(command).GetAwaiter();
|
||||
}
|
||||
|
||||
private static async Task<string> RunProcessAsync(string command)
|
||||
{
|
||||
var strings = ShellSplit(command);
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = strings.First(),
|
||||
ArgumentList =
|
||||
{
|
||||
strings.Skip(1)
|
||||
},
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using var process = Process.Start(psi);
|
||||
string output = await process.StandardOutput.ReadToEndAsync();
|
||||
string error = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
if (process.ExitCode != 0)
|
||||
throw new System.Exception($"Process exited with code {process.ExitCode}: {error}");
|
||||
return output;
|
||||
}
|
||||
}
|
||||
35
src/core/core.csproj
Normal file
35
src/core/core.csproj
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<RootNamespace>Geekeey.Core</RootNamespace>
|
||||
|
||||
<PublishAot>true</PublishAot>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
|
||||
<EnableSdkContainerSupport>true</EnableSdkContainerSupport>
|
||||
<ContainerFamily>alpine</ContainerFamily>
|
||||
<ContainerRuntimeIdentifiers>linux-x64;linux-arm64</ContainerRuntimeIdentifiers>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<AnalysisMode>Recommended</AnalysisMode>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Commands\Checkout.messages.resx" ClassName="Checkout">
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Loading…
Add table
Add a link
Reference in a new issue