Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Get-ChildItem tests -Recurse -Filter *.UnitTests.csproj | ForEach-Object { dotne

### Release

Publishing a GitHub release tagged like `v1.2.3` runs the tests, then builds a signed, self-contained x64 MSIX and attaches it to the release together with `Kontext.cer`, the public certificate. The tag sets the package version. ARM64 is not built because the embedded terminal control only ships x64 binaries.
Publishing a GitHub release tagged like `v1.2.3` runs the tests, then builds a signed, self-contained x64 MSIX and attaches it to the release together with `Kontext.cer`, the public certificate. The tag sets the package version. The workflow imports the certificate into the runner's certificate store and signs by thumbprint, which avoids the APPX0105 and APPX0107 warnings that come from pointing MSBuild at the `.pfx`. The signature is timestamped (DigiCert), so the package keeps verifying after the certificate expires. ARM64 is not built because the embedded terminal control only ships x64 binaries.

One-time setup:

Expand Down
33 changes: 18 additions & 15 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ jobs:
runs-on: windows-latest
strategy:
matrix:
# ARM64 is not built yet: EasyWindowsTerminalControl.WinUI only ships x64 binaries.
platform: [x64]
include:
- platform: x64
Expand Down Expand Up @@ -46,7 +45,6 @@ jobs:
env:
VERSION: ${{ steps.version.outputs.VERSION }}
run: |
# Only the package identity carries the app version; MinVersion and MaxVersionTested must stay as they are.
$manifest = "src\Kontext\Package.appxmanifest"
$pattern = '(<Identity\b[^>]*?\sVersion=")\d+\.\d+\.\d+\.\d+(")'
$content = Get-Content $manifest -Raw
Expand Down Expand Up @@ -77,7 +75,6 @@ jobs:
env:
CLIENT_ID: ${{ vars.KONTEXT_GITHUB_CLIENT_ID }}
run: |
# An installed MSIX is read-only, so users cannot add appsettings.local.json: the id must be built in.
if ([string]::IsNullOrWhiteSpace($env:CLIENT_ID)) {
Write-Error "Set the KONTEXT_GITHUB_CLIENT_ID repository variable; without it the released app cannot sign in."
exit 1
Expand All @@ -87,18 +84,22 @@ jobs:
$settings.GitHub.ClientId = $env:CLIENT_ID
$settings | ConvertTo-Json -Depth 5 | Set-Content $path

- name: Decode signing certificate
- name: Install signing certificate
id: certificate
shell: pwsh
env:
CERTIFICATE_BASE64: ${{ secrets.CERTIFICATE_BASE64 }}
CERT_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
run: |
$bytes = [Convert]::FromBase64String($env:CERTIFICATE_BASE64)
[IO.File]::WriteAllBytes("${{ github.workspace }}\cert.pfx", $bytes)
$pfx = "${{ github.workspace }}\cert.pfx"
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:CERTIFICATE_BASE64))
$securePassword = ConvertTo-SecureString -String $env:CERT_PASSWORD -AsPlainText -Force
$cert = Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $securePassword
Remove-Item -Path $pfx -Force
"THUMBPRINT=$($cert.Thumbprint)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append

- name: Build and Package MSIX
shell: pwsh
env:
CERT_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
run: |
dotnet publish src\Kontext\Kontext.csproj `
-c Release `
Expand All @@ -108,24 +109,26 @@ jobs:
-p:GenerateAppxPackageOnBuild=true `
-p:AppxPackageDir="${{ github.workspace }}\AppxPackages\" `
-p:AppxPackageSigningEnabled=true `
-p:PackageCertificateKeyFile="${{ github.workspace }}\cert.pfx" `
-p:PackageCertificatePassword=$env:CERT_PASSWORD `
-p:PackageCertificateThumbprint=${{ steps.certificate.outputs.THUMBPRINT }} `
-p:AppxPackageSigningTimestampDigestAlgorithm=SHA256 `
-p:AppxPackageSigningTimestampServerUrl=http://timestamp.digicert.com `
-p:AppxBundle=Never

