From c0b3e155814125d2a622194fea5a4572c59dec86 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Fri, 13 Feb 2026 16:49:15 +0800 Subject: [PATCH] feat: include issue comments in worker task context (#148) --- lib/dispatch.ts | 25 +++++++++++++++++++++++-- lib/providers/github.ts | 8 ++++++++ lib/providers/gitlab.ts | 15 +++++++++++++++ lib/providers/provider.ts | 7 +++++++ lib/services/tick.ts | 3 ++- lib/tools/work-start.ts | 1 + 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/lib/dispatch.ts b/lib/dispatch.ts index e1bc1b1..7edfe59 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -34,6 +34,8 @@ export type DispatchOpts = { toLabel: string; /** Function to transition labels (injected to avoid provider dependency) */ transitionLabel: (issueId: number, from: string, to: string) => Promise; + /** Issue provider for fetching comments */ + provider: import("./providers/provider.js").IssueProvider; /** Plugin config for model resolution */ pluginConfig?: Record; /** Orchestrator's session key (used as spawnedBy for subagent tracking) */ @@ -63,6 +65,7 @@ export async function buildTaskMessage(opts: { repo: string; baseBranch: string; groupId: string; + comments?: Array<{ author: string; body: string; created_at: string }>; }): Promise { const { workspaceDir, projectName, role, issueId, issueTitle, @@ -81,10 +84,24 @@ export async function buildTaskMessage(opts: { ``, issueTitle, issueDescription ? `\n${issueDescription}` : "", + ]; + + // Include comments if present + if (opts.comments && opts.comments.length > 0) { + parts.push(``, `## Comments`); + // Limit to last 20 comments to avoid bloating context + const recentComments = opts.comments.slice(-20); + for (const comment of recentComments) { + const date = new Date(comment.created_at).toLocaleString(); + parts.push(``, `**${comment.author}** (${date}):`, comment.body); + } + } + + parts.push( ``, `Repo: ${repo} | Branch: ${baseBranch} | ${issueUrl}`, `Project group ID: ${groupId}`, - ]; + ); if (roleInstructions) { parts.push(``, `---`, ``, roleInstructions.trim()); @@ -123,7 +140,7 @@ export async function dispatchTask( const { workspaceDir, agentId, groupId, project, issueId, issueTitle, issueDescription, issueUrl, role, level, fromLabel, toLabel, - transitionLabel, pluginConfig, + transitionLabel, provider, pluginConfig, } = opts; const model = resolveModel(role, level, pluginConfig); @@ -131,10 +148,14 @@ export async function dispatchTask( const existingSessionKey = getSessionForLevel(worker, level); const sessionAction = existingSessionKey ? "send" : "spawn"; + // Fetch comments to include in task context + const comments = await provider.listComments(issueId); + const taskMessage = await buildTaskMessage({ workspaceDir, projectName: project.name, role, issueId, issueTitle, issueDescription, issueUrl, repo: project.repo, baseBranch: project.baseBranch, groupId, + comments, }); await transitionLabel(issueId, fromLabel, toLabel); diff --git a/lib/providers/github.ts b/lib/providers/github.ts index 93f258a..6957597 100644 --- a/lib/providers/github.ts +++ b/lib/providers/github.ts @@ -65,6 +65,14 @@ export class GitHubProvider implements IssueProvider { return toIssue(JSON.parse(raw) as GhIssue); } + async listComments(issueId: number): Promise { + try { + const raw = await this.gh(["api", `repos/:owner/:repo/issues/${issueId}/comments`, "--jq", ".[] | {author: .user.login, body: .body, created_at: .created_at}"]); + if (!raw) return []; + return raw.split("\n").filter(Boolean).map((line) => JSON.parse(line)); + } catch { return []; } + } + async transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise { const issue = await this.getIssue(issueId); const stateLabels = issue.labels.filter((l) => STATE_LABELS.includes(l as StateLabel)); diff --git a/lib/providers/gitlab.ts b/lib/providers/gitlab.ts index c90b220..e0a8624 100644 --- a/lib/providers/gitlab.ts +++ b/lib/providers/gitlab.ts @@ -52,6 +52,21 @@ export class GitLabProvider implements IssueProvider { return JSON.parse(raw) as Issue; } + async listComments(issueId: number): Promise { + try { + const raw = await this.glab(["api", `projects/:id/issues/${issueId}/notes`, "--paginate"]); + const notes = JSON.parse(raw) as Array<{ author: { username: string }; body: string; created_at: string; system: boolean }>; + // Filter out system notes (e.g. "changed label", "closed issue") + return notes + .filter((note) => !note.system) + .map((note) => ({ + author: note.author.username, + body: note.body, + created_at: note.created_at, + })); + } catch { return []; } + } + async transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise { const issue = await this.getIssue(issueId); const stateLabels = issue.labels.filter((l) => STATE_LABELS.includes(l as StateLabel)); diff --git a/lib/providers/provider.ts b/lib/providers/provider.ts index 2629b37..709eac9 100644 --- a/lib/providers/provider.ts +++ b/lib/providers/provider.ts @@ -24,12 +24,19 @@ export type Issue = { web_url: string; }; +export type IssueComment = { + author: string; + body: string; + created_at: string; +}; + export interface IssueProvider { ensureLabel(name: string, color: string): Promise; ensureAllStateLabels(): Promise; createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise; listIssuesByLabel(label: StateLabel): Promise; getIssue(issueId: number): Promise; + listComments(issueId: number): Promise; transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise; closeIssue(issueId: number): Promise; reopenIssue(issueId: number): Promise; diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 07522a9..505371f 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -117,7 +117,7 @@ export async function projectTick(opts: { /** Only attempt this role. Used by work_start to fill the other slot. */ targetRole?: "dev" | "qa"; /** Optional provider override (for testing). Uses createProvider if omitted. */ - provider?: Pick; + provider?: Pick; }): Promise { const { workspaceDir, groupId, agentId, sessionKey, pluginConfig, dryRun, maxPickups, targetRole } = opts; @@ -175,6 +175,7 @@ export async function projectTick(opts: { issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url, role, level: selectedLevel, fromLabel: currentLabel, toLabel: targetLabel, transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), + provider: provider as IssueProvider, pluginConfig, sessionKey, }); pickups.push({ diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index 96e4114..983fc0b 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -97,6 +97,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url, role, level: selectedLevel, fromLabel: currentLabel, toLabel: targetLabel, transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), + provider, pluginConfig, sessionKey: ctx.sessionKey, });