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 docs/users/features/channels/gitlab.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ The adapter uses GitLab's Todos API as the message source:

The cursor (`lastProcessedId`) advances regardless of dispatch success or failure. Failed dispatches post a ⚠️ error comment on the issue/MR and are not retried — the user can re-mention the bot to trigger a new todo.

## Response Feedback

For an accepted comment mention (note with `#note_` anchor), the channel adds a 👀 award emoji to the note while the agent is working, then removes it when the run completes, fails, or is cancelled. Both operations are best-effort: an award emoji API or permission failure is logged and never prevents the final response.

Description mentions (no `#note_` anchor) do not receive an award emoji because there is no specific note to react to.

## Known Limitations

- **First start skips existing pending todos.** The cursor initializes to `{ lastProcessedId: 0, initialized: false }` on first launch. On the first poll cycle, all pre-existing pending todos are marked done without dispatch (the `initialized` flag gates this one-time drain), preventing a backlog flood.
Expand Down
302 changes: 302 additions & 0 deletions packages/channels/gitlab/src/GitlabAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ function createMockApi() {
MergeRequests: {
show: vi.fn().mockResolvedValue({ description: 'MR description' }),
},
IssueNoteAwardEmojis: {
award: vi.fn().mockResolvedValue({ id: 9000 }),
remove: vi.fn().mockResolvedValue(undefined),
},
MergeRequestNoteAwardEmojis: {
award: vi.fn().mockResolvedValue({ id: 9000 }),
remove: vi.fn().mockResolvedValue(undefined),
},
};
}

Expand Down Expand Up @@ -645,4 +653,298 @@ describe('GitlabChannel', () => {
expect(channel.cursor.lastProcessedId).toBe(2);
});
});

