From 87e44de1d61cfc7d7604598ef6db233c553b367d Mon Sep 17 00:00:00 2001 From: Stack Test Date: Mon, 3 Aug 2026 09:37:53 +0200 Subject: [PATCH] feat(server): include T3 thread links in Jira replies - Append deduplicated hosted thread deep links to Jira comments - Add coverage for link construction and footer behavior --- apps/server/src/jira/JiraIssueBridge.ts | 54 +++++++++++++++++++++++- apps/server/src/jira/JiraWebhook.test.ts | 33 ++++++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/apps/server/src/jira/JiraIssueBridge.ts b/apps/server/src/jira/JiraIssueBridge.ts index f665d76777c8..3f59e4fb1389 100644 --- a/apps/server/src/jira/JiraIssueBridge.ts +++ b/apps/server/src/jira/JiraIssueBridge.ts @@ -1,12 +1,14 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, + type EnvironmentId, MessageId, ProjectId, ThreadId, type OrchestrationThread, type TurnId, } from "@t3tools/contracts"; +import { buildAgentAwarenessDeepLink } from "@t3tools/shared/agentAwareness"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -17,6 +19,8 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; +import { hostedAppUrlConfig } from "../cloud/publicConfig.ts"; +import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; import { discoverGitHubTargetTurnId, githubFinalAnswerWithStats, @@ -62,6 +66,40 @@ export function formatJiraComment(body: string): string { return `${trimmed.slice(0, MAX_JIRA_COMMENT_LENGTH - 20)}\n\n…(truncated)`; } +export function buildJiraT3ThreadUrl(input: { + readonly hostedAppUrl: string; + readonly environmentId: string; + readonly threadId: string; +}): string { + return new URL( + buildAgentAwarenessDeepLink({ + environmentId: input.environmentId as EnvironmentId, + threadId: input.threadId as ThreadId, + }), + input.hostedAppUrl, + ).toString(); +} + +export function appendJiraT3ThreadLink( + body: string, + input: + | { + readonly hostedAppUrl: string; + readonly environmentId: string; + readonly threadId: string; + } + | null + | undefined, +): string { + const base = body.trimEnd(); + if (input === null || input === undefined) return base; + const url = buildJiraT3ThreadUrl(input); + if (base.includes(url)) return base; + const footer = `T3 thread: ${url}`; + if (base === "") return footer; + return `${base}\n\n${footer}`; +} + function isThreadBusy(thread: OrchestrationThread): boolean { const latest = thread.latestTurn; if (latest?.state === "running") return true; @@ -89,9 +127,12 @@ const make = Effect.gen(function* () { const jira = yield* JiraAppClient; const engine = yield* OrchestrationEngineService; const projection = yield* ProjectionSnapshotQuery; + const serverEnvironment = yield* ServerEnvironment; const fileSystem = yield* FileSystem.FileSystem; const crypto = yield* Crypto.Crypto; const createLock = yield* Semaphore.make(1); + const hostedAppUrl = yield* hostedAppUrlConfig; + const environmentId = yield* serverEnvironment.getEnvironmentId; /** * Post a bridge response as a **threaded reply** when possible. @@ -101,7 +142,18 @@ const make = Effect.gen(function* () { const postComment = (delivery: StoredJiraDelivery, body: string) => jira.addIssueComment({ issueKey: delivery.issueKey, - body: formatJiraComment(body), + body: formatJiraComment( + appendJiraT3ThreadLink( + body, + delivery.threadId === null + ? null + : { + hostedAppUrl, + environmentId, + threadId: delivery.threadId, + }, + ), + ), parentCommentId: delivery.replyToCommentId || delivery.sourceCommentId || null, }); diff --git a/apps/server/src/jira/JiraWebhook.test.ts b/apps/server/src/jira/JiraWebhook.test.ts index fc98198a3c66..ada282ed5cd4 100644 --- a/apps/server/src/jira/JiraWebhook.test.ts +++ b/apps/server/src/jira/JiraWebhook.test.ts @@ -7,7 +7,11 @@ import { resolveMappedT3ProjectId, resolveT3ProjectIdForJiraKey, } from "./JiraAppConfig.ts"; -import { formatJiraComment } from "./JiraIssueBridge.ts"; +import { + appendJiraT3ThreadLink, + buildJiraT3ThreadUrl, + formatJiraComment, +} from "./JiraIssueBridge.ts"; import { resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; import { classifyWebhookBodyFailure, @@ -423,6 +427,33 @@ describe("Jira helpers", () => { expect(resolveT3ProjectIdForJiraKey(map, "OTHER", projects)).toBeNull(); }); + it("builds and appends a hosted T3 thread link for Jira replies", () => { + const url = buildJiraT3ThreadUrl({ + hostedAppUrl: "https://nightly.app.t3.codes", + environmentId: "env-1", + threadId: "thread-1", + }); + expect(url).toBe("https://nightly.app.t3.codes/threads/env-1/thread-1"); + expect( + appendJiraT3ThreadLink("Ship it.", { + hostedAppUrl: "https://nightly.app.t3.codes", + environmentId: "env-1", + threadId: "thread-1", + }), + ).toBe("Ship it.\n\nT3 thread: https://nightly.app.t3.codes/threads/env-1/thread-1"); + }); + + it("does not duplicate the Jira T3 thread link footer", () => { + const body = "Ship it.\n\nT3 thread: https://nightly.app.t3.codes/threads/env-1/thread-1"; + expect( + appendJiraT3ThreadLink(body, { + hostedAppUrl: "https://nightly.app.t3.codes", + environmentId: "env-1", + threadId: "thread-1", + }), + ).toBe(body); + }); + it("bodyMentionsIdentity distinguishes misses", () => { expect(bodyMentionsIdentity("hello world", "omegent").matched).toBe(false); expect(bodyMentionsIdentity("@omegent please", "omegent").matched).toBe(true);