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
84 changes: 84 additions & 0 deletions InterlinedList/Models/GitHubBackedListCreated.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System.Text.Json;

namespace InterlinedList.Models;

/// <summary>
/// What <c>POST /api/lists</c> answers when <c>source: "github"</c> —
/// <c>{ message, data, refreshStatus }</c>, where <c>refreshStatus</c> is
/// documented as "present only for GitHub-backed lists".
///
/// <para>
/// <b>The GitHub-backed create was deliberately never executed.</b> Creating one
/// wires a real GitHub repository to a real list on shared test
/// infrastructure, so this app's create path is built and left unexercised. What
/// <em>was</em> verified live on 2026-09-16, without creating anything:
/// </para>
/// <list type="bullet">
/// <item><description><c>POST /api/lists { title, source: "github" }</c> with no
/// <c>githubRepo</c> → <b>400</b> <c>{ "error": "githubRepo is required for
/// GitHub-backed lists (format: owner/repo)", "code": "bad_request" }</c> — and
/// nothing was created (a following <c>GET /api/lists</c> still showed exactly
/// the one pre-existing list). So the server does recognise
/// <c>source: "github"</c>, and it wants <c>owner/repo</c>.</description></item>
/// <item><description>The plain local create returns
/// <c>{ "message": "List created successfully", "data": { …, "source": "local",
/// "githubRepo": null, "githubRepoPrivate": null, "properties": [] } }</c> with
/// <b>no</b> <c>refreshStatus</c> key, matching the spec's "present only for
/// GitHub-backed lists".</description></item>
/// </list>
/// <para>
/// Hence <see cref="RefreshStatus"/> is nullable and <see cref="Raw"/> is kept:
/// per this repo's read-after-write rule the caller re-reads the list rather than
/// trusting this envelope.
/// </para>
/// </summary>
public sealed class GitHubBackedListCreated
{
/// <summary>The full response body.</summary>
public JsonElement Raw { get; init; }

/// <summary>Server confirmation, e.g. "List created successfully".</summary>
public string? Message { get; init; }

/// <summary>
/// The new list's GitHub backing, read straight out of the <c>data</c> object.
/// Null if the server answered without one.
/// </summary>
public GitHubListBacking? Backing { get; init; }

/// <summary>
/// <c>refreshStatus</c> — the initial issue sync's outcome. Unobserved (see
/// the class remarks), so treat a null as "no status reported", not as
/// failure.
/// </summary>
public string? RefreshStatus { get; init; }

/// <summary>The created list's id, when the server returned one.</summary>
public string? ListId => Backing?.ListId is { Length: > 0 } id ? id : null;

public static GitHubBackedListCreated FromJson(JsonElement body)
{
string? Str(JsonElement obj, string name) =>
obj.ValueKind == JsonValueKind.Object &&
obj.TryGetProperty(name, out var p) &&
p.ValueKind == JsonValueKind.String
? p.GetString()
: null;

GitHubListBacking? backing = null;
if (body.ValueKind == JsonValueKind.Object &&
body.TryGetProperty("data", out var data) &&
data.ValueKind == JsonValueKind.Object)
{
backing = GitHubListBacking.FromListJson(data);
}

return new GitHubBackedListCreated
{
Raw = body,
Message = Str(body, "message"),
RefreshStatus = Str(body, "refreshStatus"),
Backing = backing
};
}
}
145 changes: 145 additions & 0 deletions InterlinedList/Models/GitHubListBacking.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using System.Text.Json;

namespace InterlinedList.Models;

