Skip to content
Open
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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ CoderAgentChatAction.run() (action.ts)
- **index.ts** - Entry point, parses GHA inputs, initializes clients, runs action
- **action.ts** - Core business logic: user resolution, chat creation, issue commenting
- **coder-client.ts** - Coder API client for Chat endpoints + user lookup
- **sharing.ts** - Resolves `share-with-*` inputs to the UUIDs the ACL API needs and grants read access on a new chat
- **schemas.ts** - Zod schemas for action inputs and outputs

### Test Files (src/*.test.ts)
Expand Down Expand Up @@ -101,3 +102,8 @@ bun run build
- `POST /api/experimental/chats/{id}/messages` - Send message
- `GET /api/experimental/chats/{id}` - Get chat
- `GET /api/experimental/chats` - List chats

- **Chat sharing**:
- `PATCH /api/v2/chats/{id}/acl` - Grant read access to users or groups (used by the `share-with-*` inputs)
- `GET /api/v2/users/{user}` - Resolve a username to its UUID
- `GET /api/v2/organizations/{organization}/groups/{groupName}` - Resolve a group name to its UUID (licensed deployments only)
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ The chat runs as whoever the `coder-token` belongs to; that identity is the only
| `wait-timeout-seconds` | no | `600` | Max wait when `wait: complete`. |
| `idempotency-key` | no | | Optional sharding key on the reuse scope. See [Chat reuse](#chat-reuse). |
| `force-new-chat` | no | `false` | Skip chat-reuse lookup and always create. Mutually exclusive with `existing-chat-id`. |
| `share-with-organization` | no | `false` | Give the chat's Coder organization read access to a newly created chat. See [Who can read the chat](#who-can-read-the-chat). |
| `share-with-groups` | no | | Coder groups, as names or UUIDs, comma or newline separated. Names need a licensed deployment. |
| `share-with-users` | no | | Coder users, as usernames or UUIDs, comma or newline separated. Each user is notified once per new chat. |

## Outputs

Expand Down Expand Up @@ -112,6 +115,31 @@ There is one Coder identity in play. `POST /api/experimental/chats` binds the ch

Either path fails with `chat-error-kind=org_not_found` when the org doesn't exist or the user has no memberships.

### Who can read the chat

Every chat is owned by the `coder-token` holder, usually a bot account. By default nobody else can open it, and the `chat-url` in the issue comment answers "Chat not found" for everyone but that bot. That hides the agent's reasoning from the people reading its output.

Three inputs grant read access on a chat this run creates. They combine, and the action sends them as one request.

```yaml
share-with-organization: true # everyone in the chat's organization
share-with-groups: docs, 0f1e... # group names or group UUIDs
share-with-users: nickvigilante # usernames or user UUIDs
```

`share-with-organization` needs no lookup: the Everyone group shares its organization's ID, and the organization is the one already resolved for `createChat` (see [Organization resolution](#organization-resolution)). Group names resolve through `GET /api/v2/organizations/{organization}/groups/{groupName}`, which only a licensed deployment serves; a group UUID skips the lookup and works everywhere. Usernames resolve through `GET /api/v2/users/{user}` on any deployment.

Details worth knowing:

- Read-only. Readers can open the chat and follow it; they cannot send messages.
- Only on creation. A reused chat keeps the access it already had, so turning these inputs on does not retroactively open past chats.
- Applied before the `wait: complete` poll, so a reader can watch a long run rather than only read it afterwards.
- Groups are quiet, users are not. Coder notifies each user named in `user_roles` once per chat and never notifies group members.
- The `coder-token` owner is skipped in `share-with-users`. The API rejects a request that changes the caller's own role, and that rejection would drop every other entry in the same request.
- An entry that does not resolve is skipped with a warning; the rest still share. If nothing resolves, the chat stays private and the run logs a warning.
- Sub-chats are separate. Coder sets ACLs on root chats only, so a subagent's chat is not covered by the parent's entry.
- A deployment with chat sharing disabled answers `403`. The action logs a warning and the run still succeeds, because the chat itself is fine.

### Chat reuse

By default the action reuses the most recent non-archived chat scoped to the same `github-url` and (when `GITHUB_WORKFLOW` is set) the same workflow name. Two workflows targeting the same PR keep separate chats. Re-running the same workflow continues one chat.
Expand Down
13 changes: 13 additions & 0 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@ inputs:
required: false
default: "false"

share-with-organization:
description: "Give the chat's Coder organization read access to a newly created chat, so people other than the `coder-token` holder can open it. The Everyone group shares its organization's ID, so this needs no group lookup. Only applies when this run creates the chat; a reused chat keeps whatever access it already had. Read-only, and group access sends no notifications. A deployment with chat sharing disabled logs a warning instead of failing the run."
required: false
default: "false"

share-with-groups:
description: "Coder groups to give read access to a newly created chat, as names or UUIDs, separated by commas or newlines. Names resolve inside the chat's organization and need a licensed deployment; UUIDs work everywhere. An entry that does not resolve is skipped with a warning. Same creation-only and read-only rules as share-with-organization."
required: false

share-with-users:
description: "Coder users to give read access to a newly created chat, as usernames or UUIDs, separated by commas or newlines. Each named user gets one notification per new chat; groups do not. The `coder-token` owner is skipped, since a chat cannot be shared with its own owner. Same creation-only and read-only rules as share-with-organization."
required: false

outputs:
coder-username:
description: "The Coder username the `coder-token` belongs to (always the chat owner; the chats API has no owner override)."
Expand Down
133 changes: 131 additions & 2 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions scripts/typegen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ var wantedTypes = map[string]bool{
"Chat": true,
"CreateChatMessageRequest": true,
"CreateChatRequest": true,
"UpdateChatACL": true,
"Group": true,
"Organization": true,
"User": true,
}
Expand Down
93 changes: 93 additions & 0 deletions src/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2260,6 +2260,99 @@ describe("CoderAgentChatAction", () => {
});
});

