Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
97fbd9a
fix(source-control): date Azure DevOps pull requests by their latest …
shivamhwp Nov 4, 2026
36b7515
fix(source-control): retain push dates after parallel Azure reads
shivamhwp Nov 4, 2026
b883411
fix(source-control): date Azure pull requests by the revision a read …
shivamhwp Nov 4, 2026
be37b25
fix(source-control): release interrupted Azure activity reads
shivamhwp Nov 4, 2026
ffc7355
refactor(source-control): name Azure activity read effects
shivamhwp Nov 5, 2026
1061589
fix(source-control): unblock queued Azure readers on shared completion
shivamhwp Nov 5, 2026
b2e9870
fix(source-control): list Azure pull requests before reading their ac…
shivamhwp Nov 5, 2026
8936dd1
fix(source-control): keep Azure list dates through refreshes and cach…
shivamhwp Nov 5, 2026
372a284
perf(source-control): stop rescanning protected Azure activity records
shivamhwp Nov 5, 2026
a6bfb91
fix(mobile): refresh loaded pull request pages after activity settles
shivamhwp Nov 5, 2026
877d32b
chore: merge main into Azure activity fix
shivamhwp Nov 5, 2026
15299f1
chore: merge main into Azure activity fix
shivamhwp Nov 5, 2026
fbec2cd
fix(source-control): preserve unavailable Azure action dates
shivamhwp Nov 5, 2026
9bba8d3
chore: merge current main into Azure activity fix
shivamhwp Nov 6, 2026
2d9be04
fix(pull-requests): a detail refresh dates the loaded list rows
shivamhwp Nov 6, 2026
86e172b
fix(pull-requests): a cached listing no longer undoes a detail refresh
shivamhwp Nov 6, 2026
f784fd4
fix(pull-requests): a detail read shows in listings for five minutes
shivamhwp Nov 6, 2026
79b3306
chore: merge current main into Azure activity fix
shivamhwp Nov 10, 2026
354207a
perf(source-control): Azure branch lookups no longer read every fork'…
shivamhwp Nov 10, 2026
5bc81a5
fix(client-runtime): a refresh started from an atom write reads again
shivamhwp Nov 10, 2026
ff7b3a0
fix(pull-requests): ignore stale detail observations in lists
shivamhwp Nov 10, 2026
1fafa54
fix(source-control): prioritize Azure reference activity reads
shivamhwp Nov 10, 2026
b1c81be
Merge remote-tracking branch 'origin/main' into issue-watch/31167
juliusmarminge Nov 11, 2026
67d8d5a
refactor(source-control): name the Azure detail read's dating step
juliusmarminge Nov 11, 2026
ab963de
Merge remote-tracking branch 'origin/main' into issue-watch/31167
juliusmarminge Nov 11, 2026
03c2e4b
Merge remote-tracking branch 'origin/main' into issue-watch/31167
juliusmarminge Nov 14, 2026
73e96f1
fix(source-control): keep Azure row dates in the existing look
juliusmarminge Nov 14, 2026
faa6918
fix(source-control): listed Azure PRs show a comment once the listing…
juliusmarminge Nov 14, 2026
90d00b7
Merge remote-tracking branch 'origin/main' into issue-watch/31167
juliusmarminge Nov 14, 2026
5b1e05e
fix(source-control): date Azure pull request reads by their newest th…
juliusmarminge Nov 14, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,25 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
output(json({ ...pullRequestRow, lastMergeSourceCommit: { commitId: previousHead } })),
),
)
// Azure's own row dates it by creation; the system thread its last push added is newer.
.mockReturnValueOnce(
Effect.succeed(
output(
json({
value: [
{
id: 1,
publishedDate: "2026-07-10T10:00:00Z",
lastUpdatedDate: "2026-07-10T12:30:00.123+02:00",
comments: [
{ id: 1, commentType: "system", publishedDate: "2026-07-10T10:00:00Z" },
],
},
],
}),
),
),
)
.mockReturnValueOnce(
Effect.succeed(
output(
Expand Down Expand Up @@ -199,8 +218,10 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
});

expect(detail.headSha).toBe(latestHead);
expect(argsOfCall(2)).toContain("iterationId=2");
expect(mockedExecute).toHaveBeenCalledTimes(4);
expect(detail.updatedAt).toBe("2026-07-10T10:30:00.123Z");
expect(argsOfCall(1)).toContain("pullRequestThreads");
expect(argsOfCall(3)).toContain("iterationId=2");
expect(mockedExecute).toHaveBeenCalledTimes(5);
}),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
decodePolicyChecksJson,
decodePullRequestJson,
decodePullRequestListJson,
decodeThreadActivityJson,
decodeThreadsJson,
decodeViewerJson,
type AzureDevOpsChangeEntry,
Expand Down Expand Up @@ -186,6 +187,13 @@ export class AzureDevOpsPullRequestCli extends Context.Service<
readonly number: number;
}) => Effect.Effect<ReadonlyArray<PullRequestComment>, AzureDevOpsPullRequestCliError>;