- name: Export public certificate
if: matrix.platform == 'x64'
shell: pwsh
env:
CERT_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
run: |
$securePassword = ConvertTo-SecureString -String $env:CERT_PASSWORD -AsPlainText -Force
$cert = Get-PfxCertificate -FilePath "${{ github.workspace }}\cert.pfx" -Password $securePassword
$cert = Get-Item "Cert:\CurrentUser\My\${{ steps.certificate.outputs.THUMBPRINT }}"
Export-Certificate -Cert $cert -FilePath "${{ github.workspace }}\Kontext.cer" | Out-Null

- name: Remove signing certificate
if: always()
shell: pwsh
run: Remove-Item -Path "${{ github.workspace }}\cert.pfx" -Force -ErrorAction SilentlyContinue
run: |
Remove-Item -Path "${{ github.workspace }}\cert.pfx" -Force -ErrorAction SilentlyContinue
if ("${{ steps.certificate.outputs.THUMBPRINT }}") {
Remove-Item -Path "Cert:\CurrentUser\My\${{ steps.certificate.outputs.THUMBPRINT }}" -Force -ErrorAction SilentlyContinue
}

- name: Rename MSIX with platform suffix
shell: pwsh
Expand Down
17 changes: 16 additions & 1 deletion src/Kontext.Claude/ClaudeActivityReporter.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
using Kontext.Contracts;
using Kontext.Domain;
using Kontext.Kernel;

namespace Kontext.Claude;

internal sealed class ClaudeActivityReporter(IIssueSessionRegistry registry, IIssueWorkTracker tracker, IUiDispatcher ui)
internal sealed class ClaudeActivityReporter(IIssueSessionRegistry registry, IIssueWorkTracker tracker, ISessionStore sessions, IUiDispatcher ui)
{
public void OnHook(ClaudeHookEvent hook)
{
Expand All @@ -19,6 +20,8 @@ private void Apply(ClaudeHookEvent hook)
return;
}

RememberSession(issue, hook.ClaudeSession);

var state = tracker.StateOf(issue);

if (state.SessionEnded)
Expand All @@ -31,4 +34,16 @@ private void Apply(ClaudeHookEvent hook)
tracker.Report(issue, state with { Status = next });
}
}

// Resuming a conversation gives Claude a new session id with the history copied into it. Keeping the id the
// launch used would bring the next resume back to the copy as it was, losing everything said since.
private void RememberSession(IssueKey issue, Guid? claudeSession)
{
if (claudeSession is not { } id || sessions.Find(issue) is not { } stored || stored.SessionId.Value == id)
{
return;
}

sessions.Save(stored with { SessionId = new TerminalSessionId(id) });
}
}
4 changes: 3 additions & 1 deletion src/Kontext.Claude/ClaudeHookEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace Kontext.Claude;

internal sealed record ClaudeHookEvent(TerminalSessionId Session, string Name, string? NotificationType = null);
/// <param name="Session">The id Kontext gave the terminal session; it is part of the hook address.</param>
/// <param name="ClaudeSession">The id Claude itself reports. Resuming gives a conversation a new id, so it can differ from the one it was started with.</param>
internal sealed record ClaudeHookEvent(TerminalSessionId Session, string Name, string? NotificationType = null, Guid? ClaudeSession = null);

