Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/server/src/calendar/Errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Data } from "effect";

export class CalendarError extends Data.TaggedError("CalendarError")<{
readonly message: string;
}> {}
58 changes: 58 additions & 0 deletions apps/server/src/calendar/Layers/CalendarService.ts
Original file line number Diff line number Diff line change
@@ -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<string, CalendarError> {
return Effect.try({
try: () => execSync(`gcalcli ${args}`, { encoding: "utf-8", timeout: 30000 }),
catch: (e) => new CalendarError({ message: `gcalcli failed: ${e}` }),
});
}
Comment on lines +7 to +12
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/CalendarService.ts:7

runGcalcli interpolates args directly into a shell command string passed to execSync, so malicious input like "; rm -rf /" executes arbitrary shell commands. Pass args as an array to avoid shell interpretation.

+function runGcalcli(args: string[]): Effect.Effect<string, CalendarError> {
+  return Effect.try({
+    try: () => execSync("gcalcli", args, { encoding: "utf-8", timeout: 30000 }),
    catch: (e) => new CalendarError({ message: `gcalcli failed: ${e}` }),
  });
}
🤖 Copy this AI Prompt to have your agent fix this:
In file apps/server/src/calendar/Layers/CalendarService.ts around lines 7-12:

`runGcalcli` interpolates `args` directly into a shell command string passed to `execSync`, so malicious input like `"; rm -rf /"` executes arbitrary shell commands. Pass `args` as an array to avoid shell interpretation.

Evidence trail:
apps/server/src/calendar/Layers/CalendarService.ts lines 7-10: `runGcalcli` function definition showing `execSync(\`gcalcli ${args}\`, ...)` pattern. Line 44 shows `runGcalcli(\`agenda ${dateArg} --tsv\`)` where `dateArg` comes from the `date` function parameter.


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 };
}),
}),
);
12 changes: 12 additions & 0 deletions apps/server/src/calendar/Services/CalendarService.ts
Original file line number Diff line number Diff line change
@@ -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<ReadonlyArray<CalendarEvent>, CalendarError>;
readonly meetingPrep: (input: { title: string; start: string }) => Effect.Effect<{ notes: string }, CalendarError>;
}

export class CalendarService extends ServiceMap.Service<CalendarService, CalendarServiceShape>()(
"t3/calendar/Services/CalendarService",
) {}
17 changes: 17 additions & 0 deletions apps/server/src/git/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationE
}
}

/**
* BitbucketApiError - Bitbucket Cloud REST API call failed.
*/
export class BitbucketApiError extends Schema.TaggedErrorClass<BitbucketApiError>()(
"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.
*/
Expand All @@ -64,4 +80,5 @@ export type GitManagerServiceError =
| GitManagerError
| GitCommandError
| GitHubCliError
| BitbucketApiError
| TextGenerationError;
278 changes: 278 additions & 0 deletions apps/server/src/git/Layers/BitbucketApi.ts
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low Layers/BitbucketApi.ts:21

readNetrcCredentials skips the rest of the line after finding machine bitbucket.org, so single-line netrc entries like machine bitbucket.org login user password pass are parsed as having no credentials. Consider processing the current line for login and password tokens before moving to the next line.

🤖 Copy this AI Prompt to have your agent fix this:
In file apps/server/src/git/Layers/BitbucketApi.ts around line 21:

`readNetrcCredentials` skips the rest of the line after finding `machine bitbucket.org`, so single-line netrc entries like `machine bitbucket.org login user password pass` are parsed as having no credentials. Consider processing the current line for `login` and `password` tokens before moving to the next line.

Evidence trail:
apps/server/src/git/Layers/BitbucketApi.ts lines 21-53 at REVIEWED_COMMIT. Specifically lines 30-33 show the `continue` statement that skips processing of the current line after finding `machine bitbucket.org`, and lines 37-43 show the login/password parsing that only runs on subsequent lines (not the machine line itself).

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<string, unknown>): BitbucketPullRequestSummary | null {
const id = raw.id;
const title = raw.title;
const state = raw.state;
const links = raw.links as Record<string, unknown> | undefined;
const source = raw.source as Record<string, unknown> | undefined;
const destination = raw.destination as Record<string, unknown> | undefined;

if (typeof id !== "number" || typeof title !== "string" || typeof state !== "string") {
return null;
}

const htmlLink = links?.html as Record<string, unknown> | undefined;
const url = typeof htmlLink?.href === "string" ? htmlLink.href : "";

const sourceBranch = source?.branch as Record<string, unknown> | undefined;
const sourceRefName = typeof sourceBranch?.name === "string" ? sourceBranch.name : "";

const destBranch = destination?.branch as Record<string, unknown> | 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<unknown> {
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<string, unknown>))
.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<string, unknown>))
.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<string, unknown>;
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<string, unknown>;
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);
Loading