create-interview-cockpit 0.23.0 → 0.23.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.
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useRef, Fragment } from "react";
2
2
  import { useStore } from "../store";
3
3
  import type { Question } from "../types";
4
+ import type { DriveFolder } from "../api";
4
5
  import * as api from "../api";
5
6
  import FileAttachments from "./FileAttachments";
6
7
  import WorkspaceSwitcher from "./WorkspaceSwitcher";
@@ -116,6 +117,8 @@ export default function Sidebar() {
116
117
  driveRootFolders,
117
118
  loadingDriveFolders,
118
119
  fetchDriveRootFolders,
120
+ fetchDriveSubfolders,
121
+ createDriveSubfolder,
119
122
  selectDriveSubfolder,
120
123
  clearDriveSubfolder,
121
124
  syncWorkspace,
@@ -214,6 +217,10 @@ export default function Sidebar() {
214
217
  const isAtDriveFolderRoot = isDriveWs && !currentSubFolder;
215
218
  const canSyncDriveFolder =
216
219
  !!activeWs?.driveConfig?.folderId && !isAtDriveFolderRoot;
220
+ const canPushLocalDrive =
221
+ activeWs?.type === "local" && !!activeWs.driveConfig?.folderId;
222
+ const canPushDriveDestination =
223
+ !!activeWs?.driveConfig?.folderId && (!isDriveWs || !!currentSubFolder);
217
224
  const driveSyncTargetName =
218
225
  currentSubFolder?.name ?? activeWs?.driveConfig?.folderName ?? "Drive";
219
226
  const [navigating, setNavigating] = useState(false);
@@ -224,6 +231,15 @@ export default function Sidebar() {
224
231
  const [topicDriveStatus, setTopicDriveStatus] = useState<
225
232
  Record<string, string>
226
233
  >({});
234
+ const [pushPicker, setPushPicker] = useState<{
235
+ mode: "workspace" | "topic";
236
+ topicId?: string;
237
+ folders: DriveFolder[];
238
+ loading: boolean;
239
+ } | null>(null);
240
+ const [pushPickerNewFolderName, setPushPickerNewFolderName] = useState("");
241
+ const [pushPickerCreatingFolder, setPushPickerCreatingFolder] =
242
+ useState(false);
227
243
  const [wsFilesExpanded, setWsFilesExpanded] = useState(true);
228
244
  const [driveFileSyncStatus, setDriveFileSyncStatus] = useState<string | null>(
229
245
  null,
@@ -277,15 +293,12 @@ export default function Sidebar() {
277
293
  }
278
294
  };
279
295
 
280
- const handlePushToDrive = async () => {
296
+ const pushWorkspaceToDrive = async (targetFolderId: string) => {
281
297
  if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
282
298
  setPushing(true);
283
299
  setDriveFileSyncStatus(null);
284
300
  try {
285
- const result = await exportWorkspace(
286
- activeWorkspaceId,
287
- activeWs.driveConfig.subFolderId,
288
- );
301
+ const result = await exportWorkspace(activeWorkspaceId, targetFolderId);
289
302
  if ("needsAuth" in result && result.needsAuth) {
290
303
  window.location.href = result.authUrl;
291
304
  return;
@@ -302,58 +315,100 @@ export default function Sidebar() {
302
315
  }
303
316
  };
304
317
 
305
- const setTopicStatus = (topicId: string, value: string) => {
306
- setTopicDriveStatus((prev) => ({ ...prev, [topicId]: value }));
307
- };
308
-
309
- const handlePullTopicFromDrive = async (topicId: string) => {
318
+ const pushTopicToDrive = async (topicId: string, targetFolderId: string) => {
310
319
  if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
311
- setTopicSyncingId(topicId);
312
- setTopicStatus(topicId, "Pulling topic from Drive…");
320
+ setTopicPushingId(topicId);
321
+ setTopicStatus(topicId, "Pushing topic to Drive…");
313
322
  try {
314
- const result = await syncTopic(activeWorkspaceId, topicId);
323
+ const result = await exportTopic(activeWorkspaceId, topicId, targetFolderId);
315
324
  if ("needsAuth" in result && result.needsAuth) {
316
325
  window.location.href = result.authUrl;
317
326
  return;
318
327
  }
319
- const firstError = result.errors[0];
320
328
  setTopicStatus(
321
329
  topicId,
322
330
  result.errors.length > 0
323
- ? `Topic pull finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}. ${firstError ? `First: ${firstError}` : ""}`
324
- : `Pulled ${result.filesImported} file${result.filesImported === 1 ? "" : "s"} into this topic.`,
331
+ ? `Topic push finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}.`
332
+ : `Pushed ${result.questionsExported} question${result.questionsExported === 1 ? "" : "s"} and ${result.filesExported} file${result.filesExported === 1 ? "" : "s"} to Drive.`,
325
333
  );
326
334
  } catch (err: any) {
327
- setTopicStatus(topicId, err?.message || "Topic pull failed.");
335
+ setTopicStatus(topicId, err?.message || "Topic push failed.");
328
336
  } finally {
329
- setTopicSyncingId(null);
337
+ setTopicPushingId(null);
330
338
  }
331
339
  };
332
340
 
333
- const handlePushTopicToDrive = async (topicId: string) => {
341
+ const openPushDestinationPicker = async (
342
+ mode: "workspace" | "topic",
343
+ topicId?: string,
344
+ ) => {
334
345
  if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
335
- setTopicPushingId(topicId);
336
- setTopicStatus(topicId, "Pushing topic to Drive…");
346
+ setPushPicker({ mode, topicId, folders: [], loading: true });
337
347
  try {
338
- const result = await exportTopic(
339
- activeWorkspaceId,
340
- topicId,
341
- activeWs.driveConfig.subFolderId,
342
- );
348
+ const folders = await fetchDriveSubfolders(activeWorkspaceId);
349
+ setPushPicker({ mode, topicId, folders, loading: false });
350
+ } catch (err: any) {
351
+ setPushPicker({ mode, topicId, folders: [], loading: false });
352
+ const message = err?.message || "Failed to load Drive folders.";
353
+ if (mode === "topic" && topicId) setTopicStatus(topicId, message);
354
+ else setDriveFileSyncStatus(message);
355
+ }
356
+ };
357
+
358
+ const handlePushToDrive = () => {
359
+ if (!activeWs?.driveConfig?.folderId) return;
360
+ if (currentSubFolder) {
361
+ void pushWorkspaceToDrive(currentSubFolder.id);
362
+ return;
363
+ }
364
+ void openPushDestinationPicker("workspace");
365
+ };
366
+
367
+ const handlePushTopicToDrive = (topicId: string) => {
368
+ if (!activeWs?.driveConfig?.folderId) return;
369
+ if (currentSubFolder) {
370
+ void pushTopicToDrive(topicId, currentSubFolder.id);
371
+ return;
372
+ }
373
+ void openPushDestinationPicker("topic", topicId);
374
+ };
375
+
376
+ const handlePushPickerDestination = (targetFolderId: string) => {
377
+ const picker = pushPicker;
378
+ if (!picker) return;
379
+ setPushPicker(null);
380
+ if (picker.mode === "topic" && picker.topicId) {
381
+ void pushTopicToDrive(picker.topicId, targetFolderId);
382
+ } else {
383
+ void pushWorkspaceToDrive(targetFolderId);
384
+ }
385
+ };
386
+
387
+ const setTopicStatus = (topicId: string, value: string) => {
388
+ setTopicDriveStatus((prev) => ({ ...prev, [topicId]: value }));
389
+ };
390
+
391
+ const handlePullTopicFromDrive = async (topicId: string) => {
392
+ if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
393
+ setTopicSyncingId(topicId);
394
+ setTopicStatus(topicId, "Pulling topic from Drive…");
395
+ try {
396
+ const result = await syncTopic(activeWorkspaceId, topicId);
343
397
  if ("needsAuth" in result && result.needsAuth) {
344
398
  window.location.href = result.authUrl;
345
399
  return;
346
400
  }
401
+ const firstError = result.errors[0];
347
402
  setTopicStatus(
348
403
  topicId,
349
404
  result.errors.length > 0
350
- ? `Topic push finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}.`
351
- : `Pushed ${result.questionsExported} question${result.questionsExported === 1 ? "" : "s"} and ${result.filesExported} file${result.filesExported === 1 ? "" : "s"} to Drive.`,
405
+ ? `Topic pull finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}. ${firstError ? `First: ${firstError}` : ""}`
406
+ : `Pulled ${result.filesImported} file${result.filesImported === 1 ? "" : "s"} into this topic.`,
352
407
  );
353
408
  } catch (err: any) {
354
- setTopicStatus(topicId, err?.message || "Topic push failed.");
409
+ setTopicStatus(topicId, err?.message || "Topic pull failed.");
355
410
  } finally {
356
- setTopicPushingId(null);
411
+ setTopicSyncingId(null);
357
412
  }
358
413
  };
359
414
 
@@ -1020,7 +1075,142 @@ export default function Sidebar() {
1020
1075
  };
1021
1076
 
1022
1077
  return (
1023
- <aside className="w-72 border-r border-slate-800 flex flex-col bg-slate-900/50 shrink-0">
1078
+ <>
1079
+ {pushPicker && (
1080
+ <div
1081
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
1082
+ onClick={() => !pushPicker.loading && setPushPicker(null)}
1083
+ >
1084
+ <div
1085
+ className="w-80 rounded-xl border border-slate-700 bg-slate-900 p-4 shadow-2xl"
1086
+ onClick={(e) => e.stopPropagation()}
1087
+ >
1088
+ <div className="mb-3 flex items-center justify-between gap-3">
1089
+ <div>
1090
+ <h3 className="text-sm font-semibold text-slate-200">
1091
+ Choose Drive destination
1092
+ </h3>
1093
+ <p className="mt-0.5 text-[11px] text-slate-500">
1094
+ {pushPicker.mode === "topic"
1095
+ ? "Push this topic into the selected Drive folder."
1096
+ : "Push this local workspace into the selected Drive folder."}
1097
+ </p>
1098
+ </div>
1099
+ <button
1100
+ onClick={() => setPushPicker(null)}
1101
+ className="text-slate-500 hover:text-slate-300"
1102
+ disabled={pushPicker.loading}
1103
+ >
1104
+ <X className="h-4 w-4" />
1105
+ </button>
1106
+ </div>
1107
+
1108
+ {pushPicker.loading ? (
1109
+ <div className="flex items-center justify-center gap-2 py-6 text-xs text-slate-400">
1110
+ <Loader2 className="h-4 w-4 animate-spin" />
1111
+ Loading Drive folders…
1112
+ </div>
1113
+ ) : (
1114
+ <>
1115
+ <div className="max-h-52 space-y-1 overflow-y-auto">
1116
+ {activeWs?.driveConfig?.folderId && (
1117
+ <button
1118
+ type="button"
1119
+ onClick={() =>
1120
+ handlePushPickerDestination(activeWs.driveConfig!.folderId)
1121
+ }
1122
+ className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs text-slate-300 hover:bg-slate-800"
1123
+ >
1124
+ <FolderOpen className="h-3.5 w-3.5 shrink-0 text-slate-500" />
1125
+ <span className="min-w-0 flex-1 truncate italic text-slate-400">
1126
+ {activeWs.driveConfig.folderName || "Linked root folder"}
1127
+ </span>
1128
+ </button>
1129
+ )}
1130
+ {pushPicker.folders.map((folder) => (
1131
+ <button
1132
+ key={folder.id}
1133
+ type="button"
1134
+ onClick={() => handlePushPickerDestination(folder.id)}
1135
+ className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs text-slate-200 hover:bg-slate-800"
1136
+ >
1137
+ <FolderOpen className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
1138
+ <span className="min-w-0 flex-1 truncate">
1139
+ {folder.name}
1140
+ </span>
1141
+ </button>
1142
+ ))}
1143
+ {pushPicker.folders.length === 0 && (
1144
+ <p className="px-3 py-2 text-xs text-slate-500">
1145
+ No subfolders found. Choose the linked root folder above
1146
+ or create a new folder below.
1147
+ </p>
1148
+ )}
1149
+ </div>
1150
+
1151
+ <form
1152
+ className="mt-3 flex gap-2"
1153
+ onSubmit={async (e) => {
1154
+ e.preventDefault();
1155
+ const name = pushPickerNewFolderName.trim();
1156
+ if (!name || !activeWorkspaceId) return;
1157
+ setPushPickerCreatingFolder(true);
1158
+ try {
1159
+ const created = await createDriveSubfolder(
1160
+ activeWorkspaceId,
1161
+ name,
1162
+ );
1163
+ if ("needsAuth" in created) {
1164
+ window.location.href = created.authUrl;
1165
+ return;
1166
+ }
1167
+ setPushPicker((prev) =>
1168
+ prev
1169
+ ? { ...prev, folders: [...prev.folders, created] }
1170
+ : prev,
1171
+ );
1172
+ setPushPickerNewFolderName("");
1173
+ } catch (err: any) {
1174
+ const message = err?.message || "Failed to create folder";
1175
+ if (pushPicker.mode === "topic" && pushPicker.topicId) {
1176
+ setTopicStatus(pushPicker.topicId, message);
1177
+ } else {
1178
+ setDriveFileSyncStatus(message);
1179
+ }
1180
+ } finally {
1181
+ setPushPickerCreatingFolder(false);
1182
+ }
1183
+ }}
1184
+ >
1185
+ <input
1186
+ value={pushPickerNewFolderName}
1187
+ onChange={(e) => setPushPickerNewFolderName(e.target.value)}
1188
+ placeholder="New folder name…"
1189
+ className="min-w-0 flex-1 rounded-lg border border-slate-700 bg-slate-800 px-2 py-1.5 text-xs text-slate-200 placeholder:text-slate-500 focus:border-cyan-500 focus:outline-none"
1190
+ />
1191
+ <button
1192
+ type="submit"
1193
+ disabled={
1194
+ !pushPickerNewFolderName.trim() ||
1195
+ pushPickerCreatingFolder
1196
+ }
1197
+ className="flex items-center gap-1 rounded-lg bg-cyan-700 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-cyan-600 disabled:opacity-40"
1198
+ >
1199
+ {pushPickerCreatingFolder ? (
1200
+ <Loader2 className="h-3 w-3 animate-spin" />
1201
+ ) : (
1202
+ <Plus className="h-3 w-3" />
1203
+ )}
1204
+ Create
1205
+ </button>
1206
+ </form>
1207
+ </>
1208
+ )}
1209
+ </div>
1210
+ </div>
1211
+ )}
1212
+
1213
+ <aside className="w-72 border-r border-slate-800 flex flex-col bg-slate-900/50 shrink-0">
1024
1214
  {/* Workspace switcher */}
1025
1215
  <WorkspaceSwitcher />
1026
1216
 
@@ -1060,6 +1250,32 @@ export default function Sidebar() {
1060
1250
  sync from inside that selected folder.
1061
1251
  </p>
1062
1252
  )}
1253
+ {canPushLocalDrive && (
1254
+ <div className="mt-2 space-y-1.5">
1255
+ <p className="text-[10px] text-slate-600">
1256
+ Push local workspace to Drive
1257
+ </p>
1258
+ <button
1259
+ type="button"
1260
+ onClick={handlePushToDrive}
1261
+ disabled={pushing}
1262
+ className="flex w-full items-center justify-center gap-1 rounded-md border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 text-[11px] font-medium text-cyan-300 transition-colors hover:bg-cyan-500/15 disabled:opacity-40 disabled:hover:bg-cyan-500/10"
1263
+ title="Choose a Drive folder and push topics, questions, and workspace files into it"
1264
+ >
1265
+ {pushing ? (
1266
+ <Loader2 className="w-3 h-3 animate-spin" />
1267
+ ) : (
1268
+ <Upload className="w-3 h-3" />
1269
+ )}
1270
+ {pushing ? "Pushing…" : "Choose folder & push"}
1271
+ </button>
1272
+ {driveFileSyncStatus && (
1273
+ <p className="text-[10px] leading-relaxed text-slate-500">
1274
+ {driveFileSyncStatus}
1275
+ </p>
1276
+ )}
1277
+ </div>
1278
+ )}
1063
1279
  {canSyncDriveFolder && (
1064
1280
  <div className="mt-2 space-y-1.5">
1065
1281
  <p className="text-[10px] text-slate-600">
@@ -1393,13 +1609,13 @@ export default function Sidebar() {
1393
1609
  <Download className="w-3 h-3" /> Download
1394
1610
  </button>
1395
1611
 
1396
- {canSyncDriveFolder && (
1612
+ {canPushDriveDestination && (
1397
1613
  <>
1398
1614
  <div className="border-t border-slate-700 my-0.5" />
1399
1615
  <button
1400
1616
  onClick={() => {
1401
1617
  setOpenMenuTopicId(null);
1402
- void handlePushTopicToDrive(topic.id);
1618
+ handlePushTopicToDrive(topic.id);
1403
1619
  }}
1404
1620
  disabled={topicBusy || pushing || syncing}
1405
1621
  className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-cyan-300 hover:bg-slate-700 hover:text-cyan-200 disabled:opacity-50 transition-colors"
@@ -1411,21 +1627,23 @@ export default function Sidebar() {
1411
1627
  )}
1412
1628
  Push topic
1413
1629
  </button>
1414
- <button
1415
- onClick={() => {
1416
- setOpenMenuTopicId(null);
1417
- void handlePullTopicFromDrive(topic.id);
1418
- }}
1419
- disabled={topicBusy || pushing || syncing}
1420
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-cyan-300 disabled:opacity-50 transition-colors"
1421
- >
1422
- {topicSyncingId === topic.id ? (
1423
- <Loader2 className="w-3 h-3 animate-spin" />
1424
- ) : (
1425
- <RefreshCw className="w-3 h-3" />
1426
- )}
1427
- Pull topic
1428
- </button>
1630
+ {canSyncDriveFolder && (
1631
+ <button
1632
+ onClick={() => {
1633
+ setOpenMenuTopicId(null);
1634
+ void handlePullTopicFromDrive(topic.id);
1635
+ }}
1636
+ disabled={topicBusy || pushing || syncing}
1637
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-cyan-300 disabled:opacity-50 transition-colors"
1638
+ >
1639
+ {topicSyncingId === topic.id ? (
1640
+ <Loader2 className="w-3 h-3 animate-spin" />
1641
+ ) : (
1642
+ <RefreshCw className="w-3 h-3" />
1643
+ )}
1644
+ Pull topic
1645
+ </button>
1646
+ )}
1429
1647
  </>
1430
1648
  )}
1431
1649
 
@@ -1555,6 +1773,7 @@ export default function Sidebar() {
1555
1773
  })}
1556
1774
  </div>
1557
1775
  )}
1558
- </aside>
1776
+ </aside>
1777
+ </>
1559
1778
  );
1560
1779
  }
@@ -335,7 +335,12 @@ export default function WorkspaceSwitcher() {
335
335
  <>
336
336
  <div className="space-y-1 max-h-48 overflow-y-auto">
337
337
  <button
338
- onClick={() => runExport(folderPicker.ws)}
338
+ onClick={() =>
339
+ runExport(
340
+ folderPicker.ws,
341
+ folderPicker.ws.driveConfig?.folderId,
342
+ )
343
+ }
339
344
  className="w-full text-left px-3 py-2 rounded-lg text-xs text-slate-300 hover:bg-slate-800 flex items-center gap-2"
340
345
  >
341
346
  <FolderOpen size={13} className="text-slate-500 shrink-0" />
@@ -0,0 +1,216 @@
1
+ // ─── Shared GitHub Actions concurrency helpers ─────────────────────────
2
+ // Used by the GitHub Actions Lab to evaluate `concurrency:` blocks against
3
+ // a simulated github context AND to enforce GitHub's queue/cancel rules on
4
+ // the actual `act` runs triggered from the lab UI.
5
+
6
+ import { parse as parseYaml } from "yaml";
7
+
8
+ // ─── Types ───────────────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Subset of `github.*` context fields used when evaluating a concurrency
12
+ * group expression. Kept narrow on purpose so we don't oversell what the
13
+ * tiny expression engine below can resolve.
14
+ */
15
+ export interface GhaConcurrencyContext {
16
+ event_name: string;
17
+ ref: string;
18
+ head_ref: string;
19
+ workflow: string;
20
+ }
21
+
22
+ export interface ParsedConcurrency {
23
+ groupExpr: string;
24
+ cancelExpr: string;
25
+ }
26
+
27
+ export type GhaConcurrencyRunStatus =
28
+ | "pending"
29
+ | "running"
30
+ | "completed"
31
+ | "cancelled";
32
+
33
+ /**
34
+ * One record per Run button click. Records survive in memory for the
35
+ * lifetime of the modal so the user can see the full timeline of how
36
+ * GitHub's concurrency rules played out.
37
+ */
38
+ export interface GhaConcurrencyRun {
39
+ id: string;
40
+ seq: number;
41
+ command: string;
42
+ /** github.event_name when the run was triggered. */
43
+ eventName: string;
44
+ /** Workflow file path used for the run. */
45
+ workflowPath: string;
46
+ /** Evaluated group key — empty string when no concurrency block exists. */
47
+ groupKey: string;
48
+ /** Evaluated cancel-in-progress flag — false when not declared. */
49
+ cancelInProgress: boolean;
50
+ /** Snapshot of the github.* context the run was evaluated against. */
51
+ context: GhaConcurrencyContext;
52
+ status: GhaConcurrencyRunStatus;
53
+ startedAt?: number;
54
+ endedAt?: number;
55
+ exitCode?: number;
56
+ cancelReason?: string;
57
+ }
58
+
59
+ // ─── Parsing ────────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Extract the workflow-level `concurrency` block. Tolerates both shapes
63
+ * GitHub accepts (`concurrency: my-group` and the long `group:` form).
64
+ * Returns null when the workflow doesn't declare concurrency.
65
+ */
66
+ export function parseConcurrencyBlock(
67
+ yaml: string | undefined,
68
+ ): ParsedConcurrency | null {
69
+ if (!yaml) return null;
70
+ let doc: unknown;
71
+ try {
72
+ doc = parseYaml(yaml);
73
+ } catch {
74
+ return null;
75
+ }
76
+ if (!doc || typeof doc !== "object") return null;
77
+ const raw = (doc as Record<string, unknown>).concurrency;
78
+ if (raw == null) return null;
79
+ if (typeof raw === "string") {
80
+ return { groupExpr: raw, cancelExpr: "false" };
81
+ }
82
+ if (typeof raw === "object") {
83
+ const obj = raw as Record<string, unknown>;
84
+ const group = typeof obj.group === "string" ? obj.group : "";
85
+ const cancel = obj["cancel-in-progress"];
86
+ const cancelExpr =
87
+ typeof cancel === "boolean"
88
+ ? String(cancel)
89
+ : typeof cancel === "string"
90
+ ? cancel
91
+ : "false";
92
+ return { groupExpr: group, cancelExpr };
93
+ }
94
+ return null;
95
+ }
96
+
97
+ // ─── Expression engine ─────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Resolve `github.X` against the simulated context. Returns undefined for
101
+ * any other namespace so unknown references render as empty (matching
102
+ * GitHub's behaviour for undefined expressions inside `${{ ... }}`).
103
+ */
104
+ function resolveGithubPath(
105
+ path: string,
106
+ ctx: GhaConcurrencyContext,
107
+ ): string | undefined {
108
+ if (!path.startsWith("github.")) return undefined;
109
+ const key = path.slice("github.".length) as keyof GhaConcurrencyContext;
110
+ const value = ctx[key];
111
+ return typeof value === "string" ? value : undefined;
112
+ }
113
+
114
+ /**
115
+ * Evaluate one `${{ … }}` expression. Supports the subset real workflows
116
+ * use when defining concurrency keys:
117
+ * - github.X
118
+ * - 'string literal'
119
+ * - true / false / integer literals
120
+ * - X || Y (coalesce — first non-empty wins, GitHub semantics)
121
+ * - X == Y (string equality → 'true'/'false')
122
+ */
123
+ function evalConcurrencyExpr(expr: string, ctx: GhaConcurrencyContext): string {
124
+ const trimmed = expr.trim();
125
+ if (!trimmed) return "";
126
+
127
+ // Equality binds tighter than `||` in our tiny grammar (matches the way
128
+ // real workflows are normally written: `event_name == 'pull_request'`).
129
+ if (trimmed.includes("==")) {
130
+ const [lhs, rhs] = trimmed.split("==").map((s) => s.trim());
131
+ const l = evalConcurrencyExpr(lhs, ctx);
132
+ const r = evalConcurrencyExpr(rhs, ctx);
133
+ return l === r ? "true" : "false";
134
+ }
135
+
136
+ // Coalesce: walk left-to-right, return the first non-empty value.
137
+ if (trimmed.includes("||")) {
138
+ for (const part of trimmed.split("||")) {
139
+ const value = evalConcurrencyExpr(part, ctx);
140
+ if (value) return value;
141
+ }
142
+ return "";
143
+ }
144
+
145
+ const literal = trimmed.match(/^'([^']*)'$/);
146
+ if (literal) return literal[1];
147
+
148
+ if (trimmed === "true" || trimmed === "false") return trimmed;
149
+ if (/^-?\d+$/.test(trimmed)) return trimmed;
150
+
151
+ const resolved = resolveGithubPath(trimmed, ctx);
152
+ return resolved ?? "";
153
+ }
154
+
155
+ /**
156
+ * Replace every `${{ … }}` placeholder in `template` with the value its
157
+ * inner expression evaluates to against `ctx`.
158
+ */
159
+ export function renderConcurrencyTemplate(
160
+ template: string,
161
+ ctx: GhaConcurrencyContext,
162
+ ): string {
163
+ if (!template) return "";
164
+ return template.replace(/\$\{\{\s*([\s\S]*?)\s*\}\}/g, (_match, inner) =>
165
+ evalConcurrencyExpr(String(inner), ctx),
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Compute the (groupKey, cancelInProgress) pair for a hypothetical run.
171
+ * When the workflow doesn't declare a concurrency block, returns
172
+ * (empty key, false) which means "no scheduling constraints — treat as
173
+ * its own group with no cancellation".
174
+ */
175
+ export function evaluateConcurrencyFor(
176
+ parsed: ParsedConcurrency | null,
177
+ ctx: GhaConcurrencyContext,
178
+ ): { groupKey: string; cancelInProgress: boolean } {
179
+ if (!parsed) return { groupKey: "", cancelInProgress: false };
180
+ const groupKey = renderConcurrencyTemplate(parsed.groupExpr, ctx);
181
+ const cancelRaw = renderConcurrencyTemplate(parsed.cancelExpr, ctx)
182
+ .trim()
183
+ .toLowerCase();
184
+ return { groupKey, cancelInProgress: cancelRaw === "true" };
185
+ }
186
+
187
+ /**
188
+ * Derive a plausible default github.* context from a selected `act` event
189
+ * name. We don't have the real one act injects, but these defaults are
190
+ * close enough that the evaluated group keys feel realistic.
191
+ */
192
+ export function defaultContextForEvent(
193
+ eventName: string,
194
+ workflowPath: string,
195
+ branch: string,
196
+ prNumber: number,
197
+ headRef: string,
198
+ ): GhaConcurrencyContext {
199
+ const workflowName = workflowPath
200
+ .replace(/^\.github\/workflows\//, "")
201
+ .replace(/\.ya?ml$/, "");
202
+ if (eventName === "pull_request") {
203
+ return {
204
+ event_name: eventName,
205
+ ref: `refs/pull/${prNumber}/merge`,
206
+ head_ref: headRef,
207
+ workflow: workflowName,
208
+ };
209
+ }
210
+ return {
211
+ event_name: eventName,
212
+ ref: `refs/heads/${branch}`,
213
+ head_ref: "",
214
+ workflow: workflowName,
215
+ };
216
+ }