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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 145 additions & 11 deletions apps/server/src/jira/JiraAppClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ export class JiraAppClient extends Context.Service<
readonly issueKey: string;
readonly body: string;
}) => Effect.Effect<{ readonly id: string } | null, never>;
/**
* Best-effort reaction on a comment (👀). Jira Cloud support varies; returns the emoji id
* when the site accepted the reaction, otherwise null.
*/
readonly addCommentReaction: (input: {
readonly issueKey: string;
readonly commentId: string;
readonly emojiId: string;
}) => Effect.Effect<string | null, never>;
readonly removeCommentReaction: (input: {
readonly issueKey: string;
readonly commentId: string;
readonly emojiId: string;
}) => Effect.Effect<void, never>;
}
>()("t3/jira/JiraAppClient") {}

Expand All @@ -25,24 +39,29 @@ export const make = Effect.gen(function* () {
const config = yield* JiraAppConfig;
const httpClient = yield* HttpClient.HttpClient;

const authorize = (request: HttpClientRequest.HttpClientRequest) => {
if (!config.enabled) return request;
if (config.authMode === "bearer") {
return request.pipe(HttpClientRequest.bearerToken(config.apiToken));
}
const token = Buffer.from(`${config.username}:${config.apiToken}`, "utf8").toString("base64");
return request.pipe(HttpClientRequest.setHeader("authorization", `Basic ${token}`));
};

const addIssueComment = Effect.fn("JiraAppClient.addIssueComment")(function* (input: {
readonly issueKey: string;
readonly body: string;
}) {
if (!config.enabled) return null;

const url = `${config.baseUrl}/rest/api/3/issue/${encodeURIComponent(input.issueKey)}/comment`;
let request = HttpClientRequest.post(url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.setHeader("user-agent", "t3-code-jira-bridge"),
HttpClientRequest.bodyJsonUnsafe({ body: plainTextToAdf(input.body) }),
const request = authorize(
HttpClientRequest.post(url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.setHeader("user-agent", "t3-code-jira-bridge"),
HttpClientRequest.bodyJsonUnsafe({ body: plainTextToAdf(input.body) }),
),
);
if (config.authMode === "bearer") {
request = request.pipe(HttpClientRequest.bearerToken(config.apiToken));
} else {
const token = Buffer.from(`${config.username}:${config.apiToken}`, "utf8").toString("base64");
request = request.pipe(HttpClientRequest.setHeader("authorization", `Basic ${token}`));
}

const response = yield* httpClient.execute(request).pipe(
Effect.tapError((cause) =>
Expand Down Expand Up @@ -80,7 +99,122 @@ export const make = Effect.gen(function* () {
});
});

return JiraAppClient.of({ addIssueComment });
/**
* Try a few known reaction endpoint shapes. Official Jira Cloud REST still lacks a stable
* public "react to comment" resource; UI uses internal services. Best-effort only.
*/
const addCommentReaction = Effect.fn("JiraAppClient.addCommentReaction")(function* (input: {
readonly issueKey: string;
readonly commentId: string;
readonly emojiId: string;
}) {
if (!config.enabled) return null;
const emojiId = input.emojiId.trim();
if (emojiId.length === 0) return null;

const candidates = [
{
url: `${config.baseUrl}/rest/api/3/comment/${encodeURIComponent(input.commentId)}/reactions`,
body: { emojiId } as Record<string, unknown>,
},
{
url: `${config.baseUrl}/rest/api/3/issue/${encodeURIComponent(input.issueKey)}/comment/${encodeURIComponent(input.commentId)}/reactions`,
body: { emojiId } as Record<string, unknown>,
},
{
// Some deployments accept the unicode / shortname form.
url: `${config.baseUrl}/rest/api/3/comment/${encodeURIComponent(input.commentId)}/reactions`,
body: { emoji: "👀" } as Record<string, unknown>,
},
];

for (const candidate of candidates) {
const request = authorize(
HttpClientRequest.post(candidate.url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.setHeader("user-agent", "t3-code-jira-bridge"),
HttpClientRequest.bodyJsonUnsafe(candidate.body),
),
);
const response = yield* httpClient.execute(request).pipe(Effect.orElseSucceed(() => null));
if (response === null) continue;
const ok = yield* HttpClientResponse.matchStatus(response, {
"2xx": () => Effect.succeed(true),
orElse: (failed) =>
Effect.gen(function* () {
if (failed.status === 404 || failed.status === 405) return false;
const detail = yield* failed.text.pipe(Effect.orElseSucceed(() => ""));
yield* Effect.logDebug("Jira comment reaction attempt rejected", {
issueKey: input.issueKey,
commentId: input.commentId,
status: failed.status,
detail: detail.slice(0, 200),
});
return false;
}),
});
if (ok) {
yield* Effect.logInfo("Added Jira comment acknowledgment reaction", {
issueKey: input.issueKey,
commentId: input.commentId,
emojiId,
});
return emojiId;
}
}

yield* Effect.logWarning(
"Jira comment reaction unsupported on this site; continuing without ack reaction",
{
issueKey: input.issueKey,
commentId: input.commentId,
emojiId,
},
);
return null;
});

const removeCommentReaction = Effect.fn("JiraAppClient.removeCommentReaction")(function* (input: {
readonly issueKey: string;
readonly commentId: string;
readonly emojiId: string;
}) {
if (!config.enabled) return;
const emojiId = input.emojiId.trim();
if (emojiId.length === 0) return;

const candidates = [
`${config.baseUrl}/rest/api/3/comment/${encodeURIComponent(input.commentId)}/reactions/${encodeURIComponent(emojiId)}`,
`${config.baseUrl}/rest/api/3/issue/${encodeURIComponent(input.issueKey)}/comment/${encodeURIComponent(input.commentId)}/reactions/${encodeURIComponent(emojiId)}`,
`${config.baseUrl}/rest/api/3/comment/${encodeURIComponent(input.commentId)}/reactions/1f440`,
];

for (const url of candidates) {
const request = authorize(
HttpClientRequest.delete(url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.setHeader("user-agent", "t3-code-jira-bridge"),
),
);
const response = yield* httpClient.execute(request).pipe(Effect.orElseSucceed(() => null));
if (response === null) continue;
const ok = yield* HttpClientResponse.matchStatus(response, {
"2xx": () => Effect.succeed(true),
"204": () => Effect.succeed(true),
orElse: () => Effect.succeed(false),
});
if (ok) {
yield* Effect.logInfo("Removed Jira comment acknowledgment reaction", {
issueKey: input.issueKey,
commentId: input.commentId,
emojiId,
});
return;
}
}
});

return JiraAppClient.of({ addIssueComment, addCommentReaction, removeCommentReaction });
});

export const layer = Layer.effect(JiraAppClient, make);
110 changes: 108 additions & 2 deletions apps/server/src/jira/JiraAppConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,25 @@ export interface EnabledJiraAppConfig {
readonly botAccountId: string | null;
readonly turnTimeoutMs: number;
/**
* When set, unlinked Jira mentions create a new T3 thread on this project id
* (join-or-create). When null, auto-create only if the shell has exactly one project.
* When set, unlinked Jira mentions create a new T3 thread on this project
* (join-or-create) when the Jira project key is not in projectMap.
* Accepts T3 project id, title, workspace basename, or absolute workspace root.
* When null and no map hit, auto-create only if the shell has exactly one project.
*/
readonly defaultProjectId: string | null;
/**
* Map Jira project keys (e.g. SA) → T3 project id, title, workspace basename, or
* absolute workspace root.
* From `T3CODE_JIRA_PROJECT_MAP=SA:scanner,CFG:/var/lib/t3/src/macs/configurator`.
*/
readonly projectMap: ReadonlyMap<string, string>;
/** When false, unlinked issues still get "not yet linked." Default true. */
readonly autoCreateThread: boolean;
/**
* Emoji id for the acknowledgment reaction on the triggering comment (👀 = 1f440).
* Empty disables reactions. Best-effort — site/API support varies.
*/
readonly ackEmojiId: string | null;
}

export interface DisabledJiraAppConfig {
Expand Down Expand Up @@ -69,9 +82,11 @@ const configEffect = Effect.gen(function* () {
Config.withDefault(30 * 60_000),
),
defaultProjectId: optionalString("T3CODE_JIRA_DEFAULT_PROJECT_ID"),
projectMap: Config.string("T3CODE_JIRA_PROJECT_MAP").pipe(Config.withDefault("")),
autoCreateThread: Config.boolean("T3CODE_JIRA_AUTO_CREATE_THREAD").pipe(
Config.withDefault(true),
),
ackEmojiId: optionalString("T3CODE_JIRA_ACK_EMOJI_ID"),
});

// Prefer T3CODE_* then fall back to shared MCP-style env names.
Expand All @@ -98,6 +113,10 @@ const configEffect = Effect.gen(function* () {
.filter(Boolean),
);
const mention = values.mention!.trim().replace(/^@/u, "");
const ackEmojiRaw = values.ackEmojiId?.trim();
// Default 👀 (eyes). Set empty string to disable.
const ackEmojiId =
ackEmojiRaw === undefined ? "1f440" : ackEmojiRaw.length === 0 ? null : ackEmojiRaw;
return JiraAppConfig.of({
enabled: true,
webhookSecret: values.webhookSecret!,
Expand All @@ -111,7 +130,9 @@ const configEffect = Effect.gen(function* () {
botAccountId: values.botAccountId?.trim() || null,
turnTimeoutMs: Math.max(10_000, values.turnTimeoutMs),
defaultProjectId: values.defaultProjectId?.trim() || null,
projectMap: parseJiraProjectMap(values.projectMap),
autoCreateThread: values.autoCreateThread,
ackEmojiId,
});
});

Expand All @@ -123,3 +144,88 @@ export function isJiraProjectAllowed(
): boolean {
return allowedProjects.size === 0 || allowedProjects.has(projectKey.trim().toUpperCase());
}

/**
* Parse `SA:project-id-or-name,CFG:other` (also accepts `=` separators).
* Keys are uppercased Jira project keys; values are T3 project ids, titles,
* workspace basenames, or absolute workspace roots (paths after the first `:` / `=`).
*/
export function parseJiraProjectMap(raw: string): ReadonlyMap<string, string> {
const map = new Map<string, string>();
for (const part of raw.split(",")) {
const trimmed = part.trim();
if (trimmed.length === 0) continue;
// Prefer first : or = (whichever appears first). Paths keep later colons (rare) intact.
const colon = trimmed.indexOf(":");
const eq = trimmed.indexOf("=");
const idx = colon === -1 ? eq : eq === -1 ? colon : Math.min(colon, eq);
if (idx <= 0) continue;
const key = trimmed.slice(0, idx).trim().toUpperCase();
const value = trimmed.slice(idx + 1).trim();
if (key.length === 0 || value.length === 0) continue;
map.set(key, value);
}
return map;
}

export type ShellProjectRef = {
readonly id: string;
readonly title: string;
readonly workspaceRoot: string;
};

function normalizeWorkspacePath(path: string): string {
return path.trim().replace(/\/+$/u, "").toLowerCase();
}

function workspaceBasename(path: string): string | undefined {
const normalized = path.replace(/\/+$/u, "");
if (normalized.length === 0) return undefined;
return normalized.split("/").pop()?.toLowerCase();
}

/**
* Resolve a T3 project id from a configured value:
* exact project id, project title, absolute workspace root, or workspace basename.
*/
export function resolveMappedT3ProjectId(
mapped: string,
shellProjects: ReadonlyArray<ShellProjectRef>,
): string | null {
const trimmed = mapped.trim();
if (trimmed.length === 0) return null;
if (shellProjects.some((project) => project.id === trimmed)) return trimmed;

const lower = trimmed.toLowerCase();
const byTitle = shellProjects.find((project) => project.title.trim().toLowerCase() === lower);
if (byTitle) return byTitle.id;

const normalizedMapped = normalizeWorkspacePath(trimmed);
const byRoot = shellProjects.find(
(project) => normalizeWorkspacePath(project.workspaceRoot) === normalizedMapped,
);
if (byRoot) return byRoot.id;

// Basename-only when the configured value has no path separators (avoid `…/scanner`
// partially matching a different project whose root ends in `scanner`).
const looksLikePath = trimmed.includes("/") || trimmed.includes("\\");
if (!looksLikePath) {
const byBasename = shellProjects.find(
(project) => workspaceBasename(project.workspaceRoot) === lower,
);
if (byBasename) return byBasename.id;
}

return null;
}

/** Look up T3 project for a Jira project key via the configured map. */
export function resolveT3ProjectIdForJiraKey(
projectMap: ReadonlyMap<string, string>,
jiraProjectKey: string,
shellProjects: ReadonlyArray<ShellProjectRef>,
): string | null {
const mapped = projectMap.get(jiraProjectKey.trim().toUpperCase());
if (mapped === undefined) return null;
return resolveMappedT3ProjectId(mapped, shellProjects);
}
4 changes: 4 additions & 0 deletions apps/server/src/jira/JiraDeliveryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export const JiraDelivery = Schema.Struct({
replyToCommentId: Schema.String,
commentSurface: Schema.Literals(["issue", "reply"]),
responseCommentId: Schema.NullOr(Schema.String),
/** Emoji id for ack reaction on the source comment (e.g. 1f440), when supported. */
acknowledgmentEmojiId: Schema.optional(Schema.NullOr(Schema.String)),
threadId: Schema.NullOr(Schema.String),
previousTurnId: Schema.NullOr(Schema.String),
userMessageId: Schema.NullOr(Schema.String),
Expand All @@ -38,6 +40,7 @@ export type StoredJiraDelivery = {
readonly replyToCommentId: string;
readonly commentSurface: "issue" | "reply";
readonly responseCommentId: string | null;
readonly acknowledgmentEmojiId: string | null;
readonly threadId: ThreadId | null;
readonly previousTurnId: TurnId | null;
readonly userMessageId: string | null;
Expand Down Expand Up @@ -72,6 +75,7 @@ export const make = Effect.gen(function* () {
return decodeDeliveries(raw).map(
(delivery): StoredJiraDelivery => ({
...delivery,
acknowledgmentEmojiId: delivery.acknowledgmentEmojiId ?? null,
threadId: delivery.threadId as ThreadId | null,
previousTurnId: delivery.previousTurnId as TurnId | null,
targetTurnId: delivery.targetTurnId as TurnId | null,
Expand Down
Loading
Loading