/// <summary>
/// The GitHub-backing facts about one list — <c>source</c>, <c>githubRepo</c>
/// and <c>githubRepoPrivate</c> — projected out of the raw list JSON.
///
/// <para>
/// <b>Why this exists instead of properties on <see cref="ListSummary"/>.</b>
/// The API does return those three fields on every list (verified live
/// 2026-09-16: <c>GET /api/lists</c> gives
/// <c>"source": "local", "githubRepo": null, "githubRepoPrivate": null</c> for
/// the test account's one list), but <see cref="ListSummary"/> is being extended
/// to the full wire type in a separate open PR. Reading the three fields out of
/// the raw JSON here keeps this feature independent of that change and merges
/// cleanly either way; once <see cref="ListSummary"/> carries them,
/// <c>InterlinedApiClient.GetListGitHubBackingAsync</c> is the only thing that
/// needs to change.
/// </para>
///
/// <para>
/// <b><see cref="RepoPrivate"/> is deliberately <c>bool?</c>.</b> /help/lists:
/// "Visibility is re-read from GitHub each time the list syncs… Lists created
/// before this tag existed show no tag until their first sync." A non-nullable
/// <c>bool</c> would default to <c>false</c> and silently assert *public repo*
/// for a list whose visibility has never been recorded. Since the tag exists to
/// warn that GitHub will show a collaborator a sign-in page or a 404, asserting
/// the wrong direction is a real (if small) trust problem — so
/// <see cref="ShowPrivateRepoTag"/> fires only on an explicit <c>true</c>, and
/// <see cref="VisibilityUnrecorded"/> names the third state rather than folding
/// it into "public".
/// </para>
///
/// <para>
/// The private/public flag is about <b>the repository on GitHub</b>, not about
/// who can see the InterlinedList list — <see cref="ListSummary.IsPublic"/> is
/// that, and the two are set separately.
/// </para>
/// </summary>
public sealed class GitHubListBacking
{
/// <summary>The InterlinedList list id these facts belong to.</summary>
public required string ListId { get; init; }

/// <summary>The list's title, carried along so a badge or header can label itself.</summary>
public string? Title { get; init; }

/// <summary>
/// <c>source</c> — <c>"local"</c> or <c>"github"</c> (both observed live;
/// <c>"github"</c> is what <c>POST /api/lists</c> requires to build a
/// GitHub-backed list).
/// </summary>
public string Source { get; init; } = LocalSource;

/// <summary><c>githubRepo</c> — <c>owner/repo</c>, or null on a local list.</summary>
public string? Repo { get; init; }

/// <summary>
/// <c>githubRepoPrivate</c>. <c>true</c> = private repository on GitHub,
/// <c>false</c> = public, <c>null</c> = never recorded (no sync yet) — see the
/// class remarks for why the null case must not render as "public".
/// </summary>
public bool? RepoPrivate { get; init; }

public const string LocalSource = "local";
public const string GitHubSource = "github";

/// <summary>
/// Whether this list syncs its rows from GitHub issues. Keyed off
/// <c>source</c>, with a populated <c>githubRepo</c> as a fallback so a list
/// whose <c>source</c> string ever changes spelling still reads correctly.
/// </summary>
public bool IsGitHubBacked =>
string.Equals(Source, GitHubSource, StringComparison.OrdinalIgnoreCase) ||
Repo is { Length: > 0 };

/// <summary>
/// Show the "Private repo" tag — <b>only</b> on an explicit <c>true</c>.
/// </summary>
public bool ShowPrivateRepoTag => IsGitHubBacked && RepoPrivate == true;

/// <summary>
/// GitHub-backed, but visibility has never been recorded: show no tag at all
/// and say so, rather than implying the repository is public.
/// </summary>
public bool VisibilityUnrecorded => IsGitHubBacked && RepoPrivate is null;

/// <summary>Owner login half of <c>owner/repo</c>.</summary>
public string Owner =>
Repo is { Length: > 0 } r && r.IndexOf('/') is var i && i > 0 ? r[..i] : string.Empty;

/// <summary>Repository-name half of <c>owner/repo</c> — the default list title.</summary>
public string RepoName =>
Repo is { Length: > 0 } r && r.IndexOf('/') is var i && i >= 0 && i < r.Length - 1
? r[(i + 1)..]
: Repo ?? string.Empty;

/// <summary>The link label the web app shows under the list name: "owner/repo issues".</summary>
public string RepoLinkLabel => Repo is { Length: > 0 } r ? $"{r} issues" : string.Empty;

/// <summary>The repository's issues page, opened in the OS browser.</summary>
public string? IssuesUrl => Repo is { Length: > 0 } r ? $"https://github.com/{r}/issues" : null;

/// <summary>
/// Reads the three backing fields out of one list object — the element under
/// <c>data</c> for <c>GET /api/lists/{id}</c>, or one entry of the
/// <c>lists</c> array for <c>GET /api/lists</c>. Missing fields degrade to a
/// local list rather than throwing, because this has to survive both the
/// current and the extended <see cref="ListSummary"/> wire shape.
/// </summary>
public static GitHubListBacking FromListJson(JsonElement list, string? fallbackId = null)
{
string? Str(string name) =>
list.ValueKind == JsonValueKind.Object &&
list.TryGetProperty(name, out var p) &&
p.ValueKind == JsonValueKind.String
? p.GetString()
: null;

bool? Bool(string name)
{
if (list.ValueKind != JsonValueKind.Object || !list.TryGetProperty(name, out var p))
return null;
return p.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => null
};
}

return new GitHubListBacking
{
ListId = Str("id") ?? fallbackId ?? string.Empty,
Title = Str("title"),
Source = Str("source") ?? LocalSource,
Repo = Str("githubRepo"),
RepoPrivate = Bool("githubRepoPrivate")
};
}

