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
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,15 @@ Agent Runs → Mission Control → Agent Activity Page
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `GITHUB_TOKEN` | Yes | GitHub Personal Access Token or GitHub App token |
| `MISSION_CONTROL_AGENT_TOKEN` | Yes | Bearer token for agent API authentication |
| `GITHUB_REPOSITORIES` | Yes | Bootstrap seed config for repos to track. Accepts comma-separated or newline-separated values (e.g., `myorg/repo1,myorg/repo2` or `myorg/repo1` on separate lines). Repos can also be managed via Mission Control UI after initial setup. |
| `GITHUB_REPOSITORIES` | Yes | Bootstrap seed config for repos to track. Accepts comma-separated or newline-separated values (e.g., `myorg/repo1,myorg/repo2` or `myorg/repo1` on separate lines). Repos can also be managed via Mission Control UI or `/api/automation/repos` after initial setup. |
| `NEXTAUTH_SECRET` | No | Secret for NextAuth.js (stub in Phase 1) |
| `NEXTAUTH_URL` | No | URL for NextAuth.js (stub in Phase 1) |

## First-Run Flow

To get started with Mission Control:

1. **Configure repos**: Set `GITHUB_REPOSITORIES` env var (comma or newline separated) _or_ add tracked repos via the UI after boot. `GITHUB_REPOSITORIES` is a **one-time bootstrap seed** — it is read only when the tracked-repos table is empty. Once any repo exists (seeded or added via UI), the env var is not consulted again, so updates must go through the UI or API (`POST /api/repos` / `POST /api/automation/repos`). Env-seeded repos are tagged `source: "env"` and shown with a `seed` badge in `/automation`; user-added repos are tagged `source: "user"`.
1. **Configure repos**: Set `GITHUB_REPOSITORIES` env var (comma or newline separated) _or_ add tracked repos via the UI after boot. `GITHUB_REPOSITORIES` is a **one-time bootstrap seed** — it is read only when the tracked-repos table is empty. Once any repo exists (seeded or added via UI), the env var is not consulted again, so updates must go through the UI or canonical API (`POST /api/automation/repos`). Env-seeded repos are tagged `source: "env"` and shown with a `seed` badge in `/automation`; user-added repos are tagged `source: "user"`.
2. **Deploy** with your database and GitHub token configured.
3. **Sync automation data**: `POST /api/automation/sync` (or use the Sync button on the Automation page).
4. **Sync issues**: `POST /api/sync` (or use the Sync Issues action in the board UI). OpenClaw agent heartbeats also trigger best-effort issue sync automatically.
Expand Down Expand Up @@ -224,17 +224,22 @@ List cached issues. Query params: `repo`, `agent`, `owner`, `project`, `priority
Move issue between status columns. Body: `{ issueId, repoFullName, issueNumber, oldLabels, newLabels }`

### GET /api/repos
List configured repositories (board/issue-sync view; `Repository` rows).
List configured repositories for the board/issue-sync view (`Repository` rows). This is not the tracked repository management API.

### POST /api/repos
Add a tracked repository. Body: `{ fullName: "owner/repo" }`. Creates an `AutomationRepo` row with `source: "user"` (canonical tracked-repos list) **and** a mirror `Repository` row so the board sees it immediately. Returns 409 if the repo is already tracked. Writes an `add_tracked_repo` AuditLog entry.
Deprecated compatibility endpoint for adding a tracked repository. Prefer `POST /api/automation/repos`; this endpoint delegates to the same behavior and returns deprecation headers.

### GET /api/automation/repos
Canonical tracked repository list for automation. Returns `AutomationRepo` rows with workflow/release summary fields used by `/automation`.

### POST /api/automation/repos
Same shape and semantics as `POST /api/repos` but scoped to the automation surface. Used by the `/automation` Add Repo button.
Canonical tracked repository add endpoint. Body: `{ fullName: "owner/repo" }`. Creates an `AutomationRepo` row with `source: "user"` **and** a mirror enabled `Repository` row so issue sync and the board see it immediately. Returns 409 if the repo is already tracked. Writes an `add_tracked_repo` AuditLog entry.

### DELETE /api/automation/repos/[repo]
Stop tracking a repository. `[repo]` is the URL-encoded `owner/repo` fullName. Hard-deletes the `AutomationRepo` row (cascading workflow/run/release history) and soft-disables the mirror `Repository` row (`enabled = false`) so cached issues remain visible for history but are excluded from active board filters. Writes a `remove_tracked_repo` AuditLog entry.

`/api/automation/repositories` and `/api/automation/repositories/[id]` were legacy duplicate routes and have been removed. Use `/api/automation/repos` for tracked repository management.

### POST /api/sync
Sync all issues from configured repositories. Intended callers are:

Expand Down
24 changes: 23 additions & 1 deletion src/app/api/automation/repos/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
const { mocks } = vi.hoisted(() => ({
mocks: {
createAutomationRepo: vi.fn(),
upsertRepository: vi.fn(),
transaction: vi.fn((callback) =>
callback({
automationRepo: { create: mocks.createAutomationRepo },
repository: { upsert: mocks.upsertRepository },
auditLog: { create: mocks.createAuditLog },
}),
),
createAuditLog: vi.fn().mockResolvedValue({ id: "log-1" }),
},
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
$transaction: mocks.transaction,
automationRepo: { create: mocks.createAutomationRepo },
repository: { upsert: mocks.upsertRepository },
auditLog: { create: mocks.createAuditLog },
},
}));
Expand Down Expand Up @@ -52,6 +62,13 @@ describe("POST /api/automation/repos", () => {
name: "myrepo",
source: "user",
});
mocks.upsertRepository.mockResolvedValue({
id: "mirror-1",
fullName: "myorg/myrepo",
owner: "myorg",
name: "myrepo",
enabled: true,
});
mocks.createAuditLog.mockResolvedValue({ id: "log-1" });
});

Expand All @@ -70,13 +87,18 @@ describe("POST /api/automation/repos", () => {
expect(res.status).toBe(400);
});

it("creates an AutomationRepo with source=user and writes an audit row on success", async () => {
it("creates AutomationRepo and Repository rows, then writes an audit row on success", async () => {
const res = await postRequest({ fullName: "myorg/myrepo" });
expect(res.status).toBe(201);

expect(mocks.createAutomationRepo).toHaveBeenCalledWith({
data: { fullName: "myorg/myrepo", owner: "myorg", name: "myrepo", source: "user" },
});
expect(mocks.upsertRepository).toHaveBeenCalledWith({
where: { fullName: "myorg/myrepo" },
create: { fullName: "myorg/myrepo", owner: "myorg", name: "myrepo", enabled: true },
update: { owner: "myorg", name: "myrepo", enabled: true },
});

expect(mocks.createAuditLog).toHaveBeenCalledWith({
data: expect.objectContaining({
Expand Down
34 changes: 5 additions & 29 deletions src/app/api/automation/repos/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { jsonSafe } from "@/lib/json";
import { isValidRepoName } from "@/lib/config";
import { auditTrackedRepoCreateFailure, createTrackedRepo } from "@/lib/tracked-repos";

export async function GET() {
try {
Expand Down Expand Up @@ -92,43 +93,18 @@ export async function POST(request: Request) {
);
}

const [owner, name] = fullName.split("/");

try {
const repo = await prisma.automationRepo.create({
data: { fullName, owner, name, source: "user" },
});

await prisma.auditLog.create({
data: {
actor: "user",
action: "add_tracked_repo",
repoFullName: fullName,
beforeLabels: [],
afterLabels: [],
success: true,
},
});
const { automationRepo, repository } = await createTrackedRepo(fullName);

return NextResponse.json(jsonSafe(repo), { status: 201 });
return NextResponse.json(jsonSafe({ ...automationRepo, repositoryId: repository.id }), { status: 201 });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json({ error: "Repository is already tracked" }, { status: 409 });
}

const errorMessage = error instanceof Error ? error.message : "Unknown error";
await prisma.auditLog.create({
data: {
actor: "user",
action: "add_tracked_repo",
repoFullName: fullName,
beforeLabels: [],
afterLabels: [],
success: false,
errorMessage,
},
});
await auditTrackedRepoCreateFailure(fullName, errorMessage);
console.error("Failed to add tracked repo:", error);
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
}
27 changes: 0 additions & 27 deletions src/app/api/automation/repositories/[id]/route.ts

This file was deleted.

55 changes: 0 additions & 55 deletions src/app/api/automation/repositories/route.ts

This file was deleted.

49 changes: 10 additions & 39 deletions src/app/api/repos/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { isValidRepoName } from "@/lib/config";
import { auditTrackedRepoCreateFailure, createTrackedRepo } from "@/lib/tracked-repos";

export async function GET() {
try {
Expand All @@ -15,9 +16,8 @@ export async function GET() {
}
}

// AutomationRepo is the canonical tracked-repos table. POST creates an
// AutomationRepo (source=user) and a mirror Repository so the board surfaces
// the new repo without waiting for the next /api/sync.
// Deprecated compatibility endpoint. Use POST /api/automation/repos for
// tracked repository management.
export async function POST(request: Request) {
let body: unknown;
try {
Expand All @@ -43,50 +43,21 @@ export async function POST(request: Request) {
);
}

const [owner, name] = fullName.split("/");

try {
const [automationRepo, repository] = await prisma.$transaction([
prisma.automationRepo.create({
data: { fullName, owner, name, source: "user" },
}),
prisma.repository.upsert({
where: { fullName },
create: { fullName, owner, name, enabled: true },
update: { enabled: true },
}),
]);

await prisma.auditLog.create({
data: {
actor: "user",
action: "add_tracked_repo",
repoFullName: fullName,
beforeLabels: [],
afterLabels: [],
success: true,
},
});
const { automationRepo, repository } = await createTrackedRepo(fullName);

return NextResponse.json({ ...repository, automationRepoId: automationRepo.id }, { status: 201 });
const response = NextResponse.json({ ...repository, automationRepoId: automationRepo.id }, { status: 201 });
response.headers.set("Deprecation", "true");
response.headers.set("Link", '</api/automation/repos>; rel="successor-version"');
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json({ error: "Repository is already tracked" }, { status: 409 });
}

const errorMessage = error instanceof Error ? error.message : "Unknown error";
await prisma.auditLog.create({
data: {
actor: "user",
action: "add_tracked_repo",
repoFullName: fullName,
beforeLabels: [],
afterLabels: [],
success: false,
errorMessage,
},
});
await auditTrackedRepoCreateFailure(fullName, errorMessage);
console.error("Failed to create repo:", error);
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
}
44 changes: 44 additions & 0 deletions src/lib/tracked-repos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { prisma } from "@/lib/prisma";

export async function createTrackedRepo(fullName: string) {
const [owner, name] = fullName.split("/");

const { automationRepo, repository } = await prisma.$transaction(async (tx) => {
const automationRepo = await tx.automationRepo.create({
data: { fullName, owner, name, source: "user" },
});
const repository = await tx.repository.upsert({
where: { fullName },
create: { fullName, owner, name, enabled: true },
update: { owner, name, enabled: true },
});
await tx.auditLog.create({
data: {
actor: "user",
action: "add_tracked_repo",
repoFullName: fullName,
beforeLabels: [],
afterLabels: [],
success: true,
},
});

return { automationRepo, repository };
});

return { automationRepo, repository };
}

export async function auditTrackedRepoCreateFailure(fullName: string, errorMessage: string) {
await prisma.auditLog.create({
data: {
actor: "user",
action: "add_tracked_repo",
repoFullName: fullName,
beforeLabels: [],
afterLabels: [],
success: false,
errorMessage,
},
});
}