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
119 changes: 100 additions & 19 deletions src/actions/repo-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import {
import { LocalProvider } from "@/lib/content-provider/local-provider";
import { buildFileTree } from "@/lib/bmad/utils";
import { parseBmadFile } from "@/lib/bmad/parser";
import {
getBmadConfig,
resolveBmadOutputDir,
isPathOutsideNestedOutput,
DEFAULT_OUTPUT_DIR,
} from "@/lib/bmad/parse-config";
import { prisma } from "@/lib/db/client";
import { getAuthenticatedSession } from "@/lib/db/helpers";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
Expand All @@ -26,7 +32,6 @@ import { checkRateLimit } from "@/lib/rate-limit";
// GraphQL can handle ~30 repos per query safely (GitHub complexity limits)
const GRAPHQL_BATCH_SIZE = 30;

const BMAD_OUTPUT = "_bmad-output";
const BMAD_CORE = "_bmad";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -363,8 +368,12 @@ async function refreshLocalRepo(
const provider = new LocalProvider(repoConfig.localPath);
await provider.validateRoot();

const tree = await provider.getTree();
const totalFiles = tree.paths.filter((p) => p.startsWith("_bmad-output/")).length;
const initialTree = await provider.getTree();
const { outputDir, paths } = await resolveBmadOutputDir(
provider,
initialTree.paths,
);
const totalFiles = paths.filter((p) => p.startsWith(outputDir + "/")).length;

const now = new Date();
await prisma.repo.update({
Expand Down Expand Up @@ -409,9 +418,29 @@ async function refreshGitHubRepo(
recursive: "1",
});

const totalFiles = tree.tree.filter(
(item) => item.type === "blob" && item.path?.startsWith("_bmad-output/")
).length;
const allPaths = tree.tree
.filter((item) => item.type === "blob" && typeof item.path === "string")
.map((item) => item.path as string);

const ghProviderShim = {
async getTree() {
return { paths: allPaths, rootDirectories: [] };
},
async getFileContent(p: string) {
return getCachedUserRawContent(
octokit,
userId,
input.owner,
input.name,
syncBranch,
p,
);
},
async validateRoot() {},
};
const { outputDir } = await getBmadConfig(ghProviderShim, allPaths);

const totalFiles = allPaths.filter((p) => p.startsWith(outputDir + "/")).length;

const now = new Date();
await prisma.repo.update({
Expand Down Expand Up @@ -496,17 +525,20 @@ export async function fetchBmadFiles(input: {
async function fetchBmadFilesLocal(localPath: string) {
const provider = new LocalProvider(localPath);
await provider.validateRoot();
const providerTree = await provider.getTree();
const allPaths = providerTree.paths;
const initialTree = await provider.getTree();
const { outputDir, paths: allPaths } = await resolveBmadOutputDir(
provider,
initialTree.paths,
);

const bmadPaths = allPaths.filter((p) => p.startsWith(BMAD_OUTPUT + "/"));
const fileTree = buildFileTree(bmadPaths, BMAD_OUTPUT);
const bmadPaths = allPaths.filter((p) => p.startsWith(outputDir + "/"));
const fileTree = buildFileTree(bmadPaths, outputDir);

const bmadCorePaths = allPaths.filter((p) => p.startsWith(BMAD_CORE + "/"));
const bmadCoreTree = buildFileTree(bmadCorePaths, BMAD_CORE);

// F20/F35: Detect docs/ via rootDirectories
const docsFolderName = providerTree.rootDirectories.find(
const docsFolderName = initialTree.rootDirectories.find(
(d) => d.toLowerCase() === "docs"
) ?? null;
const docsTree = docsFolderName
Expand Down Expand Up @@ -538,12 +570,30 @@ async function fetchBmadFilesGitHub(
branch,
);

const allPaths = tree.tree
.filter((item) => item.type === "blob")
.map((item) => item.path);
const allPaths: string[] = tree.tree
.filter((item) => item.type === "blob" && typeof item.path === "string")
.map((item) => item.path as string);

const bmadPaths = allPaths.filter((p) => p.startsWith(BMAD_OUTPUT + "/"));
const fileTree = buildFileTree(bmadPaths, BMAD_OUTPUT);
const ghProviderShim = {
async getTree() {
return { paths: allPaths, rootDirectories: [] };
},
async getFileContent(p: string) {
return getCachedUserRawContent(
octokit,
userId,
input.owner,
input.name,
branch,
p,
);
},
async validateRoot() {},
};
const { outputDir } = await getBmadConfig(ghProviderShim, allPaths);

const bmadPaths = allPaths.filter((p) => p.startsWith(outputDir + "/"));
const fileTree = buildFileTree(bmadPaths, outputDir);

const bmadCorePaths = allPaths.filter((p) => p.startsWith(BMAD_CORE + "/"));
const bmadCoreTree = buildFileTree(bmadCorePaths, BMAD_CORE);
Expand Down Expand Up @@ -627,7 +677,34 @@ export async function fetchFileContent(input: {
return { success: false, error: sanitizeError(null, "FS_ERROR"), code: "FS_ERROR" };
}
const provider = new LocalProvider(repoConfig.localPath);
content = await provider.getFileContent(parsed.data.path);
// The default LocalProvider whitelist covers `_bmad` and `_bmad-output`.
// When the project declares a custom (possibly nested) `output_folder`,
// we extend the whitelist to its top-level segment so the provider can
// read inside it — but the provider only validates by single segment.
// Re-check the requested path here so a nested config like
// `output_folder: custom/out` cannot be used to read `custom/secret.txt`.
const tree = await provider.getTree();
const { outputDir } = await getBmadConfig(provider, tree.paths);
const requestedPath = parsed.data.path;
if (
outputDir !== DEFAULT_OUTPUT_DIR &&
isPathOutsideNestedOutput(requestedPath, outputDir)
) {
return {
success: false,
error: sanitizeError(null, "ACCESS_DENIED"),
code: "ACCESS_DENIED",
};
}
if (outputDir !== DEFAULT_OUTPUT_DIR) {
const topSegment = outputDir.split("/")[0];
try {
provider.extendBmadDirs(topSegment);
} catch {
// Validation failed — fall through; getFileContent will deny if needed.
}
}
content = await provider.getFileContent(requestedPath);
} else {
const token = await getGitHubToken(userId);
if (!token) {
Expand Down Expand Up @@ -769,8 +846,12 @@ export async function importLocalFolder(input: {
// F11: displayName fallback to raw basename
const displayName = parsed.data.displayName ?? rawBasename;

const bmadOutputCount = providerTree.paths.filter(
(p) => p.startsWith("_bmad-output/")
const { outputDir, paths: scannedPaths } = await resolveBmadOutputDir(
provider,
providerTree.paths,
);
const bmadOutputCount = scannedPaths.filter((p) =>
p.startsWith(outputDir + "/"),
).length;

const repo = await prisma.repo.create({
Expand Down
1 change: 1 addition & 0 deletions src/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
<input
type={type}
data-slot="input"
suppressHydrationWarning
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
Expand Down
33 changes: 29 additions & 4 deletions src/lib/bmad/__tests__/correlate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,23 @@ describe("correlate", () => {
expect(result.stories[0].status).toBe("backlog");
});

it("sprint status overrides story markdown status", () => {
const stories = [makeStory({ id: "1.1", status: "backlog" })];
it("markdown status wins over sprint-status when statusExplicit is true", () => {
const stories = [
makeStory({ id: "1.1", status: "in-progress", statusExplicit: true }),
];
const epics = [makeEpic()];
const sprint: SprintStatus = {
stories: [{ id: "1.1", title: "1-1-test", status: "done", epicId: "1" }],
};

const result = correlate(sprint, epics, stories);
expect(result.stories[0].status).toBe("in-progress");
});

it("sprint-status fills in when story has no explicit status (back-compat)", () => {
const stories = [
makeStory({ id: "1.1", status: "backlog" /* statusExplicit undefined */ }),
];
const epics = [makeEpic()];
const sprint: SprintStatus = {
stories: [{ id: "1.1", title: "1-1-test", status: "done", epicId: "1" }],
Expand Down Expand Up @@ -129,11 +144,21 @@ describe("correlate", () => {
expect(result.stories[0].epicTitle).toBe("My Epic");
});

it("uses epicStatuses from sprint-status.yaml when provided", () => {
const stories = [makeStory({ id: "1.1", status: "backlog" })];
it("computed epic status wins over sprint-status epicStatuses when stories exist", () => {
// Stories say "in-progress" → sprint-status says "done" → computed wins.
const stories = [makeStory({ id: "1.1", status: "in-progress" })];
const epics = [makeEpic()];
const epicStatuses = [{ id: "1", status: "done" as const }];

const result = correlate(null, epics, stories, epicStatuses);
expect(result.epics[0].status).toBe("in-progress");
});

it("falls back to sprint-status epicStatuses when epic has no stories", () => {
const stories: StoryDetail[] = [];
const epics = [makeEpic({ stories: [] })];
const epicStatuses = [{ id: "1", status: "done" as const }];

const result = correlate(null, epics, stories, epicStatuses);
expect(result.epics[0].status).toBe("done");
});
Expand Down
Loading
Loading