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
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 usernames or UUIDs to grant read access to a new chat, separated by commas or newlines. |

## 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

New chats belong to the `coder-token` holder. These inputs grant additional read access; they do not restrict access already allowed by Coder roles.

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, platform # group names in the chat's organization
share-with-users: nickvigilante # usernames or user UUIDs
```

`share-with-organization` grants access to the Everyone group in the selected Coder organization. Pin `coder-organization` if the token owner belongs to multiple organizations. Group names resolve within that organization and require the licensed groups API; group UUIDs skip that lookup but still require chat-sharing support. Usernames resolve through the Coder users API.

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.
- Subagent chats inherit the root chat's ACL. ACLs can be changed only on the root, not independently on a child.
- A deployment with chat sharing disabled answers `403`. The action logs a warning and the run still succeeds, because the chat itself is fine.
Comment thread
bpmct marked this conversation as resolved.

### 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: "Grant the selected Coder organization read access to a new chat. Reused chats keep their existing access. Sharing failures log a warning without failing the action. See the README for sharing scope and limitations."
required: false
default: "false"

share-with-groups:
description: "Grant Coder groups read access to a new chat. Accepts comma- or newline-separated names or UUIDs. Names resolve in the chat's organization and require the licensed groups API; UUIDs skip this lookup. Failed lookups are skipped with a warning. Reused chats keep their existing access."
required: false

share-with-users:
description: "Grant Coder users read access to a new chat. Accepts comma- or newline-separated usernames or UUIDs. Reused chats keep their existing access. See the README for notification and failure behavior."
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