Skip to content

Commit 7fc11ff

Browse files
authored
chore: prepare for Marketplace publication (#31)
Rename the action to plural `coder-agents-chat-action` (the product is "Coder Agents", a chat in it is a "Coder Agents Chat") across `action.yaml`, label keys, comment marker, source, tests, `package.json`, `AGENTS.md`, README, and `dist/`. Branding `play` / `gray-dark`. Switch chat-reuse on by default, scoped to `gh-target` + Coder user + `GITHUB_WORKFLOW`. New `force-new-chat: true` opts out; mutually exclusive with `existing-chat-id`. `idempotency-key` becomes an optional sharding override. `ACTION_LABEL_KEYS` is the shared source of truth for the reuse filter and the labels written on creation, with a derivation invariant on `RESERVED_LABEL_KEYS`. Honor `wait: complete` on the reuse follow-up path via a single `runFollowUp` helper used by both the existing-chat-id path and the reuse path, so the two cannot drift. Rewrite the README for the Marketplace audience: Requirements, Quickstart, Inputs/Outputs tables, How it works (identity, org, chat reuse, wait mode, comment lifecycle), Recipes (issue triage, doc-check service account, follow-up, force new, gate on `pull-request-url`), Troubleshooting, Security model, Limitations, Versioning. Followups: - Repo rename `coder/create-agent-chat-action` -> `coder/agents-chat-action`. - Marketplace metadata (repo description, homepage, topics). - CODAGT-404: defensive sanitization of `GITHUB_WORKFLOW` against the 256-byte chat-label value cap. Generated by Coder Agents.
1 parent 6b790b1 commit 7fc11ff

15 files changed

Lines changed: 992 additions & 954 deletions

AGENTS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
# AGENTS.md - AI Agent Guide for create-agent-chat-action
1+
# AGENTS.md - AI Agent Guide for agents-chat-action
22

33
## Repository Overview
44

5-
**Purpose**: GitHub Action that creates and manages Coder Agent Chats for GitHub users with automated issue commenting support.
5+
**Purpose**: GitHub Action that creates and manages Coder Agents chats for GitHub users with automated issue commenting support.
66

77
**Key Difference from create-task-action**: This action targets the Coder Agents Chat API (`/api/experimental/chats`) instead of the Tasks API. Agents purposefully does NOT expose template selection — it either auto-provisions a workspace or uses an existing one.
88

@@ -35,7 +35,9 @@ CoderAgentChatAction.run() (action.ts)
3535
├─ Parse GitHub issue URL
3636
├─ Check if existing-chat-id provided
3737
│ ├─ YES: Send message to existing chat
38-
│ └─ NO: Create new chat (Agents auto-provisions workspace)
38+
│ └─ NO: Look up existing chat by reuse labels (unless force-new-chat)
39+
│ ├─ Match: Send message to reused chat
40+
│ └─ No match: Create new chat (Agents auto-provisions workspace)
3941
└─ Comment on GitHub issue with chat URL
4042
```
4143

README.md

Lines changed: 191 additions & 289 deletions
Large diffs are not rendered by default.

action.yaml

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
name: "Coder Create Agent Chat"
2-
description: "Create a Coder Agent Chat for a GitHub user, with support for issue commenting"
1+
name: "Coder Agents Chat"
2+
description: "Run Coder agents from any GitHub workflow. Triage issues, review PRs, or follow up on an existing chat."
33
author: "Coder Technologies Inc. <https://coder.com>"
44

55
branding:
6-
icon: message-circle
7-
color: purple
6+
icon: play
7+
color: gray-dark
88

99
inputs:
1010
chat-prompt:
11-
description: "Prompt to send to the agent chat. Templated by the workflow before being passed in."
11+
description: "Prompt to send to the agents chat. Templated by the workflow before being passed in."
1212
required: true
1313

1414
coder-token:
@@ -48,7 +48,7 @@ inputs:
4848
required: false
4949

5050
existing-chat-id:
51-
description: "Existing chat ID to send a follow-up message to instead of creating a new chat."
51+
description: "Existing chat ID to send a follow-up message to instead of creating a new chat. Mutually exclusive with force-new-chat."
5252
required: false
5353

5454
comment-on-issue:
@@ -67,9 +67,14 @@ inputs:
6767
default: "600"
6868

6969
idempotency-key:
70-
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."
70+
description: "Optional sharding key to narrow the default per-workflow scope. By default the action reuses the most recent non-archived chat scoped to `gh-target`, the resolved Coder user, and the workflow name. Set this to maintain multiple parallel chats on the same target/user/workflow (for example, one per matrix dimension)."
7171
required: false
7272

73+
force-new-chat:
74+
description: "Always create a new chat instead of reusing the most recent matching chat. Mutually exclusive with existing-chat-id."
75+
required: false
76+
default: "false"
77+
7378
outputs:
7479
coder-username:
7580
description: "The Coder username resolved from the GitHub user."

dist/index.js

Lines changed: 120 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -26955,11 +26955,13 @@ class CoderAPIError extends Error {
2695526955
}
2695626956

2695726957
// src/sanitize-label-key.ts
26958-
var RESERVED_LABEL_KEYS = new Set([
26959-
"coder-agent-chat-action",
26960-
"gh-target",
26961-
"coder-agent-chat-action-user"
26962-
]);
26958+
var ACTION_LABEL_KEYS = {
26959+
marker: "coder-agents-chat-action",
26960+
target: "gh-target",
26961+
user: "coder-agents-chat-action-user",
26962+
workflow: "coder-agents-chat-action-workflow"
26963+
};
26964+
var RESERVED_LABEL_KEYS = new Set(Object.values(ACTION_LABEL_KEYS));
2696326965
function sanitizeLabelKey(input) {
2696426966
const lowered = input.toLowerCase();
2696526967
const replaced = lowered.replace(/[^a-z0-9._/-]/g, "-");
@@ -26971,7 +26973,7 @@ function sanitizeLabelKey(input) {
2697126973
// src/comment.ts
2697226974
var core = __toESM(require_core(), 1);
2697326975
var GITHUB_URL_REGEX = /([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)\/?(?:[?#].*)?$/;
26974-
var COMMENT_MARKER_PREFIX = "<!-- coder-agent-chat-action:";
26976+
var COMMENT_MARKER_PREFIX = "<!-- coder-agents-chat-action:";
2697526977
var COMMENT_MARKER_SUFFIX = " -->";
2697626978
function buildCommentMarker(key) {
2697726979
return `${COMMENT_MARKER_PREFIX}${key}${COMMENT_MARKER_SUFFIX}`;
@@ -27072,7 +27074,7 @@ function formatMicrosAsDollars(micros) {
2707227074
}
2707327075
function buildFailureCommentBody(detail, ctx) {
2707427076
const runPhase = isRunPhaseFailure(detail.kind, ctx);
27075-
const heading = runPhase ? "**Coder Agent Chat: failed**" : "**Coder Agent Chat: failed to start**";
27077+
const heading = runPhase ? "**Coder Agents Chat: failed**" : "**Coder Agents Chat: failed to start**";
2707627078
const lines = [heading, ""];
2707727079
const linkLine = ctx.chatUrl ? `View the chat in the Coder deployment: ${ctx.chatUrl}` : `View chats in the Coder deployment: ${ctx.chatsUrl}`;
2707827080
switch (detail.kind) {
@@ -27133,15 +27135,15 @@ function apiErrorPhrase(runPhase, ctx) {
2713327135
function buildSuccessCommentBody(ctx) {
2713427136
const lines = [];
2713527137
if (ctx.waitMode === "complete" && ctx.chatStatus === "waiting") {
27136-
lines.push("**Coder Agent Chat: agent finished or is awaiting input**");
27138+
lines.push("**Coder Agents Chat: agent finished or is awaiting input**");
2713727139
} else if (ctx.waitMode === "complete" && ctx.chatStatus !== undefined) {
27138-
lines.push(`**Coder Agent Chat: ${ctx.chatStatus}**`);
27140+
lines.push(`**Coder Agents Chat: ${ctx.chatStatus}**`);
2713927141
} else if (ctx.waitMode === "complete") {
27140-
lines.push("**Coder Agent Chat: complete**");
27142+
lines.push("**Coder Agents Chat: complete**");
2714127143
} else if (ctx.chatCreated) {
27142-
lines.push("**Coder Agent Chat: created**");
27144+
lines.push("**Coder Agents Chat: created**");
2714327145
} else {
27144-
lines.push("**Coder Agent Chat: message sent**");
27146+
lines.push("**Coder Agents Chat: message sent**");
2714527147
}
2714627148
lines.push("", `Chat: ${ctx.chatUrl}`);
2714727149
if (ctx.chatStatus !== undefined) {
@@ -27615,96 +27617,48 @@ class CoderAgentChatAction {
2761527617
if (this.inputs.existingChatId) {
2761627618
core2.info(`Sending message to existing chat: ${this.inputs.existingChatId}`);
2761727619
const chatId = ChatIdSchema.parse(this.inputs.existingChatId);
27618-
await this.coder.createChatMessage(chatId, {
27619-
content: [{ type: "text", text: this.inputs.chatPrompt }],
27620-
model_config_id: this.inputs.modelConfigId
27621-
});
27622-
core2.info("Message sent successfully");
27623-
const chatUrl2 = this.generateChatUrl(chatId);
27624-
let chat;
27625-
if (this.inputs.wait === "complete") {
27626-
core2.info(`Waiting for chat to reach terminal status (timeout: ${this.inputs.waitTimeoutSeconds}s)...`);
27627-
chat = await this.pollWithContext(chatId, { coderUsername, chatUrl: chatUrl2 }, { requireNonTerminalFirst: true });
27628-
core2.info(`Chat reached terminal status: ${chat.status}`);
27629-
} else {
27630-
try {
27631-
chat = await this.coder.getChat(chatId);
27632-
core2.info(`Chat status: ${chat.status}, title: ${chat.title}`);
27633-
} catch (error3) {
27634-
core2.warning(`Failed to fetch chat after sending message; outputs will be minimal: ${error3}`);
27635-
}
27636-
}
27637-
if (this.inputs.commentOnIssue) {
27638-
core2.info(`Commenting on issue ${githubOrg}/${githubRepo}#${githubIssueNumber}`);
27639-
await this.commentOnIssue({
27640-
chatUrl: chatUrl2,
27641-
owner: githubOrg,
27642-
repo: githubRepo,
27643-
issueNumber: githubIssueNumber,
27644-
chatCreated: false,
27645-
chat
27646-
});
27647-
}
27648-
if (chat) {
27649-
return this.buildOutputs(coderUsername, chat, false);
27650-
}
27651-
return {
27620+
return this.runFollowUp({
2765227621
coderUsername,
2765327622
chatId,
27654-
chatUrl: chatUrl2,
27655-
chatCreated: false
27656-
};
27623+
preMessageChat: undefined,
27624+
githubOrg,
27625+
githubRepo,
27626+
githubIssueNumber
27627+
});
2765727628
}
2765827629
const sanitizedKey = this.inputs.idempotencyKey ? sanitizeLabelKey(this.inputs.idempotencyKey) : undefined;
2765927630
if (sanitizedKey && RESERVED_LABEL_KEYS.has(sanitizedKey)) {
2766027631
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.");
2766127632
}
2766227633
const ghTarget = `${githubOrg}/${githubRepo}#${githubIssueNumber}`;
27663-
if (sanitizedKey) {
27664-
const follow = await this.findIdempotentMatch(sanitizedKey, ghTarget, resolvedUser.id);
27634+
const workflow = process.env.GITHUB_WORKFLOW || undefined;
27635+
if (this.inputs.forceNewChat) {
27636+
core2.info("force-new-chat=true: skipping chat-reuse lookup");
27637+
} else {
27638+
const follow = await this.findReuseMatch(ghTarget, resolvedUser.id, workflow, sanitizedKey);
2766527639
if (follow) {
27666-
core2.info(`Reusing existing chat by idempotency label: ${follow.id}`);
27667-
await this.coder.createChatMessage(follow.id, {
27668-
content: [{ type: "text", text: this.inputs.chatPrompt }],
27669-
model_config_id: this.inputs.modelConfigId
27640+
core2.info(`Reusing existing chat: ${follow.id}`);
27641+
return this.runFollowUp({
27642+
coderUsername,
27643+
chatId: follow.id,
27644+
preMessageChat: follow,
27645+
githubOrg,
27646+
githubRepo,
27647+
githubIssueNumber
2767027648
});
27671-
core2.info("Message sent successfully");
27672-
const chatUrl2 = this.generateChatUrl(follow.id);
27673-
let refreshed = follow;
27674-
try {
27675-
const fetched = await this.coder.getChat(follow.id);
27676-
core2.info(`Chat status: ${fetched.status}, title: ${fetched.title}`);
27677-
refreshed = fetched;
27678-
} catch (error3) {
27679-
core2.warning(`Failed to fetch chat after sending message; outputs reflect pre-message state: ${error3}`);
27680-
}
27681-
if (this.inputs.commentOnIssue) {
27682-
core2.info(`Commenting on issue ${githubOrg}/${githubRepo}#${githubIssueNumber}`);
27683-
await this.commentOnIssue({
27684-
chatUrl: chatUrl2,
27685-
owner: githubOrg,
27686-
repo: githubRepo,
27687-
issueNumber: githubIssueNumber,
27688-
chatCreated: false,
27689-
chat: refreshed
27690-
});
27691-
}
27692-
return this.buildOutputs(coderUsername, refreshed, false);
2769327649
}
2769427650
}
27695-
core2.info("Creating new agent chat...");
27651+
core2.info("Creating new agents chat...");
2769627652
const organizationID = await this.resolveOrganizationID(coderUsername, resolvedUser);
2769727653
const req = {
2769827654
organization_id: organizationID,
2769927655
content: [{ type: "text", text: this.inputs.chatPrompt }],
2770027656
workspace_id: this.inputs.workspaceId,
27701-
model_config_id: this.inputs.modelConfigId
27657+
model_config_id: this.inputs.modelConfigId,
27658+
labels: this.buildChatLabels(ghTarget, resolvedUser.id, workflow, sanitizedKey)
2770227659
};
27703-
if (sanitizedKey) {
27704-
req.labels = this.buildIdempotencyLabels(sanitizedKey, ghTarget, resolvedUser.id);
27705-
}
2770627660
const createdChat = await this.coder.createChat(req);
27707-
core2.info(`Agent chat created successfully (id: ${createdChat.id}, status: ${createdChat.status})`);
27661+
core2.info(`Agents chat created successfully (id: ${createdChat.id}, status: ${createdChat.status})`);
2770827662
const chatUrl = this.generateChatUrl(createdChat.id);
2770927663
core2.info(`Chat URL: ${chatUrl}`);
2771027664
let finalChat = createdChat;
@@ -27732,19 +27686,77 @@ class CoderAgentChatAction {
2773227686
}
2773327687
return this.buildOutputs(coderUsername, finalChat, true);
2773427688
}
27735-
async findIdempotentMatch(sanitizedKey, ghTarget, coderUserId) {
27736-
const keyLabel = `${sanitizedKey}:true`;
27737-
const targetLabel = `gh-target:${ghTarget}`;
27738-
const userLabel = `coder-agent-chat-action-user:${coderUserId}`;
27689+
async runFollowUp(args) {
27690+
const {
27691+
coderUsername,
27692+
chatId,
27693+
preMessageChat,
27694+
githubOrg,
27695+
githubRepo,
27696+
githubIssueNumber
27697+
} = args;
27698+
await this.coder.createChatMessage(chatId, {
27699+
content: [{ type: "text", text: this.inputs.chatPrompt }],
27700+
model_config_id: this.inputs.modelConfigId
27701+
});
27702+
core2.info("Message sent successfully");
27703+
const chatUrl = this.generateChatUrl(chatId);
27704+
let chat = preMessageChat;
27705+
if (this.inputs.wait === "complete") {
27706+
core2.info(`Waiting for chat to reach terminal status (timeout: ${this.inputs.waitTimeoutSeconds}s)...`);
27707+
chat = await this.pollWithContext(chatId, { coderUsername, chatUrl }, { requireNonTerminalFirst: true });
27708+
core2.info(`Chat reached terminal status: ${chat.status}`);
27709+
} else {
27710+
try {
27711+
const fetched = await this.coder.getChat(chatId);
27712+
core2.info(`Chat status: ${fetched.status}, title: ${fetched.title}`);
27713+
chat = fetched;
27714+
} catch (error3) {
27715+
core2.warning(preMessageChat ? `Failed to fetch chat after sending message; outputs reflect pre-message state: ${error3}` : `Failed to fetch chat after sending message; outputs will be minimal: ${error3}`);
27716+
}
27717+
}
27718+
if (this.inputs.commentOnIssue) {
27719+
core2.info(`Commenting on issue ${githubOrg}/${githubRepo}#${githubIssueNumber}`);
27720+
await this.commentOnIssue({
27721+
chatUrl,
27722+
owner: githubOrg,
27723+
repo: githubRepo,
27724+
issueNumber: githubIssueNumber,
27725+
chatCreated: false,
27726+
chat
27727+
});
27728+
}
27729+
if (chat) {
27730+
return this.buildOutputs(coderUsername, chat, false);
27731+
}
27732+
return {
27733+
coderUsername,
27734+
chatId,
27735+
chatUrl,
27736+
chatCreated: false
27737+
};
27738+
}
27739+
async findReuseMatch(ghTarget, coderUserId, workflow, sanitizedKey) {
27740+
const labels = [
27741+
`${ACTION_LABEL_KEYS.marker}:true`,
27742+
`${ACTION_LABEL_KEYS.target}:${ghTarget}`,
27743+
`${ACTION_LABEL_KEYS.user}:${coderUserId}`
27744+
];
27745+
if (workflow) {
27746+
labels.push(`${ACTION_LABEL_KEYS.workflow}:${workflow}`);
27747+
}
27748+
if (sanitizedKey) {
27749+
labels.push(`${sanitizedKey}:true`);
27750+
}
2773927751
let chats;
2774027752
try {
2774127753
chats = await this.coder.listChats({
27742-
label: [keyLabel, targetLabel, userLabel],
27754+
label: labels,
2774327755
archived: false
2774427756
});
2774527757
} catch (err) {
2774627758
const inner = err instanceof Error ? err.message : String(err);
27747-
throw new Error(`Failed to look up chats by idempotency labels [${keyLabel}, ${targetLabel}, ${userLabel}]: ${inner}`, { cause: err });
27759+
throw new Error(`Failed to look up chats by reuse labels [${labels.join(", ")}]: ${inner}`, { cause: err });
2774827760
}
2774927761
const live = chats.filter((chat) => chat.archived !== true);
2775027762
if (live.length === 0) {
@@ -27759,20 +27771,25 @@ class CoderAgentChatAction {
2775927771
});
2776027772
if (live.length > 1) {
2776127773
const ignored = live.slice(1).map((c) => c.id).join(", ");
27762-
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.");
27774+
core2.warning(`Multiple non-archived chats matched reuse scope for ${ghTarget}. ` + `Reusing the most recent (${live[0].id}) and ignoring: ${ignored}. ` + "Concurrent triggers can race; subsequent runs converge on the " + "most recent match.");
2776327775
}
2776427776
return live[0];
2776527777
}
27766-
buildIdempotencyLabels(sanitizedKey, ghTarget, coderUserId) {
27767-
if (RESERVED_LABEL_KEYS.has(sanitizedKey)) {
27778+
buildChatLabels(ghTarget, coderUserId, workflow, sanitizedKey) {
27779+
if (sanitizedKey && RESERVED_LABEL_KEYS.has(sanitizedKey)) {
2776827780
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.");
2776927781
}
2777027782
const labels = {
27771-
"coder-agent-chat-action": "true",
27772-
"gh-target": ghTarget,
27773-
"coder-agent-chat-action-user": coderUserId
27783+
[ACTION_LABEL_KEYS.marker]: "true",
27784+
[ACTION_LABEL_KEYS.target]: ghTarget,
27785+
[ACTION_LABEL_KEYS.user]: coderUserId
2777427786
};
27775-
labels[sanitizedKey] = "true";
27787+
if (workflow) {
27788+
labels[ACTION_LABEL_KEYS.workflow] = workflow;
27789+
}
27790+
if (sanitizedKey) {
27791+
labels[sanitizedKey] = "true";
27792+
}
2777627793
return labels;
2777727794
}
2777827795
}
@@ -27843,11 +27860,15 @@ var ActionInputsObjectSchema = exports_external.object({
2784327860
commentOnIssue: exports_external.boolean().default(true),
2784427861
wait: exports_external.enum(["none", "complete"]).default("none"),
2784527862
waitTimeoutSeconds: exports_external.coerce.number().int().positive().default(DEFAULT_WAIT_TIMEOUT_SECONDS),
27846-
idempotencyKey: exports_external.string().min(1).optional()
27863+
idempotencyKey: exports_external.string().min(1).optional(),
27864+
forceNewChat: exports_external.boolean().default(false)
2784727865
});
2784827866
var ActionInputsSchema = ActionInputsObjectSchema.refine((data) => !(data.githubUserID !== undefined && data.coderUsername !== undefined), {
2784927867
message: "Cannot set both github-user-id and coder-username; choose one.",
2785027868
path: ["coderUsername"]
27869+
}).refine((data) => !(data.existingChatId !== undefined && data.forceNewChat === true), {
27870+
message: "Cannot set both existing-chat-id and force-new-chat; choose one.",
27871+
path: ["forceNewChat"]
2785127872
});
2785227873
var ChatErrorKindSchema2 = exports_external.enum([
2785327874
"spend_exceeded",
@@ -27904,7 +27925,8 @@ async function main() {
2790427925
commentOnIssue: core4.getBooleanInput("comment-on-issue"),
2790527926
wait: core4.getInput("wait") || undefined,
2790627927
waitTimeoutSeconds: core4.getInput("wait-timeout-seconds") || undefined,
27907-
idempotencyKey: core4.getInput("idempotency-key") || undefined
27928+
idempotencyKey: core4.getInput("idempotency-key") || undefined,
27929+
forceNewChat: core4.getBooleanInput("force-new-chat")
2790827930
});
2790927931
core4.debug("Inputs validated successfully");
2791027932
core4.debug(`Coder URL: ${inputs.coderURL}`);

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
2-
"name": "coder-agent-chat-action",
2+
"name": "coder-agents-chat-action",
33
"version": "1.0.0",
4-
"description": "GitHub Action to create and manage Coder Agent Chats",
4+
"description": "GitHub Action to create and manage Coder Agents chats",
55
"main": "dist/index.js",
66
"scripts": {
77
"build": "bun build src/index.ts --outfile dist/index.js --target node --format cjs",

0 commit comments

Comments
 (0)