Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import type { GitCommandError } from "@t3tools/contracts";

import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";

import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";

const withRepository = <A, E>(
test: (input: {
readonly git: (...args: string[]) => Effect.Effect<string, GitCommandError>;
readonly write: (path: string, contents: string) => Effect.Effect<void>;
readonly commit: Effect.Effect<void, GitCommandError>;
readonly readTemplate: (treeish?: string) => Effect.Effect<Option.Option<string>>;
}) => Effect.Effect<A, E>,
) =>
Effect.gen(function* () {
const host = yield* SourceControlHost.SourceControlHost;
const cwd = yield* Effect.acquireRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-azure-template-"))),
(directory) => Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true })),
);
const git = (...args: string[]) =>
host.git
.execute({ operation: "AzureDevOpsTemplate.test", cwd, args })
.pipe(Effect.map((result) => result.stdout.trim()));
const write = (path: string, contents: string) =>
Effect.sync(() => {
NodeFS.mkdirSync(NodePath.dirname(NodePath.join(cwd, path)), { recursive: true });
NodeFS.writeFileSync(NodePath.join(cwd, path), contents);
});
const commit = Effect.gen(function* () {
yield* git("add", "-A");
yield* git("commit", "--allow-empty", "-m", "Add pull request templates");
});
yield* git("init", "--initial-branch=main");
yield* git("config", "user.email", "test@example.com");
yield* git("config", "user.name", "Test User");
yield* git("config", "core.autocrlf", "false");
yield* git("remote", "add", "origin", cwd);
const provider = yield* AzureDevOpsSourceControlProvider.make.pipe(
Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({})),
);
return yield* test({
git,
write,
commit,
readTemplate: (treeish = "main") => provider.readChangeRequestTemplate!({ cwd, treeish }),
});
}).pipe(
Effect.scoped,
Effect.provide(
TestSourceControlHost.layerWithRealGit({
git: { resolvePrimaryRemoteName: () => Effect.succeed("origin") },
}),
),
);

const publishMain = (git: (...args: string[]) => Effect.Effect<string, GitCommandError>) =>
Effect.gen(function* () {
yield* git("update-ref", "refs/remotes/origin/main", "main");
yield* git("symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main");
});

it.effect("reads the default-branch template whatever branch the pull request targets", () =>
withRepository(({ git, write, commit, readTemplate }) =>
Effect.gen(function* () {
const templatePath = ".azuredevops/pull_request_template.md";
yield* write(templatePath, "Default branch template");
yield* commit;
yield* publishMain(git);
yield* git("checkout", "-b", "release");
yield* write(templatePath, "Release branch template");
yield* commit;
yield* write(templatePath, "Uncommitted template");

assert.deepStrictEqual(
yield* readTemplate("release"),
Option.some("Default branch template"),
);
}),
),
);

// Microsoft searches these locations in order, ignoring case; extension must not override folder.
it.effect.each([
[".AzureDevOps/PULL_REQUEST_TEMPLATE.txt", ".vsts/pull_request_template.md"],
[".VSTS/pull_request_template.TXT", "docs/pull_request_template.md"],
["Docs/Pull_Request_Template.txt", "pull_request_template.md"],
] as const)("prefers %s over %s", ([preferred, fallback]) =>
withRepository(({ git, write, commit, readTemplate }) =>
Effect.gen(function* () {
yield* write(preferred, "Preferred template");
yield* write(fallback, "Fallback template");
yield* commit;
yield* publishMain(git);

assert.deepStrictEqual(yield* readTemplate(), Option.some("Preferred template"));
}),
),
);

it.effect("ignores missing, blank, symlinked, and nested templates", () =>
withRepository(({ git, write, commit, readTemplate }) =>
Effect.gen(function* () {
yield* commit;
yield* publishMain(git);
assert.deepStrictEqual(yield* readTemplate(), Option.none());

yield* write(".azuredevops/pull_request_template.md", " \n\t ");
yield* commit;
yield* publishMain(git);
assert.deepStrictEqual(yield* readTemplate(), Option.none());

yield* git("rm", "-q", ".azuredevops/pull_request_template.md");
yield* write("template.md", "Not a default template");
yield* write("docs/nested/pull_request_template.md", "Nested template");
yield* commit;
// An index mode commits a symlink even on hosts without symlink support.
const blob = yield* git("rev-parse", "HEAD:template.md");
yield* git(
"update-index",
"--add",
"--cacheinfo",
`120000,${blob},.azuredevops/pull_request_template.md`,
);
yield* git("commit", "-m", "Add a symlinked template");
yield* publishMain(git);
assert.deepStrictEqual(yield* readTemplate(), Option.none());
}),
),
);

it.effect("bounds template content and marks truncation", () =>
withRepository(({ git, write, commit, readTemplate }) =>
Effect.gen(function* () {
const prefix = "x".repeat(8_000);
yield* write("pull_request_template.md", prefix + "EXCLUDED_SUFFIX");
yield* commit;
yield* publishMain(git);

assert.deepStrictEqual(yield* readTemplate(), Option.some(prefix + "\n\n[truncated]"));
}),
),
);

