create-interview-cockpit 0.26.1 → 0.28.0

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.
@@ -1,5 +1,80 @@
1
- import type { GithubActionsLabWorkspace } from "./types";
2
- import type { GithubActionsLabEnvironment } from "./types";
1
+ import type {
2
+ GithubActionsLabWorkspace,
3
+ GithubActionsLabEnvironment,
4
+ GithubLabOrg,
5
+ GithubLabBranchProtection,
6
+ GithubLabPullRequest,
7
+ GithubLabReview,
8
+ GithubLabCheckRun,
9
+ GithubLabCheckRunJob,
10
+ GithubLabRuleset,
11
+ GithubLabRulesetEnforcement,
12
+ GithubLabRulesetRules,
13
+ } from "./types";
14
+ import { rulesetFromLegacyProtection } from "./codeowners";
15
+
16
+ // ─── Default GitHub Lab "org" roster ────────────────────────────────────
17
+ //
18
+ // The lab simulates an org called `acme` so CODEOWNERS handles like
19
+ // `@acme/frontend` resolve to real people for the PR sim. Users can edit
20
+ // this in the Pull Request tab.
21
+ export const DEFAULT_GH_LAB_ORG: GithubLabOrg = {
22
+ slug: "acme",
23
+ viewerLogin: "octocat",
24
+ users: ["octocat", "alice", "bob", "carol"],
25
+ teams: [
26
+ {
27
+ slug: "frontend",
28
+ name: "Frontend",
29
+ members: ["alice"],
30
+ },
31
+ {
32
+ slug: "platform",
33
+ name: "Platform / Infra",
34
+ members: ["bob"],
35
+ },
36
+ {
37
+ slug: "docs",
38
+ name: "Docs",
39
+ members: ["carol"],
40
+ },
41
+ ],
42
+ };
43
+
44
+ export const DEFAULT_GH_LAB_BRANCH_PROTECTION: GithubLabBranchProtection = {
45
+ requireCodeOwnerReview: true,
46
+ requiredApprovingReviews: 1,
47
+ requiredStatusChecks: [],
48
+ };
49
+
50
+ // Default ruleset — mirrors the most common GitHub repo template:
51
+ // "protect the default branch with a PR + review + green CI before merge".
52
+ export const DEFAULT_GH_LAB_RULESETS: GithubLabRuleset[] = [
53
+ {
54
+ id: "default-branch-protection",
55
+ name: "Default branch protection",
56
+ enforcement: "active",
57
+ targetInclude: ["~DEFAULT_BRANCH"],
58
+ targetExclude: [],
59
+ bypass: [],
60
+ rules: {
61
+ pullRequest: {
62
+ requiredApprovingReviewCount: 1,
63
+ requireCodeOwnerReview: true,
64
+ dismissStaleReviewsOnPush: false,
65
+ requireLastPushApproval: false,
66
+ },
67
+ blockForcePushes: true,
68
+ restrictDeletions: true,
69
+ },
70
+ },
71
+ ];
72
+
73
+ export const DEFAULT_GH_LAB_PULL_REQUEST: GithubLabPullRequest = {
74
+ changedFiles: [],
75
+ reviews: [],
76
+ title: "Draft pull request",
77
+ };
3
78
 
4
79
  // ─── Default Lab Template ────────────────────────────────────────────────
5
80
  //
@@ -162,6 +237,38 @@ require("fs").readdirSync(".").forEach((f) => console.log(" -", f));
162
237
  # Pinning the runner image keeps installs reproducible across machines.
163
238
  -P ubuntu-latest=catthehacker/ubuntu:act-latest
164
239
  --container-architecture linux/amd64
