Skip to content

Commit 253afd1

Browse files
Geeven SinghCopilot
andcommitted
Auto-refresh PR diffs when GitHub head/base SHAs move
When DiffViewer is showing a GitHub pull request, the diff was previously frozen at the SHAs the resolver pinned on launch. Force-pushing the PR or advancing the base branch had no visible effect until the user relaunched. This commit ships PR auto-refresh end-to-end in three phases, all in one ship: Phase 1 - Manual refresh extension. The existing F5 (RefreshCommand) now also kicks IPullRequestWatcher.RequestImmediatePoll in PR mode before re-running the change-list enumerate; the existing title-bar toast continues to confirm the action. Phase 2 - Periodic polling. A new IPullRequestWatcher (sibling to IRepositoryWatcher) polls GitHub's PR endpoint via an ETag-aware GetPullRequestPolledAsync addition to IGitHubClient. The coordinator subscribes to Changed events and rebuilds the context via the existing SwitchContextCoreAsync path when head or base SHAs move - the same atomic-swap recents-row clicks use. Lifecycle changes (closed / merged) surface as toasts without rebuilding. PullRequestAutoRefresh and PullRequestPollIntervalSeconds (default 300s, clamp 30..3600) ship as new AppSettings fields (schema v10), exposed in the Settings dialog under a new Pull requests section. The toolbar Live ToggleButton is context-aware: in PR mode it routes to PullRequestAutoRefresh; in working-tree mode it routes to LiveUpdates. Ctrl+L follows the same routing. Phase 3 - Polish. IWindowVisibilityProbe pauses polling while the window is hidden (restoring visibility kicks one immediate poll). X-RateLimit-Remaining < 100 triggers 4x backoff. 401/403 stops polling permanently with a one-time toast carrying the GitHub message. Conditional-get via If-None-Match keeps unchanged polls to one round-trip with zero body bytes. Recents recording is suppressed on auto-refresh - only initial open and explicit re-clicks update the timestamp. 45 new tests across PullRequestWatcherTests (poll cadence, snapshot diff, suspend semantics, visibility pause, rate-limit backoff, terminal stop), PullRequestAutoRefreshSettingsTests (round-trip, clamp, v9->v10 migration), GitHubClientTests (304, ETag, rate-limit header), MainWindowCoordinatorTests (subscription wiring, rebuild on SHA move with no recents re-record, toast-only paths), and MainViewModelKeyboardShortcutTests (F5 calls RequestImmediatePoll, Ctrl+L routes correctly in PR mode, re-enabling kicks one poll). Also fixes a latent bug in the shared test-helper ManualTimer that clobbered a callback-re-armed schedule with the post-callback "one-shot done" branch; existing tests don't trigger it because RepositoryEventDebouncer's callback never re-arms its own timer. AI-Local-Session: 6df9761f-b968-469a-87e4-376987d4353a AI-Cloud-Session: 4080c61e-0c31-4ba9-9c66-2bbaf6fb6bf6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 406ea81 commit 253afd1

31 files changed

Lines changed: 2473 additions & 56 deletions

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,26 @@ body. Keep section headings exact and write notes in Markdown.
1212

1313
## [Unreleased]
1414

15+
### Added
16+
17+
- **Pull-request auto-refresh.** When DiffViewer is showing a GitHub
18+
pull request, it now polls GitHub's REST API for head/base SHA
19+
shifts and PR lifecycle transitions (open/closed/merged) on a
20+
configurable interval (default 5 minutes; clamped 30 s..1 h). When
21+
the head or base SHA moves — typically a force-push or a new commit
22+
to either side — the diff is automatically rebuilt with the new
23+
SHAs and a title-bar toast announces what changed. Lifecycle
24+
transitions ("This PR is now merged.") surface as toasts without
25+
rebuilding the diff. F5 always forces an immediate poll, regardless
26+
of the auto-refresh toggle state. The toolbar **Live** button is
27+
context-aware: in PR mode it controls auto-polling; in working-tree
28+
mode it controls the existing FSW-driven refresh. Both settings
29+
persist independently so toggling one does not affect the other.
30+
Polling is paused while the main window is hidden, backs off on
31+
GitHub rate-limit pressure, and stops after a terminal 401/403 with
32+
a one-time toast. Settings dialog exposes both the toggle and the
33+
interval under a new "Pull requests" section.
34+
1535
## [1.8.0] - 2026-06-06
1636