describe('working reaction', () => {
class ReactingGitlabChannel extends GitlabChannel {
override async handleInbound(envelope: Envelope): Promise<void> {
this.onPromptStart(envelope.chatId, 'session-1', envelope.messageId);
await Promise.resolve();
this.onPromptEnd(envelope.chatId, 'session-1', envelope.messageId);
}

protected override startPollLoop(): void {}
}

class LiveGitlabChannel extends GitlabChannel {
setReactionForTest(
messageId: string,
entry: {
target: { iid: number; title: string; isMr: boolean };
noteId: number;
},
): void {
(
this as unknown as {
reactions: Map<
string,
{
target: { iid: number; title: string; isMr: boolean };
noteId: number;
award?: Promise<{ awardId: number }>;
}
>;
}
).reactions.set(messageId, entry);
}

startPromptForTest(
chatId: string,
sessionId: string,
messageId: string,
): void {
this.onPromptStart(chatId, sessionId, messageId);
}

endPromptForTest(
chatId: string,
sessionId: string,
messageId: string,
): void {
this.onPromptEnd(chatId, sessionId, messageId);
}
}

it('drives award/remove through real pollOnce path for note mention', async () => {
const channel = new ReactingGitlabChannel(
'test-gitlab',
makeConfig(),
makeBridge(),
);
await channel.connect();
channel.disconnect();
(
channel as unknown as {
cursor: { lastProcessedId: number; initialized: boolean };
}
).cursor = {
lastProcessedId: 0,
initialized: true,
};
mockApi.TodoLists.all.mockResolvedValueOnce([makeTodo()]);

await (
channel as unknown as { pollOnce: () => Promise<void> }
).pollOnce();

expect(mockApi.IssueNoteAwardEmojis.award).toHaveBeenCalledWith(
'owner/repo',
42,
1001,
'eyes',
);
await vi.waitFor(() =>
expect(mockApi.IssueNoteAwardEmojis.remove).toHaveBeenCalledWith(
'owner/repo',
42,
1001,
9000,
),
);
});

it('does not award emoji for description mention via real path', async () => {
const channel = new ReactingGitlabChannel(
'test-gitlab',
makeConfig(),
makeBridge(),
);
await channel.connect();
channel.disconnect();
(
channel as unknown as {
cursor: { lastProcessedId: number; initialized: boolean };
}
).cursor = {
lastProcessedId: 0,
initialized: true,
};
mockApi.TodoLists.all.mockResolvedValueOnce([
makeTodo({
target_url: 'https://gitlab.com/owner/repo/-/issues/42',
body: 'Test Issue',
}),
]);

await (
channel as unknown as { pollOnce: () => Promise<void> }
).pollOnce();

expect(mockApi.IssueNoteAwardEmojis.award).not.toHaveBeenCalled();
});

it('acknowledges a note mention with an eyes award emoji', async () => {
const liveChannel = new LiveGitlabChannel(
'test-gitlab',
makeConfig(),
makeBridge(),
);
await liveChannel.connect();
liveChannel.disconnect();
liveChannel.setReactionForTest('100', {
target: { iid: 42, title: '', isMr: false },
noteId: 1001,
});

liveChannel.startPromptForTest('owner/repo', 'session-1', '100');

expect(mockApi.IssueNoteAwardEmojis.award).toHaveBeenCalledWith(
'owner/repo',
42,
1001,
'eyes',
);
});

it('removes the award emoji when the prompt finishes', async () => {
const liveChannel = new LiveGitlabChannel(
'test-gitlab',
makeConfig(),
makeBridge(),
);
await liveChannel.connect();
liveChannel.disconnect();
liveChannel.setReactionForTest('100', {
target: { iid: 42, title: '', isMr: false },
noteId: 1001,
});

liveChannel.startPromptForTest('owner/repo', 'session-1', '100');
await Promise.resolve();
liveChannel.endPromptForTest('owner/repo', 'session-1', '100');

await vi.waitFor(() =>
expect(mockApi.IssueNoteAwardEmojis.remove).toHaveBeenCalledWith(
'owner/repo',
42,
1001,
9000,
),
);
});

it('uses MergeRequestNoteAwardEmojis for MR note mentions', async () => {
const liveChannel = new LiveGitlabChannel(
'test-gitlab',
makeConfig(),
makeBridge(),
);
await liveChannel.connect();
liveChannel.disconnect();
liveChannel.setReactionForTest('100', {
target: { iid: 99, title: '', isMr: true },
noteId: 2001,
});

liveChannel.startPromptForTest('owner/repo', 'session-1', '100');

expect(mockApi.MergeRequestNoteAwardEmojis.award).toHaveBeenCalledWith(
'owner/repo',
99,
2001,
'eyes',
);
});

it('does not award twice when onPromptStart is called again', async () => {
const liveChannel = new LiveGitlabChannel(
'test-gitlab',
makeConfig(),
makeBridge(),
);
await liveChannel.connect();
liveChannel.disconnect();
liveChannel.setReactionForTest('100', {
target: { iid: 42, title: '', isMr: false },
noteId: 1001,
});

liveChannel.startPromptForTest('owner/repo', 'session-1', '100');
liveChannel.startPromptForTest('owner/repo', 'session-1', '100');

expect(mockApi.IssueNoteAwardEmojis.award).toHaveBeenCalledTimes(1);
});

it('handles award failure as best-effort', async () => {
mockApi.IssueNoteAwardEmojis.award.mockRejectedValueOnce(
new Error('403'),
);
const liveChannel = new LiveGitlabChannel(
'test-gitlab',
makeConfig(),
makeBridge(),
);
await liveChannel.connect();
liveChannel.disconnect();
liveChannel.setReactionForTest('100', {
target: { iid: 42, title: '', isMr: false },
noteId: 1001,
});

liveChannel.startPromptForTest('owner/repo', 'session-1', '100');

await vi.waitFor(() =>
expect(mockApi.IssueNoteAwardEmojis.award).toHaveBeenCalled(),
);
liveChannel.endPromptForTest('owner/repo', 'session-1', '100');
await Promise.resolve();
expect(mockApi.IssueNoteAwardEmojis.remove).not.toHaveBeenCalled();
});

it('handles remove failure as best-effort', async () => {
mockApi.IssueNoteAwardEmojis.remove.mockRejectedValueOnce(
new Error('403'),
);
const liveChannel = new LiveGitlabChannel(
'test-gitlab',
makeConfig(),
makeBridge(),
);
await liveChannel.connect();
liveChannel.disconnect();
liveChannel.setReactionForTest('100', {
target: { iid: 42, title: '', isMr: false },
noteId: 1001,
});

liveChannel.startPromptForTest('owner/repo', 'session-1', '100');
await Promise.resolve();
liveChannel.endPromptForTest('owner/repo', 'session-1', '100');

await vi.waitFor(() =>
expect(mockApi.IssueNoteAwardEmojis.remove).toHaveBeenCalled(),
);
});

it('waits for pending award before removing', async () => {
const { promise: awardPending, resolve: resolveAward } =
Promise.withResolvers<{ id: number }>();
mockApi.IssueNoteAwardEmojis.award.mockReturnValueOnce(awardPending);
const liveChannel = new LiveGitlabChannel(
'test-gitlab',
makeConfig(),
makeBridge(),
);
await liveChannel.connect();
liveChannel.disconnect();
liveChannel.setReactionForTest('100', {
target: { iid: 42, title: '', isMr: false },
noteId: 1001,
});

liveChannel.startPromptForTest('owner/repo', 'session-1', '100');
liveChannel.endPromptForTest('owner/repo', 'session-1', '100');
expect(mockApi.IssueNoteAwardEmojis.remove).not.toHaveBeenCalled();

resolveAward({ id: 9001 });
await awardPending;
await vi.waitFor(() =>
expect(mockApi.IssueNoteAwardEmojis.remove).toHaveBeenCalledWith(
'owner/repo',
42,
1001,
9001,
),
);
});
});
});
Loading
Loading