@teambit/ci 1.0.507 → 1.0.512

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.
Files changed (36) hide show
  1. package/dist/ci.main.runtime.d.ts +3 -2
  2. package/dist/ci.main.runtime.js +5 -6
  3. package/dist/ci.main.runtime.js.map +1 -1
  4. package/dist/{preview-1787147173446.js → preview-1787168213927.js} +1 -1
  5. package/dist/sync/git-host-provider.d.ts +9 -0
  6. package/dist/sync/git-host-provider.js.map +1 -1
  7. package/dist/sync/github-client.d.ts +28 -0
  8. package/dist/sync/github-client.js +81 -3
  9. package/dist/sync/github-client.js.map +1 -1
  10. package/dist/sync/github-client.spec.js +249 -0
  11. package/dist/sync/github-client.spec.js.map +1 -1
  12. package/dist/sync/lane-sync-executor.d.ts +47 -3
  13. package/dist/sync/lane-sync-executor.js +191 -39
  14. package/dist/sync/lane-sync-executor.js.map +1 -1
  15. package/dist/sync/lane-sync-executor.spec.js +336 -0
  16. package/dist/sync/lane-sync-executor.spec.js.map +1 -1
  17. package/dist/sync/sync-planner.d.ts +12 -2
  18. package/dist/sync/sync-planner.js +17 -8
  19. package/dist/sync/sync-planner.js.map +1 -1
  20. package/dist/sync/sync-planner.spec.js +64 -0
  21. package/dist/sync/sync-planner.spec.js.map +1 -1
  22. package/dist/sync/sync-state.d.ts +34 -3
  23. package/dist/sync/sync-state.js +61 -3
  24. package/dist/sync/sync-state.js.map +1 -1
  25. package/dist/sync/sync-state.spec.js +54 -0
  26. package/dist/sync/sync-state.spec.js.map +1 -1
  27. package/package.json +15 -15
  28. package/sync/git-host-provider.ts +8 -0
  29. package/sync/github-client.spec.ts +160 -0
  30. package/sync/github-client.ts +90 -3
  31. package/sync/lane-sync-executor.spec.ts +291 -0
  32. package/sync/lane-sync-executor.ts +233 -50
  33. package/sync/sync-planner.spec.ts +41 -0
  34. package/sync/sync-planner.ts +31 -10
  35. package/sync/sync-state.spec.ts +48 -0
  36. package/sync/sync-state.ts +104 -4
@@ -2,6 +2,8 @@ import { expect } from 'chai';
2
2
  import {
3
3
  branchMirrorsOtherLaneNote,
4
4
  branchMirrorsOtherLaneReason,
5
+ changedLaneComponents,
6
+ laneSummaryComponents,
5
7
  crossScopeDescription,
6
8
  crossScopeMidFlightHaltReason,
7
9
  crossScopeRefusal,
@@ -14,6 +16,8 @@ import {
14
16
  LaneSyncExecutor,
15
17
  laneSyncPrBody,
16
18
  racedLedgerPushSummary,
19
+ RUN_SUMMARY_MARKER,
20
+ runSummaryCommentBody,
17
21
  } from './lane-sync-executor';
18
22
  import type { LaneOwnershipEvidence } from './sync-planner';
19
23
  import { resolveSyncConfig } from './sync-config';
@@ -528,6 +532,8 @@ describe('executeExportBranch reports the ledger-race wording when only the ledg
528
532
  (executor as any).lastNonSyncCommitMessage = async () => 'feat: some change';
529
533
  (executor as any).checkoutFromRemote = async () => {};
530
534
  (executor as any).restoreWorkspace = async () => {};
535
+ (executor as any).materializeLane = async () => undefined;
536
+ (executor as any).deps.ci = { hasUnsyncedWorkChanges: async () => true };
531
537
  // The export half already succeeded; only the ledger commit that follows raced.
532
538
  (executor as any).snapAndExportOntoLane = async () => ({ status: 'exported' });
533
539
  (executor as any).recordLaneHeadOnBranch = async () => ({ status: 'raced' });
@@ -546,6 +552,116 @@ describe('executeExportBranch reports the ledger-race wording when only the ledg
546
552
  });
547
553
  });
548
554
 