1737
### Added

DiffViewer.Tests/MainWindowCoordinatorTests.cs

Lines changed: 248 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,8 @@ public async Task InitialLaunchAsync_ParseFailure_WithRecents_FallsBackToEmptyCo
222222
recents.SeededItems.Add(MakeContext(@"C:\repos\foo", "main"));
223223
var services = new AppServices(
224224
new SettingsService(), new DiffService(), new ExternalAppLauncher(null), recents,
225-
new FakePullRequestResolver(), new FakeMissingClonePromptHost(), new FakeNewDiffDialogHost());
225+
new FakePullRequestResolver(), new FakeMissingClonePromptHost(), new FakeNewDiffDialogHost(),
226+
new StubGitHubClient(), new StubPullRequestLocalFetcher(), new StubWindowVisibilityProbe());
226227

227228
var coordinator = new MainWindowCoordinator(
228229
services, dialog, default, shutdownAction: c => exitCode = c);
@@ -456,11 +457,11 @@ public async Task SwitchContextAsync_PopulatesSwitchingStatusInFlight_AndClearsO
456457
bool? isSwitchingInFlight = null;
457458
var coordinator = new MainWindowCoordinator(
458459
services, dialog, shutdownAction: _ => { },
459-
contextFactory: async (p, s, sc, ct) =>
460+
contextFactory: async (p, s, sc, ct, review) =>
460461
{
461462
statusInFlight ??= coordinatorRef!.SwitchingStatus;
462463
isSwitchingInFlight ??= coordinatorRef!.IsSwitching;
463-
return await CompositionRoot.BuildContextAsync(p, s, sc, ct);
464+
return await CompositionRoot.BuildContextAsync(p, s, sc, ct, review);
464465
});
465466
coordinatorRef = coordinator;
466467

@@ -489,14 +490,14 @@ public async Task SwitchContextAsync_OnContextBuildFailure_ClearsSwitchingStatus
489490
bool factoryHasThrown = false;
490491
var coordinator = new MainWindowCoordinator(
491492
services, dialog, shutdownAction: _ => { },
492-
contextFactory: (p, s, sc, ct) =>
493+
contextFactory: (p, s, sc, ct, review) =>
493494
{
494495
if (!factoryHasThrown)
495496
{
496497
// First call (StartFromParsedAsync below) succeeds so
497498
// there's a real outgoing context. Second call (the
498499
// SwitchContextAsync under test) throws.
499-
return CompositionRoot.BuildContextAsync(p, s, sc, ct);
500+
return CompositionRoot.BuildContextAsync(p, s, sc, ct, review);
500501
}
501502
throw new ContextBuildException("simulated build failure");
502503
});
@@ -572,7 +573,10 @@ private static AppServices BuildServices(
572573
recents,
573574
prResolver,
574575
prompt,
575-
new FakeNewDiffDialogHost());
576+
new FakeNewDiffDialogHost(),
577+
new StubGitHubClient(),
578+
new StubPullRequestLocalFetcher(),
579+
new StubWindowVisibilityProbe());
576580
}
577581

578582
private static TempRepo MakeRepoWithCommit()
@@ -674,6 +678,34 @@ internal sealed class FakeNewDiffDialogHost : INewDiffDialogHost
674678
}
675679
}
676680

