From 741c1b3c4bb583a1d7a8354bfce7aa6e468dbd7d Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Fri, 4 Sep 2026 22:20:27 -0500 Subject: [PATCH 1/5] feat(server): model native Gitea lifecycle state --- .../src/pullRequest/GiteaLifecycle.test.ts | 64 +++++++++++++ apps/server/src/pullRequest/GiteaLifecycle.ts | 92 +++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 apps/server/src/pullRequest/GiteaLifecycle.test.ts create mode 100644 apps/server/src/pullRequest/GiteaLifecycle.ts diff --git a/apps/server/src/pullRequest/GiteaLifecycle.test.ts b/apps/server/src/pullRequest/GiteaLifecycle.test.ts new file mode 100644 index 000000000000..602bdf604f2e --- /dev/null +++ b/apps/server/src/pullRequest/GiteaLifecycle.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + autoMergeEnabled, + titleForDraft, + titleForDraftAction, + titleForReady, +} from "./GiteaLifecycle.ts"; + +describe("Gitea draft titles", () => { + it("uses the configured first prefix and does not stack an existing prefix", () => { + expect(titleForDraft("Ship it", ["Draft:", "[Draft]"])).toBe("Draft: Ship it"); + expect(titleForDraft("draft: Ship it", ["Draft:", "[Draft]"])).toBe("draft: Ship it"); + }); + + it("removes the exact configured prefix case-insensitively", () => { + expect(titleForReady("RFC: Ship it", ["RFC:"])).toBe("Ship it"); + expect(titleForReady("rfc: Ship it", ["RFC:"])).toBe("Ship it"); + expect(titleForReady("Draft: Ship it", ["WIP:", "[WIP]"])).toBeNull(); + }); + + it("does not produce a blank ready title", () => { + expect(titleForReady("WIP: ", ["WIP:"])).toBeNull(); + }); + + it("keeps already-satisfied lifecycle actions idempotent", () => { + expect( + titleForDraftAction({ + action: "draft", + title: "WIP: Ship it", + isDraft: true, + prefixes: ["WIP:"], + }), + ).toBe("WIP: Ship it"); + expect( + titleForDraftAction({ + action: "ready", + title: "Ship it", + isDraft: false, + prefixes: ["WIP:"], + }), + ).toBe("Ship it"); + }); +}); + +describe("Gitea auto-merge timeline", () => { + it("uses the newest durable schedule, cancellation, or merge event", () => { + expect( + autoMergeEnabled([ + { id: 30, type: "pull_cancel_scheduled_merge" }, + { id: 10, type: "pull_scheduled_merge" }, + { id: 20, type: "comment" }, + ]), + ).toBe(false); + expect( + autoMergeEnabled([ + { id: 50, type: "pull_scheduled_merge" }, + { id: 40, type: "merge_pull" }, + ]), + ).toBe(true); + expect(autoMergeEnabled([{ id: 60, type: "merge_pull" }])).toBe(false); + expect(autoMergeEnabled([])).toBe(false); + }); +}); diff --git a/apps/server/src/pullRequest/GiteaLifecycle.ts b/apps/server/src/pullRequest/GiteaLifecycle.ts new file mode 100644 index 000000000000..ea2589f65ef9 --- /dev/null +++ b/apps/server/src/pullRequest/GiteaLifecycle.ts @@ -0,0 +1,92 @@ +import * as Config from "effect/Config"; +import * as Schema from "effect/Schema"; + +import type { PullRequestAction } from "@t3tools/contracts"; + +const DEFAULT_DRAFT_PREFIXES = ["WIP:", "[WIP]"] as const; + +export const RawGiteaLifecycleEvent = Schema.Struct({ + id: Schema.Int, + type: Schema.String, +}); +export type RawGiteaLifecycleEvent = typeof RawGiteaLifecycleEvent.Type; + +export const draftPrefixesConfig = Config.string("T3CODE_GITEA_DRAFT_PREFIXES").pipe( + Config.withDefault(DEFAULT_DRAFT_PREFIXES.join(",")), + Config.map((value) => { + const prefixes = value + .split(",") + .map((prefix) => prefix.trim()) + .filter((prefix) => prefix !== ""); + return prefixes.length === 0 ? DEFAULT_DRAFT_PREFIXES : prefixes; + }), +); + +function asciiEqualFold(left: string, right: string): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftCode = left.charCodeAt(index); + const rightCode = right.charCodeAt(index); + const foldedLeft = leftCode >= 65 && leftCode <= 90 ? leftCode + 32 : leftCode; + const foldedRight = rightCode >= 65 && rightCode <= 90 ? rightCode + 32 : rightCode; + if (foldedLeft !== foldedRight) return false; + } + return true; +} + +function matchingDraftPrefix(title: string, prefixes: ReadonlyArray): string | undefined { + return prefixes.find( + (prefix) => + prefix.length <= title.length && asciiEqualFold(title.slice(0, prefix.length), prefix), + ); +} + +/** + * Gitea derives draft state from a configurable title prefix. T3 must be configured with the same + * comma-separated prefix list when the server changes Gitea's defaults. + */ +export function titleForDraft(title: string, prefixes: ReadonlyArray): string { + if (matchingDraftPrefix(title, prefixes) !== undefined) return title; + const prefix = prefixes[0] ?? DEFAULT_DRAFT_PREFIXES[0]; + return `${prefix.trimEnd()} ${title}`; +} + +/** Returns null when the configured list cannot identify Gitea's effective prefix safely. */ +export function titleForReady(title: string, prefixes: ReadonlyArray): string | null { + const prefix = matchingDraftPrefix(title, prefixes); + if (prefix === undefined) return null; + const readyTitle = title.slice(prefix.length).trim(); + return readyTitle === "" ? null : readyTitle; +} + +/** + * Scheduling and cancellation are committed in the same database transaction as these timeline + * events in Gitea 1.27. A merge also removes the scheduled row, so the newest relevant event is a + * durable cross-client answer even though Gitea omits auto-merge from its pull response. + */ +export function autoMergeEnabled(events: ReadonlyArray): boolean { + let latest: RawGiteaLifecycleEvent | undefined; + for (const event of events) { + if ( + event.type !== "pull_scheduled_merge" && + event.type !== "pull_cancel_scheduled_merge" && + event.type !== "merge_pull" + ) { + continue; + } + if (latest === undefined || event.id > latest.id) latest = event; + } + return latest?.type === "pull_scheduled_merge"; +} + +export function titleForDraftAction(input: { + readonly action: Extract; + readonly title: string; + readonly isDraft: boolean; + readonly prefixes: ReadonlyArray; +}): string | null { + if (input.action === "draft") { + return input.isDraft ? input.title : titleForDraft(input.title, input.prefixes); + } + return input.isDraft ? titleForReady(input.title, input.prefixes) : input.title; +} From c4d30f33c8a69af775b602763872d58c9f9a6a45 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Fri, 4 Sep 2026 22:20:36 -0500 Subject: [PATCH 2/5] feat(server): support Gitea lifecycle actions --- .../pullRequest/GiteaPullRequestApi.test.ts | 166 +++++++++++++++++- .../src/pullRequest/GiteaPullRequestApi.ts | 101 +++++++++++ .../GiteaPullRequestProvider.test.ts | 36 +++- .../pullRequest/GiteaPullRequestProvider.ts | 79 ++++++--- 4 files changed, 344 insertions(+), 38 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index f6b60ecd7b94..66d631096a5f 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -870,9 +870,34 @@ layer("GiteaPullRequestApi", (it) => { }), ); - it.effect("uses Gitea's native update style and refuses unverified draft transitions", () => + it.effect("uses Gitea's native update style and verifies reversible draft transitions", () => Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.succeed(response({}))); + mockedRequest + .mockReturnValueOnce(Effect.succeed(response({}))) + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7)))) + .mockReturnValueOnce(Effect.succeed(response({}))) + .mockReturnValueOnce( + Effect.succeed( + response( + rawPullRequest(7, { + title: "WIP: Pull request 7", + draft: true, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response( + rawPullRequest(7, { + title: "WIP: Pull request 7", + draft: true, + }), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(response({}))) + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7)))); const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; yield* api.runAction({ host: "forge.example.test", @@ -881,6 +906,47 @@ layer("GiteaPullRequestApi", (it) => { action: "update-branch", updateMethod: "rebase", }); + yield* api.runAction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + action: "draft", + }); + yield* api.runAction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + action: "ready", + }); + + expect(callAt(0).path).toBe("/repos/acme/web/pulls/7/update?style=rebase"); + expect(decodeJson(callAt(2).body ?? "{}")).toEqual({ + title: "WIP: Pull request 7", + }); + expect(decodeJson(callAt(5).body ?? "{}")).toEqual({ + title: "Pull request 7", + }); + assert.strictEqual(mockedRequest.mock.calls.length, 7); + }), + ); + + it.effect("restores the title when Gitea does not recognize the configured draft prefix", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7)))) + .mockReturnValueOnce(Effect.succeed(response({}))) + .mockReturnValueOnce( + Effect.succeed( + response( + rawPullRequest(7, { + title: "WIP: Pull request 7", + draft: false, + }), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(response({}))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; const error = yield* api .runAction({ host: "forge.example.test", @@ -890,9 +956,99 @@ layer("GiteaPullRequestApi", (it) => { }) .pipe(Effect.flip); - expect(callAt(0).path).toBe("/repos/acme/web/pulls/7/update?style=rebase"); - expect(error.detail).toContain("does not expose a reliable draft operation"); - assert.strictEqual(mockedRequest.mock.calls.length, 1); + expect(error.detail).toContain("T3CODE_GITEA_DRAFT_PREFIXES"); + expect(decodeJson(callAt(3).body ?? "{}")).toEqual({ + title: "Pull request 7", + }); + }), + ); + + it.effect("reads armed auto-merge state from Gitea's durable timeline events", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response([ + { id: 10, type: "pull_scheduled_merge" }, + { id: 11, type: "comment" }, + { id: 12, type: "pull_cancel_scheduled_merge" }, + { id: 13, type: "pull_scheduled_merge" }, + ]), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + + assert.isTrue( + yield* api.getAutoMergeEnabled({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }), + ); + expect(callAt(0).path).toBe("/repos/acme/web/issues/7/timeline?page=1&limit=50"); + }), + ); + + it.effect("paginates the timeline before deciding that auto-merge is armed", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length: 50 }, (_, id) => ({ + id, + type: "comment", + })), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(response([{ id: 51, type: "pull_scheduled_merge" }]))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + + assert.isTrue( + yield* api.getAutoMergeEnabled({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }), + ); + expect(callAt(1).path).toContain("page=2"); + }), + ); + + it.effect("arms and cancels Gitea auto-merge through the native merge route", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7)))) + .mockReturnValueOnce(Effect.succeed(response({}))) + .mockReturnValueOnce(Effect.succeed(response({}))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + yield* api.runAction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + action: "enable-auto-merge", + mergeMethod: "squash", + }); + yield* api.runAction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + action: "disable-auto-merge", + }); + + expect(callAt(1)).toMatchObject({ + method: "POST", + path: "/repos/acme/web/pulls/7/merge", + }); + expect(decodeJson(callAt(1).body ?? "{}")).toEqual({ + do: "squash", + head_commit_id: "head-sha", + merge_when_checks_succeed: true, + }); + expect(callAt(2)).toMatchObject({ + method: "DELETE", + path: "/repos/acme/web/pulls/7/merge", + }); }), ); }); diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 4ea3299c1d53..e10209a48059 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -25,6 +25,7 @@ import type { } from "@t3tools/contracts"; import * as GiteaApi from "../sourceControl/GiteaApi.ts"; +import * as GiteaLifecycle from "./GiteaLifecycle.ts"; import type { ProviderListCursor } from "./PullRequestProvider.ts"; import { dedupeChecks } from "./pullRequestChecks.ts"; @@ -428,6 +429,11 @@ export class GiteaPullRequestApi extends Context.Service< host: string; repository: string; }) => Effect.Effect; + readonly getAutoMergeEnabled: (input: { + host: string; + repository: string; + number: number; + }) => Effect.Effect; readonly listComments: (input: { host: string; repository: string; @@ -558,6 +564,7 @@ export class GiteaPullRequestApi extends Context.Service< export const make = Effect.gen(function* () { const gitea = yield* GiteaApi.GiteaApi; + const draftPrefixes = yield* GiteaLifecycle.draftPrefixesConfig; const failure = (operation: string, error: GiteaApi.GiteaApiError) => new GiteaPullRequestApiError({ @@ -1174,6 +1181,93 @@ export const make = Effect.gen(function* () { ...(input.body === undefined ? {} : { body: encodeObject(input.body) }), }).pipe(Effect.asVoid); + const getAutoMergeEnabled = Effect.fn("GiteaPullRequestApi.getAutoMergeEnabled")( + function* (input: { host: string; repository: string; number: number }) { + const operation = "getAutoMergeEnabled"; + const events: Array = []; + for (let page = 1; page <= MAX_PAGINATION_PAGES; page += 1) { + const response = yield* request({ + operation, + host: input.host, + repository: input.repository, + method: "GET", + path: query(`${basePath(input.repository)}/issues/${input.number}/timeline`, { + page, + limit: PAGE_SIZE, + }), + }); + const pageEvents = yield* decode( + operation, + Schema.Array(GiteaLifecycle.RawGiteaLifecycleEvent), + response, + ); + events.push(...pageEvents); + if (pageEvents.length < PAGE_SIZE) return GiteaLifecycle.autoMergeEnabled(events); + } + return yield* new GiteaPullRequestApiError({ + operation, + reason: "failed", + detail: "Gitea's pull request timeline exceeded the pagination safety bound.", + }); + }, + ); + + const setDraftState = Effect.fn("GiteaPullRequestApi.setDraftState")(function* (input: { + host: string; + repository: string; + number: number; + action: Extract; + }) { + const before = yield* getPullRequest(input); + const title = GiteaLifecycle.titleForDraftAction({ + action: input.action, + title: before.title, + isDraft: before.isDraft, + prefixes: draftPrefixes, + }); + if (title === null) { + return yield* new GiteaPullRequestApiError({ + operation: "runAction", + reason: "failed", + detail: + "Gitea reports this pull request as draft, but its title does not start with a configured T3CODE_GITEA_DRAFT_PREFIXES value.", + }); + } + if (title === before.title) return; + + const path = `${basePath(input.repository)}/pulls/${input.number}`; + yield* write({ + operation: "runAction", + host: input.host, + repository: input.repository, + method: "PATCH", + path, + body: { title }, + }); + const after = yield* getPullRequest(input); + const expectedDraft = input.action === "draft"; + if (after.isDraft === expectedDraft) return; + + // A mismatched prefix changed the title without changing Gitea's draft state. Put the title + // back only while it is still exactly the value this operation wrote. + if (after.title === title) { + yield* write({ + operation: "runAction", + host: input.host, + repository: input.repository, + method: "PATCH", + path, + body: { title: before.title }, + }); + } + return yield* new GiteaPullRequestApiError({ + operation: "runAction", + reason: "failed", + detail: + "Gitea did not apply the requested draft state. Set T3CODE_GITEA_DRAFT_PREFIXES to the server's WORK_IN_PROGRESS_PREFIXES value.", + }); + }); + const unsupportedAction = (action: string) => new GiteaPullRequestApiError({ operation: "runAction", @@ -1203,6 +1297,7 @@ export const make = Effect.gen(function* () { listPullRequests, getPullRequest, getRepositoryAccess, + getAutoMergeEnabled, listComments, listReviews, listCommits, @@ -1363,6 +1458,12 @@ export const make = Effect.gen(function* () { }); case "ready": case "draft": + return setDraftState({ + host: input.host, + repository: input.repository, + number: input.number, + action: input.action, + }); case "revert": case "approve-workflows": return Effect.fail(unsupportedAction(input.action)); diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts index e035329c1b77..df0867a7c2ed 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { giteaProviderFailure, giteaViewerPermissions } from "./GiteaPullRequestProvider.ts"; +import { + giteaBaseComparison, + giteaProviderFailure, + giteaViewerPermissions, +} from "./GiteaPullRequestProvider.ts"; import { GiteaPullRequestApiError } from "./GiteaPullRequestApi.ts"; describe("giteaViewerPermissions", () => { @@ -12,7 +16,16 @@ describe("giteaViewerPermissions", () => { updateMethods: ["rebase"], }), ).toEqual({ - actions: ["merge", "close", "reopen", "update-branch"], + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ], comment: true, resolve: true, verdicts: ["comment", "approve", "request-changes"], @@ -30,10 +43,10 @@ describe("giteaViewerPermissions", () => { updateMethods: ["merge", "rebase"], }), ).toEqual({ - actions: ["close", "reopen"], + actions: ["ready", "draft", "close", "reopen"], comment: true, - resolve: false, - verdicts: ["comment", "approve", "request-changes"], + resolve: true, + verdicts: ["comment"], requestReviewers: false, updateMethods: [], labels: false, @@ -51,6 +64,19 @@ describe("giteaViewerPermissions", () => { }); }); +describe("giteaBaseComparison", () => { + it("compares Gitea's merge base with the current base tip", () => { + expect(giteaBaseComparison({ baseSha: "base", mergeBaseSha: "base" })).toBe("up-to-date"); + expect( + giteaBaseComparison({ + baseSha: "new-base", + mergeBaseSha: "old-base", + }), + ).toBe("behind"); + expect(giteaBaseComparison({ baseSha: "base", mergeBaseSha: "" })).toBe("unknown"); + }); +}); + describe("giteaProviderFailure", () => { it("maps missing configuration to unauthenticated", () => { expect( diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index a7063e63f9a3..dc882dbe7b8b 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -14,7 +14,16 @@ import { const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: true, - actions: ["merge", "close", "reopen", "update-branch"], + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ], mergeMethods: ["merge", "squash", "rebase"], updateMethods: ["merge", "rebase"], // Gitea's repository pull listing has no text parameter. Returning an unfiltered page keeps @@ -55,18 +64,26 @@ export function giteaViewerPermissions(input: { }): PullRequestViewerPermissions { return { actions: CAPABILITIES.actions.filter((action) => { - if (action === "close" || action === "reopen") return input.canWrite || input.ownsPullRequest; + if (action === "ready" || action === "draft" || action === "close" || action === "reopen") + return input.canWrite || input.ownsPullRequest; return input.canWrite; }), comment: true, - resolve: input.canWrite, - verdicts: CAPABILITIES.review.verdicts, + resolve: input.canWrite || input.ownsPullRequest, + verdicts: input.ownsPullRequest ? ["comment"] : CAPABILITIES.review.verdicts, requestReviewers: input.canWrite, updateMethods: input.canWrite ? input.updateMethods : [], labels: input.canWrite, }; } +export function giteaBaseComparison( + pullRequest: Pick, +): "up-to-date" | "behind" | "unknown" { + if (pullRequest.baseSha === "" || pullRequest.mergeBaseSha === "") return "unknown"; + return pullRequest.baseSha === pullRequest.mergeBaseSha ? "up-to-date" : "behind"; +} + function toChangeRequest(pullRequest: GiteaPullRequestApi.GiteaPullRequest): ProviderChangeRequest { return { number: pullRequest.number, @@ -140,30 +157,36 @@ export const make = Effect.gen(function* () { ), getChangeRequest: (input) => - Effect.all([api.getPullRequest(input), api.getRepositoryAccess(input), api.getViewer()], { - concurrency: 3, - }).pipe( - Effect.flatMap(([pullRequest, access, viewer]) => - api - .listChecks({ ...input, sha: pullRequest.headSha }) - .pipe(Effect.orElseSucceed(() => [])) - .pipe( - Effect.map((checks): ProviderChangeRequestDetail => ({ - ...toChangeRequest(pullRequest), - body: pullRequest.body, - changedFiles: pullRequest.changedFiles, - mergedAt: pullRequest.mergedAt, - closedAt: pullRequest.closedAt, - reviewers: pullRequest.reviewers, - checks, - mergeCapabilities: access.mergeCapabilities, - viewerPermissions: permissions({ - access, - viewer, - author: pullRequest.author?.login, - }), - })), - ), + Effect.all( + [ + api.getPullRequest(input), + api.getRepositoryAccess(input), + api.getViewer(), + api.getAutoMergeEnabled(input), + ], + { concurrency: 4 }, + ).pipe( + Effect.flatMap(([pullRequest, access, viewer, autoMergeEnabled]) => + api.listChecks({ ...input, sha: pullRequest.headSha }).pipe( + Effect.orElseSucceed(() => []), + Effect.map((checks): ProviderChangeRequestDetail => ({ + ...toChangeRequest(pullRequest), + body: pullRequest.body, + changedFiles: pullRequest.changedFiles, + mergedAt: pullRequest.mergedAt, + closedAt: pullRequest.closedAt, + reviewers: pullRequest.reviewers, + checks, + mergeCapabilities: access.mergeCapabilities, + baseComparison: giteaBaseComparison(pullRequest), + autoMergeEnabled, + viewerPermissions: permissions({ + access, + viewer, + author: pullRequest.author?.login, + }), + })), + ), ), Effect.mapError(fail("getChangeRequest")), ), From 39a28c350b81b130f9bb4eda3e41172fd69f17d3 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Fri, 4 Sep 2026 22:45:13 -0500 Subject: [PATCH 3/5] fix(pull-requests): read complete Gitea auto-merge timelines --- .../pullRequest/GiteaPullRequestApi.test.ts | 24 +++++++++++++++++++ .../src/pullRequest/GiteaPullRequestApi.ts | 19 +++++++++++---- docs/user/source-control.md | 5 ++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 66d631096a5f..81751a5ca1f4 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -1015,6 +1015,30 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("honors a server timeline page-size cap before reading the final merge state", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response([{ id: 1, type: "pull_scheduled_merge" }], { "x-total-count": "2" }), + ), + ); + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response([{ id: 2, type: "pull_cancel_scheduled_merge" }], { "x-total-count": "2" }), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + assert.isFalse( + yield* api.getAutoMergeEnabled({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }), + ); + expect(callAt(1).path).toContain("page=2"); + }), + ); + it.effect("arms and cancels Gitea auto-merge through the native merge route", () => Effect.gen(function* () { mockedRequest diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index e10209a48059..c9ae5268ab2c 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1185,16 +1185,17 @@ export const make = Effect.gen(function* () { function* (input: { host: string; repository: string; number: number }) { const operation = "getAutoMergeEnabled"; const events: Array = []; + let path = query(`${basePath(input.repository)}/issues/${input.number}/timeline`, { + page: 1, + limit: PAGE_SIZE, + }); for (let page = 1; page <= MAX_PAGINATION_PAGES; page += 1) { const response = yield* request({ operation, host: input.host, repository: input.repository, method: "GET", - path: query(`${basePath(input.repository)}/issues/${input.number}/timeline`, { - page, - limit: PAGE_SIZE, - }), + path, }); const pageEvents = yield* decode( operation, @@ -1202,7 +1203,15 @@ export const make = Effect.gen(function* () { response, ); events.push(...pageEvents); - if (pageEvents.length < PAGE_SIZE) return GiteaLifecycle.autoMergeEnabled(events); + const next = nextPagePath({ + path, + page, + pageRows: pageEvents.length, + rowsSeen: events.length, + headers: response.headers, + }); + if (next === null) return GiteaLifecycle.autoMergeEnabled(events); + path = next; } return yield* new GiteaPullRequestApiError({ operation, diff --git a/docs/user/source-control.md b/docs/user/source-control.md index b9ab65f9ebad..853ef9a63689 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -70,6 +70,11 @@ Use SSH aliases as `git@work-forge:owner/repository.git` or `ssh://git@work-forge/owner/repository.git`. API requests always go to the configured web address. Configure Git authentication separately for cloning, fetching, and pushing. +Gitea drafts use a title prefix. If your server uses custom work-in-progress prefixes, configure +`T3CODE_GITEA_DRAFT_PREFIXES` with the same comma-separated values and restart T3. T3 verifies +that draft/ready changes take effect. Auto-merge uses Gitea's own scheduler and can merge immediately +when the request already satisfies its requirements. + ### Azure DevOps Install [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/), add the DevOps extension, and sign in: From 3c866b6f6b26ebfc9f044ab8ba31c634dc4b516b Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 01:57:40 -0500 Subject: [PATCH 4/5] fix(pull-requests): paginate Gitea timelines correctly --- .../src/pullRequest/GiteaPullRequestApi.test.ts | 12 ++++++++---- apps/server/src/pullRequest/GiteaPullRequestApi.ts | 12 +++++------- docs/user/source-control.md | 6 +++--- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 81751a5ca1f4..a9db186f6c53 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -998,6 +998,7 @@ layer("GiteaPullRequestApi", (it) => { id, type: "comment", })), + { "x-total-count": "50" }, ), ), ) @@ -1015,16 +1016,19 @@ layer("GiteaPullRequestApi", (it) => { }), ); - it.effect("honors a server timeline page-size cap before reading the final merge state", () => + it.effect("follows a timeline next link before reading the final merge state", () => Effect.gen(function* () { mockedRequest.mockReturnValueOnce( Effect.succeed( - response([{ id: 1, type: "pull_scheduled_merge" }], { "x-total-count": "2" }), + response([{ id: 1, type: "pull_scheduled_merge" }], { + link: '; rel="next"', + "x-total-count": "1", + }), ), ); mockedRequest.mockReturnValueOnce( Effect.succeed( - response([{ id: 2, type: "pull_cancel_scheduled_merge" }], { "x-total-count": "2" }), + response([{ id: 2, type: "pull_cancel_scheduled_merge" }], { "x-total-count": "1" }), ), ); const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; @@ -1035,7 +1039,7 @@ layer("GiteaPullRequestApi", (it) => { number: 7, }), ); - expect(callAt(1).path).toContain("page=2"); + expect(callAt(1).path).toBe("/repos/acme/web/issues/7/timeline?page=2&limit=1"); }), ); diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index c9ae5268ab2c..d1dfeda5626d 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1203,13 +1203,11 @@ export const make = Effect.gen(function* () { response, ); events.push(...pageEvents); - const next = nextPagePath({ - path, - page, - pageRows: pageEvents.length, - rowsSeen: events.length, - headers: response.headers, - }); + // Gitea's timeline route reports the current page length in X-Total-Count rather than the + // total number of events, so that header cannot prove the timeline is complete. + const next = + nextLink(response.headers) ?? + (pageEvents.length >= PAGE_SIZE ? pathAtPage(path, page + 1) : null); if (next === null) return GiteaLifecycle.autoMergeEnabled(events); path = next; } diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 853ef9a63689..f2713cba2b1a 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -114,9 +114,9 @@ For Azure DevOps, use the host website to view diffs or change comments. Bitbuck reopening a declined pull request. Gitea supports PR tracking, comments, reviews, diffs, reviewer and label updates, merge methods, -branch updates, and close/reopen. Draft status is shown when Gitea reports it, but draft/ready -changes, auto-merge controls, reactions, comment editing, workflow approval, and revert PRs are -not currently available in T3. Use your Gitea website for those tasks. +branch updates, close/reopen, draft/ready changes, and auto-merge controls. Reactions, comment +editing, workflow approval, and revert PRs are not currently available in T3. Use your Gitea +website for those tasks. ## Troubleshooting From 92761803ad9ee6d548af49b2965323ae28806018 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 02:10:36 -0500 Subject: [PATCH 5/5] fix(pull-requests): tolerate Gitea timeline failures --- .../GiteaPullRequestProvider.test.ts | 91 ++++++++++++++++++- .../pullRequest/GiteaPullRequestProvider.ts | 4 +- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts index df0867a7c2ed..60596816bab9 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -1,12 +1,101 @@ -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as GiteaApi from "../sourceControl/GiteaApi.ts"; import { giteaBaseComparison, giteaProviderFailure, giteaViewerPermissions, + make as makeGiteaPullRequestProvider, } from "./GiteaPullRequestProvider.ts"; +import * as GiteaPullRequestApi from "./GiteaPullRequestApi.ts"; import { GiteaPullRequestApiError } from "./GiteaPullRequestApi.ts"; +function response(value: unknown) { + return { body: JSON.stringify(value), truncated: false, headers: {} }; +} + +function rawPullRequest() { + return { + number: 7, + title: "Pull request 7", + body: "Body", + state: "open", + merged: false, + mergeable: true, + draft: false, + html_url: "https://forge.example.test/gitea/acme/web/pulls/7", + created_at: "2026-09-01T10:00:00Z", + updated_at: "2026-09-02T10:00:00Z", + additions: 4, + deletions: 2, + changed_files: 1, + comments: 1, + review_comments: 2, + merge_base: "base-sha", + user: { id: 1, login: "author", full_name: "Author" }, + base: { ref: "main", sha: "base-sha", repo: { full_name: "acme/web" } }, + head: { ref: "feature", sha: "head-sha", repo: { full_name: "fork/web" } }, + requested_reviewers: [], + labels: [], + }; +} + +describe("GiteaPullRequestProvider", () => { + it.effect("keeps pull request detail when auto-merge state cannot be read", () => + Effect.gen(function* () { + const request = vi.fn((input) => { + switch (input.path) { + case "/repos/acme/web/pulls/7": + return Effect.succeed(response(rawPullRequest())); + case "/repos/acme/web": + return Effect.succeed(response({ permissions: { push: true } })); + case "/user": + return Effect.succeed(response({ login: "reader" })); + case "/repos/acme/web/issues/7/timeline?page=1&limit=50": + return Effect.fail( + new GiteaApi.GiteaApiError({ + operation: "getAutoMergeEnabled", + reason: "failed", + detail: "timeline unavailable", + }), + ); + case "/repos/acme/web/commits/head-sha/status?page=1&limit=50": + return Effect.succeed(response({ statuses: [], total_count: 0 })); + default: + return Effect.die(`Unexpected Gitea request: ${input.path}`); + } + }); + const apiLayer = GiteaPullRequestApi.layer.pipe( + Layer.provide( + Layer.succeed( + GiteaApi.GiteaApi, + GiteaApi.GiteaApi.of({ + baseUrl: Option.some("https://forge.example.test/gitea"), + sshHosts: [], + request, + probeAuth: Effect.die("not used"), + }), + ), + ), + ); + const provider = yield* makeGiteaPullRequestProvider.pipe(Effect.provide(apiLayer)); + + const detail = yield* provider.getChangeRequest({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect(detail.number).toBe(7); + expect(detail.checks).toEqual([]); + expect("autoMergeEnabled" in detail).toBe(false); + }), + ); +}); + describe("giteaViewerPermissions", () => { it("offers repository writes and only the configured branch update strategies", () => { expect( diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index dc882dbe7b8b..e798da895b9c 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -162,7 +162,7 @@ export const make = Effect.gen(function* () { api.getPullRequest(input), api.getRepositoryAccess(input), api.getViewer(), - api.getAutoMergeEnabled(input), + api.getAutoMergeEnabled(input).pipe(Effect.orElseSucceed(() => undefined)), ], { concurrency: 4 }, ).pipe( @@ -179,7 +179,7 @@ export const make = Effect.gen(function* () { checks, mergeCapabilities: access.mergeCapabilities, baseComparison: giteaBaseComparison(pullRequest), - autoMergeEnabled, + ...(autoMergeEnabled === undefined ? {} : { autoMergeEnabled }), viewerPermissions: permissions({ access, viewer,