Complete workflow template adoption UX in actions settings

swadhinbiswas committed 8 months ago
e73d9d0
1 parent df63ee1
commit e73d9d0
2 files changed
+234 -3
▼doc/feature_audit.md
+3-3
11
11
| **Pull Requests** | 9 | 6 | 0 | 15 |
12
12
| **Code Review** | 9 | 1 | 0 | 10 |
13
13
| **Issues & Planning** | 10 | 0 | 0 | 10 |
14
-| **CI/CD & Automation** | 5 | 3 | 0 | 8 |
14
+| **CI/CD & Automation** | 6 | 2 | 0 | 8 |
15
15
| **Third-Party Integrations** | 4 | 18 | 0 | 22 |
16
16
| **Dependency & Impact Awareness** | 1 | 4 | 0 | 5 |
17
17
| **Security** | 9 | 3 | 0 | 12 |
19
19
| **Notifications & Collaboration** | 5 | 3 | 0 | 8 |
20
20
| **Interfaces & Extensibility** | 5 | 2 | 0 | 7 |
21
21
| **Self-Hosted & Deployment** | 4 | 3 | 0 | 7 |
22
-| **Total** | **76** | **49** | **0** | **125** |
22
+| **Total** | **77** | **48** | **0** | **125** |
23
23
24
24
---
25
25
97
97
| Merge checks & gates | ✅ | Required status checks + external CI gate readiness are enforced, with explicit PR gate introspection via `pulls/{number}/merge-readiness` now including policy reports (`failedByType`, recommendations, pass/fail counts), active label/review/custom gate evaluation in `ci-gates.ts` with gate-type metadata, repository-level gate policy management/reporting APIs (`/repos/{owner}/{repo}/merge-gates`, `/repos/{owner}/{repo}/merge-gates/{id}`), and PR detail merge-gate policy UX for live blockers/breakdowns/remediation guidance |
98
98
| Automation rules engine | ⚠️ | Conditions/actions active with retry + dead-letter audit logging; advanced orchestration still maturing |
99
99
| Webhooks | ✅ | Full implementation with UI |
100
-| Workflow templates | ⚠️ | Template library plus repo-level listing/apply APIs are now implemented (`workflow/templates`); broader UX/workflow adoption still maturing |
100
+| Workflow templates | ✅ | Template library plus repo-level listing/apply APIs are implemented (`workflow/templates`), and repository Actions settings now include workflow template discovery/adoption UX with category/language filters, direct apply flow, and applied-workflow visibility |
101
101
102
102
## 6. Third-Party Integrations
103
103
### 6.1 Code Quality & Coverage
104
104
▼src/pages/[owner]/[repo]/settings/actions/index.astro
+231-0
4
4
import { getDatabase, schema } from "@/db";
5
5
import { eq, and } from "drizzle-orm";
6
6
import { canWriteRepo } from "@/lib/permissions";
7
+import { getRepositoryWorkflows, getWorkflowTemplates } from "@/lib/workflow-templates";
7
8
8
9
const { owner: ownerName, repo: repoName } = Astro.params;
9
10
const db = getDatabase();
48
49
openPrCount: repoData.openPrCount,
49
50
openIssueCount: 0
50
51
};
52
+
53
+const [initialTemplates, existingWorkflows] = await Promise.all([
54
+ getWorkflowTemplates().catch(() => []),
55
+ getRepositoryWorkflows(repoData.id).catch(() => []),
56
+]);
51
57
---
52
58
53
59
<BaseLayout title={`Actions Settings · ${repo.owner}/${repo.name}`}>
215
221
</div>
216
222
</div>
217
223
224
+ <!-- Workflow Templates -->
225
+ <div class="relative">
226
+ <div class="absolute -inset-[1px] bg-gradient-to-r from-emerald-500/20 via-transparent to-teal-500/20 rounded-xl opacity-50"></div>
227
+ <div class="relative rounded-xl border border-white/10 bg-[#0d1117]/80 backdrop-blur-sm p-6 space-y-4">
228
+ <h3 class="text-lg font-semibold text-white flex items-center gap-2">
229
+ <svg class="h-5 w-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
230
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16h8M8 12h8m-8-4h8M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H9l-4 4v10a2 2 0 002 2z"></path>
231
+ </svg>
232
+ Workflow templates
233
+ </h3>
234
+ <p class="text-sm text-gray-500">
235
+ Browse official workflow templates and apply them directly to this repository.
236
+ </p>
237
+
238
+ <div class="flex flex-wrap items-end gap-3">
239
+ <div>
240
+ <label for="template-category-filter" class="block text-xs text-gray-400 mb-1">Category</label>
241
+ <select id="template-category-filter" class="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white">
242
+ <option value="">All categories</option>
243
+ <option value="ci">CI</option>
244
+ <option value="cd">CD</option>
245
+ <option value="security">Security</option>
246
+ <option value="quality">Quality</option>
247
+ </select>
248
+ </div>
249
+ <div>
250
+ <label for="template-language-filter" class="block text-xs text-gray-400 mb-1">Language</label>
251
+ <select id="template-language-filter" class="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white">
252
+ <option value="">Any language</option>
253
+ <option value="node">Node.js</option>
254
+ <option value="python">Python</option>
255
+ <option value="go">Go</option>
256
+ <option value="rust">Rust</option>
257
+ </select>
258
+ </div>
259
+ <div>
260
+ <label for="template-workflow-name" class="block text-xs text-gray-400 mb-1">Workflow file name</label>
261
+ <input
262
+ id="template-workflow-name"
263
+ placeholder="ci"
264
+ class="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder:text-gray-600"
265
+ />
266
+ </div>
267
+ <button
268
+ id="template-refresh-btn"
269
+ class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-emerald-500/30 text-emerald-300 text-sm hover:bg-emerald-500/10 transition-colors"
270
+ >
271
+ Refresh templates
272
+ </button>
273
+ </div>
274
+
275
+ <div id="template-action-msg" class="text-xs text-gray-400"></div>
276
+
277
+ <div id="template-list" class="space-y-2">
278
+ {initialTemplates.length === 0 ? (
279
+ <div class="text-sm text-gray-500">No templates found.</div>
280
+ ) : (
281
+ initialTemplates.slice(0, 6).map((template) => (
282
+ <div
283
+ class="rounded-lg border border-white/10 bg-white/[0.02] p-3 flex items-start justify-between gap-3"
284
+ data-template-id={template.id}
285
+ >
286
+ <div>
287
+ <div class="text-sm font-medium text-white">{template.name}</div>
288
+ <div class="text-xs text-gray-500 mt-1">
289
+ {(template.category || "uncategorized").toUpperCase()}
290
+ {template.language ? ` • ${template.language}` : ""}
291
+ </div>
292
+ {template.description && (
293
+ <p class="text-xs text-gray-400 mt-1">{template.description}</p>
294
+ )}
295
+ </div>
296
+ <button
297
+ class="apply-template-btn px-3 py-1.5 rounded-lg border border-emerald-500/30 text-emerald-300 text-xs hover:bg-emerald-500/10"
298
+ data-template-id={template.id}
299
+ >
300
+ Apply
301
+ </button>
302
+ </div>
303
+ ))
304
+ )}
305
+ </div>
306
+
307
+ <div class="pt-2 border-t border-white/10">
308
+ <h4 class="text-sm font-medium text-white mb-2">Applied workflows</h4>
309
+ <div id="applied-workflows-list" class="space-y-1">
310
+ {existingWorkflows.length === 0 ? (
311
+ <div class="text-xs text-gray-500">No workflows applied from templates yet.</div>
312
+ ) : (
313
+ existingWorkflows.map((workflow) => (
314
+ <div class="flex items-center justify-between text-xs rounded border border-white/10 bg-white/[0.02] px-2 py-1.5">
315
+ <span class="text-gray-300">{workflow.name}</span>
316
+ <span class="text-gray-500 font-mono">{workflow.path}</span>
317
+ </div>
318
+ ))
319
+ )}
320
+ </div>
321
+ </div>
322
+ </div>
323
+ </div>
324
+
218
325
<!-- Artifact and Log Retention -->
219
326
<div class="relative">
220
327
<div class="absolute -inset-[1px] bg-gradient-to-r from-cyan-500/20 via-transparent to-blue-500/20 rounded-xl opacity-50"></div>
308
415
</div>
309
416
</div>
310
417
</BaseLayout>
418
+
419
+<script
420
+ define:vars={{
421
+ repoOwner: repo.owner,
422
+ repoName: repo.name,
423
+ }}
424
+>
425
+ const templateList = document.getElementById("template-list");
426
+ const templateMsg = document.getElementById("template-action-msg");
427
+ const templateRefreshBtn = document.getElementById("template-refresh-btn");
428
+ const templateCategoryFilter = document.getElementById("template-category-filter");
429
+ const templateLanguageFilter = document.getElementById("template-language-filter");
430
+ const templateWorkflowName = document.getElementById("template-workflow-name");
431
+ const appliedWorkflowsList = document.getElementById("applied-workflows-list");
432
+
433
+ function templateSummary(template) {
434
+ const category = (template.category || "uncategorized").toUpperCase();
435
+ return `${category}${template.language ? ` • ${template.language}` : ""}`;
436
+ }
437
+
438
+ function renderTemplates(templates) {
439
+ if (!templateList) return;
440
+ if (!templates.length) {
441
+ templateList.innerHTML = '<div class="text-sm text-gray-500">No templates found.</div>';
442
+ return;
443
+ }
444
+
445
+ templateList.innerHTML = templates.map((template) => `
446
+ <div class="rounded-lg border border-white/10 bg-white/[0.02] p-3 flex items-start justify-between gap-3" data-template-id="${template.id}">
447
+ <div>
448
+ <div class="text-sm font-medium text-white">${template.name}</div>
449
+ <div class="text-xs text-gray-500 mt-1">${templateSummary(template)}</div>
450
+ ${template.description ? `<p class="text-xs text-gray-400 mt-1">${template.description}</p>` : ""}
451
+ </div>
452
+ <button
453
+ class="apply-template-btn px-3 py-1.5 rounded-lg border border-emerald-500/30 text-emerald-300 text-xs hover:bg-emerald-500/10"
454
+ data-template-id="${template.id}"
455
+ >
456
+ Apply
457
+ </button>
458
+ </div>
459
+ `).join("");
460
+ }
461
+
462
+ function renderAppliedWorkflow(workflow) {
463
+ if (!appliedWorkflowsList) return;
464
+ const empty = appliedWorkflowsList.querySelector(".text-gray-500");
465
+ if (empty) {
466
+ appliedWorkflowsList.innerHTML = "";
467
+ }
468
+ const row = document.createElement("div");
469
+ row.className = "flex items-center justify-between text-xs rounded border border-white/10 bg-white/[0.02] px-2 py-1.5";
470
+ row.innerHTML = `
471
+ <span class="text-gray-300">${workflow.name}</span>
472
+ <span class="text-gray-500 font-mono">${workflow.path}</span>
473
+ `;
474
+ appliedWorkflowsList.prepend(row);
475
+ }
476
+
477
+ async function loadTemplates() {
478
+ if (!templateMsg) return;
479
+ templateMsg.textContent = "Loading templates...";
480
+
481
+ const category = templateCategoryFilter?.value?.trim() || "";
482
+ const language = templateLanguageFilter?.value?.trim() || "";
483
+ const query = new URLSearchParams();
484
+ if (category) query.set("category", category);
485
+ if (language) query.set("language", language);
486
+
487
+ try {
488
+ const suffix = query.toString() ? `?${query.toString()}` : "";
489
+ const res = await fetch(`/api/repos/${repoOwner}/${repoName}/workflow/templates${suffix}`);
490
+ const payload = await res.json();
491
+ if (!res.ok) {
492
+ throw new Error(payload?.error?.message || payload?.message || "Failed to load templates");
493
+ }
494
+ renderTemplates(payload?.data?.templates || []);
495
+ templateMsg.textContent = `Loaded ${payload?.data?.templates?.length || 0} template(s).`;
496
+ } catch (error) {
497
+ templateMsg.textContent = error.message || "Failed to load templates";
498
+ }
499
+ }
500
+
501
+ async function applyTemplate(templateId) {
502
+ if (!templateMsg) return;
503
+ const workflowName = templateWorkflowName?.value?.trim() || undefined;
504
+ templateMsg.textContent = "Applying template...";
505
+
506
+ try {
507
+ const res = await fetch(`/api/repos/${repoOwner}/${repoName}/workflow/templates`, {
508
+ method: "POST",
509
+ headers: { "Content-Type": "application/json" },
510
+ body: JSON.stringify({ templateId, workflowName }),
511
+ });
512
+ const payload = await res.json();
513
+ if (!res.ok) {
514
+ throw new Error(payload?.error?.message || payload?.message || "Failed to apply template");
515
+ }
516
+
517
+ const workflow = payload?.data?.workflow;
518
+ if (workflow) {
519
+ renderAppliedWorkflow(workflow);
520
+ }
521
+ templateMsg.textContent = `Applied template to ${workflow?.path || "workflow file"}.`;
522
+ } catch (error) {
523
+ templateMsg.textContent = error.message || "Failed to apply template";
524
+ }
525
+ }
526
+
527
+ templateRefreshBtn?.addEventListener("click", (event) => {
528
+ event.preventDefault();
529
+ loadTemplates();
530
+ });
531
+ templateCategoryFilter?.addEventListener("change", loadTemplates);
532
+ templateLanguageFilter?.addEventListener("change", loadTemplates);
533
+
534
+ templateList?.addEventListener("click", (event) => {
535
+ const button = event.target.closest(".apply-template-btn");
536
+ if (!button) return;
537
+ const templateId = button.getAttribute("data-template-id");
538
+ if (!templateId) return;
539
+ applyTemplate(templateId);
540
+ });
541
+</script>
311
542