mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
fix(gitea): handle AGit PRs and deleted head branches
neither has a branch on origin. forgejo reports refs/pull/N/head as head.ref for both, so skip the refs/heads fallback and dont track origin. also try refs/pull/N/head on upstream for clones where origin is your fork, like github does. that list lives in the shared util now.
This commit is contained in:
committed by
paseo-bot[bot]
parent
c958166402
commit
c77b046337
@@ -182,6 +182,28 @@ describe("resolveWorktreeCreationIntent", () => {
|
||||
expect(deps.headRefLookups).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not track origin for a same-repo head with no origin branch", async () => {
|
||||
const deps = createResolverHarness();
|
||||
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
|
||||
number: 42,
|
||||
baseRefName: "main",
|
||||
headRefName: "ford/towel",
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: false,
|
||||
hasOriginHeadBranch: false,
|
||||
});
|
||||
|
||||
const intent = await resolveWorktreeCreationIntent(
|
||||
{ action: "checkout", githubPrNumber: 42 },
|
||||
repoRoot,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(intent).not.toHaveProperty("trackOriginHead");
|
||||
});
|
||||
|
||||
test("configures the contributor remote for fork PR targets", async () => {
|
||||
const deps = createResolverHarness();
|
||||
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
|
||||
|
||||
@@ -191,7 +191,8 @@ async function resolvePrCheckoutIntent(
|
||||
}) ?? [{ remoteName: "origin", remoteRef: `refs/heads/${headRef}` }];
|
||||
const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget });
|
||||
const crossRepository = resolveCrossRepositoryFields(checkoutTarget);
|
||||
const trackOriginHead = !checkoutTarget.isCrossRepository;
|
||||
const trackOriginHead =
|
||||
!checkoutTarget.isCrossRepository && checkoutTarget.hasOriginHeadBranch !== false;
|
||||
|
||||
return {
|
||||
kind: "checkout-change-request",
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface PullRequestCheckoutTarget {
|
||||
headRepositorySshUrl: string | null;
|
||||
headRepositoryUrl: string | null;
|
||||
isCrossRepository: boolean;
|
||||
/** false when origin has no head branch to track */
|
||||
hasOriginHeadBranch?: boolean;
|
||||
}
|
||||
|
||||
export interface PullRequestCheckoutRef {
|
||||
|
||||
@@ -2297,11 +2297,13 @@ describe("createGiteaService", () => {
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
|
||||
{ remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" },
|
||||
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
|
||||
],
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: false,
|
||||
hasOriginHeadBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2330,16 +2332,62 @@ describe("createGiteaService", () => {
|
||||
number: 5,
|
||||
baseRefName: "main",
|
||||
headRefName: "feat/sample-change",
|
||||
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/5/head" }],
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
|
||||
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
|
||||
],
|
||||
headOwnerLogin: "contributor",
|
||||
headRepositorySshUrl: "git@gitea.com:contributor/sample-repo.git",
|
||||
headRepositoryUrl: "https://gitea.com/contributor/sample-repo",
|
||||
isCrossRepository: true,
|
||||
hasOriginHeadBranch: false,
|
||||
});
|
||||
|
||||
expect(calls).toContainEqual(["api", "repos/example-user/sample-repo/pulls/5"]);
|
||||
});
|
||||
|
||||
it("skips the origin branch fallback and tracking for an AGit pull request", async () => {
|
||||
const { service } = makeService((args) =>
|
||||
args[0] === "api"
|
||||
? ok(
|
||||
JSON.stringify({
|
||||
flow: 1,
|
||||
head: { ref: "refs/pull/5/head", repo: { id: 1 } },
|
||||
base: { repo: { id: 1 } },
|
||||
}),
|
||||
)
|
||||
: ok(JSON.stringify(STATUS_PR_VIEW)),
|
||||
);
|
||||
|
||||
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
|
||||
|
||||
expect(target).toMatchObject({
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
|
||||
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
|
||||
],
|
||||
isCrossRepository: false,
|
||||
hasOriginHeadBranch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the origin branch fallback when the head branch was deleted", async () => {
|
||||
const { service } = makeService((args) =>
|
||||
args[0] === "api"
|
||||
? ok(
|
||||
JSON.stringify({
|
||||
head: { ref: "refs/pull/5/head", repo: { id: 1 } },
|
||||
base: { repo: { id: 1 } },
|
||||
}),
|
||||
)
|
||||
: ok(JSON.stringify(STATUS_PR_VIEW)),
|
||||
);
|
||||
|
||||
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
|
||||
|
||||
expect(target.hasOriginHeadBranch).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a pull request from a deleted fork cross-repository", async () => {
|
||||
const { service } = makeService((args) =>
|
||||
args[0] === "api"
|
||||
@@ -2350,12 +2398,32 @@ describe("createGiteaService", () => {
|
||||
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
|
||||
|
||||
expect(target).toMatchObject({
|
||||
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/5/head" }],
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
|
||||
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
|
||||
],
|
||||
headOwnerLogin: null,
|
||||
isCrossRepository: true,
|
||||
hasOriginHeadBranch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the origin branch fallback when the server sends no head ref", async () => {
|
||||
const { service } = makeService((args) =>
|
||||
args[0] === "api"
|
||||
? ok(JSON.stringify({ head: { repo: { id: 1 } }, base: { repo: { id: 1 } } }))
|
||||
: ok(JSON.stringify(STATUS_PR_VIEW)),
|
||||
);
|
||||
|
||||
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
|
||||
|
||||
expect(target.checkoutRefs).toContainEqual({
|
||||
remoteName: "origin",
|
||||
remoteRef: "refs/heads/feat/sample-change",
|
||||
});
|
||||
expect(target.hasOriginHeadBranch).toBe(true);
|
||||
});
|
||||
|
||||
it("prefixes the local branch name with the fork owner for a cross-repository checkout", () => {
|
||||
const { service } = makeService(() => ok(""));
|
||||
|
||||
@@ -2380,6 +2448,7 @@ describe("createGiteaService", () => {
|
||||
|
||||
expect(service.defaultCheckoutRefs?.({ changeRequestNumber: 5, headRef: "patch-1" })).toEqual([
|
||||
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
|
||||
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import pLimit from "p-limit";
|
||||
import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import { findExecutable } from "../executable-resolution/executable-resolution.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { buildForkLocalBranchName } from "../utils/change-request-checkout.js";
|
||||
import {
|
||||
buildForkLocalBranchName,
|
||||
buildPullHeadCheckoutRefs,
|
||||
} from "../utils/change-request-checkout.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
import {
|
||||
createCachedCliPathResolver,
|
||||
@@ -76,6 +79,8 @@ const CURRENT_PR_HEAD_SHA_CACHE_TTL_MS = 30_000;
|
||||
// the sweep to the 250 most recent Actions tasks.
|
||||
const GITEA_ACTION_PAGE_LIMIT = 50;
|
||||
const GITEA_ACTION_MAX_PAGES = 5;
|
||||
// PullRequestFlowAGit in forgejo models/issues/pull.go
|
||||
const GITEA_PULL_REQUEST_FLOW_AGIT = 1;
|
||||
|
||||
/**
|
||||
* Fields requested from `tea pr list -o json`. tea's default field set omits the
|
||||
@@ -226,8 +231,12 @@ const GiteaPullRequestRepoSchema = z
|
||||
|
||||
const GiteaPullRequestApiSchema = z
|
||||
.object({
|
||||
flow: z.number().optional(),
|
||||
head: z
|
||||
.object({ repo: GiteaPullRequestRepoSchema.nullable().optional() })
|
||||
.object({
|
||||
ref: z.string().optional(),
|
||||
repo: GiteaPullRequestRepoSchema.nullable().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
base: z
|
||||
@@ -1441,12 +1450,14 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
summary: PullRequestSummary,
|
||||
): Promise<{
|
||||
isCrossRepository: boolean;
|
||||
hasHeadBranch: boolean;
|
||||
headOwnerLogin: string | null;
|
||||
headRepositorySshUrl: string | null;
|
||||
headRepositoryUrl: string | null;
|
||||
}> {
|
||||
const sameRepo = {
|
||||
isCrossRepository: false,
|
||||
hasHeadBranch: true,
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
@@ -1465,18 +1476,21 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
);
|
||||
const headRepo = pr.head?.repo ?? null;
|
||||
const baseRepo = pr.base?.repo ?? null;
|
||||
// head.ref only becomes the branch name when the branch still exists,
|
||||
// agit PRs and deleted branches keep the pull ref
|
||||
const hasHeadBranch =
|
||||
pr.flow !== GITEA_PULL_REQUEST_FLOW_AGIT &&
|
||||
pr.head?.ref !== `refs/pull/${summary.number}/head`;
|
||||
if (baseRepo?.id == null || headRepo?.id === baseRepo.id) {
|
||||
return sameRepo;
|
||||
}
|
||||
if (headRepo?.id == null) {
|
||||
// fork was deleted
|
||||
return { ...sameRepo, isCrossRepository: true };
|
||||
return { ...sameRepo, hasHeadBranch };
|
||||
}
|
||||
// headRepo is null when the fork was deleted
|
||||
return {
|
||||
isCrossRepository: true,
|
||||
headOwnerLogin: headRepo.owner?.login ?? null,
|
||||
headRepositorySshUrl: headRepo.ssh_url ?? null,
|
||||
headRepositoryUrl: headRepo.html_url ?? null,
|
||||
hasHeadBranch,
|
||||
headOwnerLogin: headRepo?.owner?.login ?? null,
|
||||
headRepositorySshUrl: headRepo?.ssh_url ?? null,
|
||||
headRepositoryUrl: headRepo?.html_url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2003,7 +2017,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
},
|
||||
|
||||
defaultCheckoutRefs({ changeRequestNumber }) {
|
||||
return [{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }];
|
||||
return buildPullHeadCheckoutRefs(changeRequestNumber);
|
||||
},
|
||||
|
||||
buildPrLocalBranchName({ headRef, checkoutTarget }) {
|
||||
@@ -2015,13 +2029,16 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
): Promise<PullRequestCheckoutTarget> {
|
||||
const summary = await this.getPullRequest(input);
|
||||
const fork = await resolveForkCheckoutFacts(input.cwd, summary);
|
||||
const checkoutRefs = [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${summary.number}/head` },
|
||||
];
|
||||
// on a fork PR thats the base repos branch
|
||||
if (summary.headRefName && !fork.isCrossRepository) {
|
||||
const [originPullRef, upstreamPullRef] = buildPullHeadCheckoutRefs(summary.number);
|
||||
const checkoutRefs = [originPullRef];
|
||||
// for a fork PR a branch with that name on origin belongs to the base repo
|
||||
const headBranchOnOrigin =
|
||||
!fork.isCrossRepository && fork.hasHeadBranch && Boolean(summary.headRefName);
|
||||
if (headBranchOnOrigin) {
|
||||
checkoutRefs.push({ remoteName: "origin", remoteRef: `refs/heads/${summary.headRefName}` });
|
||||
}
|
||||
// upstream can hold a different PR with the same number, so it goes last
|
||||
checkoutRefs.push(upstreamPullRef);
|
||||
return {
|
||||
number: summary.number,
|
||||
baseRefName: summary.baseRefName,
|
||||
@@ -2031,6 +2048,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
headRepositorySshUrl: fork.headRepositorySshUrl,
|
||||
headRepositoryUrl: fork.headRepositoryUrl,
|
||||
isCrossRepository: fork.isCrossRepository,
|
||||
hasOriginHeadBranch: headBranchOnOrigin,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
} from "@getpaseo/protocol/git-remote";
|
||||
import { findExecutable } from "../executable-resolution/executable-resolution.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { buildForkLocalBranchName } from "../utils/change-request-checkout.js";
|
||||
import {
|
||||
buildForkLocalBranchName,
|
||||
buildPullHeadCheckoutRefs,
|
||||
} from "../utils/change-request-checkout.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
import { resolveSshHostname } from "../utils/ssh-hostname.js";
|
||||
import {
|
||||
@@ -2105,10 +2108,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
},
|
||||
|
||||
defaultCheckoutRefs({ changeRequestNumber }) {
|
||||
return [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
];
|
||||
return buildPullHeadCheckoutRefs(changeRequestNumber);
|
||||
},
|
||||
|
||||
buildPrLocalBranchName({ headRef, checkoutTarget }) {
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import type { PullRequestCheckoutRef } from "../services/forge-service.js";
|
||||
|
||||
// upstream for clones where origin is your fork
|
||||
export function buildPullHeadCheckoutRefs(number: number): PullRequestCheckoutRef[] {
|
||||
return [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${number}/head` },
|
||||
{ remoteName: "upstream", remoteRef: `refs/pull/${number}/head` },
|
||||
];
|
||||
}
|
||||
|
||||
// forgejo username pattern, covers github logins too
|
||||
export function normalizeForgeOwnerForBranch(owner: string | null): string | null {
|
||||
const normalized = owner?.trim().toLowerCase() ?? "";
|
||||
|
||||
Reference in New Issue
Block a user