feat: include PR/MR URL in DEV completion notifications

Adds automatic PR/MR URL detection and inclusion in DEV completion
announcements to provide better visibility into completed work.

Changes:
1. TaskManager interface:
   - Added getMergedMRUrl() method to fetch PR/MR URL for an issue

2. GitHub/GitLab providers:
   - Implemented getMergedMRUrl() to find most recent merged PR/MR
   - Returns URL of PR/MR that references the issue number

3. task_complete tool:
   - Added optional prUrl parameter
   - Auto-detects PR/MR URL if not provided
   - Includes PR URL in announcement with 🔗 prefix
   - Format: 'PR: https://github.com/...'

4. Role templates (dev.md):
   - Updated to instruct workers to include prUrl in task_complete
   - Documents that prUrl is optional (auto-detected as fallback)

Example announcement:
 DEV done #101 — Added PR/MR URL to notifications
🔗 PR: https://github.com/user/repo/pull/102
. Moved to QA queue.

Addresses issue #101
This commit is contained in:
Lauren ten Hoor
2026-02-10 18:22:44 +08:00
parent 83e723df32
commit 09f6903ebb
4 changed files with 95 additions and 1 deletions

View File

@@ -224,6 +224,37 @@ export class GitHubProvider implements TaskManager {
} }
} }
async getMergedMRUrl(issueId: number): Promise<string | null> {
try {
const raw = await this.gh([
"pr", "list",
"--state", "merged",
"--json", "number,title,body,url,mergedAt",
"--limit", "20",
]);
const prs = JSON.parse(raw) as Array<{
number: number;
title: string;
body: string;
url: string;
mergedAt: string;
}>;
const pattern = `#${issueId}`;
// Find the most recently merged PR that references this issue
// PRs are returned in reverse chronological order by default
const matchingPr = prs.find(
(pr) =>
pr.title.includes(pattern) || (pr.body ?? "").includes(pattern),
);
return matchingPr?.url ?? null;
} catch {
return null;
}
}
async addComment(issueId: number, body: string): Promise<void> { async addComment(issueId: number, body: string): Promise<void> {
// Write body to temp file to preserve newlines // Write body to temp file to preserve newlines
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`); const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`);

View File

@@ -187,6 +187,38 @@ export class GitLabProvider implements TaskManager {
} }
} }
async getMergedMRUrl(issueId: number): Promise<string | null> {
try {
const raw = await this.glab([
"mr", "list", "--output", "json", "--state", "merged",
]);
const mrs = JSON.parse(raw) as Array<{
iid: number;
title: string;
description: string;
web_url: string;
merged_at: string;
}>;
const pattern = `#${issueId}`;
// Find the most recently merged MR that references this issue
// Sort by merged_at to get the most recent first
const matchingMr = mrs
.filter(
(mr) =>
mr.title.includes(pattern) || (mr.description ?? "").includes(pattern),
)
.sort((a, b) =>
new Date(b.merged_at).getTime() - new Date(a.merged_at).getTime()
)[0];
return matchingMr?.web_url ?? null;
} catch {
return null;
}
}
async addComment(issueId: number, body: string): Promise<void> { async addComment(issueId: number, body: string): Promise<void> {
// Write body to temp file to preserve newlines // Write body to temp file to preserve newlines
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`); const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`);

View File

@@ -75,6 +75,9 @@ export interface TaskManager {
/** Check if any merged MR/PR exists for a specific issue. */ /** Check if any merged MR/PR exists for a specific issue. */
hasMergedMR(issueId: number): Promise<boolean>; hasMergedMR(issueId: number): Promise<boolean>;
/** Get the URL of the most recently merged MR/PR for a specific issue. Returns null if not found. */
getMergedMRUrl(issueId: number): Promise<string | null>;
/** Add a comment to an issue. */ /** Add a comment to an issue. */
addComment(issueId: number, body: string): Promise<void>; addComment(issueId: number, body: string): Promise<void>;

View File

@@ -57,6 +57,10 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
type: "string", type: "string",
description: "Brief summary for group announcement", description: "Brief summary for group announcement",
}, },
prUrl: {
type: "string",
description: "Pull Request or Merge Request URL (optional, auto-detected if not provided)",
},
}, },
}, },
@@ -65,6 +69,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
const result = params.result as "done" | "pass" | "fail" | "refine" | "blocked"; const result = params.result as "done" | "pass" | "fail" | "refine" | "blocked";
const groupId = params.projectGroupId as string; const groupId = params.projectGroupId as string;
const summary = params.summary as string | undefined; const summary = params.summary as string | undefined;
let prUrl = params.prUrl as string | undefined;
const workspaceDir = ctx.workspaceDir; const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) { if (!workspaceDir) {
@@ -133,11 +138,34 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
output.gitPull = `warning: ${(err as Error).message}`; output.gitPull = `warning: ${(err as Error).message}`;
} }
// Auto-detect PR/MR URL if not provided
if (!prUrl) {
try {
prUrl = await provider.getMergedMRUrl(issueId) ?? undefined;
} catch (err) {
// Ignore errors in PR URL detection
}
}
await deactivateWorker(workspaceDir, groupId, "dev"); await deactivateWorker(workspaceDir, groupId, "dev");
await provider.transitionLabel(issueId, "Doing", "To Test"); await provider.transitionLabel(issueId, "Doing", "To Test");
output.labelTransition = "Doing → To Test"; output.labelTransition = "Doing → To Test";
output.announcement = `✅ DEV done #${issueId}${summary ? `${summary}` : ""}. Moved to QA queue.`;
// Build announcement with PR URL if available
let announcement = `✅ DEV done #${issueId}`;
if (summary) {
announcement += `${summary}`;
}
if (prUrl) {
announcement += `\n🔗 PR: ${prUrl}`;
}
announcement += `. Moved to QA queue.`;
output.announcement = announcement;
if (prUrl) {
output.prUrl = prUrl;
}
if (project.autoChain) { if (project.autoChain) {
try { try {