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
89 changes: 88 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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` | `"<owner>/<repo>#<number>"` |
| `coder-agent-chat-action-user` | `"<coder-user-uuid>"` |
| `<sanitized-key>` | `"true"` |

The label namespace is action-owned. The `<sanitized-key>` 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=<sanitized-key>:true&label=gh-target:<owner>/<repo>#<number>&label=coder-agent-chat-action-user:<coder-user-uuid>&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.

2 changes: 1 addition & 1 deletion action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
145 changes: 133 additions & 12 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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+)/;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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 = {
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading