Files
oh-my-pi/packages/coding-agent/test/discovery/github-copilot.test.ts
T
Asaf Mahlev a8db2747e5 fix(discovery): correct Copilot custom-instruction-dir and prompt handling
Address review against GitHub Copilot CLI docs (add-custom-instructions):

- COPILOT_CUSTOM_INSTRUCTIONS_DIRS: each dir contributes AGENTS.md (context) and .github/instructions/**/*.instructions.md (recursive), per spec. Dropped the non-spec <dir>/copilot-instructions.md and the wrong top-level <dir>/*.instructions.md scan.

- Scan the project .github/instructions/ tree recursively ('within or below').

- Prompts: .github/prompts/*.prompt.md is a VS Code construct, not a Copilot CLI feature; dropped the fictional ~/.copilot/prompts/ user dir and reframed comments/descriptions accordingly.
2026-06-12 19:15:34 +03:00

137 lines
6.0 KiB
TypeScript

/**
* Regression for the GitHub Copilot user-global discovery gaps:
* - #1913: ~/.copilot/copilot-instructions.md (user-global instructions)
* - #1915: COPILOT_HOME relocation + COPILOT_CUSTOM_INSTRUCTIONS_DIRS
* - #1916: *.prompt.md in .github/prompts/ and ~/.copilot/prompts/
*
* The `github` provider previously only scanned the project `.github/` tree. These
* tests pin the user-global surface, driven through COPILOT_HOME so they never touch
* the developer's real ~/.copilot directory.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { loadCapability } from "@oh-my-pi/pi-coding-agent/capability";
import type { ContextFile } from "@oh-my-pi/pi-coding-agent/capability/context-file";
import { clearCache } from "@oh-my-pi/pi-coding-agent/capability/fs";
import type { Instruction } from "@oh-my-pi/pi-coding-agent/capability/instruction";
import type { Prompt } from "@oh-my-pi/pi-coding-agent/capability/prompt";
import "@oh-my-pi/pi-coding-agent/capability/context-file";
import "@oh-my-pi/pi-coding-agent/capability/instruction";
import "@oh-my-pi/pi-coding-agent/capability/prompt";
import "@oh-my-pi/pi-coding-agent/discovery/github";
const ENV_KEYS = ["COPILOT_HOME", "COPILOT_CUSTOM_INSTRUCTIONS_DIRS"] as const;
function write(file: string, content: string): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
describe("github discovery — Copilot user-global surface", () => {
let tempDir!: string;
let cwd!: string;
let copilotHome!: string;
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
clearCache();
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-github-copilot-"));
cwd = path.join(tempDir, "project");
copilotHome = path.join(tempDir, "copilot-home");
fs.mkdirSync(cwd, { recursive: true });
process.env.COPILOT_HOME = copilotHome;
delete process.env.COPILOT_CUSTOM_INSTRUCTIONS_DIRS;
});
afterEach(() => {
clearCache();
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
});
test("loads user-global ~/.copilot/copilot-instructions.md via COPILOT_HOME (#1913)", async () => {
write(path.join(copilotHome, "copilot-instructions.md"), "user-global guidance");
const result = await loadCapability<ContextFile>("context-files", { cwd, providers: ["github"] });
const found = result.all.find(f => f.path === path.join(copilotHome, "copilot-instructions.md"));
expect(found).toBeDefined();
expect(found?.content).toBe("user-global guidance");
expect(found?.level).toBe("user");
expect(found?._source.provider).toBe("github");
});
test("still loads project .github/copilot-instructions.md alongside user-global", async () => {
write(path.join(cwd, ".github", "copilot-instructions.md"), "project guidance");
write(path.join(copilotHome, "copilot-instructions.md"), "user guidance");
const result = await loadCapability<ContextFile>("context-files", { cwd, providers: ["github"] });
const project = result.all.find(f => f.level === "project");
const user = result.all.find(f => f.level === "user");
expect(project?.content).toBe("project guidance");
expect(user?.content).toBe("user guidance");
});
test("loads AGENTS.md from COPILOT_CUSTOM_INSTRUCTIONS_DIRS (#1915)", async () => {
const extraA = path.join(tempDir, "extra-a");
const extraB = path.join(tempDir, "extra-b");
write(path.join(extraA, "AGENTS.md"), "extra A agents");
write(path.join(extraB, "AGENTS.md"), "extra B agents");
// copilot-instructions.md in a custom dir is NOT part of the spec and must be ignored.
write(path.join(extraA, "copilot-instructions.md"), "should be ignored");
process.env.COPILOT_CUSTOM_INSTRUCTIONS_DIRS = `${extraA}, ${extraB}`;
const result = await loadCapability<ContextFile>("context-files", { cwd, providers: ["github"] });
const contents = result.all.filter(f => f.level === "user").map(f => f.content);
expect(contents).toContain("extra A agents");
expect(contents).toContain("extra B agents");
expect(contents).not.toContain("should be ignored");
});
test("loads <dir>/.github/instructions/**/*.instructions.md from custom dirs (#1915)", async () => {
const extra = path.join(tempDir, "extra");
// Recursive, under <dir>/.github/instructions — not top-level <dir>/*.instructions.md.
write(
path.join(extra, ".github", "instructions", "nested", "style.instructions.md"),
"---\napplyTo: '**/*.ts'\n---\nStyle rules",
);
// A top-level instructions file in the custom dir must NOT be picked up.
write(path.join(extra, "toplevel.instructions.md"), "---\napplyTo: '**'\n---\nIgnored");
process.env.COPILOT_CUSTOM_INSTRUCTIONS_DIRS = extra;
const result = await loadCapability<Instruction>("instructions", { cwd, providers: ["github"] });
const found = result.all.find(i => i.name === "style");
expect(found).toBeDefined();
expect(found?.applyTo).toBe("**/*.ts");
expect(found?.content.trim()).toBe("Style rules");
expect(found?._source.level).toBe("user");
expect(result.all.find(i => i.name === "toplevel")).toBeUndefined();
});
test("discovers *.prompt.md from .github/prompts/ (#1916)", async () => {
write(
path.join(cwd, ".github", "prompts", "review.prompt.md"),
"---\ndescription: Review helper\n---\nReview the diff.",
);
// Plain markdown that is not a prompt file must be ignored.
write(path.join(cwd, ".github", "prompts", "notes.md"), "not a prompt");
const result = await loadCapability<Prompt>("prompts", { cwd, providers: ["github"] });
const review = result.all.find(p => p.name === "review");
expect(review).toBeDefined();
expect(review?.content.trim()).toBe("Review the diff.");
expect(review?._source.level).toBe("project");
expect(result.all.find(p => p.name === "notes")).toBeUndefined();
});
});