feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only) Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged. - Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry. - GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved. - forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client). - createPullRequest reports typed CLI/auth errors instead of a generic message. - forge-resolver host/remote caches are LRU-bounded. - Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com. - Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology. - Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs. * feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals. - threadIsResolved is part of the neutral timeline item. - Pipeline load failures show an error instead of an empty section. - Manual pipeline jobs render as pending. - Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request). * feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg) One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts. - CI status aggregates commit statuses and Actions runs together. - Gitea's terminal "warning" state maps to failure on server and client. - Gitea Actions check details are reachable from the PR pane by workflowRunId. * refactor(forge): localize compatibility handling * test(forge): expect normalized GitLab facts --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
04d1ebdce0
commit
a8ebd390fa
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
|
||||
|
||||
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
|
||||
forge: "github";
|
||||
mergeStateStatus: string | null;
|
||||
autoMergeRequest: {
|
||||
enabledAt: string | null;
|
||||
mergeMethod: string | null;
|
||||
enabledBy: string | null;
|
||||
} | null;
|
||||
viewerCanEnableAutoMerge: boolean;
|
||||
viewerCanDisableAutoMerge: boolean;
|
||||
viewerCanMergeAsAdmin: boolean;
|
||||
viewerCanUpdateBranch: boolean;
|
||||
repository: {
|
||||
autoMergeAllowed: boolean;
|
||||
mergeCommitAllowed: boolean;
|
||||
squashMergeAllowed: boolean;
|
||||
rebaseMergeAllowed: boolean;
|
||||
viewerDefaultMergeMethod: string | null;
|
||||
};
|
||||
isMergeQueueEnabled: boolean;
|
||||
isInMergeQueue: boolean;
|
||||
};
|
||||
|
||||
type GitlabMergeFactsFixture = ForgeSpecificStatusFacts & {
|
||||
forge: "gitlab";
|
||||
detailedMergeStatus: string | null;
|
||||
hasConflicts: boolean;
|
||||
blockingDiscussionsResolved: boolean;
|
||||
approvalsRequired: number;
|
||||
approvalsGiven: number;
|
||||
pipelineStatus: string | null;
|
||||
pipelineId: number | null;
|
||||
pipelineUrl: string | null;
|
||||
mergeWhenPipelineSucceeds: boolean;
|
||||
};
|
||||
|
||||
type GiteaMergeFactsFixture = ForgeSpecificStatusFacts & {
|
||||
forge: "gitea";
|
||||
mergeable: boolean;
|
||||
hasMerged: boolean;
|
||||
ciStatus: string | null;
|
||||
};
|
||||
|
||||
function facts(overrides: Partial<GithubMergeFactsFixture> = {}): GithubMergeFactsFixture {
|
||||
return {
|
||||
forge: "github",
|
||||
mergeStateStatus: "CLEAN",
|
||||
autoMergeRequest: null,
|
||||
viewerCanEnableAutoMerge: false,
|
||||
viewerCanDisableAutoMerge: false,
|
||||
viewerCanMergeAsAdmin: false,
|
||||
viewerCanUpdateBranch: false,
|
||||
repository: {
|
||||
autoMergeAllowed: false,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
viewerDefaultMergeMethod: "SQUASH",
|
||||
},
|
||||
isMergeQueueEnabled: false,
|
||||
isInMergeQueue: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function gitlabFacts(overrides: Partial<GitlabMergeFactsFixture> = {}): GitlabMergeFactsFixture {
|
||||
return {
|
||||
forge: "gitlab",
|
||||
detailedMergeStatus: "mergeable",
|
||||
hasConflicts: false,
|
||||
blockingDiscussionsResolved: true,
|
||||
approvalsRequired: 0,
|
||||
approvalsGiven: 0,
|
||||
pipelineStatus: "success",
|
||||
pipelineId: null,
|
||||
pipelineUrl: null,
|
||||
mergeWhenPipelineSucceeds: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function giteaFacts(overrides: Partial<GiteaMergeFactsFixture> = {}): GiteaMergeFactsFixture {
|
||||
return {
|
||||
forge: "gitea",
|
||||
mergeable: true,
|
||||
hasMerged: false,
|
||||
ciStatus: "success",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveMergeCapability", () => {
|
||||
it("returns null when the forge supplied no merge facts", () => {
|
||||
expect(deriveMergeCapability(null)).toBeNull();
|
||||
expect(deriveMergeCapability(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects untagged and schema-mismatched forge facts at the registry boundary", () => {
|
||||
expect(deriveMergeCapability({ approvalsRequired: 2 })).toBeNull();
|
||||
expect(
|
||||
deriveMergeCapability({
|
||||
...gitlabFacts(),
|
||||
approvalsRequired: "two",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("marks direct merge ready only for the GitHub clean states", () => {
|
||||
expect(deriveMergeCapability(facts({ mergeStateStatus: "CLEAN" }))?.directMergeReady).toBe(
|
||||
true,
|
||||
);
|
||||
expect(deriveMergeCapability(facts({ mergeStateStatus: "HAS_HOOKS" }))?.directMergeReady).toBe(
|
||||
true,
|
||||
);
|
||||
expect(deriveMergeCapability(facts({ mergeStateStatus: "BLOCKED" }))?.directMergeReady).toBe(
|
||||
false,
|
||||
);
|
||||
expect(deriveMergeCapability(facts({ mergeStateStatus: null }))?.directMergeReady).toBe(false);
|
||||
});
|
||||
|
||||
it("can enable auto-merge only when blocked, allowed, and the viewer may enable it", () => {
|
||||
const ready = facts({
|
||||
mergeStateStatus: "BLOCKED",
|
||||
viewerCanEnableAutoMerge: true,
|
||||
repository: {
|
||||
autoMergeAllowed: true,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
viewerDefaultMergeMethod: "SQUASH",
|
||||
},
|
||||
});
|
||||
expect(deriveMergeCapability(ready)?.canEnableAutoMerge).toBe(true);
|
||||
|
||||
expect(
|
||||
deriveMergeCapability(facts({ ...ready, viewerCanEnableAutoMerge: false }))
|
||||
?.canEnableAutoMerge,
|
||||
).toBe(false);
|
||||
expect(
|
||||
deriveMergeCapability(facts({ ...ready, mergeStateStatus: "CLEAN" }))?.canEnableAutoMerge,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reports whether auto-merge is already enabled and can be disabled", () => {
|
||||
const cap = deriveMergeCapability(
|
||||
facts({
|
||||
autoMergeRequest: { enabledAt: "now", mergeMethod: "SQUASH", enabledBy: "octocat" },
|
||||
viewerCanDisableAutoMerge: true,
|
||||
}),
|
||||
);
|
||||
expect(cap?.autoMergeEnabled).toBe(true);
|
||||
expect(cap?.canDisableAutoMerge).toBe(true);
|
||||
expect(deriveMergeCapability(facts())?.autoMergeEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an enabled or in-progress merge queue as blocking", () => {
|
||||
expect(deriveMergeCapability(facts({ isMergeQueueEnabled: true }))?.mergeBlockedByQueue).toBe(
|
||||
true,
|
||||
);
|
||||
expect(deriveMergeCapability(facts({ isInMergeQueue: true }))?.mergeBlockedByQueue).toBe(true);
|
||||
expect(deriveMergeCapability(facts())?.mergeBlockedByQueue).toBe(false);
|
||||
});
|
||||
|
||||
it("derives allowed methods and the preferred method from the repository policy", () => {
|
||||
const cap = deriveMergeCapability(
|
||||
facts({
|
||||
repository: {
|
||||
autoMergeAllowed: false,
|
||||
mergeCommitAllowed: false,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
viewerDefaultMergeMethod: "REBASE",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(cap?.allowedMethods).toEqual(["squash", "rebase"]);
|
||||
expect(cap?.preferredMethod).toBe("rebase");
|
||||
});
|
||||
|
||||
it("returns a null preferred method when the forge reports an unknown default", () => {
|
||||
expect(
|
||||
deriveMergeCapability(
|
||||
facts({ repository: { ...facts().repository, viewerDefaultMergeMethod: null } }),
|
||||
)?.preferredMethod,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveMergeCapability (legacy github fallback)", () => {
|
||||
it("synthesizes full GitHub capability from legacy status.github when forgeSpecific is absent", () => {
|
||||
const { forge: _forge, ...legacy } = facts({
|
||||
mergeStateStatus: "CLEAN",
|
||||
repository: {
|
||||
autoMergeAllowed: false,
|
||||
mergeCommitAllowed: false,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: "SQUASH",
|
||||
},
|
||||
});
|
||||
const cap = deriveMergeCapability(undefined, legacy);
|
||||
expect(cap).not.toBeNull();
|
||||
expect(cap?.directMergeReady).toBe(true);
|
||||
expect(cap?.allowedMethods).toEqual(["squash"]);
|
||||
expect(cap?.preferredMethod).toBe("squash");
|
||||
});
|
||||
|
||||
it("returns null when both forgeSpecific and legacy github facts are absent", () => {
|
||||
expect(deriveMergeCapability(undefined, null)).toBeNull();
|
||||
expect(deriveMergeCapability(undefined, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveMergeCapability (gitlab)", () => {
|
||||
it("produces a non-null capability for the gitlab arm", () => {
|
||||
expect(deriveMergeCapability(gitlabFacts())).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks direct merge ready only when GitLab reports the mergeable status", () => {
|
||||
expect(
|
||||
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "mergeable" }))?.directMergeReady,
|
||||
).toBe(true);
|
||||
expect(
|
||||
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "ci_still_running" }))
|
||||
?.directMergeReady,
|
||||
).toBe(false);
|
||||
expect(
|
||||
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "discussions_not_resolved" }))
|
||||
?.directMergeReady,
|
||||
).toBe(false);
|
||||
expect(
|
||||
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: null }))?.directMergeReady,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reflects merge-when-pipeline-succeeds as an enabled auto-merge", () => {
|
||||
const enabled = deriveMergeCapability(
|
||||
gitlabFacts({ mergeWhenPipelineSucceeds: true, pipelineStatus: "running" }),
|
||||
);
|
||||
expect(enabled?.autoMergeEnabled).toBe(true);
|
||||
expect(enabled?.canDisableAutoMerge).toBe(true);
|
||||
expect(enabled?.canEnableAutoMerge).toBe(false);
|
||||
expect(deriveMergeCapability(gitlabFacts())?.autoMergeEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("can enable auto-merge only while a pipeline is still in flight", () => {
|
||||
expect(
|
||||
deriveMergeCapability(gitlabFacts({ pipelineStatus: "created" }))?.canEnableAutoMerge,
|
||||
).toBe(true);
|
||||
expect(
|
||||
deriveMergeCapability(gitlabFacts({ pipelineStatus: "waiting_for_resource" }))
|
||||
?.canEnableAutoMerge,
|
||||
).toBe(true);
|
||||
expect(
|
||||
deriveMergeCapability(gitlabFacts({ pipelineStatus: "preparing" }))?.canEnableAutoMerge,
|
||||
).toBe(true);
|
||||
expect(
|
||||
deriveMergeCapability(gitlabFacts({ pipelineStatus: "pending" }))?.canEnableAutoMerge,
|
||||
).toBe(true);
|
||||
expect(
|
||||
deriveMergeCapability(gitlabFacts({ pipelineStatus: "running" }))?.canEnableAutoMerge,
|
||||
).toBe(true);
|
||||
expect(
|
||||
deriveMergeCapability(gitlabFacts({ pipelineStatus: "scheduled" }))?.canEnableAutoMerge,
|
||||
).toBe(true);
|
||||
expect(
|
||||
deriveMergeCapability(gitlabFacts({ pipelineStatus: "success" }))?.canEnableAutoMerge,
|
||||
).toBe(false);
|
||||
expect(deriveMergeCapability(gitlabFacts({ pipelineStatus: null }))?.canEnableAutoMerge).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("offers GitLab merge methods and never reports a merge queue", () => {
|
||||
const cap = deriveMergeCapability(gitlabFacts());
|
||||
expect(cap?.allowedMethods).toEqual(["merge", "squash", "rebase"]);
|
||||
expect(cap?.mergeBlockedByQueue).toBe(false);
|
||||
expect(cap?.canEnableAutoMerge).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveMergeCapability (gitea)", () => {
|
||||
it("allows direct merge only when the pull request is mergeable and unmerged", () => {
|
||||
expect(deriveMergeCapability(giteaFacts())?.directMergeReady).toBe(true);
|
||||
expect(deriveMergeCapability(giteaFacts({ mergeable: false }))?.directMergeReady).toBe(false);
|
||||
expect(deriveMergeCapability(giteaFacts({ hasMerged: true }))?.directMergeReady).toBe(false);
|
||||
});
|
||||
|
||||
it("offers direct merge styles without auto-merge", () => {
|
||||
const capability = deriveMergeCapability(giteaFacts());
|
||||
expect(capability?.allowedMethods).toEqual(["merge", "squash", "rebase"]);
|
||||
expect(capability?.canEnableAutoMerge).toBe(false);
|
||||
expect(capability?.autoMergeEnabled).toBe(false);
|
||||
expect(capability?.canDisableAutoMerge).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user