internal sealed class ClaudeHookEventArgs(ClaudeHookEvent hook) : EventArgs
{
Expand Down
4 changes: 2 additions & 2 deletions src/Kontext.Claude/ClaudeHookPayload.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace Kontext.Claude;

internal static class ClaudeHookPayload
{
public static (string Name, string? NotificationType)? Parse(string json)
public static (string Name, string? NotificationType, string? SessionId)? Parse(string json)
{
try
{
Expand All @@ -15,7 +15,7 @@ public static (string Name, string? NotificationType)? Parse(string json)
}

var name = StringProperty(document.RootElement, "hook_event_name");
return string.IsNullOrWhiteSpace(name) ? null : (name, StringProperty(document.RootElement, "notification_type"));
return string.IsNullOrWhiteSpace(name) ? null : (name, StringProperty(document.RootElement, "notification_type"), StringProperty(document.RootElement, "session_id"));
}
catch (JsonException)
{
Expand Down
4 changes: 2 additions & 2 deletions src/Kontext.Claude/LoopbackClaudeHookServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ private async Task HandleAsync(HttpListenerContext context)
var session = SessionIn(context.Request.Url);
Respond(context);

if (session is { } id && ClaudeHookPayload.Parse(body) is var (name, notificationType))
if (session is { } id && ClaudeHookPayload.Parse(body) is var (name, notificationType, claudeSession))
{
EventReceived?.Invoke(this, new ClaudeHookEventArgs(new ClaudeHookEvent(id, name, notificationType)));
EventReceived?.Invoke(this, new ClaudeHookEventArgs(new ClaudeHookEvent(id, name, notificationType, Guid.TryParse(claudeSession, CultureInfo.InvariantCulture, out var guid) ? guid : null)));
}
}

Expand Down
9 changes: 9 additions & 0 deletions src/Kontext.Contracts/ICollapsedRepositoryStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Kontext.Contracts;

/// <summary>Remembers which repository groups the user collapsed in the issue menu, by <c>RepositoryRef.DisplayName</c>.</summary>
public interface ICollapsedRepositoryStore
{
IReadOnlySet<string> Load();

void Save(IReadOnlySet<string> collapsed);
}
10 changes: 10 additions & 0 deletions src/Kontext.Contracts/IWorkspaceChanges.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Kontext.Domain;

namespace Kontext.Contracts;

public interface IWorkspaceChanges
{
Task<WorkingTreeChanges> GetChangesAsync(string worktreePath, CancellationToken cancellationToken = default);

Task<FileDiff> GetDiffAsync(string worktreePath, FileChange change, CancellationToken cancellationToken = default);
}
23 changes: 23 additions & 0 deletions src/Kontext.Domain/FileChange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Kontext.Domain;

public enum FileChangeStatus
{
Modified,
Added,
Deleted,
Renamed,
Untracked,
Conflicted,
}

/// <summary>One changed file. Paths are relative to the worktree root and use '/' as git prints them.</summary>
/// <param name="Additions">Null when unknown (binary or oversized files).</param>
/// <param name="Deletions">Null when unknown (binary or oversized files).</param>
public sealed record FileChange(
string Path,
string? OriginalPath,
FileChangeStatus Status,
bool IsStaged,
int? Additions,
int? Deletions,
bool IsBinary);
32 changes: 32 additions & 0 deletions src/Kontext.Domain/FileDiff.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Kontext.Domain;

public enum DiffLineKind
{
Context,
Added,
Removed,
HunkHeader,
Note,
}

public sealed record DiffLine(DiffLineKind Kind, int? OldNumber, int? NewNumber, string Text);

/// <summary>The diff of one file. <paramref name="Notice"/> explains a missing or cut-off diff (binary, too large, conflict).</summary>
public sealed record FileDiff(string Path, IReadOnlyList<DiffLine> Lines, string? Notice = null)
{
public bool Equals(FileDiff? other)
=> other is not null && Path == other.Path && Notice == other.Notice && Lines.SequenceEqual(other.Lines);

public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(Path);
hash.Add(Notice);
foreach (var line in Lines)
{
hash.Add(line);
}

return hash.ToHashCode();
}
}
26 changes: 26 additions & 0 deletions src/Kontext.Domain/WorkingTreeChanges.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Kontext.Domain;

public sealed record WorkingTreeChanges(IReadOnlyList<FileChange> Staged, IReadOnlyList<FileChange> Unstaged)
{
public static WorkingTreeChanges Empty { get; } = new([], []);

public bool Equals(WorkingTreeChanges? other)
=> other is not null && Staged.SequenceEqual(other.Staged) && Unstaged.SequenceEqual(other.Unstaged);

public override int GetHashCode()
{
var hash = new HashCode();
foreach (var change in Staged)
{
hash.Add(change);
}

hash.Add(int.MinValue);
foreach (var change in Unstaged)
{
hash.Add(change);
}

return hash.ToHashCode();
}
}
47 changes: 47 additions & 0 deletions src/Kontext.GitAutomation/NumstatParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Globalization;

namespace Kontext.GitAutomation;

/// <summary>Line counts of one file; both null when git reports the file as binary.</summary>
internal readonly record struct NumstatEntry(int? Additions, int? Deletions);

internal static class NumstatParser
{
/// <summary>Parses <c>git diff --numstat -z</c>. The result is keyed by the (new) path.</summary>
public static IReadOnlyDictionary<string, NumstatEntry> Parse(string output)
{
ArgumentNullException.ThrowIfNull(output);

var counts = new Dictionary<string, NumstatEntry>(StringComparer.Ordinal);
var records = output.Split('\0');

for (var i = 0; i < records.Length; i++)
{
var parts = records[i].Split('\t', 3);
if (parts.Length < 3)
{
continue;
}

var path = parts[2];
if (path.Length == 0)
{
// A rename: the old and new paths follow as two separate records.
if (i + 2 >= records.Length)
{
break;
}

path = records[i + 2];
i += 2;
}

counts[path] = new NumstatEntry(Count(parts[0]), Count(parts[1]));
}

return counts;
}

private static int? Count(string text)
=> int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var value) ? value : null;
}
1 change: 1 addition & 0 deletions src/Kontext.GitAutomation/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public static IServiceCollection AddKontextGitAutomation(this IServiceCollection
services.AddSingleton<IBranchNamer, BranchNamer>();
services.AddSingleton<IWorktreePathProvider, WorktreePathProvider>();
services.AddSingleton<IGitWorktreeService, GitWorktreeService>();
services.AddSingleton<IWorkspaceChanges, WorkspaceChanges>();
services.AddSingleton<IGitRepositoryInspector, GitRepositoryInspector>();
services.AddSingleton<IRepositoryCloneStore, JsonRepositoryCloneStore>();
services.AddSingleton<IRepositoryCloner, RepositoryCloner>();
Expand Down
60 changes: 60 additions & 0 deletions src/Kontext.GitAutomation/StatusPorcelainV2Parser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
namespace Kontext.GitAutomation;

/// <summary>One record of <c>git status --porcelain=v2 -z</c>. <see cref="Index"/> and <see cref="Worktree"/> are the XY characters; '.' means unchanged.</summary>
internal sealed record StatusEntry(string Path, string? OriginalPath, char Index, char Worktree, bool IsUntracked, bool IsConflicted);

internal static class StatusPorcelainV2Parser
{
// Number of space-separated fields before the path (the path itself may contain spaces).
private const int OrdinaryFields = 9;
private const int RenamedFields = 10;
private const int UnmergedFields = 11;

public static IReadOnlyList<StatusEntry> Parse(string output)
{
ArgumentNullException.ThrowIfNull(output);

var records = output.Split('\0');
var entries = new List<StatusEntry>();

for (var i = 0; i < records.Length; i++)
{
var record = records[i];
if (record.Length < 2)
{
continue;
}

switch (record[0])
{
case '1':
AddWithPath(entries, record, OrdinaryFields, originalPath: null, conflicted: false);
break;
case '2':
// A rename or copy is followed by its original path as a separate NUL-terminated record.
var original = i + 1 < records.Length ? records[++i] : null;
AddWithPath(entries, record, RenamedFields, original, conflicted: false);
break;
case 'u':
AddWithPath(entries, record, UnmergedFields, originalPath: null, conflicted: true);
break;
case '?':
entries.Add(new StatusEntry(record[2..], null, '.', '?', IsUntracked: true, IsConflicted: false));
break;
}
}

return entries;
}

private static void AddWithPath(List<StatusEntry> entries, string record, int fieldCount, string? originalPath, bool conflicted)
{
var fields = record.Split(' ', fieldCount);
if (fields.Length < fieldCount || fields[1].Length != 2)
{
return;
}

entries.Add(new StatusEntry(fields[^1], originalPath, fields[1][0], fields[1][1], IsUntracked: false, conflicted));
}
}
Loading