Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
220 changes: 220 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,216 @@ describe('GitlabChannel', () => {
expect(channel.cursor.lastProcessedId).toBe(2);
});
});

describe('working reaction', () => {
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('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('does not acknowledge a description mention', async () => {
const liveChannel = new LiveGitlabChannel(
'test-gitlab',
makeConfig(),
makeBridge(),
);
await liveChannel.connect();
liveChannel.disconnect();

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

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

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('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 Promise.resolve();
await Promise.resolve();

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