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
20 changes: 20 additions & 0 deletions apps/server/src/github/Errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Schema } from "effect";
import { GitHubCliError } from "../git/Errors.ts";

/**
* GitHubServiceError - GitHub issue/notification service error.
*/
export class GitHubServiceError extends Schema.TaggedErrorClass<GitHubServiceError>()(
"GitHubServiceError",
{
operation: Schema.String,
detail: Schema.String,
cause: Schema.optional(Schema.Defect),
},
) {
override get message(): string {
return `GitHub service failed in ${this.operation}: ${this.detail}`;
}
}

export type GitHubIssueServiceError = GitHubServiceError | GitHubCliError;
218 changes: 218 additions & 0 deletions apps/server/src/github/Layers/GitHub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/**
* GitHub Layer - Implementation of the GitHub issue service using the `gh` CLI.
*
* Delegates all GitHub CLI interactions to the existing GitHubCli service.
*/
import { Effect, Layer, Schema } from "effect";
import type {
GitHubGetIssueResult,
GitHubIssueComment,
GitHubIssueDetail,
GitHubIssueSummary,
GitHubListIssuesResult,
GitHubPostCommentResult,
} from "@okcode/contracts";
import { GitHubCli } from "../../git/Services/GitHubCli.ts";
import { GitHubServiceError } from "../Errors.ts";
import { GitHub, type GitHubShape } from "../Services/GitHub.ts";

// ── Raw CLI output schemas ──────────────────────────────────────────

const RawIssueLabel = Schema.Struct({
name: Schema.String,
color: Schema.optional(Schema.String),
});

const RawIssueAuthor = Schema.Struct({
login: Schema.String,
avatarUrl: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
name: Schema.optional(Schema.NullOr(Schema.String)),
});

const RawIssueSummary = Schema.Struct({
number: Schema.Number,
title: Schema.String,
state: Schema.String,
labels: Schema.Array(RawIssueLabel),
author: Schema.NullOr(RawIssueAuthor),
url: Schema.String,
updatedAt: Schema.String,
});

const RawIssueComment = Schema.Struct({
id: Schema.String,
body: Schema.String,
author: Schema.NullOr(RawIssueAuthor),
createdAt: Schema.String,
url: Schema.optional(Schema.NullOr(Schema.String)),
});

const RawIssueDetail = Schema.Struct({
number: Schema.Number,
title: Schema.String,
state: Schema.String,
body: Schema.String,
labels: Schema.Array(RawIssueLabel),
author: Schema.NullOr(RawIssueAuthor),
assignees: Schema.Array(RawIssueAuthor),
comments: Schema.Array(RawIssueComment),
milestone: Schema.optional(Schema.NullOr(Schema.Struct({ title: Schema.String }))),
url: Schema.String,
createdAt: Schema.String,
updatedAt: Schema.String,
});

// ── Normalization helpers ───────────────────────────────────────────

function normalizeAuthor(
raw: Schema.Schema.Type<typeof RawIssueAuthor> | null,
): GitHubIssueSummary["author"] {
if (!raw) return null;
return {
login: raw.login as any,
avatarUrl: raw.avatarUrl ?? "",
url: raw.url ?? `https://github.com/${raw.login}`,
name: raw.name ?? null,
bio: null,
company: null,
location: null,
};
}

function normalizeIssueSummary(
raw: Schema.Schema.Type<typeof RawIssueSummary>,
): GitHubIssueSummary {
return {
number: raw.number as any,
title: raw.title as any,
state: raw.state.toLowerCase() === "closed" ? "closed" : ("open" as any),
labels: raw.labels.map((l) => ({ name: l.name as any, color: l.color ?? "" })),
author: normalizeAuthor(raw.author),
url: raw.url,
updatedAt: raw.updatedAt,
};
}