240
+ `,
241
+
242
+ // The CODEOWNERS file lives in .github/ (one of the three locations
243
+ // GitHub looks at). Open the "Pull Request" tab to see who gets
244
+ // auto-requested when these patterns match files in your PR.
245
+ ".github/CODEOWNERS": `# CODEOWNERS — auto-request reviewers when files match a pattern.
246
+ #
247
+ # Syntax: <pattern> <@user> | <@org/team> | <email>
248
+ # Rules are evaluated top-to-bottom, and the LAST matching rule wins.
249
+ # A line with no owners explicitly clears ownership for that path.
250
+ #
251
+ # This lab ships with a fake "acme" org you can edit in the Pull Request
252
+ # tab. The default users are:
253
+ # - octocat (you, the PR author — cannot self-approve)
254
+ # - alice (frontend lead)
255
+ # - bob (platform/infra)
256
+ # - carol (docs writer)
257
+ # Teams: @acme/frontend, @acme/platform, @acme/docs
258
+
259
+ # Default owner for everything not matched by a more specific rule.
260
+ * @octocat
261
+
262
+ # Frontend code is owned by the frontend team.
263
+ /scripts/ @acme/frontend
264
+ *.js @acme/frontend
265
+
266
+ # CI / actions config is owned by the platform team.
267
+ /.github/ @acme/platform
268
+ /.github/workflows/ @acme/platform @bob
269
+
270
+ # Docs are reviewed by the docs writer.
271
+ *.md @acme/docs @carol
165
272
  `,
166
273
  };
167
274
 
@@ -384,11 +491,14 @@ li {
384
491
 
385
492
  export const DEFAULT_GHA_LAB: GithubActionsLabWorkspace = {
386
493
  version: 1,
387
- label: "GitHub Actions Playground",
494
+ label: "GitHub Lab Playground",
388
495
  activeFile: ".github/workflows/ci.yml",
389
496
  defaultEvent: "push",
390
497
  defaultWorkflow: ".github/workflows/ci.yml",
391
498
  files: DEFAULT_FILES,
499
+ ghOrg: DEFAULT_GH_LAB_ORG,
500
+ rulesets: DEFAULT_GH_LAB_RULESETS,
501
+ pullRequest: DEFAULT_GH_LAB_PULL_REQUEST,
392
502
  };
393
503
 
394
504
  export const REACT_VITE_TYPESCRIPT_GHA_LAB: GithubActionsLabWorkspace = {
@@ -452,6 +562,28 @@ export function cloneGhaLabWorkspace(
452
562
  ? source.activeFile
453
563
  : (Object.keys(sourceFiles)[0] ?? ".github/workflows/ci.yml");
454
564
  const environment = cloneGhaLabEnvironment(source.environment);
565
+ const ghOrg = cloneGhLabOrg(source.ghOrg);
566
+ const pullRequest = cloneGhLabPullRequest(source.pullRequest, sourceFiles);
567
+ // Branches default to ["main"]; we always keep the default branch in the
568
+ // list, and dedupe/sanitize any user-added names so a saved lab can't
569
+ // smuggle in empty strings or non-string junk.
570
+ const rawBranches = Array.isArray(source.branches)
571
+ ? source.branches.filter(
572
+ (b): b is string => typeof b === "string" && !!b.trim(),
573
+ )
574
+ : [];
575
+ const defaultBranch =
576
+ typeof source.defaultBranch === "string" && source.defaultBranch.trim()
577
+ ? source.defaultBranch.trim()
578
+ : "main";
579
+ const branches = Array.from(new Set([defaultBranch, ...rawBranches]));
580
+ // Migrate legacy branchProtection → a synthetic ruleset so saved labs
581
+ // from before the rulesets era keep their merge gating.
582
+ const rulesets = cloneGhLabRulesets(
583
+ source.rulesets,
584
+ source.branchProtection,
585
+ defaultBranch,
586
+ );
455
587
 
456
588
  return {
457
589
  version: 1,
@@ -471,6 +603,313 @@ export function cloneGhaLabWorkspace(
471
603
  ? { includeRunHistoryInContext: true }
472
604
  : {}),
473
605
  ...(environment ? { environment } : {}),
606
+ ...(ghOrg ? { ghOrg } : {}),
607
+ ...(pullRequest ? { pullRequest } : {}),
608
+ branches,
609
+ defaultBranch,
610
+ rulesets,
611
+ };
612
+ }
613
+
614
+ // ─── GitHub Lab roster / PR helpers ────────────────────────────────────
615
+
616
+ function cloneGhLabOrg(org?: GithubLabOrg): GithubLabOrg | undefined {
617
+ if (!org || typeof org !== "object") return undefined;
618
+ const users = Array.isArray(org.users)
619
+ ? Array.from(
620
+ new Set(
621
+ org.users.filter(
622
+ (u): u is string => typeof u === "string" && !!u.trim(),
623
+ ),
624
+ ),
625
+ )
626
+ : [];
627
+ const teams = Array.isArray(org.teams)
628
+ ? org.teams
629
+ .filter((t) => t && typeof t.slug === "string" && t.slug.trim())
630
+ .map((t) => ({
631
+ slug: t.slug,
632
+ ...(typeof t.name === "string" && t.name ? { name: t.name } : {}),
633
+ members: Array.isArray(t.members)
634
+ ? Array.from(
635
+ new Set(
636
+ t.members.filter(
637
+ (m): m is string => typeof m === "string" && !!m.trim(),
638
+ ),
639
+ ),
640
+ )
641
+ : [],
642
+ }))
643
+ : [];
644
+ return {
645
+ slug: typeof org.slug === "string" && org.slug.trim() ? org.slug : "acme",
646
+ viewerLogin:
647
+ typeof org.viewerLogin === "string" && org.viewerLogin.trim()
648
+ ? org.viewerLogin
649
+ : users[0] || "octocat",
650
+ users,
651
+ teams,
652
+ };
653
+ }
654
+
655
+ function cloneGhLabBranchProtection(
656
+ bp?: GithubLabBranchProtection,
657
+ ): GithubLabBranchProtection | undefined {
658
+ if (!bp || typeof bp !== "object") return undefined;
659
+ const requiredStatusChecks = Array.isArray(bp.requiredStatusChecks)
660
+ ? Array.from(
661
+ new Set(
662
+ bp.requiredStatusChecks.filter(
663
+ (n): n is string => typeof n === "string" && !!n.trim(),
664
+ ),
665
+ ),
666
+ )
667
+ : [];
668
+ return {
669
+ requireCodeOwnerReview: !!bp.requireCodeOwnerReview,
670
+ requiredApprovingReviews: Math.max(
671
+ 0,
672
+ Math.floor(Number(bp.requiredApprovingReviews) || 0),
673
+ ),
674
+ ...(requiredStatusChecks.length ? { requiredStatusChecks } : {}),
675
+ };
676
+ }
677
+
678
+ // ─── Rulesets clone + migration ────────────────────────────────────────
679
+ //
680
+ // Saved labs may carry: (a) the modern `rulesets` array, (b) the legacy
681
+ // `branchProtection` object, or (c) neither. We preserve user-authored
682
+ // rulesets when present, otherwise synthesize one from branchProtection
683
+ // (so labs from before the rulesets era keep their merge gating), and
684
+ // fall back to the default ruleset otherwise.
685
+
686
+ const RULESET_ENFORCEMENTS: ReadonlyArray<GithubLabRulesetEnforcement> = [
687
+ "active",
688
+ "evaluate",
689
+ "disabled",
690
+ ];
691
+
692
+ function cloneStringArray(value: unknown): string[] {
693
+ if (!Array.isArray(value)) return [];
694
+ return Array.from(
695
+ new Set(
696
+ value.filter((v): v is string => typeof v === "string" && !!v.trim()),
697
+ ),
698
+ );
699
+ }
700
+
701
+ function cloneGhLabRulesetRules(rules: unknown): GithubLabRulesetRules {
702
+ if (!rules || typeof rules !== "object") return {};
703
+ const r = rules as Partial<GithubLabRulesetRules>;
704
+ const out: GithubLabRulesetRules = {};
705
+ if (r.restrictCreations) out.restrictCreations = true;
706
+ if (r.restrictUpdates) out.restrictUpdates = true;
707
+ if (r.restrictDeletions) out.restrictDeletions = true;
708
+ if (r.requireLinearHistory) out.requireLinearHistory = true;
709
+ if (r.requireSignedCommits) out.requireSignedCommits = true;
710
+ if (r.blockForcePushes) out.blockForcePushes = true;
711
+ if (r.requireCodeQuality) out.requireCodeQuality = true;
712
+ if (r.pullRequest && typeof r.pullRequest === "object") {
713
+ out.pullRequest = {
714
+ requiredApprovingReviewCount: Math.max(
715
+ 0,
716
+ Math.floor(Number(r.pullRequest.requiredApprovingReviewCount) || 0),
717
+ ),
718
+ requireCodeOwnerReview: !!r.pullRequest.requireCodeOwnerReview,
719
+ dismissStaleReviewsOnPush: !!r.pullRequest.dismissStaleReviewsOnPush,
720
+ requireLastPushApproval: !!r.pullRequest.requireLastPushApproval,
721
+ };
722
+ }
723
+ if (r.statusChecks && typeof r.statusChecks === "object") {
724
+ const checks = cloneStringArray(r.statusChecks.checks);
725
+ if (checks.length || r.statusChecks.strict) {
726
+ out.statusChecks = { checks, strict: !!r.statusChecks.strict };
727
+ }
728
+ }
729
+ if (r.requireDeployments && typeof r.requireDeployments === "object") {
730
+ const environments = cloneStringArray(r.requireDeployments.environments);
731
+ if (environments.length) {
732
+ out.requireDeployments = { environments };
733
+ }
734
+ }
735
+ if (r.requireCodeScanning && typeof r.requireCodeScanning === "object") {
736
+ out.requireCodeScanning = {
737
+ tools: cloneStringArray(r.requireCodeScanning.tools),
738
+ };
739
+ }
740
+ return out;
741
+ }
742
+
743
+ function cloneGhLabRuleset(input: unknown): GithubLabRuleset | undefined {
744
+ if (!input || typeof input !== "object") return undefined;
745
+ const obj = input as Partial<GithubLabRuleset>;
746
+ const targetInclude = cloneStringArray(obj.targetInclude);
747
+ if (targetInclude.length === 0) return undefined;
748
+ const enforcement = RULESET_ENFORCEMENTS.includes(
749
+ obj.enforcement as GithubLabRulesetEnforcement,
750
+ )
751
+ ? (obj.enforcement as GithubLabRulesetEnforcement)
752
+ : "active";
753
+ return {
754
+ id:
755
+ typeof obj.id === "string" && obj.id
756
+ ? obj.id
757
+ : `rs_${Math.random().toString(36).slice(2, 10)}`,
758
+ name:
759
+ typeof obj.name === "string" && obj.name.trim()
760
+ ? obj.name.trim()
761
+ : "Untitled ruleset",
762
+ enforcement,
763
+ targetInclude,
764
+ targetExclude: cloneStringArray(obj.targetExclude),
765
+ bypass: cloneStringArray(obj.bypass),
766
+ rules: cloneGhLabRulesetRules(obj.rules),
767
+ };
768
+ }
769
+
770
+ function cloneGhLabRulesets(
771
+ rulesets: unknown,
772
+ legacyProtection: GithubLabBranchProtection | undefined,
773
+ defaultBranch: string,
774
+ ): GithubLabRuleset[] {
775
+ if (Array.isArray(rulesets)) {
776
+ const cleaned = rulesets
777
+ .map(cloneGhLabRuleset)
778
+ .filter((r): r is GithubLabRuleset => !!r);
779
+ if (cleaned.length > 0) return cleaned;
780
+ }
781
+ // No rulesets persisted — try migrating classic branchProtection.
782
+ const migrated = rulesetFromLegacyProtection(legacyProtection, defaultBranch);
783
+ if (migrated) return [migrated];
784
+ // Brand-new lab with no protection at all → use the template ruleset.
785
+ return DEFAULT_GH_LAB_RULESETS.map((r) => ({
786
+ ...r,
787
+ targetInclude: [...r.targetInclude],
788
+ targetExclude: [...r.targetExclude],
789
+ bypass: [...r.bypass],
790
+ rules: cloneGhLabRulesetRules(r.rules),
791
+ }));
792
+ }
793
+
794
+ const REVIEW_STATES: ReadonlyArray<GithubLabReview["state"]> = [
795
+ "approved",
796
+ "changes_requested",
797
+ "commented",
798
+ ];
799
+
800
+ function cloneGhLabReview(r: unknown): GithubLabReview | undefined {
801
+ if (!r || typeof r !== "object") return undefined;
802
+ const obj = r as Partial<GithubLabReview>;
803
+ if (typeof obj.author !== "string" || !obj.author.trim()) return undefined;
804
+ if (!REVIEW_STATES.includes(obj.state as GithubLabReview["state"])) {
805
+ return undefined;
806
+ }
807
+ return {
808
+ id:
809
+ typeof obj.id === "string" && obj.id
810
+ ? obj.id
811
+ : `rev_${Math.random().toString(36).slice(2, 10)}`,
812
+ author: obj.author,
813
+ state: obj.state as GithubLabReview["state"],
814
+ createdAt:
815
+ typeof obj.createdAt === "string" && obj.createdAt
816
+ ? obj.createdAt
817
+ : new Date().toISOString(),
818
+ ...(typeof obj.body === "string" && obj.body.trim()
819
+ ? { body: obj.body }
820
+ : {}),
821
+ };
822
+ }
823
+
824
+ const CHECK_STATUSES: ReadonlyArray<GithubLabCheckRunJob["status"]> = [
825
+ "queued",
826
+ "running",
827
+ "success",
828
+ "failed",
829
+ "cancelled",
830
+ "skipped",
831
+ ];
832
+
833
+ function cloneGhLabCheckRun(
834
+ run: GithubLabCheckRun | undefined,
835
+ ): GithubLabCheckRun | undefined {
836
+ if (!run || typeof run !== "object") return undefined;
837
+ const jobs = Array.isArray(run.jobs)
838
+ ? run.jobs.flatMap((j) => {
839
+ if (!j || typeof j !== "object") return [];
840
+ const name = (j as Partial<GithubLabCheckRunJob>).name;
841
+ const status = (j as Partial<GithubLabCheckRunJob>).status;
842
+ if (typeof name !== "string" || !name.trim()) return [];
843
+ if (
844
+ !CHECK_STATUSES.includes(status as GithubLabCheckRunJob["status"])
845
+ ) {
846
+ return [];
847
+ }
848
+ const durationMs = (j as Partial<GithubLabCheckRunJob>).durationMs;
849
+ return [
850
+ {
851
+ name,
852
+ status: status as GithubLabCheckRunJob["status"],
853
+ ...(typeof durationMs === "number" && Number.isFinite(durationMs)
854
+ ? { durationMs }
855
+ : {}),
856
+ } satisfies GithubLabCheckRunJob,
857
+ ];
858
+ })
859
+ : [];
860
+ if (jobs.length === 0) return undefined;
861
+ return {
862
+ completedAt:
863
+ typeof run.completedAt === "string" && run.completedAt
864
+ ? run.completedAt
865
+ : new Date().toISOString(),
866
+ ...(typeof run.workflow === "string" && run.workflow
867
+ ? { workflow: run.workflow }
868
+ : {}),
869
+ jobs,
870
+ };
871
+ }
872
+
873
+ function cloneGhLabPullRequest(
874
+ pr: GithubLabPullRequest | undefined,
875
+ files: Record<string, string>,
876
+ ): GithubLabPullRequest | undefined {
877
+ if (!pr || typeof pr !== "object") return undefined;
878
+ const changedFiles = Array.isArray(pr.changedFiles)
879
+ ? Array.from(
880
+ new Set(
881
+ pr.changedFiles.filter(
882
+ (p): p is string =>
883
+ typeof p === "string" &&
884
+ Object.prototype.hasOwnProperty.call(files, p),
885
+ ),
886
+ ),
887
+ )
888
+ : [];
889
+ const reviews: GithubLabReview[] = Array.isArray(pr.reviews)
890
+ ? pr.reviews
891
+ .map((r) => cloneGhLabReview(r))
892
+ .filter((r): r is GithubLabReview => !!r)
893
+ : [];
894
+ // Back-compat: pre-reviews labs only stored `approvals: string[]`. Promote
895
+ // those into synthetic "approved" reviews so they keep blocking merge.
896
+ if (reviews.length === 0 && Array.isArray(pr.approvals)) {
897
+ for (const login of pr.approvals) {
898
+ if (typeof login !== "string" || !login.trim()) continue;
899
+ reviews.push({
900
+ id: `legacy_${login}`,
901
+ author: login,
902
+ state: "approved",
903
+ createdAt: new Date(0).toISOString(),
904
+ });
905
+ }
906
+ }
907
+ const lastCheckRun = cloneGhLabCheckRun(pr.lastCheckRun);
908
+ return {
909
+ changedFiles,
910
+ reviews,
911
+ ...(lastCheckRun ? { lastCheckRun } : {}),
912
+ ...(typeof pr.title === "string" && pr.title ? { title: pr.title } : {}),
474
913
  };
475
914
  }
476
915
 
@@ -558,6 +997,25 @@ export function parseGhaLabWorkspace(
558
997
  ...(parsed.environment && typeof parsed.environment === "object"
559
998
  ? { environment: parsed.environment as GithubActionsLabEnvironment }
560
999
  : {}),
1000
+ ...(parsed.ghOrg && typeof parsed.ghOrg === "object"
1001
+ ? { ghOrg: parsed.ghOrg as GithubLabOrg }
1002
+ : {}),
1003
+ ...(parsed.branchProtection && typeof parsed.branchProtection === "object"
1004
+ ? {
1005
+ branchProtection:
1006
+ parsed.branchProtection as GithubLabBranchProtection,
1007
+ }
1008
+ : {}),
1009
+ ...(Array.isArray(parsed.rulesets)
1010
+ ? { rulesets: parsed.rulesets as GithubLabRuleset[] }
1011
+ : {}),
1012
+ ...(parsed.pullRequest && typeof parsed.pullRequest === "object"
1013
+ ? { pullRequest: parsed.pullRequest as GithubLabPullRequest }
1014
+ : {}),
1015
+ ...(Array.isArray(parsed.branches) ? { branches: parsed.branches } : {}),
1016
+ ...(typeof parsed.defaultBranch === "string"
1017
+ ? { defaultBranch: parsed.defaultBranch }
1018
+ : {}),
561
1019
  });
562
1020
  } catch {
563
1021
  return null;