From f312917772966a3317b4b6468da7a48d605fee9a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 11 May 2026 18:02:06 +0000 Subject: [PATCH 1/2] feat(create-agent-chat-action): opt-in idempotency by label scoped per user When the `idempotency-key` input is set, the action lists chats filtered by labels matching the sanitized key, `gh-target`, and the resolved Coder user UUID. If a non-archived match exists, the action sends a follow-up message via the chat API instead of creating a duplicate. The chat is created with the same four labels so subsequent runs converge. The per-user scope (`coder-agent-chat-action-user: `) prevents a malicious or accidental shared `idempotency-key` on the same `gh-target` from letting one Coder user hijack another's chat. The `coder-username` input path now eagerly fetches the user via `getCoderUserByUsername` so `user.id` is available; the helper re-throws 404 with `kind: "user_not_found"` so a typo routes through the helpful failure-comment path. The input is sanitized into a chat-label key (regex `^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`, max 64 bytes) via a new `sanitize-label-key` module. Sanitized keys that collide with the reserved set (`coder-agent-chat-action`, `gh-target`, `coder-agent-chat-action-user`) are rejected upstream of any API call. `ListChatsOptions` adds `label` (string or string[]) and `archived` options to the client. Multiple `label` params are ANDed by the chats API. The lookup passes `?q=archived:false` explicitly to pin the contract; client-side double-filters archived for belt-and-braces. Reuse path uses S8's marker-based `commentOnIssue` so the comment body renders with the new `**Coder Agent Chat: message sent**` heading and updates the same marker as create-path comments. Failed lookups propagate with operation context; concurrent triggers can race, the picked-most-recent chat is logged via `core.warning`, and the README documents the known v0 race. --- README.md | 89 +++++- action.yaml | 2 +- src/action.test.ts | 527 +++++++++++++++++++++++++++++++-- src/action.ts | 188 +++++++++++- src/coder-client.ts | 51 +++- src/comment.ts | 3 +- src/sanitize-label-key.test.ts | 40 +++ src/sanitize-label-key.ts | 23 ++ src/test-helpers.ts | 10 +- 9 files changed, 889 insertions(+), 44 deletions(-) create mode 100644 src/sanitize-label-key.test.ts create mode 100644 src/sanitize-label-key.ts diff --git a/README.md b/README.md index 5b84e31..5f4e068 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ other kinds are documented under [Outputs](#outputs). | comment-on-issue | Whether to comment on the GitHub issue or pull request with the chat URL and status. | false | true | | wait | Wait mode. `none` (default) returns immediately. `complete` polls every 5 seconds until the chat reaches a terminal status (`waiting`, `completed`, `error`) or `wait-timeout-seconds` elapses. | false | none | | wait-timeout-seconds | Maximum seconds to wait when wait=complete before failing with a timeout. | false | 600 | -| idempotency-key | Optional key used to deduplicate chats. Reserved; not yet wired, the action emits a warning if set and always creates a new chat. | false | - | +| idempotency-key | Optional key used to deduplicate chats. When set and existing-chat-id is unset, the action looks up the most recent non-archived chat scoped to this `gh-target` and resolved Coder user carrying this label and sends a follow-up message instead of creating a duplicate. | false | - | ## Outputs @@ -270,3 +270,90 @@ pull request maintain separate failure comments. To intentionally share one comment across workflows, set `idempotency-key` to the same value in each workflow. If two workflow files share the same `name:`, their markers will collide; give them distinct names. + +## Idempotency by label + +Re-applying a label or re-running a workflow without `idempotency-key` +set always creates a new chat (parity with `create-task-action`'s default). +Setting `idempotency-key` opts the workflow into label-based +dedup: the action lists chats filtered by the label, and if a non-archived +match exists, sends a follow-up message via the chat API instead of +creating a duplicate. + +### Labels written on chat creation + +When `idempotency-key` is set and no existing chat matches, the action +creates the chat with four labels: + +| Label key | Value | +| ------------------------------ | --------------------------- | +| `coder-agent-chat-action` | `"true"` | +| `gh-target` | `"/#"` | +| `coder-agent-chat-action-user` | `""` | +| `` | `"true"` | + +The label namespace is action-owned. The `` is derived +from the `idempotency-key` input via the rule below. +The `coder-agent-chat-action-user` value is the UUID of the Coder user +resolved from `github-user-id` or `coder-username`, so two GitHub users +sharing an `idempotency-key` on the same target each get their own chat. + +### Sanitization rule + +The Coder chats API requires label keys to match +`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$` (max 64 bytes). The action sanitizes the +`idempotency-key` input as follows so workflow authors can pass +arbitrary strings: + +1. Lowercase the input. +2. Replace any character outside `[a-z0-9._/-]` with `-`. +3. Trim leading characters until the first `[a-z0-9]`. If the result is + empty, fall back to the literal string `key`. +4. Truncate to 64 bytes. + +For example, `My Custom Key!` becomes `my-custom-key-`. + +If the sanitized key collides with one of the reserved label keys +(`coder-agent-chat-action`, `gh-target`, `coder-agent-chat-action-user`), +the action fails fast with a clear error. Choose a different +`idempotency-key` value. + +Note: two different inputs that both sanitize to an empty string (every +character outside `[a-z0-9._/-]` after lowering) collapse to the literal +fallback `key` and share the same idempotency scope per `gh-target`. +The practical risk is small (the input has to contain only special +characters), but pass an `idempotency-key` that contains at least +one `[a-z0-9._/-]` character to avoid the collision. + +The lookup uses the same sanitized key, so the chat the action creates is +the chat the next run finds. + +### Lookup query + +The chats API exposes a `?label=key:value` filter. The action calls +`GET /api/experimental/chats?label=:true&label=gh-target:/#&label=coder-agent-chat-action-user:&q=archived:false` +and reuses the most recent (by `updated_at`) match. All three label +filters are ANDed by the API, which scopes the lookup per target and +per Coder user so a static `idempotency-key` (for example +`"review-bot"`) does not cross-contaminate Issue #2's follow-up into +Issue #1's chat, and User B's run does not hijack User A's chat. +Archived +chats are excluded by the explicit query (and double-checked +client-side); if the only match is archived the action creates a new +chat. + +### Known limitation: parallel triggers race + +If two workflow runs trigger at the same time with the same +`idempotency-key`, both can pass the lookup before either creates +its chat, and both will create. The action picks the most recent match on +subsequent runs and emits a `core.warning` listing the duplicates so the +workflow author can clean up. + +### Future: `Idempotency-Key` header + +When the chats API exposes an `Idempotency-Key` HTTP header on the chat +create endpoint, the action will switch to it to make the create itself +idempotent and remove the race. The label key on the chat survives that +switch; only the lookup mechanism changes. + diff --git a/action.yaml b/action.yaml index 9f3e308..05ab155 100644 --- a/action.yaml +++ b/action.yaml @@ -67,7 +67,7 @@ inputs: default: "600" idempotency-key: - description: "Optional key used to deduplicate chats. Reserved; not yet wired, the action emits a warning if set and always creates a new chat." + description: "Optional key used to deduplicate chats. When set and existing-chat-id is unset, the action looks up the most recent non-archived chat scoped to this `gh-target` and resolved Coder user carrying this label and sends a follow-up message instead of creating a duplicate." required: false outputs: diff --git a/src/action.test.ts b/src/action.test.ts index 7efcc03..f354e59 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -8,6 +8,7 @@ import { } from "./action"; import type { Octokit } from "./action"; import { CoderAPIError } from "./coder-client"; +import { ChatIdSchema, type CoderSDKUser } from "./coder-client"; import { ActionOutputsSchema } from "./schemas"; import { MockCoderClient, @@ -729,29 +730,6 @@ describe("CoderAgentChatAction", () => { } }); - test("warns when idempotency-key is set", () => { - const warning = spyOn(core, "warning").mockImplementation(() => {}); - try { - const inputs = createMockInputs({ - idempotencyKey: "gh:owner/repo#1", - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - createMockContext(), - ); - - action.warnUnwiredInputs(); - - expect(warning).toHaveBeenCalledWith( - expect.stringContaining("`idempotency-key`"), - ); - } finally { - warning.mockRestore(); - } - }); - test("does not warn at defaults", () => { const warning = spyOn(core, "warning").mockImplementation(() => {}); try { @@ -1568,6 +1546,10 @@ describe("CoderAgentChatAction", () => { // service-account identity. The trust gate must not refuse: the // fork PR's prompt is still attacker-controlled, but the workflow // author has accepted the responsibility of that opt-in. + coderClient.mockGetCoderUserByUsername.mockResolvedValue({ + ...mockUser, + username: "bot-user", + }); coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ @@ -3281,4 +3263,503 @@ describe("CoderAgentChatAction", () => { expect((caught as ActionFailureError).cause).toBe(originalError); }); }); + + describe("Idempotency by label", () => { + test("unset: action creates a new chat without listChats and without labels", async () => { + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + + const inputs = createMockInputs({ + githubUserID: 12345, + idempotencyKey: undefined, + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + await action.run(); + + expect(coderClient.mockListChats).not.toHaveBeenCalled(); + expect(coderClient.mockCreateChat).toHaveBeenCalledTimes(1); + const req = coderClient.mockCreateChat.mock.calls[0]?.[0] as + | { labels?: Record } + | undefined; + expect(req?.labels).toBeUndefined(); + expect(coderClient.mockCreateChatMessage).not.toHaveBeenCalled(); + }); + + test( + "set, no match: creates with the three labels and uses the " + + "sanitized key value", + async () => { + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockListChats.mockResolvedValue([]); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + + const inputs = createMockInputs({ + githubUserID: 12345, + githubURL: "https://github.com/test-org/test-repo/issues/123", + idempotencyKey: "My Custom Key!", + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + await action.run(); + + expect(coderClient.mockListChats).toHaveBeenCalledTimes(1); + expect(coderClient.mockCreateChat).toHaveBeenCalledTimes(1); + const req = coderClient.mockCreateChat.mock.calls[0]?.[0] as + | { labels?: Record } + | undefined; + expect(req?.labels).toBeDefined(); + expect(req?.labels?.["coder-agent-chat-action"]).toBe("true"); + expect(req?.labels?.["gh-target"]).toBe("test-org/test-repo#123"); + expect(req?.labels?.["coder-agent-chat-action-user"]).toBe(mockUser.id); + // The fourth key is the sanitized idempotency-key: exactly one + // extra key, allowed by the platform's regex, mapped to "true". + const sanitizedKeys = Object.keys(req?.labels ?? {}).filter( + (k) => + k !== "coder-agent-chat-action" && + k !== "gh-target" && + k !== "coder-agent-chat-action-user", + ); + expect(sanitizedKeys).toHaveLength(1); + const sanitizedKey = sanitizedKeys[0]; + expect(sanitizedKey).toMatch(/^[a-z0-9][a-z0-9._/-]*$/); + expect(req?.labels?.[sanitizedKey]).toBe("true"); + }, + ); + + test("set, no match: listChats is called with the sanitized label filter", async () => { + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockListChats.mockResolvedValue([]); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + + const inputs = createMockInputs({ + githubUserID: 12345, + idempotencyKey: "my-key", + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + await action.run(); + + expect(coderClient.mockListChats).toHaveBeenCalledTimes(1); + const arg = coderClient.mockListChats.mock.calls[0]?.[0] as + | { label?: string | string[]; archived?: boolean } + | undefined; + expect(arg?.label).toEqual([ + "my-key:true", + "gh-target:test-org/test-repo#123", + `coder-agent-chat-action-user:${mockUser.id}`, + ]); + expect(arg?.archived).toBe(false); + }); + + test( + "set, one non-archived match: sends a follow-up via createChatMessage " + + "and does not create a new chat", + async () => { + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockListChats.mockResolvedValue([ + { ...mockChat, archived: false }, + ]); + coderClient.mockCreateChatMessage.mockResolvedValue( + mockChatMessageResponse, + ); + coderClient.mockGetChat.mockResolvedValue(mockChat); + + const inputs = createMockInputs({ + githubUserID: 12345, + idempotencyKey: "my-key", + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + const result = await action.run(); + + expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); + expect(coderClient.mockCreateChatMessage).toHaveBeenCalledTimes(1); + const [chatId, params] = + coderClient.mockCreateChatMessage.mock.calls[0] ?? []; + expect(chatId).toBe(mockChat.id); + expect(params).toEqual({ + content: [{ type: "text", text: "Test prompt" }], + model_config_id: undefined, + }); + + const parsedResult = ActionOutputsSchema.parse(result); + expect(parsedResult.chatCreated).toBe(false); + expect(parsedResult.chatId).toBe(mockChat.id); + // Reuse path uses S8's success-comment body shape with the + // "message sent" heading (not "created"). + expect(octokit.rest.issues.createComment).toHaveBeenCalledTimes(1); + const commentCall = octokit.rest.issues.createComment.mock + .calls[0]?.[0] as { body: string } | undefined; + expect(commentCall?.body).toMatch( + /^\*\*Coder Agent Chat: message sent\*\*/, + ); + }, + ); + + test( + "set, single match, getChat refresh fails: action resolves with " + + "pre-message snapshot rather than failing", + async () => { + // Outputs degrade to the pre-message chat when the refresh + // throws; the message itself already succeeded. + const stale = { + ...mockChat, + archived: false, + status: "waiting" as const, + }; + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockListChats.mockResolvedValue([stale]); + coderClient.mockCreateChatMessage.mockResolvedValue( + mockChatMessageResponse, + ); + coderClient.mockGetChat.mockRejectedValue( + new Error("transient API error"), + ); + + const inputs = createMockInputs({ + githubUserID: 12345, + idempotencyKey: "my-key", + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + const result = await action.run(); + + const parsedResult = ActionOutputsSchema.parse(result); + expect(parsedResult.chatCreated).toBe(false); + expect(parsedResult.chatId).toBe(stale.id); + expect(parsedResult.chatStatus).toBe("waiting"); + expect(coderClient.mockGetChat).toHaveBeenCalledWith(stale.id); + }, + ); + + test( + "set, multiple non-archived matches: picks the most recent by " + + "updated_at and warns in the workflow log", + async () => { + const older = { + ...mockChat, + id: ChatIdSchema.parse("aa0e8400-e29b-41d4-a716-446655440111"), + archived: false, + updated_at: "2026-04-01T00:00:00Z", + }; + const newer = { + ...mockChat, + id: ChatIdSchema.parse("bb0e8400-e29b-41d4-a716-446655440222"), + archived: false, + updated_at: "2026-04-29T00:00:00Z", + }; + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + // Non-sorted order: proves the action sorts itself. + coderClient.mockListChats.mockResolvedValue([older, newer]); + coderClient.mockCreateChatMessage.mockResolvedValue( + mockChatMessageResponse, + ); + // Keep the warning count at one (the multi-match warning). + coderClient.mockGetChat.mockResolvedValue(newer); + + const warnSpy = spyOn(core, "warning").mockImplementation(() => {}); + + const inputs = createMockInputs({ + githubUserID: 12345, + idempotencyKey: "my-key", + // Avoid the unwired-input warning for `coder-organization`. + coderOrganization: undefined, + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + try { + await action.run(); + + expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); + expect(coderClient.mockCreateChatMessage).toHaveBeenCalledTimes(1); + const [chatId] = + coderClient.mockCreateChatMessage.mock.calls[0] ?? []; + expect(chatId).toBe(newer.id); + + expect(warnSpy).toHaveBeenCalledTimes(1); + const warnArg = warnSpy.mock.calls[0]?.[0]; + expect(String(warnArg)).toContain("my-key"); + expect(String(warnArg)).toContain(newer.id); + // The reused chat is not listed as ignored. + const msg = String(warnArg); + const ignoringClause = msg.slice(msg.indexOf("and ignoring:")); + expect(ignoringClause).not.toContain(newer.id); + expect(ignoringClause).toContain(older.id); + } finally { + warnSpy.mockRestore(); + } + }, + ); + + test("set, only match is archived: creates a new chat (does not unarchive)", async () => { + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockListChats.mockResolvedValue([ + { ...mockChat, archived: true }, + ]); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + + const inputs = createMockInputs({ + githubUserID: 12345, + idempotencyKey: "my-key", + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + await action.run(); + + // Pin that the lookup ran; otherwise the archived filter is + // not what made creation proceed. + expect(coderClient.mockListChats).toHaveBeenCalledTimes(1); + expect(coderClient.mockCreateChatMessage).not.toHaveBeenCalled(); + expect(coderClient.mockCreateChat).toHaveBeenCalledTimes(1); + const createReq = coderClient.mockCreateChat.mock.calls[0]?.[0] as + | { labels?: Record } + | undefined; + expect(createReq?.labels).toEqual({ + "coder-agent-chat-action": "true", + "gh-target": "test-org/test-repo#123", + "coder-agent-chat-action-user": mockUser.id, + "my-key": "true", + }); + }); + + test( + "set, existing-chat-id provided: skips listChats lookup and uses " + + "the existing-chat-id (existing-chat-id wins)", + async () => { + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockCreateChatMessage.mockResolvedValue( + mockChatMessageResponse, + ); + coderClient.mockGetChat.mockResolvedValue(mockChat); + + const existingChatId = "990e8400-e29b-41d4-a716-446655440000"; + const inputs = createMockInputs({ + githubUserID: 12345, + existingChatId, + idempotencyKey: "my-key", + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + const result = await action.run(); + + expect(coderClient.mockListChats).not.toHaveBeenCalled(); + expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); + expect(coderClient.mockCreateChatMessage).toHaveBeenCalledWith( + existingChatId, + expect.objectContaining({ + content: [{ type: "text", text: "Test prompt" }], + }), + ); + const parsedResult = ActionOutputsSchema.parse(result); + expect(parsedResult.chatCreated).toBe(false); + expect(parsedResult.chatId).toBe(mockChat.id); + expect(parsedResult.chatStatus).toBe(mockChat.status); + expect(parsedResult.chatTitle).toBe(mockChat.title); + }, + ); + + test( + "set, listChats throws: error propagates with operation context " + + "(no silent fall-through to creation)", + async () => { + // A failed lookup must propagate (no silent fall-through to + // duplicate creation) and the message must name what failed. + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockListChats.mockRejectedValue( + new CoderAPIError("Coder API error: Bad Request", 400, ""), + ); + + const inputs = createMockInputs({ + githubUserID: 12345, + idempotencyKey: "my-key", + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + await expect(action.run()).rejects.toThrow( + /Failed to look up chats by idempotency labels/, + ); + expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); + expect(coderClient.mockCreateChatMessage).not.toHaveBeenCalled(); + }, + ); + + test( + "set to a value that sanitizes to a reserved label key: " + + "action fails fast with a clear error and does not call the API", + async () => { + // `idempotency-key: "gh-target"` would silently overwrite + // the reserved label. The check is hoisted before + // findIdempotentMatch, so listChats never runs. + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + + const inputs = createMockInputs({ + githubUserID: 12345, + idempotencyKey: "gh-target", + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + await expect(action.run()).rejects.toThrow(/reserved/i); + expect(coderClient.mockListChats).not.toHaveBeenCalled(); + expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); + expect(coderClient.mockCreateChatMessage).not.toHaveBeenCalled(); + }, + ); + + test( + "set, distinct Coder users sharing the same idempotency-key on the " + + "same gh-target each get their own chat (no cross-user hijack)", + async () => { + // User A's chat lookup matches their own chat; User B with a + // different resolved user ID, the same key, and the same target + // must not find User A's chat. We pin this by asserting the + // lookup carries the per-user label so the API cannot AND-match + // a chat created with the other user's UUID. + const userB: CoderSDKUser = { + ...mockUser, + id: "770e8400-e29b-41d4-a716-446655440777", + username: "userB", + }; + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(userB); + coderClient.mockListChats.mockResolvedValue([]); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + + const inputs = createMockInputs({ + githubUserID: 67890, + idempotencyKey: "shared-key", + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + await action.run(); + + // The lookup must include the per-user scope so the chats API + // cannot match a chat created with mockUser.id. + expect(coderClient.mockListChats).toHaveBeenCalledTimes(1); + const arg = coderClient.mockListChats.mock.calls[0]?.[0] as + | { label?: string[] } + | undefined; + expect(arg?.label).toContain( + `coder-agent-chat-action-user:${userB.id}`, + ); + expect(arg?.label).not.toContain( + `coder-agent-chat-action-user:${mockUser.id}`, + ); + + // Creation went through and stamped User B's UUID into the + // chat's per-user label so a later lookup by User B finds it + // and a later lookup by User A does not. + expect(coderClient.mockCreateChat).toHaveBeenCalledTimes(1); + const createReq = coderClient.mockCreateChat.mock.calls[0]?.[0] as + | { labels?: Record } + | undefined; + expect(createReq?.labels?.["coder-agent-chat-action-user"]).toBe( + userB.id, + ); + }, + ); + + test( + "set, coder-username resolution path: the same per-user scope is " + + "applied as the github-user-id path", + async () => { + // All other idempotency tests resolve the user via + // `getCoderUserByGithubID`. Without this test, dropping the + // `getCoderUserByUsername` call (leaving `coderUserId` + // uninitialized) would still pass the rest of the suite. + coderClient.mockGetCoderUserByUsername.mockResolvedValue(mockUser); + coderClient.mockListChats.mockResolvedValue([]); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + + const inputs = createMockInputs({ + githubUserID: undefined, + coderUsername: mockUser.username, + idempotencyKey: "my-key", + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + await action.run(); + + expect(coderClient.mockGetCoderUserByUsername).toHaveBeenCalledWith( + mockUser.username, + ); + expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); + + expect(coderClient.mockListChats).toHaveBeenCalledTimes(1); + const arg = coderClient.mockListChats.mock.calls[0]?.[0] as + | { label?: string[] } + | undefined; + expect(arg?.label).toContain( + `coder-agent-chat-action-user:${mockUser.id}`, + ); + + expect(coderClient.mockCreateChat).toHaveBeenCalledTimes(1); + const createReq = coderClient.mockCreateChat.mock.calls[0]?.[0] as + | { labels?: Record } + | undefined; + expect(createReq?.labels?.["coder-agent-chat-action-user"]).toBe( + mockUser.id, + ); + }, + ); + }); }); diff --git a/src/action.ts b/src/action.ts index 9bd603e..f80f9ba 100644 --- a/src/action.ts +++ b/src/action.ts @@ -9,6 +9,7 @@ import type { CoderSDKUser, CreateChatRequest, } from "./coder-client"; +import { RESERVED_LABEL_KEYS, sanitizeLabelKey } from "./sanitize-label-key"; import { buildCommentMarker, buildDeploymentChatsUrl, @@ -376,12 +377,8 @@ export class CoderAgentChatAction { * opt in. */ warnUnwiredInputs(): void { - if (this.inputs.idempotencyKey !== undefined) { - core.warning( - "`idempotency-key` is declared but not yet implemented; " + - "the action will always create a new chat.", - ); - } + // All v0 inputs are now wired. The helper remains for the test + // suite import and future unwired inputs. } /** @@ -613,17 +610,40 @@ export class CoderAgentChatAction { * * Returns `{ username, user? }`. `user` is set when the identity path * fetched a `CoderSDKUser` (sources 2-4); the explicit `coder-username` - * path (source 1) skips the lookup so `user` is undefined. + * path (source 1) always now also fetches the user via + * `getCoderUserByUsername` so `user.id` is available for the + * idempotency-by-label per-user scope. * `resolveOrganizationID` reuses `user` to read `organization_ids` - * without a redundant lookup, and fetches lazily when it is undefined. + * without a redundant lookup. */ async resolveCoderUsername(): Promise<{ username: string; - user?: CoderSDKUser; + user: CoderSDKUser; }> { if (this.inputs.coderUsername) { core.info(`Using provided Coder username: ${this.inputs.coderUsername}`); - return { username: this.inputs.coderUsername }; + // Fetch the full user so `user.id` is available downstream for + // the `coder-agent-chat-action-user` per-user idempotency scope + // (S7). + let coderUser: CoderSDKUser; + try { + coderUser = await this.coder.getCoderUserByUsername( + this.inputs.coderUsername, + ); + } catch (err) { + // Symmetric with the named-org 404 wrap in `resolveOrganizationID`. + if (err instanceof CoderAPIError && err.statusCode === 404) { + throw new ActionFailureError( + "user_not_found", + `Coder user '${this.inputs.coderUsername}' not found. ` + + "Check the `coder-username` input value.", + undefined, + { cause: err }, + ); + } + throw err; + } + return { username: coderUser.username, user: coderUser }; } if (this.inputs.githubUserID !== undefined) { core.info( @@ -995,6 +1015,71 @@ export class CoderAgentChatAction { }; } + // Idempotency by label: if `idempotency-key` is set, look up an + // existing non-archived chat scoped to this `gh-target` and the + // resolved Coder user, and reuse it before creating a duplicate. + // The lookup ANDs the sanitized key with `gh-target` and + // `coder-agent-chat-action-user` so a shared `idempotency-key` + // across targets or users does not cross-contaminate. + const sanitizedKey = this.inputs.idempotencyKey + ? sanitizeLabelKey(this.inputs.idempotencyKey) + : undefined; + if (sanitizedKey && RESERVED_LABEL_KEYS.has(sanitizedKey)) { + throw new Error( + `idempotency-key sanitizes to a reserved chat-label key ("${sanitizedKey}"). ` + + `Reserved keys: ${[...RESERVED_LABEL_KEYS].join(", ")}. ` + + "Choose a different idempotency-key value.", + ); + } + const ghTarget = `${githubOrg}/${githubRepo}#${githubIssueNumber}`; + + if (sanitizedKey) { + const follow = await this.findIdempotentMatch( + sanitizedKey, + ghTarget, + resolvedUser.id, + ); + if (follow) { + core.info(`Reusing existing chat by idempotency label: ${follow.id}`); + await this.coder.createChatMessage(follow.id, { + content: [{ type: "text", text: this.inputs.chatPrompt }], + model_config_id: this.inputs.modelConfigId, + }); + core.info("Message sent successfully"); + const chatUrl = this.generateChatUrl(follow.id); + + // Refresh so outputs reflect post-message state. The message + // already succeeded; on fetch failure, fall back to the + // pre-message chat rather than failing the run. + let refreshed: CoderChat = follow; + try { + const fetched = await this.coder.getChat(follow.id); + core.info(`Chat status: ${fetched.status}, title: ${fetched.title}`); + refreshed = fetched; + } catch (error) { + core.warning( + `Failed to fetch chat after sending message; outputs reflect pre-message state: ${error}`, + ); + } + + if (this.inputs.commentOnIssue) { + core.info( + `Commenting on issue ${githubOrg}/${githubRepo}#${githubIssueNumber}`, + ); + await this.commentOnIssue({ + chatUrl, + owner: githubOrg, + repo: githubRepo, + issueNumber: githubIssueNumber, + chatCreated: false, + chat: refreshed, + }); + } + + return this.buildOutputs(coderUsername, refreshed, false); + } + } + // Resolve `organization_id` only on the create branch: the // existing-chat path inherits the chat's org via `createChatMessage`, // and resolving eagerly would fire an extra API call and a spurious @@ -1010,6 +1095,13 @@ export class CoderAgentChatAction { workspace_id: this.inputs.workspaceId, model_config_id: this.inputs.modelConfigId, }; + if (sanitizedKey) { + req.labels = this.buildIdempotencyLabels( + sanitizedKey, + ghTarget, + resolvedUser.id, + ); + } const createdChat = await this.coder.createChat(req); core.info( @@ -1052,4 +1144,80 @@ export class CoderAgentChatAction { return this.buildOutputs(coderUsername, finalChat, true); } + + /** + * Most-recent non-archived match for this key+target+user, or undefined. + * Warns on multiple matches (concurrent triggers can race). + */ + private async findIdempotentMatch( + sanitizedKey: string, + ghTarget: string, + coderUserId: string, + ): Promise { + const keyLabel = `${sanitizedKey}:true`; + const targetLabel = `gh-target:${ghTarget}`; + const userLabel = `coder-agent-chat-action-user:${coderUserId}`; + let chats: CoderChat[]; + try { + chats = await this.coder.listChats({ + label: [keyLabel, targetLabel, userLabel], + archived: false, + }); + } catch (err) { + const inner = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to look up chats by idempotency labels [${keyLabel}, ${targetLabel}, ${userLabel}]: ${inner}`, + { cause: err }, + ); + } + // Belt-and-braces: the API filters archived by default. + const live = chats.filter((chat) => chat.archived !== true); + if (live.length === 0) { + return undefined; + } + // PostgreSQL `timestamptz` serializes with uniform fractional + // precision, so lex comparison sorts correctly. ISO 8601 strings + // are not lex-comparable in general. + live.sort((a, b) => { + if (a.updated_at < b.updated_at) return 1; + if (a.updated_at > b.updated_at) return -1; + return 0; + }); + if (live.length > 1) { + const ignored = live + .slice(1) + .map((c) => c.id) + .join(", "); + core.warning( + `Multiple non-archived chats matched idempotency-key=${this.inputs.idempotencyKey} for ${ghTarget}. ` + + `Reusing the most recent (${live[0].id}) and ignoring: ${ignored}. ` + + "Concurrent triggers can race; subsequent runs converge on the " + + "most recent match.", + ); + } + return live[0]; + } + + private buildIdempotencyLabels( + sanitizedKey: string, + ghTarget: string, + coderUserId: string, + ): Record { + // Defense in depth: `runInner` rejects collisions before any API + // call; this guards direct callers. + if (RESERVED_LABEL_KEYS.has(sanitizedKey)) { + throw new Error( + `idempotency-key sanitizes to a reserved chat-label key ("${sanitizedKey}"). ` + + `Reserved keys: ${[...RESERVED_LABEL_KEYS].join(", ")}. ` + + "Choose a different idempotency-key value.", + ); + } + const labels: Record = { + "coder-agent-chat-action": "true", + "gh-target": ghTarget, + "coder-agent-chat-action-user": coderUserId, + }; + labels[sanitizedKey] = "true"; + return labels; + } } diff --git a/src/coder-client.ts b/src/coder-client.ts index 770618e..905de7a 100644 --- a/src/coder-client.ts +++ b/src/coder-client.ts @@ -18,7 +18,17 @@ export interface CoderClient { getChat(chatId: ChatId): Promise; - listChats(): Promise; + listChats(opts?: ListChatsOptions): Promise; +} + +export interface ListChatsOptions { + /** + * `key:value` label filter. Multiple entries become repeated + * `?label=...` params and are ANDed by the API. + */ + label?: string | string[]; + /** If false, send `?q=archived:false` explicitly. */ + archived?: boolean; } export class RealCoderClient implements CoderClient { @@ -105,8 +115,23 @@ export class RealCoderClient implements CoderClient { throw new CoderAPIError("Coder username cannot be empty", 400); } const endpoint = `/api/v2/users/${encodeURIComponent(username)}`; - const response = await this.request(endpoint); - return CoderSDKUserSchema.parse(response); + try { + const response = await this.request(endpoint); + return CoderSDKUserSchema.parse(response); + } catch (err) { + // Re-throw 404 with the `user_not_found` kind so `classifyError` + // routes a typo in `coder-username` to the helpful failure + // comment rather than a generic `api_error`. + if (err instanceof CoderAPIError && err.statusCode === 404) { + throw new CoderAPIError( + `No Coder user found with username "${username}"`, + 404, + err.response, + "user_not_found", + ); + } + throw err; + } } async getOrganizationByName(name: string): Promise { @@ -145,8 +170,21 @@ export class RealCoderClient implements CoderClient { return CoderChatSchema.parse(response); } - async listChats(): Promise { - const endpoint = "/api/experimental/chats"; + async listChats(opts?: ListChatsOptions): Promise { + const params: string[] = []; + if (opts?.label !== undefined) { + const labels = Array.isArray(opts.label) ? opts.label : [opts.label]; + for (const l of labels) { + params.push(`label=${encodeURIComponent(l)}`); + } + } + if (opts?.archived === false) { + // Explicit `?q=archived:false` pins the contract even though + // the API filters archived chats by default. + params.push(`q=${encodeURIComponent("archived:false")}`); + } + const query = params.length ? `?${params.join("&")}` : ""; + const endpoint = `/api/experimental/chats${query}`; const response = await this.request(endpoint); const parsed = CoderChatListResponseSchema.parse(response); return parsed; @@ -256,6 +294,9 @@ export const CreateChatRequestSchema = z.object({ content: z.array(ChatInputPartSchema).min(1), workspace_id: z.string().uuid().optional(), model_config_id: z.string().uuid().optional(), + // Sent only when `idempotency-key` is provided. Platform key regex: + // `^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`, max 50 entries. + labels: z.record(z.string(), z.string()).optional(), }); export type CreateChatRequest = z.infer; diff --git a/src/comment.ts b/src/comment.ts index 648bed8..fb62649 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -5,6 +5,7 @@ import { type ChatStatus, CoderAPIError, } from "./coder-client"; +import { sanitizeLabelKey } from "./sanitize-label-key"; import type { ActionInputs } from "./schemas"; type Octokit = ReturnType; @@ -64,7 +65,7 @@ export function deriveCommentKey( }, ): string { if (inputs.idempotencyKey) { - return inputs.idempotencyKey; + return sanitizeLabelKey(inputs.idempotencyKey); } const match = inputs.githubURL.match(GITHUB_URL_REGEX); let base: string; diff --git a/src/sanitize-label-key.test.ts b/src/sanitize-label-key.test.ts new file mode 100644 index 0000000..921a087 --- /dev/null +++ b/src/sanitize-label-key.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { RESERVED_LABEL_KEYS, sanitizeLabelKey } from "./sanitize-label-key"; + +describe("sanitizeLabelKey", () => { + test("lowercases and replaces disallowed characters with '-'", () => { + expect(sanitizeLabelKey("My Custom Key!")).toBe("my-custom-key-"); + }); + + test("preserves the four punctuation classes the platform allows", () => { + expect(sanitizeLabelKey("a.b_c/d-e")).toBe("a.b_c/d-e"); + }); + + test("falls back to 'key' when the input sanitizes to empty", () => { + expect(sanitizeLabelKey("!@#$%")).toBe("key"); + expect(sanitizeLabelKey("")).toBe("key"); + }); + + test("trims leading non-alphanumeric characters before returning", () => { + expect(sanitizeLabelKey(".foo")).toBe("foo"); + expect(sanitizeLabelKey("---bar")).toBe("bar"); + expect(sanitizeLabelKey("/baz")).toBe("baz"); + }); + + test("truncates to 64 bytes", () => { + const seventy = "a".repeat(70); + const result = sanitizeLabelKey(seventy); + expect(result).toHaveLength(64); + expect(result).toBe("a".repeat(64)); + }); +}); + +describe("RESERVED_LABEL_KEYS", () => { + test("includes the per-user scope key that prevents cross-user hijack", () => { + // Without this entry, a sanitized idempotency-key value of + // "coder-agent-chat-action-user" would silently overwrite the + // per-user label and let any user impersonate any other on the + // same target. + expect(RESERVED_LABEL_KEYS.has("coder-agent-chat-action-user")).toBe(true); + }); +}); diff --git a/src/sanitize-label-key.ts b/src/sanitize-label-key.ts new file mode 100644 index 0000000..2b6c66a --- /dev/null +++ b/src/sanitize-label-key.ts @@ -0,0 +1,23 @@ +/** + * Reserved label keys on chats this action creates. A sanitized + * `idempotency-key` matching one of these is rejected upstream so the + * user input cannot overwrite an action-owned label. + */ +export const RESERVED_LABEL_KEYS: ReadonlySet = new Set([ + "coder-agent-chat-action", + "gh-target", + "coder-agent-chat-action-user", +]); + +/** + * Coerce an arbitrary string into a chat-label key the platform accepts. + * Platform regex: `^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`, max 64 bytes. Empty + * results fall back to `"key"`. + */ +export function sanitizeLabelKey(input: string): string { + const lowered = input.toLowerCase(); + const replaced = lowered.replace(/[^a-z0-9._/-]/g, "-"); + const trimmed = replaced.replace(/^[^a-z0-9]+/, ""); + const nonEmpty = trimmed.length > 0 ? trimmed : "key"; + return nonEmpty.slice(0, 64); +} diff --git a/src/test-helpers.ts b/src/test-helpers.ts index db265ce..de4e311 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -1,5 +1,9 @@ import { mock } from "bun:test"; -import { type CoderClient, ChatIdSchema } from "./coder-client"; +import { + type CoderClient, + type ListChatsOptions, + ChatIdSchema, +} from "./coder-client"; import type { CoderSDKUser, CoderSDKGetUsersResponse, @@ -189,8 +193,8 @@ export class MockCoderClient implements CoderClient { return this.mockGetChat(chatId); } - async listChats(): Promise { - return this.mockListChats(); + async listChats(opts?: ListChatsOptions): Promise { + return this.mockListChats(opts); } } From c981b5abd830eafd7f66d3408b4561479835ce40 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 11 May 2026 18:02:11 +0000 Subject: [PATCH 2/2] chore(dist): rebuild for S7 idempotency by label --- dist/index.js | 145 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 133 insertions(+), 12 deletions(-) diff --git a/dist/index.js b/dist/index.js index e7d018c..4507985 100644 --- a/dist/index.js +++ b/dist/index.js @@ -26756,8 +26756,15 @@ class RealCoderClient { throw new CoderAPIError("Coder username cannot be empty", 400); } const endpoint = `/api/v2/users/${encodeURIComponent(username)}`; - const response = await this.request(endpoint); - return CoderSDKUserSchema.parse(response); + try { + const response = await this.request(endpoint); + return CoderSDKUserSchema.parse(response); + } catch (err) { + if (err instanceof CoderAPIError && err.statusCode === 404) { + throw new CoderAPIError(`No Coder user found with username "${username}"`, 404, err.response, "user_not_found"); + } + throw err; + } } async getOrganizationByName(name) { if (!name) { @@ -26788,8 +26795,19 @@ class RealCoderClient { const response = await this.request(endpoint); return CoderChatSchema.parse(response); } - async listChats() { - const endpoint = "/api/experimental/chats"; + async listChats(opts) { + const params = []; + if (opts?.label !== undefined) { + const labels = Array.isArray(opts.label) ? opts.label : [opts.label]; + for (const l of labels) { + params.push(`label=${encodeURIComponent(l)}`); + } + } + if (opts?.archived === false) { + params.push(`q=${encodeURIComponent("archived:false")}`); + } + const query = params.length ? `?${params.join("&")}` : ""; + const endpoint = `/api/experimental/chats${query}`; const response = await this.request(endpoint); const parsed = CoderChatListResponseSchema.parse(response); return parsed; @@ -26865,7 +26883,8 @@ var CreateChatRequestSchema = exports_external.object({ organization_id: exports_external.string().uuid(), content: exports_external.array(ChatInputPartSchema).min(1), workspace_id: exports_external.string().uuid().optional(), - model_config_id: exports_external.string().uuid().optional() + model_config_id: exports_external.string().uuid().optional(), + labels: exports_external.record(exports_external.string(), exports_external.string()).optional() }); var CreateChatMessageRequestSchema = exports_external.object({ content: exports_external.array(ChatInputPartSchema).min(1), @@ -26896,6 +26915,20 @@ class CoderAPIError extends Error { } } +// src/sanitize-label-key.ts +var RESERVED_LABEL_KEYS = new Set([ + "coder-agent-chat-action", + "gh-target", + "coder-agent-chat-action-user" +]); +function sanitizeLabelKey(input) { + const lowered = input.toLowerCase(); + const replaced = lowered.replace(/[^a-z0-9._/-]/g, "-"); + const trimmed = replaced.replace(/^[^a-z0-9]+/, ""); + const nonEmpty = trimmed.length > 0 ? trimmed : "key"; + return nonEmpty.slice(0, 64); +} + // src/comment.ts var core = __toESM(require_core(), 1); var GITHUB_URL_REGEX = /([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)/; @@ -26906,7 +26939,7 @@ function buildCommentMarker(key) { } function deriveCommentKey(inputs) { if (inputs.idempotencyKey) { - return inputs.idempotencyKey; + return sanitizeLabelKey(inputs.idempotencyKey); } const match = inputs.githubURL.match(GITHUB_URL_REGEX); let base; @@ -27296,11 +27329,7 @@ class CoderAgentChatAction { marker }); } - warnUnwiredInputs() { - if (this.inputs.idempotencyKey !== undefined) { - core2.warning("`idempotency-key` is declared but not yet implemented; " + "the action will always create a new chat."); - } - } + warnUnwiredInputs() {} buildOutputs(coderUsername, chat, chatCreated) { const diff = chat.diff_status; const hasPR = diff?.pr_number != null; @@ -27399,7 +27428,16 @@ class CoderAgentChatAction { async resolveCoderUsername() { if (this.inputs.coderUsername) { core2.info(`Using provided Coder username: ${this.inputs.coderUsername}`); - return { username: this.inputs.coderUsername }; + let coderUser; + try { + coderUser = await this.coder.getCoderUserByUsername(this.inputs.coderUsername); + } catch (err) { + if (err instanceof CoderAPIError && err.statusCode === 404) { + throw new ActionFailureError("user_not_found", `Coder user '${this.inputs.coderUsername}' not found. ` + "Check the `coder-username` input value.", undefined, { cause: err }); + } + throw err; + } + return { username: coderUser.username, user: coderUser }; } if (this.inputs.githubUserID !== undefined) { core2.info(`Looking up Coder user by GitHub user ID: ${this.inputs.githubUserID}`); @@ -27581,6 +27619,43 @@ class CoderAgentChatAction { chatCreated: false }; } + const sanitizedKey = this.inputs.idempotencyKey ? sanitizeLabelKey(this.inputs.idempotencyKey) : undefined; + if (sanitizedKey && RESERVED_LABEL_KEYS.has(sanitizedKey)) { + throw new Error(`idempotency-key sanitizes to a reserved chat-label key ("${sanitizedKey}"). ` + `Reserved keys: ${[...RESERVED_LABEL_KEYS].join(", ")}. ` + "Choose a different idempotency-key value."); + } + const ghTarget = `${githubOrg}/${githubRepo}#${githubIssueNumber}`; + if (sanitizedKey) { + const follow = await this.findIdempotentMatch(sanitizedKey, ghTarget, resolvedUser.id); + if (follow) { + core2.info(`Reusing existing chat by idempotency label: ${follow.id}`); + await this.coder.createChatMessage(follow.id, { + content: [{ type: "text", text: this.inputs.chatPrompt }], + model_config_id: this.inputs.modelConfigId + }); + core2.info("Message sent successfully"); + const chatUrl2 = this.generateChatUrl(follow.id); + let refreshed = follow; + try { + const fetched = await this.coder.getChat(follow.id); + core2.info(`Chat status: ${fetched.status}, title: ${fetched.title}`); + refreshed = fetched; + } catch (error3) { + core2.warning(`Failed to fetch chat after sending message; outputs reflect pre-message state: ${error3}`); + } + if (this.inputs.commentOnIssue) { + core2.info(`Commenting on issue ${githubOrg}/${githubRepo}#${githubIssueNumber}`); + await this.commentOnIssue({ + chatUrl: chatUrl2, + owner: githubOrg, + repo: githubRepo, + issueNumber: githubIssueNumber, + chatCreated: false, + chat: refreshed + }); + } + return this.buildOutputs(coderUsername, refreshed, false); + } + } core2.info("Creating new agent chat..."); const organizationID = await this.resolveOrganizationID(coderUsername, resolvedUser); const req = { @@ -27589,6 +27664,9 @@ class CoderAgentChatAction { workspace_id: this.inputs.workspaceId, model_config_id: this.inputs.modelConfigId }; + if (sanitizedKey) { + req.labels = this.buildIdempotencyLabels(sanitizedKey, ghTarget, resolvedUser.id); + } const createdChat = await this.coder.createChat(req); core2.info(`Agent chat created successfully (id: ${createdChat.id}, status: ${createdChat.status})`); const chatUrl = this.generateChatUrl(createdChat.id); @@ -27618,6 +27696,49 @@ class CoderAgentChatAction { } return this.buildOutputs(coderUsername, finalChat, true); } + async findIdempotentMatch(sanitizedKey, ghTarget, coderUserId) { + const keyLabel = `${sanitizedKey}:true`; + const targetLabel = `gh-target:${ghTarget}`; + const userLabel = `coder-agent-chat-action-user:${coderUserId}`; + let chats; + try { + chats = await this.coder.listChats({ + label: [keyLabel, targetLabel, userLabel], + archived: false + }); + } catch (err) { + const inner = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to look up chats by idempotency labels [${keyLabel}, ${targetLabel}, ${userLabel}]: ${inner}`, { cause: err }); + } + const live = chats.filter((chat) => chat.archived !== true); + if (live.length === 0) { + return; + } + live.sort((a, b) => { + if (a.updated_at < b.updated_at) + return 1; + if (a.updated_at > b.updated_at) + return -1; + return 0; + }); + if (live.length > 1) { + const ignored = live.slice(1).map((c) => c.id).join(", "); + core2.warning(`Multiple non-archived chats matched idempotency-key=${this.inputs.idempotencyKey} for ${ghTarget}. ` + `Reusing the most recent (${live[0].id}) and ignoring: ${ignored}. ` + "Concurrent triggers can race; subsequent runs converge on the " + "most recent match."); + } + return live[0]; + } + buildIdempotencyLabels(sanitizedKey, ghTarget, coderUserId) { + if (RESERVED_LABEL_KEYS.has(sanitizedKey)) { + throw new Error(`idempotency-key sanitizes to a reserved chat-label key ("${sanitizedKey}"). ` + `Reserved keys: ${[...RESERVED_LABEL_KEYS].join(", ")}. ` + "Choose a different idempotency-key value."); + } + const labels = { + "coder-agent-chat-action": "true", + "gh-target": ghTarget, + "coder-agent-chat-action-user": coderUserId + }; + labels[sanitizedKey] = "true"; + return labels; + } } // src/outputs.ts