function normalizeIssueComment(
raw: Schema.Schema.Type<typeof RawIssueComment>,
): GitHubIssueComment {
return {
id: raw.id as any,
body: raw.body,
author: normalizeAuthor(raw.author),
createdAt: raw.createdAt,
url: raw.url ?? null,
};
}

function normalizeIssueDetail(raw: Schema.Schema.Type<typeof RawIssueDetail>): GitHubIssueDetail {
return {
number: raw.number as any,
title: raw.title as any,
state: raw.state.toLowerCase() === "closed" ? "closed" : ("open" as any),
body: raw.body,
labels: raw.labels.map((l) => ({ name: l.name as any, color: l.color ?? "" })),
author: normalizeAuthor(raw.author),
assignees: raw.assignees.map((a) => normalizeAuthor(a)!).filter(Boolean) as any,
comments: raw.comments.map(normalizeIssueComment),
milestone: raw.milestone?.title ?? null,
url: raw.url,
createdAt: raw.createdAt,
updatedAt: raw.updatedAt,
commentsCount: raw.comments.length as any,
};
}

// ── Helper: parse JSON from gh CLI output ───────────────────────────

function decodeGhJson<S extends Schema.Top>(
raw: string,
schema: S,
operation: string,
invalidDetail: string,
): Effect.Effect<S["Type"], GitHubServiceError, S["DecodingServices"]> {
return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
Effect.mapError(
(error) =>
new GitHubServiceError({
operation,
detail: error instanceof Error ? `${invalidDetail}: ${error.message}` : invalidDetail,
cause: error,
}),
),
);
}

// ── Layer implementation ────────────────────────────────────────────

const makeGitHub = Effect.gen(function* () {
const gitHubCli = yield* GitHubCli;

const listIssues: GitHubShape["listIssues"] = (input) =>
Effect.gen(function* () {
const args: string[] = [
"issue",
"list",
"--json",
"number,title,state,labels,author,url,updatedAt",
"--limit",
String(input.limit ?? 10),
];
if (input.assignee) {
args.push("--assignee", input.assignee);
}
if (input.state) {
args.push("--state", input.state);
}
if (input.labels) {
args.push("--label", input.labels);
}

const result = yield* gitHubCli.execute({ cwd: input.cwd, args });
const raw = yield* decodeGhJson(
result.stdout,
Schema.Array(RawIssueSummary),
"listIssues",
"Failed to parse issue list output",
);
return { issues: raw.map(normalizeIssueSummary) } satisfies GitHubListIssuesResult;
});

const getIssue: GitHubShape["getIssue"] = (input) =>
Effect.gen(function* () {
const result = yield* gitHubCli.execute({
cwd: input.cwd,
args: [
"issue",
"view",
String(input.number),
"--json",
"number,title,state,body,labels,author,assignees,comments,milestone,url,createdAt,updatedAt",
],
});
const raw = yield* decodeGhJson(
result.stdout,
RawIssueDetail,
"getIssue",
"Failed to parse issue detail output",
);
return { issue: normalizeIssueDetail(raw) } satisfies GitHubGetIssueResult;
});

const postComment: GitHubShape["postComment"] = (input) =>
Effect.gen(function* () {
const result = yield* gitHubCli.execute({
cwd: input.cwd,
args: ["issue", "comment", String(input.issueNumber), "--body", input.body],
});
// gh issue comment outputs the comment URL on success
const url = result.stdout.trim() || "";
return { url } satisfies GitHubPostCommentResult;
});

const service = { listIssues, getIssue, postComment } satisfies GitHubShape;
return service;
});

export const GitHubLive = Layer.effect(GitHub, makeGitHub);
52 changes: 52 additions & 0 deletions apps/server/src/github/Services/GitHub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* GitHub - Effect service contract for GitHub issue operations.
*
* Provides listing, retrieval, and commenting on GitHub issues
* via the `gh` CLI.
*
* @module GitHub
*/
import { ServiceMap } from "effect";
import type { Effect } from "effect";
import type {
GitHubGetIssueInput,
GitHubGetIssueResult,
GitHubListIssuesInput,
GitHubListIssuesResult,
GitHubPostCommentInput,
GitHubPostCommentResult,
} from "@okcode/contracts";
import type { GitHubIssueServiceError } from "../Errors.ts";

