Skip to content
Open
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
64 changes: 64 additions & 0 deletions apps/server/src/pullRequest/GiteaLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
92 changes: 92 additions & 0 deletions apps/server/src/pullRequest/GiteaLifecycle.ts
Original file line number Diff line number Diff line change
@@ -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>): 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>): 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>): 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<RawGiteaLifecycleEvent>): 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<PullRequestAction, "draft" | "ready">;
readonly title: string;
readonly isDraft: boolean;
readonly prefixes: ReadonlyArray<string>;
}): 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;
}
194 changes: 189 additions & 5 deletions apps/server/src/pullRequest/GiteaPullRequestApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -890,9 +956,127 @@ 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",
})),
{ "x-total-count": "50" },
),
),
)
.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("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" }], {
link: '</repos/acme/web/issues/7/timeline?page=2&limit=1>; rel="next"',
"x-total-count": "1",
}),
),
);
mockedRequest.mockReturnValueOnce(
Effect.succeed(
response([{ id: 2, type: "pull_cancel_scheduled_merge" }], { "x-total-count": "1" }),
),
);
const api = yield* GiteaPullRequestApi.GiteaPullRequestApi;
assert.isFalse(
yield* api.getAutoMergeEnabled({
host: "forge.example.test",
repository: "acme/web",
number: 7,
}),
);
expect(callAt(1).path).toBe("/repos/acme/web/issues/7/timeline?page=2&limit=1");
}),
);

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",
});
}),
);
});
Loading