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
34 changes: 31 additions & 3 deletions apps/explorer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,38 @@ During development, select the repository at process startup:
QUALITY_PROJECT_ROOT=/absolute/path/to/project pnpm --filter @shiplightai/quality-explorer dev
```

To preview Release Intelligence against an exact GitHub Actions run, supply a
run URL (recommended) or a numeric run ID. A numeric ID infers the repository
from the project's GitHub `origin` remote:

```bash
GITHUB_TOKEN="$(gh auth token)" \
QUALITY_PROJECT_ROOT=/absolute/path/to/project \
RELEASE_ACTION_RUN=https://github.com/owner/repo/actions/runs/123 \
pnpm --filter @shiplightai/quality-explorer dev
```

The numeric-ID form is equivalent:

```bash
GITHUB_TOKEN="$(gh auth token)" \
QUALITY_PROJECT_ROOT=/absolute/path/to/project \
RELEASE_ACTION_RUN_ID=123 \
pnpm --filter @shiplightai/quality-explorer dev
```

Open <http://127.0.0.1:4173/release-intelligence>. The adapter resolves the run,
analyzes its exact commit in a temporary checkout, and removes that checkout
after rendering. It uses `git archive` when the commit is available locally and
otherwise downloads a private, token-authenticated GitHub archive. Both paths
write only beneath the system temporary directory; they do not fetch into,
modify tracked files in, or write Git metadata to the selected repository.
Optional `RELEASE_ENVIRONMENT` values are `staging` and `production` (the
default); `RELEASE_COMPONENTS` accepts a comma-separated component list.

The server binds to `127.0.0.1:4173`. API handlers ignore client-supplied project
paths and always operate on `QUALITY_PROJECT_ROOT`. Repository authoring remains
the responsibility of the `quality` agent skill and normal code review.

The current component source lives in the application while standalone parity
is established. Reusable presentation will move incrementally into
`packages/ui`.
The Explorer owns the server adapter and application shell. Reusable,
host-independent Release Intelligence presentation lives in `packages/ui`.
19 changes: 19 additions & 0 deletions apps/explorer/src/app/release-intelligence/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import {
ReleaseUiHostProvider,
type ReleaseUiHost,
} from "@shiplightai/quality-ui/release-intelligence";
import "@shiplightai/quality-ui/release-intelligence.css";

const host: ReleaseUiHost = {
routeBase: "/release-intelligence",
apiBase: "/api/release-intelligence",
};

export default function ReleaseIntelligenceLayout({
children,
}: {
readonly children: ReactNode;
}): React.ReactElement {
return <ReleaseUiHostProvider host={host}>{children}</ReleaseUiHostProvider>;
}
27 changes: 27 additions & 0 deletions apps/explorer/src/app/release-intelligence/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Alert, Code, Container, Stack, Text, Title } from "@mantine/core";
import { loadReleasePreview } from "@/lib/release-intelligence/action-run";
import { ReleasePreview } from "./release-preview";

export const dynamic = "force-dynamic";

export default async function ReleaseIntelligencePage(): Promise<React.ReactElement> {
try {
const model = await loadReleasePreview();
return <ReleasePreview model={model} />;
} catch (error) {
return (
<Container size="lg" py="xl">
<Stack gap="md">
<Title order={1}>Release Intelligence</Title>
<Alert color="red" title="Release preview could not start">
{error instanceof Error ? error.message : String(error)}
</Alert>
<Text size="sm" c="dimmed">
Start Explorer with <Code>GITHUB_TOKEN</Code>, <Code>QUALITY_PROJECT_ROOT</Code>, and
either <Code>RELEASE_ACTION_RUN</Code> or <Code>RELEASE_ACTION_RUN_ID</Code>.
</Text>
</Stack>
</Container>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.workspace {
display: flex;
height: 100dvh;
min-height: 0;
flex-direction: column;
gap: var(--mantine-spacing-md);
padding: var(--mantine-spacing-lg);
overflow: hidden;
}

@media (max-width: 61.99em) {
.workspace {
height: auto;
min-height: 100dvh;
overflow: visible;
}
}
16 changes: 16 additions & 0 deletions apps/explorer/src/app/release-intelligence/release-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Box } from "@mantine/core";
import { ReleaseDetail } from "@shiplightai/quality-ui/release-intelligence";
import type { ReleasePreviewModel } from "@/lib/release-intelligence/action-run";
import classes from "./release-preview.module.css";

export function ReleasePreview({
model,
}: {
readonly model: ReleasePreviewModel;
}): React.ReactElement {
return (
<Box className={classes.workspace}>
<ReleaseDetail detail={model} />
</Box>
);
}
60 changes: 60 additions & 0 deletions apps/explorer/src/lib/release-intelligence/action-run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
archiveListingHasUnsafePath,
parseGitHubRemote,
resolveActionRunReference,
} from "./action-run";

describe("release action run configuration", () => {
it("accepts a workflow run URL with an attempt", () => {
expect(
resolveActionRunReference(
"https://github.com/ShiplightAI/shipyard/actions/runs/32042052300/attempts/2",
),
).toEqual({
owner: "ShiplightAI",
repo: "shipyard",
runId: "32042052300",
runAttempt: 2,
});
});

it("infers a numeric run's repository from HTTPS or SSH origin", () => {
expect(resolveActionRunReference("42", "git@github.com:ShiplightAI/shipyard.git")).toEqual({
owner: "ShiplightAI",
repo: "shipyard",
runId: "42",
});
expect(parseGitHubRemote("https://github.com/ShiplightAI/quality.git")).toEqual({
owner: "ShiplightAI",
repo: "quality",
});
expect(parseGitHubRemote("git@github-loggia:ShiplightAI/shipyard.git")).toEqual({
owner: "ShiplightAI",
repo: "shipyard",
});
});

it("rejects a numeric run without an attributable GitHub repository", () => {
expect(() => resolveActionRunReference("42", "https://example.com/repo.git")).toThrow(
/GitHub origin remote/u,
);
});

it("rejects archive paths and symlink targets that escape the temporary checkout", () => {
expect(archiveListingHasUnsafePath("safe/file\n", "-rw-r--r-- safe/file\n")).toBe(false);
expect(archiveListingHasUnsafePath("../outside\n", "-rw-r--r-- ../outside\n")).toBe(true);
expect(
archiveListingHasUnsafePath(
"safe/link\n",
"lrwxr-xr-x safe/link -> /etc/passwd\n",
),
).toBe(true);
expect(
archiveListingHasUnsafePath(
"safe/link\n",
"lrwxr-xr-x safe/link -> ../../outside\n",
),
).toBe(true);
});
});
Loading