it.effect("returns no template when the local remote default branch is unknown", () =>
withRepository(({ write, commit, readTemplate }) =>
Effect.gen(function* () {
yield* write("pull_request_template.md", "Target branch template");
yield* commit;

assert.deepStrictEqual(yield* readTemplate(), Option.none());
}),
),
);
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ it.effect("names the remote's repository when reading the default branch", () =>
remoteUrl,
);
}
}).pipe(Effect.provide(AzureDevOpsCli.layer.pipe(Layer.provide(support))));
}).pipe(Effect.provide(AzureDevOpsCli.layer.pipe(Layer.provideMerge(support))));
});

it.effect(
Expand All @@ -103,5 +103,12 @@ it.effect(
}),
{ headSelectors: ["feature"], limit: 100 },
);
}).pipe(Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}))),
}).pipe(
Effect.provide(
Layer.mergeAll(
Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
TestSourceControlHost.layer(),
),
),
),
);
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { SourceControlProviderKind } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";

import * as AzureDevOpsChangeRequestTemplate from "./azureDevOpsChangeRequestTemplate.ts";
import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
import type { NormalizedAzureDevOpsPullRequestRecord } from "./azureDevOpsPullRequests.ts";
import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
Expand Down Expand Up @@ -89,6 +92,7 @@ function toChangeRequest(summary: NormalizedAzureDevOpsPullRequestRecord): Chang

export const make = Effect.gen(function* () {
const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
const { git } = yield* SourceControlHost.SourceControlHost;

return SourceControlProvider.SourceControlProvider.of({
kind: SourceControlProviderKind.make("azure-devops"),
Expand All @@ -97,6 +101,25 @@ export const make = Effect.gen(function* () {
headSelectors: headSelectors.filter((selector) => !selector.includes(":")),
limit: 100,
}),
readChangeRequestTemplate: Effect.fn(
"AzureDevOpsSourceControlProvider.readChangeRequestTemplate",
)(
function* ({ cwd }) {
// Azure uses the repository default branch even when the PR targets another branch.
const remoteName = yield* git.resolvePrimaryRemoteName(cwd);
const defaultRef = yield* git.execute({
operation: "AzureDevOpsSourceControlProvider.resolveDefaultBranch",
cwd,
args: ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`],
});
return yield* AzureDevOpsChangeRequestTemplate.detect(
cwd,
defaultRef.stdout.trim(),
git.execute,
);
},
Effect.orElseSucceed(() => Option.none()),
),
listChangeRequests: Effect.fn("AzureDevOpsSourceControlProvider.listChangeRequests")(
function (input) {
const source = SourceControlProvider.sourceControlRefFromInput(input);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";

import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";

const TEMPLATE_DIRECTORIES = [".azuredevops", ".vsts", "docs", ""];
const TEMPLATE_MAX_BYTES = 8_000;
const TREE_LIST_MAX_BYTES = 100_000;
const TRUNCATION_MARKER = "[truncated]";

type ExecuteGit = SourceControlHost.SourceControlHost["Service"]["git"]["execute"];

export const detect = Effect.fn("azureDevOpsChangeRequestTemplate.detect")(
function* (cwd: string, treeish: string, executeGit: ExecuteGit) {
// Inspect only the root and template directories; never recurse through the repository.
const root = yield* executeGit({
operation: "azureDevOpsChangeRequestTemplate.listTemplates",
cwd,
args: ["ls-tree", "-z", "--full-tree", treeish],
maxOutputBytes: TREE_LIST_MAX_BYTES,
appendTruncationMarker: true,
});
if (root.stdoutTruncated) {
return Option.none();
}

const rootEntries = root.stdout.split("\0");
let objectId: string | undefined;
for (const directory of TEMPLATE_DIRECTORIES) {
let entries = rootEntries;
if (directory !== "") {
const trees = rootEntries.flatMap((record) => {
const entry = /^040000 tree ([0-9a-f]{40,64})\t(.+)$/.exec(record);
return entry?.[1] && entry[2]?.toLowerCase() === directory ? [entry[1]] : [];
});
entries = [];
for (const tree of trees) {
const listing = yield* executeGit({
operation: "azureDevOpsChangeRequestTemplate.listTemplates",
cwd,
args: ["ls-tree", "-z", "--full-tree", tree],
maxOutputBytes: TREE_LIST_MAX_BYTES,
appendTruncationMarker: true,
});
if (listing.stdoutTruncated) {
return Option.none();
}
entries.push(...listing.stdout.split("\0"));
}
}
// Only regular committed blobs qualify, so symlinks never reach the host filesystem.
objectId = entries
.map((record) => /^100(?:644|755) blob ([0-9a-f]{40,64})\t(.+)$/.exec(record))
.find((entry) => /^pull_request_template\.(?:md|txt)$/i.test(entry?.[2] ?? ""))?.[1];
if (objectId) {
break;
}
}
if (!objectId) {
return Option.none();
}

const blob = yield* executeGit({
operation: "azureDevOpsChangeRequestTemplate.readTemplate",
cwd,
args: ["cat-file", "blob", objectId],
maxOutputBytes: TEMPLATE_MAX_BYTES,
appendTruncationMarker: true,
});
const template = blob.stdout.trim();
if (template.length === 0) {
return Option.none();
}
return Option.some(
blob.stdoutTruncated && !template.endsWith(TRUNCATION_MARKER)
? `${template}\n\n${TRUNCATION_MARKER}`
: template,
);
},
Effect.orElseSucceed(() => Option.none()),
);
Loading