681+
internal sealed class StubGitHubClient : IGitHubClient
682+
{
683+
public Task<DiffViewer.Services.PullRequestInfo> GetPullRequestAsync(
684+
DiffViewer.Models.PullRequestRef pr, System.Threading.CancellationToken ct)
685+
=> throw new NotSupportedException("PR mode not exercised by these tests.");
686+
687+
public Task<DiffViewer.Models.PullRequestPolledResult> GetPullRequestPolledAsync(
688+
DiffViewer.Models.PullRequestRef pr, string? ifNoneMatch, System.Threading.CancellationToken ct)
689+
=> throw new NotSupportedException("PR mode not exercised by these tests.");
690+
}
691+
692+
internal sealed class StubPullRequestLocalFetcher : IPullRequestLocalFetcher
693+
{
694+
public Task<DiffViewer.Services.PullRequestFetchResult> FetchAsync(
695+
string repoPath, DiffViewer.Services.PullRequestInfo info, System.Threading.CancellationToken ct)
696+
=> throw new NotSupportedException("PR mode not exercised by these tests.");
697+
}
698+
699+
internal sealed class StubWindowVisibilityProbe : IWindowVisibilityProbe
700+
{
701+
public bool IsVisible => true;
702+
public event EventHandler? VisibilityChanged
703+
{
704+
add { _ = value; }
705+
remove { _ = value; }
706+
}
707+
}
708+
677709
// ---- IsStashRef tests ----
678710

