create-interview-cockpit 0.28.0 → 0.29.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -790,3 +790,123 @@ export function findCodeOwnersPath(
790
790
  export function isCodeOwnersPath(path: string): boolean {
791
791
  return (CODEOWNERS_LOCATIONS as readonly string[]).includes(path);
792
792
  }
793
+
794
+ // ─── Pull request templates ───────────────────────────────────────────
795
+ //
796
+ // GitHub doesn't have a settings UI for PR templates — they're picked
797
+ // up purely from files in the repo. The lookup rules we mirror:
798
+ //
799
+ // 1. Single template: the first match wins, in priority order:
800
+ // .github/pull_request_template.md
801
+ // .github/PULL_REQUEST_TEMPLATE.md
802
+ // pull_request_template.md (repo root)
803
+ // PULL_REQUEST_TEMPLATE.md (repo root)
804
+ // docs/pull_request_template.md
805
+ // docs/PULL_REQUEST_TEMPLATE.md
806
+ // Lookup is also case-insensitive on the filename.
807
+ //
808
+ // 2. Multiple templates: any *.md (or *.txt) file inside
809
+ // .github/PULL_REQUEST_TEMPLATE/
810
+ // PULL_REQUEST_TEMPLATE/
811
+ // docs/PULL_REQUEST_TEMPLATE/
812
+ // Each becomes a pick-able template; on github.com you'd append
813
+ // ?template=foo.md to the compare URL. We just show a picker.
814
+ //
815
+ // If both kinds are present, github.com shows the directory picker but
816
+ // also still lists the single template; we follow the same behaviour.
817
+
818
+ export interface PullRequestTemplate {
819
+ /** Workspace path of the file. */
820
+ path: string;
821
+ /** Display name (file basename minus extension, prettified). */
822
+ name: string;
823
+ /** Raw markdown body of the template. */
824
+ body: string;
825
+ /** True if this is the repo's default single template. */
826
+ isDefault: boolean;
827
+ }
828
+
829
+ const SINGLE_PR_TEMPLATE_DIRS: readonly string[] = [".github/", "", "docs/"];
830
+ const MULTI_PR_TEMPLATE_DIRS: readonly string[] = [
831
+ ".github/PULL_REQUEST_TEMPLATE/",
832
+ "PULL_REQUEST_TEMPLATE/",
833
+ "docs/PULL_REQUEST_TEMPLATE/",
834
+ ];
835
+
836
+ function isPullRequestTemplateFilename(filename: string): boolean {
837
+ // GitHub matches pull_request_template.md case-insensitively, with .md
838
+ // or .markdown extensions. We accept both.
839
+ return /^pull_request_template\.(md|markdown)$/i.test(filename);
840
+ }
841
+
842
+ function prettifyTemplateName(filename: string): string {
843
+ const base = filename.replace(/\.(md|markdown|txt)$/i, "");
844
+ return base.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
845
+ }
846
+
847
+ /**
848
+ * Discover all PR templates in the workspace, in github.com priority
849
+ * order. Returns an empty array if no templates exist.
850
+ */
851
+ export function findPullRequestTemplates(
852
+ files: Record<string, string>,
853
+ ): PullRequestTemplate[] {
854
+ const out: PullRequestTemplate[] = [];
855
+ const seen = new Set<string>();
856
+
857
+ // 1. Single-template lookup
858
+ for (const dir of SINGLE_PR_TEMPLATE_DIRS) {
859
+ for (const path of Object.keys(files)) {
860
+ if (seen.has(path)) continue;
861
+ if (!path.startsWith(dir)) continue;
862
+ const rest = path.slice(dir.length);
863
+ if (rest.includes("/")) continue; // not directly in this dir
864
+ if (!isPullRequestTemplateFilename(rest)) continue;
865
+ out.push({
866
+ path,
867
+ name: "Default",
868
+ body: files[path] ?? "",
869
+ isDefault: true,
870
+ });
871
+ seen.add(path);
872
+ break; // first match per dir; priority order handles the rest
873
+ }
874
+ }
875
+
876
+ // 2. Multi-template directory lookup
877
+ for (const dir of MULTI_PR_TEMPLATE_DIRS) {
878
+ for (const path of Object.keys(files)) {
879
+ if (seen.has(path)) continue;
880
+ if (!path.startsWith(dir)) continue;
881
+ const rest = path.slice(dir.length);
882
+ if (!rest || rest.includes("/")) continue;
883
+ if (!/\.(md|markdown|txt)$/i.test(rest)) continue;
884
+ out.push({
885
+ path,
886
+ name: prettifyTemplateName(rest),
887
+ body: files[path] ?? "",
888
+ isDefault: false,
889
+ });
890
+ seen.add(path);
891
+ }
892
+ }
893
+
894
+ return out;
895
+ }
896
+
897
+ /** True if the path is a recognised PR-template location. */
898
+ export function isPullRequestTemplatePath(path: string): boolean {
899
+ for (const dir of SINGLE_PR_TEMPLATE_DIRS) {
900
+ if (!path.startsWith(dir)) continue;
901
+ const rest = path.slice(dir.length);
902
+ if (!rest.includes("/") && isPullRequestTemplateFilename(rest)) return true;
903
+ }
904
+ for (const dir of MULTI_PR_TEMPLATE_DIRS) {
905
+ if (!path.startsWith(dir)) continue;
906
+ const rest = path.slice(dir.length);
907
+ if (rest && !rest.includes("/") && /\.(md|markdown|txt)$/i.test(rest)) {
908
+ return true;
909
+ }
910
+ }
911
+ return false;
912
+ }
@@ -482,13 +482,24 @@ export default function ChatView({ question }: Props) {
482
482
  // and triggering an update loop.
483
483
  const initialMessages = useMemo(
484
484
  () =>
485
- (question.messages ?? []).map((m) => ({
486
- id: m.id,
487
- role: m.role as "user" | "assistant",
488
- parts: (m.parts ?? [
489
- { type: "text" as const, text: m.content },
490
- ]) as UIMessage["parts"],
491
- })),
485
+ (question.messages ?? []).map((m) => {
486
+ // Older saved messages persist as `{ content, parts: [] }` — the empty
487
+ // array is truthy so a plain `m.parts ?? fallback` keeps `parts`
488
+ // empty, which makes `getTextContent` return "" and breaks any feature
489
+ // that reads message text (annotations, copy-to-clipboard, etc.).
490
+ // Treat empty `parts` the same as missing and rebuild from `content`.
491
+ const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
492
+ const parts = hasParts
493
+ ? (m.parts as UIMessage["parts"])
494
+ : ([
495
+ { type: "text" as const, text: m.content ?? "" },
496
+ ] as UIMessage["parts"]);
497
+ return {
498
+ id: m.id,
499
+ role: m.role as "user" | "assistant",
500
+ parts,
501
+ };
502
+ }),
492
503
  // eslint-disable-next-line react-hooks/exhaustive-deps
493
504
  [question.id],
494
505
  );
@@ -3,6 +3,8 @@ import {
3
3
  ChevronDown,
4
4
  ChevronRight,
5
5
  Copy,
6
+ Eye,
7
+ EyeOff,
6
8
  FilePlus,
7
9
  Folder,
8
10
  GitPullRequest,
@@ -24,6 +26,8 @@ import {
24
26
  X,
25
27
  } from "lucide-react";
26
28
  import MonacoEditorLib from "@monaco-editor/react";
29
+ import ReactMarkdown from "react-markdown";
30
+ import remarkGfm from "remark-gfm";
27
31
  import type {
28
32
  BeforeMount,
29
33
  Monaco,
@@ -346,6 +350,11 @@ export default function GithubActionsLabModal() {
346
350
  const [historyNonce, setHistoryNonce] = useState(0);
347
351
  const [leftCollapsed, setLeftCollapsed] = useState(false);
348
352
  const [rightCollapsed, setRightCollapsed] = useState(false);
353
+ // When viewing a markdown file, let the user toggle between the raw
354
+ // editor and a rendered preview. We default to preview because most
355
+ // of the .md files in the governance template are explanatory docs
356
+ // — readers want them to look like real GitHub markdown, not source.
357
+ const [mdPreview, setMdPreview] = useState(true);
349
358
  const abortRef = useRef<AbortController | null>(null);
350
359
  // ── Concurrency engine state ───────────────────────────────────────
351
360
  // The concurrency tab is no longer a simulator — these records track
@@ -2188,48 +2197,80 @@ interface ImportMeta {
2188
2197
  {activeFile || "(no file selected)"}
2189
2198
  </span>
2190
2199
  </div>
2191
- <button
2192
- onClick={() => setRightCollapsed((v) => !v)}
2193
- className="p-1 rounded text-slate-500 hover:text-amber-300 hover:bg-slate-800/60 shrink-0"
2194
- title={
2195
- rightCollapsed
2196
- ? "Show console/jobs panel"
2197
- : "Hide console/jobs panel"
2198
- }
2199
- >
2200
- {rightCollapsed ? (
2201
- <PanelRightOpen className="w-3.5 h-3.5" />
2202
- ) : (
2203
- <PanelRightClose className="w-3.5 h-3.5" />
2200
+ <div className="flex items-center gap-1">
2201
+ {/* Markdown preview toggle. Only meaningful for .md / .markdown
2202
+ files; hidden otherwise so it doesn't clutter the header. */}
2203
+ {activeFile && /\.(md|markdown)$/i.test(activeFile) && (
2204
+ <button
2205
+ onClick={() => setMdPreview((v) => !v)}
2206
+ className="flex items-center gap-1 p-1 rounded text-slate-500 hover:text-amber-300 hover:bg-slate-800/60"
2207
+ title={
2208
+ mdPreview
2209
+ ? "Show raw markdown source"
2210
+ : "Render markdown preview"
2211
+ }
2212
+ >
2213
+ {mdPreview ? (
2214
+ <EyeOff className="w-3.5 h-3.5" />
2215
+ ) : (
2216
+ <Eye className="w-3.5 h-3.5" />
2217
+ )}
2218
+ <span className="text-[10px] uppercase tracking-wide">
2219
+ {mdPreview ? "Source" : "Preview"}
2220
+ </span>
2221
+ </button>
2204
2222
  )}
2205
- </button>
2223
+ <button
2224
+ onClick={() => setRightCollapsed((v) => !v)}
2225
+ className="p-1 rounded text-slate-500 hover:text-amber-300 hover:bg-slate-800/60 shrink-0"
2226
+ title={
2227
+ rightCollapsed
2228
+ ? "Show console/jobs panel"
2229
+ : "Hide console/jobs panel"
2230
+ }
2231
+ >
2232
+ {rightCollapsed ? (
2233
+ <PanelRightOpen className="w-3.5 h-3.5" />
2234
+ ) : (
2235
+ <PanelRightClose className="w-3.5 h-3.5" />
2236
+ )}
2237
+ </button>
2238
+ </div>
2206
2239
  </div>
2207
2240
  <div className="flex-1 min-h-0">
2208
- {activeFile && workspace.files[activeFile] !== undefined && (
2209
- <MonacoEditorLib
2210
- key={activeFile}
2211
- height="100%"
2212
- width="100%"
2213
- language={getEditorLanguage(activeFile)}
2214
- theme="gha-lab-dark"
2215
- path={getGhaLabModelPath(activeFile)}
2216
- value={workspace.files[activeFile]}
2217
- beforeMount={handleBeforeMount}
2218
- onMount={handleMount}
2219
- onChange={handleEditorChange}
2220
- options={{
2221
- fontFamily: EDITOR_FONT,
2222
- fontSize: 13,
2223
- lineHeight: 22,
2224
- minimap: { enabled: false },
2225
- automaticLayout: true,
2226
- scrollBeyondLastLine: false,
2227
- wordWrap: "off",
2228
- tabSize: 2,
2229
- insertSpaces: true,
2230
- }}
2231
- />
2232
- )}
2241
+ {activeFile &&
2242
+ workspace.files[activeFile] !== undefined &&
2243
+ (mdPreview && /\.(md|markdown)$/i.test(activeFile) ? (
2244
+ <div className="h-full w-full overflow-auto px-6 py-4 text-slate-200 gha-md-preview">
2245
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
2246
+ {workspace.files[activeFile]}
2247
+ </ReactMarkdown>
2248
+ </div>
2249
+ ) : (
2250
+ <MonacoEditorLib
2251
+ key={activeFile}
2252
+ height="100%"
2253
+ width="100%"
2254
+ language={getEditorLanguage(activeFile)}
2255
+ theme="gha-lab-dark"
2256
+ path={getGhaLabModelPath(activeFile)}
2257
+ value={workspace.files[activeFile]}
2258
+ beforeMount={handleBeforeMount}
2259
+ onMount={handleMount}
2260
+ onChange={handleEditorChange}
2261
+ options={{
2262
+ fontFamily: EDITOR_FONT,
2263
+ fontSize: 13,
2264
+ lineHeight: 22,
2265
+ minimap: { enabled: false },
2266
+ automaticLayout: true,
2267
+ scrollBeyondLastLine: false,
2268
+ wordWrap: "off",
2269
+ tabSize: 2,
2270
+ insertSpaces: true,
2271
+ }}
2272
+ />
2273
+ ))}
2233
2274
  </div>
2234
2275
  </div>
2235
2276
 
@@ -9,6 +9,7 @@ import {
9
9
  } from "../infraLab";
10
10
  import {
11
11
  DEFAULT_GHA_LAB,
12
+ GOVERNANCE_GHA_LAB,
12
13
  parseGhaLabWorkspace,
13
14
  REACT_VITE_TYPESCRIPT_GHA_LAB,
14
15
  } from "../githubActionsLab";
@@ -696,6 +697,12 @@ export default function LabsPanel() {
696
697
  "Multi-job CI workflow with a local composite action and a matrix build",
697
698
  onClick: () => openGhaLab(DEFAULT_GHA_LAB),
698
699
  },
700
+ {
701
+ label: "Platform Governance Template",
702
+ description:
703
+ "PLF-style mono-repo: CODEOWNERS, PR template, Azure PIM/Policy + AWS IAM deploy workflows, offboarding",
704
+ onClick: () => openGhaLab(GOVERNANCE_GHA_LAB),
705
+ },
699
706
  ]}
700
707
  onOpen={openGhaFile}
701
708
  openTitle="Open in GitHub Lab"
@@ -26,6 +26,7 @@ import { useMemo, useState } from "react";
26
26
  import {
27
27
  Check,
28
28
  CircleDot,
29
+ FileText,
29
30
  GitBranch,
30
31
  GitPullRequest,
31
32
  Info,
@@ -52,10 +53,12 @@ import {
52
53
  evaluateCodeOwners,
53
54
  evaluateMergeability,
54
55
  findCodeOwnersPath,
56
+ findPullRequestTemplates,
55
57
  latestReviewByAuthor,
56
58
  parseCodeOwners,
57
59
  type CheckStatus,
58
60
  type MergeabilityResult,
61
+ type PullRequestTemplate,
59
62
  } from "../codeowners";
60
63
 
61
64
  interface PullRequestPanelProps {
@@ -119,6 +122,13 @@ export default function PullRequestPanel({
119
122
  [evaluation, pr, workspace.rulesets, org, baseBranch, defaultBranch],
120
123
  );
121
124
 
125
+ // PR templates discovered from .github/PULL_REQUEST_TEMPLATE[.md]
126
+ // (just like real github.com — no Settings UI, file-based only).
127
+ const templates = useMemo(
128
+ () => findPullRequestTemplates(workspace.files),
129
+ [workspace.files],
130
+ );
131
+
122
132
  // ── Mutators ────────────────────────────────────────────────────
123
133
  const updatePR = (next: Partial<GithubLabPullRequest>) =>
124
134
  onChange((prev) => ({
@@ -206,6 +216,13 @@ export default function PullRequestPanel({
206
216
  </div>
207
217
  </header>
208
218
 
219
+ {/* ── Description (PR body + template picker) ── */}
220
+ <PullRequestBody
221
+ body={pr.body ?? ""}
222
+ templates={templates}
223
+ onChange={(body) => updatePR({ body })}
224
+ />
225
+
209
226
  {!codeownersPath && (
210
227
  <div className="mb-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-2 text-amber-200">
211
228
  No CODEOWNERS file found. Add one at <code>.github/CODEOWNERS</code>{" "}
@@ -578,6 +595,114 @@ function labelForStatus(s: string): string {
578
595
  }
579
596
  }
580
597
 
598
+ // ─── PR description body + template picker ────────────────────────────
599
+ //
600
+ // On github.com the description box is auto-prefilled from a PR
601
+ // template when you open a new PR, and you can choose between
602
+ // templates via ?template= when there's a directory of them. Here we
603
+ // surface the same mechanic explicitly: a textarea for the body, plus
604
+ // "Apply template" buttons that overwrite the body with the chosen
605
+ // template (with a confirm if the body has been edited away from any
606
+ // template's contents).
607
+
608
+ function PullRequestBody({
609
+ body,
610
+ templates,
611
+ onChange,
612
+ }: {
613
+ body: string;
614
+ templates: PullRequestTemplate[];
615
+ onChange: (next: string) => void;
616
+ }) {
617
+ const [collapsed, setCollapsed] = useState(false);
618
+ // Map body → matching template name (so we can mark the active chip).
619
+ const matchingTemplate = templates.find((t) => t.body === body);
620
+
621
+ const apply = (t: PullRequestTemplate) => {
622
+ if (
623
+ body.trim().length > 0 &&
624
+ !templates.some((tpl) => tpl.body === body) &&
625
+ !window.confirm(
626
+ `Replace the current description with template "${t.name}"?`,
627
+ )
628
+ ) {
629
+ return;
630
+ }
631
+ onChange(t.body);
632
+ };
633
+
634
+ return (
635
+ <section className="mb-3 rounded-xl border border-slate-800/60 bg-slate-900/40">
636
+ <header className="flex items-center justify-between gap-2 px-3 py-2">
637
+ <button
638
+ onClick={() => setCollapsed((c) => !c)}
639
+ className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-slate-400 hover:text-amber-200"
640
+ >
641
+ <FileText className="h-3 w-3" />
642
+ Description
643
+ </button>
644
+ {templates.length > 0 && (
645
+ <div className="flex flex-wrap items-center gap-1">
646
+ <span className="text-[10px] text-slate-500">Templates:</span>
647
+ {templates.map((t) => {
648
+ const active = matchingTemplate?.path === t.path;
649
+ return (
650
+ <button
651
+ key={t.path}
652
+ onClick={() => apply(t)}
653
+ title={t.path}
654
+ className={`rounded px-1.5 py-0.5 text-[10px] ${
655
+ active
656
+ ? "bg-amber-500/25 text-amber-100"
657
+ : "bg-slate-800 text-slate-300 hover:bg-slate-700"
658
+ }`}
659
+ >
660
+ {t.name}
661
+ </button>
662
+ );
663
+ })}
664
+ {body.length > 0 && (
665
+ <button
666
+ onClick={() => onChange("")}
667
+ className="rounded px-1.5 py-0.5 text-[10px] text-slate-500 hover:text-rose-300"
668
+ title="Clear the description"
669
+ >
670
+ Clear
671
+ </button>
672
+ )}
673
+ </div>
674
+ )}
675
+ </header>
676
+ {!collapsed && (
677
+ <div className="px-3 pb-3">
678
+ {templates.length === 0 && body.length === 0 && (
679
+ <p className="mb-1.5 text-[10px] text-slate-500">
680
+ Tip: add{" "}
681
+ <code className="rounded bg-slate-800 px-1 py-px text-[10px] text-amber-200">
682
+ .github/pull_request_template.md
683
+ </code>{" "}
684
+ to the workspace and a one-click "Default" template will appear
685
+ here — same as github.com.
686
+ </p>
687
+ )}
688
+ <textarea
689
+ value={body}
690
+ onChange={(e) => onChange(e.target.value)}
691
+ placeholder="Leave a description (Markdown supported)…"
692
+ className="h-32 w-full resize-y rounded border border-slate-700 bg-slate-950/60 p-2 font-mono text-[11px] text-slate-200 outline-none focus:border-emerald-500/40"
693
+ />
694
+ {matchingTemplate && (
695
+ <p className="mt-1 text-[10px] text-slate-500">
696
+ Using template{" "}
697
+ <code className="text-slate-400">{matchingTemplate.path}</code>
698
+ </p>
699
+ )}
700
+ </div>
701
+ )}
702
+ </section>
703
+ );
704
+ }
705
+
581
706
  // ─── Rulesets summary (read-only) ──────────────────────────────────────
582
707
  //
583
708
  // On real github.com the PR sidebar shows a tiny summary of which
@@ -1379,17 +1379,20 @@ function EnvironmentsSection({
1379
1379
 
1380
1380
  function Field({
1381
1381
  label,
1382
+ help,
1382
1383
  children,
1383
1384
  }: {
1384
1385
  label: string;
1386
+ help?: string;
1385
1387
  children: React.ReactNode;
1386
1388
  }) {
1387
1389
  return (
1388
- <div>
1390
+ <div className="mt-3">
1389
1391
  <div className="mb-1 text-[11px] font-semibold text-slate-200">
1390
1392
  {label}
1391
1393
  </div>
1392
1394
  {children}
1395
+ {help && <p className="mt-1 text-[11px] text-slate-500">{help}</p>}
1393
1396
  </div>
1394
1397
  );
1395
1398
  }