diff --git a/InterlinedList/Models/ListRefreshResult.cs b/InterlinedList/Models/ListRefreshResult.cs new file mode 100644 index 0000000..743da55 --- /dev/null +++ b/InterlinedList/Models/ListRefreshResult.cs @@ -0,0 +1,98 @@ +using System.Text.Json; + +namespace InterlinedList.Models; + +/// +/// The result of POST /api/lists/{id}/refresh — "Refresh from GitHub". +/// +/// +/// Envelope deliberately loose. The success body is not live +/// verified: the test account owns no GitHub-backed list, and creating one would +/// wire a real repository to it, so only the rejection path was exercised. +/// Probed live 2026-09-16 against a throwaway local list (created, probed, +/// deleted, confirmed gone): +/// +/// +/// POST /api/lists/{local-list-id}/refresh +/// → 400 { "error": "Refresh is only available for GitHub-backed lists", +/// "code": "bad_request" } +/// +/// +/// No mutation: re-reading the list afterwards returned an identical body with +/// an unchanged updatedAt. The OpenAPI spec documents 201 for +/// success with the body "not individually modelled yet", so rather than guess a +/// typed envelope this keeps and picks up the handful of field +/// names the spec's neighbouring routes use, if any of them happen to be there. +/// Callers must re-read the list and its rows afterwards regardless — that is the +/// repo's read-after-write rule and here it is also the only way to know what +/// actually changed. +/// +/// +public sealed class ListRefreshResult +{ + /// The whole response body, so nothing is lost to a wrong guess. + public JsonElement Raw { get; init; } + + /// message, if the server sends one. + public string? Message { get; init; } + + /// + /// refreshStatus/status, if present. POST /api/lists + /// documents a refreshStatus string "present only for GitHub-backed + /// lists", so the refresh route plausibly echoes the same field. + /// + public string? Status { get; init; } + + /// Best-effort issue/row count, if the server reports one. + public int? Imported { get; init; } + + /// What to show the user after a refresh; never empty. + public string DisplayText => + (Message, Status, Imported) switch + { + ({ Length: > 0 } m, _, { } n) => $"{m} ({n} rows)", + ({ Length: > 0 } m, _, _) => m, + (_, { Length: > 0 } s, { } n) => $"Refreshed — {s} ({n} rows)", + (_, { Length: > 0 } s, _) => $"Refreshed — {s}", + (_, _, { } n) => $"Refreshed — {n} rows", + _ => "Refreshed from GitHub." + }; + + /// Projects whatever the server sent, tolerating an empty body. + public static ListRefreshResult FromJson(JsonElement body) + { + string? Str(params string[] names) + { + if (body.ValueKind != JsonValueKind.Object) return null; + foreach (var name in names) + { + if (body.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String) + return p.GetString(); + } + return null; + } + + int? Int(params string[] names) + { + if (body.ValueKind != JsonValueKind.Object) return null; + foreach (var name in names) + { + if (body.TryGetProperty(name, out var p) && + p.ValueKind == JsonValueKind.Number && + p.TryGetInt32(out var value)) + { + return value; + } + } + return null; + } + + return new ListRefreshResult + { + Raw = body, + Message = Str("message"), + Status = Str("refreshStatus", "status"), + Imported = Int("imported", "issuesImported", "rowCount", "count") + }; + } +} diff --git a/InterlinedList/Services/InterlinedApiClient.GitHubLists.cs b/InterlinedList/Services/InterlinedApiClient.GitHubLists.cs index 167b8c2..f912aaf 100644 --- a/InterlinedList/Services/InterlinedApiClient.GitHubLists.cs +++ b/InterlinedList/Services/InterlinedApiClient.GitHubLists.cs @@ -5,8 +5,8 @@ namespace InterlinedList.Services; /// -/// Where lists meet GitHub: creating a GitHub-backed list and reading a list's -/// GitHub backing. +/// Where lists meet GitHub: creating a GitHub-backed list, reading a list's +/// GitHub backing, and "Refresh from GitHub". /// /// /// Kept in its own partial rather than folded into @@ -21,15 +21,19 @@ namespace InterlinedList.Services; /// /// Live-probe record, 2026-09-16 (test account, bearer sync-token). No /// GitHub-backed list was created — doing so would link a real GitHub repository -/// on shared test infrastructure — so the create success path is built -/// and unexercised. What was verified, with a throwaway list titled -/// "ZZ claude-probe …" that was deleted and confirmed gone afterwards: +/// on shared test infrastructure — so the create and refresh success +/// paths are built and unexercised. What was verified, with a throwaway list +/// titled "ZZ claude-probe …" that was deleted and confirmed gone afterwards: /// /// /// POST /api/lists { title, source: "github" } and no /// githubRepo → 400 "githubRepo is required for GitHub-backed /// lists (format: owner/repo)", and nothing was created. The server does /// understand source: "github". +/// POST /api/lists/{id}/refresh on a local list +/// → 400 "Refresh is only available for GitHub-backed lists". Re-reading +/// the list gave an identical body with an unchanged updatedAt, so the +/// rejection is inert. /// GET /api/lists really does carry /// source/githubRepo/githubRepoPrivate on every list /// ("local"/null/null for the account's one @@ -99,6 +103,39 @@ public async Task CreateGitHubBackedListAsync( return GitHubBackedListCreated.FromJson(json); } + /// + /// "Refresh from GitHub" — POST /api/lists/{id}/refresh, re-pulling the + /// repository's issues into the list's rows and re-reading the repository's + /// public/private visibility. + /// + /// A local list answers 400 "Refresh is only available for + /// GitHub-backed lists" (verified live, and inert — nothing changed), so gate + /// the action on and treat that + /// 400 as a state error rather than something to retry. The success body is + /// unverified; re-read the list and its rows afterwards. + /// + /// + public async Task RefreshListFromGitHubAsync(string listId, CancellationToken ct = default) + { + using var resp = await SendAsync(HttpMethod.Post, $"api/lists/{listId}/refresh", new { }, ct); + await EnsureSuccessAsync(resp, ct); + + // A 2xx with an empty body is possible and is not an error here — the + // caller re-reads the list for truth either way. + var text = await resp.Content.ReadAsStringAsync(ct); + if (string.IsNullOrWhiteSpace(text)) + return new ListRefreshResult(); + + try + { + return ListRefreshResult.FromJson(JsonSerializer.Deserialize(text, JsonOptions)); + } + catch (JsonException) + { + return new ListRefreshResult { Message = "Refreshed from GitHub." }; + } + } + /// /// One list's GitHub backing (source, githubRepo, /// githubRepoPrivate) from GET /api/lists/{id}, whose body is diff --git a/InterlinedList/ViewModels/GitHubListHeaderViewModel.cs b/InterlinedList/ViewModels/GitHubListHeaderViewModel.cs new file mode 100644 index 0000000..dc5546c --- /dev/null +++ b/InterlinedList/ViewModels/GitHubListHeaderViewModel.cs @@ -0,0 +1,227 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using InterlinedList.Models; +using InterlinedList.Services; + +namespace InterlinedList.ViewModels; + +/// +/// The strip that sits under a GitHub-backed list's name: the +/// owner/repo issues link, the Private repo tag, and +/// Refresh from GitHub. +/// +/// +/// Collapses to nothing for a local list, so it can be hosted unconditionally. +/// +/// +/// +/// The private/public flag has three states, not two. /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." So +/// githubRepoPrivate is true (private), false (public) or +/// null (never recorded) and the third case must render no tag — +/// never "public". fires only on an explicit +/// true; is the null case, and it says +/// so rather than staying silent about why there is no tag. +/// +/// +/// +/// And the tag is about the repository, not the list. A list can be public +/// while its repository is private, or the reverse — they are set separately. The +/// tag exists to warn that somebody invited to the list may have no +/// access to the repository, so GitHub will show them a sign-in page or +/// a 404 when they follow the link. is that +/// sentence, and it is the whole reason the tag is worth drawing. +/// +/// +public partial class GitHubListHeaderViewModel : ObservableObject +{ + private readonly SessionService _session; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsGitHubBacked))] + [NotifyPropertyChangedFor(nameof(RepoLinkLabel))] + [NotifyPropertyChangedFor(nameof(ShowPrivateRepoTag))] + [NotifyPropertyChangedFor(nameof(VisibilityUnrecorded))] + [NotifyPropertyChangedFor(nameof(RepoVisibilityLine))] + private GitHubListBacking? backing; + + [ObservableProperty] + private bool isBusy; + + [ObservableProperty] + private string? statusMessage; + + [ObservableProperty] + private string? errorMessage; + + /// Raised after a successful refresh so the host can reload the list's rows. + public event EventHandler? Refreshed; + + public GitHubListHeaderViewModel(SessionService session) + { + _session = session; + } + + public bool IsGitHubBacked => Backing?.IsGitHubBacked == true; + + /// "owner/repo issues" — the label the web app shows under the list name. + public string RepoLinkLabel => Backing?.RepoLinkLabel ?? string.Empty; + + /// Only ever true for an explicit githubRepoPrivate: true. + public bool ShowPrivateRepoTag => Backing?.ShowPrivateRepoTag == true; + + /// GitHub-backed but visibility never recorded — no tag, and an explanation. + public bool VisibilityUnrecorded => Backing?.VisibilityUnrecorded == true; + + /// + /// The copy that keeps the two visibilities apart. This is the point of the + /// tag, so it is spelled out rather than left to a one-word chip. + /// + public string PrivateTagExplanation => + "This repository is private on GitHub. That's about the repository, not about who can see " + + "this InterlinedList list — the two are set separately. Anyone you invite to the list who " + + "doesn't have repository access will get a GitHub sign-in page or \"not found\" when they " + + "follow the link."; + + /// + /// The line under the link. Says nothing about public/private when the value + /// has never been recorded — the one thing the null case must not do is imply + /// the repository is public. + /// + public string RepoVisibilityLine => Backing switch + { + { ShowPrivateRepoTag: true } => "Private repo", + { VisibilityUnrecorded: true } => + "Repository visibility hasn't been recorded yet — it's read from GitHub on the next sync. " + + "Refresh from GitHub to find out.", + { RepoPrivate: false } => "Public repository on GitHub.", + _ => string.Empty + }; + + /// + /// Loads one list's backing. A null or empty id clears the strip, which is + /// what "no list selected" looks like. + /// + public async Task LoadAsync(string? listId, CancellationToken ct = default) + { + StatusMessage = null; + ErrorMessage = null; + + if (listId is not { Length: > 0 }) + { + Backing = null; + return; + } + + // Answer instantly from the shared index when it already knows, so + // selecting a list doesn't wait on a round trip to draw the strip. + var cached = GitHubListIndex.Get(listId); + if (cached is not null) + Backing = cached; + + try + { + var fresh = await _session.Api.GetListGitHubBackingAsync(listId, ct); + Backing = fresh; + GitHubListIndex.Put(fresh); + } + catch (InterlinedApiException ex) + { + // Keep whatever the index gave us rather than blanking the strip; a + // stale repo link is more use than none. + if (Backing is null) + ErrorMessage = ex.Message; + else + AppLog.Warn($"Re-reading list {listId} for its GitHub backing failed: {ex.Message}"); + } + catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or System.Text.Json.JsonException) + { + if (Backing is null) + ErrorMessage = "Couldn't reach InterlinedList to read this list."; + } + } + + /// + /// Opens the repository's issues page in the OS browser — the link the web app + /// puts under the list name. + /// + [RelayCommand] + private void OpenRepoIssues() + { + if (Backing?.IssuesUrl is not { Length: > 0 } url) return; + + try + { + // UseShellExecute is required for .NET Core/5+ to hand a URL to the OS + // default browser. + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }); + } + catch (Exception ex) + { + AppLog.Error($"Opening {url} failed.", ex); + ErrorMessage = $"Couldn't open your browser. The page is {url}"; + } + } + + private bool CanRefresh() => !IsBusy && IsGitHubBacked && Backing?.ListId is { Length: > 0 }; + + /// + /// "Refresh from GitHub" — POST /api/lists/{id}/refresh, then a re-read. + /// + /// The re-read is not belt-and-braces: the refresh response envelope has never + /// been observed (no GitHub-backed list exists on the test account to observe + /// it with), and re-reading is also the only way to pick up a + /// githubRepoPrivate that just changed — which is exactly what a sync + /// is documented to do. + /// + /// + [RelayCommand(CanExecute = nameof(CanRefresh))] + private async Task RefreshAsync() + { + if (Backing?.ListId is not { Length: > 0 } listId) return; + + IsBusy = true; + try + { + var result = await _session.Api.RefreshListFromGitHubAsync(listId); + + var before = Backing; + var fresh = await _session.Api.GetListGitHubBackingAsync(listId); + Backing = fresh; + GitHubListIndex.Put(fresh); + + StatusMessage = before?.RepoPrivate != fresh.RepoPrivate + ? $"{result.DisplayText} Repository visibility is now {(fresh.RepoPrivate == true ? "private" : "public")}." + : result.DisplayText; + ErrorMessage = null; + + Refreshed?.Invoke(this, EventArgs.Empty); + } + catch (InterlinedApiException ex) + { + // The one failure worth naming: refresh on a list that isn't + // GitHub-backed. CanRefresh should prevent it, so if it happens the + // list's source changed underneath us. + ErrorMessage = ex.StatusCode == 400 && ex.Message.Contains("GitHub-backed", StringComparison.OrdinalIgnoreCase) + ? "This list isn't GitHub-backed, so there's nothing to refresh from." + : ex.Message; + } + catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or System.Text.Json.JsonException) + { + ErrorMessage = "Couldn't reach InterlinedList to refresh this list."; + } + finally + { + IsBusy = false; + } + } + + partial void OnIsBusyChanged(bool value) => RefreshCommand.NotifyCanExecuteChanged(); + + partial void OnBackingChanged(GitHubListBacking? value) => RefreshCommand.NotifyCanExecuteChanged(); +} diff --git a/InterlinedList/Views/GitHubListHeader.xaml b/InterlinedList/Views/GitHubListHeader.xaml new file mode 100644 index 0000000..cbddcd8 --- /dev/null +++ b/InterlinedList/Views/GitHubListHeader.xaml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +