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

namespace InterlinedList.Models;

/// <summary>
/// The result of <c>POST /api/lists/{id}/refresh</c> — "Refresh from GitHub".
///
/// <para>
/// <b>Envelope deliberately loose.</b> The success body is <b>not</b> 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):
/// </para>
/// <code>
/// POST /api/lists/{local-list-id}/refresh
/// → 400 { "error": "Refresh is only available for GitHub-backed lists",
/// "code": "bad_request" }
/// </code>
/// <para>
/// No mutation: re-reading the list afterwards returned an identical body with
/// an unchanged <c>updatedAt</c>. The OpenAPI spec documents <c>201</c> for
/// success with the body "not individually modelled yet", so rather than guess a
/// typed envelope this keeps <see cref="Raw"/> 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.
/// </para>
/// </summary>
public sealed class ListRefreshResult
{
/// <summary>The whole response body, so nothing is lost to a wrong guess.</summary>
public JsonElement Raw { get; init; }

/// <summary><c>message</c>, if the server sends one.</summary>
public string? Message { get; init; }

/// <summary>
/// <c>refreshStatus</c>/<c>status</c>, if present. <c>POST /api/lists</c>
/// documents a <c>refreshStatus</c> string "present only for GitHub-backed
/// lists", so the refresh route plausibly echoes the same field.
/// </summary>
public string? Status { get; init; }

/// <summary>Best-effort issue/row count, if the server reports one.</summary>
public int? Imported { get; init; }

/// <summary>What to show the user after a refresh; never empty.</summary>
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."
};

/// <summary>Projects whatever the server sent, tolerating an empty body.</summary>
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")
};
}
}
47 changes: 42 additions & 5 deletions InterlinedList/Services/InterlinedApiClient.GitHubLists.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
namespace InterlinedList.Services;

/// <summary>
/// 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".
///
/// <para>
/// Kept in its own partial rather than folded into
Expand All @@ -21,15 +21,19 @@ namespace InterlinedList.Services;
/// <para>
/// <b>Live-probe record, 2026-09-16</b> (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 <em>success</em> 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 <em>success</em>
/// paths are built and unexercised. What was verified, with a throwaway list
/// titled "ZZ claude-probe …" that was deleted and confirmed gone afterwards:
/// </para>
/// <list type="bullet">
/// <item><description><c>POST /api/lists { title, source: "github" }</c> and no
/// <c>githubRepo</c> → <b>400</b> "githubRepo is required for GitHub-backed
/// lists (format: owner/repo)", <b>and nothing was created</b>. The server does
/// understand <c>source: "github"</c>.</description></item>
/// <item><description><c>POST /api/lists/{id}/refresh</c> on a <c>local</c> list
/// → <b>400</b> "Refresh is only available for GitHub-backed lists". Re-reading
/// the list gave an identical body with an unchanged <c>updatedAt</c>, so the
/// rejection is inert.</description></item>
/// <item><description><c>GET /api/lists</c> really does carry
/// <c>source</c>/<c>githubRepo</c>/<c>githubRepoPrivate</c> on every list
/// (<c>"local"</c>/<c>null</c>/<c>null</c> for the account's one
Expand Down Expand Up @@ -99,6 +103,39 @@ public async Task<GitHubBackedListCreated> CreateGitHubBackedListAsync(
return GitHubBackedListCreated.FromJson(json);
}

/// <summary>
/// "Refresh from GitHub" — <c>POST /api/lists/{id}/refresh</c>, re-pulling the
/// repository's issues into the list's rows and re-reading the repository's
/// public/private visibility.
/// <para>
/// A <c>local</c> list answers <c>400</c> "Refresh is only available for
/// GitHub-backed lists" (verified live, and inert — nothing changed), so gate
/// the action on <see cref="GitHubListBacking.IsGitHubBacked"/> 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.
/// </para>
/// </summary>
public async Task<ListRefreshResult> 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<JsonElement>(text, JsonOptions));
}
catch (JsonException)
{
return new ListRefreshResult { Message = "Refreshed from GitHub." };
}
}

/// <summary>
/// One list's GitHub backing (<c>source</c>, <c>githubRepo</c>,
/// <c>githubRepoPrivate</c>) from <c>GET /api/lists/{id}</c>, whose body is
Expand Down
Loading
Loading