/**
* GitHubShape - Service API for GitHub issue operations.
*/
export interface GitHubShape {
/**
* List issues for the current repository, optionally filtered by assignee/state/labels.
*/
readonly listIssues: (
input: GitHubListIssuesInput,
) => Effect.Effect<GitHubListIssuesResult, GitHubIssueServiceError>;

/**
* Get a single issue with its full body, comments, and metadata.
*/
readonly getIssue: (
input: GitHubGetIssueInput,
) => Effect.Effect<GitHubGetIssueResult, GitHubIssueServiceError>;

/**
* Post a comment on a GitHub issue.
*/
readonly postComment: (
input: GitHubPostCommentInput,
) => Effect.Effect<GitHubPostCommentResult, GitHubIssueServiceError>;
}

/**
* GitHub - Service tag for GitHub issue operations.
*/
export class GitHub extends ServiceMap.Service<GitHub, GitHubShape>()(
"okcode/github/Services/GitHub",
) {}
8 changes: 8 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
interactionMode: event.payload.interactionMode,
branch: event.payload.branch,
worktreePath: event.payload.worktreePath,
githubRef: event.payload.githubRef ? JSON.stringify(event.payload.githubRef) : null,
latestTurnId: null,
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
Expand All @@ -440,6 +441,13 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
...(event.payload.worktreePath !== undefined
? { worktreePath: event.payload.worktreePath }
: {}),
...(event.payload.githubRef !== undefined
? {
githubRef: event.payload.githubRef
? JSON.stringify(event.payload.githubRef)
: null,
}
: {}),
updatedAt: event.payload.updatedAt,
});
return;
Expand Down
49 changes: 30 additions & 19 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,25 +542,36 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
deletedAt: row.deletedAt,
}));

const threads: Array<OrchestrationThread> = threadRows.map((row) => ({
id: row.threadId,
projectId: row.projectId,
title: row.title,
model: row.model,
runtimeMode: row.runtimeMode,
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
messages: messagesByThread.get(row.threadId) ?? [],
proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
activities: activitiesByThread.get(row.threadId) ?? [],
checkpoints: checkpointsByThread.get(row.threadId) ?? [],
session: sessionsByThread.get(row.threadId) ?? null,
}));
const threads: Array<OrchestrationThread> = threadRows.map((row) => {
let githubRef: OrchestrationThread["githubRef"];
try {
if (row.githubRef) {
githubRef = JSON.parse(row.githubRef);
}
} catch {
// Ignore invalid JSON — treat as no ref
}
return {
id: row.threadId,
projectId: row.projectId,
title: row.title,
model: row.model,
runtimeMode: row.runtimeMode,
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
...(githubRef ? { githubRef } : {}),
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
messages: messagesByThread.get(row.threadId) ?? [],
proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
activities: activitiesByThread.get(row.threadId) ?? [],
checkpoints: checkpointsByThread.get(row.threadId) ?? [],
session: sessionsByThread.get(row.threadId) ?? null,
};
});

const snapshot = {
snapshotSequence: computeSnapshotSequence(stateRows),
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
interactionMode: command.interactionMode,
branch: command.branch,
worktreePath: command.worktreePath,
...(command.githubRef ? { githubRef: command.githubRef } : {}),
createdAt: command.createdAt,
updatedAt: command.createdAt,
},
Expand Down Expand Up @@ -264,6 +265,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
...(command.model !== undefined ? { model: command.model } : {}),
...(command.branch !== undefined ? { branch: command.branch } : {}),
...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),
...(command.githubRef !== undefined ? { githubRef: command.githubRef } : {}),
updatedAt: occurredAt,
},
};
Expand Down
Loading
Loading