@calltelemetry/openclaw-linear 0.9.0 → 0.9.2
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/README.md +355 -223
- package/package.json +1 -1
- package/src/__test__/smoke-linear-api.test.ts +147 -0
- package/src/infra/doctor.test.ts +763 -3
- package/src/pipeline/dag-dispatch.test.ts +444 -0
- package/src/pipeline/pipeline.test.ts +1247 -1
- package/src/pipeline/planner.test.ts +457 -3
- package/src/pipeline/planning-state.test.ts +164 -3
- package/src/pipeline/webhook-dedup.test.ts +1 -1
- package/src/pipeline/webhook.test.ts +2438 -19
- package/src/tools/planner-tools.test.ts +722 -0
|
@@ -532,4 +532,726 @@ describe("createPlannerTools", () => {
|
|
|
532
532
|
},
|
|
533
533
|
});
|
|
534
534
|
});
|
|
535
|
+
|
|
536
|
+
// ---- plan_create_issue: additional branch tests ----
|
|
537
|
+
|
|
538
|
+
it("plan_create_issue: includes priority and estimate when provided", async () => {
|
|
539
|
+
const tool = findTool("plan_create_issue");
|
|
540
|
+
|
|
541
|
+
await tool.execute("call-6", {
|
|
542
|
+
title: "Estimated task",
|
|
543
|
+
description: "Full description here",
|
|
544
|
+
priority: 2,
|
|
545
|
+
estimate: 5,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
expect(mockLinearApi.createIssue).toHaveBeenCalledWith(
|
|
549
|
+
expect.objectContaining({
|
|
550
|
+
priority: 2,
|
|
551
|
+
estimate: 5,
|
|
552
|
+
}),
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("plan_create_issue: resolves parentIdentifier to parentId", async () => {
|
|
557
|
+
mockLinearApi.getProjectIssues.mockResolvedValueOnce([
|
|
558
|
+
makeIssue({ identifier: "PROJ-1", title: "Parent", id: "parent-id" }),
|
|
559
|
+
]);
|
|
560
|
+
|
|
561
|
+
const tool = findTool("plan_create_issue");
|
|
562
|
+
|
|
563
|
+
await tool.execute("call-7", {
|
|
564
|
+
title: "Child task",
|
|
565
|
+
description: "A child issue under parent",
|
|
566
|
+
parentIdentifier: "PROJ-1",
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
expect(mockLinearApi.getProjectIssues).toHaveBeenCalledWith(PROJECT_ID);
|
|
570
|
+
expect(mockLinearApi.createIssue).toHaveBeenCalledWith(
|
|
571
|
+
expect.objectContaining({
|
|
572
|
+
parentId: "parent-id",
|
|
573
|
+
}),
|
|
574
|
+
);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("plan_create_issue: adds Epic label when isEpic=true and label exists", async () => {
|
|
578
|
+
mockLinearApi.getTeamLabels.mockResolvedValueOnce([
|
|
579
|
+
{ id: "label-epic", name: "epic" },
|
|
580
|
+
]);
|
|
581
|
+
|
|
582
|
+
const tool = findTool("plan_create_issue");
|
|
583
|
+
|
|
584
|
+
await tool.execute("call-8", {
|
|
585
|
+
title: "Feature epic",
|
|
586
|
+
description: "An epic issue for the project",
|
|
587
|
+
isEpic: true,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
expect(mockLinearApi.getTeamLabels).toHaveBeenCalledWith(TEAM_ID);
|
|
591
|
+
expect(mockLinearApi.updateIssueExtended).toHaveBeenCalledWith("new-id", { labelIds: ["label-epic"] });
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("plan_create_issue: isEpic=true but no Epic label on team — no crash", async () => {
|
|
595
|
+
mockLinearApi.getTeamLabels.mockResolvedValueOnce([
|
|
596
|
+
{ id: "label-bug", name: "bug" },
|
|
597
|
+
]);
|
|
598
|
+
|
|
599
|
+
const tool = findTool("plan_create_issue");
|
|
600
|
+
|
|
601
|
+
const result = await tool.execute("call-9", {
|
|
602
|
+
title: "Epic without label",
|
|
603
|
+
description: "Epic but team has no epic label",
|
|
604
|
+
isEpic: true,
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
expect(mockLinearApi.getTeamLabels).toHaveBeenCalledWith(TEAM_ID);
|
|
608
|
+
expect(mockLinearApi.updateIssueExtended).not.toHaveBeenCalled();
|
|
609
|
+
expect(result.data.isEpic).toBe(true);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("plan_create_issue: isEpic=true handles error when labeling fails", async () => {
|
|
613
|
+
mockLinearApi.getTeamLabels.mockRejectedValueOnce(new Error("API error"));
|
|
614
|
+
|
|
615
|
+
const tool = findTool("plan_create_issue");
|
|
616
|
+
|
|
617
|
+
const result = await tool.execute("call-10", {
|
|
618
|
+
title: "Epic with error",
|
|
619
|
+
description: "Epic label fetch fails",
|
|
620
|
+
isEpic: true,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// Should not throw — best-effort labeling
|
|
624
|
+
expect(result.data.isEpic).toBe(true);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it("plan_create_issue: estimate=0 is passed (falsy but valid)", async () => {
|
|
628
|
+
const tool = findTool("plan_create_issue");
|
|
629
|
+
|
|
630
|
+
await tool.execute("call-11", {
|
|
631
|
+
title: "Zero estimate",
|
|
632
|
+
description: "Issue with zero estimate",
|
|
633
|
+
estimate: 0,
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
expect(mockLinearApi.createIssue).toHaveBeenCalledWith(
|
|
637
|
+
expect.objectContaining({
|
|
638
|
+
estimate: 0,
|
|
639
|
+
}),
|
|
640
|
+
);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// ---- plan_update_issue ----
|
|
644
|
+
|
|
645
|
+
it("plan_update_issue: updates description, estimate, priority, and labelIds", async () => {
|
|
646
|
+
mockLinearApi.getProjectIssues.mockResolvedValueOnce([
|
|
647
|
+
makeIssue({ identifier: "PROJ-1", title: "Existing", id: "id-1" }),
|
|
648
|
+
]);
|
|
649
|
+
|
|
650
|
+
const tool = findTool("plan_update_issue");
|
|
651
|
+
|
|
652
|
+
const result = await tool.execute("call-12", {
|
|
653
|
+
identifier: "PROJ-1",
|
|
654
|
+
description: "Updated description text",
|
|
655
|
+
estimate: 8,
|
|
656
|
+
priority: 1,
|
|
657
|
+
labelIds: ["label-1", "label-2"],
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
expect(mockLinearApi.updateIssueExtended).toHaveBeenCalledWith("id-1", {
|
|
661
|
+
description: "Updated description text",
|
|
662
|
+
estimate: 8,
|
|
663
|
+
priority: 1,
|
|
664
|
+
labelIds: ["label-1", "label-2"],
|
|
665
|
+
});
|
|
666
|
+
expect(result.data.updated).toBe(true);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("plan_update_issue: only sends provided fields", async () => {
|
|
670
|
+
mockLinearApi.getProjectIssues.mockResolvedValueOnce([
|
|
671
|
+
makeIssue({ identifier: "PROJ-1", title: "Existing", id: "id-1" }),
|
|
672
|
+
]);
|
|
673
|
+
|
|
674
|
+
const tool = findTool("plan_update_issue");
|
|
675
|
+
|
|
676
|
+
await tool.execute("call-13", {
|
|
677
|
+
identifier: "PROJ-1",
|
|
678
|
+
estimate: 3,
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
expect(mockLinearApi.updateIssueExtended).toHaveBeenCalledWith("id-1", {
|
|
682
|
+
estimate: 3,
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("plan_update_issue: throws on unknown identifier", async () => {
|
|
687
|
+
mockLinearApi.getProjectIssues.mockResolvedValueOnce([
|
|
688
|
+
makeIssue({ identifier: "PROJ-1", title: "Only", id: "id-1" }),
|
|
689
|
+
]);
|
|
690
|
+
|
|
691
|
+
const tool = findTool("plan_update_issue");
|
|
692
|
+
|
|
693
|
+
await expect(
|
|
694
|
+
tool.execute("call-14", { identifier: "PROJ-999" }),
|
|
695
|
+
).rejects.toThrow("Unknown issue identifier: PROJ-999");
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
// requireContext: throws when no planner context set
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
describe("createPlannerTools — no context", () => {
|
|
704
|
+
it("throws when tools are used without active planner context", async () => {
|
|
705
|
+
clearActivePlannerContext();
|
|
706
|
+
const tools = createPlannerTools();
|
|
707
|
+
const tool = tools.find((t: any) => t.name === "plan_get_project") as any;
|
|
708
|
+
|
|
709
|
+
await expect(tool.execute("call-no-ctx", {})).rejects.toThrow(
|
|
710
|
+
"No active planning session",
|
|
711
|
+
);
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
// detectCycles — additional branch coverage
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
describe("detectCycles — additional branches", () => {
|
|
720
|
+
it("handles blocked_by relation type", () => {
|
|
721
|
+
const issues: ProjectIssue[] = [
|
|
722
|
+
makeIssue({ identifier: "PROJ-1", title: "A" }),
|
|
723
|
+
makeIssue({
|
|
724
|
+
identifier: "PROJ-2",
|
|
725
|
+
title: "B",
|
|
726
|
+
relations: {
|
|
727
|
+
nodes: [{ type: "blocked_by", relatedIssue: { identifier: "PROJ-1" } }],
|
|
728
|
+
} as any,
|
|
729
|
+
}),
|
|
730
|
+
];
|
|
731
|
+
|
|
732
|
+
// Valid DAG — no cycle
|
|
733
|
+
expect(detectCycles(issues)).toEqual([]);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("ignores relations with null target identifier", () => {
|
|
737
|
+
const issues: ProjectIssue[] = [
|
|
738
|
+
makeIssue({
|
|
739
|
+
identifier: "PROJ-1",
|
|
740
|
+
title: "A",
|
|
741
|
+
relations: {
|
|
742
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: null } }],
|
|
743
|
+
} as any,
|
|
744
|
+
}),
|
|
745
|
+
];
|
|
746
|
+
|
|
747
|
+
expect(detectCycles(issues)).toEqual([]);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("ignores relations with missing relatedIssue", () => {
|
|
751
|
+
const issues: ProjectIssue[] = [
|
|
752
|
+
makeIssue({
|
|
753
|
+
identifier: "PROJ-1",
|
|
754
|
+
title: "A",
|
|
755
|
+
relations: {
|
|
756
|
+
nodes: [{ type: "blocks", relatedIssue: null }],
|
|
757
|
+
} as any,
|
|
758
|
+
}),
|
|
759
|
+
];
|
|
760
|
+
|
|
761
|
+
expect(detectCycles(issues)).toEqual([]);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("ignores relations to identifiers not in the issue set", () => {
|
|
765
|
+
const issues: ProjectIssue[] = [
|
|
766
|
+
makeIssue({
|
|
767
|
+
identifier: "PROJ-1",
|
|
768
|
+
title: "A",
|
|
769
|
+
relations: {
|
|
770
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "OTHER-99" } }],
|
|
771
|
+
} as any,
|
|
772
|
+
}),
|
|
773
|
+
];
|
|
774
|
+
|
|
775
|
+
expect(detectCycles(issues)).toEqual([]);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("handles issues with null/undefined relations", () => {
|
|
779
|
+
const issues: ProjectIssue[] = [
|
|
780
|
+
makeIssue({
|
|
781
|
+
identifier: "PROJ-1",
|
|
782
|
+
title: "A",
|
|
783
|
+
relations: null as any,
|
|
784
|
+
}),
|
|
785
|
+
makeIssue({
|
|
786
|
+
identifier: "PROJ-2",
|
|
787
|
+
title: "B",
|
|
788
|
+
relations: undefined as any,
|
|
789
|
+
}),
|
|
790
|
+
];
|
|
791
|
+
|
|
792
|
+
expect(detectCycles(issues)).toEqual([]);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it("detects cycle via blocked_by relations", () => {
|
|
796
|
+
const issues: ProjectIssue[] = [
|
|
797
|
+
makeIssue({
|
|
798
|
+
identifier: "PROJ-1",
|
|
799
|
+
title: "A",
|
|
800
|
+
relations: {
|
|
801
|
+
nodes: [{ type: "blocked_by", relatedIssue: { identifier: "PROJ-2" } }],
|
|
802
|
+
} as any,
|
|
803
|
+
}),
|
|
804
|
+
makeIssue({
|
|
805
|
+
identifier: "PROJ-2",
|
|
806
|
+
title: "B",
|
|
807
|
+
relations: {
|
|
808
|
+
nodes: [{ type: "blocked_by", relatedIssue: { identifier: "PROJ-1" } }],
|
|
809
|
+
} as any,
|
|
810
|
+
}),
|
|
811
|
+
];
|
|
812
|
+
|
|
813
|
+
const cycleNodes = detectCycles(issues);
|
|
814
|
+
expect(cycleNodes).toContain("PROJ-1");
|
|
815
|
+
expect(cycleNodes).toContain("PROJ-2");
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it("ignores non-blocks/non-blocked_by relation types", () => {
|
|
819
|
+
const issues: ProjectIssue[] = [
|
|
820
|
+
makeIssue({
|
|
821
|
+
identifier: "PROJ-1",
|
|
822
|
+
title: "A",
|
|
823
|
+
relations: {
|
|
824
|
+
nodes: [{ type: "related", relatedIssue: { identifier: "PROJ-2" } }],
|
|
825
|
+
} as any,
|
|
826
|
+
}),
|
|
827
|
+
makeIssue({ identifier: "PROJ-2", title: "B" }),
|
|
828
|
+
];
|
|
829
|
+
|
|
830
|
+
// "related" is not a dependency — no cycle
|
|
831
|
+
expect(detectCycles(issues)).toEqual([]);
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// ---------------------------------------------------------------------------
|
|
836
|
+
// auditPlan — additional branch coverage
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
describe("auditPlan — additional branches", () => {
|
|
840
|
+
it("fails: null description", () => {
|
|
841
|
+
const issues: ProjectIssue[] = [
|
|
842
|
+
makeIssue({
|
|
843
|
+
identifier: "PROJ-1",
|
|
844
|
+
title: "Null desc",
|
|
845
|
+
description: null as any,
|
|
846
|
+
}),
|
|
847
|
+
];
|
|
848
|
+
|
|
849
|
+
const result = auditPlan(issues);
|
|
850
|
+
expect(result.pass).toBe(false);
|
|
851
|
+
expect(result.problems.some((p) => p.includes("PROJ-1") && p.includes("description"))).toBe(true);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it("fails: non-epic with null priority", () => {
|
|
855
|
+
const issues: ProjectIssue[] = [
|
|
856
|
+
makeIssue({
|
|
857
|
+
identifier: "PROJ-1",
|
|
858
|
+
title: "Null priority",
|
|
859
|
+
priority: null as any,
|
|
860
|
+
}),
|
|
861
|
+
];
|
|
862
|
+
|
|
863
|
+
const result = auditPlan(issues);
|
|
864
|
+
expect(result.pass).toBe(false);
|
|
865
|
+
expect(result.problems.some((p) => p.includes("PROJ-1") && p.includes("priority"))).toBe(true);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it("no acceptance criteria warning when description contains AC markers", () => {
|
|
869
|
+
const issues: ProjectIssue[] = [
|
|
870
|
+
makeIssue({
|
|
871
|
+
identifier: "PROJ-1",
|
|
872
|
+
title: "Good AC",
|
|
873
|
+
description: "As a user, I want to test this feature. Given a prerequisite, When I do something, Then I expect results.",
|
|
874
|
+
}),
|
|
875
|
+
];
|
|
876
|
+
|
|
877
|
+
const result = auditPlan(issues);
|
|
878
|
+
const acWarnings = result.warnings.filter((w) => w.includes("acceptance criteria"));
|
|
879
|
+
expect(acWarnings).toHaveLength(0);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it("warns: no acceptance criteria when description lacks AC markers", () => {
|
|
883
|
+
const issues: ProjectIssue[] = [
|
|
884
|
+
makeIssue({
|
|
885
|
+
identifier: "PROJ-1",
|
|
886
|
+
title: "No AC",
|
|
887
|
+
description: "This is a long description that has enough characters but none of the required markers or keywords whatsoever at all.",
|
|
888
|
+
}),
|
|
889
|
+
];
|
|
890
|
+
|
|
891
|
+
const result = auditPlan(issues);
|
|
892
|
+
expect(result.warnings.some((w) => w.includes("PROJ-1") && w.includes("acceptance criteria"))).toBe(true);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it("does not warn about acceptance criteria for epic issues", () => {
|
|
896
|
+
const issues: ProjectIssue[] = [
|
|
897
|
+
makeEpicIssue({
|
|
898
|
+
identifier: "PROJ-1",
|
|
899
|
+
title: "Epic no AC",
|
|
900
|
+
description: "This is a long epic description that has enough characters but no acceptance criteria at all whatsoever.",
|
|
901
|
+
}),
|
|
902
|
+
];
|
|
903
|
+
|
|
904
|
+
const result = auditPlan(issues);
|
|
905
|
+
const acWarnings = result.warnings.filter((w) => w.includes("acceptance criteria"));
|
|
906
|
+
expect(acWarnings).toHaveLength(0);
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it("does not warn about acceptance criteria when description is null (already a problem)", () => {
|
|
910
|
+
const issues: ProjectIssue[] = [
|
|
911
|
+
makeIssue({
|
|
912
|
+
identifier: "PROJ-1",
|
|
913
|
+
title: "No desc at all",
|
|
914
|
+
description: null as any,
|
|
915
|
+
}),
|
|
916
|
+
];
|
|
917
|
+
|
|
918
|
+
const result = auditPlan(issues);
|
|
919
|
+
// Should have description problem but NOT an AC warning
|
|
920
|
+
expect(result.problems.some((p) => p.includes("description"))).toBe(true);
|
|
921
|
+
const acWarnings = result.warnings.filter((w) => w.includes("acceptance criteria") && w.includes("PROJ-1"));
|
|
922
|
+
expect(acWarnings).toHaveLength(0);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it("no orphan warning for issue with parent", () => {
|
|
926
|
+
const issues: ProjectIssue[] = [
|
|
927
|
+
makeIssue({
|
|
928
|
+
identifier: "PROJ-1",
|
|
929
|
+
title: "Parent",
|
|
930
|
+
relations: {
|
|
931
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
|
|
932
|
+
} as any,
|
|
933
|
+
}),
|
|
934
|
+
makeIssue({
|
|
935
|
+
identifier: "PROJ-2",
|
|
936
|
+
title: "Child with parent",
|
|
937
|
+
parent: { identifier: "PROJ-1" } as any,
|
|
938
|
+
}),
|
|
939
|
+
];
|
|
940
|
+
|
|
941
|
+
const result = auditPlan(issues);
|
|
942
|
+
const orphanWarnings = result.warnings.filter((w) => w.includes("orphan"));
|
|
943
|
+
expect(orphanWarnings).toHaveLength(0);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it("no orphan warning for issue involved in relations (even without parent)", () => {
|
|
947
|
+
const issues: ProjectIssue[] = [
|
|
948
|
+
makeIssue({
|
|
949
|
+
identifier: "PROJ-1",
|
|
950
|
+
title: "Has relation",
|
|
951
|
+
relations: {
|
|
952
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
|
|
953
|
+
} as any,
|
|
954
|
+
}),
|
|
955
|
+
makeIssue({
|
|
956
|
+
identifier: "PROJ-2",
|
|
957
|
+
title: "Related to PROJ-1",
|
|
958
|
+
}),
|
|
959
|
+
];
|
|
960
|
+
|
|
961
|
+
const result = auditPlan(issues);
|
|
962
|
+
const orphanWarnings = result.warnings.filter((w) => w.includes("orphan"));
|
|
963
|
+
expect(orphanWarnings).toHaveLength(0);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it("handles issues with null labels (not epic)", () => {
|
|
967
|
+
const issues: ProjectIssue[] = [
|
|
968
|
+
makeIssue({
|
|
969
|
+
identifier: "PROJ-1",
|
|
970
|
+
title: "No labels",
|
|
971
|
+
labels: null as any,
|
|
972
|
+
}),
|
|
973
|
+
];
|
|
974
|
+
|
|
975
|
+
const result = auditPlan(issues);
|
|
976
|
+
// Should be treated as non-epic — no crash
|
|
977
|
+
expect(result).toBeDefined();
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
// ---------------------------------------------------------------------------
|
|
982
|
+
// buildPlanSnapshot — additional branch coverage
|
|
983
|
+
// ---------------------------------------------------------------------------
|
|
984
|
+
|
|
985
|
+
describe("buildPlanSnapshot — additional branches", () => {
|
|
986
|
+
it("formats priority label: Low (4)", () => {
|
|
987
|
+
const issues: ProjectIssue[] = [
|
|
988
|
+
makeIssue({ identifier: "PROJ-1", title: "Low pri", priority: 4 }),
|
|
989
|
+
];
|
|
990
|
+
const result = buildPlanSnapshot(issues);
|
|
991
|
+
expect(result).toContain("pri: Low");
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("formats priority label: None (5 or unrecognized)", () => {
|
|
995
|
+
const issues: ProjectIssue[] = [
|
|
996
|
+
makeIssue({ identifier: "PROJ-1", title: "No pri", priority: 5 }),
|
|
997
|
+
];
|
|
998
|
+
const result = buildPlanSnapshot(issues);
|
|
999
|
+
expect(result).toContain("pri: None");
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("formats priority label: None (0)", () => {
|
|
1003
|
+
const issues: ProjectIssue[] = [
|
|
1004
|
+
makeIssue({ identifier: "PROJ-1", title: "Zero pri", priority: 0 }),
|
|
1005
|
+
];
|
|
1006
|
+
const result = buildPlanSnapshot(issues);
|
|
1007
|
+
expect(result).toContain("pri: None");
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it("formats estimate as dash when null", () => {
|
|
1011
|
+
const issues: ProjectIssue[] = [
|
|
1012
|
+
makeIssue({ identifier: "PROJ-1", title: "No est", estimate: null as any }),
|
|
1013
|
+
];
|
|
1014
|
+
const result = buildPlanSnapshot(issues);
|
|
1015
|
+
expect(result).toContain("est: -");
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it("formats only standalone section when no epics", () => {
|
|
1019
|
+
const issues: ProjectIssue[] = [
|
|
1020
|
+
makeIssue({ identifier: "PROJ-1", title: "Task A" }),
|
|
1021
|
+
makeIssue({ identifier: "PROJ-2", title: "Task B" }),
|
|
1022
|
+
];
|
|
1023
|
+
const result = buildPlanSnapshot(issues);
|
|
1024
|
+
expect(result).toContain("### Standalone issues (2)");
|
|
1025
|
+
expect(result).not.toContain("### Epics");
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("formats only epics section when no standalone issues", () => {
|
|
1029
|
+
const epic = makeEpicIssue({
|
|
1030
|
+
identifier: "PROJ-1",
|
|
1031
|
+
title: "Only Epic",
|
|
1032
|
+
priority: 2,
|
|
1033
|
+
});
|
|
1034
|
+
const child = makeIssue({
|
|
1035
|
+
identifier: "PROJ-2",
|
|
1036
|
+
title: "Under epic",
|
|
1037
|
+
parent: { identifier: "PROJ-1" } as any,
|
|
1038
|
+
});
|
|
1039
|
+
const result = buildPlanSnapshot([epic, child]);
|
|
1040
|
+
expect(result).toContain("### Epics (1)");
|
|
1041
|
+
expect(result).not.toContain("### Standalone issues");
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it("formats issue with no relations as empty relation string", () => {
|
|
1045
|
+
const issues: ProjectIssue[] = [
|
|
1046
|
+
makeIssue({ identifier: "PROJ-1", title: "No rels", relations: { nodes: [] } as any }),
|
|
1047
|
+
];
|
|
1048
|
+
const result = buildPlanSnapshot(issues);
|
|
1049
|
+
// Should not have any "→" relation markers
|
|
1050
|
+
expect(result).not.toContain("→");
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("formats multiple relations on a single issue", () => {
|
|
1054
|
+
const issues: ProjectIssue[] = [
|
|
1055
|
+
makeIssue({
|
|
1056
|
+
identifier: "PROJ-1",
|
|
1057
|
+
title: "Multi-rel",
|
|
1058
|
+
relations: {
|
|
1059
|
+
nodes: [
|
|
1060
|
+
{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } },
|
|
1061
|
+
{ type: "related", relatedIssue: { identifier: "PROJ-3" } },
|
|
1062
|
+
],
|
|
1063
|
+
} as any,
|
|
1064
|
+
}),
|
|
1065
|
+
makeIssue({ identifier: "PROJ-2", title: "B" }),
|
|
1066
|
+
makeIssue({ identifier: "PROJ-3", title: "C" }),
|
|
1067
|
+
];
|
|
1068
|
+
const result = buildPlanSnapshot(issues);
|
|
1069
|
+
expect(result).toContain("blocks PROJ-2");
|
|
1070
|
+
expect(result).toContain("related PROJ-3");
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it("formats nested children (grandchildren)", () => {
|
|
1074
|
+
const epic = makeEpicIssue({
|
|
1075
|
+
identifier: "PROJ-1",
|
|
1076
|
+
title: "Epic",
|
|
1077
|
+
priority: 2,
|
|
1078
|
+
});
|
|
1079
|
+
const child = makeIssue({
|
|
1080
|
+
identifier: "PROJ-2",
|
|
1081
|
+
title: "Child",
|
|
1082
|
+
parent: { identifier: "PROJ-1" } as any,
|
|
1083
|
+
});
|
|
1084
|
+
const grandchild = makeIssue({
|
|
1085
|
+
identifier: "PROJ-3",
|
|
1086
|
+
title: "Grandchild",
|
|
1087
|
+
parent: { identifier: "PROJ-2" } as any,
|
|
1088
|
+
});
|
|
1089
|
+
const result = buildPlanSnapshot([epic, child, grandchild]);
|
|
1090
|
+
// Grandchild should be double-indented
|
|
1091
|
+
expect(result).toContain("PROJ-3");
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it("handles issues with null relations in snapshot (formatRelations null guard)", () => {
|
|
1095
|
+
const issues: ProjectIssue[] = [
|
|
1096
|
+
makeIssue({
|
|
1097
|
+
identifier: "PROJ-1",
|
|
1098
|
+
title: "Null rels",
|
|
1099
|
+
relations: null as any,
|
|
1100
|
+
}),
|
|
1101
|
+
];
|
|
1102
|
+
const result = buildPlanSnapshot(issues);
|
|
1103
|
+
expect(result).toContain("PROJ-1");
|
|
1104
|
+
expect(result).not.toContain("→");
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
it("handles issues with undefined relations.nodes in snapshot", () => {
|
|
1108
|
+
const issues: ProjectIssue[] = [
|
|
1109
|
+
makeIssue({
|
|
1110
|
+
identifier: "PROJ-1",
|
|
1111
|
+
title: "Undef nodes",
|
|
1112
|
+
relations: {} as any,
|
|
1113
|
+
}),
|
|
1114
|
+
];
|
|
1115
|
+
const result = buildPlanSnapshot(issues);
|
|
1116
|
+
expect(result).toContain("PROJ-1");
|
|
1117
|
+
expect(result).not.toContain("→");
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
// ---------------------------------------------------------------------------
|
|
1122
|
+
// auditPlan — orphan check with relation having null relatedIssue
|
|
1123
|
+
// ---------------------------------------------------------------------------
|
|
1124
|
+
|
|
1125
|
+
describe("auditPlan — orphan relation edge cases", () => {
|
|
1126
|
+
it("relation with null relatedIssue.identifier does not count as having-relation", () => {
|
|
1127
|
+
const issues: ProjectIssue[] = [
|
|
1128
|
+
makeIssue({
|
|
1129
|
+
identifier: "PROJ-1",
|
|
1130
|
+
title: "Has broken relation",
|
|
1131
|
+
relations: {
|
|
1132
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: null } }],
|
|
1133
|
+
} as any,
|
|
1134
|
+
}),
|
|
1135
|
+
];
|
|
1136
|
+
|
|
1137
|
+
const result = auditPlan(issues);
|
|
1138
|
+
// PROJ-1 has a relation node, so hasRelation.add(issue.identifier) fires,
|
|
1139
|
+
// but rel.relatedIssue?.identifier is null so the second add doesn't fire.
|
|
1140
|
+
// PROJ-1 still counts as having a relation (from the first add).
|
|
1141
|
+
const orphanWarnings = result.warnings.filter((w) => w.includes("orphan"));
|
|
1142
|
+
expect(orphanWarnings).toHaveLength(0);
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
it("relation with null relatedIssue skips identifier add", () => {
|
|
1146
|
+
const issues: ProjectIssue[] = [
|
|
1147
|
+
makeIssue({
|
|
1148
|
+
identifier: "PROJ-1",
|
|
1149
|
+
title: "Broken rel",
|
|
1150
|
+
relations: {
|
|
1151
|
+
nodes: [{ type: "blocks", relatedIssue: null }],
|
|
1152
|
+
} as any,
|
|
1153
|
+
}),
|
|
1154
|
+
];
|
|
1155
|
+
|
|
1156
|
+
const result = auditPlan(issues);
|
|
1157
|
+
// PROJ-1 still gets added to hasRelation set from the outer loop
|
|
1158
|
+
const orphanWarnings = result.warnings.filter((w) => w.includes("orphan"));
|
|
1159
|
+
expect(orphanWarnings).toHaveLength(0);
|
|
1160
|
+
});
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
// ---------------------------------------------------------------------------
|
|
1164
|
+
// createPlannerTools — plan_update_issue: exercise all optional param branches
|
|
1165
|
+
// ---------------------------------------------------------------------------
|
|
1166
|
+
|
|
1167
|
+
describe("createPlannerTools — plan_update_issue branch coverage", () => {
|
|
1168
|
+
const PROJECT_ID = "proj-123";
|
|
1169
|
+
const TEAM_ID = "team-456";
|
|
1170
|
+
|
|
1171
|
+
let tools: ReturnType<typeof createPlannerTools>;
|
|
1172
|
+
|
|
1173
|
+
beforeEach(() => {
|
|
1174
|
+
vi.clearAllMocks();
|
|
1175
|
+
setActivePlannerContext({
|
|
1176
|
+
linearApi: mockLinearApi as any,
|
|
1177
|
+
projectId: PROJECT_ID,
|
|
1178
|
+
teamId: TEAM_ID,
|
|
1179
|
+
});
|
|
1180
|
+
tools = createPlannerTools();
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
afterEach(() => {
|
|
1184
|
+
clearActivePlannerContext();
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
function findTool(name: string) {
|
|
1188
|
+
const tool = tools.find((t: any) => t.name === name) as any;
|
|
1189
|
+
if (!tool) throw new Error(`Tool '${name}' not found`);
|
|
1190
|
+
return tool;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
it("plan_update_issue: sends only description when only description provided", async () => {
|
|
1194
|
+
mockLinearApi.getProjectIssues.mockResolvedValueOnce([
|
|
1195
|
+
makeIssue({ identifier: "PROJ-1", title: "Existing", id: "id-1" }),
|
|
1196
|
+
]);
|
|
1197
|
+
|
|
1198
|
+
const tool = findTool("plan_update_issue");
|
|
1199
|
+
|
|
1200
|
+
await tool.execute("call-desc", {
|
|
1201
|
+
identifier: "PROJ-1",
|
|
1202
|
+
description: "New desc only",
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
expect(mockLinearApi.updateIssueExtended).toHaveBeenCalledWith("id-1", {
|
|
1206
|
+
description: "New desc only",
|
|
1207
|
+
});
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it("plan_update_issue: sends only priority when only priority provided", async () => {
|
|
1211
|
+
mockLinearApi.getProjectIssues.mockResolvedValueOnce([
|
|
1212
|
+
makeIssue({ identifier: "PROJ-1", title: "Existing", id: "id-1" }),
|
|
1213
|
+
]);
|
|
1214
|
+
|
|
1215
|
+
const tool = findTool("plan_update_issue");
|
|
1216
|
+
|
|
1217
|
+
await tool.execute("call-pri", {
|
|
1218
|
+
identifier: "PROJ-1",
|
|
1219
|
+
priority: 1,
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
expect(mockLinearApi.updateIssueExtended).toHaveBeenCalledWith("id-1", {
|
|
1223
|
+
priority: 1,
|
|
1224
|
+
});
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
it("plan_update_issue: sends only labelIds when only labelIds provided", async () => {
|
|
1228
|
+
mockLinearApi.getProjectIssues.mockResolvedValueOnce([
|
|
1229
|
+
makeIssue({ identifier: "PROJ-1", title: "Existing", id: "id-1" }),
|
|
1230
|
+
]);
|
|
1231
|
+
|
|
1232
|
+
const tool = findTool("plan_update_issue");
|
|
1233
|
+
|
|
1234
|
+
await tool.execute("call-labels", {
|
|
1235
|
+
identifier: "PROJ-1",
|
|
1236
|
+
labelIds: ["lbl-1"],
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
expect(mockLinearApi.updateIssueExtended).toHaveBeenCalledWith("id-1", {
|
|
1240
|
+
labelIds: ["lbl-1"],
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
it("plan_update_issue: sends empty updates when no optional fields provided", async () => {
|
|
1245
|
+
mockLinearApi.getProjectIssues.mockResolvedValueOnce([
|
|
1246
|
+
makeIssue({ identifier: "PROJ-1", title: "Existing", id: "id-1" }),
|
|
1247
|
+
]);
|
|
1248
|
+
|
|
1249
|
+
const tool = findTool("plan_update_issue");
|
|
1250
|
+
|
|
1251
|
+
await tool.execute("call-none", {
|
|
1252
|
+
identifier: "PROJ-1",
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
expect(mockLinearApi.updateIssueExtended).toHaveBeenCalledWith("id-1", {});
|
|
1256
|
+
});
|
|
535
1257
|
});
|