Complete workflow template adoption UX in actions settings
swadhinbiswas committed 8 months ago
e73d9d0
1 parent df63ee1
commit e73d9d0
2 files changed
+234 -3
Showing 2 changed files+234-3
▼doc/feature_audit.md
+3-3
| @@ -11,7 +11,7 @@ | ||
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,7 +19,7 @@ | ||
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,7 +97,7 @@ | ||
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,6 +4,7 @@ import RepoHeader from "@/components/repo/RepoHeader.astro"; | ||
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,6 +49,11 @@ const repo = { | ||
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,6 +221,107 @@ const repo = { | ||
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,3 +415,127 @@ const repo = { | ||
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 | |