feat: include issue comments in worker task context (#148)

This commit is contained in:
Lauren ten Hoor
2026-02-13 16:49:15 +08:00
parent 54fabe1197
commit c0b3e15581
6 changed files with 56 additions and 3 deletions

View File

@@ -34,6 +34,8 @@ export type DispatchOpts = {
toLabel: string; toLabel: string;
/** Function to transition labels (injected to avoid provider dependency) */ /** Function to transition labels (injected to avoid provider dependency) */
transitionLabel: (issueId: number, from: string, to: string) => Promise<void>; transitionLabel: (issueId: number, from: string, to: string) => Promise<void>;
/** Issue provider for fetching comments */
provider: import("./providers/provider.js").IssueProvider;
/** Plugin config for model resolution */ /** Plugin config for model resolution */
pluginConfig?: Record<string, unknown>; pluginConfig?: Record<string, unknown>;
/** Orchestrator's session key (used as spawnedBy for subagent tracking) */ /** Orchestrator's session key (used as spawnedBy for subagent tracking) */
@@ -63,6 +65,7 @@ export async function buildTaskMessage(opts: {
repo: string; repo: string;
baseBranch: string; baseBranch: string;
groupId: string; groupId: string;
comments?: Array<{ author: string; body: string; created_at: string }>;
}): Promise<string> { }): Promise<string> {
const { const {
workspaceDir, projectName, role, issueId, issueTitle, workspaceDir, projectName, role, issueId, issueTitle,
@@ -81,10 +84,24 @@ export async function buildTaskMessage(opts: {
``, ``,
issueTitle, issueTitle,
issueDescription ? `\n${issueDescription}` : "", 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}`, `Repo: ${repo} | Branch: ${baseBranch} | ${issueUrl}`,
`Project group ID: ${groupId}`, `Project group ID: ${groupId}`,
]; );
if (roleInstructions) { if (roleInstructions) {
parts.push(``, `---`, ``, roleInstructions.trim()); parts.push(``, `---`, ``, roleInstructions.trim());
@@ -123,7 +140,7 @@ export async function dispatchTask(
const { const {
workspaceDir, agentId, groupId, project, issueId, issueTitle, workspaceDir, agentId, groupId, project, issueId, issueTitle,
issueDescription, issueUrl, role, level, fromLabel, toLabel, issueDescription, issueUrl, role, level, fromLabel, toLabel,
transitionLabel, pluginConfig, transitionLabel, provider, pluginConfig,
} = opts; } = opts;
const model = resolveModel(role, level, pluginConfig); const model = resolveModel(role, level, pluginConfig);
@@ -131,10 +148,14 @@ export async function dispatchTask(
const existingSessionKey = getSessionForLevel(worker, level); const existingSessionKey = getSessionForLevel(worker, level);
const sessionAction = existingSessionKey ? "send" : "spawn"; const sessionAction = existingSessionKey ? "send" : "spawn";
// Fetch comments to include in task context
const comments = await provider.listComments(issueId);
const taskMessage = await buildTaskMessage({ const taskMessage = await buildTaskMessage({
workspaceDir, projectName: project.name, role, issueId, workspaceDir, projectName: project.name, role, issueId,
issueTitle, issueDescription, issueUrl, issueTitle, issueDescription, issueUrl,
repo: project.repo, baseBranch: project.baseBranch, groupId, repo: project.repo, baseBranch: project.baseBranch, groupId,
comments,
}); });
await transitionLabel(issueId, fromLabel, toLabel); await transitionLabel(issueId, fromLabel, toLabel);

View File

@@ -65,6 +65,14 @@ export class GitHubProvider implements IssueProvider {
return toIssue(JSON.parse(raw) as GhIssue); return toIssue(JSON.parse(raw) as GhIssue);
} }
async listComments(issueId: number): Promise<import("./provider.js").IssueComment[]> {
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<void> { async transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void> {
const issue = await this.getIssue(issueId); const issue = await this.getIssue(issueId);
const stateLabels = issue.labels.filter((l) => STATE_LABELS.includes(l as StateLabel)); const stateLabels = issue.labels.filter((l) => STATE_LABELS.includes(l as StateLabel));

View File

@@ -52,6 +52,21 @@ export class GitLabProvider implements IssueProvider {
return JSON.parse(raw) as Issue; return JSON.parse(raw) as Issue;
} }
async listComments(issueId: number): Promise<import("./provider.js").IssueComment[]> {
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<void> { async transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void> {
const issue = await this.getIssue(issueId); const issue = await this.getIssue(issueId);
const stateLabels = issue.labels.filter((l) => STATE_LABELS.includes(l as StateLabel)); const stateLabels = issue.labels.filter((l) => STATE_LABELS.includes(l as StateLabel));

View File

@@ -24,12 +24,19 @@ export type Issue = {
web_url: string; web_url: string;
}; };
export type IssueComment = {
author: string;
body: string;
created_at: string;
};
export interface IssueProvider { export interface IssueProvider {
ensureLabel(name: string, color: string): Promise<void>; ensureLabel(name: string, color: string): Promise<void>;
ensureAllStateLabels(): Promise<void>; ensureAllStateLabels(): Promise<void>;
createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue>; createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue>;
listIssuesByLabel(label: StateLabel): Promise<Issue[]>; listIssuesByLabel(label: StateLabel): Promise<Issue[]>;
getIssue(issueId: number): Promise<Issue>; getIssue(issueId: number): Promise<Issue>;
listComments(issueId: number): Promise<IssueComment[]>;
transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void>; transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void>;
closeIssue(issueId: number): Promise<void>; closeIssue(issueId: number): Promise<void>;
reopenIssue(issueId: number): Promise<void>; reopenIssue(issueId: number): Promise<void>;

View File

@@ -117,7 +117,7 @@ export async function projectTick(opts: {
/** Only attempt this role. Used by work_start to fill the other slot. */ /** Only attempt this role. Used by work_start to fill the other slot. */
targetRole?: "dev" | "qa"; targetRole?: "dev" | "qa";
/** Optional provider override (for testing). Uses createProvider if omitted. */ /** Optional provider override (for testing). Uses createProvider if omitted. */
provider?: Pick<IssueProvider, "listIssuesByLabel" | "transitionLabel">; provider?: Pick<IssueProvider, "listIssuesByLabel" | "transitionLabel" | "listComments">;
}): Promise<TickResult> { }): Promise<TickResult> {
const { workspaceDir, groupId, agentId, sessionKey, pluginConfig, dryRun, maxPickups, targetRole } = opts; 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, issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
role, level: selectedLevel, fromLabel: currentLabel, toLabel: targetLabel, role, level: selectedLevel, fromLabel: currentLabel, toLabel: targetLabel,
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
provider: provider as IssueProvider,
pluginConfig, sessionKey, pluginConfig, sessionKey,
}); });
pickups.push({ pickups.push({

View File

@@ -97,6 +97,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url, issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
role, level: selectedLevel, fromLabel: currentLabel, toLabel: targetLabel, role, level: selectedLevel, fromLabel: currentLabel, toLabel: targetLabel,
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
provider,
pluginConfig, sessionKey: ctx.sessionKey, pluginConfig, sessionKey: ctx.sessionKey,
}); });