/** The newest thread or comment time, which is when anything last happened on the pull request. */
readonly readActivityAt: (input: {
readonly cwd: string;
readonly location: AzureDevOpsRepositoryLocation;
readonly number: number;
}) => Effect.Effect<string | null, AzureDevOpsPullRequestCliError>;

readonly listIterations: (input: {
readonly cwd: string;
readonly location: AzureDevOpsRepositoryLocation;
Expand Down Expand Up @@ -628,6 +636,17 @@ export const make = Effect.gen(function* () {
});
}),

readActivityAt: Effect.fn("AzureDevOpsPullRequestCli.readActivityAt")(function (input) {
return invoke({
cwd: input.cwd,
operation: "listThreads",
resource: "pullRequestThreads",
routeParameters: pullRequestRoute(input),
maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES,
decode: decodeThreadActivityJson,
});
}),

listIterations: Effect.fn("AzureDevOpsPullRequestCli.listIterations")(function (input) {
return invoke({
cwd: input.cwd,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const readSlice = (input: {
Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
...NO_BLOBS,
getPullRequest: () => Effect.succeed(PULL_REQUEST),
readActivityAt: () => Effect.succeed(null),
listIterations: () => Effect.succeed([ITERATION]),
listIterationChanges: () =>
Effect.succeed({
Expand Down Expand Up @@ -128,13 +129,57 @@ function patchedPaths(patch: string): ReadonlyArray<string> {
);
}

it.effect("dates an open pull request by its newest thread and keeps its own date otherwise", () =>
Effect.gen(function* () {
const activity: Array<
Effect.Effect<string | null, AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError>
> = [
Effect.succeed("2026-07-05T08:00:00.000Z"),
Effect.succeed("2026-07-05T08:00:00.000Z"),
Effect.fail(
new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({
command: "az",
cwd: "/w",
operation: "listThreads",
cause: "unavailable",
}),
),
];
let shown: typeof PULL_REQUEST | (Omit<typeof PULL_REQUEST, "state"> & { state: "merged" }) =
PULL_REQUEST;
const provider = yield* make.pipe(
Effect.provide(
Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
getPullRequest: () => Effect.succeed(shown),
runPullRequestAction: () => Effect.succeed(shown),
listPolicyChecks: () => Effect.succeed([]),
readActivityAt: () => activity.shift() ?? Effect.die("a closed pull request was read"),
}),
),
);
const input = { cwd: "/w", repository: "acme/web", host: "dev.azure.com", number: 7 };
const summaryDate = provider.getChangeRequestSummary!(input).pipe(
Effect.map((summary) => summary.updatedAt),
);

expect(yield* summaryDate).toBe("2026-07-05T08:00:00.000Z");
// Clients drop a reply dated before the summary they show.
const reply = yield* provider.runAction({ ...input, action: "ready" });
expect(reply?.updatedAt).toBe("2026-07-05T08:00:00.000Z");
expect(yield* summaryDate).toBe(PULL_REQUEST.updatedAt);
shown = { ...PULL_REQUEST, state: "merged" };
expect(yield* summaryDate).toBe(PULL_REQUEST.updatedAt);
}),
);

describe("getChangeRequest", () => {
it.effect("preserves the newest head when file counting fails", () =>
Effect.gen(function* () {
const provider = yield* make.pipe(
Effect.provide(
Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
getPullRequest: () => Effect.succeed(PULL_REQUEST),
readActivityAt: () => Effect.succeed(null),
listPolicyChecks: () => Effect.succeed([]),
listIterations: () =>
Effect.succeed([ITERATION, { ...ITERATION, id: 4, headCommit: "new-head" }]),
Expand Down Expand Up @@ -170,6 +215,7 @@ describe("getChangeRequest", () => {
Effect.provide(
Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
getPullRequest: () => Effect.succeed(PULL_REQUEST),
readActivityAt: () => Effect.succeed(null),
listPolicyChecks: () => Effect.succeed([]),
listIterations: () =>
state === "missing"
Expand Down Expand Up @@ -226,6 +272,7 @@ describe("policy checks", () => {
Effect.provide(
Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
getPullRequest: () => Effect.succeed(pullRequest),
readActivityAt: () => Effect.succeed(null),
listIterations: () => Effect.succeed([ITERATION]),
listIterationChanges: () => Effect.succeed({ changes: [], truncated: false }),
listPolicyChecks,
Expand Down Expand Up @@ -273,6 +320,7 @@ describe("getDiff reads", () => {
Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
...NO_BLOBS,
getPullRequest: () => Effect.succeed(PULL_REQUEST),
readActivityAt: () => Effect.succeed(null),
listIterations: () => Effect.succeed([ITERATION]),
listIterationChanges: () =>
Effect.succeed({ changes: paths.map((path) => change(path)), truncated: false }),
Expand Down Expand Up @@ -318,6 +366,7 @@ describe("getDiff reads", () => {
Effect.provide(
Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
getPullRequest: () => Effect.succeed(PULL_REQUEST),
readActivityAt: () => Effect.succeed(null),
listIterations: () => Effect.succeed([ITERATION]),
listIterationChanges: () => Effect.succeed({ changes, truncated: false }),
readBlobs: (input) =>
Expand Down Expand Up @@ -386,6 +435,7 @@ describe("getDiff reads", () => {
Effect.provide(
Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
getPullRequest: () => Effect.succeed(PULL_REQUEST),
readActivityAt: () => Effect.succeed(null),
listIterations: () => Effect.succeed([ITERATION]),
listIterationChanges: () =>
Effect.succeed({
Expand Down Expand Up @@ -440,6 +490,7 @@ describe("getDiff reads", () => {
Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
...NO_BLOBS,
getPullRequest: () => Effect.succeed(PULL_REQUEST),
readActivityAt: () => Effect.succeed(null),
listIterations: () => Effect.sync(() => ((iterationReads += 1), [ITERATION])),
listIterationChanges: () =>
Effect.sync(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ import {
type ProviderDiffSlice,
type PullRequestProviderApi,
} from "@t3tools/source-control-core/server/PullRequestProvider";
import type {
AzureDevOpsChangeEntry,
AzureDevOpsItemContent,
AzureDevOpsIteration,
AzureDevOpsPullRequest,
AzureDevOpsRepositoryLocation,
import {
latestAzureDevOpsTime,
type AzureDevOpsChangeEntry,
type AzureDevOpsItemContent,
type AzureDevOpsIteration,
type AzureDevOpsPullRequest,
type AzureDevOpsRepositoryLocation,
} from "./azureDevOpsPullRequestJson.ts";
import { definition } from "../shared/definition.ts";

Expand Down Expand Up @@ -227,6 +228,23 @@ export const make = Effect.gen(function* () {
isDraft: pullRequest.isDraft,
});

/**
* Azure's own rows date an open pull request by its creation, so a read of one pull request is
* dated by its newest thread instead. A failed read keeps the row's own date.
*/
const withActivity = (input: { readonly cwd: string }, pullRequest: AzureDevOpsPullRequest) => {
const location = pullRequest.location;
if (pullRequest.state !== "open" || location === null) return Effect.succeed(pullRequest);
return cli.readActivityAt({ cwd: input.cwd, location, number: pullRequest.number }).pipe(
Effect.map((activityAt) => ({
...pullRequest,
updatedAt:
latestAzureDevOpsTime([pullRequest.updatedAt, activityAt]) ?? pullRequest.updatedAt,
})),
Effect.orElseSucceed(() => pullRequest),
);
};

/** A later slice names its iteration, which the list it was cut from already holds. */
const diffScope = Effect.fn("AzureDevOpsPullRequestProvider.diffScope")(function* (input: {
readonly cwd: string;
Expand Down Expand Up @@ -388,7 +406,9 @@ export const make = Effect.gen(function* () {

getChangeRequestSummary: Effect.fn("AzureDevOpsPullRequestProvider.getChangeRequestSummary")(
function* (input) {
const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number });
const pullRequest = yield* cli
.getPullRequest({ cwd: input.cwd, number: input.number })
.pipe(Effect.flatMap((read) => withActivity(input, read)));
// Undefined keeps the last known state; null would claim no checks exist.
const checksState = yield* readChecks(input, pullRequest).pipe(
Effect.map((checks) => rollupCheckStatuses(checks.map((check) => check.status))),
Expand All @@ -404,7 +424,9 @@ export const make = Effect.gen(function* () {

getChangeRequest: Effect.fn("AzureDevOpsPullRequestProvider.getChangeRequest")(
function* (input) {
const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number });
const pullRequest = yield* cli
.getPullRequest({ cwd: input.cwd, number: input.number })
.pipe(Effect.flatMap((read) => withActivity(input, read)));
const location = pullRequest.location;
const iterations =
location === null
Expand Down Expand Up @@ -672,15 +694,19 @@ export const make = Effect.gen(function* () {
})
.pipe(
Effect.mapError(fail("runAction")),
Effect.map(
(pullRequest) =>
pullRequest && {
...toSummary(pullRequest),
autoMergeEnabled: pullRequest.autoMergeEnabled,
...(pullRequest.autoMergeMethod === undefined
? {}
: { autoMergeMethod: pullRequest.autoMergeMethod }),
},
// Clients drop a reply dated before the summary they show, so it is dated the same way.
Effect.flatMap((reply) =>
reply
? withActivity(input, reply).pipe(
Effect.map((pullRequest) => ({
...toSummary(pullRequest),
autoMergeEnabled: pullRequest.autoMergeEnabled,
...(pullRequest.autoMergeMethod === undefined
? {}
: { autoMergeMethod: pullRequest.autoMergeMethod }),
})),
)
: Effect.void,
),
),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
normalizeRefName,
} from "@t3tools/source-control-core/server/pullRequestUtils";
import * as Cause from "effect/Cause";
import * as DateTime from "effect/DateTime";
import * as Exit from "effect/Exit";
import * as Option from "effect/Option";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
import type {
Expand Down Expand Up @@ -75,6 +77,8 @@ const RawPullRequestSchema = Schema.Struct({
const RawThreadSchema = Schema.Struct({
id: Schema.Int,
isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)),
publishedDate: Schema.optional(Schema.NullOr(Schema.String)),
lastUpdatedDate: Schema.optional(Schema.NullOr(Schema.String)),
threadContext: Schema.optional(
Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })),
),
Expand All @@ -86,6 +90,7 @@ const RawThreadSchema = Schema.Struct({
content: Schema.optional(Schema.NullOr(Schema.String)),
author: Schema.optional(Schema.NullOr(RawIdentitySchema)),
publishedDate: Schema.optional(Schema.NullOr(Schema.String)),
lastUpdatedDate: Schema.optional(Schema.NullOr(Schema.String)),
isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)),
commentType: Schema.optional(Schema.NullOr(Schema.String)),
}),
Expand Down Expand Up @@ -456,6 +461,38 @@ export function decodePolicyChecksJson(
});
}

/** The newest of Azure's timestamps, which mix offsets and precisions; unreadable ones are skipped. */
export function latestAzureDevOpsTime(values: Iterable<string | null | undefined>): string | null {
let latest: DateTime.Utc | null = null;
for (const value of values) {
const at = DateTime.make(value?.trim() ?? "");
if (Option.isSome(at) && (latest === null || DateTime.isGreaterThan(at.value, latest))) {
latest = at.value;
}
}
return latest === null ? null : DateTime.formatIso(latest);
}

/**
* When the pull request's conversation last moved. Azure keeps no last-updated time on a pull
* request, but it adds a system thread for each push, vote and state change, so every thread counts.
*/
export function decodeThreadActivityJson(raw: string): Result.Result<string | null, DecodeFailure> {
const decoded = decodeThreadPage(raw);
if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
const times: Array<string | null | undefined> = [];
for (const entry of decoded.success.value) {
const decodedThread = decodeThreadEntry(entry);
if (Exit.isFailure(decodedThread)) continue;
const thread = decodedThread.value;
times.push(thread.publishedDate, thread.lastUpdatedDate);
for (const comment of thread.comments ?? []) {
times.push(comment.publishedDate, comment.lastUpdatedDate);
}
}
return Result.succeed(latestAzureDevOpsTime(times));
}

const RawIterationSchema = Schema.Struct({
id: Schema.Int,
sourceRefCommit: Schema.optional(
Expand Down
Loading