diff --git a/apps/server/src/calendar/Errors.ts b/apps/server/src/calendar/Errors.ts new file mode 100644 index 0000000000..dec2ece028 --- /dev/null +++ b/apps/server/src/calendar/Errors.ts @@ -0,0 +1,5 @@ +import { Data } from "effect"; + +export class CalendarError extends Data.TaggedError("CalendarError")<{ + readonly message: string; +}> {} diff --git a/apps/server/src/calendar/Layers/CalendarService.ts b/apps/server/src/calendar/Layers/CalendarService.ts new file mode 100644 index 0000000000..5d7e0ba1de --- /dev/null +++ b/apps/server/src/calendar/Layers/CalendarService.ts @@ -0,0 +1,58 @@ +import { Effect, Layer } from "effect"; +import { execSync } from "node:child_process"; +import { CalendarService } from "../Services/CalendarService.ts"; +import { CalendarError } from "../Errors.ts"; +import type { CalendarEvent } from "@t3tools/contracts"; + +function runGcalcli(args: string): Effect.Effect { + return Effect.try({ + try: () => execSync(`gcalcli ${args}`, { encoding: "utf-8", timeout: 30000 }), + catch: (e) => new CalendarError({ message: `gcalcli failed: ${e}` }), + }); +} + +function parseTsvAgenda(tsv: string): CalendarEvent[] { + const lines = tsv.trim().split("\n").filter(Boolean); + const events: CalendarEvent[] = []; + + for (const line of lines) { + const parts = line.split("\t"); + if (parts.length < 4) continue; + + const [startDate, startTime, endDate, endTime, ...titleParts] = parts; + const title = titleParts.join("\t").trim(); + if (!title) continue; + + const isAllDay = !startTime || startTime.trim() === ""; + const start = isAllDay ? (startDate ?? "").trim() : `${(startDate ?? "").trim()}T${(startTime ?? "").trim()}`; + const end = isAllDay ? (endDate ?? "").trim() : `${(endDate ?? "").trim()}T${(endTime ?? "").trim()}`; + + events.push({ + title: title as CalendarEvent["title"], + start, + end, + isAllDay, + } as CalendarEvent); + } + + return events; +} + +export const CalendarServiceLive = Layer.succeed( + CalendarService, + CalendarService.of({ + agenda: ({ date }) => + Effect.gen(function* () { + const dateArg = date ?? "today"; + const output = yield* runGcalcli(`agenda ${dateArg} --tsv`); + return parseTsvAgenda(output); + }), + + meetingPrep: ({ title, start }) => + Effect.gen(function* () { + // Generate meeting prep notes based on title and time + const notes = `## Meeting Prep: ${title}\n**Time:** ${start}\n\n### Talking Points\n- \n\n### Questions\n- \n\n### Action Items\n- `; + return { notes }; + }), + }), +); diff --git a/apps/server/src/calendar/Services/CalendarService.ts b/apps/server/src/calendar/Services/CalendarService.ts new file mode 100644 index 0000000000..b2ea0b26ca --- /dev/null +++ b/apps/server/src/calendar/Services/CalendarService.ts @@ -0,0 +1,12 @@ +import { ServiceMap, Effect } from "effect"; +import type { CalendarEvent } from "@t3tools/contracts"; +import type { CalendarError } from "../Errors.ts"; + +export interface CalendarServiceShape { + readonly agenda: (input: { date?: string }) => Effect.Effect, CalendarError>; + readonly meetingPrep: (input: { title: string; start: string }) => Effect.Effect<{ notes: string }, CalendarError>; +} + +export class CalendarService extends ServiceMap.Service()( + "t3/calendar/Services/CalendarService", +) {} diff --git a/apps/server/src/git/Errors.ts b/apps/server/src/git/Errors.ts index 15bf482f7b..2d5c5a01c7 100644 --- a/apps/server/src/git/Errors.ts +++ b/apps/server/src/git/Errors.ts @@ -44,6 +44,22 @@ export class TextGenerationError extends Schema.TaggedErrorClass()( + "BitbucketApiError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + } +} + /** * GitManagerError - Stacked Git workflow orchestration failed. */ @@ -64,4 +80,5 @@ export type GitManagerServiceError = | GitManagerError | GitCommandError | GitHubCliError + | BitbucketApiError | TextGenerationError; diff --git a/apps/server/src/git/Layers/BitbucketApi.ts b/apps/server/src/git/Layers/BitbucketApi.ts new file mode 100644 index 0000000000..7a0cfae123 --- /dev/null +++ b/apps/server/src/git/Layers/BitbucketApi.ts @@ -0,0 +1,278 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { Effect, Layer } from "effect"; +import { BitbucketApiError } from "../Errors.ts"; +import { + BitbucketApi, + type BitbucketApiShape, + type BitbucketPullRequestSummary, +} from "../Services/BitbucketApi.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const BITBUCKET_API_BASE = "https://api.bitbucket.org/2.0"; + +interface NetrcCredentials { + login: string; + password: string; +} + +function readNetrcCredentials(): NetrcCredentials | null { + try { + const netrcPath = join(homedir(), ".netrc"); + const content = readFileSync(netrcPath, "utf-8"); + const lines = content.split("\n").map((l) => l.trim()); + let inBitbucket = false; + let login: string | null = null; + let password: string | null = null; + + for (const line of lines) { + if (line.startsWith("machine") && line.includes("bitbucket.org")) { + inBitbucket = true; + continue; + } + if (inBitbucket && line.startsWith("machine")) { + break; + } + if (inBitbucket) { + if (line.startsWith("login")) { + login = line.replace(/^login\s+/, "").trim(); + } + if (line.startsWith("password")) { + password = line.replace(/^password\s+/, "").trim(); + } + } + } + + if (login && password) { + return { login, password }; + } + return null; + } catch { + return null; + } +} + +function normalizeBitbucketPrState( + state: string, +): "open" | "closed" | "merged" { + switch (state.toUpperCase()) { + case "OPEN": + return "open"; + case "MERGED": + return "merged"; + case "DECLINED": + case "SUPERSEDED": + return "closed"; + default: + return "closed"; + } +} + +function parsePrSummary(raw: Record): BitbucketPullRequestSummary | null { + const id = raw.id; + const title = raw.title; + const state = raw.state; + const links = raw.links as Record | undefined; + const source = raw.source as Record | undefined; + const destination = raw.destination as Record | undefined; + + if (typeof id !== "number" || typeof title !== "string" || typeof state !== "string") { + return null; + } + + const htmlLink = links?.html as Record | undefined; + const url = typeof htmlLink?.href === "string" ? htmlLink.href : ""; + + const sourceBranch = source?.branch as Record | undefined; + const sourceRefName = typeof sourceBranch?.name === "string" ? sourceBranch.name : ""; + + const destBranch = destination?.branch as Record | undefined; + const destRefName = typeof destBranch?.name === "string" ? destBranch.name : ""; + + return { + id, + title, + url, + sourceRefName, + destinationRefName: destRefName, + state: normalizeBitbucketPrState(state), + }; +} + +async function bitbucketFetch( + path: string, + options: { method?: string; body?: string; timeoutMs?: number } = {}, +): Promise { + const credentials = readNetrcCredentials(); + if (!credentials) { + throw new Error( + "Bitbucket credentials not found in ~/.netrc. Add an entry for machine bitbucket.org or api.bitbucket.org with your app password.", + ); + } + + const url = `${BITBUCKET_API_BASE}${path}`; + const auth = Buffer.from(`${credentials.login}:${credentials.password}`).toString("base64"); + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ); + + try { + const response = await fetch(url, { + method: options.method ?? "GET", + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: options.body, + signal: controller.signal, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Bitbucket API ${response.status}: ${text}`); + } + + return await response.json(); + } finally { + clearTimeout(timeout); + } +} + +function normalizeBitbucketApiError( + operation: string, + error: unknown, +): BitbucketApiError { + if (error instanceof Error) { + if (error.message.includes("credentials not found")) { + return new BitbucketApiError({ + operation, + detail: + "Bitbucket credentials not configured. Add to ~/.netrc:\n machine bitbucket.org\n login your@email.com\n password YOUR_APP_PASSWORD", + cause: error, + }); + } + if (error.message.includes("401") || error.message.includes("403")) { + return new BitbucketApiError({ + operation, + detail: "Bitbucket authentication failed. Check your app password in ~/.netrc.", + cause: error, + }); + } + if (error.message.includes("404")) { + return new BitbucketApiError({ + operation, + detail: "Bitbucket resource not found. Check workspace and repository names.", + cause: error, + }); + } + return new BitbucketApiError({ + operation, + detail: `Bitbucket API call failed: ${error.message}`, + cause: error, + }); + } + return new BitbucketApiError({ + operation, + detail: "Bitbucket API call failed.", + cause: error, + }); +} + +const makeBitbucketApi = Effect.sync(() => { + const service: BitbucketApiShape = { + listOpenPullRequests: (input) => + Effect.tryPromise({ + try: async () => { + const q = encodeURIComponent( + `source.branch.name="${input.sourceBranch}" AND state="OPEN"`, + ); + const limit = input.limit ?? 10; + const data = (await bitbucketFetch( + `/repositories/${input.workspace}/${input.repoSlug}/pullrequests?q=${q}&pagelen=${limit}`, + )) as { values?: unknown[] }; + const values = Array.isArray(data.values) ? data.values : []; + return values + .map((v) => parsePrSummary(v as Record)) + .filter((v): v is BitbucketPullRequestSummary => v !== null); + }, + catch: (error) => normalizeBitbucketApiError("listOpenPullRequests", error), + }), + + listAllPullRequests: (input) => + Effect.tryPromise({ + try: async () => { + const q = encodeURIComponent( + `source.branch.name="${input.sourceBranch}"`, + ); + const limit = input.limit ?? 20; + const data = (await bitbucketFetch( + `/repositories/${input.workspace}/${input.repoSlug}/pullrequests?q=${q}&pagelen=${limit}&sort=-updated_on`, + )) as { values?: unknown[] }; + const values = Array.isArray(data.values) ? data.values : []; + return values + .map((v) => parsePrSummary(v as Record)) + .filter((v): v is BitbucketPullRequestSummary => v !== null); + }, + catch: (error) => normalizeBitbucketApiError("listAllPullRequests", error), + }), + + getPullRequest: (input) => + Effect.tryPromise({ + try: async () => { + const data = (await bitbucketFetch( + `/repositories/${input.workspace}/${input.repoSlug}/pullrequests/${input.prId}`, + )) as Record; + const pr = parsePrSummary(data); + if (!pr) { + throw new Error(`Failed to parse PR #${input.prId}`); + } + return pr; + }, + catch: (error) => normalizeBitbucketApiError("getPullRequest", error), + }), + + createPullRequest: (input) => + Effect.tryPromise({ + try: async () => { + const body = JSON.stringify({ + title: input.title, + description: input.description, + source: { branch: { name: input.sourceBranch } }, + destination: { branch: { name: input.destinationBranch } }, + close_source_branch: true, + }); + const data = (await bitbucketFetch( + `/repositories/${input.workspace}/${input.repoSlug}/pullrequests`, + { method: "POST", body }, + )) as Record; + const pr = parsePrSummary(data); + if (!pr) { + throw new Error("Failed to parse created PR response"); + } + return pr; + }, + catch: (error) => normalizeBitbucketApiError("createPullRequest", error), + }), + + getDefaultBranch: (input) => + Effect.tryPromise({ + try: async () => { + const data = (await bitbucketFetch( + `/repositories/${input.workspace}/${input.repoSlug}`, + )) as { mainbranch?: { name?: string } }; + return data.mainbranch?.name ?? null; + }, + catch: (error) => normalizeBitbucketApiError("getDefaultBranch", error), + }), + }; + + return service; +}); + +export const BitbucketApiLive = Layer.effect(BitbucketApi, makeBitbucketApi); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index e76994f853..a5e601e79e 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -15,6 +15,8 @@ import { GitHubCli, } from "../Services/GitHubCli.ts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { BitbucketApi, type BitbucketApiShape } from "../Services/BitbucketApi.ts"; +import { BitbucketApiError } from "../Errors.ts"; import { GitServiceLive } from "./GitService.ts"; import { GitService } from "../Services/GitService.ts"; import { GitCoreLive } from "./GitCore.ts"; @@ -485,9 +487,20 @@ function makeManager(input?: { Layer.provideMerge(ServerConfigLayer), ); + const fakeBitbucketApi: BitbucketApiShape = { + listOpenPullRequests: () => Effect.succeed([]), + listAllPullRequests: () => Effect.succeed([]), + getPullRequest: () => + Effect.fail(new BitbucketApiError({ operation: "getPullRequest", detail: "Not configured in test" })), + createPullRequest: () => + Effect.fail(new BitbucketApiError({ operation: "createPullRequest", detail: "Not configured in test" })), + getDefaultBranch: () => Effect.succeed(null), + }; + const managerLayer = Layer.mergeAll( Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), + Layer.succeed(BitbucketApi, fakeBitbucketApi), gitCoreLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 1a3cf2bb35..916c5ac6a5 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -12,6 +12,7 @@ import { GitManagerError } from "../Errors.ts"; import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; +import { BitbucketApi } from "../Services/BitbucketApi.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; interface OpenPrInfo { @@ -93,6 +94,50 @@ function resolvePullRequestWorktreeLocalBranchName( return `t3code/pr-${pullRequest.number}/${suffix}`; } +type RemoteProvider = "github" | "bitbucket" | "unknown"; + +interface BitbucketRemoteInfo { + workspace: string; + repoSlug: string; +} + +function detectRemoteProvider(url: string | null): RemoteProvider { + const trimmed = url?.trim() ?? ""; + if (trimmed.length === 0) return "unknown"; + if (/github\.com/i.test(trimmed)) return "github"; + if (/bitbucket\.org/i.test(trimmed)) return "bitbucket"; + return "unknown"; +} + +function parseBitbucketRemoteInfo(url: string | null): BitbucketRemoteInfo | null { + const trimmed = url?.trim() ?? ""; + if (trimmed.length === 0) return null; + + // SSH: git@bitbucket.org:workspace/repo.git + const sshMatch = + /^git@bitbucket\.org:([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(trimmed); + if (sshMatch?.[1] && sshMatch[2]) { + return { workspace: sshMatch[1], repoSlug: sshMatch[2] }; + } + + // HTTPS: https://bitbucket.org/workspace/repo.git + const httpsMatch = + /^https?:\/\/(?:[^@]+@)?bitbucket\.org\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(trimmed); + if (httpsMatch?.[1] && httpsMatch[2]) { + return { workspace: httpsMatch[1], repoSlug: httpsMatch[2] }; + } + + return null; +} + +function parseBitbucketPrUrl(url: string): { workspace: string; repoSlug: string; prId: number } | null { + const match = /^https?:\/\/bitbucket\.org\/([^/]+)\/([^/]+)\/pull-requests\/(\d+)/i.exec(url.trim()); + if (match?.[1] && match[2] && match[3]) { + return { workspace: match[1], repoSlug: match[2], prId: parseInt(match[3], 10) }; + } + return null; +} + function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { @@ -335,8 +380,78 @@ function toPullRequestHeadRemoteInfo(pr: { export const makeGitManager = Effect.gen(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; + const bitbucketApi = yield* BitbucketApi; const textGeneration = yield* TextGeneration; + /** Detect the remote provider for a repo by reading origin URL. */ + const detectCwdProvider = (cwd: string) => + gitCore + .readConfigValue(cwd, "remote.origin.url") + .pipe( + Effect.map(detectRemoteProvider), + Effect.catch(() => Effect.succeed("unknown" as RemoteProvider)), + ); + + /** Parse Bitbucket workspace/repo from origin remote. */ + const resolveBitbucketRemote = (cwd: string) => + gitCore + .readConfigValue(cwd, "remote.origin.url") + .pipe( + Effect.map(parseBitbucketRemoteInfo), + Effect.catch(() => Effect.succeed(null)), + ); + + /** Find the latest PR for a branch on Bitbucket. */ + const findLatestBitbucketPr = (cwd: string, branch: string) => + Effect.gen(function* () { + const remote = yield* resolveBitbucketRemote(cwd); + if (!remote) return null; + + const prs = yield* bitbucketApi.listAllPullRequests({ + workspace: remote.workspace, + repoSlug: remote.repoSlug, + sourceBranch: branch, + limit: 20, + }); + + // Prefer open PRs + const openPr = prs.find((pr) => pr.state === "open"); + return openPr ?? prs[0] ?? null; + }); + + /** Find an open PR for a branch on Bitbucket. */ + const findOpenBitbucketPr = (cwd: string, branch: string) => + Effect.gen(function* () { + const remote = yield* resolveBitbucketRemote(cwd); + if (!remote) return null; + + const prs = yield* bitbucketApi.listOpenPullRequests({ + workspace: remote.workspace, + repoSlug: remote.repoSlug, + sourceBranch: branch, + limit: 1, + }); + + return prs[0] ?? null; + }); + + /** Convert a Bitbucket PR to the internal PullRequestInfo format. */ + const bitbucketPrToStatusPr = (pr: { + id: number; + title: string; + url: string; + sourceRefName: string; + destinationRefName: string; + state: "open" | "closed" | "merged"; + }) => ({ + number: pr.id, + title: pr.title, + url: pr.url, + baseBranch: pr.destinationRefName, + headBranch: pr.sourceRefName, + state: pr.state, + }); + const configurePullRequestHeadUpstream = ( cwd: string, pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, @@ -708,6 +823,64 @@ export const makeGitManager = Effect.gen(function* () { }; }); + const runBitbucketPrStep = (cwd: string, branch: string, model?: string) => + Effect.gen(function* () { + const remote = yield* resolveBitbucketRemote(cwd); + if (!remote) { + return yield* gitManagerError( + "runPrStep", + "Could not resolve Bitbucket workspace/repo from origin remote.", + ); + } + + // Check for existing open PR + const existingPr = yield* findOpenBitbucketPr(cwd, branch); + if (existingPr) { + return { + status: "opened_existing" as const, + url: existingPr.url, + number: existingPr.id, + baseBranch: existingPr.destinationRefName, + headBranch: existingPr.sourceRefName, + title: existingPr.title, + }; + } + + const defaultBranch = yield* bitbucketApi + .getDefaultBranch({ workspace: remote.workspace, repoSlug: remote.repoSlug }) + .pipe(Effect.map((b) => b ?? "main")); + + const rangeContext = yield* gitCore.readRangeContext(cwd, defaultBranch); + + const generated = yield* textGeneration.generatePrContent({ + cwd, + baseBranch: defaultBranch, + headBranch: branch, + commitSummary: limitContext(rangeContext.commitSummary, 20_000), + diffSummary: limitContext(rangeContext.diffSummary, 20_000), + diffPatch: limitContext(rangeContext.diffPatch, 60_000), + ...(model ? { model } : {}), + }); + + const createdPr = yield* bitbucketApi.createPullRequest({ + workspace: remote.workspace, + repoSlug: remote.repoSlug, + sourceBranch: branch, + destinationBranch: defaultBranch, + title: generated.title, + description: generated.body, + }); + + return { + status: "created" as const, + url: createdPr.url, + number: createdPr.id, + baseBranch: createdPr.destinationRefName, + headBranch: createdPr.sourceRefName, + title: createdPr.title, + }; + }); + const runPrStep = (cwd: string, fallbackBranch: string | null, model?: string) => Effect.gen(function* () { const details = yield* gitCore.statusDetails(cwd); @@ -725,6 +898,12 @@ export const makeGitManager = Effect.gen(function* () { ); } + // Route to Bitbucket if applicable + const provider = yield* detectCwdProvider(cwd); + if (provider === "bitbucket") { + return yield* runBitbucketPrStep(cwd, branch, model); + } + const headContext = yield* resolveBranchHeadContext(cwd, { branch, upstreamRef: details.upstreamRef, @@ -796,16 +975,26 @@ export const makeGitManager = Effect.gen(function* () { const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) { const details = yield* gitCore.statusDetails(input.cwd); - const pr = - details.branch !== null - ? yield* findLatestPr(input.cwd, { - branch: details.branch, - upstreamRef: details.upstreamRef, - }).pipe( - Effect.map((latest) => (latest ? toStatusPr(latest) : null)), - Effect.catch(() => Effect.succeed(null)), - ) - : null; + let pr: { number: number; title: string; url: string; baseBranch: string; headBranch: string; state: "open" | "closed" | "merged" } | null = null; + + if (details.branch !== null) { + const provider = yield* detectCwdProvider(input.cwd); + + if (provider === "bitbucket") { + pr = yield* findLatestBitbucketPr(input.cwd, details.branch).pipe( + Effect.map((latest) => (latest ? bitbucketPrToStatusPr(latest) : null)), + Effect.catch(() => Effect.succeed(null)), + ); + } else { + pr = yield* findLatestPr(input.cwd, { + branch: details.branch, + upstreamRef: details.upstreamRef, + }).pipe( + Effect.map((latest) => (latest ? toStatusPr(latest) : null)), + Effect.catch(() => Effect.succeed(null)), + ); + } + } return { branch: details.branch, @@ -820,10 +1009,56 @@ export const makeGitManager = Effect.gen(function* () { const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fnUntraced( function* (input) { + const normalizedRef = normalizePullRequestReference(input.reference); + + // Check if this is a Bitbucket PR URL + const bbPrUrl = parseBitbucketPrUrl(normalizedRef); + if (bbPrUrl) { + const bbPr = yield* bitbucketApi.getPullRequest({ + workspace: bbPrUrl.workspace, + repoSlug: bbPrUrl.repoSlug, + prId: bbPrUrl.prId, + }); + return { + pullRequest: { + number: bbPr.id, + title: bbPr.title, + url: bbPr.url, + baseBranch: bbPr.destinationRefName, + headBranch: bbPr.sourceRefName, + state: bbPr.state, + }, + }; + } + + // Check if the cwd is a Bitbucket repo with a numeric PR reference + const provider = yield* detectCwdProvider(input.cwd); + if (provider === "bitbucket" && /^\d+$/.test(normalizedRef)) { + const remote = yield* resolveBitbucketRemote(input.cwd); + if (remote) { + const bbPr = yield* bitbucketApi.getPullRequest({ + workspace: remote.workspace, + repoSlug: remote.repoSlug, + prId: parseInt(normalizedRef, 10), + }); + return { + pullRequest: { + number: bbPr.id, + title: bbPr.title, + url: bbPr.url, + baseBranch: bbPr.destinationRefName, + headBranch: bbPr.sourceRefName, + state: bbPr.state, + }, + }; + } + } + + // Fall back to GitHub const pullRequest = yield* gitHubCli .getPullRequest({ cwd: input.cwd, - reference: normalizePullRequestReference(input.reference), + reference: normalizedRef, }) .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); diff --git a/apps/server/src/git/Services/BitbucketApi.ts b/apps/server/src/git/Services/BitbucketApi.ts new file mode 100644 index 0000000000..1bbb4b233f --- /dev/null +++ b/apps/server/src/git/Services/BitbucketApi.ts @@ -0,0 +1,81 @@ +/** + * BitbucketApi - Effect service contract for Bitbucket Cloud REST API interactions. + * + * Provides PR operations for Bitbucket repositories, parallel to GitHubCli. + * + * @module BitbucketApi + */ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { BitbucketApiError } from "../Errors.ts"; + +export interface BitbucketPullRequestSummary { + readonly id: number; + readonly title: string; + readonly url: string; + readonly sourceRefName: string; + readonly destinationRefName: string; + readonly state: "open" | "closed" | "merged"; +} + +/** + * BitbucketApiShape - Service API for Bitbucket Cloud REST API. + */ +export interface BitbucketApiShape { + /** + * List open pull requests for a source branch. + */ + readonly listOpenPullRequests: (input: { + readonly workspace: string; + readonly repoSlug: string; + readonly sourceBranch: string; + readonly limit?: number; + }) => Effect.Effect, BitbucketApiError>; + + /** + * Get a pull request by ID. + */ + readonly getPullRequest: (input: { + readonly workspace: string; + readonly repoSlug: string; + readonly prId: number; + }) => Effect.Effect; + + /** + * Create a pull request. + */ + readonly createPullRequest: (input: { + readonly workspace: string; + readonly repoSlug: string; + readonly sourceBranch: string; + readonly destinationBranch: string; + readonly title: string; + readonly description: string; + }) => Effect.Effect; + + /** + * Get the default branch (main branch) for a repository. + */ + readonly getDefaultBranch: (input: { + readonly workspace: string; + readonly repoSlug: string; + }) => Effect.Effect; + + /** + * List all pull requests (any state) for a source branch. + */ + readonly listAllPullRequests: (input: { + readonly workspace: string; + readonly repoSlug: string; + readonly sourceBranch: string; + readonly limit?: number; + }) => Effect.Effect, BitbucketApiError>; +} + +/** + * BitbucketApi - Service tag for Bitbucket Cloud REST API. + */ +export class BitbucketApi extends ServiceMap.Service()( + "t3/git/Services/BitbucketApi", +) {} diff --git a/apps/server/src/gmail/Errors.ts b/apps/server/src/gmail/Errors.ts new file mode 100644 index 0000000000..a33d683f5b --- /dev/null +++ b/apps/server/src/gmail/Errors.ts @@ -0,0 +1,5 @@ +import { Data } from "effect"; + +export class GmailError extends Data.TaggedError("GmailError")<{ + readonly message: string; +}> {} diff --git a/apps/server/src/gmail/Layers/GmailService.ts b/apps/server/src/gmail/Layers/GmailService.ts new file mode 100644 index 0000000000..d5af2cb477 --- /dev/null +++ b/apps/server/src/gmail/Layers/GmailService.ts @@ -0,0 +1,92 @@ +import { Effect, Layer } from "effect"; +import { execSync } from "node:child_process"; +import { GmailService } from "../Services/GmailService.ts"; +import { GmailError } from "../Errors.ts"; +import type { GmailMessage } from "@t3tools/contracts"; + +const ACCOUNT = "tryan@mediafly.com"; + +function runGogcli(args: string): Effect.Effect { + return Effect.try({ + try: () => execSync(`gogcli ${args}`, { encoding: "utf-8", timeout: 30000 }), + catch: (e) => new GmailError({ message: `gogcli failed: ${e}` }), + }); +} + +function categorizeEmail(from: string, subject: string): "ACTION" | "REVIEW" | "FYI" | "NOISE" { + const lowerSubject = subject.toLowerCase(); + const lowerFrom = from.toLowerCase(); + + // Noise patterns + if (lowerFrom.includes("noreply") || lowerFrom.includes("no-reply")) return "NOISE"; + if (lowerSubject.includes("[resolved]")) return "NOISE"; + if (lowerFrom.includes("grafana") && lowerSubject.includes("[ok]")) return "NOISE"; + if (lowerFrom.includes("jira@mediafly") || lowerFrom.includes("atlassian")) return "NOISE"; + if (lowerFrom.includes("salesforce")) return "NOISE"; + + // Review patterns + if (lowerSubject.includes("review requested") || lowerSubject.includes("pull request")) return "REVIEW"; + if (lowerFrom.includes("github.com")) return "REVIEW"; + + // Action patterns + if (lowerSubject.includes("action required") || lowerSubject.includes("urgent")) return "ACTION"; + + return "FYI"; +} + +function parseGogcliOutput(output: string): GmailMessage[] { + // gogcli json output returns an array of message objects + try { + const messages = JSON.parse(output); + if (!Array.isArray(messages)) return []; + return messages.map((m: any) => ({ + id: m.id ?? m.messageId ?? "", + threadId: m.threadId ?? "", + from: m.from ?? "", + subject: m.subject ?? "", + snippet: m.snippet ?? "", + date: m.date ?? m.internalDate ?? "", + isUnread: m.labelIds?.includes("UNREAD") ?? true, + category: categorizeEmail(m.from ?? "", m.subject ?? ""), + })); + } catch { + // Fallback: parse plain text output + return output.split("\n").filter(Boolean).map((line, i) => ({ + id: `msg-${i}`, + threadId: `thread-${i}`, + from: "", + subject: line.trim(), + snippet: "", + date: new Date().toISOString(), + isUnread: true, + })); + } +} + +export const GmailServiceLive = Layer.succeed( + GmailService, + GmailService.of({ + search: ({ query, maxResults }) => + Effect.gen(function* () { + const max = maxResults ?? 50; + const output = yield* runGogcli( + `gmail search "${query}" --account ${ACCOUNT} --json --max ${max}`, + ); + return parseGogcliOutput(output); + }), + + markRead: ({ threadId }) => + runGogcli( + `gmail thread modify ${threadId} --remove UNREAD --account ${ACCOUNT} --force`, + ).pipe(Effect.asVoid), + + createDraft: ({ to, subject, body, replyToMessageId }) => + Effect.gen(function* () { + const replyArg = replyToMessageId ? ` --reply-to-message-id "${replyToMessageId}"` : ""; + yield* runGogcli( + `gmail drafts create --account ${ACCOUNT} --to "${to}" --subject "${subject}" --body "${body}"${replyArg}`, + ); + return { id: crypto.randomUUID() }; + }), + }), +); diff --git a/apps/server/src/gmail/Services/GmailService.ts b/apps/server/src/gmail/Services/GmailService.ts new file mode 100644 index 0000000000..7cdf8fba9f --- /dev/null +++ b/apps/server/src/gmail/Services/GmailService.ts @@ -0,0 +1,13 @@ +import { ServiceMap, Effect } from "effect"; +import type { GmailMessage } from "@t3tools/contracts"; +import type { GmailError } from "../Errors.ts"; + +export interface GmailServiceShape { + readonly search: (input: { query: string; maxResults?: number }) => Effect.Effect, GmailError>; + readonly markRead: (input: { threadId: string }) => Effect.Effect; + readonly createDraft: (input: { to: string; subject: string; body: string; replyToMessageId?: string }) => Effect.Effect<{ id: string }, GmailError>; +} + +export class GmailService extends ServiceMap.Service()( + "t3/gmail/Services/GmailService", +) {} diff --git a/apps/server/src/jira/Errors.ts b/apps/server/src/jira/Errors.ts new file mode 100644 index 0000000000..14c37fca5d --- /dev/null +++ b/apps/server/src/jira/Errors.ts @@ -0,0 +1,11 @@ +import { Data } from "effect"; + +export class JiraApiError extends Data.TaggedError("JiraApiError")<{ + readonly message: string; + readonly statusCode?: number; + readonly ticketKey?: string; +}> {} + +export class JiraConfigError extends Data.TaggedError("JiraConfigError")<{ + readonly message: string; +}> {} diff --git a/apps/server/src/jira/Layers/JiraService.ts b/apps/server/src/jira/Layers/JiraService.ts new file mode 100644 index 0000000000..eb5dae71d7 --- /dev/null +++ b/apps/server/src/jira/Layers/JiraService.ts @@ -0,0 +1,328 @@ +import { Effect, Layer } from "effect"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { JiraService } from "../Services/JiraService.ts"; +import { JiraApiError, JiraConfigError } from "../Errors.ts"; +import type { JiraTicket } from "@t3tools/contracts"; + +const JIRA_BASE_URL = process.env.JIRA_BASE_URL ?? "https://mediafly.atlassian.net"; + +function readNetrcCredentials(host: string): { login: string; password: string } | null { + try { + const netrcPath = path.join(os.homedir(), ".netrc"); + const content = fs.readFileSync(netrcPath, "utf-8"); + const machineRegex = new RegExp( + `machine\\s+${host.replace(/\./g, "\\.")}\\s+login\\s+(\\S+)\\s+password\\s+(\\S+)`, + ); + const match = content.match(machineRegex); + if (match) return { login: match[1]!, password: match[2]! }; + return null; + } catch { + return null; + } +} + +function getAuthHeader(): Effect.Effect { + return Effect.sync(() => { + const url = new URL(JIRA_BASE_URL); + const creds = readNetrcCredentials(url.hostname); + if (!creds) { + return Effect.fail( + new JiraConfigError({ message: `No credentials found in ~/.netrc for ${url.hostname}` }), + ); + } + return Effect.succeed( + `Basic ${Buffer.from(`${creds.login}:${creds.password}`).toString("base64")}`, + ); + }).pipe(Effect.flatten); +} + +function mapIssueToTicket(issue: any): JiraTicket { + const fields = issue.fields ?? {}; + return { + key: issue.key, + summary: fields.summary ?? "Untitled", + status: fields.status?.name ?? "Unknown", + priority: fields.priority?.name ?? "Medium", + issueType: fields.issuetype?.name ?? "Task", + assignee: fields.assignee?.displayName ?? null, + reporter: fields.reporter?.displayName ?? null, + description: extractTextFromAdf(fields.description), + components: (fields.components ?? []).map((c: any) => c.name), + labels: fields.labels ?? [], + parentKey: fields.parent?.key ?? null, + url: `${JIRA_BASE_URL}/browse/${issue.key}`, + created: fields.created ?? new Date().toISOString(), + updated: fields.updated ?? new Date().toISOString(), + } as JiraTicket; +} + +function extractTextFromAdf(adf: any): string | null { + if (!adf || typeof adf !== "object") return null; + if (adf.type === "text") return adf.text ?? ""; + if (Array.isArray(adf.content)) { + return adf.content.map(extractTextFromAdf).filter(Boolean).join(""); + } + return null; +} + + +export const JiraServiceLive = Layer.effect( + JiraService, + Effect.gen(function* () { + return JiraService.of({ + listTickets: ({ assignee, status, maxResults }) => + Effect.gen(function* () { + const auth = yield* getAuthHeader(); + const jql = [ + assignee ? `assignee = "${assignee}"` : "assignee = currentUser()", + "resolution = Unresolved", + status ? `status = "${status}"` : undefined, + ] + .filter(Boolean) + .join(" AND "); + const jqlEncoded = encodeURIComponent(jql); + const limit = maxResults ?? 50; + const url = `${JIRA_BASE_URL}/rest/api/3/search/jql?jql=${jqlEncoded}&maxResults=${limit}&fields=*all`; + const response = yield* Effect.tryPromise({ + try: () => + fetch(url, { + headers: { Authorization: auth, Accept: "application/json" }, + }), + catch: (e) => new JiraApiError({ message: `Fetch failed: ${e}` }), + }); + if (!response.ok) { + return yield* new JiraApiError({ + message: `Jira API error: ${response.status}`, + statusCode: response.status, + }); + } + const data = yield* Effect.tryPromise({ + try: () => response.json() as Promise<{ issues: any[] }>, + catch: (e) => new JiraApiError({ message: `JSON parse failed: ${e}` }), + }); + return (data.issues ?? []).map(mapIssueToTicket); + }), + + getTicket: ({ ticketKey }) => + Effect.gen(function* () { + const auth = yield* getAuthHeader(); + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${JIRA_BASE_URL}/rest/api/3/issue/${ticketKey}`, { + headers: { Authorization: auth, Accept: "application/json" }, + }), + catch: (e) => new JiraApiError({ message: `Fetch failed: ${e}`, ticketKey }), + }); + if (!response.ok) { + return yield* new JiraApiError({ + message: `Jira API error: ${response.status}`, + statusCode: response.status, + ticketKey, + }); + } + const issue = yield* Effect.tryPromise({ + try: () => response.json(), + catch: (e) => new JiraApiError({ message: `JSON parse failed: ${e}`, ticketKey }), + }); + return mapIssueToTicket(issue); + }), + + searchTickets: ({ jql, maxResults }) => + Effect.gen(function* () { + const auth = yield* getAuthHeader(); + const jqlEncoded = encodeURIComponent(jql); + const limit = maxResults ?? 50; + const url = `${JIRA_BASE_URL}/rest/api/3/search/jql?jql=${jqlEncoded}&maxResults=${limit}&fields=*all`; + const response = yield* Effect.tryPromise({ + try: () => + fetch(url, { + headers: { Authorization: auth, Accept: "application/json" }, + }), + catch: (e) => new JiraApiError({ message: `Fetch failed: ${e}` }), + }); + if (!response.ok) { + return yield* new JiraApiError({ + message: `Jira API error: ${response.status}`, + statusCode: response.status, + }); + } + const data = yield* Effect.tryPromise({ + try: () => response.json() as Promise<{ issues: any[] }>, + catch: (e) => new JiraApiError({ message: `JSON parse failed: ${e}` }), + }); + return (data.issues ?? []).map(mapIssueToTicket); + }), + + postComment: ({ ticketKey, body }) => + Effect.gen(function* () { + const auth = yield* getAuthHeader(); + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${JIRA_BASE_URL}/rest/api/3/issue/${ticketKey}/comment`, { + method: "POST", + headers: { + Authorization: auth, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + body: { + type: "doc", + version: 1, + content: [ + { type: "paragraph", content: [{ type: "text", text: body }] }, + ], + }, + }), + }), + catch: (e) => new JiraApiError({ message: `Fetch failed: ${e}`, ticketKey }), + }); + if (!response.ok) { + return yield* new JiraApiError({ + message: `Jira API error: ${response.status}`, + statusCode: response.status, + ticketKey, + }); + } + }), + + refreshCache: () => Effect.succeed({ count: 0 }), + + transitionTicket: ({ ticketKey, transitionName }) => + Effect.gen(function* () { + const auth = yield* getAuthHeader(); + const transitionsResponse = yield* Effect.tryPromise({ + try: () => + fetch(`${JIRA_BASE_URL}/rest/api/3/issue/${ticketKey}/transitions`, { + headers: { Authorization: auth, Accept: "application/json" }, + }), + catch: (e) => new JiraApiError({ message: `Fetch failed: ${e}`, ticketKey }), + }); + if (!transitionsResponse.ok) { + return yield* new JiraApiError({ + message: `Jira API error: ${transitionsResponse.status}`, + statusCode: transitionsResponse.status, + ticketKey, + }); + } + const transitionsData = yield* Effect.tryPromise({ + try: () => + transitionsResponse.json() as Promise<{ + transitions?: Array<{ id: string; name: string }>; + }>, + catch: (e) => new JiraApiError({ message: `JSON parse failed: ${e}`, ticketKey }), + }); + const target = (transitionsData.transitions ?? []).find( + (t) => t.name.toLowerCase() === transitionName.toLowerCase(), + ); + if (!target) { + return yield* new JiraApiError({ + message: `Transition "${transitionName}" not available for ${ticketKey}`, + ticketKey, + }); + } + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${JIRA_BASE_URL}/rest/api/3/issue/${ticketKey}/transitions`, { + method: "POST", + headers: { + Authorization: auth, + "Content-Type": "application/json", + }, + body: JSON.stringify({ transition: { id: target.id } }), + }), + catch: (e) => new JiraApiError({ message: `Fetch failed: ${e}`, ticketKey }), + }); + if (!response.ok) { + return yield* new JiraApiError({ + message: `Transition failed: ${response.status}`, + statusCode: response.status, + ticketKey, + }); + } + }), + + listServiceDeskRequestTypes: () => + Effect.gen(function* () { + const auth = yield* getAuthHeader(); + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${JIRA_BASE_URL}/rest/servicedeskapi/servicedesk/1/requesttype`, { + headers: { Authorization: auth, Accept: "application/json" }, + }), + catch: (e) => new JiraApiError({ message: `Fetch failed: ${e}` }), + }); + if (!response.ok) { + return yield* new JiraApiError({ + message: `JSM API error: ${response.status}`, + statusCode: response.status, + }); + } + const data = yield* Effect.tryPromise({ + try: () => response.json() as Promise<{ values?: any[] }>, + catch: (e) => new JiraApiError({ message: `JSON parse failed: ${e}` }), + }); + return (data.values ?? []) + .filter((v: any) => v.canCreateRequest) + .map((v: any) => { + const entry: { id: string; name: string; description?: string } = { + id: v.id as string, + name: v.name as string, + }; + if (v.description) entry.description = v.description as string; + return entry; + }); + }), + + createServiceDeskRequest: ({ requestTypeId, summary, description }) => + Effect.gen(function* () { + const auth = yield* getAuthHeader(); + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${JIRA_BASE_URL}/rest/servicedeskapi/request`, { + method: "POST", + headers: { + Authorization: auth, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + serviceDeskId: "1", + requestTypeId, + requestFieldValues: { + summary, + ...(description ? { description } : {}), + }, + }), + }), + catch: (e) => new JiraApiError({ message: `Fetch failed: ${e}` }), + }); + if (!response.ok) { + const text = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (e) => new JiraApiError({ message: `Failed to read error response: ${e}` }), + }); + return yield* new JiraApiError({ + message: `JSM API error ${response.status}: ${text}`, + statusCode: response.status, + }); + } + const data = yield* Effect.tryPromise({ + try: () => + response.json() as Promise<{ + issueKey: string; + _links?: { agent?: string }; + }>, + catch: (e) => new JiraApiError({ message: `JSON parse failed: ${e}` }), + }); + return { + issueKey: data.issueKey, + url: data._links?.agent ?? `${JIRA_BASE_URL}/browse/${data.issueKey}`, + }; + }), + }); + }), +); diff --git a/apps/server/src/jira/Services/JiraService.ts b/apps/server/src/jira/Services/JiraService.ts new file mode 100644 index 0000000000..97b3b585dd --- /dev/null +++ b/apps/server/src/jira/Services/JiraService.ts @@ -0,0 +1,47 @@ +import { ServiceMap, Effect } from "effect"; +import type { JiraTicket } from "@t3tools/contracts"; +import type { JiraApiError, JiraConfigError } from "../Errors.ts"; + +export interface JiraServiceShape { + readonly listTickets: (input: { + readonly assignee?: string; + readonly status?: string; + readonly maxResults?: number; + }) => Effect.Effect, JiraApiError | JiraConfigError>; + + readonly getTicket: (input: { + readonly ticketKey: string; + }) => Effect.Effect; + + readonly searchTickets: (input: { + readonly jql: string; + readonly maxResults?: number; + }) => Effect.Effect, JiraApiError | JiraConfigError>; + + readonly postComment: (input: { + readonly ticketKey: string; + readonly body: string; + }) => Effect.Effect; + + readonly refreshCache: () => Effect.Effect<{ count: number }, JiraApiError | JiraConfigError>; + + readonly transitionTicket: (input: { + readonly ticketKey: string; + readonly transitionName: string; + }) => Effect.Effect; + + readonly listServiceDeskRequestTypes: () => Effect.Effect< + ReadonlyArray<{ id: string; name: string; description?: string }>, + JiraApiError | JiraConfigError + >; + + readonly createServiceDeskRequest: (input: { + readonly requestTypeId: string; + readonly summary: string; + readonly description?: string; + }) => Effect.Effect<{ issueKey: string; url: string }, JiraApiError | JiraConfigError>; +} + +export class JiraService extends ServiceMap.Service()( + "t3/jira/Services/JiraService", +) {} diff --git a/apps/server/src/jira/contextSeeding.ts b/apps/server/src/jira/contextSeeding.ts new file mode 100644 index 0000000000..813eba4dd4 --- /dev/null +++ b/apps/server/src/jira/contextSeeding.ts @@ -0,0 +1,107 @@ +import { Data, Effect } from "effect"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { JiraService } from "./Services/JiraService.ts"; + +export class ContextSeedingError extends Data.TaggedError("ContextSeedingError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** + * Generate a CLAUDE.md context file for a project workspace from Jira ticket data. + * This seeds the AI agent with relevant project context. + */ +export const generateContextFile = ({ + workspaceRoot, + ticketKey, + specContent, +}: { + workspaceRoot: string; + ticketKey: string; + specContent?: string; +}) => + Effect.gen(function* () { + const jiraService = yield* JiraService; + + // Fetch ticket data + const ticket = yield* jiraService.getTicket({ ticketKey }).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + + const sections: string[] = []; + + // Header + sections.push(`# Project Context — ${ticketKey}`); + sections.push(""); + + if (ticket) { + // Ticket info + sections.push(`## Jira Ticket: ${ticket.key}`); + sections.push(""); + sections.push(`- **Summary:** ${ticket.summary}`); + sections.push(`- **Status:** ${ticket.status}`); + sections.push(`- **Priority:** ${ticket.priority}`); + sections.push(`- **Type:** ${ticket.issueType}`); + if (ticket.assignee) sections.push(`- **Assignee:** ${ticket.assignee}`); + if (ticket.reporter) sections.push(`- **Reporter:** ${ticket.reporter}`); + if (ticket.components.length > 0) { + sections.push(`- **Components:** ${ticket.components.join(", ")}`); + } + if (ticket.labels.length > 0) { + sections.push(`- **Labels:** ${ticket.labels.join(", ")}`); + } + if (ticket.parentKey) { + sections.push(`- **Parent:** ${ticket.parentKey}`); + } + sections.push(`- **URL:** ${ticket.url}`); + sections.push(""); + + // Description + if (ticket.description) { + sections.push("## Description"); + sections.push(""); + sections.push(ticket.description); + sections.push(""); + } + } + + // Spec/planning notes + if (specContent && specContent.trim().length > 0) { + sections.push("## Planning Notes"); + sections.push(""); + sections.push(specContent); + sections.push(""); + } + + // Instructions + sections.push("## Instructions"); + sections.push(""); + sections.push("- This file was auto-generated from Jira ticket data and planning notes."); + sections.push("- Focus on implementing the requirements described above."); + sections.push("- Reference the Jira ticket for additional context and acceptance criteria."); + sections.push("- Update the ticket status when work is complete."); + sections.push(""); + + const content = sections.join("\n"); + + // Write to workspace + yield* Effect.try({ + try: () => { + const claudeMdPath = path.join(workspaceRoot, "CLAUDE.md"); + // Don't overwrite if it already exists and has custom content + if (fs.existsSync(claudeMdPath)) { + const existing = fs.readFileSync(claudeMdPath, "utf-8"); + if (!existing.includes("# Project Context —")) { + // File exists with custom content, append instead + fs.appendFileSync(claudeMdPath, "\n\n" + content); + return; + } + } + fs.writeFileSync(claudeMdPath, content, "utf-8"); + }, + catch: (cause) => new ContextSeedingError({ message: `Failed to write CLAUDE.md`, cause }), + }); + + return { path: path.join(workspaceRoot, "CLAUDE.md"), ticketKey }; + }); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 17bf7f32f7..98423ab240 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -158,7 +158,7 @@ const ServerConfigLive = (input: CliInput) => const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const autoBootstrapProjectFromCwd = resolveBooleanFlag( input.autoBootstrapProjectFromCwd, - env.autoBootstrapProjectFromCwd ?? mode === "web", + env.autoBootstrapProjectFromCwd ?? false, ); const logWebSocketEvents = resolveBooleanFlag( input.logWebSocketEvents, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index ab38c10332..f6c1ee0eee 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -569,7 +569,7 @@ const make = Effect.gen(function* () { turnCount: event.payload.turnCount, detail: "Thread was not found in read model.", createdAt: now, - }).pipe(Effect.catch(() => Effect.void)); + }).pipe(Effect.catch(() => Effect.logWarning("checkpoint revert failure activity could not be appended"))); return; } @@ -580,7 +580,7 @@ const make = Effect.gen(function* () { turnCount: event.payload.turnCount, detail: "No active provider session with workspace cwd is bound to this thread.", createdAt: now, - }).pipe(Effect.catch(() => Effect.void)); + }).pipe(Effect.catch(() => Effect.logWarning("checkpoint revert failure activity could not be appended"))); return; } if (!isGitWorkspace(sessionRuntime.value.cwd)) { @@ -589,7 +589,7 @@ const make = Effect.gen(function* () { turnCount: event.payload.turnCount, detail: "Checkpoints are unavailable because this project is not a git repository.", createdAt: now, - }).pipe(Effect.catch(() => Effect.void)); + }).pipe(Effect.catch(() => Effect.logWarning("checkpoint revert failure activity could not be appended"))); return; } @@ -604,7 +604,7 @@ const make = Effect.gen(function* () { turnCount: event.payload.turnCount, detail: `Checkpoint turn count ${event.payload.turnCount} exceeds current turn count ${currentTurnCount}.`, createdAt: now, - }).pipe(Effect.catch(() => Effect.void)); + }).pipe(Effect.catch(() => Effect.logWarning("checkpoint revert failure activity could not be appended"))); return; } @@ -621,7 +621,7 @@ const make = Effect.gen(function* () { turnCount: event.payload.turnCount, detail: `Checkpoint ref for turn ${event.payload.turnCount} is unavailable in read model.`, createdAt: now, - }).pipe(Effect.catch(() => Effect.void)); + }).pipe(Effect.catch(() => Effect.logWarning("checkpoint revert failure activity could not be appended"))); return; } @@ -636,7 +636,7 @@ const make = Effect.gen(function* () { turnCount: event.payload.turnCount, detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`, createdAt: now, - }).pipe(Effect.catch(() => Effect.void)); + }).pipe(Effect.catch(() => Effect.logWarning("checkpoint revert failure activity could not be appended"))); return; } @@ -717,7 +717,7 @@ const make = Effect.gen(function* () { turnId: event.payload.turnId, detail: error.message, createdAt: new Date().toISOString(), - }).pipe(Effect.catch(() => Effect.void)), + }).pipe(Effect.catch(() => Effect.logWarning("checkpoint capture failure activity could not be appended"))), ), ); } @@ -738,7 +738,7 @@ const make = Effect.gen(function* () { turnId, detail: error.message, createdAt: new Date().toISOString(), - }).pipe(Effect.catch(() => Effect.void)), + }).pipe(Effect.catch(() => Effect.logWarning("checkpoint capture failure activity could not be appended"))), ), ); return; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 69b28b9d3c..f05387898c 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -37,6 +37,9 @@ function commandToAggregateRef(command: OrchestrationCommand): { case "project.create": case "project.meta.update": case "project.delete": + case "project.jira-metadata.update": + case "project.note.update": + case "project.touch": return { aggregateKind: "project", aggregateId: command.projectId, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 0651dab646..c664f071e4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -367,6 +367,20 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, deletedAt: null, + ticketKey: null, + jiraStatus: null, + priority: null, + jiraUrl: null, + components: null, + labels: null, + assignee: null, + reporter: null, + description: null, + parentKey: null, + suggestedRepo: null, + note: null, + lastAccessedAt: null, + archivedAt: null, }); return; @@ -407,6 +421,61 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { return; } + case "project.jira-metadata-updated": { + const existingRow = yield* projectionProjectRepository.getById({ + projectId: event.payload.projectId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionProjectRepository.upsert({ + ...existingRow.value, + ...(event.payload.ticketKey !== undefined ? { ticketKey: event.payload.ticketKey } : {}), + ...(event.payload.jiraStatus !== undefined ? { jiraStatus: event.payload.jiraStatus } : {}), + ...(event.payload.priority !== undefined ? { priority: event.payload.priority } : {}), + ...(event.payload.jiraUrl !== undefined ? { jiraUrl: event.payload.jiraUrl } : {}), + ...(event.payload.components !== undefined ? { components: event.payload.components } : {}), + ...(event.payload.labels !== undefined ? { labels: event.payload.labels } : {}), + ...(event.payload.assignee !== undefined ? { assignee: event.payload.assignee } : {}), + ...(event.payload.reporter !== undefined ? { reporter: event.payload.reporter } : {}), + ...(event.payload.description !== undefined ? { description: event.payload.description } : {}), + ...(event.payload.parentKey !== undefined ? { parentKey: event.payload.parentKey } : {}), + ...(event.payload.suggestedRepo !== undefined ? { suggestedRepo: event.payload.suggestedRepo } : {}), + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "project.note-updated": { + const existingRow = yield* projectionProjectRepository.getById({ + projectId: event.payload.projectId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionProjectRepository.upsert({ + ...existingRow.value, + note: event.payload.note, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "project.touched": { + const existingRow = yield* projectionProjectRepository.getById({ + projectId: event.payload.projectId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionProjectRepository.upsert({ + ...existingRow.value, + lastAccessedAt: event.payload.lastAccessedAt, + updatedAt: event.payload.lastAccessedAt, + }); + return; + } + default: return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 849d2fa3b6..f742e9673c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -46,6 +46,8 @@ const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + components: Schema.NullOr(Schema.fromJsonString(Schema.Array(Schema.String))), + labels: Schema.NullOr(Schema.fromJsonString(Schema.Array(Schema.String))), }), ); const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( @@ -145,7 +147,21 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + ticket_key AS "ticketKey", + jira_status AS "jiraStatus", + priority, + jira_url AS "jiraUrl", + components_json AS "components", + labels_json AS "labels", + assignee, + reporter, + description, + parent_key AS "parentKey", + suggested_repo AS "suggestedRepo", + note, + last_accessed_at AS "lastAccessedAt", + archived_at AS "archivedAt" FROM projection_projects ORDER BY created_at ASC, project_id ASC `, @@ -540,6 +556,20 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, + ...(row.ticketKey != null ? { ticketKey: row.ticketKey } : {}), + ...(row.jiraStatus != null ? { jiraStatus: row.jiraStatus } : {}), + ...(row.priority != null ? { priority: row.priority } : {}), + ...(row.jiraUrl != null ? { jiraUrl: row.jiraUrl } : {}), + ...(row.components != null ? { components: row.components } : {}), + ...(row.labels != null ? { labels: row.labels } : {}), + ...(row.assignee != null ? { assignee: row.assignee } : {}), + ...(row.reporter != null ? { reporter: row.reporter } : {}), + ...(row.description != null ? { description: row.description } : {}), + ...(row.parentKey != null ? { parentKey: row.parentKey } : {}), + ...(row.suggestedRepo != null ? { suggestedRepo: row.suggestedRepo } : {}), + ...(row.note != null ? { note: row.note } : {}), + ...(row.lastAccessedAt != null ? { lastAccessedAt: row.lastAccessedAt } : {}), + ...(row.archivedAt !== undefined ? { archivedAt: row.archivedAt } : {}), })); const threads: Array = threadRows.map((row) => ({ diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 57405ca515..a96fa4206a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -37,7 +37,8 @@ type ProviderIntentEvent = Extract< | "thread.turn-interrupt-requested" | "thread.approval-response-requested" | "thread.user-input-response-requested" - | "thread.session-stop-requested"; + | "thread.session-stop-requested" + | "thread.deleted"; } >; @@ -157,6 +158,11 @@ const make = Effect.gen(function* () { ), ); + // These Maps are safe without Ref synchronization because all access (reads + // in `ensureSessionForThread` and `processDomainEvent`, writes in + // `sendTurnForThread`) flows exclusively through the serial DrainableWorker + // queue, which processes one event at a time. No concurrent fibers access + // these Maps simultaneously. const threadProviderOptions = new Map(); const threadModelOptions = new Map(); @@ -671,7 +677,14 @@ const make = Effect.gen(function* () { const now = event.payload.createdAt; if (thread.session && thread.session.status !== "stopped") { - yield* providerService.stopSession({ threadId: thread.id }); + yield* providerService.stopSession({ threadId: thread.id }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to stop provider session during session-stop-requested", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ); } yield* setThreadSession({ @@ -722,6 +735,19 @@ const make = Effect.gen(function* () { case "thread.session-stop-requested": yield* processSessionStopRequested(event); return; + case "thread.deleted": { + const threadId = event.payload.threadId; + yield* Effect.logInfo("thread deleted, stopping provider session", { threadId }); + yield* providerService.stopSession({ threadId }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to stop provider session during thread deletion", { + threadId, + cause: Cause.pretty(cause), + }), + ), + ); + return; + } } }); @@ -748,7 +774,8 @@ const make = Effect.gen(function* () { event.type !== "thread.turn-interrupt-requested" && event.type !== "thread.approval-response-requested" && event.type !== "thread.user-input-response-requested" && - event.type !== "thread.session-stop-requested" + event.type !== "thread.session-stop-requested" && + event.type !== "thread.deleted" ) { return Effect.void; } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3df47941af..c6ef1f22f4 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1234,6 +1234,15 @@ const make = Effect.gen(function* () { yield* Effect.forkScoped( Stream.runForEach(providerService.streamEvents, (event) => worker.enqueue({ source: "runtime", event }), + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "provider runtime event stream failed", + { + cause: Cause.pretty(cause), + }, + ), + ), ), ); yield* Effect.forkScoped( @@ -1242,7 +1251,16 @@ const make = Effect.gen(function* () { return Effect.void; } return worker.enqueue({ source: "domain", event }); - }), + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "provider runtime domain event stream failed", + { + cause: Cause.pretty(cause), + }, + ), + ), + ), ); }); diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index c96385cad1..a41fb2025c 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -2,6 +2,9 @@ import { ProjectCreatedPayload as ContractsProjectCreatedPayloadSchema, ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema, ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, + ProjectJiraMetadataUpdatedPayload as ContractsProjectJiraMetadataUpdatedPayloadSchema, + ProjectNoteUpdatedPayload as ContractsProjectNoteUpdatedPayloadSchema, + ProjectTouchedPayload as ContractsProjectTouchedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, @@ -24,6 +27,9 @@ import { export const ProjectCreatedPayload = ContractsProjectCreatedPayloadSchema; export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchema; export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; +export const ProjectJiraMetadataUpdatedPayload = ContractsProjectJiraMetadataUpdatedPayloadSchema; +export const ProjectNoteUpdatedPayload = ContractsProjectNoteUpdatedPayloadSchema; +export const ProjectTouchedPayload = ContractsProjectTouchedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 6ea4c51759..461e47ded4 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -111,6 +111,83 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "project.jira-metadata.update": { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + const occurredAt = nowIso(); + return { + ...withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt, + commandId: command.commandId, + }), + type: "project.jira-metadata-updated", + payload: { + projectId: command.projectId, + ...(command.ticketKey !== undefined ? { ticketKey: command.ticketKey } : {}), + ...(command.jiraStatus !== undefined ? { jiraStatus: command.jiraStatus } : {}), + ...(command.priority !== undefined ? { priority: command.priority } : {}), + ...(command.jiraUrl !== undefined ? { jiraUrl: command.jiraUrl } : {}), + ...(command.components !== undefined ? { components: command.components } : {}), + ...(command.labels !== undefined ? { labels: command.labels } : {}), + ...(command.assignee !== undefined ? { assignee: command.assignee } : {}), + ...(command.reporter !== undefined ? { reporter: command.reporter } : {}), + ...(command.description !== undefined ? { description: command.description } : {}), + ...(command.parentKey !== undefined ? { parentKey: command.parentKey } : {}), + ...(command.suggestedRepo !== undefined ? { suggestedRepo: command.suggestedRepo } : {}), + updatedAt: occurredAt, + }, + }; + } + + case "project.note.update": { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + const occurredAt = nowIso(); + return { + ...withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt, + commandId: command.commandId, + }), + type: "project.note-updated", + payload: { + projectId: command.projectId, + note: command.note, + updatedAt: occurredAt, + }, + }; + } + + case "project.touch": { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + return { + ...withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt: command.lastAccessedAt, + commandId: command.commandId, + }), + type: "project.touched", + payload: { + projectId: command.projectId, + lastAccessedAt: command.lastAccessedAt, + }, + }; + } + case "project.delete": { yield* requireProject({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 015f82a677..40114ff861 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -12,7 +12,10 @@ import { MessageSentPayloadSchema, ProjectCreatedPayload, ProjectDeletedPayload, + ProjectJiraMetadataUpdatedPayload, ProjectMetaUpdatedPayload, + ProjectNoteUpdatedPayload, + ProjectTouchedPayload, ThreadActivityAppendedPayload, ThreadCreatedPayload, ThreadDeletedPayload, @@ -238,6 +241,63 @@ export function projectEvent( })), ); + case "project.jira-metadata-updated": + return decodeForEvent(ProjectJiraMetadataUpdatedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + projects: nextBase.projects.map((project) => + project.id === payload.projectId + ? { + ...project, + ...(payload.ticketKey !== undefined ? { ticketKey: payload.ticketKey } : {}), + ...(payload.jiraStatus !== undefined ? { jiraStatus: payload.jiraStatus } : {}), + ...(payload.priority !== undefined ? { priority: payload.priority } : {}), + ...(payload.jiraUrl !== undefined ? { jiraUrl: payload.jiraUrl } : {}), + ...(payload.components !== undefined ? { components: payload.components } : {}), + ...(payload.labels !== undefined ? { labels: payload.labels } : {}), + ...(payload.assignee !== undefined ? { assignee: payload.assignee } : {}), + ...(payload.reporter !== undefined ? { reporter: payload.reporter } : {}), + ...(payload.description !== undefined ? { description: payload.description } : {}), + ...(payload.parentKey !== undefined ? { parentKey: payload.parentKey } : {}), + ...(payload.suggestedRepo !== undefined ? { suggestedRepo: payload.suggestedRepo } : {}), + updatedAt: payload.updatedAt, + } + : project, + ), + })), + ); + + case "project.note-updated": + return decodeForEvent(ProjectNoteUpdatedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + projects: nextBase.projects.map((project) => + project.id === payload.projectId + ? { + ...project, + note: payload.note, + updatedAt: payload.updatedAt, + } + : project, + ), + })), + ); + + case "project.touched": + return decodeForEvent(ProjectTouchedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + projects: nextBase.projects.map((project) => + project.id === payload.projectId + ? { + ...project, + lastAccessedAt: payload.lastAccessedAt, + } + : project, + ), + })), + ); + case "thread.created": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 5dbc8c2d1f..fb2d4bccaf 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -13,9 +13,13 @@ import { } from "../Services/ProjectionProjects.ts"; import { ProjectScript } from "@t3tools/contracts"; -// Makes sure that the scripts are parsed from the JSON string the DB returns +// Makes sure that JSON columns are parsed from the JSON strings the DB returns const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( - Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)) }), + Struct.assign({ + scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + components: Schema.NullOr(Schema.fromJsonString(Schema.Array(Schema.String))), + labels: Schema.NullOr(Schema.fromJsonString(Schema.Array(Schema.String))), + }), ); function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { @@ -40,7 +44,21 @@ const makeProjectionProjectRepository = Effect.gen(function* () { scripts_json, created_at, updated_at, - deleted_at + deleted_at, + ticket_key, + jira_status, + priority, + jira_url, + components_json, + labels_json, + assignee, + reporter, + description, + parent_key, + suggested_repo, + note, + last_accessed_at, + archived_at ) VALUES ( ${row.projectId}, @@ -50,7 +68,21 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.scripts}, ${row.createdAt}, ${row.updatedAt}, - ${row.deletedAt} + ${row.deletedAt}, + ${row.ticketKey}, + ${row.jiraStatus}, + ${row.priority}, + ${row.jiraUrl}, + ${row.components}, + ${row.labels}, + ${row.assignee}, + ${row.reporter}, + ${row.description}, + ${row.parentKey}, + ${row.suggestedRepo}, + ${row.note}, + ${row.lastAccessedAt}, + ${row.archivedAt} ) ON CONFLICT (project_id) DO UPDATE SET @@ -60,7 +92,21 @@ const makeProjectionProjectRepository = Effect.gen(function* () { scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, - deleted_at = excluded.deleted_at + deleted_at = excluded.deleted_at, + ticket_key = excluded.ticket_key, + jira_status = excluded.jira_status, + priority = excluded.priority, + jira_url = excluded.jira_url, + components_json = excluded.components_json, + labels_json = excluded.labels_json, + assignee = excluded.assignee, + reporter = excluded.reporter, + description = excluded.description, + parent_key = excluded.parent_key, + suggested_repo = excluded.suggested_repo, + note = excluded.note, + last_accessed_at = excluded.last_accessed_at, + archived_at = excluded.archived_at `, }); @@ -77,7 +123,21 @@ const makeProjectionProjectRepository = Effect.gen(function* () { scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + ticket_key AS "ticketKey", + jira_status AS "jiraStatus", + priority, + jira_url AS "jiraUrl", + components_json AS "components", + labels_json AS "labels", + assignee, + reporter, + description, + parent_key AS "parentKey", + suggested_repo AS "suggestedRepo", + note, + last_accessed_at AS "lastAccessedAt", + archived_at AS "archivedAt" FROM projection_projects WHERE project_id = ${projectId} `, @@ -96,7 +156,21 @@ const makeProjectionProjectRepository = Effect.gen(function* () { scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + ticket_key AS "ticketKey", + jira_status AS "jiraStatus", + priority, + jira_url AS "jiraUrl", + components_json AS "components", + labels_json AS "labels", + assignee, + reporter, + description, + parent_key AS "parentKey", + suggested_repo AS "suggestedRepo", + note, + last_accessed_at AS "lastAccessedAt", + archived_at AS "archivedAt" FROM projection_projects ORDER BY created_at ASC, project_id ASC `, diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index c430e79efb..7380367338 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -30,6 +30,7 @@ const setup = Layer.effectDiscard( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`PRAGMA journal_mode = WAL;`; + yield* sql`PRAGMA busy_timeout = 5000;`; yield* sql`PRAGMA foreign_keys = ON;`; yield* runMigrations; }), diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ea1821014a..2482824fa2 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -27,6 +27,11 @@ import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts" import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; +import Migration0016 from "./Migrations/016_ProjectionProjectsJiraMetadata.ts"; +import Migration0017 from "./Migrations/017_PromptHistory.ts"; +import Migration0018 from "./Migrations/018_JiraCache.ts"; +import Migration0019 from "./Migrations/019_Specs.ts"; +import Migration0020 from "./Migrations/020_BackfillProjectTicketKeys.ts"; import { Effect } from "effect"; /** @@ -55,6 +60,11 @@ const loader = Migrator.fromRecord({ "13_ProjectionThreadProposedPlans": Migration0013, "14_ProjectionThreadProposedPlanImplementation": Migration0014, "15_ProjectionTurnsSourceProposedPlan": Migration0015, + "16_ProjectionProjectsJiraMetadata": Migration0016, + "17_PromptHistory": Migration0017, + "18_JiraCache": Migration0018, + "19_Specs": Migration0019, + "20_BackfillProjectTicketKeys": Migration0020, }); /** diff --git a/apps/server/src/persistence/Migrations/016_ProjectionProjectsJiraMetadata.ts b/apps/server/src/persistence/Migrations/016_ProjectionProjectsJiraMetadata.ts new file mode 100644 index 0000000000..48e89b9dc9 --- /dev/null +++ b/apps/server/src/persistence/Migrations/016_ProjectionProjectsJiraMetadata.ts @@ -0,0 +1,26 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Jira metadata fields + yield* sql`ALTER TABLE projection_projects ADD COLUMN ticket_key TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN jira_status TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN priority TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN jira_url TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN components_json TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN labels_json TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN assignee TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN reporter TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN description TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN parent_key TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN suggested_repo TEXT`; + + // User note + yield* sql`ALTER TABLE projection_projects ADD COLUMN note TEXT`; + + // Access tracking + yield* sql`ALTER TABLE projection_projects ADD COLUMN last_accessed_at TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN archived_at TEXT`; +}); diff --git a/apps/server/src/persistence/Migrations/017_PromptHistory.ts b/apps/server/src/persistence/Migrations/017_PromptHistory.ts new file mode 100644 index 0000000000..40ec31966c --- /dev/null +++ b/apps/server/src/persistence/Migrations/017_PromptHistory.ts @@ -0,0 +1,20 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS prompt_history ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + prompt TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_prompt_history_project_created + ON prompt_history(project_id, created_at DESC) + `; +}); diff --git a/apps/server/src/persistence/Migrations/018_JiraCache.ts b/apps/server/src/persistence/Migrations/018_JiraCache.ts new file mode 100644 index 0000000000..61ebfbc21a --- /dev/null +++ b/apps/server/src/persistence/Migrations/018_JiraCache.ts @@ -0,0 +1,43 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS jira_tickets ( + key TEXT PRIMARY KEY, + summary TEXT NOT NULL, + status TEXT NOT NULL, + priority TEXT NOT NULL, + issue_type TEXT NOT NULL, + assignee TEXT, + reporter TEXT, + description TEXT, + components_json TEXT NOT NULL DEFAULT '[]', + labels_json TEXT NOT NULL DEFAULT '[]', + parent_key TEXT, + url TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + cached_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS component_repo_map ( + component TEXT PRIMARY KEY, + repo TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_jira_tickets_status + ON jira_tickets(status) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_jira_tickets_assignee + ON jira_tickets(assignee) + `; +}); diff --git a/apps/server/src/persistence/Migrations/019_Specs.ts b/apps/server/src/persistence/Migrations/019_Specs.ts new file mode 100644 index 0000000000..d4ce90156a --- /dev/null +++ b/apps/server/src/persistence/Migrations/019_Specs.ts @@ -0,0 +1,21 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS specs ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL UNIQUE, + content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_specs_project_id + ON specs(project_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/020_BackfillProjectTicketKeys.ts b/apps/server/src/persistence/Migrations/020_BackfillProjectTicketKeys.ts new file mode 100644 index 0000000000..4cfaf141bf --- /dev/null +++ b/apps/server/src/persistence/Migrations/020_BackfillProjectTicketKeys.ts @@ -0,0 +1,18 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Backfill ticket_key and jira_url for projects created before the Jira import + // feature. These projects have the ticket key embedded in their title + // (e.g., "CE-15113: Igus and PepsiCo shutdown") but ticket_key is NULL. + yield* sql` + UPDATE projection_projects + SET ticket_key = SUBSTR(title, 1, INSTR(title, ':') - 1), + jira_url = 'https://mediafly.atlassian.net/browse/' || SUBSTR(title, 1, INSTR(title, ':') - 1) + WHERE ticket_key IS NULL + AND deleted_at IS NULL + AND title GLOB '[A-Z]*-[0-9]*:*' + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 1380a9609a..3010c03177 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -21,6 +21,21 @@ export const ProjectionProject = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), + // Jira metadata + ticketKey: Schema.NullOr(Schema.String), + jiraStatus: Schema.NullOr(Schema.String), + priority: Schema.NullOr(Schema.String), + jiraUrl: Schema.NullOr(Schema.String), + components: Schema.NullOr(Schema.Array(Schema.String)), + labels: Schema.NullOr(Schema.Array(Schema.String)), + assignee: Schema.NullOr(Schema.String), + reporter: Schema.NullOr(Schema.String), + description: Schema.NullOr(Schema.String), + parentKey: Schema.NullOr(Schema.String), + suggestedRepo: Schema.NullOr(Schema.String), + note: Schema.NullOr(Schema.String), + lastAccessedAt: Schema.NullOr(IsoDateTime), + archivedAt: Schema.NullOr(IsoDateTime), }); export type ProjectionProject = typeof ProjectionProject.Type; diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index cbb97a807e..bc54df5340 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -491,10 +491,41 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { }; } +/** + * Check `~/.claude/.credentials.json` for a valid OAuth token as a + * fallback when `claude auth status` hangs or fails. The file contains + * `{ claudeAiOauth: { accessToken, expiresAt, ... } }`. + */ +const checkClaudeCredentialsFile: Effect.Effect< + boolean, + never, + FileSystem.FileSystem | Path.Path +> = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const credentialsPath = path.join(OS.homedir(), ".claude", ".credentials.json"); + const content = yield* fileSystem + .readFileString(credentialsPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (!content) return false; + try { + const parsed = JSON.parse(content); + const oauth = parsed?.claudeAiOauth; + if (!oauth?.accessToken) return false; + // Check if token is not expired (with 5 min buffer). + if (typeof oauth.expiresAt === "number" && oauth.expiresAt < Date.now() + 300_000) { + return false; + } + return true; + } catch { + return false; + } +}); + export const checkClaudeProviderStatus: Effect.Effect< ServerProviderStatus, never, - ChildProcessSpawner.ChildProcessSpawner + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > = Effect.gen(function* () { const checkedAt = new Date().toISOString(); @@ -550,40 +581,41 @@ export const checkClaudeProviderStatus: Effect.Effect< Effect.result, ); - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return { - provider: CLAUDE_AGENT_PROVIDER, - status: "warning" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: - error instanceof Error - ? `Could not verify Claude authentication status: ${error.message}.` - : "Could not verify Claude authentication status.", - }; + // If auth probe succeeded, try to parse it. + if (Result.isSuccess(authProbe) && Option.isSome(authProbe.success)) { + const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); + if (parsed.authStatus !== "unknown") { + return { + provider: CLAUDE_AGENT_PROVIDER, + status: parsed.status, + available: true, + authStatus: parsed.authStatus, + checkedAt, + ...(parsed.message ? { message: parsed.message } : {}), + } satisfies ServerProviderStatus; + } } - if (Option.isNone(authProbe.success)) { + // Fallback: check ~/.claude/.credentials.json when `claude auth status` + // times out (common in Claude Code v2.x) or returns unparseable output. + const hasCredentials = yield* checkClaudeCredentialsFile; + if (hasCredentials) { return { provider: CLAUDE_AGENT_PROVIDER, - status: "warning" as const, + status: "ready" as const, available: true, - authStatus: "unknown" as const, + authStatus: "authenticated" as const, checkedAt, - message: "Could not verify Claude authentication status. Timed out while running command.", - }; + } satisfies ServerProviderStatus; } - const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); return { provider: CLAUDE_AGENT_PROVIDER, - status: parsed.status, + status: "error" as const, available: true, - authStatus: parsed.authStatus, + authStatus: "unauthenticated" as const, checkedAt, - ...(parsed.message ? { message: parsed.message } : {}), + message: "Claude is not authenticated. Run `claude auth login` and try again.", } satisfies ServerProviderStatus; }); @@ -592,12 +624,103 @@ export const checkClaudeProviderStatus: Effect.Effect< export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { - concurrency: "unbounded", - }).pipe(Effect.forkScoped); + // Capture services during layer construction so methods can use them later. + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const provideServices = (effect: Effect.Effect) => + Effect.provideService( + Effect.provideService( + Effect.provideService(effect, ChildProcessSpawner.ChildProcessSpawner, spawner), + FileSystem.FileSystem, + fileSystem, + ), + Path.Path, + path, + ); + + // Mutable ref to hold the latest statuses. + let cachedStatuses: ReadonlyArray = []; + + const runChecks = provideServices( + Effect.all( + [checkCodexProviderStatus, checkClaudeProviderStatus], + { concurrency: "unbounded" }, + ), + ); - return { - getStatuses: Fiber.join(statusesFiber), - } satisfies ProviderHealthShape; + // Initial startup check (forked so it doesn't block layer construction). + const initialFiber = yield* runChecks.pipe(Effect.forkScoped); + + const getStatuses: ProviderHealthShape["getStatuses"] = Effect.gen(function* () { + if (cachedStatuses.length === 0) { + cachedStatuses = yield* Fiber.join(initialFiber); + } + return cachedStatuses; + }); + + const refreshStatuses: ProviderHealthShape["refreshStatuses"] = Effect.gen(function* () { + const fresh = yield* runChecks; + cachedStatuses = fresh; + return fresh; + }); + + const login: ProviderHealthShape["login"] = (provider) => { + const command = + provider === "codex" + ? ChildProcess.make("codex", ["login"], { + shell: process.platform === "win32", + }) + : ChildProcess.make("claude", ["auth", "login"], { + shell: process.platform === "win32", + }); + + return provideServices( + Effect.gen(function* () { + const s = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* s.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode === 0) { + return { success: true } as { success: boolean; message?: string }; + } + + const detail = nonEmptyTrimmed(stderr) ?? nonEmptyTrimmed(stdout); + return { + success: false, + message: detail ?? `Login command exited with code ${exitCode}.`, + } as { success: boolean; message?: string }; + }).pipe(Effect.scoped), + ).pipe( + Effect.flatMap((result) => { + if (result.success) { + return runChecks.pipe( + Effect.map((fresh) => { + cachedStatuses = fresh; + return result; + }), + ); + } + return Effect.succeed(result); + }), + Effect.timeout(30_000), + Effect.catch(() => + Effect.succeed({ + success: false, + message: "Login timed out or failed. Try running the command manually in your terminal.", + } as { success: boolean; message?: string }), + ), + ); + }; + + return { getStatuses, refreshStatuses, login } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Layers/ServiceHealth.ts b/apps/server/src/provider/Layers/ServiceHealth.ts new file mode 100644 index 0000000000..60945d7e37 --- /dev/null +++ b/apps/server/src/provider/Layers/ServiceHealth.ts @@ -0,0 +1,290 @@ +/** + * ServiceHealth - External service auth status checks. + * + * Checks whether CLI tools (gogcli, gcalcli) and credentials (~/.netrc) + * are available and authenticated. + * + * @module ServiceHealth + */ +import * as OS from "node:os"; +import type { ServiceAuthStatus } from "@t3tools/contracts"; +import { Effect, FileSystem, Option, Path, Result, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +const DEFAULT_TIMEOUT_MS = 4_000; + +// ── Pure helpers ──────────────────────────────────────────────────── + +interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + Stream.runFold( + stream, + () => "", + (acc, chunk) => acc + new TextDecoder().decode(chunk), + ); + +const runCommand = (bin: string, args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make(bin, [...args], { + shell: process.platform === "win32", + }); + + const child = yield* spawner.spawn(command); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +function isCommandMissingCause(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const lower = error.message.toLowerCase(); + return lower.includes("enoent") || lower.includes("notfound"); +} + +// ── Gmail check ───────────────────────────────────────────────────── + +/** + * Check Gmail auth by running `gogcli gmail search "is:unread" --account tryan@mediafly.com --json --max 1`. + * If it succeeds, authenticated. If it errors about keyring/auth, unauthenticated. + * If command not found, not available. + */ +export const checkGmailStatus: Effect.Effect< + ServiceAuthStatus, + never, + ChildProcessSpawner.ChildProcessSpawner +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + + const probe = yield* runCommand("gogcli", [ + "gmail", "search", "is:unread", + "--account", "tryan@mediafly.com", + "--json", "--max", "1", + ]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(probe)) { + const error = probe.failure; + return { + service: "gmail" as const, + available: false, + authenticated: false, + checkedAt, + message: isCommandMissingCause(error) + ? "gogcli is not installed or not on PATH." + : `Failed to check Gmail status: ${error instanceof Error ? error.message : String(error)}.`, + }; + } + + if (Option.isNone(probe.success)) { + return { + service: "gmail" as const, + available: false, + authenticated: false, + checkedAt, + message: "gogcli check timed out.", + }; + } + + const res = probe.success.value; + + if (res.code === 0) { + return { service: "gmail" as const, available: true, authenticated: true, checkedAt }; + } + + const output = `${res.stdout}\n${res.stderr}`.toLowerCase(); + if ( + output.includes("keyring") || + output.includes("password") || + output.includes("decrypt") || + output.includes("auth") + ) { + return { + service: "gmail" as const, + available: true, + authenticated: false, + checkedAt, + message: "gogcli keyring password not set. Set GOG_KEYRING_PASSWORD env var.", + }; + } + + return { + service: "gmail" as const, + available: true, + authenticated: false, + checkedAt, + message: res.stderr.trim() || `gogcli exited with code ${res.code}.`, + }; +}).pipe( + Effect.catch(() => + Effect.succeed({ + service: "gmail" as const, + available: false, + authenticated: false, + checkedAt: new Date().toISOString(), + message: "Failed to check Gmail status.", + }), + ), +); + +// ── Jira check ────────────────────────────────────────────────────── + +/** + * Check Jira auth by looking for ~/.netrc entry for mediafly.atlassian.net. + */ +export const checkJiraStatus: Effect.Effect< + ServiceAuthStatus, + never, + FileSystem.FileSystem | Path.Path +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const netrcPath = path.join(OS.homedir(), ".netrc"); + + const content = yield* fileSystem + .readFileString(netrcPath) + .pipe(Effect.orElseSucceed(() => undefined)); + + if (!content) { + return { + service: "jira" as const, + available: false, + authenticated: false, + checkedAt, + message: "~/.netrc not found. Create it with your Jira API token.", + }; + } + + if (content.includes("mediafly.atlassian.net")) { + return { service: "jira" as const, available: true, authenticated: true, checkedAt }; + } + + return { + service: "jira" as const, + available: true, + authenticated: false, + checkedAt, + message: "~/.netrc exists but missing mediafly.atlassian.net entry.", + }; +}).pipe( + Effect.catch(() => + Effect.succeed({ + service: "jira" as const, + available: false, + authenticated: false, + checkedAt: new Date().toISOString(), + message: "Failed to check Jira status.", + }), + ), +); + +// ── Calendar check ────────────────────────────────────────────────── + +/** + * Check Calendar auth by running `gcalcli agenda --tsv --nocolor`. + */ +export const checkCalendarStatus: Effect.Effect< + ServiceAuthStatus, + never, + ChildProcessSpawner.ChildProcessSpawner +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + + const probe = yield* runCommand("gcalcli", ["agenda", "--tsv", "--nocolor"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(probe)) { + const error = probe.failure; + return { + service: "calendar" as const, + available: false, + authenticated: false, + checkedAt, + message: isCommandMissingCause(error) + ? "gcalcli is not installed or not on PATH." + : `Failed to check Calendar status: ${error instanceof Error ? error.message : String(error)}.`, + }; + } + + if (Option.isNone(probe.success)) { + return { + service: "calendar" as const, + available: false, + authenticated: false, + checkedAt, + message: "gcalcli check timed out.", + }; + } + + const res = probe.success.value; + + if (res.code === 0) { + return { service: "calendar" as const, available: true, authenticated: true, checkedAt }; + } + + const output = `${res.stdout}\n${res.stderr}`.toLowerCase(); + if ( + output.includes("auth") || + output.includes("credentials") || + output.includes("token") || + output.includes("login") + ) { + return { + service: "calendar" as const, + available: true, + authenticated: false, + checkedAt, + message: "gcalcli needs authentication. Run `gcalcli init`.", + }; + } + + return { + service: "calendar" as const, + available: true, + authenticated: false, + checkedAt, + message: res.stderr.trim() || `gcalcli exited with code ${res.code}.`, + }; +}).pipe( + Effect.catch(() => + Effect.succeed({ + service: "calendar" as const, + available: false, + authenticated: false, + checkedAt: new Date().toISOString(), + message: "Failed to check Calendar status.", + }), + ), +); + +// ── Aggregate ─────────────────────────────────────────────────────── + +/** + * Run all service checks concurrently. + */ +export const checkAllServiceStatuses: Effect.Effect< + ReadonlyArray, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> = Effect.all( + [checkGmailStatus, checkJiraStatus, checkCalendarStatus], + { concurrency: "unbounded" }, +); diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts index ec3b2d318d..740b7e861f 100644 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -15,6 +15,15 @@ export interface ProviderHealthShape { * Read the latest provider health statuses. */ readonly getStatuses: Effect.Effect>; + /** + * Re-run all provider health checks and return fresh results. + */ + readonly refreshStatuses: Effect.Effect>; + /** + * Trigger a login flow for the specified provider. + * Returns success/failure with an optional message. + */ + readonly login: (provider: "codex" | "claudeAgent") => Effect.Effect<{ success: boolean; message?: string }>; } export class ProviderHealth extends ServiceMap.Service()( diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 7250f8566c..431bd8de26 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -30,11 +30,15 @@ import { KeybindingsLive } from "./keybindings"; import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; +import { BitbucketApiLive } from "./git/Layers/BitbucketApi"; import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; import { GitServiceLive } from "./git/Layers/GitService"; import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { JiraServiceLive } from "./jira/Layers/JiraService"; +import { GmailServiceLive } from "./gmail/Layers/GmailService"; +import { CalendarServiceLive } from "./calendar/Layers/CalendarService"; export function makeServerProviderLayer(): Layer.Layer< ProviderService, @@ -119,6 +123,7 @@ export function makeServerRuntimeServicesLayer() { const gitManagerLayer = GitManagerLive.pipe( Layer.provideMerge(gitCoreLayer), Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(BitbucketApiLive), Layer.provideMerge(textGenerationLayer), ); @@ -128,5 +133,8 @@ export function makeServerRuntimeServicesLayer() { gitManagerLayer, terminalLayer, KeybindingsLive, + JiraServiceLive, + GmailServiceLive, + CalendarServiceLive, ).pipe(Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/skills/triageSkill.ts b/apps/server/src/skills/triageSkill.ts new file mode 100644 index 0000000000..2543264b93 --- /dev/null +++ b/apps/server/src/skills/triageSkill.ts @@ -0,0 +1,242 @@ +import { Effect } from "effect"; +import { JiraService } from "../jira/Services/JiraService.ts"; +import { GmailService } from "../gmail/Services/GmailService.ts"; +import { CalendarService } from "../calendar/Services/CalendarService.ts"; + +export interface TriageItem { + priority: "urgent" | "respond" | "prepare" | "progress" | "housekeeping"; + source: "jira" | "email" | "calendar" | "github"; + summary: string; + action?: string; + link?: string; +} + +export interface TriageReport { + timestamp: string; + items: TriageItem[]; + stats: { + unreadEmails: number; + actionEmails: number; + noiseEmails: number; + jiraTickets: number; + jiraChanged: number; + meetingsToday: number; + meetingsTomorrow: number; + }; + markdown: string; +} + +/** + * Scan all communication channels and generate a prioritized action list. + */ +export const generateTriageReport = Effect.gen(function* () { + const jiraService = yield* JiraService; + const gmailService = yield* GmailService; + const calendarService = yield* CalendarService; + + const now = new Date(); + const timestamp = now.toISOString(); + const items: TriageItem[] = []; + + // Fetch all data in parallel + const [tickets, unreadEmails, todayMeetings, tomorrowMeetings] = yield* Effect.all( + [ + jiraService.listTickets({}).pipe(Effect.catch(() => Effect.succeed([] as const))), + gmailService.search({ query: "is:unread", maxResults: 50 }).pipe(Effect.catch(() => Effect.succeed([] as const))), + calendarService.agenda({}).pipe(Effect.catch(() => Effect.succeed([] as const))), + calendarService.agenda({ date: "tomorrow" }).pipe(Effect.catch(() => Effect.succeed([] as const))), + ] as const, + { concurrency: 4 }, + ); + + // --- Email Triage --- + let actionEmails = 0; + let noiseEmails = 0; + + for (const email of unreadEmails) { + const category = email.category ?? "FYI"; + + if (category === "NOISE") { + noiseEmails++; + continue; + } + + if (category === "ACTION") { + actionEmails++; + items.push({ + priority: "respond", + source: "email", + summary: `[EMAIL] From: ${email.from} — ${email.subject}`, + action: "Reply needed", + }); + } else if (category === "REVIEW") { + items.push({ + priority: "respond", + source: "email", + summary: `[REVIEW] ${email.subject}`, + action: "Review requested", + }); + } + } + + // --- Meeting Prep --- + const realMeetings = todayMeetings.filter((m) => !m.isAllDay); + const upcomingMeetings = realMeetings.filter((m) => { + try { + return new Date(m.start) > now; + } catch { + return false; + } + }); + + for (const meeting of upcomingMeetings.slice(0, 3)) { + const startTime = new Date(meeting.start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + items.push({ + priority: "prepare", + source: "calendar", + summary: `[${startTime}] ${meeting.title}`, + action: "Prepare talking points", + }); + } + + // --- Jira Activity --- + const inProgress = tickets.filter((t) => t.status === "In Progress"); + const staleTickets = tickets.filter((t) => { + try { + const updated = new Date(t.updated); + const daysSinceUpdate = (now.getTime() - updated.getTime()) / (1000 * 60 * 60 * 24); + return daysSinceUpdate > 14; + } catch { + return false; + } + }); + const agingReview = tickets.filter((t) => { + try { + if (t.status !== "In Review") return false; + const updated = new Date(t.updated); + const daysSinceUpdate = (now.getTime() - updated.getTime()) / (1000 * 60 * 60 * 24); + return daysSinceUpdate > 5; + } catch { + return false; + } + }); + + for (const ticket of inProgress.slice(0, 5)) { + items.push({ + priority: "progress", + source: "jira", + summary: `${ticket.key}: ${ticket.summary}`, + action: `Status: ${ticket.status} | Priority: ${ticket.priority}`, + link: ticket.url, + }); + } + + for (const ticket of agingReview) { + items.push({ + priority: "urgent", + source: "jira", + summary: `${ticket.key} has been In Review for too long`, + action: "Follow up on review", + link: ticket.url, + }); + } + + for (const ticket of staleTickets.slice(0, 3)) { + items.push({ + priority: "housekeeping", + source: "jira", + summary: `${ticket.key}: No update in 14+ days — close or update?`, + link: ticket.url, + }); + } + + // Housekeeping: noise emails + if (noiseEmails > 0) { + items.push({ + priority: "housekeeping", + source: "email", + summary: `Archive ${noiseEmails} noise email(s)`, + action: "Auto-mark as read", + }); + } + + // Sort by priority + const priorityOrder = { urgent: 0, respond: 1, prepare: 2, progress: 3, housekeeping: 4 }; + items.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]); + + // Generate markdown + const sections: string[] = [ + `# Triage Report — ${now.toISOString().split("T")[0]} ${now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`, + "", + ]; + + const urgent = items.filter((i) => i.priority === "urgent"); + const respond = items.filter((i) => i.priority === "respond"); + const prepare = items.filter((i) => i.priority === "prepare"); + const progress = items.filter((i) => i.priority === "progress"); + const housekeeping = items.filter((i) => i.priority === "housekeeping"); + + if (urgent.length > 0) { + sections.push("## Do Right Now"); + for (const item of urgent) { + sections.push(`- ${item.summary}${item.action ? ` — ${item.action}` : ""}`); + } + sections.push(""); + } + + if (respond.length > 0) { + sections.push("## Respond To"); + for (const item of respond) { + sections.push(`- ${item.summary}${item.action ? ` — ${item.action}` : ""}`); + } + sections.push(""); + } + + if (prepare.length > 0) { + sections.push("## Meeting Prep"); + for (const item of prepare) { + sections.push(`- ${item.summary}${item.action ? ` — ${item.action}` : ""}`); + } + sections.push(""); + } + + if (progress.length > 0) { + sections.push("## Keep Working On"); + for (const item of progress) { + sections.push(`- ${item.summary}${item.action ? ` — ${item.action}` : ""}`); + } + sections.push(""); + } + + if (housekeeping.length > 0) { + sections.push("## Housekeeping"); + for (const item of housekeeping) { + sections.push(`- ${item.summary}${item.action ? ` — ${item.action}` : ""}`); + } + sections.push(""); + } + + sections.push("## Inbox Summary"); + sections.push(`- ${unreadEmails.length} unread emails (${actionEmails} action, ${noiseEmails} noise)`); + sections.push(`- ${tickets.length} Jira tickets`); + sections.push(`- ${realMeetings.length} meetings today, ${tomorrowMeetings.length} tomorrow`); + + const markdown = sections.join("\n"); + + const report: TriageReport = { + timestamp, + items, + stats: { + unreadEmails: unreadEmails.length, + actionEmails, + noiseEmails, + jiraTickets: tickets.length, + jiraChanged: 0, + meetingsToday: realMeetings.length, + meetingsTomorrow: tomorrowMeetings.length, + }, + markdown, + }; + + return report; +}); diff --git a/apps/server/src/skills/updateSkill.ts b/apps/server/src/skills/updateSkill.ts new file mode 100644 index 0000000000..4d1d11a75d --- /dev/null +++ b/apps/server/src/skills/updateSkill.ts @@ -0,0 +1,121 @@ +import { Effect } from "effect"; +import { JiraService } from "../jira/Services/JiraService.ts"; +import { GmailService } from "../gmail/Services/GmailService.ts"; +import { CalendarService } from "../calendar/Services/CalendarService.ts"; + +export interface UpdateReport { + date: string; + yesterday: string[]; + today: string[]; + blockers: string[]; + stats: { + jiraTickets: number; + inProgress: number; + waiting: number; + unreadEmails: number; + meetingsToday: number; + }; + markdown: string; +} + +/** + * Generate a daily status update by aggregating data from all services. + */ +export const generateDailyUpdate = Effect.gen(function* () { + const jiraService = yield* JiraService; + const gmailService = yield* GmailService; + const calendarService = yield* CalendarService; + + const today = new Date().toISOString().split("T")[0]!; + + // Fetch data from all services in parallel + const [tickets, emails, meetings] = yield* Effect.all( + [ + jiraService.listTickets({}).pipe(Effect.catch(() => Effect.succeed([] as const))), + gmailService.search({ query: "is:unread newer_than:1d" }).pipe(Effect.catch(() => Effect.succeed([] as const))), + calendarService.agenda({}).pipe(Effect.catch(() => Effect.succeed([] as const))), + ] as const, + { concurrency: 3 }, + ); + + // Categorize tickets + const inProgress = tickets.filter((t) => t.status === "In Progress"); + const waiting = tickets.filter( + (t) => t.status === "Waiting" || t.status === "In Review" || t.status === "Blocked", + ); + const done = tickets.filter((t) => t.status === "Done" || t.status === "Closed"); + + // Build yesterday section (from recently updated tickets) + const yesterday: string[] = []; + for (const ticket of inProgress.slice(0, 3)) { + yesterday.push(`Worked on ${ticket.key}: ${ticket.summary}`); + } + if (done.length > 0) { + for (const ticket of done.slice(0, 2)) { + yesterday.push(`Completed ${ticket.key}: ${ticket.summary}`); + } + } + if (yesterday.length === 0) { + yesterday.push("Continued work on active tickets"); + } + + // Build today section + const todayItems: string[] = []; + for (const ticket of inProgress.slice(0, 3)) { + todayItems.push(`Continue ${ticket.key}: ${ticket.summary} (${ticket.priority})`); + } + if (meetings.length > 0) { + const realMeetings = meetings.filter((m) => !m.isAllDay); + if (realMeetings.length > 0) { + todayItems.push(`${realMeetings.length} meeting(s) scheduled`); + } + } + if (todayItems.length === 0) { + todayItems.push("Review and prioritize backlog"); + } + + // Build blockers section + const blockers: string[] = []; + for (const ticket of waiting) { + blockers.push(`${ticket.key}: ${ticket.summary} — ${ticket.status}`); + } + if (blockers.length === 0) { + blockers.push("None"); + } + + // Generate markdown + const markdown = [ + `# Daily Status Update — ${today}`, + "", + "## Yesterday", + ...yesterday.map((item) => `- ${item}`), + "", + "## Today", + ...todayItems.map((item) => `- ${item}`), + "", + "## Blockers", + ...blockers.map((item) => `- ${item}`), + "", + "---", + `📋 ${tickets.length} Jira tickets (${inProgress.length} in progress, ${waiting.length} waiting)`, + `📧 ${emails.length} unread emails`, + `📅 ${meetings.length} events today`, + ].join("\n"); + + const report: UpdateReport = { + date: today, + yesterday, + today: todayItems, + blockers, + stats: { + jiraTickets: tickets.length, + inProgress: inProgress.length, + waiting: waiting.length, + unreadEmails: emails.length, + meetingsToday: meetings.length, + }, + markdown, + }; + + return report; +}); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 8c71834e9e..6d95319478 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -35,7 +35,7 @@ const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; const DEFAULT_OPEN_COLS = 120; const DEFAULT_OPEN_ROWS = 30; -const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); +const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE", "T3CODE_AUTH_TOKEN", "T3CODE_DESKTOP_WS_URL"]); const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput); const decodeTerminalRestartInput = Schema.decodeUnknownSync(TerminalRestartInput); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 9c6adfeba9..cab7e0e1d5 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -76,6 +76,8 @@ const defaultProviderStatuses: ReadonlyArray = [ const defaultProviderHealthService: ProviderHealthShape = { getStatuses: Effect.succeed(defaultProviderStatuses), + refreshStatuses: Effect.succeed(defaultProviderStatuses), + login: () => Effect.succeed({ success: true }), }; class MockTerminalManager implements TerminalManagerShape { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index e22c23988b..a9eec5eca7 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -45,6 +45,8 @@ import { } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { createLogger } from "./logger"; import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; @@ -55,6 +57,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; import { ProviderService } from "./provider/Services/ProviderService"; import { ProviderHealth } from "./provider/Services/ProviderHealth"; +import { checkAllServiceStatuses } from "./provider/Layers/ServiceHealth"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { clamp } from "effect/Number"; import { Open, resolveAvailableEditors } from "./open"; @@ -74,6 +77,9 @@ import { } from "./attachmentStore.ts"; import { parseBase64DataUrl } from "./imageMime.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { JiraService } from "./jira/Services/JiraService.ts"; +import { CalendarService } from "./calendar/Services/CalendarService.ts"; +import { GmailService } from "./gmail/Services/GmailService.ts"; import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; @@ -89,7 +95,7 @@ export interface ServerShape { readonly start: Effect.Effect< http.Server, ServerLifecycleError, - Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path | SqlClient.SqlClient | ChildProcessSpawner.ChildProcessSpawner >; /** @@ -217,7 +223,10 @@ export type ServerRuntimeServices = | TerminalManager | Keybindings | Open - | AnalyticsService; + | AnalyticsService + | JiraService + | CalendarService + | GmailService; export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", @@ -234,7 +243,7 @@ class RouteRequestError extends Schema.TaggedErrorClass()("Ro export const createServer = Effect.fn(function* (): Effect.fn.Return< http.Server, ServerLifecycleError, - Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path | SqlClient.SqlClient | ChildProcessSpawner.ChildProcessSpawner > { const serverConfig = yield* ServerConfig; const { @@ -257,6 +266,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; + + // Service health checks (Gmail, Jira, Calendar) — run at startup. + let serviceStatuses: ReadonlyArray = yield* checkAllServiceStatuses; yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( Effect.catch((error) => @@ -603,6 +616,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const checkpointDiffQuery = yield* CheckpointDiffQuery; const orchestrationReactor = yield* OrchestrationReactor; const { openInEditor } = yield* Open; + const jiraService = yield* JiraService; + const calendarService = yield* CalendarService; + const gmailService = yield* GmailService; const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); @@ -615,6 +631,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { issues: event.issues, providers: providerStatuses, + services: serviceStatuses, }), ).pipe(Effect.forkIn(subscriptionsScope)); @@ -685,7 +702,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } const runtimeServices = yield* Effect.services< - ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner >(); const runPromise = Effect.runPromiseWith(runtimeServices); @@ -748,6 +765,24 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); } + case WS_METHODS.projectsReadFile: { + const body = stripRequestTag(request.body); + const target = yield* resolveWorkspaceWritePath({ + workspaceRoot: body.cwd, + relativePath: body.relativePath, + path, + }); + const contents = yield* fileSystem.readFileString(target.absolutePath).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to read workspace file: ${String(cause)}`, + }), + ), + ); + return { contents }; + } + case WS_METHODS.projectsWriteFile: { const body = stripRequestTag(request.body); const target = yield* resolveWorkspaceWritePath({ @@ -870,10 +905,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const keybindingsConfig = yield* keybindingsManager.loadConfigState; return { cwd, + baseDir: serverConfig.baseDir, keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers: providerStatuses, + services: serviceStatuses, availableEditors, }; @@ -883,6 +920,215 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { keybindings: keybindingsConfig, issues: [] }; } + case WS_METHODS.providerRefreshStatus: { + const fresh = yield* providerHealth.refreshStatuses; + return fresh; + } + + case WS_METHODS.providerLogin: { + const body = stripRequestTag(request.body); + return yield* providerHealth.login(body.provider); + } + + case WS_METHODS.serviceRefreshStatus: { + const fresh = yield* checkAllServiceStatuses; + serviceStatuses = fresh; + return fresh; + } + + case WS_METHODS.specGet: { + const body = stripRequestTag(request.body); + const rows = yield* sql` + SELECT id, project_id, content, created_at, updated_at + FROM specs + WHERE project_id = ${body.projectId} + LIMIT 1 + `; + if (rows.length === 0) return null; + const r = rows[0] as any; + return { id: r.id, projectId: r.project_id, content: r.content, createdAt: r.created_at, updatedAt: r.updated_at }; + } + + case WS_METHODS.specUpdate: { + const body = stripRequestTag(request.body); + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + yield* sql` + INSERT INTO specs (id, project_id, content, created_at, updated_at) + VALUES (${id}, ${body.projectId}, ${body.content}, ${now}, ${now}) + ON CONFLICT(project_id) DO UPDATE SET + content = ${body.content}, + updated_at = ${now} + `; + return { id }; + } + + case WS_METHODS.promptHistoryList: { + const { projectId, limit } = request.body; + const maxLimit = limit ?? 50; + const rows = yield* sql` + SELECT id, project_id, prompt, created_at + FROM prompt_history + WHERE project_id = ${projectId} + ORDER BY created_at DESC + LIMIT ${maxLimit} + `; + return rows.map((r: any) => ({ + id: r.id, + projectId: r.project_id, + prompt: r.prompt, + createdAt: r.created_at, + })); + } + + case WS_METHODS.promptHistoryAdd: { + const { projectId, prompt } = request.body; + const id = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + yield* sql` + INSERT INTO prompt_history (id, project_id, prompt, created_at) + VALUES (${id}, ${projectId}, ${prompt}, ${createdAt}) + `; + return { id }; + } + + case WS_METHODS.gmailSearch: { + const body = stripRequestTag(request.body); + return yield* gmailService.search({ + query: body.query, + ...(body.maxResults !== undefined ? { maxResults: body.maxResults } : {}), + }); + } + + case WS_METHODS.gmailMarkRead: { + const body = stripRequestTag(request.body); + yield* gmailService.markRead({ threadId: body.threadId }); + return {}; + } + + case WS_METHODS.gmailCreateDraft: { + const body = stripRequestTag(request.body); + return yield* gmailService.createDraft({ + to: body.to, + subject: body.subject, + body: body.body, + ...(body.replyToMessageId !== undefined ? { replyToMessageId: body.replyToMessageId } : {}), + }); + } + + case WS_METHODS.calendarAgenda: { + const body = stripRequestTag(request.body); + return yield* calendarService.agenda({ + ...(body.date !== undefined ? { date: body.date } : {}), + }); + } + + case WS_METHODS.calendarMeetingPrep: { + const body = stripRequestTag(request.body); + return yield* calendarService.meetingPrep({ + title: body.title, + start: body.start, + }); + } + + case WS_METHODS.jiraList: { + const body = stripRequestTag(request.body); + return yield* jiraService.listTickets({ + ...(body.assignee !== undefined ? { assignee: body.assignee } : {}), + ...(body.status !== undefined ? { status: body.status } : {}), + ...(body.maxResults !== undefined ? { maxResults: body.maxResults } : {}), + }); + } + + case WS_METHODS.jiraGet: { + const body = stripRequestTag(request.body); + return yield* jiraService.getTicket({ ticketKey: body.ticketKey }); + } + + case WS_METHODS.jiraSearch: { + const body = stripRequestTag(request.body); + return yield* jiraService.searchTickets({ + jql: body.jql, + ...(body.maxResults !== undefined ? { maxResults: body.maxResults } : {}), + }); + } + + case WS_METHODS.jiraRefresh: { + return yield* jiraService.refreshCache(); + } + + case WS_METHODS.jiraPostComment: { + const body = stripRequestTag(request.body); + yield* jiraService.postComment({ + ticketKey: body.ticketKey, + body: body.body, + }); + return {}; + } + + case WS_METHODS.jiraTransition: { + const body = stripRequestTag(request.body); + yield* jiraService.transitionTicket({ + ticketKey: body.ticketKey, + transitionName: body.transitionName, + }); + return {}; + } + + case WS_METHODS.jiraListSecDeskRequestTypes: { + return yield* jiraService.listServiceDeskRequestTypes(); + } + + case WS_METHODS.jiraCreateSecDeskRequest: { + const body = stripRequestTag(request.body); + return yield* jiraService.createServiceDeskRequest({ + requestTypeId: body.requestTypeId, + summary: body.summary, + ...(body.description ? { description: body.description } : {}), + }); + } + + // Gmail methods + case WS_METHODS.gmailSearch: { + const body = stripRequestTag(request.body); + return yield* gmailService.search({ + query: body.query, + ...(body.maxResults !== undefined ? { maxResults: body.maxResults } : {}), + }); + } + + case WS_METHODS.gmailMarkRead: { + const body = stripRequestTag(request.body); + yield* gmailService.markRead({ threadId: body.threadId }); + return {}; + } + + case WS_METHODS.gmailCreateDraft: { + const body = stripRequestTag(request.body); + return yield* gmailService.createDraft({ + to: body.to, + subject: body.subject, + body: body.body, + ...(body.replyToMessageId !== undefined ? { replyToMessageId: body.replyToMessageId } : {}), + }); + } + + // Calendar methods + case WS_METHODS.calendarAgenda: { + const body = stripRequestTag(request.body); + return yield* calendarService.agenda({ + ...(body.date !== undefined ? { date: body.date } : {}), + }); + } + + case WS_METHODS.calendarMeetingPrep: { + const body = stripRequestTag(request.body); + return yield* calendarService.meetingPrep({ + title: body.title, + start: body.start, + }); + } + default: { const _exhaustiveCheck: never = request.body; return yield* new RouteRequestError({ diff --git a/apps/server/src/wsServer/pushBus.test.ts b/apps/server/src/wsServer/pushBus.test.ts index 172944607b..ee7efd95cd 100644 --- a/apps/server/src/wsServer/pushBus.test.ts +++ b/apps/server/src/wsServer/pushBus.test.ts @@ -54,6 +54,7 @@ describe("makeServerPushBus", () => { yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }], providers: [], + services: [], }); const delivered = yield* pushBus.publishClient( @@ -71,6 +72,7 @@ describe("makeServerPushBus", () => { yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { issues: [], providers: [], + services: [], }); yield* Effect.promise(() => client.waitForSentCount(2)); diff --git a/apps/web/index.html b/apps/web/index.html index 0322f2d019..88395bd790 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -11,7 +11,7 @@ href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap" rel="stylesheet" /> - T3 Code (Alpha) + Fly Code
diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js index daa58d0f12..8fa9dca80e 100644 --- a/apps/web/public/mockServiceWorker.js +++ b/apps/web/public/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.12.10' +const PACKAGE_VERSION = '2.12.11' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 14b6a6a92d..52eff55e2c 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -17,6 +17,9 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; + +export const SidebarSide = Schema.Literals(["left", "right"]); +export type SidebarSide = typeof SidebarSide.Type; type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels"; export type ProviderCustomModelConfig = { provider: ProviderKind; @@ -56,6 +59,7 @@ export const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString), + sidebarSide: SidebarSide.pipe(withDefaults(() => "left" as const satisfies SidebarSide)), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index bffd983815..e62e2aa2bd 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,4 +1,4 @@ -export const APP_BASE_NAME = "T3 Code"; +export const APP_BASE_NAME = "Fly Code"; export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha"; export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; diff --git a/apps/web/src/commandTrayStore.ts b/apps/web/src/commandTrayStore.ts new file mode 100644 index 0000000000..ae412c7f77 --- /dev/null +++ b/apps/web/src/commandTrayStore.ts @@ -0,0 +1,59 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export interface CommandTrayButton { + id: string; + label: string; + command: string; + icon?: string; + /** Where to send the command: "chat" (default) submits as a chat message, "terminal" writes directly to the active terminal. */ + target?: "chat" | "terminal"; +} + +interface CommandTrayState { + buttons: CommandTrayButton[]; + setButtons: (buttons: CommandTrayButton[]) => void; + addButton: (button: CommandTrayButton) => void; + removeButton: (id: string) => void; + updateButton: (id: string, updates: Partial) => void; + resetToDefaults: () => void; +} + +const DEFAULT_BUTTONS: CommandTrayButton[] = [ + { id: "update", label: "/update", command: "/update\n" }, + { id: "triage", label: "/triage", command: "/triage\n" }, + { id: "commit", label: "/commit", command: "/commit\n" }, + { id: "aws-sso", label: "AWS SSO", command: "aws sso login --profile default\n", target: "terminal" }, + { id: "shell", label: "Shell", command: "" }, + { id: "claude", label: "Claude", command: "" }, +]; + +const COMMAND_TRAY_STORAGE_KEY = "t3code:command-tray:v1"; + +export const useCommandTrayStore = create()( + persist( + (set) => ({ + buttons: DEFAULT_BUTTONS, + setButtons: (buttons) => set({ buttons }), + addButton: (button) => + set((state) => ({ buttons: [...state.buttons, button] })), + removeButton: (id) => + set((state) => ({ + buttons: state.buttons.filter((btn) => btn.id !== id), + })), + updateButton: (id, updates) => + set((state) => ({ + buttons: state.buttons.map((btn) => + btn.id === id ? { ...btn, ...updates } : btn, + ), + })), + resetToDefaults: () => set({ buttons: DEFAULT_BUTTONS }), + }), + { + name: COMMAND_TRAY_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ buttons: state.buttons }), + }, + ), +); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 48c627747d..510440117a 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -102,6 +102,7 @@ function isoAt(offsetSeconds: number): string { function createBaseServerConfig(): ServerConfig { return { cwd: "/repo/project", + baseDir: "/repo", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], issues: [], @@ -114,6 +115,7 @@ function createBaseServerConfig(): ServerConfig { checkedAt: NOW_ISO, }, ], + services: [], availableEditors: [], }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e628f6ea6a..b4f6a2fea5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -31,7 +31,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; +import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions, gitInitMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; @@ -117,6 +117,7 @@ import { setupProjectScript, } from "~/projectScripts"; import { SidebarTrigger } from "./ui/sidebar"; +import WorkflowDropdowns from "./chat/WorkflowDropdowns"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { @@ -160,7 +161,6 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./chat/composerProviderRegistry"; -import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { buildExpiredTerminalContextToastCopy, @@ -1122,6 +1122,23 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeProjectCwd, activeThreadWorktreePath]); // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; + + // Auto-init git for project directories that aren't git repos (e.g. Jira-imported projects) + const gitInitMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); + const gitInitAttemptedRef = useRef(null); + useEffect(() => { + if ( + gitCwd && + branchesQuery.data && + !branchesQuery.data.isRepo && + !gitInitMutation.isPending && + gitInitAttemptedRef.current !== gitCwd + ) { + gitInitAttemptedRef.current = gitCwd; + gitInitMutation.mutate(); + } + }, [gitCwd, branchesQuery.data, gitInitMutation.isPending]); + const splitTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.split"), [keybindings], @@ -2670,6 +2687,62 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; + // Listen for command tray injections + // eslint-disable-next-line react-hooks/exhaustive-deps + const onSendRef = useRef(onSend); + onSendRef.current = onSend; + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ command: string }>).detail; + if (!detail?.command) return; + promptRef.current = detail.command; + setPrompt(detail.command); + // Use ref to always get latest onSend + setTimeout(() => { + void onSendRef.current(); + }, 0); + }; + window.addEventListener("commandTraySubmit", handler); + return () => window.removeEventListener("commandTraySubmit", handler); + }, [setPrompt]); + + // Listen for command tray terminal-targeted commands + useEffect(() => { + const handler = async (event: Event) => { + const detail = (event as CustomEvent<{ command: string; threadId: string; terminalId: string }>).detail; + if (!detail?.command || !detail.threadId || !detail.terminalId) return; + const api = readNativeApi(); + if (!api) return; + try { + await api.terminal.write({ + threadId: detail.threadId, + terminalId: detail.terminalId, + data: detail.command, + }); + } catch { + // Terminal might not be open yet — open it first, then write + try { + const cwd = activeProject?.cwd; + if (!cwd) return; + await api.terminal.open({ + threadId: detail.threadId, + terminalId: detail.terminalId, + cwd, + }); + await api.terminal.write({ + threadId: detail.threadId, + terminalId: detail.terminalId, + data: detail.command, + }); + } catch { + // Silently fail — terminal may not be available + } + } + }; + window.addEventListener("commandTrayTerminalSubmit", handler); + return () => window.removeEventListener("commandTrayTerminalSubmit", handler); + }, [activeProject?.cwd]); + const onInterrupt = async () => { const api = readNativeApi(); if (!api || !activeThread) return; @@ -3495,7 +3568,6 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Error banner */} - setThreadError(activeThread.id, null)} @@ -3859,6 +3931,12 @@ export default function ChatView({ threadId }: ChatViewProps) { + + prompt} /> + {activePlan || sidebarProposedPlan || planSidebarOpen ? ( <> state.buttons); + const [editOpen, setEditOpen] = useState(false); + + const sendCommand = useCallback( + (btn: CommandTrayButton) => { + if (!btn.command || !threadId) return; + if (btn.target === "terminal") { + // Write directly to the active terminal + window.dispatchEvent( + new CustomEvent("commandTrayTerminalSubmit", { + detail: { command: btn.command, threadId, terminalId }, + }), + ); + } else { + // Default: submit as a chat message + const text = btn.command.replace(/\n$/, ""); + if (!text) return; + window.dispatchEvent( + new CustomEvent("commandTraySubmit", { detail: { command: text } }), + ); + } + }, + [threadId, terminalId], + ); + + return ( +
+ {buttons.map((btn) => ( + + ))} + + + setEditOpen(false)} /> + +
+ ); +} + +function EditCommandTrayDialog({ onClose }: { onClose: () => void }) { + const buttons = useCommandTrayStore((state) => state.buttons); + const addButton = useCommandTrayStore((state) => state.addButton); + const removeButton = useCommandTrayStore((state) => state.removeButton); + const updateButton = useCommandTrayStore((state) => state.updateButton); + const resetToDefaults = useCommandTrayStore((state) => state.resetToDefaults); + + const [newLabel, setNewLabel] = useState(""); + const [newCommand, setNewCommand] = useState(""); + + const handleAdd = () => { + const label = newLabel.trim(); + const command = newCommand.trim(); + if (!label) return; + addButton({ + id: `custom-${Date.now()}`, + label, + command: command ? `${command}\n` : "", + }); + setNewLabel(""); + setNewCommand(""); + }; + + return ( + + + Edit Command Tray + + Customize the command buttons shown at the bottom of the chat. + + + +
+ {buttons.map((btn) => ( +
+ ) => + updateButton(btn.id, { + label: (e.target as HTMLInputElement).value, + }) + } + placeholder="Label" + size="sm" + className="flex-1" + /> + ) => + updateButton(btn.id, { + command: (e.target as HTMLInputElement).value + ? `${(e.target as HTMLInputElement).value}\n` + : "", + }) + } + placeholder="Command" + size="sm" + className="flex-1" + /> + +
+ ))} +
+ ) => + setNewLabel((e.target as HTMLInputElement).value) + } + placeholder="New label" + size="sm" + className="flex-1" + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") handleAdd(); + }} + /> + ) => + setNewCommand((e.target as HTMLInputElement).value) + } + placeholder="New command" + size="sm" + className="flex-1" + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") handleAdd(); + }} + /> + +
+
+
+ + + Done} /> + +
+ ); +} + +export default CommandTray; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 34ad788814..663f42817d 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -3,7 +3,7 @@ import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/reac import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { ThreadId, type TurnId } from "@t3tools/contracts"; -import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; +import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon, XIcon } from "lucide-react"; import { type WheelEvent as ReactWheelEvent, useCallback, @@ -326,6 +326,18 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, }); }; + const closeDiffPanel = useCallback(() => { + if (!activeThread) return; + void navigate({ + to: "/$threadId", + params: { threadId: activeThread.id }, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return { ...rest, diff: undefined }; + }, + }); + }, [activeThread, navigate]); + const selectWholeConversation = () => { if (!activeThread) return; void navigate({ @@ -509,6 +521,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { + ); diff --git a/apps/web/src/components/FileBrowser.tsx b/apps/web/src/components/FileBrowser.tsx new file mode 100644 index 0000000000..b025d60662 --- /dev/null +++ b/apps/web/src/components/FileBrowser.tsx @@ -0,0 +1,257 @@ +import type { ProjectEntry } from "@t3tools/contracts"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronRightIcon, FileIcon, FolderIcon, FolderOpenIcon } from "lucide-react"; +import { memo, useCallback, useMemo, useState } from "react"; +import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { cn } from "~/lib/utils"; +import { FileViewerModal } from "./FileViewerModal"; + +function isHiddenEntry(name: string): boolean { + if (name.startsWith(".")) return true; + if (name === "CLAUDE.md") return true; + return false; +} + +interface TreeNode { + name: string; + path: string; + kind: "file" | "directory"; + children: TreeNode[]; +} + +function buildTree(entries: ReadonlyArray): TreeNode[] { + // First pass: determine which paths are excluded (hidden entries and all their descendants) + const excluded = new Set(); + for (const entry of entries) { + const name = entry.path.split("/").pop() ?? entry.path; + if (isHiddenEntry(name)) excluded.add(entry.path); + if (entry.parentPath && excluded.has(entry.parentPath)) excluded.add(entry.path); + } + + const nodeMap = new Map(); + + for (const entry of entries) { + if (excluded.has(entry.path)) continue; + + nodeMap.set(entry.path, { + name: entry.path.split("/").pop() ?? entry.path, + path: entry.path, + kind: entry.kind, + children: [], + }); + } + + const roots: TreeNode[] = []; + + for (const entry of entries) { + const node = nodeMap.get(entry.path); + if (!node) continue; + + const parentPath = entry.parentPath; + if (parentPath && nodeMap.has(parentPath)) { + nodeMap.get(parentPath)!.children.push(node); + } else { + roots.push(node); + } + } + + const sortNodes = (nodes: TreeNode[]): TreeNode[] => { + nodes.sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "directory" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const node of nodes) { + if (node.children.length > 0) sortNodes(node.children); + } + return nodes; + }; + + return sortNodes(roots); +} + +function sendToChat(command: string) { + const text = command.replace(/\n$/, ""); + if (!text) return; + window.dispatchEvent( + new CustomEvent("commandTraySubmit", { detail: { command: text } }), + ); +} + +interface TreeNodeRowProps { + node: TreeNode; + depth: number; + cwd: string; + expandedDirs: Set; + onToggleDir: (path: string) => void; + onFileClick: (relativePath: string) => void; +} + +const TreeNodeRow = memo(function TreeNodeRow({ + node, + depth, + cwd, + expandedDirs, + onToggleDir, + onFileClick, +}: TreeNodeRowProps) { + const leftPadding = 8 + depth * 12; + + const handleFileClick = useCallback( + (e: React.MouseEvent) => { + if (e.metaKey || e.ctrlKey) { + const fullPath = `${cwd}/${node.path}`; + sendToChat(`please read and summarize ${fullPath}`); + } else { + onFileClick(node.path); + } + }, + [cwd, node.path, onFileClick], + ); + + if (node.kind === "directory") { + const isExpanded = expandedDirs.has(node.path); + return ( +
+ + {isExpanded && + node.children.map((child) => ( + + ))} +
+ ); + } + + return ( + + ); +}); + +export const FileBrowser = memo(function FileBrowser({ cwd }: { cwd: string | null }) { + const [sectionExpanded, setSectionExpanded] = useState(true); + const [expandedDirs, setExpandedDirs] = useState>(() => new Set()); + const [viewingFile, setViewingFile] = useState(null); + + const queryResult = useQuery( + projectSearchEntriesQueryOptions({ cwd, query: ".", limit: 200 }), + ); + + const entries = queryResult.data?.entries ?? []; + + const treeNodes = useMemo(() => buildTree(entries), [entries]); + + const handleToggleDir = useCallback((path: string) => { + setExpandedDirs((current) => { + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + const handleFileClick = useCallback((relativePath: string) => { + setViewingFile(relativePath); + }, []); + + const handleCloseModal = useCallback((open: boolean) => { + if (!open) setViewingFile(null); + }, []); + + return ( +
+ + + {sectionExpanded && ( +
+ {!cwd ? ( +

No workspace open.

+ ) : queryResult.isPending ? ( +

Loading...

+ ) : treeNodes.length === 0 ? ( +

No files found.

+ ) : ( + treeNodes.map((node) => ( + + )) + )} +
+ )} + + +
+ ); +}); diff --git a/apps/web/src/components/FileViewerModal.tsx b/apps/web/src/components/FileViewerModal.tsx new file mode 100644 index 0000000000..5f168571a3 --- /dev/null +++ b/apps/web/src/components/FileViewerModal.tsx @@ -0,0 +1,75 @@ +import { useQuery } from "@tanstack/react-query"; +import { memo, useCallback, useMemo } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { projectReadFileQueryOptions } from "~/lib/projectReactQuery"; +import { + Dialog, + DialogPopup, + DialogHeader, + DialogTitle, + DialogPanel, +} from "~/components/ui/dialog"; + +interface FileViewerModalProps { + cwd: string | null; + relativePath: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** Convert single newlines to double so markdown renders line breaks as separate lines. */ +function normalizeLineBreaks(text: string): string { + return text.replace(/(? normalizeLineBreaks(fileQuery.data?.contents ?? ""), + [fileQuery.data?.contents], + ); + + const handleOpenChange = useCallback( + (_open: boolean) => { + onOpenChange(_open); + }, + [onOpenChange], + ); + + return ( + + + + {fileName} + + + {fileQuery.isPending ? ( +

Loading...

+ ) : fileQuery.isError ? ( +

+ Failed to read file: {fileQuery.error.message} +

+ ) : ( +
+ {contents} +
+ )} +
+
+
+ ); +}); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index ba4c8f4320..f07eea216d 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -40,6 +40,7 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/); function createBaseServerConfig(): ServerConfig { return { cwd: "/repo/project", + baseDir: "/repo", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], issues: [], @@ -52,6 +53,7 @@ function createBaseServerConfig(): ServerConfig { checkedAt: NOW_ISO, }, ], + services: [], availableEditors: [], }; } diff --git a/apps/web/src/components/PRCreationModal.tsx b/apps/web/src/components/PRCreationModal.tsx new file mode 100644 index 0000000000..bd7d686cf7 --- /dev/null +++ b/apps/web/src/components/PRCreationModal.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogTitle, +} from "~/components/ui/dialog"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { ensureNativeApi } from "~/nativeApi"; +import type { GitStatusResult, ProjectId, ThreadId } from "@t3tools/contracts"; + +interface PRCreationModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectId: ProjectId; + threadId: ThreadId; + ticketKey?: string; + workspaceRoot: string; +} + +type Step = "review" | "create" | "jira"; + +export function PRCreationModal({ + open, + onOpenChange, + projectId, + threadId, + ticketKey, + workspaceRoot, +}: PRCreationModalProps) { + const [step, setStep] = useState("review"); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [baseBranch, setBaseBranch] = useState("main"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [gitStatus, setGitStatus] = useState(null); + const [jiraComment, setJiraComment] = useState(""); + const [jiraPosted, setJiraPosted] = useState(false); + + // Reset state when modal opens + useEffect(() => { + if (open) { + setStep("review"); + setTitle(ticketKey ? `${ticketKey}: ` : ""); + setBody(""); + setBaseBranch("main"); + setLoading(false); + setError(null); + setGitStatus(null); + setJiraComment(""); + setJiraPosted(false); + } + }, [open, ticketKey]); + + // Fetch git status when review step is active + useEffect(() => { + if (!open || step !== "review") return; + let cancelled = false; + + async function fetchStatus() { + try { + setLoading(true); + setError(null); + const api = ensureNativeApi(); + const status = await api.git.status({ cwd: workspaceRoot }); + if (!cancelled) { + setGitStatus(status); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch git status"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void fetchStatus(); + return () => { + cancelled = true; + }; + }, [open, step, workspaceRoot]); + + const handleCreatePR = useCallback(async () => { + if (!title.trim()) { + setError("PR title is required"); + return; + } + + setLoading(true); + setError(null); + + try { + const api = ensureNativeApi(); + // Escape double quotes in title and body for the shell command + const escapedTitle = title.replace(/"/g, '\\"'); + const escapedBody = body.replace(/"/g, '\\"'); + const command = `gh pr create --title "${escapedTitle}" --body "${escapedBody}" --base ${baseBranch}\n`; + + await api.terminal.write({ + threadId, + data: command, + }); + + // Pre-fill Jira comment if ticket key is available + if (ticketKey) { + setJiraComment( + `Pull request created for branch ${gitStatus?.branch ?? "unknown"}:\n\nTitle: ${title}\nBase: ${baseBranch}`, + ); + setStep("jira"); + } else { + onOpenChange(false); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create pull request"); + } finally { + setLoading(false); + } + }, [title, body, baseBranch, threadId, ticketKey, gitStatus?.branch, onOpenChange]); + + const handlePostJiraComment = useCallback(async () => { + if (!ticketKey || !jiraComment.trim()) return; + + setLoading(true); + setError(null); + + try { + const api = ensureNativeApi(); + await api.jira.postComment({ + ticketKey, + body: jiraComment, + }); + setJiraPosted(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to post Jira comment"); + } finally { + setLoading(false); + } + }, [ticketKey, jiraComment]); + + return ( + + + + + {step === "review" && "Review Changes"} + {step === "create" && "Create Pull Request"} + {step === "jira" && "Post to Jira"} + + + {step === "review" && "Review your current branch and changes before creating a PR."} + {step === "create" && "Fill in the pull request details."} + {step === "jira" && `Post a comment to ${ticketKey} about this PR.`} + + + + + {/* Step 1: Review */} + {step === "review" && ( +
+ {loading && !gitStatus && ( +

Loading git status...

+ )} + + {error &&

{error}

} + + {gitStatus && ( + <> +
+ + Branch + + {gitStatus.branch ?? "detached HEAD"} +
+ +
+ + Upstream + + + {gitStatus.hasUpstream + ? `${gitStatus.aheadCount} ahead, ${gitStatus.behindCount} behind` + : "No upstream branch"} + +
+ + {gitStatus.pr && ( +
+ + Existing PR + + + #{gitStatus.pr.number} {gitStatus.pr.title} ({gitStatus.pr.state}) + +
+ )} + +
+ + Diff Summary + + + {gitStatus.workingTree.files.length} file + {gitStatus.workingTree.files.length !== 1 ? "s" : ""} changed + {gitStatus.workingTree.insertions > 0 && + `, +${gitStatus.workingTree.insertions}`} + {gitStatus.workingTree.deletions > 0 && + `, -${gitStatus.workingTree.deletions}`} + +
+ + {gitStatus.workingTree.files.length > 0 && ( +
+ + Changed Files + +
    + {gitStatus.workingTree.files.map((file) => ( +
  • + {file.path} + + {file.insertions > 0 && ( + +{file.insertions} + )} + {file.deletions > 0 && ( + -{file.deletions} + )} + +
  • + ))} +
+
+ )} + + )} +
+ )} + + {/* Step 2: Create PR */} + {step === "create" && ( +
+ {error &&

{error}

} + +
+ + setTitle((e.target as HTMLInputElement).value)} + placeholder="PR title" + /> +
+ +
+ +