@desplega.ai/agent-swarm 1.56.2 → 1.56.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.json +26 -1
- package/package.json +1 -1
- package/src/github/handlers.ts +122 -406
- package/src/github/index.ts +8 -1
- package/src/github/mentions.ts +10 -0
- package/src/github/templates.ts +55 -0
- package/src/github/types.ts +2 -0
- package/src/hooks/hook.ts +11 -1
- package/src/prompts/session-templates.ts +35 -0
- package/src/tests/concurrency.test.ts +3 -3
- package/src/tests/github-event-filter.test.ts +301 -0
- package/src/tests/github-event-labels.test.ts +24 -0
- package/src/types.ts +1 -1
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Swarm API",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.56.3",
|
|
6
6
|
"description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
|
@@ -509,6 +509,10 @@
|
|
|
509
509
|
"type": "string",
|
|
510
510
|
"maxLength": 65536
|
|
511
511
|
},
|
|
512
|
+
"heartbeatMd": {
|
|
513
|
+
"type": "string",
|
|
514
|
+
"maxLength": 65536
|
|
515
|
+
},
|
|
512
516
|
"changeSource": {
|
|
513
517
|
"type": "string"
|
|
514
518
|
},
|
|
@@ -1911,6 +1915,27 @@
|
|
|
1911
1915
|
}
|
|
1912
1916
|
}
|
|
1913
1917
|
},
|
|
1918
|
+
"/api/heartbeat/checklist": {
|
|
1919
|
+
"post": {
|
|
1920
|
+
"summary": "Trigger an immediate heartbeat checklist check",
|
|
1921
|
+
"tags": [
|
|
1922
|
+
"Heartbeat"
|
|
1923
|
+
],
|
|
1924
|
+
"security": [
|
|
1925
|
+
{
|
|
1926
|
+
"bearerAuth": []
|
|
1927
|
+
}
|
|
1928
|
+
],
|
|
1929
|
+
"responses": {
|
|
1930
|
+
"200": {
|
|
1931
|
+
"description": "Checklist check completed successfully"
|
|
1932
|
+
},
|
|
1933
|
+
"401": {
|
|
1934
|
+
"description": "Unauthorized"
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
},
|
|
1914
1939
|
"/api/memory/index": {
|
|
1915
1940
|
"post": {
|
|
1916
1941
|
"summary": "Ingest content into memory system (async embedding)",
|
package/package.json
CHANGED
package/src/github/handlers.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { createTaskExtended, failTask, findTaskByVcs, getAllAgents } from "../be/db";
|
|
2
2
|
import { resolveTemplate } from "../prompts/resolver";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
detectMention,
|
|
5
|
+
extractMentionContext,
|
|
6
|
+
GITHUB_BOT_NAME,
|
|
7
|
+
isBotAssignee,
|
|
8
|
+
isSwarmLabel,
|
|
9
|
+
} from "./mentions";
|
|
4
10
|
import { addIssueReaction, addReaction } from "./reactions";
|
|
5
11
|
// Side-effect import: registers all GitHub event templates in the in-memory registry
|
|
6
12
|
import "./templates";
|
|
@@ -315,42 +321,32 @@ export async function handlePullRequest(
|
|
|
315
321
|
return { created: false };
|
|
316
322
|
}
|
|
317
323
|
|
|
318
|
-
// Handle
|
|
319
|
-
if (action === "
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (!task) {
|
|
323
|
-
// No task for this PR, nothing to notify
|
|
324
|
+
// Handle labeled action - swarm label added to PR
|
|
325
|
+
if (action === "labeled") {
|
|
326
|
+
const labelName = event.label?.name;
|
|
327
|
+
if (!labelName || !isSwarmLabel(labelName)) {
|
|
324
328
|
return { created: false };
|
|
325
329
|
}
|
|
326
330
|
|
|
327
331
|
// Deduplicate
|
|
328
|
-
const eventKey = `pr-
|
|
332
|
+
const eventKey = `pr-labeled:${repository.full_name}:${pr.number}:${labelName}`;
|
|
329
333
|
if (isDuplicate(eventKey)) {
|
|
330
334
|
return { created: false };
|
|
331
335
|
}
|
|
332
336
|
|
|
333
337
|
const lead = findLeadAgent();
|
|
334
|
-
const wasMerged = pr.merged;
|
|
335
|
-
const emoji = wasMerged ? "🎉" : "❌";
|
|
336
|
-
const status = wasMerged ? "MERGED" : "CLOSED";
|
|
337
|
-
const mergedBy = wasMerged && pr.merged_by ? ` by ${pr.merged_by.login}` : "";
|
|
338
|
-
const followUpSuggestion = wasMerged
|
|
339
|
-
? "💡 PR successfully merged! Update any related issues or documentation."
|
|
340
|
-
: "💡 PR was closed without merging. Review if follow-up is needed.";
|
|
341
|
-
|
|
342
338
|
const result = resolveTemplate(
|
|
343
|
-
"github.pull_request.
|
|
339
|
+
"github.pull_request.labeled",
|
|
344
340
|
{
|
|
345
|
-
status_emoji: emoji,
|
|
346
341
|
pr_number: pr.number,
|
|
347
|
-
status,
|
|
348
|
-
merged_by: mergedBy,
|
|
349
342
|
pr_title: pr.title,
|
|
343
|
+
label_name: labelName,
|
|
344
|
+
sender_login: sender.login,
|
|
350
345
|
repo_full_name: repository.full_name,
|
|
346
|
+
head_ref: pr.head.ref,
|
|
347
|
+
base_ref: pr.base.ref,
|
|
351
348
|
pr_url: pr.html_url,
|
|
352
|
-
|
|
353
|
-
follow_up_suggestion: followUpSuggestion,
|
|
349
|
+
context: pr.body || pr.title,
|
|
354
350
|
},
|
|
355
351
|
{ agentId: lead?.id, repoId: repository.full_name },
|
|
356
352
|
);
|
|
@@ -359,11 +355,11 @@ export async function handlePullRequest(
|
|
|
359
355
|
return { created: false };
|
|
360
356
|
}
|
|
361
357
|
|
|
362
|
-
const
|
|
358
|
+
const task = createTaskExtended(result.text, {
|
|
363
359
|
agentId: lead?.id ?? "",
|
|
364
360
|
source: "github",
|
|
365
361
|
vcsProvider: "github",
|
|
366
|
-
taskType: "github-pr
|
|
362
|
+
taskType: "github-pr",
|
|
367
363
|
vcsRepo: repository.full_name,
|
|
368
364
|
vcsEventType: "pull_request",
|
|
369
365
|
vcsNumber: pr.number,
|
|
@@ -371,64 +367,37 @@ export async function handlePullRequest(
|
|
|
371
367
|
vcsUrl: pr.html_url,
|
|
372
368
|
});
|
|
373
369
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (action === "synchronize") {
|
|
383
|
-
// Find the related task
|
|
384
|
-
const task = findTaskByVcs(repository.full_name, pr.number);
|
|
385
|
-
if (!task) {
|
|
386
|
-
// No task for this PR, nothing to notify
|
|
387
|
-
return { created: false };
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// Deduplicate using SHA to avoid duplicate notifications for same push
|
|
391
|
-
const eventKey = `pr-sync:${repository.full_name}:${pr.number}:${pr.head.sha}`;
|
|
392
|
-
if (isDuplicate(eventKey)) {
|
|
393
|
-
return { created: false };
|
|
370
|
+
if (lead) {
|
|
371
|
+
console.log(
|
|
372
|
+
`[GitHub] Created task ${task.id} for PR #${pr.number} (labeled: ${labelName}) -> ${lead.name}`,
|
|
373
|
+
);
|
|
374
|
+
} else {
|
|
375
|
+
console.log(
|
|
376
|
+
`[GitHub] Created unassigned task ${task.id} for PR #${pr.number} (labeled: ${labelName}, no lead available)`,
|
|
377
|
+
);
|
|
394
378
|
}
|
|
395
379
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
"github.pull_request.synchronize",
|
|
399
|
-
{
|
|
400
|
-
pr_number: pr.number,
|
|
401
|
-
pr_title: pr.title,
|
|
402
|
-
repo_full_name: repository.full_name,
|
|
403
|
-
head_ref: pr.head.ref,
|
|
404
|
-
head_sha_short: pr.head.sha.substring(0, 7),
|
|
405
|
-
pr_url: pr.html_url,
|
|
406
|
-
related_task_id: task.id,
|
|
407
|
-
},
|
|
408
|
-
{ agentId: lead?.id, repoId: repository.full_name },
|
|
409
|
-
);
|
|
410
|
-
|
|
411
|
-
if (result.skipped) {
|
|
412
|
-
return { created: false };
|
|
380
|
+
if (installation?.id) {
|
|
381
|
+
addIssueReaction(repository.full_name, pr.number, "eyes", installation.id);
|
|
413
382
|
}
|
|
414
383
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
source: "github",
|
|
418
|
-
vcsProvider: "github",
|
|
419
|
-
taskType: "github-pr-update",
|
|
420
|
-
vcsRepo: repository.full_name,
|
|
421
|
-
vcsEventType: "pull_request",
|
|
422
|
-
vcsNumber: pr.number,
|
|
423
|
-
vcsAuthor: sender.login,
|
|
424
|
-
vcsUrl: pr.html_url,
|
|
425
|
-
});
|
|
384
|
+
return { created: true, taskId: task.id };
|
|
385
|
+
}
|
|
426
386
|
|
|
387
|
+
// Suppressed: see thoughts/taras/plans/2026-03-30-github-event-safety-defaults.md
|
|
388
|
+
if (action === "closed") {
|
|
427
389
|
console.log(
|
|
428
|
-
`[GitHub]
|
|
390
|
+
`[GitHub:suppressed] pull_request.closed on ${repository.full_name}#${pr.number} — lifecycle events disabled by default`,
|
|
429
391
|
);
|
|
392
|
+
return { created: false };
|
|
393
|
+
}
|
|
430
394
|
|
|
431
|
-
|
|
395
|
+
// Suppressed: see thoughts/taras/plans/2026-03-30-github-event-safety-defaults.md
|
|
396
|
+
if (action === "synchronize") {
|
|
397
|
+
console.log(
|
|
398
|
+
`[GitHub:suppressed] pull_request.synchronize on ${repository.full_name}#${pr.number} — lifecycle events disabled by default`,
|
|
399
|
+
);
|
|
400
|
+
return { created: false };
|
|
432
401
|
}
|
|
433
402
|
|
|
434
403
|
// Only handle opened/edited actions for mention-based flow
|
|
@@ -595,6 +564,67 @@ export async function handleIssue(
|
|
|
595
564
|
return { created: false };
|
|
596
565
|
}
|
|
597
566
|
|
|
567
|
+
// Handle labeled action - swarm label added to issue
|
|
568
|
+
if (action === "labeled") {
|
|
569
|
+
const labelName = event.label?.name;
|
|
570
|
+
if (!labelName || !isSwarmLabel(labelName)) {
|
|
571
|
+
return { created: false };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Deduplicate
|
|
575
|
+
const eventKey = `issue-labeled:${repository.full_name}:${issue.number}:${labelName}`;
|
|
576
|
+
if (isDuplicate(eventKey)) {
|
|
577
|
+
return { created: false };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const lead = findLeadAgent();
|
|
581
|
+
const result = resolveTemplate(
|
|
582
|
+
"github.issue.labeled",
|
|
583
|
+
{
|
|
584
|
+
issue_number: issue.number,
|
|
585
|
+
issue_title: issue.title,
|
|
586
|
+
label_name: labelName,
|
|
587
|
+
sender_login: sender.login,
|
|
588
|
+
repo_full_name: repository.full_name,
|
|
589
|
+
issue_url: issue.html_url,
|
|
590
|
+
context: issue.body || issue.title,
|
|
591
|
+
},
|
|
592
|
+
{ agentId: lead?.id, repoId: repository.full_name },
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
if (result.skipped) {
|
|
596
|
+
return { created: false };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const task = createTaskExtended(result.text, {
|
|
600
|
+
agentId: lead?.id ?? "",
|
|
601
|
+
source: "github",
|
|
602
|
+
vcsProvider: "github",
|
|
603
|
+
taskType: "github-issue",
|
|
604
|
+
vcsRepo: repository.full_name,
|
|
605
|
+
vcsEventType: "issues",
|
|
606
|
+
vcsNumber: issue.number,
|
|
607
|
+
vcsAuthor: sender.login,
|
|
608
|
+
vcsUrl: issue.html_url,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
if (lead) {
|
|
612
|
+
console.log(
|
|
613
|
+
`[GitHub] Created task ${task.id} for issue #${issue.number} (labeled: ${labelName}) -> ${lead.name}`,
|
|
614
|
+
);
|
|
615
|
+
} else {
|
|
616
|
+
console.log(
|
|
617
|
+
`[GitHub] Created unassigned task ${task.id} for issue #${issue.number} (labeled: ${labelName}, no lead available)`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (installation?.id) {
|
|
622
|
+
addIssueReaction(repository.full_name, issue.number, "eyes", installation.id);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return { created: true, taskId: task.id };
|
|
626
|
+
}
|
|
627
|
+
|
|
598
628
|
// Only handle opened/edited actions for mention-based flow
|
|
599
629
|
if (action !== "opened" && action !== "edited") {
|
|
600
630
|
return { created: false };
|
|
@@ -770,103 +800,13 @@ export async function handleComment(
|
|
|
770
800
|
export async function handlePullRequestReview(
|
|
771
801
|
event: PullRequestReviewEvent,
|
|
772
802
|
): Promise<{ created: boolean; taskId?: string }> {
|
|
773
|
-
const { action,
|
|
803
|
+
const { action, pull_request: pr, repository } = event;
|
|
774
804
|
|
|
775
|
-
//
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
return { created: false };
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// Skip "commented" reviews that are empty - these are often just line comments
|
|
782
|
-
// without an overall review body
|
|
783
|
-
if (review.state === "commented" && !review.body) {
|
|
784
|
-
return { created: false };
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// Deduplicate
|
|
788
|
-
const eventKey = `pr-review:${repository.full_name}:${pr.number}:${review.id}`;
|
|
789
|
-
if (isDuplicate(eventKey)) {
|
|
790
|
-
return { created: false };
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// Find any existing task for this PR
|
|
794
|
-
const existingTask = findTaskByVcs(repository.full_name, pr.number);
|
|
795
|
-
|
|
796
|
-
// Only notify for PRs where bot is creator or already has a task
|
|
797
|
-
const isBotCreator = isBotAssignee(pr.user.login);
|
|
798
|
-
if (!isBotCreator && !existingTask) {
|
|
799
|
-
return { created: false };
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
// Find lead agent for new task
|
|
803
|
-
const lead = findLeadAgent();
|
|
804
|
-
|
|
805
|
-
// Get review state info
|
|
806
|
-
const { emoji, label } = getReviewStateInfo(review.state);
|
|
807
|
-
|
|
808
|
-
// Build task description
|
|
809
|
-
const reviewBodySection = review.body ? `\n\nReview Comment:\n${review.body}` : "";
|
|
810
|
-
const relatedTaskSection = existingTask
|
|
811
|
-
? `Related task: ${existingTask.id}\n🔀 Consider routing to the same agent working on the related task.\n`
|
|
812
|
-
: "";
|
|
813
|
-
const reviewSuggestions =
|
|
814
|
-
review.state === "approved"
|
|
815
|
-
? "💡 Suggested: Merge the PR or wait for additional reviews"
|
|
816
|
-
: review.state === "changes_requested"
|
|
817
|
-
? "💡 Suggested: Address the requested changes and update the PR"
|
|
818
|
-
: "💡 Suggested: Review the feedback and respond if needed";
|
|
819
|
-
|
|
820
|
-
const result = resolveTemplate(
|
|
821
|
-
"github.pull_request.review_submitted",
|
|
822
|
-
{
|
|
823
|
-
review_emoji: emoji,
|
|
824
|
-
pr_number: pr.number,
|
|
825
|
-
review_label: label,
|
|
826
|
-
pr_title: pr.title,
|
|
827
|
-
sender_login: sender.login,
|
|
828
|
-
repo_full_name: repository.full_name,
|
|
829
|
-
review_url: review.html_url,
|
|
830
|
-
review_body_section: reviewBodySection,
|
|
831
|
-
related_task_section: relatedTaskSection,
|
|
832
|
-
review_suggestions: reviewSuggestions,
|
|
833
|
-
},
|
|
834
|
-
{ agentId: lead?.id, repoId: repository.full_name },
|
|
805
|
+
// Suppressed: see thoughts/taras/plans/2026-03-30-github-event-safety-defaults.md
|
|
806
|
+
console.log(
|
|
807
|
+
`[GitHub:suppressed] pull_request_review.${action} on ${repository.full_name}#${pr.number} — review events disabled by default`,
|
|
835
808
|
);
|
|
836
|
-
|
|
837
|
-
if (result.skipped) {
|
|
838
|
-
return { created: false };
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// Create task (assigned to lead if available, otherwise unassigned)
|
|
842
|
-
const task = createTaskExtended(result.text, {
|
|
843
|
-
agentId: lead?.id ?? "",
|
|
844
|
-
source: "github",
|
|
845
|
-
vcsProvider: "github",
|
|
846
|
-
taskType: "github-review",
|
|
847
|
-
vcsRepo: repository.full_name,
|
|
848
|
-
vcsEventType: "pull_request_review",
|
|
849
|
-
vcsNumber: pr.number,
|
|
850
|
-
vcsAuthor: sender.login,
|
|
851
|
-
vcsUrl: review.html_url,
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
if (lead) {
|
|
855
|
-
console.log(
|
|
856
|
-
`[GitHub] Created task ${task.id} for PR #${pr.number} review (${review.state}) -> ${lead.name}`,
|
|
857
|
-
);
|
|
858
|
-
} else {
|
|
859
|
-
console.log(
|
|
860
|
-
`[GitHub] Created unassigned task ${task.id} for PR #${pr.number} review (${review.state}, no lead available)`,
|
|
861
|
-
);
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// Add reaction to acknowledge the review
|
|
865
|
-
if (installation?.id) {
|
|
866
|
-
addIssueReaction(repository.full_name, pr.number, "eyes", installation.id);
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
return { created: true, taskId: task.id };
|
|
809
|
+
return { created: false };
|
|
870
810
|
}
|
|
871
811
|
|
|
872
812
|
/**
|
|
@@ -879,89 +819,12 @@ export async function handleCheckRun(
|
|
|
879
819
|
): Promise<{ created: boolean; taskId?: string }> {
|
|
880
820
|
const { action, check_run, repository } = event;
|
|
881
821
|
|
|
882
|
-
//
|
|
883
|
-
|
|
884
|
-
return { created: false };
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
// Only notify on failure or action_required - success is less critical
|
|
888
|
-
// Skip neutral/skipped/cancelled as they're usually not actionable
|
|
889
|
-
const conclusion = check_run.conclusion;
|
|
890
|
-
if (conclusion !== "failure" && conclusion !== "action_required") {
|
|
891
|
-
return { created: false };
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
// Must be associated with at least one PR
|
|
895
|
-
if (!check_run.pull_requests || check_run.pull_requests.length === 0) {
|
|
896
|
-
return { created: false };
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
// Check if we have a task for any of these PRs
|
|
900
|
-
let relatedTask = null;
|
|
901
|
-
let prNumber = 0;
|
|
902
|
-
for (const pr of check_run.pull_requests) {
|
|
903
|
-
const task = findTaskByVcs(repository.full_name, pr.number);
|
|
904
|
-
if (task) {
|
|
905
|
-
relatedTask = task;
|
|
906
|
-
prNumber = pr.number;
|
|
907
|
-
break;
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
if (!relatedTask) {
|
|
912
|
-
// No task for any of the associated PRs
|
|
913
|
-
return { created: false };
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
// Deduplicate
|
|
917
|
-
const eventKey = `check-run:${repository.full_name}:${check_run.id}`;
|
|
918
|
-
if (isDuplicate(eventKey)) {
|
|
919
|
-
return { created: false };
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
const lead = findLeadAgent();
|
|
923
|
-
const { emoji, label } = getCheckConclusionInfo(conclusion);
|
|
924
|
-
|
|
925
|
-
const outputSummarySection = check_run.output.summary
|
|
926
|
-
? `\n\nSummary:\n${check_run.output.summary.substring(0, 500)}`
|
|
927
|
-
: "";
|
|
928
|
-
|
|
929
|
-
const result = resolveTemplate(
|
|
930
|
-
"github.check_run.failed",
|
|
931
|
-
{
|
|
932
|
-
conclusion_emoji: emoji,
|
|
933
|
-
pr_number: prNumber,
|
|
934
|
-
check_name: check_run.name,
|
|
935
|
-
conclusion_label: label,
|
|
936
|
-
repo_full_name: repository.full_name,
|
|
937
|
-
check_url: check_run.html_url,
|
|
938
|
-
output_summary_section: outputSummarySection,
|
|
939
|
-
related_task_id: relatedTask.id,
|
|
940
|
-
},
|
|
941
|
-
{ agentId: lead?.id, repoId: repository.full_name },
|
|
942
|
-
);
|
|
943
|
-
|
|
944
|
-
if (result.skipped) {
|
|
945
|
-
return { created: false };
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
const task = createTaskExtended(result.text, {
|
|
949
|
-
agentId: lead?.id ?? "",
|
|
950
|
-
source: "github",
|
|
951
|
-
vcsProvider: "github",
|
|
952
|
-
taskType: "github-ci",
|
|
953
|
-
vcsRepo: repository.full_name,
|
|
954
|
-
vcsEventType: "check_run",
|
|
955
|
-
vcsNumber: prNumber,
|
|
956
|
-
vcsAuthor: "",
|
|
957
|
-
vcsUrl: check_run.html_url,
|
|
958
|
-
});
|
|
959
|
-
|
|
822
|
+
// Suppressed: see thoughts/taras/plans/2026-03-30-github-event-safety-defaults.md
|
|
823
|
+
const conclusion = check_run.conclusion ?? "unknown";
|
|
960
824
|
console.log(
|
|
961
|
-
`[GitHub]
|
|
825
|
+
`[GitHub:suppressed] check_run.${action} (${conclusion}) on ${repository.full_name} — CI events disabled by default`,
|
|
962
826
|
);
|
|
963
|
-
|
|
964
|
-
return { created: true, taskId: task.id };
|
|
827
|
+
return { created: false };
|
|
965
828
|
}
|
|
966
829
|
|
|
967
830
|
/**
|
|
@@ -974,84 +837,12 @@ export async function handleCheckSuite(
|
|
|
974
837
|
): Promise<{ created: boolean; taskId?: string }> {
|
|
975
838
|
const { action, check_suite, repository } = event;
|
|
976
839
|
|
|
977
|
-
//
|
|
978
|
-
|
|
979
|
-
return { created: false };
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
// Only notify on failure - success notifications would be too noisy
|
|
983
|
-
const conclusion = check_suite.conclusion;
|
|
984
|
-
if (conclusion !== "failure") {
|
|
985
|
-
return { created: false };
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
// Must be associated with at least one PR
|
|
989
|
-
if (!check_suite.pull_requests || check_suite.pull_requests.length === 0) {
|
|
990
|
-
return { created: false };
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
// Check if we have a task for any of these PRs
|
|
994
|
-
let relatedTask = null;
|
|
995
|
-
let prNumber = 0;
|
|
996
|
-
for (const pr of check_suite.pull_requests) {
|
|
997
|
-
const task = findTaskByVcs(repository.full_name, pr.number);
|
|
998
|
-
if (task) {
|
|
999
|
-
relatedTask = task;
|
|
1000
|
-
prNumber = pr.number;
|
|
1001
|
-
break;
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
if (!relatedTask) {
|
|
1006
|
-
// No task for any of the associated PRs
|
|
1007
|
-
return { created: false };
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
// Deduplicate
|
|
1011
|
-
const eventKey = `check-suite:${repository.full_name}:${check_suite.id}`;
|
|
1012
|
-
if (isDuplicate(eventKey)) {
|
|
1013
|
-
return { created: false };
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
const lead = findLeadAgent();
|
|
1017
|
-
const { emoji, label } = getCheckConclusionInfo(conclusion);
|
|
1018
|
-
const branch = check_suite.head_branch ?? "unknown";
|
|
1019
|
-
|
|
1020
|
-
const result = resolveTemplate(
|
|
1021
|
-
"github.check_suite.failed",
|
|
1022
|
-
{
|
|
1023
|
-
conclusion_emoji: emoji,
|
|
1024
|
-
pr_number: prNumber,
|
|
1025
|
-
conclusion_label: label,
|
|
1026
|
-
repo_full_name: repository.full_name,
|
|
1027
|
-
branch,
|
|
1028
|
-
head_sha_short: check_suite.head_sha.substring(0, 7),
|
|
1029
|
-
related_task_id: relatedTask.id,
|
|
1030
|
-
},
|
|
1031
|
-
{ agentId: lead?.id, repoId: repository.full_name },
|
|
1032
|
-
);
|
|
1033
|
-
|
|
1034
|
-
if (result.skipped) {
|
|
1035
|
-
return { created: false };
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
const task = createTaskExtended(result.text, {
|
|
1039
|
-
agentId: lead?.id ?? "",
|
|
1040
|
-
source: "github",
|
|
1041
|
-
vcsProvider: "github",
|
|
1042
|
-
taskType: "github-ci",
|
|
1043
|
-
vcsRepo: repository.full_name,
|
|
1044
|
-
vcsEventType: "check_suite",
|
|
1045
|
-
vcsNumber: prNumber,
|
|
1046
|
-
vcsAuthor: "",
|
|
1047
|
-
vcsUrl: repository.html_url,
|
|
1048
|
-
});
|
|
1049
|
-
|
|
840
|
+
// Suppressed: see thoughts/taras/plans/2026-03-30-github-event-safety-defaults.md
|
|
841
|
+
const conclusion = check_suite.conclusion ?? "unknown";
|
|
1050
842
|
console.log(
|
|
1051
|
-
`[GitHub]
|
|
843
|
+
`[GitHub:suppressed] check_suite.${action} (${conclusion}) on ${repository.full_name} — CI events disabled by default`,
|
|
1052
844
|
);
|
|
1053
|
-
|
|
1054
|
-
return { created: true, taskId: task.id };
|
|
845
|
+
return { created: false };
|
|
1055
846
|
}
|
|
1056
847
|
|
|
1057
848
|
/**
|
|
@@ -1065,87 +856,12 @@ export async function handleCheckSuite(
|
|
|
1065
856
|
export async function handleWorkflowRun(
|
|
1066
857
|
event: WorkflowRunEvent,
|
|
1067
858
|
): Promise<{ created: boolean; taskId?: string }> {
|
|
1068
|
-
const { action, workflow_run,
|
|
1069
|
-
|
|
1070
|
-
// Only handle completed workflow runs
|
|
1071
|
-
if (action !== "completed") {
|
|
1072
|
-
return { created: false };
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
// Only notify on failure - success notifications would be too noisy
|
|
1076
|
-
const conclusion = workflow_run.conclusion;
|
|
1077
|
-
if (conclusion !== "failure") {
|
|
1078
|
-
return { created: false };
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
// Must be associated with at least one PR
|
|
1082
|
-
if (!workflow_run.pull_requests || workflow_run.pull_requests.length === 0) {
|
|
1083
|
-
return { created: false };
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
// Check if we have a task for any of these PRs
|
|
1087
|
-
let relatedTask = null;
|
|
1088
|
-
let prNumber = 0;
|
|
1089
|
-
for (const pr of workflow_run.pull_requests) {
|
|
1090
|
-
const task = findTaskByVcs(repository.full_name, pr.number);
|
|
1091
|
-
if (task) {
|
|
1092
|
-
relatedTask = task;
|
|
1093
|
-
prNumber = pr.number;
|
|
1094
|
-
break;
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
if (!relatedTask) {
|
|
1099
|
-
// No task for any of the associated PRs
|
|
1100
|
-
return { created: false };
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
// Deduplicate
|
|
1104
|
-
const eventKey = `workflow-run:${repository.full_name}:${workflow_run.id}`;
|
|
1105
|
-
if (isDuplicate(eventKey)) {
|
|
1106
|
-
return { created: false };
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
const lead = findLeadAgent();
|
|
1110
|
-
const { emoji, label } = getCheckConclusionInfo(conclusion);
|
|
1111
|
-
|
|
1112
|
-
const result = resolveTemplate(
|
|
1113
|
-
"github.workflow_run.failed",
|
|
1114
|
-
{
|
|
1115
|
-
conclusion_emoji: emoji,
|
|
1116
|
-
pr_number: prNumber,
|
|
1117
|
-
workflow_run_name: workflow_run.name,
|
|
1118
|
-
conclusion_label: label,
|
|
1119
|
-
repo_full_name: repository.full_name,
|
|
1120
|
-
workflow_name: workflow.name,
|
|
1121
|
-
run_number: workflow_run.run_number,
|
|
1122
|
-
head_branch: workflow_run.head_branch,
|
|
1123
|
-
trigger_event: workflow_run.event,
|
|
1124
|
-
logs_url: workflow_run.html_url,
|
|
1125
|
-
related_task_id: relatedTask.id,
|
|
1126
|
-
},
|
|
1127
|
-
{ agentId: lead?.id, repoId: repository.full_name },
|
|
1128
|
-
);
|
|
1129
|
-
|
|
1130
|
-
if (result.skipped) {
|
|
1131
|
-
return { created: false };
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
const task = createTaskExtended(result.text, {
|
|
1135
|
-
agentId: lead?.id ?? "",
|
|
1136
|
-
source: "github",
|
|
1137
|
-
vcsProvider: "github",
|
|
1138
|
-
taskType: "github-ci",
|
|
1139
|
-
vcsRepo: repository.full_name,
|
|
1140
|
-
vcsEventType: "workflow_run",
|
|
1141
|
-
vcsNumber: prNumber,
|
|
1142
|
-
vcsAuthor: "",
|
|
1143
|
-
vcsUrl: workflow_run.html_url,
|
|
1144
|
-
});
|
|
859
|
+
const { action, workflow_run, repository } = event;
|
|
1145
860
|
|
|
861
|
+
// Suppressed: see thoughts/taras/plans/2026-03-30-github-event-safety-defaults.md
|
|
862
|
+
const conclusion = workflow_run.conclusion ?? "unknown";
|
|
1146
863
|
console.log(
|
|
1147
|
-
`[GitHub]
|
|
864
|
+
`[GitHub:suppressed] workflow_run.${action} (${conclusion}) on ${repository.full_name} — CI events disabled by default`,
|
|
1148
865
|
);
|
|
1149
|
-
|
|
1150
|
-
return { created: true, taskId: task.id };
|
|
866
|
+
return { created: false };
|
|
1151
867
|
}
|
package/src/github/index.ts
CHANGED
|
@@ -17,7 +17,14 @@ export {
|
|
|
17
17
|
handlePullRequestReview,
|
|
18
18
|
handleWorkflowRun,
|
|
19
19
|
} from "./handlers";
|
|
20
|
-
export {
|
|
20
|
+
export {
|
|
21
|
+
detectMention,
|
|
22
|
+
extractMentionContext,
|
|
23
|
+
GITHUB_BOT_NAME,
|
|
24
|
+
GITHUB_EVENT_LABELS,
|
|
25
|
+
isBotAssignee,
|
|
26
|
+
isSwarmLabel,
|
|
27
|
+
} from "./mentions";
|
|
21
28
|
export type { ReactionType } from "./reactions";
|
|
22
29
|
export { addIssueReaction, addReaction, postComment } from "./reactions";
|
|
23
30
|
export type {
|
package/src/github/mentions.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
// Bot name for @mentions (can be overridden via env)
|
|
2
2
|
export const GITHUB_BOT_NAME = process.env.GITHUB_BOT_NAME || "agent-swarm-bot";
|
|
3
3
|
|
|
4
|
+
// Labels that trigger agent action on PR/issue label events (comma-separated env var)
|
|
5
|
+
const GITHUB_EVENT_LABELS_RAW = process.env.GITHUB_EVENT_LABELS || "swarm-review";
|
|
6
|
+
export const GITHUB_EVENT_LABELS: string[] = GITHUB_EVENT_LABELS_RAW.split(",")
|
|
7
|
+
.map((l) => l.trim().toLowerCase())
|
|
8
|
+
.filter(Boolean);
|
|
9
|
+
|
|
10
|
+
export function isSwarmLabel(label: string): boolean {
|
|
11
|
+
return GITHUB_EVENT_LABELS.includes(label.toLowerCase());
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
// Additional aliases that also trigger the bot (comma-separated env var)
|
|
5
15
|
// Example: GITHUB_BOT_ALIASES=heysidekick,sidekick,review-bot
|
|
6
16
|
function computeBotNames(): string[] {
|
package/src/github/templates.ts
CHANGED
|
@@ -197,6 +197,35 @@ Context:
|
|
|
197
197
|
category: "event",
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
registerTemplate({
|
|
201
|
+
eventType: "github.pull_request.labeled",
|
|
202
|
+
header: "[GitHub PR #{{pr_number}}] {{pr_title}}",
|
|
203
|
+
defaultBody: `Label added: {{label_name}}
|
|
204
|
+
From: {{sender_login}}
|
|
205
|
+
Repo: {{repo_full_name}}
|
|
206
|
+
Branch: {{head_ref}} → {{base_ref}}
|
|
207
|
+
URL: {{pr_url}}
|
|
208
|
+
|
|
209
|
+
Context:
|
|
210
|
+
{{context}}
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
{{@template[common.delegation_instruction]}}
|
|
214
|
+
{{@template[common.command_suggestions.github_pr]}}`,
|
|
215
|
+
variables: [
|
|
216
|
+
{ name: "pr_number", description: "Pull request number" },
|
|
217
|
+
{ name: "pr_title", description: "Pull request title" },
|
|
218
|
+
{ name: "label_name", description: "Label that was added" },
|
|
219
|
+
{ name: "sender_login", description: "Event sender login" },
|
|
220
|
+
{ name: "repo_full_name", description: "Repository full name (owner/repo)" },
|
|
221
|
+
{ name: "head_ref", description: "Head branch name" },
|
|
222
|
+
{ name: "base_ref", description: "Base branch name" },
|
|
223
|
+
{ name: "pr_url", description: "Pull request HTML URL" },
|
|
224
|
+
{ name: "context", description: "PR body or title as context" },
|
|
225
|
+
],
|
|
226
|
+
category: "event",
|
|
227
|
+
});
|
|
228
|
+
|
|
200
229
|
// ============================================================================
|
|
201
230
|
// Issue events
|
|
202
231
|
// ============================================================================
|
|
@@ -251,6 +280,32 @@ Context:
|
|
|
251
280
|
category: "event",
|
|
252
281
|
});
|
|
253
282
|
|
|
283
|
+
registerTemplate({
|
|
284
|
+
eventType: "github.issue.labeled",
|
|
285
|
+
header: "[GitHub Issue #{{issue_number}}] {{issue_title}}",
|
|
286
|
+
defaultBody: `Label added: {{label_name}}
|
|
287
|
+
From: {{sender_login}}
|
|
288
|
+
Repo: {{repo_full_name}}
|
|
289
|
+
URL: {{issue_url}}
|
|
290
|
+
|
|
291
|
+
Context:
|
|
292
|
+
{{context}}
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
{{@template[common.delegation_instruction]}}
|
|
296
|
+
{{@template[common.command_suggestions.github_issue]}}`,
|
|
297
|
+
variables: [
|
|
298
|
+
{ name: "issue_number", description: "Issue number" },
|
|
299
|
+
{ name: "issue_title", description: "Issue title" },
|
|
300
|
+
{ name: "label_name", description: "Label that was added" },
|
|
301
|
+
{ name: "sender_login", description: "Event sender login" },
|
|
302
|
+
{ name: "repo_full_name", description: "Repository full name (owner/repo)" },
|
|
303
|
+
{ name: "issue_url", description: "Issue HTML URL" },
|
|
304
|
+
{ name: "context", description: "Issue body or title as context" },
|
|
305
|
+
],
|
|
306
|
+
category: "event",
|
|
307
|
+
});
|
|
308
|
+
|
|
254
309
|
// ============================================================================
|
|
255
310
|
// Comment events
|
|
256
311
|
// ============================================================================
|
package/src/github/types.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface PullRequestEvent extends GitHubWebhookEvent {
|
|
|
20
20
|
changed_files?: number;
|
|
21
21
|
};
|
|
22
22
|
requested_reviewer?: { login: string; id: number }; // Added for review_requested/review_request_removed events
|
|
23
|
+
label?: { id: number; name: string; color: string }; // Added for labeled/unlabeled events
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export interface IssueEvent extends GitHubWebhookEvent {
|
|
@@ -30,6 +31,7 @@ export interface IssueEvent extends GitHubWebhookEvent {
|
|
|
30
31
|
html_url: string;
|
|
31
32
|
user: { login: string };
|
|
32
33
|
};
|
|
34
|
+
label?: { id: number; name: string; color: string }; // Added for labeled/unlabeled events
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export interface CommentEvent extends GitHubWebhookEvent {
|
package/src/hooks/hook.ts
CHANGED
|
@@ -14,6 +14,7 @@ const CLAUDE_MD_BACKUP_PATH = `${process.env.HOME}/.claude/CLAUDE.md.bak`;
|
|
|
14
14
|
const SOUL_MD_PATH = "/workspace/SOUL.md";
|
|
15
15
|
const IDENTITY_MD_PATH = "/workspace/IDENTITY.md";
|
|
16
16
|
const TOOLS_MD_PATH = "/workspace/TOOLS.md";
|
|
17
|
+
const HEARTBEAT_MD_PATH = "/workspace/HEARTBEAT.md";
|
|
17
18
|
const SETUP_SCRIPT_PATH = "/workspace/start-up.sh";
|
|
18
19
|
|
|
19
20
|
type McpServerConfig = {
|
|
@@ -389,6 +390,14 @@ export async function handleHook(): Promise<void> {
|
|
|
389
390
|
}
|
|
390
391
|
}
|
|
391
392
|
|
|
393
|
+
const heartbeatFile = Bun.file(HEARTBEAT_MD_PATH);
|
|
394
|
+
if (await heartbeatFile.exists()) {
|
|
395
|
+
const content = await heartbeatFile.text();
|
|
396
|
+
if (content.length <= 65536) {
|
|
397
|
+
updates.heartbeatMd = content;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
392
401
|
if (Object.keys(updates).length === 0) return;
|
|
393
402
|
|
|
394
403
|
try {
|
|
@@ -910,7 +919,8 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
|
|
|
910
919
|
if (
|
|
911
920
|
editedPath === SOUL_MD_PATH ||
|
|
912
921
|
editedPath === IDENTITY_MD_PATH ||
|
|
913
|
-
editedPath === TOOLS_MD_PATH
|
|
922
|
+
editedPath === TOOLS_MD_PATH ||
|
|
923
|
+
editedPath === HEARTBEAT_MD_PATH
|
|
914
924
|
) {
|
|
915
925
|
await syncIdentityFilesToServer(agentInfo.id, "self_edit");
|
|
916
926
|
}
|
|
@@ -77,6 +77,10 @@ Available Slack tools:
|
|
|
77
77
|
- \`slack-read\`: Read thread/channel history (use taskId or channelId)
|
|
78
78
|
- \`slack-list-channels\`: Discover available Slack channels the bot can access
|
|
79
79
|
|
|
80
|
+
#### Identity & profile tools
|
|
81
|
+
|
|
82
|
+
- \`update-profile\`: Update your own profile fields (name, role, capabilities, soulMd, identityMd, heartbeatMd, claudeMd, toolsMd, setupScript). As lead, you can also update other agents' profiles to shape their behavior.
|
|
83
|
+
|
|
80
84
|
#### General monitor and control tools
|
|
81
85
|
|
|
82
86
|
- \`get-swarm\`: Get the list of all workers in the swarm along with their status
|
|
@@ -226,6 +230,37 @@ Task: {describe what needs to be done}
|
|
|
226
230
|
- Complex feature/major refactor → Use PLANNING first, then IMPLEMENTATION
|
|
227
231
|
- Bug fix/small code change → Use QUICK FIX template
|
|
228
232
|
- Non-code task/question → Use GENERAL template
|
|
233
|
+
|
|
234
|
+
#### Heartbeat Checklist
|
|
235
|
+
|
|
236
|
+
The system reads your \`/workspace/HEARTBEAT.md\` every 30 minutes. If it has content (not just
|
|
237
|
+
comments or empty lines), it creates a \`heartbeat-checklist\` task for you containing:
|
|
238
|
+
1. **Auto-generated system status** — task counts, stalled tasks, agent health, idle workers, unassigned work
|
|
239
|
+
2. **Your standing orders** — whatever you wrote in HEARTBEAT.md
|
|
240
|
+
|
|
241
|
+
**How to configure:**
|
|
242
|
+
- **Edit the file directly:** Open \`/workspace/HEARTBEAT.md\` and write your standing orders. Changes sync to the database automatically on save.
|
|
243
|
+
- **Use \`update-profile\`:** Call \`update-profile\` with the \`heartbeatMd\` field set to your checklist content. This updates both the database and the file.
|
|
244
|
+
|
|
245
|
+
**What to put in HEARTBEAT.md** — a plain markdown list of actionable standing orders:
|
|
246
|
+
\`\`\`markdown
|
|
247
|
+
- Check Slack for unaddressed requests older than 1 hour
|
|
248
|
+
- Review active tasks for any that seem stuck or need follow-up
|
|
249
|
+
- If idle workers exist and unassigned tasks are available, investigate why
|
|
250
|
+
- Post a daily summary to #agent-status at 5pm
|
|
251
|
+
\`\`\`
|
|
252
|
+
|
|
253
|
+
**Key mechanics:**
|
|
254
|
+
- **Empty = disabled** — leave HEARTBEAT.md empty (or all HTML comments) to skip checks at zero LLM cost
|
|
255
|
+
- **System status is automatic** — don't gather it yourself, it's injected into every checklist task
|
|
256
|
+
- **Don't create checklist tasks yourself** — the system handles scheduling. Complete your current one and the next arrives in ~30 minutes
|
|
257
|
+
- **Boot triage** — after a server restart, you get a one-time higher-priority checklist task within 30 seconds
|
|
258
|
+
|
|
259
|
+
**When you receive a checklist task:**
|
|
260
|
+
1. Review the system status for anything that needs attention
|
|
261
|
+
2. Review your standing orders for any periodic checks or actions due
|
|
262
|
+
3. If something needs action — do it now (create tasks, post to Slack, etc.)
|
|
263
|
+
4. If everything is healthy — complete the task with a brief "All clear" summary
|
|
229
264
|
`,
|
|
230
265
|
variables: [],
|
|
231
266
|
category: "system",
|
|
@@ -203,11 +203,11 @@ describe("Concurrency Integration Tests", () => {
|
|
|
203
203
|
isLead: false,
|
|
204
204
|
status: "idle",
|
|
205
205
|
capabilities: [],
|
|
206
|
-
maxTasks:
|
|
206
|
+
maxTasks: 100, // Max allowed by schema
|
|
207
207
|
});
|
|
208
208
|
|
|
209
|
-
expect(agent.maxTasks).toBe(
|
|
210
|
-
expect(getRemainingCapacity(agent.id)).toBe(
|
|
209
|
+
expect(agent.maxTasks).toBe(100);
|
|
210
|
+
expect(getRemainingCapacity(agent.id)).toBe(100);
|
|
211
211
|
});
|
|
212
212
|
});
|
|
213
213
|
});
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, createAgent, initDb } from "../be/db";
|
|
4
|
+
import {
|
|
5
|
+
handleCheckRun,
|
|
6
|
+
handleCheckSuite,
|
|
7
|
+
handleComment,
|
|
8
|
+
handleIssue,
|
|
9
|
+
handlePullRequest,
|
|
10
|
+
handlePullRequestReview,
|
|
11
|
+
handleWorkflowRun,
|
|
12
|
+
} from "../github/handlers";
|
|
13
|
+
import { GITHUB_BOT_NAME } from "../github/mentions";
|
|
14
|
+
import type {
|
|
15
|
+
CheckRunEvent,
|
|
16
|
+
CheckSuiteEvent,
|
|
17
|
+
CommentEvent,
|
|
18
|
+
IssueEvent,
|
|
19
|
+
PullRequestEvent,
|
|
20
|
+
PullRequestReviewEvent,
|
|
21
|
+
WorkflowRunEvent,
|
|
22
|
+
} from "../github/types";
|
|
23
|
+
|
|
24
|
+
const TEST_DB_PATH = "./test-github-event-filter.sqlite";
|
|
25
|
+
|
|
26
|
+
// ── Setup ──
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
30
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
31
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
32
|
+
initDb(TEST_DB_PATH);
|
|
33
|
+
// Create a lead agent so handlers can assign tasks
|
|
34
|
+
createAgent({
|
|
35
|
+
id: "lead-gh-001",
|
|
36
|
+
name: "GitHubTestLead",
|
|
37
|
+
status: "idle",
|
|
38
|
+
isLead: true,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
closeDb();
|
|
44
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
45
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
46
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ── Helpers ──
|
|
50
|
+
|
|
51
|
+
const BASE_REPO = { full_name: "test/repo", html_url: "https://github.com/test/repo" };
|
|
52
|
+
const BASE_SENDER = { login: "testuser" };
|
|
53
|
+
const BASE_PR = {
|
|
54
|
+
number: 1,
|
|
55
|
+
title: "Test PR",
|
|
56
|
+
body: null as string | null,
|
|
57
|
+
html_url: "https://github.com/test/repo/pull/1",
|
|
58
|
+
user: { login: "testuser" },
|
|
59
|
+
head: { ref: "feature", sha: "abc1234567890" },
|
|
60
|
+
base: { ref: "main" },
|
|
61
|
+
merged: false,
|
|
62
|
+
merged_by: undefined,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function makePREvent(overrides: Partial<PullRequestEvent> = {}): PullRequestEvent {
|
|
66
|
+
return {
|
|
67
|
+
action: "opened",
|
|
68
|
+
pull_request: BASE_PR,
|
|
69
|
+
repository: BASE_REPO,
|
|
70
|
+
sender: BASE_SENDER,
|
|
71
|
+
...overrides,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeIssueEvent(overrides: Partial<IssueEvent> = {}): IssueEvent {
|
|
76
|
+
return {
|
|
77
|
+
action: "opened",
|
|
78
|
+
issue: {
|
|
79
|
+
number: 10,
|
|
80
|
+
title: "Test Issue",
|
|
81
|
+
body: null,
|
|
82
|
+
html_url: "https://github.com/test/repo/issues/10",
|
|
83
|
+
user: { login: "testuser" },
|
|
84
|
+
},
|
|
85
|
+
repository: BASE_REPO,
|
|
86
|
+
sender: BASE_SENDER,
|
|
87
|
+
...overrides,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Suppressed events ──
|
|
92
|
+
|
|
93
|
+
describe("suppressed cascade events", () => {
|
|
94
|
+
test("pull_request.closed returns created: false", async () => {
|
|
95
|
+
const result = await handlePullRequest(makePREvent({ action: "closed" }));
|
|
96
|
+
expect(result.created).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("pull_request.synchronize returns created: false", async () => {
|
|
100
|
+
const result = await handlePullRequest(makePREvent({ action: "synchronize" }));
|
|
101
|
+
expect(result.created).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("pull_request_review.submitted returns created: false", async () => {
|
|
105
|
+
const event: PullRequestReviewEvent = {
|
|
106
|
+
action: "submitted",
|
|
107
|
+
review: {
|
|
108
|
+
id: 1,
|
|
109
|
+
body: "Looks good",
|
|
110
|
+
state: "approved",
|
|
111
|
+
html_url: "https://github.com/test/repo/pull/1#pullrequestreview-1",
|
|
112
|
+
user: { login: "reviewer" },
|
|
113
|
+
submitted_at: "2026-01-01T00:00:00Z",
|
|
114
|
+
},
|
|
115
|
+
pull_request: {
|
|
116
|
+
number: 1,
|
|
117
|
+
title: "Test PR",
|
|
118
|
+
body: null,
|
|
119
|
+
html_url: "https://github.com/test/repo/pull/1",
|
|
120
|
+
user: { login: "testuser" },
|
|
121
|
+
head: { ref: "feature" },
|
|
122
|
+
base: { ref: "main" },
|
|
123
|
+
},
|
|
124
|
+
repository: BASE_REPO,
|
|
125
|
+
sender: { login: "reviewer" },
|
|
126
|
+
};
|
|
127
|
+
const result = await handlePullRequestReview(event);
|
|
128
|
+
expect(result.created).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("check_run.completed with failure returns created: false", async () => {
|
|
132
|
+
const event: CheckRunEvent = {
|
|
133
|
+
action: "completed",
|
|
134
|
+
check_run: {
|
|
135
|
+
id: 1,
|
|
136
|
+
name: "ci/test",
|
|
137
|
+
status: "completed",
|
|
138
|
+
conclusion: "failure",
|
|
139
|
+
html_url: "https://github.com/test/repo/runs/1",
|
|
140
|
+
started_at: "2026-01-01T00:00:00Z",
|
|
141
|
+
completed_at: "2026-01-01T00:01:00Z",
|
|
142
|
+
output: { title: null, summary: null },
|
|
143
|
+
check_suite: { id: 1, head_sha: "abc1234" },
|
|
144
|
+
pull_requests: [{ number: 1, head: { sha: "abc1234" } }],
|
|
145
|
+
},
|
|
146
|
+
repository: BASE_REPO,
|
|
147
|
+
sender: BASE_SENDER,
|
|
148
|
+
};
|
|
149
|
+
const result = await handleCheckRun(event);
|
|
150
|
+
expect(result.created).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("check_suite.completed with failure returns created: false", async () => {
|
|
154
|
+
const event: CheckSuiteEvent = {
|
|
155
|
+
action: "completed",
|
|
156
|
+
check_suite: {
|
|
157
|
+
id: 1,
|
|
158
|
+
status: "completed",
|
|
159
|
+
conclusion: "failure",
|
|
160
|
+
head_sha: "abc1234567890",
|
|
161
|
+
head_branch: "feature",
|
|
162
|
+
pull_requests: [{ number: 1, head: { sha: "abc1234" } }],
|
|
163
|
+
},
|
|
164
|
+
repository: BASE_REPO,
|
|
165
|
+
sender: BASE_SENDER,
|
|
166
|
+
};
|
|
167
|
+
const result = await handleCheckSuite(event);
|
|
168
|
+
expect(result.created).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("workflow_run.completed with failure returns created: false", async () => {
|
|
172
|
+
const event: WorkflowRunEvent = {
|
|
173
|
+
action: "completed",
|
|
174
|
+
workflow_run: {
|
|
175
|
+
id: 1,
|
|
176
|
+
name: "CI",
|
|
177
|
+
head_branch: "feature",
|
|
178
|
+
head_sha: "abc1234567890",
|
|
179
|
+
status: "completed",
|
|
180
|
+
conclusion: "failure",
|
|
181
|
+
html_url: "https://github.com/test/repo/actions/runs/1",
|
|
182
|
+
run_number: 42,
|
|
183
|
+
event: "pull_request",
|
|
184
|
+
pull_requests: [{ number: 1, head: { sha: "abc1234" } }],
|
|
185
|
+
},
|
|
186
|
+
workflow: { id: 1, name: "CI", path: ".github/workflows/ci.yml" },
|
|
187
|
+
repository: BASE_REPO,
|
|
188
|
+
sender: BASE_SENDER,
|
|
189
|
+
};
|
|
190
|
+
const result = await handleWorkflowRun(event);
|
|
191
|
+
expect(result.created).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── Explicit actions still create tasks ──
|
|
196
|
+
|
|
197
|
+
describe("explicit actions create tasks", () => {
|
|
198
|
+
test("issue_comment with @mention creates task", async () => {
|
|
199
|
+
const event: CommentEvent = {
|
|
200
|
+
action: "created",
|
|
201
|
+
comment: {
|
|
202
|
+
id: 100,
|
|
203
|
+
body: `@${GITHUB_BOT_NAME} please review this`,
|
|
204
|
+
html_url: "https://github.com/test/repo/issues/10#issuecomment-100",
|
|
205
|
+
user: { login: "testuser" },
|
|
206
|
+
},
|
|
207
|
+
issue: {
|
|
208
|
+
number: 10,
|
|
209
|
+
title: "Test Issue",
|
|
210
|
+
html_url: "https://github.com/test/repo/issues/10",
|
|
211
|
+
},
|
|
212
|
+
repository: BASE_REPO,
|
|
213
|
+
sender: BASE_SENDER,
|
|
214
|
+
};
|
|
215
|
+
const result = await handleComment(event, "issue_comment");
|
|
216
|
+
expect(result.created).toBe(true);
|
|
217
|
+
expect(result.taskId).toBeDefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("pull_request.review_requested with bot as reviewer creates task", async () => {
|
|
221
|
+
const event = makePREvent({
|
|
222
|
+
action: "review_requested",
|
|
223
|
+
requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
|
|
224
|
+
});
|
|
225
|
+
const result = await handlePullRequest(event);
|
|
226
|
+
expect(result.created).toBe(true);
|
|
227
|
+
expect(result.taskId).toBeDefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("pull_request.assigned with bot as assignee creates task", async () => {
|
|
231
|
+
const event = makePREvent({
|
|
232
|
+
action: "assigned",
|
|
233
|
+
pull_request: { ...BASE_PR, number: 2 },
|
|
234
|
+
assignee: { login: GITHUB_BOT_NAME, id: 1 },
|
|
235
|
+
});
|
|
236
|
+
const result = await handlePullRequest(event);
|
|
237
|
+
expect(result.created).toBe(true);
|
|
238
|
+
expect(result.taskId).toBeDefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("pull_request.labeled with matching label creates task", async () => {
|
|
242
|
+
const event = makePREvent({
|
|
243
|
+
action: "labeled",
|
|
244
|
+
pull_request: { ...BASE_PR, number: 3 },
|
|
245
|
+
label: { id: 1, name: "swarm-review", color: "0075ca" },
|
|
246
|
+
});
|
|
247
|
+
const result = await handlePullRequest(event);
|
|
248
|
+
expect(result.created).toBe(true);
|
|
249
|
+
expect(result.taskId).toBeDefined();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("pull_request.labeled with non-matching label does not create task", async () => {
|
|
253
|
+
const event = makePREvent({
|
|
254
|
+
action: "labeled",
|
|
255
|
+
pull_request: { ...BASE_PR, number: 4 },
|
|
256
|
+
label: { id: 2, name: "bug", color: "d73a4a" },
|
|
257
|
+
});
|
|
258
|
+
const result = await handlePullRequest(event);
|
|
259
|
+
expect(result.created).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("issue.labeled with matching label creates task", async () => {
|
|
263
|
+
const event = makeIssueEvent({
|
|
264
|
+
action: "labeled",
|
|
265
|
+
label: { id: 1, name: "swarm-review", color: "0075ca" },
|
|
266
|
+
});
|
|
267
|
+
const result = await handleIssue(event);
|
|
268
|
+
expect(result.created).toBe(true);
|
|
269
|
+
expect(result.taskId).toBeDefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("issue.labeled with non-matching label does not create task", async () => {
|
|
273
|
+
const event = makeIssueEvent({
|
|
274
|
+
action: "labeled",
|
|
275
|
+
issue: {
|
|
276
|
+
number: 11,
|
|
277
|
+
title: "Another Issue",
|
|
278
|
+
body: null,
|
|
279
|
+
html_url: "https://github.com/test/repo/issues/11",
|
|
280
|
+
user: { login: "testuser" },
|
|
281
|
+
},
|
|
282
|
+
label: { id: 2, name: "enhancement", color: "a2eeef" },
|
|
283
|
+
});
|
|
284
|
+
const result = await handleIssue(event);
|
|
285
|
+
expect(result.created).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("pull_request.opened with @mention creates task", async () => {
|
|
289
|
+
const event = makePREvent({
|
|
290
|
+
action: "opened",
|
|
291
|
+
pull_request: {
|
|
292
|
+
...BASE_PR,
|
|
293
|
+
number: 5,
|
|
294
|
+
title: `@${GITHUB_BOT_NAME} review this PR`,
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
const result = await handlePullRequest(event);
|
|
298
|
+
expect(result.created).toBe(true);
|
|
299
|
+
expect(result.taskId).toBeDefined();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { GITHUB_EVENT_LABELS, isSwarmLabel } from "../github/mentions";
|
|
3
|
+
|
|
4
|
+
describe("isSwarmLabel", () => {
|
|
5
|
+
test("default label 'swarm-review' matches", () => {
|
|
6
|
+
expect(isSwarmLabel("swarm-review")).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("case-insensitive matching", () => {
|
|
10
|
+
expect(isSwarmLabel("Swarm-Review")).toBe(true);
|
|
11
|
+
expect(isSwarmLabel("SWARM-REVIEW")).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("non-matching labels return false", () => {
|
|
15
|
+
expect(isSwarmLabel("bug")).toBe(false);
|
|
16
|
+
expect(isSwarmLabel("enhancement")).toBe(false);
|
|
17
|
+
expect(isSwarmLabel("")).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("GITHUB_EVENT_LABELS is populated", () => {
|
|
21
|
+
expect(GITHUB_EVENT_LABELS.length).toBeGreaterThan(0);
|
|
22
|
+
expect(GITHUB_EVENT_LABELS).toContain("swarm-review");
|
|
23
|
+
});
|
|
24
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -179,7 +179,7 @@ export const AgentSchema = z.object({
|
|
|
179
179
|
heartbeatMd: z.string().max(65536).optional(),
|
|
180
180
|
|
|
181
181
|
// Concurrency limit (defaults to 1 for backwards compatibility)
|
|
182
|
-
maxTasks: z.number().int().min(1).max(
|
|
182
|
+
maxTasks: z.number().int().min(1).max(100).optional(),
|
|
183
183
|
|
|
184
184
|
// Polling limit tracking (consecutive empty polls)
|
|
185
185
|
emptyPollCount: z.number().int().min(0).optional(),
|