/// <summary>A list we know nothing GitHub-ish about — renders no badge, no tag.</summary>
public static GitHubListBacking Local(string listId) => new() { ListId = listId };
}
110 changes: 110 additions & 0 deletions InterlinedList/Services/GitHubListIndex.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using InterlinedList.Models;

namespace InterlinedList.Services;

/// <summary>
/// One shared, lazily-loaded map of list id → <see cref="GitHubListBacking"/>,
/// so that marking GitHub-backed lists in a lists browser costs <b>one</b>
/// request rather than one per row.
///
/// <para>
/// A per-row <c>GET /api/lists/{id}</c> would be the obvious way to give each
/// badge its own data, and it is exactly the wrong one: a user with fifty lists
/// would fire fifty requests to draw fifty tiny labels. <c>GET /api/lists?all=1</c>
/// already returns <c>source</c>/<c>githubRepo</c>/<c>githubRepoPrivate</c> for
/// every list (verified live 2026-09-16), so one call answers every badge.
/// </para>
///
/// <para>
/// Deliberately <b>not</b> registered in <see cref="AppServices"/>: it is a cache
/// in front of an existing singleton, has no other collaborators, and adding it
/// there would touch a file three other open branches also touch. It resolves
/// <see cref="AppServices.Session"/> itself at call time.
/// </para>
///
/// <para>
/// UI-thread affine by design. Every caller is a WPF control, loads are awaited
/// on the dispatcher, and <see cref="Changed"/> is raised on whichever thread
/// completed the load — which, with the default synchronization context, is the
/// UI thread. Concurrent loads are collapsed onto one in-flight task rather than
/// locked, so two badges appearing at once make one request.
/// </para>
/// </summary>
public static class GitHubListIndex
{
private static readonly Dictionary<string, GitHubListBacking> Cache = new(StringComparer.Ordinal);
private static Task? _inFlight;
private static bool _loaded;

/// <summary>Raised whenever the cache changes, so live badges can re-read it.</summary>
public static event EventHandler? Changed;

/// <summary>
/// The backing for one list, or null if the index hasn't been loaded or
/// doesn't know that list. Null means "don't know yet" — never "local" — so
/// callers must not draw a conclusion from it.
/// </summary>
public static GitHubListBacking? Get(string? listId) =>
listId is { Length: > 0 } id && Cache.TryGetValue(id, out var backing) ? backing : null;

/// <summary>
/// Loads the index once. Repeat calls are free; concurrent calls share the
/// single in-flight request. Failures are swallowed — a missing badge is not
/// worth an error banner — but leave <see cref="_loaded"/> false so a later
/// call retries.
/// </summary>
public static Task EnsureLoadedAsync()
{
if (_loaded) return Task.CompletedTask;
return _inFlight ??= LoadAsync();
}

/// <summary>Forces a reload, e.g. after creating a list or refreshing one from GitHub.</summary>
public static Task RefreshAsync()
{
_loaded = false;
_inFlight = null;
return EnsureLoadedAsync();
}

/// <summary>
/// Writes one freshly-read list straight into the cache — used after a
/// "Refresh from GitHub", where the list's <c>githubRepoPrivate</c> may have
/// just changed and re-reading every list would be wasteful.
/// </summary>
public static void Put(GitHubListBacking backing)
{
if (backing.ListId is not { Length: > 0 }) return;
Cache[backing.ListId] = backing;
Changed?.Invoke(null, EventArgs.Empty);
}

private static async Task LoadAsync()
{
try
{
var backings = await AppServices.Session.Api.GetListGitHubBackingsAsync();

Cache.Clear();
foreach (var backing in backings)
{
if (backing.ListId is { Length: > 0 })
Cache[backing.ListId] = backing;
}

_loaded = true;
Changed?.Invoke(null, EventArgs.Empty);
}
catch (Exception ex) when (ex is InterlinedApiException or System.Net.Http.HttpRequestException
or System.Text.Json.JsonException)
{
// A badge that can't be drawn is not an error the user needs; the next
// EnsureLoadedAsync will try again. Logged so it isn't invisible.
AppLog.Warn($"GitHubListIndex load failed: {ex.Message}");
}
finally
{
_inFlight = null;
}
}
}
Loading
Loading