555
+ // The probe's "clean" answer means converged only against the lane the PLAN read. A lane that moved
556
+ // in between must re-plan — comparing the branch's files with the newer lane would misread the
557
+ // lane's own advance as branch work and export a revert over a concurrent developer's export.
558
+ describe('executeExportBranch bails when the lane moved since planning', () => {
559
+ function stubExport(currentLaneComponents: LaneComponents, plannedLaneComponents: LaneComponents) {
560
+ const { executor } = stubExecutor();
561
+ const touched: string[] = [];
562
+ (executor as any).checkoutFromRemote = async () => {};
563
+ (executor as any).restoreWorkspace = async () => {};
564
+ (executor as any).materializeLane = async () => undefined;
565
+ (executor as any).lastNonSyncCommitMessage = async () => 'feat: some change';
566
+ (executor as any).getRemoteLane = async () => ({ components: currentLaneComponents });
567
+ (executor as any).deps.ci = {
568
+ hasUnsyncedWorkChanges: async () => {
569
+ touched.push('status');
570
+ return false;
571
+ },
572
+ };
573
+ (executor as any).snapAndExportOntoLane = async () => {
574
+ touched.push('snap');
575
+ return { status: 'exported' };
576
+ };
577
+ const run = () =>
578
+ (executor as any).executeExportBranch({
579
+ target: { hostScope: 'acme.shop', name: 'my-lane' },
580
+ laneIdStr: 'acme.shop/my-lane',
581
+ branch: 'my-lane',
582
+ defaultBranch: 'main',
583
+ preExportLane: { components: plannedLaneComponents },
584
+ }) as Promise<string>;
585
+ return { run, touched };
586
+ }
587
+
588
+ const L1 = [comp('acme.shop/comp1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1')];
589
+ const L2 = [comp('acme.shop/comp1', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2')];
590
+
591
+ it('a moved lane returns a re-plan noop before the status read or any snap', async () => {
592
+ const { run, touched } = stubExport(L2, L1);
593
+ const summary = await run();
594
+ expect(summary).to.include('my-lane -> noop');
595
+ expect(summary).to.include('moved');
596
+ expect(touched).to.deep.equal([]);
597
+ });
598
+
599
+ it('an unmoved lane proceeds to the probe', async () => {
600
+ const { run, touched } = stubExport(L1, L1);
601
+ const summary = await run();
602
+ expect(summary).to.include('my-lane -> noop (converged)');
603
+ expect(touched).to.deep.equal(['status']);
604
+ });
605
+ });
606
+
607
+ // The merge path snaps whatever the merge produced — which can be nothing new, now routinely: a
608
+ // source-bundling tip plus a moved lane plans merge-diverged, and a branch with nothing of its own
609
+ // merges clean and leaves the snap a noop. The summary must say so, and the run-summary comment
610
+ // belongs only to runs that actually exported.
611
+ describe('executeMergeDiverged distinguishes an exporting merge from a nothing-new merge', () => {
612
+ function stubMerge(snapStatus: 'noop' | 'exported', currentLaneComponents: LaneComponents = []) {
613
+ const { executor } = stubExecutor();
614
+ const summaryCalls: any[] = [];
615
+ (executor as any).checkoutFromRemote = async () => {};
616
+ (executor as any).restoreWorkspace = async () => {};
617
+ (executor as any).getRemoteLane = async () => ({ components: currentLaneComponents });
618
+ (executor as any).mergeLaneIntoBranchWorkingTree = async () => ({ conflicts: [], conflictedFileCount: 0 });
619
+ (executor as any).snapAndExportOntoLane = async () => ({ status: snapStatus });
620
+ (executor as any).recordLaneHeadOnBranch = async () => ({
621
+ status: 'ok',
622
+ laneHead: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
623
+ remoteLane: { components: [] },
624
+ });
625
+ (executor as any).postRunSummaryComment = async (args: any) => summaryCalls.push(args);
626
+ const run = () =>
627
+ (executor as any).executeMergeDiverged({
628
+ target: { hostScope: 'acme.shop', name: 'my-lane' },
629
+ laneIdStr: 'acme.shop/my-lane',
630
+ branch: 'my-lane',
631
+ defaultBranch: 'main',
632
+ preExportLane: { components: [] },
633
+ }) as Promise<string>;
634
+ return { run, summaryCalls };
635
+ }
636
+
637
+ it('a nothing-new merge says so, and posts no run summary', async () => {
638
+ const { run, summaryCalls } = stubMerge('noop');
639
+ const summary = await run();
640
+ expect(summary).to.include('my-lane -> merge-diverged');
641
+ expect(summary).to.include('nothing new to export');
642
+ expect(summary).to.not.include('then exported');
643
+ expect(summaryCalls).to.have.lengthOf(0);
644
+ });
645
+
646
+ it('an exporting merge keeps the exported wording and posts the run summary', async () => {
647
+ const { run, summaryCalls } = stubMerge('exported');
648
+ const summary = await run();
649
+ expect(summary).to.include('then exported');
650
+ expect(summaryCalls).to.have.lengthOf(1);
651
+ expect(summaryCalls[0].laneHead).to.equal('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1');
652
+ });
653
+
654
+ // Same freshness rule as the export probe: the plan's inputs must still describe the world.
655
+ it('a lane that moved since planning bails to a re-plan before merging anything', async () => {
656
+ const moved = [comp('acme.shop/comp1', 'ccccccccccccccccccccccccccccccccccccccc3')];
657
+ const { run, summaryCalls } = stubMerge('exported', moved);
658
+ const summary = await run();
659
+ expect(summary).to.include('my-lane -> noop');
660
+ expect(summary).to.include('moved');
661
+ expect(summaryCalls).to.have.lengthOf(0);
662
+ });
663
+ });
664
+
549
665
  // Adoption never snaps or exports, so its raced line must NOT claim the lane was updated — the lane
550
666
  // is untouched and only the branch's pointer commit lost the race.
551
667
  describe('executeAdoptBranch reports a lane-untouched race when its ledger push races', () => {
@@ -593,3 +709,178 @@ describe('isProtectedBranch', () => {
593
709
  expect(isProtectedBranch('my-lane', 'develop', 'bit-sync/main')).to.equal(false);
594
710
  });
595
711
  });
712
+
713
+ describe('laneSummaryComponents', () => {
714
+ it('includes hidden updateDependents — a cascade export must not read as "nothing changed"', () => {
715
+ const lane = {
716
+ components: [comp('acme.shop/comp1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1')],
717
+ updateDependents: [comp('acme.shop/dep1', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2')],
718
+ } as any;
719
+ expect(laneSummaryComponents(lane).map((c) => c.id.toStringWithoutVersion())).to.deep.equal([
720
+ 'acme.shop/comp1',
721
+ 'acme.shop/dep1',
722
+ ]);
723
+ });
724
+
725
+ it('an absent lane lists nothing', () => {
726
+ expect(laneSummaryComponents(undefined)).to.deep.equal([]);
727
+ });
728
+ });
729
+
730
+ describe('changedLaneComponents', () => {
731
+ it('includes a component new to the lane, and one whose head moved', () => {
732
+ const before = [comp('acme.shop/comp1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1')];
733
+ const after = [
734
+ comp('acme.shop/comp1', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2'),
735
+ comp('acme.shop/comp2', 'ccccccccccccccccccccccccccccccccccccccc3'),
736
+ ];
737
+ const changed = changedLaneComponents(before, after);
738
+ expect(changed.map((c) => c.id.toStringWithoutVersion())).to.deep.equal(['acme.shop/comp1', 'acme.shop/comp2']);
739
+ });
740
+
741
+ it('excludes a component whose head did not move', () => {
742
+ const stable = comp('acme.shop/comp1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1');
743
+ expect(changedLaneComponents([stable], [stable])).to.deep.equal([]);
744
+ });
745
+
746
+ it('treats an undefined "before" (never seen this lane) as everything being new', () => {
747
+ const after = [comp('acme.shop/comp1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1')];
748
+ expect(changedLaneComponents(undefined, after)).to.deep.equal(after);
749
+ });
750
+ });
751
+
752
+ describe('runSummaryCommentBody', () => {
753
+ it('carries the marker, the changed components, and the branch/lane anchors', () => {
754
+ const body = runSummaryCommentBody({
755
+ laneIdStr: 'acme.shop/my-lane',
756
+ laneUrl: 'https://bit.cloud/acme/shop/~lane/my-lane',
757
+ branch: 'my-lane',
758
+ branchTipSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
759
+ laneHead: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2',
760
+ changed: [comp('acme.shop/comp1', 'ccccccccccccccccccccccccccccccccccccccc3')],
761
+ });
762
+ expect(body).to.include(RUN_SUMMARY_MARKER);
763
+ expect(body).to.include('acme.shop/comp1` @ `ccccccccc');
764
+ expect(body).to.include('branch: `my-lane` @ `aaaaaaaaa`');
765
+ // the lane anchor is a LINK — the reader lands on the lane, not on a name to copy-paste
766
+ expect(body).to.include('lane: [`acme.shop/my-lane`](https://bit.cloud/acme/shop/~lane/my-lane) @ `bbbbbbbbb`');
767
+ });
768
+
769
+ it('says plainly that nothing changed, rather than an empty list', () => {
770
+ const body = runSummaryCommentBody({
771
+ laneIdStr: 'acme.shop/my-lane',
772
+ laneUrl: 'https://bit.cloud/acme/shop/~lane/my-lane',
773
+ branch: 'my-lane',
774
+ branchTipSha: undefined,
775
+ laneHead: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2',
776
+ changed: [],
777
+ });
778
+ expect(body).to.include('none — nothing on the lane changed this run');
779
+ expect(body).to.not.include('@ `undefined');
780
+ });
781
+ });
782
+
783
+ // The comment is a pure surface effect: it must never gate the run's own outcome, and must degrade
784
+ // silently through every "nothing to comment on" state a real host, PR or branch can be in.
785
+ describe('postRunSummaryComment', () => {
786
+ const BEFORE = [comp('acme.shop/comp1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1')];
787
+ const AFTER = [comp('acme.shop/comp1', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2')];
788
+
789
+ function executorWith(gitHost: any, warnings: string[] = []) {
790
+ const executor = new LaneSyncExecutor({
791
+ lanes: {} as any,
792
+ ci: {} as any,
793
+ logger: { console: () => {}, consoleWarning: (msg: string) => warnings.push(msg), error: () => {} } as any,
794
+ gitHost,
795
+ cfg: resolveSyncConfig({}),
796
+ defaultScope: 'acme.shop',
797
+ });
798
+ (executor as any).currentBranchTip = async () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1';
799
+ return executor;
800
+ }
801
+
802
+ const call = (executor: LaneSyncExecutor) =>
803
+ (executor as any).postRunSummaryComment({
804
+ target: { hostScope: 'acme.shop', name: 'my-lane' },
805
+ laneIdStr: 'acme.shop/my-lane',
806
+ branch: 'my-lane',
807
+ laneHead: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2',
808
+ branchTipSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
809
+ preComponents: BEFORE,
810
+ postComponents: AFTER,
811
+ }) as Promise<void>;
812
+
813
+ // The anchor is the ledger sha THIS run pushed — passed in, never refetched: a concurrent developer
814
+ // push must not be presented as the synced tip, and no remote read exists to fail the path.
815
+ it('anchors the body on the given pushed sha without any branch refetch', async () => {
816
+ const upserts: string[] = [];
817
+ const gitHost = {
818
+ findPrByBranch: async () => ({ number: 7, htmlUrl: 'https://example.test/pr/7', labels: [] }),
819
+ upsertComment: async (_n: number, _m: string, body: string) => upserts.push(body),
820
+ };
821
+ const executor = executorWith(gitHost);
822
+ (executor as any).currentBranchTip = async () => {
823
+ throw new Error('must not be called');
824
+ };
825
+ await call(executor);
826
+ expect(upserts).to.have.lengthOf(1);
827
+ expect(upserts[0]).to.include('branch: `my-lane` @ `aaaaaaaaa`');
828
+ });
829
+
830
+ it('upserts the marked comment on the open PR when the host supports it', async () => {
831
+ const calls: any[] = [];
832
+ const gitHost = {
833
+ findPrByBranch: async () => ({ number: 7, htmlUrl: 'https://example.test/pr/7', labels: [] }),
834
+ upsertComment: async (prNumber: number, marker: string, body: string) => {
835
+ calls.push({ prNumber, marker, body });
836
+ },
837
+ };
838
+ await call(executorWith(gitHost));
839
+ expect(calls).to.have.lengthOf(1);
840
+ expect(calls[0].prNumber).to.equal(7);
841
+ expect(calls[0].marker).to.equal(RUN_SUMMARY_MARKER);
842
+ expect(calls[0].body).to.include('acme.shop/comp1` @ `bbbbbbbbb');
843
+ });
844
+
845
+ it('is a no-op with no configured git host', async () => {
846
+ await call(executorWith(undefined)); // would throw on any host call; nothing to assert but "did not throw"
847
+ });
848
+
849
+ it('is a no-op when the host does not implement upsertComment', async () => {
850
+ const calls: any[] = [];
851
+ const gitHost = {
852
+ findPrByBranch: async () => {
853
+ calls.push('findPrByBranch');
854
+ return { number: 7, htmlUrl: 'https://example.test/pr/7', labels: [] };
855
+ },
856
+ };
857
+ await call(executorWith(gitHost));
858
+ // upsertComment is feature-tested BEFORE the PR lookup — an unsupported host must not even look up the PR.
859
+ expect(calls).to.deep.equal([]);
860
+ });
861
+
862
+ it('is a no-op when the branch has no open PR yet', async () => {
863
+ const calls: any[] = [];
864
+ const gitHost = {
865
+ findPrByBranch: async () => undefined,
866
+ upsertComment: async () => {
867
+ calls.push('upsertComment');
868
+ },
869
+ };
870
+ await call(executorWith(gitHost));
871
+ expect(calls).to.deep.equal([]);
872
+ });
873
+
874
+ it('warns and does not throw when the comment API call fails', async () => {
875
+ const warnings: string[] = [];
876
+ const gitHost = {
877
+ findPrByBranch: async () => ({ number: 7, htmlUrl: 'https://example.test/pr/7', labels: [] }),
878
+ upsertComment: async () => {
879
+ throw new Error('rate limited');
880
+ },
881
+ };
882
+ await call(executorWith(gitHost, warnings));
883
+ expect(warnings.join('\n')).to.contain('Could not post the run-summary comment');
884
+ expect(warnings.join('\n')).to.contain('rate limited');
885
+ });
886
+ });