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
12 changes: 12 additions & 0 deletions apps/server/src/identity/IdentityService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const people = [
personId: "patroza",
username: "patroza",
name: "Patrick Roza",
jira: { accountId: "712020:pat-account" },
},
{
personId: "julius",
Expand Down Expand Up @@ -119,6 +120,17 @@ describe("IdentityService", () => {
}).pipe(Effect.provide(TestLayer)),
);

it.effect("resolves mapped Jira account ids and reports map enabled", () =>
Effect.gen(function* () {
const identity = yield* IdentityService.IdentityService;
expect(yield* identity.isMapEnabled()).toBe(true);
const hit = yield* identity.resolveByJiraAccountId("accountid:712020:PAT-ACCOUNT");
expect(hit?.username).toBe("patroza");
const miss = yield* identity.resolveByJiraAccountId("712020:stranger");
expect(miss).toBeNull();
}).pipe(Effect.provide(TestLayer)),
);

it.effect("non-bot sessions still require a claim when map is enabled", () =>
Effect.gen(function* () {
const identity = yield* IdentityService.IdentityService;
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/identity/IdentityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from "@t3tools/contracts";
import {
parseIdentityMapDocument,
resolvePersonByJiraAccountId,
toIdentityPersonPublic,
type IdentityMapPerson,
IdentityMapParseError,
Expand Down Expand Up @@ -84,6 +85,15 @@ export class IdentityService extends Context.Service<
readonly clientDeviceType?: AuthClientMetadataDeviceType;
},
) => Effect.Effect<SessionIdentityClaim | null, IdentityError>;
/**
* Resolve a closed-set person from a Jira actor accountId.
* Returns null when the map is off, accountId is missing, or unmapped.
*/
readonly resolveByJiraAccountId: (
accountId: string | null | undefined,
) => Effect.Effect<IdentityMapPerson | null>;
/** True when T3_IDENTITY_MAP_PATH loaded at least one person. */
readonly isMapEnabled: () => Effect.Effect<boolean>;
}
>()("t3/identity/IdentityService") {}

Expand Down Expand Up @@ -247,6 +257,11 @@ function makeService(
}
return toPublicClaim(existing);
}),

resolveByJiraAccountId: (accountId) =>
Effect.succeed(enabled ? resolvePersonByJiraAccountId(people, accountId) : null),

isMapEnabled: () => Effect.succeed(enabled),
};
}