describe("share-with-organization", () => {
test("grants the resolved organization read access on a new chat", async () => {
coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser);
coderClient.mockCreateChat.mockResolvedValue(mockChat);

const inputs = createMockInputs({
coderOrganization: "coder",
shareWithOrganization: true,
});
const action = new CoderAgentChatAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);

await action.run();

// The Everyone group shares its organization's ID, so the entry
// is keyed by the same UUID that createChat received.
expect(coderClient.mockUpdateChatACL).toHaveBeenCalledWith(mockChat.id, {
group_roles: { [mockOrganization.id]: "read" },
});
});

test("shares nothing by default", async () => {
coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser);
coderClient.mockCreateChat.mockResolvedValue(mockChat);

const inputs = createMockInputs({});
const action = new CoderAgentChatAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);

await action.run();

expect(coderClient.mockUpdateChatACL).not.toHaveBeenCalled();
});

test("does not re-share a reused chat", async () => {
coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser);
coderClient.mockListChats.mockResolvedValue([mockChat]);
coderClient.mockCreateChatMessage.mockResolvedValue(
mockChatMessageResponse,
);
coderClient.mockGetChat.mockResolvedValue(mockChat);

const inputs = createMockInputs({ shareWithOrganization: true });
const action = new CoderAgentChatAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);

await action.run();

expect(coderClient.mockCreateChat).not.toHaveBeenCalled();
expect(coderClient.mockUpdateChatACL).not.toHaveBeenCalled();
});

test("warns and still succeeds when sharing fails", async () => {
const warning = spyOn(core, "warning").mockImplementation(() => {});
try {
coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser);
coderClient.mockCreateChat.mockResolvedValue(mockChat);
coderClient.mockUpdateChatACL.mockRejectedValue(
new CoderAPIError(
"Chat sharing is disabled for this deployment.",
403,
),
);

const inputs = createMockInputs({ shareWithOrganization: true });
const action = new CoderAgentChatAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);

const outputs = await action.run();

expect(outputs.chatId).toBe(mockChat.id);
expect(outputs.chatCreated).toBe(true);
expect(warning).toHaveBeenCalledWith(
expect.stringContaining("Could not share the chat"),
);
} finally {
warning.mockRestore();
}
});
});

describe("Chat reuse", () => {
test("default: listChats is called with the gh-target scope before creating", async () => {
coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser);
Expand Down
Loading