679711
[Theory]
@@ -695,4 +727,214 @@ public void IsStashRef_WorkingTree_ReturnsFalse()
695727
{
696728
MainWindowCoordinator.IsStashRef(new DiffSide.WorkingTree()).Should().BeFalse();
697729
}
730+
731+
// ===================== PR auto-refresh subscription =====================
732+
733+
private static PullRequestRef SamplePr() => new("github.com", "owner", "repo", 42);
734+
735+
[Fact]
736+
public async Task SwitchToGitHubPullRequest_SubscribesToWatcherChanged_AndStartsIt()
737+
{
738+
using var repo = MakeRepoWithCommit();
739+
var services = BuildServices(out _, out var prResolver, out _);
740+
741+
// Resolver returns Ready with the repo path the test owns.
742+
// Use HEAD (the only known ref in MakeRepoWithCommit) so the
743+
// initial LoadInitialChangesAsync succeeds.
744+
prResolver.Results.Enqueue(new PullRequestResolution.Ready(
745+
new ParsedCommandLine(repo.Path, new DiffSide.CommitIsh("HEAD"), new DiffSide.CommitIsh("HEAD")),
746+
SamplePr()));
747+
748+
var prWatchers = new List<RecordingPullRequestWatcher>();
749+
var coordinator = new MainWindowCoordinator(
750+
services, new FakeDialog(), default,
751+
contextFactory: (parsed, svc, scope, ct, review) =>
752+
BuildVmWithWatcher(parsed, svc, scope, ct, review, prWatchers));
753+
754+
var ok = await coordinator.SwitchToAsync(
755+
new DiffLaunchSource.GitHubPullRequest(SamplePr()));
756+
757+
ok.Should().BeTrue();
758+
prWatchers.Should().ContainSingle();
759+
prWatchers[0].StartCount.Should().Be(1);
760+
prWatchers[0].ChangedSubscriberCount.Should().Be(1,
761+
"the coordinator must subscribe to Changed after a successful PR swap");
762+
763+
await coordinator.DisposeCurrentAsync();
764+
}
765+
766+
[Fact]
767+
public async Task PullRequestWatcher_FiresHeadMoved_TriggersContextRebuild_WithoutRecordsing()
768+
{
769+
using var repo = MakeRepoWithCommit();
770+
var services = BuildServices(out var recents, out var prResolver, out _);
771+
772+
// Initial swap uses HEAD; auto-refresh rebuild uses HEAD too —
773+
// both must resolve. Real PR rebuilds receive real SHAs from
774+
// the watcher's snapshot; here we use HEAD so libgit2 is happy.
775+
prResolver.Results.Enqueue(new PullRequestResolution.Ready(
776+
new ParsedCommandLine(repo.Path, new DiffSide.CommitIsh("HEAD"), new DiffSide.CommitIsh("HEAD")),
777+
SamplePr()));
778+
779+
var prWatchers = new List<RecordingPullRequestWatcher>();
780+
var coordinator = new MainWindowCoordinator(
781+
services, new FakeDialog(), default,
782+
contextFactory: (parsed, svc, scope, ct, review) =>
783+
BuildVmWithWatcher(parsed, svc, scope, ct, review, prWatchers));
784+
785+
(await coordinator.SwitchToAsync(new DiffLaunchSource.GitHubPullRequest(SamplePr())))
786+
.Should().BeTrue();
787+
recents.RecordedRepoPaths.Count.Should().Be(1, "initial swap records");
788+
789+
// Watcher detects a head move. The watcher's pre-resolved
790+
// snapshot carries the SHAs the rebuild will use — point them
791+
// at HEAD so libgit2 can resolve them in the rebuild path.
792+
var snapshot = new RemoteRefSnapshot("HEAD", "HEAD");
793+
prWatchers[0].RaiseChanged(new PullRequestChangedEventArgs(
794+
PullRequestChangeKind.HeadMoved,
795+
NewInfo: null,
796+
NewSnapshot: snapshot,
797+
FailureMessage: null,
798+
UtcTimestamp: DateTime.UtcNow));
799+
800+
// Auto-refresh rebuilds via SwitchContextCoreAsync → a SECOND
801+
// contextFactory call with the new SHAs.
802+
prWatchers.Should().HaveCount(2,
803+
"the auto-refresh path must produce a new VM with a new watcher");
804+
recents.RecordedRepoPaths.Count.Should().Be(1,
805+
"auto-refresh must NOT re-record the recents row (decision 5)");
806+
807+
await coordinator.DisposeCurrentAsync();
808+
}
809+
810+
[Fact]
811+
public async Task PullRequestWatcher_FiresStateChanged_TogglesToast_WithoutRebuild()
812+
{
813+
using var repo = MakeRepoWithCommit();
814+
var services = BuildServices(out _, out var prResolver, out _);
815+
816+
prResolver.Results.Enqueue(new PullRequestResolution.Ready(
817+
new ParsedCommandLine(repo.Path, new DiffSide.CommitIsh("HEAD"), new DiffSide.CommitIsh("HEAD")),
818+
SamplePr()));
819+
820+
var prWatchers = new List<RecordingPullRequestWatcher>();
821+
var coordinator = new MainWindowCoordinator(
822+
services, new FakeDialog(), default,
823+
contextFactory: (parsed, svc, scope, ct, review) =>
824+
BuildVmWithWatcher(parsed, svc, scope, ct, review, prWatchers));
825+
826+
(await coordinator.SwitchToAsync(new DiffLaunchSource.GitHubPullRequest(SamplePr())))
827+
.Should().BeTrue();
828+
var toasts = new List<string>();
829+
((MainViewModel)coordinator.Current!).ToastHandler = toasts.Add;
830+
831+
var info = new DiffViewer.Services.PullRequestInfo(
832+
Number: 42, Title: "t", State: "closed", Merged: true,
833+
BaseRef: "main", BaseSha: "BASE", HeadRef: "feat", HeadSha: "HEAD",
834+
HeadRepoCloneUrl: "u", BaseRepoCloneUrl: "u");
835+
836+
prWatchers[0].RaiseChanged(new PullRequestChangedEventArgs(
837+
PullRequestChangeKind.StateChanged,
838+
NewInfo: info,
839+
NewSnapshot: null,
840+
FailureMessage: null,
841+
UtcTimestamp: DateTime.UtcNow));
842+
843+
prWatchers.Should().HaveCount(1,
844+
"lifecycle changes must not rebuild the context");
845+
toasts.Should().ContainSingle().Which.Should().Contain("merged");
846+
847+
await coordinator.DisposeCurrentAsync();
848+
}
849+
850+
[Fact]
851+
public async Task PullRequestWatcher_FiresPollFailed_Toasts_WithoutRebuild()
852+
{
853+
using var repo = MakeRepoWithCommit();
854+
var services = BuildServices(out _, out var prResolver, out _);
855+
856+
prResolver.Results.Enqueue(new PullRequestResolution.Ready(
857+
new ParsedCommandLine(repo.Path, new DiffSide.CommitIsh("HEAD"), new DiffSide.CommitIsh("HEAD")),
858+
SamplePr()));
859+
860+
var prWatchers = new List<RecordingPullRequestWatcher>();
861+
var coordinator = new MainWindowCoordinator(
862+
services, new FakeDialog(), default,
863+
contextFactory: (parsed, svc, scope, ct, review) =>
864+
BuildVmWithWatcher(parsed, svc, scope, ct, review, prWatchers));
865+
866+
(await coordinator.SwitchToAsync(new DiffLaunchSource.GitHubPullRequest(SamplePr())))
867+
.Should().BeTrue();
868+
var toasts = new List<string>();
869+
((MainViewModel)coordinator.Current!).ToastHandler = toasts.Add;
870+
871+
prWatchers[0].RaiseChanged(new PullRequestChangedEventArgs(
872+
PullRequestChangeKind.PollFailed,
873+
NewInfo: null,
874+
NewSnapshot: null,
875+
FailureMessage: "GitHub rejected the auth token.",
876+
UtcTimestamp: DateTime.UtcNow));
877+
878+
prWatchers.Should().HaveCount(1, "terminal failures don't rebuild");
879+
toasts.Should().ContainSingle().Which.Should().Contain("rejected the auth token");
880+
881+
await coordinator.DisposeCurrentAsync();
882+
}
883+
884+
private static async Task<MainViewModel> BuildVmWithWatcher(
885+
ParsedCommandLine parsed,
886+
AppServices services,
887+
ContextScope scope,
888+
System.Threading.CancellationToken ct,
889+
IReviewRef? review,
890+
List<RecordingPullRequestWatcher> sink)
891+
{
892+
var repo = new RepositoryService(parsed.RepoPath);
893+
scope.Register(repo);
894+
895+
RecordingPullRequestWatcher? watcher = null;
896+
if (review is PullRequestRef)
897+
{
898+
watcher = new RecordingPullRequestWatcher();
899+
sink.Add(watcher);
900+
scope.Register(watcher);
901+
}
902+
903+
var vm = new MainViewModel(
904+
repository: repo,
905+
left: parsed.Left,
906+
right: parsed.Right,
907+
scope: scope,
908+
pullRequestWatcher: watcher);
909+
910+
await vm.LoadInitialChangesAsync(ct).ConfigureAwait(true);
911+
return vm;
912+
}
913+
914+
internal sealed class RecordingPullRequestWatcher : IPullRequestWatcher
915+
{
916+
public int StartCount { get; private set; }
917+
public int ImmediatePollCount { get; private set; }
918+
public bool Disposed { get; private set; }
919+
public int ChangedSubscriberCount { get; private set; }
920+
private EventHandler<PullRequestChangedEventArgs>? _changed;
921+
922+
public event EventHandler<PullRequestChangedEventArgs>? Changed
923+
{
924+
add { _changed += value; ChangedSubscriberCount++; }
925+
remove { _changed -= value; ChangedSubscriberCount--; }
926+
}
927+
928+
public void Start() => StartCount++;
929+
public IDisposable Suspend() => new NoopToken();
930+
public void RequestImmediatePoll() => ImmediatePollCount++;
931+
public void Dispose() => Disposed = true;
932+
933+
public void RaiseChanged(PullRequestChangedEventArgs args) => _changed?.Invoke(this, args);
934+
935+
private sealed class NoopToken : IDisposable
936+
{
937+
public void Dispose() { }
938+
}
939+
}
698940
}

0 commit comments

Comments
 (0)