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
23 changes: 20 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,32 @@ When implementation work for a user request is done (code, docs, config — not
`fork/changes`.
5. Never assume an earlier PR in the session is still open.

## Discord-originated pull requests
## Discord-originated commits (REQUIRED)

When opening a PR from a Discord thread request, append this footer at the end of the PR description (use the current requester and that thread’s real jump link):
When the Discord turn includes an **Identity map** block with ready-to-paste `Co-authored-by` trailers, attribution is **mandatory**, not optional:

1. Keep the environment default **author/committer** (usually the GitHub App bot).
2. **Every** `git commit` you create for that work MUST end with those exact trailers after a blank line. Do not invent emails for unmapped people.
3. Before `git push` / opening a PR, verify with `git log -1 --format=%B` that the trailers are present on each new commit.
4. A Discord-originated commit **without** the mapped trailers is incomplete — fix it (amend if not pushed, or a follow-up commit is not enough for GitHub multi-author on already-pushed SHAs; amend/rebase when safe).

GitHub multi-author avatars (`bot & human`) come from commit trailers, not from PR body prose alone.

## Discord-originated pull requests (REQUIRED)

When opening or updating a PR from a Discord thread:

1. **Discord footer (required in the PR description).** Append this exact footer form at the end of the PR body (use the **thread starter** when known, otherwise the current requester, and that thread’s real jump link):

```md
opened by [<displayName>](discord_user_id) in chat thread **Discord** · [Thread Title](https://discord.com/channels/<guild_id>/<channel_or_thread_id>/<message_id>)
```

If Discord turn context lists **Linked work items** / Jira issues for the thread, include those Jira issue links in the PR description (and prefer the primary key in the title/branch when one is clear).
Prefer the thread starter’s Discord id/display name from turn context. Do not skip this because the bot _might_ patch the body later — still write it when you create the PR so the first revision is correct. The bot may also hard-append the footer when a PR URL is linked; that is a safety net, not a reason to omit it.

2. If Discord turn context lists **Linked work items** / Jira issues for the thread, include those Jira issue links in the PR description (and prefer the primary key in the title/branch when one is clear).

3. Prefer opening the PR only after commits already include the Identity map `Co-authored-by` trailers (see above).

## Task Completion Requirements

Expand Down
217 changes: 216 additions & 1 deletion apps/discord-bot/src/features/ThreadInfoPin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import {
extractJiraIssueKeysFromDiscordMessage,
mergeJiraIssueKeys,
} from "../presentation/jiraLinks.ts";
import {
buildDiscordThreadJumpUrl,
ensureDiscordPrAttributionFooters,
formatDiscordPrAttributionFooter,
starterDisplayName,
starterUserId,
type DiscordThreadStarterLike,
} from "../presentation/discordPrAttribution.ts";
import {
extractPullRequestUrlsFromDiscordMessage,
mergePullRequestUrls,
Expand All @@ -36,10 +44,22 @@ interface DiscordMessageSummary {
readonly description?: string | null;
readonly footer?: { readonly text?: string | null } | null;
}> | null;
readonly author?: { readonly id?: string; readonly bot?: boolean } | null;
readonly author?: {
readonly id?: string;
readonly bot?: boolean;
readonly username?: string;
readonly global_name?: string | null;
} | null;
readonly timestamp?: string | null;
}

interface DiscordChannelSummary {
readonly id: string;
readonly name?: string | null;
readonly parent_id?: string | null;
readonly owner_id?: string | null;
}

export interface ThreadInfoPinMessageRef {
readonly channelId: string;
readonly messageId: string;
Expand Down Expand Up @@ -196,6 +216,185 @@ const resolveChannelGithubRepoSlug = (input: {
return normalizeGithubRepoSlug(githubUrl);
});

/**
* Load Discord thread starter (public-thread parent message or oldest thread message)
* and the current thread title for hardcoded PR attribution footers.
*/
const loadDiscordThreadAttributionContext = (input: {
readonly discordThreadId: string;
readonly parentChannelId: string | null;
readonly guildId: string;
readonly baseUrl: string;
readonly botToken: string;
}) =>
Effect.gen(function* () {
const channel = yield* Effect.tryPromise({
try: () =>
discordApiJson<DiscordChannelSummary>({
baseUrl: input.baseUrl,
botToken: input.botToken,
path: `/channels/${input.discordThreadId}`,
}),
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed((): DiscordChannelSummary | null => null));

const parentChannelId =
input.parentChannelId ??
(channel?.parent_id !== null && channel?.parent_id !== undefined && channel.parent_id !== ""
? channel.parent_id
: null);

let starter: DiscordThreadStarterLike | null = null;
if (parentChannelId !== null) {
// Public threads created from a message use the starter message id as the thread id.
starter = yield* Effect.tryPromise({
try: () =>
discordApiJson<DiscordMessageSummary>({
baseUrl: input.baseUrl,
botToken: input.botToken,
path: `/channels/${parentChannelId}/messages/${input.discordThreadId}`,
}),
catch: (cause) => cause,
}).pipe(
Effect.map(
(message): DiscordThreadStarterLike => ({
id: message.id,
author: {
id: message.author?.id,
username: message.author?.username,
displayName: message.author?.global_name ?? message.author?.username,
},
}),
),
Effect.orElseSucceed((): DiscordThreadStarterLike | null => null),
);
}

if (starter === null) {
const listed = yield* Effect.tryPromise({
try: () =>
discordApiJson<ReadonlyArray<DiscordMessageSummary>>({
baseUrl: input.baseUrl,
botToken: input.botToken,
path: `/channels/${input.discordThreadId}/messages?limit=5&after=0`,
}),
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed((): ReadonlyArray<DiscordMessageSummary> => []));

const oldest = listed.at(-1) ?? listed[0];
if (oldest !== undefined) {
starter = {
id: oldest.id,
author: {
id: oldest.author?.id,
username: oldest.author?.username,
displayName: oldest.author?.global_name ?? oldest.author?.username,
},
};
}
}

const threadTitle =
channel?.name !== null && channel?.name !== undefined && channel.name.trim() !== ""
? channel.name.trim()
: "Discord thread";

const userId = starterUserId(starter);
if (userId === null) return null;

return {
footer: formatDiscordPrAttributionFooter({
starterDisplayName: starterDisplayName(starter),
starterUserId: userId,
threadTitle,
threadJumpUrl: buildDiscordThreadJumpUrl({
guildId: input.guildId,
discordThreadId: input.discordThreadId,
messageId: starter?.id ?? null,
}),
}),
threadTitle,
starterUserId: userId,
} as const;
});

/**
* When GitHub PR URLs are observed on a Discord-linked thread, hard-append the
* Discord attribution footer using the **thread starter** + thread title.
* Idempotent (skips bodies that already have the footer). Best-effort only.
*/
const ensureAttributionFootersForIncomingPrs = (input: {
readonly discordThreadId: string;
readonly link: ThreadLink | null;
readonly incomingPrUrls: ReadonlyArray<string>;
}) =>
Effect.gen(function* () {
const prUrls = mergePullRequestUrls([], input.incomingPrUrls);
if (prUrls.length === 0) return;

const links = yield* ThreadLinkStore;
const link = input.link ?? (yield* links.getByDiscordThreadId(input.discordThreadId));
if (link === null) {
yield* Effect.logWarning("Skipping Discord PR attribution footer: no thread link", {
discordThreadId: input.discordThreadId,
prCount: prUrls.length,
});
return;
}

const discordConfig = yield* DiscordConfig.DiscordConfig;
const botToken = Redacted.value(discordConfig.token);
const baseUrl = discordConfig.rest.baseUrl;

const attribution = yield* loadDiscordThreadAttributionContext({
discordThreadId: input.discordThreadId,
parentChannelId: link.channelId,
guildId: link.guildId,
baseUrl,
botToken,
});
if (attribution === null) {
yield* Effect.logWarning("Skipping Discord PR attribution footer: no thread starter", {
discordThreadId: input.discordThreadId,
prCount: prUrls.length,
});
return;
}

const results = yield* Effect.tryPromise({
try: () =>
ensureDiscordPrAttributionFooters({
prUrls,
footer: attribution.footer,
}),
catch: (cause) => cause,
}).pipe(
Effect.catch((error) =>
Effect.logWarning("Discord PR attribution footer ensure failed", {
discordThreadId: input.discordThreadId,
error: String(error),
}).pipe(Effect.as([] as const)),
),
);

for (const result of results) {
if (result.status === "updated") {
yield* Effect.logInfo("Appended Discord PR attribution footer", {
discordThreadId: input.discordThreadId,
prUrl: result.url,
threadTitle: attribution.threadTitle,
starterUserId: attribution.starterUserId,
});
} else if (result.status === "error") {
yield* Effect.logWarning("Failed to append Discord PR attribution footer", {
discordThreadId: input.discordThreadId,
prUrl: result.url,
detail: result.detail ?? null,
});
}
}
});

/**
* Create or update the pinned thread-info message and ensure it stays pinned.
*/
Expand Down Expand Up @@ -359,6 +558,22 @@ export const upsertThreadInfoPin = (input: {
}
}

// Hardcode Discord PR attribution (thread starter + title) — no agent prompt.
// Only runs for *incoming* PR URLs this call (pin refresh with empty incoming is a no-op).
// ensureDiscordPrAttributionFooters is idempotent if the footer is already present.
yield* ensureAttributionFootersForIncomingPrs({
discordThreadId: input.discordThreadId,
link: existing,
incomingPrUrls: input.incomingPrUrls ?? [],
}).pipe(
Effect.catch((error) =>
Effect.logWarning("Discord PR attribution side-effect failed", {
discordThreadId: input.discordThreadId,
error: String(error),
}),
),
);

const nextModelLine =
input.modelSelection === null || input.modelSelection === undefined
? null
Expand Down
73 changes: 73 additions & 0 deletions apps/discord-bot/src/identityMap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
formatCoAuthoredByTrailer,
formatIdentityAttributionBlock,
loadIdentityMapFromFileSync,
makeRefreshingIdentityMapStore,
parseIdentityMapDocument,
parseSimpleIdentityYaml,
resolveGitHubCoAuthorEmail,
Expand Down Expand Up @@ -248,3 +249,75 @@ describe("loadIdentityMapFromFileSync", () => {
expect(people[0]?.github?.id).toBe("2");
});
});

describe("makeRefreshingIdentityMapStore", () => {
it("reloads after the TTL expires and keeps the prior map on load failure", async () => {
const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-identity-ttl-"));
const path = NodePath.join(dir, "identity-map.json");
await NodeFSP.writeFile(
path,
JSON.stringify({
people: [
{
name: "A",
discord: { id: "1" },
github: { login: "a", id: "11" },
},
],
}),
"utf8",
);

let now = 1_000_000;
let loads = 0;
const store = makeRefreshingIdentityMapStore({
filePath: path,
ttlMs: 60_000,
now: () => now,
load: (p) => {
loads += 1;
return loadIdentityMapFromFileSync(p);
},
});

expect(store.list()).toHaveLength(1);
expect(store.resolveByDiscordId("1")?.name).toBe("A");
expect(loads).toBe(1);

// Within TTL — no reload
now += 30_000;
expect(store.list()).toHaveLength(1);
expect(loads).toBe(1);

// After TTL — pick up new file contents
await NodeFSP.writeFile(
path,
JSON.stringify({
people: [
{
name: "A",
discord: { id: "1" },
github: { login: "a", id: "11" },
},
{
name: "Davide Di Pumpo",
discord: { id: "150802733316702208" },
github: { login: "MakhBeth", id: "2373426" },
},
],
}),
"utf8",
);
now += 60_000;
expect(store.list()).toHaveLength(2);
expect(store.resolveByDiscordId("150802733316702208")?.github?.login).toBe("MakhBeth");
expect(loads).toBe(2);

// Corrupt file after TTL — keep last good snapshot
await NodeFSP.writeFile(path, "{ not valid json", "utf8");
now += 60_000;
expect(store.list()).toHaveLength(2);
expect(store.resolveByDiscordId("150802733316702208")?.name).toBe("Davide Di Pumpo");
expect(loads).toBe(3);
});
});
Loading