Skip to content
Merged
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
36 changes: 36 additions & 0 deletions dist/index.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.cjs.map

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/__tests__/caching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ describe("CachingGitHubClient caches", () => {
async getTreeSHAForPath() {
return "";
},
async getSymlinkTarget() {
return null;
},
async getCommitSHAsForPath() {
return [];
},
Expand Down Expand Up @@ -85,6 +88,9 @@ describe("CachingGitHubClient caches", () => {
async getTreeSHAForPath() {
return (++call).toString();
},
async getSymlinkTarget() {
return null;
},
async getCommitSHAsForPath() {
return [];
},
Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/cleanup-closed-pr-tracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ function createMockGitHubClient(
async getTreeSHAForPath() {
return "mock-tree-sha";
},
async getSymlinkTarget() {
return null;
},
async getCommitSHAsForPath() {
return [];
},
Expand Down
81 changes: 80 additions & 1 deletion src/__tests__/github.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
// Tests for GitHub API utilities.

import { describe, it, expect } from "vitest";
import { resolveSymlinkTarget } from "../github.js";
import {
resolveSymlinkTarget,
getGitConfigRefPromotionInfo,
GitHubClient,
} from "../github.js";
import { PrefixingLogger } from "../log.js";

describe("resolveSymlinkTarget", () => {
it("resolves relative symlinks with ..", () => {
Expand Down Expand Up @@ -48,3 +53,77 @@ describe("resolveSymlinkTarget", () => {
);
});
});

describe("getGitConfigRefPromotionInfo", () => {
const logger = PrefixingLogger.silent();

it("returns symlink message when path is a symlink", async () => {
const mockGitHubClient: GitHubClient = {
async resolveRefToSHA() {
return "abc123";
},
async getTreeSHAForPath() {
return "tree-sha";
},
async getSymlinkTarget() {
return "../shared/chart";
},
async getCommitSHAsForPath() {
return [];
},
async getPullRequest() {
return { state: "open", title: "Test PR", closedAt: null };
},
};

const result = await getGitConfigRefPromotionInfo({
oldRef: "old-ref",
newRef: "new-ref",
repoURL: "https://github.com/example/repo.git",
path: "apps/linter/chart",
gitHubClient: mockGitHubClient,
logger,
});

expect(result.type).toBe("unknown");
if (result.type === "unknown") {
expect(result.message).toContain("is a symlink to");
expect(result.message).toContain("apps/shared/chart");
expect(result.message).toContain(
"should be converted to regular directories",
);
}
});

it("proceeds normally when path is not a symlink", async () => {
const mockGitHubClient: GitHubClient = {
async resolveRefToSHA() {
return "abc123";
},
async getTreeSHAForPath() {
return "tree-sha";
},
async getSymlinkTarget() {
return null;
},
async getCommitSHAsForPath() {
return ["commit1"];
},
async getPullRequest() {
return { state: "open", title: "Test PR", closedAt: null };
},
};

const result = await getGitConfigRefPromotionInfo({
oldRef: "old-ref",
newRef: "new-ref",
repoURL: "https://github.com/example/repo.git",
path: "apps/linter/chart",
gitHubClient: mockGitHubClient,
logger,
});

// Should not return unknown/symlink message - normal processing occurred
expect(result.type).toBe("no-commits");
});
});
15 changes: 15 additions & 0 deletions src/__tests__/update-git-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ const mockGitHubClient: GitHubClient = {
async getTreeSHAForPath() {
return `${Math.random()}`;
},
async getSymlinkTarget() {
return null;
},
async getCommitSHAsForPath() {
return [];
},
Expand Down Expand Up @@ -85,6 +88,9 @@ describe("action", () => {
async getTreeSHAForPath({ commitSHA }) {
return commitSHA === "old" ? "aaaa" : treeSHAForNew;
},
async getSymlinkTarget() {
return null;
},
async getCommitSHAsForPath() {
return [];
},
Expand Down Expand Up @@ -128,6 +134,9 @@ describe("action", () => {
async getTreeSHAForPath() {
return "aaaa";
},
async getSymlinkTarget() {
return null;
},
async getCommitSHAsForPath() {
return [];
},
Expand Down Expand Up @@ -156,6 +165,9 @@ describe("action", () => {
async getTreeSHAForPath({ commitSHA }) {
return commitSHA === "d97b3a3240" ? "bad" : "aaaa";
},
async getSymlinkTarget() {
return null;
},
async getCommitSHAsForPath() {
return [];
},
Expand Down Expand Up @@ -187,6 +199,9 @@ describe("action", () => {
async getTreeSHAForPath({ commitSHA }) {
return commitSHA === "old" ? "oldaaaa" : "aaaa";
},
async getSymlinkTarget() {
return null;
},
async getCommitSHAsForPath() {
return [];
},
Expand Down
51 changes: 51 additions & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export interface PullRequest {
export interface GitHubClient {
resolveRefToSHA(options: ResolveRefToSHAOptions): Promise<string>;
getTreeSHAForPath(options: GetTreeSHAForPathOptions): Promise<string | null>;
// Returns the symlink target if the path is a symlink, or null if it's not.
getSymlinkTarget(options: GetTreeSHAForPathOptions): Promise<string | null>;
getCommitSHAsForPath(options: GetCommitSHAsForPathOptions): Promise<string[]>;
getPullRequest(options: GetPullRequestForNumberOptions): Promise<PullRequest>;
}
Expand Down Expand Up @@ -259,6 +261,21 @@ export class OctokitGitHubClient {
return this.getTreeSHAForPathViaGetContent({ repoURL, commitSHA, path });
}

async getSymlinkTarget({
repoURL,
commitSHA,
path,
}: GetTreeSHAForPathOptions): Promise<string | null> {
const allTreesForCommit = await this.allTreesForCommitCache.fetch(
JSON.stringify({ repoURL, commitSHA }),
{ context: { repoURL, commitSHA } },
);
if (!allTreesForCommit) {
throw Error(`Unexpected missing entry in allTreesForCommitCache`);
}
return allTreesForCommit.pathToSymlinkTarget.get(path) ?? null;
}

// Fall back to asking the GitHub API for the tree hash directly if our cache
// was truncated.
private async getTreeSHAForPathViaGetContent({
Expand Down Expand Up @@ -466,6 +483,14 @@ export class CachingGitHubClient {
return shas;
}

async getSymlinkTarget(
options: GetTreeSHAForPathOptions,
): Promise<string | null> {
// No caching for symlink targets - they're already cached in the wrapped
// client's allTreesForCommitCache.
return this.wrapped.getSymlinkTarget(options);
}

dump(): CachingGitHubClientDump {
// We don't dump resolveRefToSHACache because it is not immutable (it tracks
// the current commits on main, etc).
Expand Down Expand Up @@ -541,6 +566,32 @@ export async function getGitConfigRefPromotionInfo(options: {
}): Promise<PromotionInfo> {
const { oldRef, newRef, repoURL, path, gitHubClient, logger } = options;

// Check if the path is a symlink at the new ref. If so, we can't reliably
// determine which commits affected it, so return a message encouraging the
// user to convert the symlink to a real directory.
let newRefCommitSHA;
try {
newRefCommitSHA = await gitHubClient.resolveRefToSHA({
repoURL,
ref: newRef,
});
} catch (e) {
logger.error(`Error resolving ref ${newRef} in ${repoURL}: ${e}`);
return promotionInfoUnknown(`Error resolving ref ${newRef}`);
}
const symlinkTarget = await gitHubClient.getSymlinkTarget({
repoURL,
commitSHA: newRefCommitSHA,
path,
});
if (symlinkTarget) {
const resolvedPath = resolveSymlinkTarget(path, symlinkTarget);
return promotionInfoUnknown(
`Path \`${path}\` is a symlink to \`${resolvedPath}\`. ` +
`Symlinks are useful during migrations but should be converted to regular directories.`,
);
}

// Figure out what commits affect the path in the new version.
let newCommitSHAs;
try {
Expand Down