feat(gitlab-channel): add transient 👀 award emoji while agent is working - #8119
Conversation
Adds a working-reaction feature to the GitLab channel adapter, mirroring the GitHub adapter's eyes reaction (PR QwenLM#8061). When the agent starts processing a note mention, a 👀 award emoji is added to the note; it is removed when the run completes, fails, or is cancelled. Both operations are best-effort and never block the response. Also replaces the custom Todo interface with gitbeaker's TodoSchema, introduces GitlabTarget to consolidate target info, and simplifies processTodo/buildMetadata signatures by passing the parsed target directly instead of redundant targetType/threadId parameters. Co-Authored-By: Qwen Code <noreply@alibaba.com>
|
Thanks for the PR! Template looks good ✓ Problem: Feature parity — the GitHub adapter already ships eyes reactions (#8061), and this brings the GitLab adapter to the same level. Tracked as part of #8117 (P0 item #4). The need is concrete, not theoretical. Direction: Aligned. CHANGELOG has both the GitLab polling channel adapter (#7862, same author) and the GitHub eyes reaction feature (#8061) — this is the natural intersection of those two areas. Size: Not applicable — no core paths touched. Production code is 102 additions / 33 deletions in a single adapter file, plus 302 lines of tests and 6 lines of docs. Approach: The scope feels right — one adapter file, one test file, one docs page. The Risk: No elevated risk signals — no high-risk paths matched. Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题:功能对齐——GitHub 适配器已有 eyes reaction(#8061),本 PR 让 GitLab 适配器达到同等水平。属于 #8117(P0 第 4 项)。需求具体,非理论性加固。 方向:对齐。CHANGELOG 中同时包含 GitLab 轮询 channel 适配器(#7862,同一作者)和 GitHub eyes reaction 功能(#8061)——本 PR 是这两个领域的自然交叉。 规模:不适用——未触及核心路径。生产代码为单个适配器文件中 102 行新增 / 33 行删除,另有 302 行测试和 6 行文档。 方案:范围合理——一个适配器文件、一个测试文件、一个文档页面。 风险:无升级风险信号——未匹配高风险路径。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-preview Reviewed at |
Code ReviewClean implementation — both findings from the previous review round are fixed, and no new issues surfaced. The The core design is unchanged from the previous round and remains sound: The No correctness bugs, security issues, or AGENTS.md violations found. Testing
All CI checks green on The actual GitLab API integration (award emoji appearing on a real note) requires a live GitLab instance with a PAT and is not testable in CI or in a sandboxed lane. The author reports a 7/7 E2E pass against 中文说明代码审查实现干净——上一轮 review 的两个发现均已修复,未出现新问题。
核心设计与上一轮相同,依然合理:
未发现正确性 bug、安全问题或 AGENTS.md 违规。 测试所有 CI 检查在 实际 GitLab API 集成(award emoji 出现在真实评论上)需要带 PAT 的真实 GitLab 实例,无法在 CI 或沙箱中测试。作者报告在 — Qwen Code · qwen3.8-max-preview Reviewed at |
|
Confidence: 5/5 — both previous findings fixed cleanly, CI green, would merge without hesitation. The two issues from the last round are resolved in exactly the right way: The promise-chaining approach for the create/remove race remains cleaner than the GitHub adapter's 中文说明置信度:5/5 —— 上一轮两个发现均已干净修复,CI 全绿,毫不犹豫地合并。 上一轮的两个问题以恰当的方式解决: promise 链处理创建/移除竞态的方式依然比 GitHub 适配器的 — Qwen Code · qwen3.8-max-preview Reviewed at |
ReviewOverviewAdds GitHub-parity transient 👀 feedback to the GitLab adapter: The core design is sound, and the pending-award race handling (storing the award promise and chaining removal onto it) is genuinely correct — Two defects in the refactor are worth fixing before merge. I verified both with a scratch test against this branch and against Critical — dropping the
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
Restore null guard for todo.target (dropped in the TodoSchema refactor) to prevent TypeError when GitLab returns a todo without target. Use try/finally instead of try/catch for reactions cleanup so entries are removed on all handleInbound return paths, not just throws. Co-Authored-By: Qwen Code <noreply@alibaba.com>
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline.
中文说明
已审查。 建议见行内评论。
— qwen3.8-max-preview via Qwen Code /review
| if (isNoteMention) { | ||
| this.reactions.set(messageId, { | ||
| target, | ||
| noteId: Number(noteMatch![1]), | ||
| }); | ||
| } |
There was a problem hiding this comment.
[Suggestion] The noteId parsed from target_url (noteMatch![1]) and the target seeded into the reactions map here are never verified by any test. Every reaction test injects the map entry manually via setReactionForTest with a hardcoded noteId, and no pollOnce-driven test asserts the award call receives the parsed value — Concrete cost: the capture group noteMatch![1] is new in this diff (the old code was a non-capturing .test()), so a regression such as noteMatch![0] (→ Number('#note_1001') = NaN) would make every real note mention call award(chatId, iid, NaN, 'eyes'), which fails best-effort and is only logged — the 👀 reaction would silently never appear for any real user, and the mutant ships green.
Suggested fix — add one pollOnce-driven test that runs a todo whose target_url ends in #note_1001 through the real processTodo path and pins the parsed value:
// drive pollOnce with a fixture target_url ending in '#note_1001',
// trigger onPromptStart via the bridge mock, then assert:
expect(mockApi.IssueNoteAwardEmojis.award).toHaveBeenCalledWith(
'owner/repo',
42,
1001, // the parsed noteId, not an injected one
'eyes',
);中文说明
这里从 target_url 解析出的 noteId(noteMatch![1])以及写入 reactions map 的 target,没有任何测试覆盖。所有 reaction 测试都通过 setReactionForTest 手动注入 map entry(使用硬编码的 noteId),没有 pollOnce 驱动的测试断言 award 调用收到的是解析出来的值 —— 具体代价:捕获组 noteMatch![1] 是本 diff 新增的(旧代码是非捕获的 .test()),因此像 noteMatch = NaN)这样的回归会让每个真实 note mention 调用 award(chatId, iid, NaN, 'eyes'),它以 best-effort 失败且只记录日志 —— 👀 reaction 对所有真实用户都会静默不出现,而这个 mutant 会绿灯通过。
建议修复 —— 新增一个 pollOnce 驱动的测试,让 target_url 以 #note_1001 结尾的 todo 走真实的 processTodo 路径,并断言 award 收到 1001(解析值)而非注入值(见上方代码示例)。
— qwen3.8-max-preview via Qwen Code /review
| .catch((err) => { | ||
| process.stderr.write( | ||
| `[Channel:${this.name}] failed to remove acknowledgement from note ${entry.noteId}: ${err}\n`, | ||
| ); | ||
| }); |
There was a problem hiding this comment.
[Suggestion] This .catch() terminates the whole entry.award.then(remove) chain, so it catches rejections from BOTH a rejected award promise and a rejected remove call under the single message "failed to remove acknowledgement" — Concrete cost: when the award API is slow and the prompt finishes first, onPromptEnd chains on the still-pending award; if the award then fails (e.g. 403 missing scope), this logs "failed to remove acknowledgement from note N: Error: 403" even though no removal was ever attempted, while onPromptStart's catch already logged the real cause ("failed to acknowledge note N"). An oncall engineer seeing the second line investigates the wrong API endpoint.
Suggested fix — scope the removal error handler to only the api.remove() call so the award rejection is absorbed here (it is already logged above):
void entry.award.then(
({ awardId }) =>
api.remove(chatId, entry.target.iid, entry.noteId, awardId).catch((err) => {
process.stderr.write(
`[Channel:${this.name}] failed to remove acknowledgement from note ${entry.noteId}: ${err}\n`,
);
}),
() => {
// Award failure — already logged by onPromptStart's catch handler.
},
);中文说明
这个 .catch() 终结了整条 entry.award.then(remove) 链,因此它会同时捕获两种失败 —— award promise 被拒绝和 remove 调用被拒绝 —— 并都用同一条 "failed to remove acknowledgement" 消息输出 —— 具体代价:当 award API 较慢、prompt 先完成时,onPromptEnd 会在仍未 settle 的 award 上挂链;如果随后 award 失败(例如 403 缺少 scope),这里会输出 "failed to remove acknowledgement from note N: Error: 403",但实际上根本没有尝试过 remove,而 onPromptStart 的 catch 已经记录了真正的原因("failed to acknowledge note N")。凌晨值班工程师看到第二行会去排查错误的 API 端点。
建议修复 —— 把 remove 的错误处理限定在 api.remove() 调用本身,使 award 的拒绝在这里被静默吸收(它已在上方记录,见上方代码示例)。
— qwen3.8-max-preview via Qwen Code /review
Review: transient 👀 award emoji for GitLab note mentionsReviewed at What it doesMirrors the GitHub adapter's eyes reaction (#8061) for GitLab: What I verified
Suggestions1. All 7 new tests inject private state, so the actual wiring is untested. Every test calls 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 {}
}Driving 2. 3. Nits
Informational, no action expected
Other notes
|
…uencing Add ReactingGitlabChannel that drives real pollOnce → handleInbound → onPromptStart/onPromptEnd path, covering #note_ parse, key derivation, and finally cleanup. Replace the tautological description-mention test with one that exercises the real isNoteMention guard. Replace bare Promise.resolve() microtask waits with vi.waitFor in the award-failure test. Co-Authored-By: Qwen Code <noreply@alibaba.com>
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
中文说明
已审查——无阻断问题。 建议见行内评论。
— qwen3.8-max-preview via Qwen Code /review
| const entry = this.reactions.get(messageId); | ||
| if (!entry || entry.award) return; |
There was a problem hiding this comment.
[Suggestion] The entry.award truthy branch of this double-call guard is never exercised by any test — removing || entry.award is a surviving mutation. The guard is correct today, but a future regression deleting it would ship undetected.
Failure scenario: if onPromptStart fires twice for the same messageId (a duplicate lifecycle event or a future retry), the second call overwrites entry.award; onPromptEnd then removes only the second emoji and the first 👀 stays on the note permanently. No test calls startPromptForTest twice on the same messageId to pin the guard down.
Suggested fix — add a test in the working reaction block:
it('does not award twice when onPromptStart is called again', async () => {
// seed a reaction, then call startPromptForTest twice on the same messageId
expect(mockApi.IssueNoteAwardEmojis.award).toHaveBeenCalledTimes(1);
});中文说明
这个双重调用守卫中 entry.award 为真的分支没有任何测试覆盖——移除 || entry.award 是一个能存活的变异。守卫目前是正确的,但未来若误删它将不会被检测到。
失败场景:如果 onPromptStart 对同一个 messageId 触发两次(重复的生命周期事件或未来的重试),第二次调用会覆盖 entry.award;onPromptEnd 随后只移除第二个 emoji,第一个 👀 会永久留在评论上。建议修复:在 working reaction 测试块中新增一个测试,对同一 messageId 调用两次 startPromptForTest,并断言 IssueNoteAwardEmojis.award 只被调用一次。
— qwen3.8-max-preview via Qwen Code /review
Add test that calls startPromptForTest twice on the same messageId and asserts award is called exactly once, pinning the `|| entry.award` guard in onPromptStart against surviving mutations. Co-Authored-By: Qwen Code <noreply@alibaba.com>
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
— qwen3.8-max-preview via Qwen Code /review
Runtime verification report — built and executed locallyI verified this PR by running the real This matters because the PR's own tests replace Verdict: the feature works as described. I recommend merging. One test-coverage gap and two pre-existing limitations are noted below; none of them block. Setup
1. The emoji lifecycle, observed on the wireThe left/right panels below are rendered from the mock GitLab server's actual state at two moments in a single run; the table underneath is the real HTTP request log the channel produced. Confirmed on the wire: the endpoint, the HTTP verbs, 2. Scenario matrix and mutation testingAll 12 scenarios pass on the PR. The interesting ones:
To prove the checks aren't vacuous, I broke the compiled PR code six ways and re-ran. Every mutant was killed by the intended check — see the second table in the image. 3. Finding: the PR's own leak fix is not pinned by any testCommit try {
await this.handleInbound(envelope);
} finally {
this.reactions.delete(messageId);
}I deleted that It is not dead code — it is load-bearing. When Here is a test that pins it. I added it to it('does not leak a reactions entry when the sender gate rejects the mention', async () => {
class GatedGitlabChannel extends GitlabChannel {
protected override startPollLoop(): void {}
}
const channel = new GatedGitlabChannel(
'test-gitlab',
makeConfig({ senderPolicy: 'allowlist', allowedUsers: ['bob'] }),
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(
(channel as unknown as { reactions: Map<string, unknown> }).reactions.size,
).toBe(0);
});4. Undocumented behaviour change from the refactor (an improvement)I diffed the exact prompt text the adapter builds, PR vs base, across four todo shapes. Everything is byte-identical except one case — a target that has an - base: [owner/repo|…|alice|Issue|43|%title%|14||%|%bogus%]
+ PR: [owner/repo|…|alice|Issue|43||14||%|%bogus%]
5. Two limitations worth knowing (pre-existing, not introduced here, not blockers)
6. ReproductionThe harness is a ~500-line mock GitLab v4 server ( 中文版运行时验证报告 — 本地构建并实际执行我没有只看 diff、也没有只信任单元测试,而是让真实的 这一点很关键:本 PR 自带的测试把 结论:功能符合描述,建议合并。 下面记录了一个测试覆盖缺口和两个既有限制,都不构成阻塞。 环境
1. 网络层观测到的 emoji 生命周期上图左右两栏是同一次运行中 mock GitLab 服务器的真实状态在两个时刻的渲染,下方表格是 channel 实际产生的 HTTP 请求日志。 在网络层确认:端点正确、HTTP 动词正确、 2. 场景矩阵与变异测试PR 上 12 个场景全部通过。其中比较有价值的几个:
为了证明这些检查不是空转,我对编译后的 PR 代码做了 6 种定向破坏并重跑,每个变异体都被对应的检查杀死(见图中第二张表)。 3. 发现:PR 自己的 leak 修复没有被任何测试钉住commit try {
await this.handleInbound(envelope);
} finally {
this.reactions.delete(messageId);
}我把这个 它并不是冗余代码,而是有实际作用的。当 下面是能钉住它的测试。我已把它加进 it('does not leak a reactions entry when the sender gate rejects the mention', async () => {
class GatedGitlabChannel extends GitlabChannel {
protected override startPollLoop(): void {}
}
const channel = new GatedGitlabChannel(
'test-gitlab',
makeConfig({ senderPolicy: 'allowlist', allowedUsers: ['bob'] }),
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(
(channel as unknown as { reactions: Map<string, unknown> }).reactions.size,
).toBe(0);
});4. 重构带来的、描述中未提及的行为变化(属于改进)我对 PR 与 base 在四种 todo 形态下构造出的 prompt 文本做了逐字节 diff。除一种情况外完全一致 —— 即 target 有 - base: [owner/repo|…|alice|Issue|43|%title%|14||%|%bogus%]
+ PR: [owner/repo|…|alice|Issue|43||14||%|%bogus%]
5. 两个值得知晓的限制(既有问题,非本 PR 引入,不阻塞)
6. 复现方式harness 由一个约 500 行的 mock GitLab v4 服务器( |
|
Two follow-ups from the same harness, closing the loop on my review at Both defects I raised are fixed and verified at runtime, not just by inspection:
Correction to my "misleading duplicate error log" minor — it is narrower than I described. I claimed a failed award logs both
The So the two log lines I saw were an artifact of the unit tests' tight sequencing rather than normal operation. Still worth the 中文版以下两点来自同一套 harness,用于收尾我在 我提的两个缺陷都已修复,并且是在运行时验证的,不只是看代码:
对我那条「重复错误日志」minor 的更正 —— 它的触发条件比我描述的窄。 我当时说 award 失败会同时打出
所以我之前看到的两行日志其实是单测紧凑时序造成的假象,而非常态。我建议的 |
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
|
Released in v0.21.3. |



What this PR does
Adds a transient 👀 award emoji to GitLab note mentions while the agent is working, mirroring the GitHub adapter's eyes reaction (#8061). When
onPromptStartfires for a note mention, the channel calls the GitLab award emoji API to add 👀 to the triggering note. WhenonPromptEndfires (completion, failure, or cancellation), the emoji is removed. Both operations are best-effort: API errors or permission failures are logged to stderr and never block the agent's response.Description mentions (no
#note_anchor intarget_url) do not receive an award emoji because there is no specific note to react to.Implementation uses a single
reactionsMap keyed byString(todo.id)that stores the target info, noteId, and the award Promise.onPromptStartfills in the award Promise;onPromptEnddeletes the entry and chains.then()on the Promise to remove the emoji. This naturally handles the race whereonPromptEndfires before the award API returns — the removal waits for the award to settle.Also replaces the custom
Todointerface with gitbeaker'sTodoSchema(getting propertarget_typeenum'Issue' | 'MergeRequest' | ...instead ofstring), introducesGitlabTargetto consolidate parsed target info, and simplifiesprocessTodo/buildMetadatasignatures by passing the parsed target directly instead of redundanttargetType/threadIdparameters.Why it's needed
Part of #8117 (P0 item #4: Transient 👀 reaction / award emoji). The GitHub adapter already has this feature; the GitLab adapter needs parity. The transient emoji gives users visual feedback that the bot has accepted their mention and is working on it, and its removal signals completion.
Reviewer Test Plan
How to verify
senderPolicy: "open",groupPolicy: "open", and anaction_prompt_templatewith amentionedkey.qwen channel start <name>@bot-username do something).Unit tests cover: emoji creation, emoji removal, MR vs issue routing, description mention skip, award failure best-effort, remove failure best-effort, and pending award race handling.
Evidence (Before & After)
E2E test run against a live GitLab repo (
zore3475/gl-channel-e2e-0730):Tested on
Environment
Local
qwen channel start my-gitlabwith coding plan model (qwen3-coder-plus), poll interval 15s.Risk & Scope
apiscope needed for award emoji endpoints, the emoji silently fails but the agent still processes the mention and posts a reply. Theapiscope is already documented as required for posting notes.messageIdformat for GitLab envelopes remainsString(todo.id).Linked Issues
Part of #8117 (item 4: Transient 👀 reaction / award emoji).
中文翻译
本 PR 做了什么
为 GitLab 评论 mention 添加临时 👀 award emoji,在 agent 工作期间显示,完成后移除。镜像 GitHub 适配器的 eyes reaction(#8061)。当
onPromptStart在 note mention 时触发,channel 调用 GitLab award emoji API 在触发评论上添加 👀。当onPromptEnd触发(完成、失败或取消)时,emoji 被移除。两个操作都是 best-effort:API 错误或权限失败记录到 stderr,不会阻塞 agent 的响应。描述 mention(
target_url中没有#note_锚点)不会收到 award emoji,因为没有具体的评论可以添加 reaction。实现使用单个
reactionsMap,以String(todo.id)为 key,存储 target info、noteId 和 award Promise。onPromptStart填充 award Promise;onPromptEnd删除 entry 并在 Promise 上链式调用.then()来移除 emoji。这自然处理了onPromptEnd在 award API 返回之前触发的竞态——移除操作会等待 award 完成。同时将自定义
Todo接口替换为 gitbeaker 的TodoSchema(获得正确的target_type枚举'Issue' | 'MergeRequest' | ...而非string),引入GitlabTarget整合解析后的 target 信息,并简化了processTodo/buildMetadata的签名,直接传递解析后的 target 而非冗余的targetType/threadId参数。为什么需要这个
属于 #8117(P0 第 4 项:临时 👀 reaction / award emoji)。GitHub 适配器已有此功能;GitLab 适配器需要对齐。临时 emoji 给用户视觉反馈,表示 bot 已接受 mention 并正在处理,移除表示完成。
Reviewer 测试计划
验证方法
senderPolicy: "open"、groupPolicy: "open",action_prompt_template包含mentionedkey。qwen channel start <name>@bot-username do something)。单元测试覆盖:emoji 创建、emoji 移除、MR vs issue 路由、description mention 跳过、award 失败 best-effort、remove 失败 best-effort、pending award 竞态处理。
证据(Before & After)
针对真实 GitLab 仓库(
zore3475/gl-channel-e2e-0730)的 E2E 测试运行:测试环境
运行环境
本地
qwen channel start my-gitlab,使用 coding plan 模型(qwen3-coder-plus),poll 间隔 15 秒。风险与范围
apiscope,emoji 会静默失败,但 agent 仍会处理 mention 并发布回复。apiscope 已在文档中标注为发布评论的必要条件。messageId格式保持String(todo.id)不变。关联 Issue
属于 #8117(第 4 项:临时 👀 reaction / award emoji)。