@calltelemetry/openclaw-linear 0.9.1 → 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.
@@ -10,6 +10,8 @@ import {
10
10
  isProjectStuck,
11
11
  readProjectDispatch,
12
12
  writeProjectDispatch,
13
+ startProjectDispatch,
14
+ dispatchReadyIssues,
13
15
  onProjectIssueCompleted,
14
16
  onProjectIssueStuck,
15
17
  type ProjectIssueStatus,
@@ -550,4 +552,446 @@ describe("onProjectIssueStuck", () => {
550
552
  const result = await readProjectDispatch("proj-1", p);
551
553
  expect(result!.status).toBe("completed");
552
554
  });
555
+
556
+ it("does nothing if issue not found in dispatch", async () => {
557
+ const p = tmpStatePath();
558
+ const pd = makeProjectDispatch({
559
+ issues: { "PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched" }) },
560
+ });
561
+ await writeProjectDispatch(pd, p);
562
+
563
+ const hookCtx = makeHookCtx(p);
564
+ await onProjectIssueStuck(hookCtx, "proj-1", "PROJ-999");
565
+
566
+ // Should not crash, PROJ-1 remains dispatched
567
+ const result = await readProjectDispatch("proj-1", p);
568
+ expect(result!.issues["PROJ-1"].dispatchStatus).toBe("dispatched");
569
+ });
570
+
571
+ it("does nothing if projectDispatch is null (non-existent project)", async () => {
572
+ const p = tmpStatePath();
573
+
574
+ const hookCtx = makeHookCtx(p);
575
+ // Should not crash on non-existent project
576
+ await onProjectIssueStuck(hookCtx, "no-such-proj", "PROJ-1");
577
+ });
578
+ });
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // buildDispatchQueue — additional branch coverage
582
+ // ---------------------------------------------------------------------------
583
+
584
+ describe("buildDispatchQueue — additional branches", () => {
585
+ it("ignores relations with null relatedIssue", () => {
586
+ const issues = [
587
+ makeIssue("PROJ-1", {
588
+ relations: [{ type: "blocks", relatedIssue: null as any }],
589
+ }),
590
+ ];
591
+ const queue = buildDispatchQueue(issues);
592
+ expect(queue["PROJ-1"].unblocks).toHaveLength(0);
593
+ });
594
+
595
+ it("ignores relations with undefined identifier", () => {
596
+ const issues = [
597
+ makeIssue("PROJ-1", {
598
+ relations: [{ type: "blocks", relatedIssue: { identifier: undefined as any } }],
599
+ }),
600
+ ];
601
+ const queue = buildDispatchQueue(issues);
602
+ expect(queue["PROJ-1"].unblocks).toHaveLength(0);
603
+ });
604
+
605
+ it("ignores non-blocks/non-blocked_by relation types", () => {
606
+ const issues = [
607
+ makeIssue("PROJ-1", {
608
+ relations: [{ type: "related", relatedIssue: { identifier: "PROJ-2" } }],
609
+ }),
610
+ makeIssue("PROJ-2"),
611
+ ];
612
+ const queue = buildDispatchQueue(issues);
613
+ expect(queue["PROJ-1"].unblocks).toHaveLength(0);
614
+ expect(queue["PROJ-1"].dependsOn).toHaveLength(0);
615
+ expect(queue["PROJ-2"].dependsOn).toHaveLength(0);
616
+ expect(queue["PROJ-2"].unblocks).toHaveLength(0);
617
+ });
618
+
619
+ it("filters skipped (epic) dependencies from unblocks lists", () => {
620
+ const issues = [
621
+ makeIssue("PROJ-1", {
622
+ relations: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
623
+ }),
624
+ makeIssue("PROJ-2", { labels: ["Epic"] }),
625
+ ];
626
+ const queue = buildDispatchQueue(issues);
627
+ // PROJ-1 unblocks PROJ-2 but PROJ-2 is skipped — should be filtered
628
+ expect(queue["PROJ-1"].unblocks).toHaveLength(0);
629
+ });
630
+
631
+ it("handles issues with null labels gracefully", () => {
632
+ const issue = {
633
+ id: "id-PROJ-1",
634
+ identifier: "PROJ-1",
635
+ labels: null as any,
636
+ relations: { nodes: [] },
637
+ };
638
+ const queue = buildDispatchQueue([issue as any]);
639
+ // Should not crash; labels?.nodes?.some returns falsy
640
+ expect(queue["PROJ-1"].dispatchStatus).toBe("pending");
641
+ });
642
+ });
643
+
644
+ // ---------------------------------------------------------------------------
645
+ // getReadyIssues — additional branch coverage
646
+ // ---------------------------------------------------------------------------
647
+
648
+ describe("getReadyIssues — additional branches", () => {
649
+ it("excludes stuck issues", () => {
650
+ const issues: Record<string, ProjectIssueStatus> = {
651
+ A: makeIssueStatus("A", { dispatchStatus: "stuck" }),
652
+ };
653
+ expect(getReadyIssues(issues)).toHaveLength(0);
654
+ });
655
+
656
+ it("excludes skipped issues", () => {
657
+ const issues: Record<string, ProjectIssueStatus> = {
658
+ A: makeIssueStatus("A", { dispatchStatus: "skipped" }),
659
+ };
660
+ expect(getReadyIssues(issues)).toHaveLength(0);
661
+ });
662
+
663
+ it("excludes pending issues where a dep is stuck", () => {
664
+ const issues: Record<string, ProjectIssueStatus> = {
665
+ A: makeIssueStatus("A", { dispatchStatus: "stuck" }),
666
+ B: makeIssueStatus("B", { dependsOn: ["A"] }),
667
+ };
668
+ expect(getReadyIssues(issues)).toHaveLength(0);
669
+ });
670
+
671
+ it("returns empty for empty issues", () => {
672
+ expect(getReadyIssues({})).toHaveLength(0);
673
+ });
674
+ });
675
+
676
+ // ---------------------------------------------------------------------------
677
+ // isProjectStuck — additional branch coverage
678
+ // ---------------------------------------------------------------------------
679
+
680
+ describe("isProjectStuck — additional branches", () => {
681
+ it("false when no issues at all", () => {
682
+ expect(isProjectStuck({})).toBe(false);
683
+ });
684
+
685
+ it("false when all issues are done (no stuck)", () => {
686
+ const issues: Record<string, ProjectIssueStatus> = {
687
+ A: makeIssueStatus("A", { dispatchStatus: "done" }),
688
+ B: makeIssueStatus("B", { dispatchStatus: "done" }),
689
+ };
690
+ expect(isProjectStuck(issues)).toBe(false);
691
+ });
692
+ });
693
+
694
+ // ---------------------------------------------------------------------------
695
+ // dispatchReadyIssues
696
+ // ---------------------------------------------------------------------------
697
+
698
+ describe("dispatchReadyIssues", () => {
699
+ it("returns early when no ready issues", async () => {
700
+ const p = tmpStatePath();
701
+ const hookCtx = makeHookCtx(p);
702
+ const pd = makeProjectDispatch({
703
+ issues: {
704
+ "PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched" }),
705
+ "PROJ-2": makeIssueStatus("PROJ-2", { dependsOn: ["PROJ-1"] }),
706
+ },
707
+ });
708
+
709
+ await dispatchReadyIssues(hookCtx, pd);
710
+
711
+ // No issues dispatched — logger.info not called for dispatching
712
+ expect(hookCtx.api.logger.info).not.toHaveBeenCalledWith(
713
+ expect.stringContaining("dispatching PROJ"),
714
+ );
715
+ });
716
+
717
+ it("returns early when all slots are filled", async () => {
718
+ const p = tmpStatePath();
719
+ const hookCtx = makeHookCtx(p);
720
+ const pd = makeProjectDispatch({
721
+ maxConcurrent: 1,
722
+ issues: {
723
+ "PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched" }),
724
+ "PROJ-2": makeIssueStatus("PROJ-2"),
725
+ },
726
+ });
727
+
728
+ await dispatchReadyIssues(hookCtx, pd);
729
+
730
+ // PROJ-2 is ready but no slots — should not be dispatched
731
+ expect(pd.issues["PROJ-2"].dispatchStatus).toBe("pending");
732
+ });
733
+
734
+ it("dispatches ready issues up to max concurrent", async () => {
735
+ const p = tmpStatePath();
736
+ const hookCtx = makeHookCtx(p);
737
+ const pd = makeProjectDispatch({
738
+ maxConcurrent: 2,
739
+ issues: {
740
+ "PROJ-1": makeIssueStatus("PROJ-1"),
741
+ "PROJ-2": makeIssueStatus("PROJ-2"),
742
+ "PROJ-3": makeIssueStatus("PROJ-3"),
743
+ },
744
+ });
745
+
746
+ await dispatchReadyIssues(hookCtx, pd);
747
+
748
+ const dispatched = Object.values(pd.issues).filter((i) => i.dispatchStatus === "dispatched");
749
+ expect(dispatched).toHaveLength(2);
750
+ });
751
+ });
752
+
753
+ // ---------------------------------------------------------------------------
754
+ // startProjectDispatch
755
+ // ---------------------------------------------------------------------------
756
+
757
+ describe("startProjectDispatch", () => {
758
+ it("creates dispatch state and dispatches leaf issues", async () => {
759
+ const p = tmpStatePath();
760
+ const mockIssues = [
761
+ makeIssue("PROJ-1", {
762
+ relations: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
763
+ }),
764
+ makeIssue("PROJ-2"),
765
+ ];
766
+
767
+ const hookCtx = {
768
+ api: {
769
+ logger: {
770
+ info: vi.fn(),
771
+ warn: vi.fn(),
772
+ error: vi.fn(),
773
+ },
774
+ } as any,
775
+ linearApi: {
776
+ getProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Test Project" }),
777
+ getProjectIssues: vi.fn().mockResolvedValue(mockIssues),
778
+ } as any,
779
+ notify: vi.fn().mockResolvedValue(undefined),
780
+ pluginConfig: { planningStatePath: p },
781
+ configPath: p,
782
+ };
783
+
784
+ await startProjectDispatch(hookCtx, "proj-1");
785
+
786
+ const result = await readProjectDispatch("proj-1", p);
787
+ expect(result).not.toBeNull();
788
+ expect(result!.status).toBe("dispatching");
789
+ expect(result!.projectName).toBe("Test Project");
790
+ expect(Object.keys(result!.issues)).toHaveLength(2);
791
+ expect(hookCtx.notify).toHaveBeenCalledWith("project_progress", expect.any(Object));
792
+ });
793
+
794
+ it("returns early when project has no issues", async () => {
795
+ const p = tmpStatePath();
796
+ const hookCtx = {
797
+ api: {
798
+ logger: {
799
+ info: vi.fn(),
800
+ warn: vi.fn(),
801
+ error: vi.fn(),
802
+ },
803
+ } as any,
804
+ linearApi: {
805
+ getProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Empty Project" }),
806
+ getProjectIssues: vi.fn().mockResolvedValue([]),
807
+ } as any,
808
+ notify: vi.fn().mockResolvedValue(undefined),
809
+ pluginConfig: { planningStatePath: p },
810
+ configPath: p,
811
+ };
812
+
813
+ await startProjectDispatch(hookCtx, "proj-1");
814
+
815
+ expect(hookCtx.api.logger.warn).toHaveBeenCalledWith(
816
+ expect.stringContaining("no issues found"),
817
+ );
818
+ const result = await readProjectDispatch("proj-1", p);
819
+ expect(result).toBeNull();
820
+ });
821
+
822
+ it("uses maxConcurrent from opts when provided", async () => {
823
+ const p = tmpStatePath();
824
+ const hookCtx = {
825
+ api: {
826
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
827
+ } as any,
828
+ linearApi: {
829
+ getProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Test" }),
830
+ getProjectIssues: vi.fn().mockResolvedValue([makeIssue("PROJ-1")]),
831
+ } as any,
832
+ notify: vi.fn().mockResolvedValue(undefined),
833
+ pluginConfig: { planningStatePath: p },
834
+ configPath: p,
835
+ };
836
+
837
+ await startProjectDispatch(hookCtx, "proj-1", { maxConcurrent: 5 });
838
+
839
+ const result = await readProjectDispatch("proj-1", p);
840
+ expect(result!.maxConcurrent).toBe(5);
841
+ });
842
+
843
+ it("uses maxConcurrent from pluginConfig when opts not provided", async () => {
844
+ const p = tmpStatePath();
845
+ const hookCtx = {
846
+ api: {
847
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
848
+ } as any,
849
+ linearApi: {
850
+ getProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Test" }),
851
+ getProjectIssues: vi.fn().mockResolvedValue([makeIssue("PROJ-1")]),
852
+ } as any,
853
+ notify: vi.fn().mockResolvedValue(undefined),
854
+ pluginConfig: { planningStatePath: p, maxConcurrentDispatches: 7 },
855
+ configPath: p,
856
+ };
857
+
858
+ await startProjectDispatch(hookCtx, "proj-1");
859
+
860
+ const result = await readProjectDispatch("proj-1", p);
861
+ expect(result!.maxConcurrent).toBe(7);
862
+ });
863
+
864
+ it("defaults maxConcurrent to 3 when no config", async () => {
865
+ const p = tmpStatePath();
866
+ const hookCtx = {
867
+ api: {
868
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
869
+ } as any,
870
+ linearApi: {
871
+ getProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Test" }),
872
+ getProjectIssues: vi.fn().mockResolvedValue([makeIssue("PROJ-1")]),
873
+ } as any,
874
+ notify: vi.fn().mockResolvedValue(undefined),
875
+ pluginConfig: { planningStatePath: p },
876
+ configPath: p,
877
+ };
878
+
879
+ await startProjectDispatch(hookCtx, "proj-1");
880
+
881
+ const result = await readProjectDispatch("proj-1", p);
882
+ expect(result!.maxConcurrent).toBe(3);
883
+ });
884
+
885
+ it("uses rootIdentifier from planning session when available", async () => {
886
+ const p = tmpStatePath();
887
+
888
+ // Pre-register a planning session
889
+ const { registerPlanningSession } = await import("./planning-state.js");
890
+ await registerPlanningSession("proj-1", {
891
+ projectId: "proj-1",
892
+ projectName: "Test",
893
+ rootIssueId: "issue-1",
894
+ rootIdentifier: "PLAN-100",
895
+ teamId: "team-1",
896
+ status: "approved",
897
+ startedAt: new Date().toISOString(),
898
+ turnCount: 5,
899
+ }, p);
900
+
901
+ const hookCtx = {
902
+ api: {
903
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
904
+ } as any,
905
+ linearApi: {
906
+ getProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Test Project" }),
907
+ getProjectIssues: vi.fn().mockResolvedValue([makeIssue("PROJ-1")]),
908
+ } as any,
909
+ notify: vi.fn().mockResolvedValue(undefined),
910
+ pluginConfig: { planningStatePath: p },
911
+ configPath: p,
912
+ };
913
+
914
+ await startProjectDispatch(hookCtx, "proj-1");
915
+
916
+ const result = await readProjectDispatch("proj-1", p);
917
+ expect(result!.rootIdentifier).toBe("PLAN-100");
918
+ });
919
+ });
920
+
921
+ // ---------------------------------------------------------------------------
922
+ // onProjectIssueCompleted — additional branches
923
+ // ---------------------------------------------------------------------------
924
+
925
+ describe("onProjectIssueCompleted — additional branches", () => {
926
+ it("does nothing when projectDispatch is null (non-existent project)", async () => {
927
+ const p = tmpStatePath();
928
+ const hookCtx = makeHookCtx(p);
929
+
930
+ // Should not crash
931
+ await onProjectIssueCompleted(hookCtx, "nonexistent", "PROJ-1");
932
+ expect(hookCtx.notify).not.toHaveBeenCalled();
933
+ });
934
+
935
+ it("dispatches newly ready issues after completion", async () => {
936
+ const p = tmpStatePath();
937
+ const pd = makeProjectDispatch({
938
+ issues: {
939
+ "PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched", unblocks: ["PROJ-2"] }),
940
+ "PROJ-2": makeIssueStatus("PROJ-2", { dependsOn: ["PROJ-1"] }),
941
+ },
942
+ });
943
+ await writeProjectDispatch(pd, p);
944
+
945
+ const hookCtx = makeHookCtx(p);
946
+ await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-1");
947
+
948
+ // PROJ-2 should now be dispatchable (its dep PROJ-1 is done)
949
+ const result = await readProjectDispatch("proj-1", p);
950
+ expect(result!.issues["PROJ-1"].dispatchStatus).toBe("done");
951
+ // Progress notification should be sent
952
+ expect(hookCtx.notify).toHaveBeenCalledWith("project_progress", expect.objectContaining({
953
+ status: expect.stringContaining("1/2"),
954
+ }));
955
+ });
956
+
957
+ it("counts only non-skipped issues in progress totals", async () => {
958
+ const p = tmpStatePath();
959
+ const pd = makeProjectDispatch({
960
+ issues: {
961
+ "PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched" }),
962
+ "PROJ-2": makeIssueStatus("PROJ-2", { dispatchStatus: "skipped" }),
963
+ },
964
+ });
965
+ await writeProjectDispatch(pd, p);
966
+
967
+ const hookCtx = makeHookCtx(p);
968
+ await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-1");
969
+
970
+ // Should count 1/1 (PROJ-2 is skipped)
971
+ const result = await readProjectDispatch("proj-1", p);
972
+ expect(result!.status).toBe("completed");
973
+ expect(hookCtx.notify).toHaveBeenCalledWith("project_complete", expect.objectContaining({
974
+ status: expect.stringContaining("1/1"),
975
+ }));
976
+ });
977
+ });
978
+
979
+ // ---------------------------------------------------------------------------
980
+ // writeProjectDispatch — additional branches
981
+ // ---------------------------------------------------------------------------
982
+
983
+ describe("writeProjectDispatch — additional branches", () => {
984
+ it("initializes projectDispatches when not present in state", async () => {
985
+ const p = tmpStatePath();
986
+ // Write some base planning state first (no projectDispatches key)
987
+ const { writePlanningState } = await import("./planning-state.js");
988
+ await writePlanningState({ sessions: {}, processedEvents: [] }, p);
989
+
990
+ const pd = makeProjectDispatch();
991
+ await writeProjectDispatch(pd, p);
992
+
993
+ const result = await readProjectDispatch("proj-1", p);
994
+ expect(result).not.toBeNull();
995
+ expect(result!.projectId).toBe("proj-1");
996
+ });
553
997
  });