Expand Down
148 changes: 136 additions & 12 deletions apps/server/src/jira/JiraIssueBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
ProjectId,
ThreadId,
type OrchestrationThread,
type SourceRef,
type TurnId,
} from "@t3tools/contracts";
import type { IdentityMapPerson } from "@t3tools/shared/identityMap";
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -39,7 +41,13 @@ import {
resolveT3ProjectIdForJiraKey,
} from "./JiraAppConfig.ts";
import { JiraDeliveryStore, type StoredJiraDelivery } from "./JiraDeliveryStore.ts";
import { resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts";
import { resolveDiscordLinkForJiraIssue, resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts";
import { classifyJiraActorTrust, type JiraActorTrustDecision } from "./jiraActorTrust.ts";
import {
formatDiscordJiraContextNote,
postDiscordChannelMessage,
resolveDiscordBotToken,
} from "./jiraDiscordContext.ts";
import { buildJiraTurnPrompt, type JiraIssueInvocation } from "./JiraWebhookPayload.ts";

const NOT_LINKED_RESPONSE =
Expand All @@ -56,8 +64,34 @@ const EMPTY_PROMPT_RESPONSE =
"Provide a prompt after the mention (for example: `@omegent investigate the packing failure`).";
const CREATE_FAILED_RESPONSE =
"T3 could not create a thread for this Jira issue. Check server logs or link an existing thread.";
const CONTEXT_UNLINKED_RESPONSE =
"Your Jira account is not in the T3 identity map, so this is context-only — and there is no Discord thread linked to this issue yet. A trusted operator needs to open a Discord-linked thread first; then untrusted mentions can post context there.";
const CONTEXT_AMBIGUOUS_RESPONSE =
"Your Jira account is not in the T3 identity map (context-only), but multiple Discord threads are linked to this issue, so the bot could not pick which one to use.";
const CONTEXT_NOTED_RESPONSE =
"Noted as **context only** on the linked Discord thread (no agent run). Your Jira account is not in the T3 identity map; a trusted operator can act on it.";
const CONTEXT_FAILED_RESPONSE =
"Could not post context to the linked Discord thread (bot token missing or Discord API error). Ask a trusted operator to check the bot config.";
const CONTEXT_NO_LINKS_PATH_RESPONSE =
"Your Jira account is not in the T3 identity map (context-only), but Discord links are not configured on this server (`T3CODE_JIRA_DISCORD_LINKS_PATH`).";
const MAX_JIRA_COMMENT_LENGTH = 32_000;

function jiraSourceRef(
invocation: JiraIssueInvocation,
people: ReadonlyArray<IdentityMapPerson>,
): SourceRef {
return buildIntegrationSourceRef({
people,
channel: "jira",
platformId: invocation.actorAccountId,
displayName: invocation.actorDisplayName,
location: {
...(invocation.projectKey ? { projectKey: invocation.projectKey } : {}),
issueKey: invocation.issueKey,
},
});
}

export function formatJiraComment(body: string): string {
const trimmed = body.trim();
if (trimmed.length <= MAX_JIRA_COMMENT_LENGTH) return trimmed;
Expand Down Expand Up @@ -96,6 +130,17 @@ const make = Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
const createLock = yield* Semaphore.make(1);

const resolveActorTrust = (invocation: JiraIssueInvocation) =>
Effect.gen(function* () {
const people = yield* identity.listMapPeople();
const mapEnabled = yield* identity.isMapEnabled();
return classifyJiraActorTrust({
identityMapEnabled: mapEnabled,
actorAccountId: invocation.actorAccountId,
people,
}) satisfies JiraActorTrustDecision;
});

/**
* Post a bridge response as a **threaded reply** when possible.
* Uses delivery.replyToCommentId (thread root, or the mention itself when top-level).
Expand Down Expand Up @@ -453,12 +498,98 @@ const make = Effect.gen(function* () {
return;
}

const trust = yield* resolveActorTrust(input.invocation);
yield* Effect.logInfo("Classified Jira actor trust", {
deliveryId: input.deliveryId,
issueKey: input.invocation.issueKey,
actorAccountId: input.invocation.actorAccountId,
mode: trust.mode,
reason: trust.reason,
personId: trust.person?.personId ?? null,
});

const link = yield* resolveLinkedThreadId(input.invocation.issueKey);
if (link._tag === "ambiguous") {
yield* finishDelivery(acknowledged, AMBIGUOUS_RESPONSE, "rejected");
return;
}

// Untrusted actors: Discord context only (no agent, no T3 transcript write).
// Requires a unique Discord-linked issue in links.json. Never auto-creates.
if (trust.mode === "context-only") {
const linksPath = config.discordLinksPath;
if (linksPath === null || linksPath.length === 0) {
yield* finishDelivery(acknowledged, CONTEXT_NO_LINKS_PATH_RESPONSE, "rejected");
return;
}
const linksRaw = yield* fileSystem
.readFileString(linksPath)
.pipe(Effect.orElseSucceed(() => ""));
const discordLink = resolveDiscordLinkForJiraIssue({
issueKey: input.invocation.issueKey,
linksJson: linksRaw,
});
if (discordLink._tag === "unlinked") {
yield* finishDelivery(acknowledged, CONTEXT_UNLINKED_RESPONSE, "rejected");
return;
}
if (discordLink._tag === "ambiguous") {
yield* finishDelivery(acknowledged, CONTEXT_AMBIGUOUS_RESPONSE, "rejected");
return;
}

const token = yield* Effect.promise(() => resolveDiscordBotToken());
if (token === null) {
yield* finishDelivery(acknowledged, CONTEXT_FAILED_RESPONSE, "rejected");
return;
}

const requester =
input.invocation.actorDisplayName ?? input.invocation.actorAccountId ?? "unknown";
const content = formatDiscordJiraContextNote({
issueKey: input.invocation.issueKey,
requester,
prompt: input.invocation.prompt,
commentUrl: input.invocation.commentUrl,
});
const posted = yield* Effect.promise(() =>
postDiscordChannelMessage({
token,
channelId: discordLink.discordThreadId,
content,
})
.then((message) => ({ _tag: "ok" as const, message }))
.catch((cause) => ({ _tag: "err" as const, cause })),
);
if (posted._tag === "err") {
yield* Effect.logError("Failed to post Jira context-only note to Discord", {
deliveryId: input.deliveryId,
issueKey: input.invocation.issueKey,
discordThreadId: discordLink.discordThreadId,
cause: posted.cause,
});
yield* finishDelivery(acknowledged, CONTEXT_FAILED_RESPONSE, "rejected");
return;
}

yield* Effect.logInfo("Posted Jira context-only note to Discord (no agent run)", {
deliveryId: input.deliveryId,
issueKey: input.invocation.issueKey,
discordThreadId: discordLink.discordThreadId,
t3ThreadId: discordLink.t3ThreadId,
discordMessageId: posted.message.id,
});
const notedDelivery: StoredJiraDelivery = {
...acknowledged,
threadId:
discordLink.t3ThreadId !== null
? (discordLink.t3ThreadId as ThreadId)
: acknowledged.threadId,
};
yield* finishDelivery(notedDelivery, CONTEXT_NOTED_RESPONSE, "completed");
return;
}

let thread: OrchestrationThread;
if (link._tag === "linked") {
const snapshot = yield* projection
Expand All @@ -480,7 +611,7 @@ const make = Effect.gen(function* () {
thread = snapshot.value;
}
} else {
// unlinked — join-or-create
// unlinked — join-or-create (trusted actors only)
if (!config.enabled || !config.autoCreateThread) {
yield* finishDelivery(acknowledged, CREATE_DISABLED_RESPONSE, "rejected");
return;
Expand Down Expand Up @@ -525,19 +656,12 @@ const make = Effect.gen(function* () {
threadId: thread.id,
issueKey: input.invocation.issueKey,
userMessageId: messageId,
trustMode: trust.mode,
personId: trust.person?.personId ?? null,
});

const mapPeople = yield* identity.listMapPeople();
const source = buildIntegrationSourceRef({
people: mapPeople,
channel: "jira",
platformId: input.invocation.actorAccountId,
displayName: input.invocation.actorDisplayName,
location: {
...(input.invocation.projectKey ? { projectKey: input.invocation.projectKey } : {}),
issueKey: input.invocation.issueKey,
},
});
const source = jiraSourceRef(input.invocation, mapPeople);
const dispatched = yield* engine
.dispatch({
type: "thread.turn.start",
Expand Down
79 changes: 66 additions & 13 deletions apps/server/src/jira/JiraThreadLookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*
* Preferred resolution is the server-native {@link ThreadWorkItemStore}. This helper remains
* for migration/fallback when Discord still holds associations that have not been imported yet.
*
* Discord destinations (for untrusted context notes) also come from the same links.json.
*/

import type { ThreadId } from "@t3tools/contracts";
Expand All @@ -11,6 +13,8 @@ import * as Schema from "effect/Schema";
const DiscordThreadLink = Schema.Struct({
discordThreadId: Schema.optional(Schema.String),
t3ThreadId: Schema.String,
channelId: Schema.optional(Schema.String),
guildId: Schema.optional(Schema.String),
status: Schema.optional(Schema.String),
jiraIssueKeys: Schema.optional(Schema.Array(Schema.String)),
});
Expand All @@ -27,26 +31,47 @@ export type JiraThreadLookupResult =
| { readonly _tag: "ambiguous"; readonly threadIds: ReadonlyArray<ThreadId> }
| { readonly _tag: "linked"; readonly threadId: ThreadId };

export function resolveThreadIdForJiraIssue(input: {
readonly issueKey: string;
readonly linksJson: string;
}): JiraThreadLookupResult {
const issueKey = input.issueKey.trim().toUpperCase();
if (issueKey.length === 0) return { _tag: "unlinked" };
export type JiraDiscordLinkLookupResult =
| { readonly _tag: "unlinked" }
| {
readonly _tag: "ambiguous";
readonly discordThreadIds: ReadonlyArray<string>;
}
| {
readonly _tag: "linked";
readonly discordThreadId: string;
readonly t3ThreadId: string | null;
readonly channelId: string | null;
readonly guildId: string | null;
};

function activeLinksWithIssue(
linksJson: string,
issueKeyRaw: string,
): ReadonlyArray<typeof DiscordThreadLink.Type> {
const issueKey = issueKeyRaw.trim().toUpperCase();
if (issueKey.length === 0) return [];

let links: ReadonlyArray<typeof DiscordThreadLink.Type>;
try {
links = decodeLinksFile(input.linksJson).links;
links = decodeLinksFile(linksJson).links;
} catch {
return { _tag: "unlinked" };
return [];
}

const matches = new Set<string>();
for (const link of links) {
if (link.status !== undefined && link.status !== "active") continue;
return links.filter((link) => {
if (link.status !== undefined && link.status !== "active") return false;
const keys = link.jiraIssueKeys ?? [];
const hit = keys.some((key) => key.trim().toUpperCase() === issueKey);
if (!hit) continue;
return keys.some((key) => key.trim().toUpperCase() === issueKey);
});
}

export function resolveThreadIdForJiraIssue(input: {
readonly issueKey: string;
readonly linksJson: string;
}): JiraThreadLookupResult {
const matches = new Set<string>();
for (const link of activeLinksWithIssue(input.linksJson, input.issueKey)) {
const threadId = link.t3ThreadId.trim();
if (threadId.length > 0) matches.add(threadId);
}
Expand All @@ -61,3 +86,31 @@ export function resolveThreadIdForJiraIssue(input: {
const [only] = matches;
return { _tag: "linked", threadId: only as ThreadId };
}

/**
* Resolve the Discord thread to post untrusted Jira context into.
* Requires a unique active links.json row with both the issue key and a discordThreadId.
*/
export function resolveDiscordLinkForJiraIssue(input: {
readonly issueKey: string;
readonly linksJson: string;
}): JiraDiscordLinkLookupResult {
const withDiscord = activeLinksWithIssue(input.linksJson, input.issueKey).filter(
(link) => (link.discordThreadId?.trim().length ?? 0) > 0,
);
if (withDiscord.length === 0) return { _tag: "unlinked" };
if (withDiscord.length > 1) {
return {
_tag: "ambiguous",
discordThreadIds: withDiscord.map((link) => link.discordThreadId!.trim()),
};
}
const only = withDiscord[0]!;
return {
_tag: "linked",
discordThreadId: only.discordThreadId!.trim(),
t3ThreadId: only.t3ThreadId.trim() || null,
channelId: only.channelId?.trim() || null,
guildId: only.guildId?.trim() || null,
};
}
Loading
Loading