diff --git a/.github/extensions/process-workflow-fleet/README.md b/.github/extensions/process-workflow-fleet/README.md new file mode 100644 index 00000000..27ac75e8 --- /dev/null +++ b/.github/extensions/process-workflow-fleet/README.md @@ -0,0 +1,103 @@ +# Process workflow fleet canvas + +This project-scoped Copilot canvas lets maintainers refresh the authenticated +Process-PSModule caller inventory, inspect each repository against the v8 caller +contract, and send a confirmed migration request to the active agent. The +loopback server never edits another repository or opens a pull request. + +## Structure + +| File | Responsibility | +| --- | --- | +| `extension.mjs` | Declares the canvas, strict open/action schemas, lifecycle, and SDK wiring. | +| `fleet-service.mjs` | Resolves the repository, stores workspace-scoped state, runs inventory refreshes, serves loopback HTTP, and calls `session.send()`. | +| `fleet-model.mjs` | Normalizes inventory records, encodes the v8 target, computes deltas, and builds structured migration prompts. | +| `renderer.mjs` | Returns the dependency-free dashboard HTML, CSS, and browser interactions. | +| `fleet-model.test.mjs` | Tests normalization, comparison, fail-closed behavior, and prompt generation with built-in Node modules. | + +`extension.mjs` must remain an ES module with that exact name. Copilot resolves +`@github/copilot-sdk` for the extension process, so this directory does not need +a `package.json` or `node_modules`. + +## Load and open + +Copilot discovers immediate children of `.github/extensions/`. After changing +the extension, reload project extensions and confirm +`project:process-workflow-fleet` is running: + +```text +extensions_reload({}) +extensions_manage({ operation: "list" }) +extensions_manage({ operation: "inspect", name: "process-workflow-fleet" }) +``` + +Inspect the declaration, then open a stable panel instance: + +```text +list_canvas_capabilities({ canvasId: "process-workflow-fleet" }) +open_canvas({ + canvasId: "process-workflow-fleet", + instanceId: "process-workflow-fleet-main", + input: { organization: "PSModule" } +}) +``` + +Reopening the same `instanceId` focuses the panel. Durable inventory and +selection state is keyed by the repository workspace under the Copilot session +`files/process-workflow-fleet/` artifact directory, not by the panel ID. +Refreshed evidence is disposable user-specific state and is never committed +automatically. + +## Use and test + +The dashboard automatically refreshes missing inventory and evidence older than +15 minutes. Its refresh button provides an explicit retry. Both paths run +`.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1` in authenticated +GitHub mode with target `v8`. A failed refresh clears prior success state and +shows the command context and sanitized diagnostic. + +Agent-facing actions are: + +- `refresh_inventory` +- `get_summary` +- `get_repository` +- `set_selection` +- `request_migration` + +`request_migration` returns a preview by default. It calls `session.send()` only +when `dryRun` is `false`, `confirmed` is `true`, the selection is nonempty, and +the current inventory is complete. + +Run deterministic helper tests with: + +```powershell +node --test .github/extensions/process-workflow-fleet/fleet-model.test.mjs +``` + +## Debug + +Start with `extensions_manage({ operation: "inspect", name: +"process-workflow-fleet" })`. The reported log captures provider startup and +runtime failures; do not add `console.log`, because standard output carries the +JSON-RPC protocol. Use `session.log()` for deliberate diagnostics. + +For lifecycle and schema checks, reload before testing and verify: + +1. discovery and capabilities; +2. valid and invalid open input; +3. each declared action and invalid action input; +4. reserved `canvas.*` action rejection; +5. loopback rendering and panel cleanup. + +## Extend safely + +Keep the SDK declaration in `extension.mjs`, domain logic in +`fleet-model.mjs`, privileged boundaries in `fleet-service.mjs`, and rendering +in `renderer.mjs`. Add a strict JSON schema for every new agent action and a +deterministic test for every comparison or prompt change. + +Bind HTTP only to `127.0.0.1`, require the per-panel request token for writes, +and close the server in `onClose`. HTTP handlers may prepare evidence or +requests, but repository mutations must stay in normal Copilot sessions where +the user can see tool calls and permission prompts. Never include credentials +in state, HTML, diagnostics, or migration requests. diff --git a/.github/extensions/process-workflow-fleet/extension.mjs b/.github/extensions/process-workflow-fleet/extension.mjs new file mode 100644 index 00000000..42ad8665 --- /dev/null +++ b/.github/extensions/process-workflow-fleet/extension.mjs @@ -0,0 +1,178 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + CanvasError, + createCanvas, + joinSession, +} from "@github/copilot-sdk/extension"; + +import { + createFleetService, + resolveRepositoryRoot, +} from "./fleet-service.mjs"; + +const moduleDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolveRepositoryRoot({ + currentWorkingDirectory: process.cwd(), + moduleDirectory, +}); + +let session; +const fleet = createFleetService({ + getSession: () => session, + repositoryRoot, +}); + +const canvas = createCanvas({ + id: "process-workflow-fleet", + displayName: "Process workflow fleet", + description: + "Inspect Process-PSModule caller workflows, compare them with the v8 contract, and request repository-scoped migrations.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + organization: { + type: "string", + minLength: 1, + maxLength: 100, + pattern: "^[A-Za-z0-9][A-Za-z0-9-]*$", + }, + }, + }, + actions: [ + { + name: "refresh_inventory", + description: + "Refresh the authenticated GitHub inventory and replace prior canvas data fail-closed.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + organization: { + type: "string", + minLength: 1, + maxLength: 100, + pattern: "^[A-Za-z0-9][A-Za-z0-9-]*$", + }, + repositories: { + type: "array", + uniqueItems: true, + maxItems: 500, + items: { + type: "string", + minLength: 1, + maxLength: 200, + pattern: + "^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?$", + }, + }, + includeArchived: { + type: "boolean", + }, + }, + }, + handler: async (ctx) => fleet.refreshInventory(ctx.input ?? {}), + }, + { + name: "get_summary", + description: + "Return refresh health and v8 compliance counts for the current workspace inventory.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + handler: async () => fleet.getSummary(), + }, + { + name: "get_repository", + description: + "Return normalized workflow evidence and the migration delta for one repository.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["repository"], + properties: { + repository: { + type: "string", + minLength: 1, + maxLength: 200, + }, + }, + }, + handler: async (ctx) => fleet.getRepository(ctx.input.repository), + }, + { + name: "set_selection", + description: + "Persist the selected repository identities for this session workspace.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["repositories"], + properties: { + repositories: { + type: "array", + uniqueItems: true, + maxItems: 500, + items: { + type: "string", + minLength: 1, + maxLength: 200, + }, + }, + }, + }, + handler: async (ctx) => fleet.setSelection(ctx.input.repositories), + }, + { + name: "request_migration", + description: + "Preview or confirm an agent-orchestrated migration request for selected repositories; never mutates repositories directly.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + repositories: { + type: "array", + uniqueItems: true, + maxItems: 500, + items: { + type: "string", + minLength: 1, + maxLength: 200, + }, + }, + dryRun: { + type: "boolean", + }, + confirmed: { + type: "boolean", + }, + }, + }, + handler: async (ctx) => fleet.requestMigration(ctx.input ?? {}), + }, + ], + open: async (ctx) => { + const entry = await fleet.openPanel(ctx.instanceId, { + organization: ctx.input?.organization, + }); + return { + title: "Process workflow fleet", + status: "Inventory updates automatically", + url: entry.url, + }; + }, + onClose: async (ctx) => { + await fleet.closePanel(ctx.instanceId); + }, +}); + +session = await joinSession({ + canvases: [canvas], +}); + +fleet.setCanvasErrorFactory((code, message) => new CanvasError(code, message)); diff --git a/.github/extensions/process-workflow-fleet/fleet-model.mjs b/.github/extensions/process-workflow-fleet/fleet-model.mjs new file mode 100644 index 00000000..7df4c139 --- /dev/null +++ b/.github/extensions/process-workflow-fleet/fleet-model.mjs @@ -0,0 +1,644 @@ +const EXPECTED_WORKFLOW_PATH = ".github/workflows/Process-PSModule.yml"; +const EXPECTED_WORKFLOW_NAME = "Process-PSModule"; +const EXPECTED_JOB_NAME = "Process-PSModule"; +const EXPECTED_USES = + "PSModule/Process-PSModule/.github/workflows/workflow.yml@v8"; +const EXPECTED_CONCURRENCY_GROUP = + "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}"; +const EXPECTED_CANCEL_IN_PROGRESS = + "${{ github.event_name == 'pull_request' }}"; + +const REQUIRED_EVENTS = [ + "pull_request", + "push", + "schedule", + "workflow_dispatch", +]; +const REQUIRED_PULL_REQUEST_TYPES = [ + "closed", + "labeled", + "opened", + "reopened", + "synchronize", + "unlabeled", +]; +const REQUIRED_JOB_PERMISSIONS = { + contents: "read", + pages: "write", + "id-token": "write", +}; +const REQUIRED_SECRETS = { + PSGALLERY_API_KEY: "${{ secrets.PSGALLERY_API_KEY }}", + GitHubAppClientId: "${{ secrets.SHELLY_CLIENT_ID }}", + GitHubAppPrivateKey: "${{ secrets.SHELLY_PRIVATE_KEY }}", +}; +const OPTIONAL_INPUTS = new Set([ + "ImportantFilePatterns", + "Prerelease", + "SettingsPath", + "Verbose", + "Version", + "WorkingDirectory", +]); +const OPTIONAL_SECRETS = new Set(["TestData"]); + +const REQUIRED_RECORD_FIELDS = [ + "WorkflowName", + "Events", + "Schedules", + "PushBranches", + "PushBranchesIgnore", + "PushPaths", + "PushPathsIgnore", + "PullRequestBranches", + "PullRequestTypes", + "PullRequestPaths", + "PullRequestPathsIgnore", + "ConcurrencyGroup", + "CancelInProgress", + "Permissions", + "ProcessJobs", + "AdditionalJobs", +]; +const REQUIRED_JOB_FIELDS = [ + "Name", + "Uses", + "Reference", + "Inputs", + "SecretMode", + "SecretMappings", + "Permissions", + "Condition", +]; + +export const TARGET_CONTRACT = Object.freeze({ + identity: { + workflowPath: EXPECTED_WORKFLOW_PATH, + workflowName: EXPECTED_WORKFLOW_NAME, + jobName: EXPECTED_JOB_NAME, + }, + triggers: { + events: REQUIRED_EVENTS, + scheduleRequired: true, + pushBranches: ["main"], + pullRequestBranches: ["main"], + pullRequestTypes: REQUIRED_PULL_REQUEST_TYPES, + pathFiltersAllowed: false, + }, + concurrency: { + group: EXPECTED_CONCURRENCY_GROUP, + cancelInProgress: EXPECTED_CANCEL_IN_PROGRESS, + }, + permissions: { + workflow: {}, + job: REQUIRED_JOB_PERMISSIONS, + }, + caller: { + condition: null, + uses: EXPECTED_USES, + secrets: REQUIRED_SECRETS, + optionalSecrets: [...OPTIONAL_SECRETS], + optionalInputs: [...OPTIONAL_INPUTS].sort(), + debugTrueAllowed: false, + }, + additionalJobs: { + allowed: true, + boundaryReviewRequired: true, + }, +}); + +function hasOwn(value, property) { + return ( + value !== null && + typeof value === "object" && + Object.prototype.hasOwnProperty.call(value, property) + ); +} + +function toArray(value) { + if (value === null || value === undefined) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +function toObject(value) { + if ( + value === null || + value === undefined || + Array.isArray(value) || + typeof value !== "object" + ) { + return {}; + } + return { ...value }; +} + +function normalizedScalar(value) { + if (value === null || value === undefined) { + return null; + } + return typeof value === "string" ? value.trim() : value; +} + +function normalizeJob(job, index) { + const source = toObject(job); + return { + sourceIndex: index, + sourceFields: Object.keys(source), + name: normalizedScalar(source.Name), + uses: normalizedScalar(source.Uses), + reference: normalizedScalar(source.Reference), + inputs: toObject(source.Inputs), + secretMode: normalizedScalar(source.SecretMode), + secretMappings: toObject(source.SecretMappings), + permissions: source.Permissions, + environment: source.Environment ?? null, + condition: normalizedScalar(source.Condition), + }; +} + +function normalizeRecord(record, index) { + const source = toObject(record); + const status = source.Status === "ParseError" ? "parse-error" : "parsed"; + return { + sourceIndex: index, + sourceFields: Object.keys(source), + repository: normalizedScalar(source.Repository) ?? `unknown-${index + 1}`, + defaultBranch: normalizedScalar(source.DefaultBranch), + archived: source.Archived === true, + repositoryUrl: normalizedScalar(source.RepositoryUrl), + workflowPath: normalizedScalar(source.WorkflowPath), + workflowUrl: normalizedScalar(source.WorkflowUrl), + status, + error: normalizedScalar(source.Error), + workflowName: normalizedScalar(source.WorkflowName), + runName: normalizedScalar(source.RunName), + events: toArray(source.Events).map(String), + schedules: toArray(source.Schedules).map(String), + pushBranches: toArray(source.PushBranches).map(String), + pushBranchesIgnore: toArray(source.PushBranchesIgnore).map(String), + pushPaths: toArray(source.PushPaths).map(String), + pushPathsIgnore: toArray(source.PushPathsIgnore).map(String), + pullRequestBranches: toArray(source.PullRequestBranches).map(String), + pullRequestTypes: toArray(source.PullRequestTypes).map(String), + pullRequestPaths: toArray(source.PullRequestPaths).map(String), + pullRequestPathsIgnore: toArray(source.PullRequestPathsIgnore).map(String), + concurrencyGroup: normalizedScalar(source.ConcurrencyGroup), + cancelInProgress: normalizedScalar(source.CancelInProgress), + permissions: source.Permissions, + processJobs: toArray(source.ProcessJobs).map(normalizeJob), + additionalJobs: toArray(source.AdditionalJobs).map(String), + versionComments: toArray(source.VersionComments).map((entry) => ({ + reference: normalizedScalar(entry?.Reference), + version: normalizedScalar(entry?.Version), + })), + }; +} + +export function normalizeInventory(value) { + const records = Array.isArray(value) ? value : value ? [value] : []; + return records.map(normalizeRecord); +} + +function stableJson(value) { + if (Array.isArray(value)) { + return JSON.stringify([...value].map(String).sort()); + } + if (value !== null && typeof value === "object") { + return JSON.stringify( + Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, entry]), + ), + ); + } + return JSON.stringify(value ?? null); +} + +function equalArray(actual, expected) { + return stableJson(actual) === stableJson(expected); +} + +function equalObject(actual, expected) { + return stableJson(toObject(actual)) === stableJson(expected); +} + +function missingInventoryFields(record) { + const missing = REQUIRED_RECORD_FIELDS.filter( + (field) => !record.sourceFields.includes(field), + ); + for (const job of record.processJobs) { + for (const field of REQUIRED_JOB_FIELDS) { + if (!job.sourceFields.includes(field)) { + missing.push(`ProcessJobs[${job.sourceIndex}].${field}`); + } + } + } + return missing; +} + +function addDelta(deltas, field, current, target, matches, instruction) { + if (!matches) { + deltas.push({ + field, + current, + target, + instruction, + }); + } +} + +function isEmptyCondition(value) { + return value === null || value === ""; +} + +function hasDebugTrue(inputs) { + return Object.entries(inputs).some( + ([key, value]) => + key.toLowerCase() === "debug" && + String(value).trim().toLowerCase() === "true", + ); +} + +function compareSecrets(job, deltas) { + addDelta( + deltas, + "job.secretMode", + job.secretMode, + "explicit", + job.secretMode === "explicit", + "Replace inherited or missing secrets with explicit mappings.", + ); + + for (const [name, target] of Object.entries(REQUIRED_SECRETS)) { + addDelta( + deltas, + `job.secrets.${name}`, + job.secretMappings[name] ?? null, + target, + job.secretMappings[name] === target, + `Map ${name} to the agreed repository or organization secret.`, + ); + } + + const unsupported = Object.keys(job.secretMappings).filter( + (name) => !hasOwn(REQUIRED_SECRETS, name) && !OPTIONAL_SECRETS.has(name), + ); + addDelta( + deltas, + "job.secrets.unsupported", + unsupported, + [], + unsupported.length === 0, + "Remove unsupported mappings after checking whether they belong in TestData.", + ); +} + +function compareInputs(job, deltas, preservation) { + const inputNames = Object.keys(job.inputs); + if (hasOwn(job.inputs, "TestData")) { + deltas.push({ + field: "job.inputs.TestData", + current: job.inputs.TestData, + target: "secret mapping named TestData", + instruction: + "Move test data to the optional TestData secret mapping without exposing values.", + }); + } + + addDelta( + deltas, + "job.inputs.Debug", + job.inputs.Debug ?? null, + "omitted or false", + !hasDebugTrue(job.inputs), + "Remove Debug: true so the reusable workflow default remains false.", + ); + + const unsupported = inputNames.filter( + (name) => + name !== "Debug" && + name !== "TestData" && + !OPTIONAL_INPUTS.has(name), + ); + addDelta( + deltas, + "job.inputs.unsupported", + unsupported, + [], + unsupported.length === 0, + "Inspect unsupported inputs against the current reusable-workflow interface.", + ); + + for (const name of inputNames.filter((entry) => OPTIONAL_INPUTS.has(entry))) { + preservation.push({ + field: `job.inputs.${name}`, + value: job.inputs[name], + instruction: `Preserve ${name} when it remains valid for this repository.`, + }); + } + if (hasOwn(job.secretMappings, "TestData")) { + preservation.push({ + field: "job.secrets.TestData", + value: job.secretMappings.TestData, + instruction: + "Preserve the TestData JSON mapping after verifying referenced secrets and variables.", + }); + } +} + +export function compareRepository(record) { + if (record.status === "parse-error") { + return { + repository: record.repository, + workflowPath: record.workflowPath, + status: "parse-error", + compliant: false, + complete: false, + requestReady: false, + missingFields: [], + deltas: [ + { + field: "inventory.parse", + current: record.error, + target: "a parsed workflow", + instruction: + "Fix or inspect the YAML parse error before planning migration.", + }, + ], + preservation: [], + reviewWarnings: [], + }; + } + + const missingFields = missingInventoryFields(record); + const deltas = []; + const preservation = []; + const reviewWarnings = []; + + addDelta( + deltas, + "identity.workflowPath", + record.workflowPath, + EXPECTED_WORKFLOW_PATH, + record.workflowPath === EXPECTED_WORKFLOW_PATH, + "Use the standard caller workflow path.", + ); + addDelta( + deltas, + "identity.workflowName", + record.workflowName, + EXPECTED_WORKFLOW_NAME, + record.workflowName === EXPECTED_WORKFLOW_NAME, + "Use the standard workflow name.", + ); + addDelta( + deltas, + "triggers.events", + record.events, + REQUIRED_EVENTS, + equalArray(record.events, REQUIRED_EVENTS), + "Declare workflow_dispatch, schedule, push, and pull_request.", + ); + addDelta( + deltas, + "triggers.schedule", + record.schedules, + "at least one schedule", + record.schedules.length > 0, + "Keep at least one repository-appropriate scheduled health run.", + ); + addDelta( + deltas, + "triggers.push.branches", + record.pushBranches, + ["main"], + equalArray(record.pushBranches, ["main"]), + "Target the main branch for stable default-branch publication.", + ); + addDelta( + deltas, + "triggers.push.filters", + { + branchesIgnore: record.pushBranchesIgnore, + paths: record.pushPaths, + pathsIgnore: record.pushPathsIgnore, + }, + {}, + record.pushBranchesIgnore.length === 0 && + record.pushPaths.length === 0 && + record.pushPathsIgnore.length === 0, + "Remove push filters that bypass reusable-workflow change evaluation.", + ); + addDelta( + deltas, + "triggers.pullRequest.branches", + record.pullRequestBranches, + ["main"], + equalArray(record.pullRequestBranches, ["main"]), + "Target pull requests into main.", + ); + addDelta( + deltas, + "triggers.pullRequest.types", + record.pullRequestTypes, + REQUIRED_PULL_REQUEST_TYPES, + equalArray(record.pullRequestTypes, REQUIRED_PULL_REQUEST_TYPES), + "Declare closed, opened, reopened, synchronize, labeled, and unlabeled.", + ); + addDelta( + deltas, + "triggers.pullRequest.filters", + { + paths: record.pullRequestPaths, + pathsIgnore: record.pullRequestPathsIgnore, + }, + {}, + record.pullRequestPaths.length === 0 && + record.pullRequestPathsIgnore.length === 0, + "Remove pull-request path filters that bypass Process-PSModule planning.", + ); + addDelta( + deltas, + "concurrency.group", + record.concurrencyGroup, + EXPECTED_CONCURRENCY_GROUP, + record.concurrencyGroup === EXPECTED_CONCURRENCY_GROUP, + "Key concurrency by workflow and pull-request number or full ref.", + ); + addDelta( + deltas, + "concurrency.cancelInProgress", + record.cancelInProgress, + EXPECTED_CANCEL_IN_PROGRESS, + record.cancelInProgress === EXPECTED_CANCEL_IN_PROGRESS, + "Cancel superseded pull-request runs only.", + ); + addDelta( + deltas, + "permissions.workflow", + record.permissions, + {}, + equalObject(record.permissions, {}), + "Set top-level permissions to an empty mapping.", + ); + addDelta( + deltas, + "jobs.count", + record.processJobs.length, + 1, + record.processJobs.length === 1, + "Keep exactly one Process-PSModule delegation job.", + ); + + for (const job of record.processJobs) { + addDelta( + deltas, + "job.name", + job.name, + EXPECTED_JOB_NAME, + job.name === EXPECTED_JOB_NAME, + "Use the standard Process-PSModule job identity.", + ); + addDelta( + deltas, + "job.permissions", + job.permissions, + REQUIRED_JOB_PERMISSIONS, + equalObject(job.permissions, REQUIRED_JOB_PERMISSIONS), + "Grant only contents:read, pages:write, and id-token:write.", + ); + addDelta( + deltas, + "job.condition", + job.condition, + null, + isEmptyCondition(job.condition), + "Remove the caller condition so the reusable workflow owns authorization.", + ); + addDelta( + deltas, + "job.uses", + job.uses, + EXPECTED_USES, + job.uses === EXPECTED_USES, + "Pin the caller to the controlled v8 major reference.", + ); + compareSecrets(job, deltas); + compareInputs(job, deltas, preservation); + } + + if (record.additionalJobs.length > 0) { + reviewWarnings.push({ + field: "additionalJobs", + value: record.additionalJobs, + instruction: + "Read each repository-owned job before migration and verify it cannot bypass the Process-PSModule trigger, concurrency, permission, or authorization boundary.", + }); + } + + const complete = missingFields.length === 0; + const compliant = complete && deltas.length === 0; + return { + repository: record.repository, + workflowPath: record.workflowPath, + status: complete + ? compliant + ? "compliant" + : "migration-needed" + : "incomplete", + compliant, + complete, + requestReady: complete, + missingFields, + deltas, + preservation, + reviewWarnings, + }; +} + +export function analyzeInventory(records) { + return records.map((record) => ({ + ...record, + analysis: compareRepository(record), + })); +} + +export function getSummary(state) { + const records = state.records ?? []; + const analyses = records.map((record) => record.analysis); + return { + inventoryStatus: state.inventoryStatus, + generatedAt: state.generatedAt ?? null, + organization: state.organization ?? null, + total: records.length, + parsed: records.filter((record) => record.status === "parsed").length, + parseErrors: analyses.filter((item) => item.status === "parse-error").length, + incomplete: analyses.filter((item) => item.status === "incomplete").length, + compliant: analyses.filter((item) => item.compliant).length, + migrationNeeded: analyses.filter( + (item) => item.status === "migration-needed", + ).length, + requestReady: analyses.filter( + (item) => item.requestReady && !item.compliant, + ).length, + selected: state.selection?.length ?? 0, + error: state.error ?? null, + }; +} + +export function buildMigrationRequest(records) { + if (!Array.isArray(records) || records.length === 0) { + throw new Error("Select at least one repository before requesting migration."); + } + + const blocked = records.filter((record) => !record.analysis.requestReady); + if (blocked.length > 0) { + throw new Error( + `Migration request blocked because inventory is incomplete for: ${blocked + .map((record) => record.repository) + .join(", ")}.`, + ); + } + + return { + schemaVersion: 1, + target: TARGET_CONTRACT, + repositories: records.map((record) => ({ + repository: record.repository, + defaultBranch: record.defaultBranch, + workflowPath: record.workflowPath, + workflowUrl: record.workflowUrl, + currentReferences: record.processJobs.map((job) => job.reference), + deltas: record.analysis.deltas, + preserve: record.analysis.preservation, + reviewWarnings: record.analysis.reviewWarnings, + })), + }; +} + +export function buildMigrationPrompt(request) { + const repositoryNames = request.repositories + .map((record) => record.repository) + .join(", "); + return `Orchestrate the Process-PSModule v8 caller migration for these repositories: ${repositoryNames}. + +Invoke the orchestrate skill and follow the MSX fleet orchestration and contribution workflows. Create exactly one coordinated child project session per selected repository. In each repository: + +1. Refresh and read the target repository's default-branch caller workflow before editing; do not trust this snapshot as the source of truth. +2. Create one repository-scoped delivery branch and open one draft pull request early. Adopt a matching open pull request instead of creating a duplicate. +3. Preserve valid repository-specific schedule timing, optional TestData JSON, and valid ImportantFilePatterns, SettingsPath, WorkingDirectory, Version, Prerelease, or Verbose behavior after verifying each value against the current repository and reusable-workflow interface. +4. Apply the agreed caller identity, trigger, concurrency, permission, unconditional-call, explicit-secret, and @v8 contract from the structured request below. Do not set Debug: true. +5. Read every additional repository-owned job and verify it cannot bypass the Process-PSModule trigger, concurrency, permission, or authorization boundary. Stop and report a blocker instead of guessing when inventory or workflow parsing is incomplete. +6. Run the repository-native validation and the applicable review loop. Report every child session, branch, validation result, blocker, and draft pull request URL to this parent session. + +Do not batch repositories into one branch or pull request. Do not mutate repositories from outside their child sessions. + +Structured migration request: + +\`\`\`json +${JSON.stringify(request, null, 2)} +\`\`\``; +} diff --git a/.github/extensions/process-workflow-fleet/fleet-model.test.mjs b/.github/extensions/process-workflow-fleet/fleet-model.test.mjs new file mode 100644 index 00000000..e8d51f96 --- /dev/null +++ b/.github/extensions/process-workflow-fleet/fleet-model.test.mjs @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + analyzeInventory, + buildMigrationPrompt, + buildMigrationRequest, + compareRepository, + normalizeInventory, +} from "./fleet-model.mjs"; + +function createRecord(overrides = {}) { + return { + Repository: "PSModule/Example", + DefaultBranch: "main", + Archived: false, + RepositoryUrl: "https://github.com/PSModule/Example", + WorkflowPath: ".github/workflows/Process-PSModule.yml", + WorkflowUrl: + "https://github.com/PSModule/Example/blob/main/.github/workflows/Process-PSModule.yml", + Status: "Parsed", + Error: null, + WorkflowName: "Process-PSModule", + RunName: null, + Events: ["pull_request", "push", "schedule", "workflow_dispatch"], + Schedules: ["0 0 * * *"], + PushBranches: ["main"], + PushBranchesIgnore: [], + PushPaths: [], + PushPathsIgnore: [], + PullRequestBranches: ["main"], + PullRequestTypes: [ + "closed", + "opened", + "reopened", + "synchronize", + "labeled", + "unlabeled", + ], + PullRequestPaths: [], + PullRequestPathsIgnore: [], + ConcurrencyGroup: + "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}", + CancelInProgress: "${{ github.event_name == 'pull_request' }}", + Permissions: {}, + ProcessJobs: [ + { + Name: "Process-PSModule", + Uses: "PSModule/Process-PSModule/.github/workflows/workflow.yml@v8", + Reference: "v8", + Inputs: {}, + SecretMode: "explicit", + SecretMappings: { + PSGALLERY_API_KEY: "${{ secrets.PSGALLERY_API_KEY }}", + GitHubAppClientId: "${{ secrets.SHELLY_CLIENT_ID }}", + GitHubAppPrivateKey: "${{ secrets.SHELLY_PRIVATE_KEY }}", + }, + Permissions: { + contents: "read", + pages: "write", + "id-token": "write", + }, + Environment: null, + Condition: null, + }, + ], + AdditionalJobs: [], + VersionComments: [{ Reference: "v8", Version: "v8.0.1" }], + ...overrides, + }; +} + +describe("inventory normalization and comparison", () => { + it("recognizes the agreed v8 caller contract", () => { + const [record] = normalizeInventory([createRecord()]); + const analysis = compareRepository(record); + + assert.equal(record.repository, "PSModule/Example"); + assert.equal(analysis.status, "compliant"); + assert.equal(analysis.compliant, true); + assert.deepEqual(analysis.deltas, []); + }); + + it("reports migration deltas and preserves supported optional behavior", () => { + const source = createRecord({ + Events: ["pull_request", "schedule", "workflow_dispatch"], + ProcessJobs: [ + { + ...createRecord().ProcessJobs[0], + Uses: "PSModule/Process-PSModule/.github/workflows/workflow.yml@v6", + Reference: "v6", + Inputs: { + Debug: true, + ImportantFilePatterns: '["src/**","README.md"]', + }, + SecretMappings: { + PSGALLERY_API_KEY: "${{ secrets.PSGALLERY_API_KEY }}", + GitHubAppClientId: "${{ secrets.SHELLY_CLIENT_ID }}", + GitHubAppPrivateKey: "${{ secrets.SHELLY_PRIVATE_KEY }}", + TestData: + '{"secrets":{"TOKEN":"${{ secrets.TEST_TOKEN }}"}}', + }, + }, + ], + }); + const [record] = analyzeInventory(normalizeInventory(source)); + + assert.equal(record.analysis.status, "migration-needed"); + assert.ok( + record.analysis.deltas.some((delta) => delta.field === "job.uses"), + ); + assert.ok( + record.analysis.deltas.some( + (delta) => delta.field === "job.inputs.Debug", + ), + ); + assert.deepEqual( + record.analysis.preservation.map((item) => item.field).sort(), + ["job.inputs.ImportantFilePatterns", "job.secrets.TestData"], + ); + }); + + it("fails closed when the inventory contract is incomplete", () => { + const source = createRecord(); + delete source.PullRequestPaths; + const [record] = analyzeInventory(normalizeInventory(source)); + + assert.equal(record.analysis.status, "incomplete"); + assert.equal(record.analysis.requestReady, false); + assert.deepEqual(record.analysis.missingFields, ["PullRequestPaths"]); + assert.throws( + () => buildMigrationRequest([record]), + /inventory is incomplete/, + ); + }); + + it("fails closed on parse errors", () => { + const [record] = analyzeInventory( + normalizeInventory({ + Repository: "PSModule/Broken", + WorkflowPath: ".github/workflows/Process-PSModule.yml", + Status: "ParseError", + Error: "Unexpected token", + }), + ); + + assert.equal(record.analysis.status, "parse-error"); + assert.equal(record.analysis.requestReady, false); + }); +}); + +describe("migration request generation", () => { + it("creates a complete orchestration prompt without mutating repositories", () => { + const [record] = analyzeInventory(normalizeInventory(createRecord())); + const request = buildMigrationRequest([record]); + const prompt = buildMigrationPrompt(request); + + assert.match(prompt, /exactly one coordinated child project session/); + assert.match(prompt, /open one draft pull request early/); + assert.match(prompt, /refresh and read/i); + assert.match(prompt, /ImportantFilePatterns/); + assert.match(prompt, /workflow\.yml@v8/); + assert.match(prompt, /repository-native validation/); + assert.match(prompt, /PSModule\/Example/); + }); + + it("rejects an empty repository selection", () => { + assert.throws( + () => buildMigrationRequest([]), + /Select at least one repository/, + ); + }); +}); diff --git a/.github/extensions/process-workflow-fleet/fleet-service.mjs b/.github/extensions/process-workflow-fleet/fleet-service.mjs new file mode 100644 index 00000000..325795af --- /dev/null +++ b/.github/extensions/process-workflow-fleet/fleet-service.mjs @@ -0,0 +1,695 @@ +import { execFile, execFileSync } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { + mkdir, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { + dirname, + isAbsolute, + join, + resolve, +} from "node:path"; +import { promisify } from "node:util"; + +import { + analyzeInventory, + buildMigrationPrompt, + buildMigrationRequest, + getSummary, + normalizeInventory, +} from "./fleet-model.mjs"; +import { renderDashboard } from "./renderer.mjs"; + +const execFileAsync = promisify(execFile); +const STATE_VERSION = 1; +const MAX_REQUEST_BYTES = 1024 * 1024; +const servers = new Map(); +const refreshes = new Map(); + +let createCanvasError = (code, message) => { + const error = new Error(message); + error.code = code; + return error; +}; + +function pathLooksLikeRepository(candidate) { + try { + const root = execFileSync( + "git", + ["-C", candidate, "rev-parse", "--show-toplevel"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ).trim(); + return root || null; + } catch (error) { + if ( + error && + typeof error === "object" && + hasOwn(error, "status") && + error.status !== 0 + ) { + return null; + } + throw error; + } +} + +function hasOwn(value, property) { + return ( + value !== null && + typeof value === "object" && + Object.prototype.hasOwnProperty.call(value, property) + ); +} + +export function resolveRepositoryRoot({ + currentWorkingDirectory, + moduleDirectory, + sessionWorkspacePath, +} = {}) { + const candidates = [ + currentWorkingDirectory, + process.env.GITHUB_WORKSPACE, + process.env.COPILOT_WORKSPACE_PATH, + sessionWorkspacePath, + moduleDirectory, + ].filter(Boolean); + + for (const candidate of candidates) { + const root = pathLooksLikeRepository(resolve(candidate)); + if (root) { + return root; + } + } + + throw new Error( + `Could not locate the repository root from: ${candidates.join(", ")}.`, + ); +} + +function workspaceIdentity(repositoryRoot) { + return createHash("sha256") + .update(resolve(repositoryRoot).toLowerCase()) + .digest("hex") + .slice(0, 16); +} + +function sanitizeDiagnostic(value) { + return String(value ?? "") + .replace( + /\b(?:gh[opurs]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)\b/g, + "[REDACTED_TOKEN]", + ) + .replace( + /(GH_TOKEN|GITHUB_TOKEN|PSGALLERY_API_KEY)\s*[:=]\s*\S+/gi, + "$1=[REDACTED]", + ) + .trim(); +} + +function initialState(repositoryRoot, organization = "PSModule") { + return { + stateVersion: STATE_VERSION, + workspaceIdentity: workspaceIdentity(repositoryRoot), + repositoryRoot, + organization, + inventoryStatus: "not-refreshed", + generatedAt: null, + command: null, + error: null, + records: [], + selection: [], + }; +} + +function responseJson(response, statusCode, value) { + response.writeHead(statusCode, { + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + "X-Content-Type-Options": "nosniff", + }); + response.end(JSON.stringify(value)); +} + +function responseHtml(response, html) { + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Security-Policy": + "default-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'self'", + "Content-Type": "text/html; charset=utf-8", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }); + response.end(html); +} + +async function readRequestJson(request) { + let size = 0; + const chunks = []; + for await (const chunk of request) { + size += chunk.length; + if (size > MAX_REQUEST_BYTES) { + throw createCanvasError( + "request_too_large", + `Canvas request exceeds ${MAX_REQUEST_BYTES} bytes.`, + ); + } + chunks.push(chunk); + } + if (chunks.length === 0) { + return {}; + } + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch (error) { + throw createCanvasError( + "request_json_invalid", + `Canvas request JSON is invalid: ${sanitizeDiagnostic(error.message)}`, + ); + } +} + +function requireCanvasToken(request, token) { + if (request.headers["x-canvas-token"] !== token) { + throw createCanvasError( + "canvas_request_forbidden", + "Canvas request token is missing or invalid.", + ); + } +} + +async function closeHttpServer(server) { + await new Promise((resolveClose, rejectClose) => { + server.close((error) => { + if (error) { + rejectClose(error); + } else { + resolveClose(); + } + }); + }); +} + +export function createFleetService({ getSession, repositoryRoot }) { + const identity = workspaceIdentity(repositoryRoot); + + function stateDirectory() { + const sessionWorkspacePath = getSession()?.workspacePath; + if (sessionWorkspacePath) { + return join( + sessionWorkspacePath, + "files", + "process-workflow-fleet", + identity, + ); + } + return join(tmpdir(), "copilot-process-workflow-fleet", identity); + } + + function statePath() { + return join(stateDirectory(), "state.json"); + } + + function inventoryPath() { + return join(stateDirectory(), "inventory.json"); + } + + async function writeState(state) { + const directory = stateDirectory(); + await mkdir(directory, { recursive: true }); + const path = statePath(); + const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + try { + await writeFile( + temporaryPath, + `${JSON.stringify(state, null, 2)}\n`, + "utf8", + ); + await rename(temporaryPath, path); + } finally { + await rm(temporaryPath, { force: true }); + } + return state; + } + + async function readState() { + try { + const state = JSON.parse(await readFile(statePath(), "utf8")); + if ( + state.stateVersion !== STATE_VERSION || + state.workspaceIdentity !== identity + ) { + throw new Error( + `Unsupported or mismatched state at ${statePath()}.`, + ); + } + if ( + state.inventoryStatus === "refreshing" && + !refreshes.has(identity) + ) { + return writeState({ + ...state, + inventoryStatus: "not-refreshed", + generatedAt: null, + error: { + code: "inventory_refresh_interrupted", + message: + "The previous inventory refresh was interrupted. Refresh again.", + }, + records: [], + selection: [], + }); + } + return state; + } catch (error) { + if (error?.code === "ENOENT") { + return null; + } + throw createCanvasError( + "fleet_state_read_failed", + `Could not read workflow fleet state at ${statePath()}: ${sanitizeDiagnostic(error.message)}`, + ); + } + } + + async function ensureState({ organization } = {}) { + const existing = await readState(); + if (existing) { + if (organization && organization !== existing.organization) { + return writeState({ + ...initialState(repositoryRoot, organization), + selection: [], + }); + } + return existing; + } + return writeState(initialState(repositoryRoot, organization)); + } + + function inventoryCommand(input, outputPath) { + const organization = input.organization || "PSModule"; + const scriptPath = join( + repositoryRoot, + ".github", + "scripts", + "Get-ProcessPSModuleWorkflowInventory.ps1", + ); + if (!isAbsolute(scriptPath)) { + throw createCanvasError( + "inventory_script_path_invalid", + `Inventory script path is not absolute: ${scriptPath}`, + ); + } + const args = [ + "-NoLogo", + "-NoProfile", + "-File", + scriptPath, + "-Organization", + organization, + ]; + if (input.repositories?.length) { + args.push("-Repository", ...input.repositories); + } + args.push("-TargetReference", "v8", "-JsonPath", outputPath); + if (input.includeArchived === true) { + args.push("-IncludeArchived"); + } + return { + executable: "pwsh", + args, + organization, + repositories: input.repositories ?? [], + display: `pwsh -NoLogo -NoProfile -File ${scriptPath} -Organization ${organization}${input.repositories?.length ? ` -Repository ${input.repositories.join(",")}` : ""} -TargetReference v8 -JsonPath ${outputPath}${input.includeArchived ? " -IncludeArchived" : ""}`, + }; + } + + async function loadGeneratedInventory() { + const content = await readFile(inventoryPath(), "utf8"); + const parsed = JSON.parse(content.replace(/^\uFEFF/, "")); + const records = analyzeInventory(normalizeInventory(parsed)); + if (records.length === 0) { + throw new Error( + `Inventory command wrote no workflow records to ${inventoryPath()}.`, + ); + } + return records; + } + + async function refreshInventory(input = {}) { + if (refreshes.has(identity)) { + throw createCanvasError( + "inventory_refresh_in_progress", + "A workflow inventory refresh is already running for this workspace.", + ); + } + + const refresh = (async () => { + const previous = await ensureState({ + organization: input.organization, + }); + const command = inventoryCommand( + { + ...input, + organization: input.organization || previous.organization, + }, + inventoryPath(), + ); + await rm(inventoryPath(), { force: true }); + await writeState({ + ...previous, + organization: command.organization, + inventoryStatus: "refreshing", + generatedAt: null, + command: command.display, + error: null, + records: [], + selection: [], + }); + + try { + await execFileAsync(command.executable, command.args, { + cwd: repositoryRoot, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + const records = await loadGeneratedInventory(); + const state = await writeState({ + ...previous, + organization: command.organization, + inventoryStatus: "ready", + generatedAt: new Date().toISOString(), + command: command.display, + error: null, + records, + selection: [], + }); + return { + summary: getSummary(state), + artifactPath: inventoryPath(), + }; + } catch (error) { + let records = []; + try { + records = await loadGeneratedInventory(); + } catch (artifactError) { + if (artifactError?.code !== "ENOENT") { + error.message = `${error.message}; generated inventory could not be read: ${artifactError.message}`; + } + } + + const diagnostic = sanitizeDiagnostic( + error.stderr || error.stdout || error.message, + ); + const failure = await writeState({ + ...previous, + organization: command.organization, + inventoryStatus: "failed", + generatedAt: new Date().toISOString(), + command: command.display, + error: { + code: "inventory_refresh_failed", + message: diagnostic, + organization: command.organization, + repositories: command.repositories, + }, + records, + selection: [], + }); + getSession()?.log( + `Process workflow fleet refresh failed for ${command.organization}: ${diagnostic}`, + { level: "error", ephemeral: false }, + ); + throw createCanvasError( + "inventory_refresh_failed", + `Inventory command failed for ${command.organization}: ${diagnostic}. State: ${statePath()}`, + ); + } + })(); + + refreshes.set(identity, refresh); + try { + return await refresh; + } finally { + refreshes.delete(identity); + } + } + + async function getSummaryResult() { + return getSummary(await ensureState()); + } + + async function getRepository(repository) { + const state = await ensureState(); + const record = state.records.find( + (item) => item.repository === repository, + ); + if (!record) { + throw createCanvasError( + "repository_not_found", + `Repository [${repository}] is not present in the current inventory.`, + ); + } + return record; + } + + async function setSelection(repositories) { + const state = await ensureState(); + if (state.inventoryStatus !== "ready") { + throw createCanvasError( + "inventory_not_ready", + "Refresh inventory successfully before selecting repositories.", + ); + } + const known = new Set(state.records.map((record) => record.repository)); + const unknown = repositories.filter((repository) => !known.has(repository)); + if (unknown.length > 0) { + throw createCanvasError( + "selection_repository_unknown", + `Selection contains repositories outside the current inventory: ${unknown.join(", ")}.`, + ); + } + const selection = [...new Set(repositories)].sort(); + await writeState({ + ...state, + selection, + }); + return { + repositories: selection, + count: selection.length, + }; + } + + async function requestMigration({ + repositories, + dryRun = true, + confirmed = false, + } = {}) { + const state = await ensureState(); + if (state.inventoryStatus !== "ready") { + throw createCanvasError( + "inventory_not_ready", + "Migration requests require a successful current inventory refresh.", + ); + } + const selection = repositories ?? state.selection; + if (!selection || selection.length === 0) { + throw createCanvasError( + "migration_selection_empty", + "Select at least one repository before requesting migration.", + ); + } + const selected = selection.map((repository) => { + const record = state.records.find( + (item) => item.repository === repository, + ); + if (!record) { + throw createCanvasError( + "migration_repository_unknown", + `Repository [${repository}] is not present in the current inventory.`, + ); + } + return record; + }); + + let request; + try { + request = buildMigrationRequest(selected); + } catch (error) { + throw createCanvasError( + "migration_inventory_incomplete", + error.message, + ); + } + const prompt = buildMigrationPrompt(request); + + if (dryRun !== false) { + return { + dryRun: true, + sent: false, + request, + prompt, + }; + } + if (confirmed !== true) { + throw createCanvasError( + "migration_confirmation_required", + "Set confirmed to true only after the user explicitly confirms the migration request.", + ); + } + + const activeSession = getSession(); + if (!activeSession) { + throw createCanvasError( + "session_unavailable", + "The active Copilot session is not available for migration orchestration.", + ); + } + const messageId = await activeSession.send({ prompt }); + return { + dryRun: false, + sent: true, + messageId, + repositories: selection, + }; + } + + async function handleHttpRequest(request, response, token, organization) { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method === "GET" && url.pathname === "/") { + responseHtml(response, renderDashboard(token)); + return; + } + if (request.method === "GET" && url.pathname === "/api/state") { + responseJson(response, 200, await ensureState({ organization })); + return; + } + if (request.method !== "POST") { + responseJson(response, 404, { + error: { + code: "route_not_found", + message: `No canvas route for ${request.method} ${url.pathname}.`, + }, + }); + return; + } + + requireCanvasToken(request, token); + const input = await readRequestJson(request); + if (url.pathname === "/api/refresh") { + responseJson(response, 200, await refreshInventory(input)); + return; + } + if (url.pathname === "/api/selection") { + responseJson( + response, + 200, + await setSelection(input.repositories ?? []), + ); + return; + } + if (url.pathname === "/api/migration") { + responseJson(response, 200, await requestMigration(input)); + return; + } + responseJson(response, 404, { + error: { + code: "route_not_found", + message: `No canvas route for POST ${url.pathname}.`, + }, + }); + } + + async function openPanel(instanceId, { organization } = {}) { + const existing = servers.get(instanceId); + if (existing) { + return existing; + } + + const token = randomBytes(24).toString("base64url"); + const server = createServer((request, response) => { + handleHttpRequest(request, response, token, organization).catch( + (error) => { + const code = error.code || "canvas_http_failed"; + const message = sanitizeDiagnostic(error.message); + getSession()?.log( + `Process workflow fleet request failed: ${message}`, + { level: "error", ephemeral: true }, + ); + if (!response.headersSent) { + responseJson( + response, + code === "route_not_found" ? 404 : 500, + { + error: { code, message }, + }, + ); + } else { + response.end(); + } + }, + ); + }); + server.on("clientError", (error, socket) => { + getSession()?.log( + `Process workflow fleet HTTP client error: ${sanitizeDiagnostic(error.message)}`, + { level: "warning", ephemeral: true }, + ); + socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); + }); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectListen); + resolveListen(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + await closeHttpServer(server); + throw createCanvasError( + "canvas_server_address_invalid", + "Loopback canvas server did not return a TCP address.", + ); + } + const entry = { + server, + token, + url: `http://127.0.0.1:${address.port}/`, + }; + servers.set(instanceId, entry); + return entry; + } + + async function closePanel(instanceId) { + const entry = servers.get(instanceId); + if (!entry) { + return; + } + servers.delete(instanceId); + await closeHttpServer(entry.server); + } + + return { + closePanel, + ensureState, + getRepository, + getSummary: getSummaryResult, + openPanel, + refreshInventory, + requestMigration, + setCanvasErrorFactory(factory) { + createCanvasError = factory; + }, + setSelection, + }; +} diff --git a/.github/extensions/process-workflow-fleet/renderer.mjs b/.github/extensions/process-workflow-fleet/renderer.mjs new file mode 100644 index 00000000..83b8570b --- /dev/null +++ b/.github/extensions/process-workflow-fleet/renderer.mjs @@ -0,0 +1,714 @@ +function serializeForInlineScript(value) { + return JSON.stringify(value).replaceAll("<", "\\u003c"); +} + +export function renderDashboard(token) { + return ` + + + + + Process workflow fleet + + + +
+
+
+

Process workflow fleet

+
Authenticated evidence and explicit v8 migration deltas
+
+ +
+
Loading workspace state…
+
+
+
+
+ + + + +
+
+ + + + + + + + + + + + + + + +
RepositoryReference / complianceTriggersConcurrencyPermissionsConditionSecretsInputsExtra jobs
+ +
+
+ +
+ +
+ + +`; +} diff --git a/.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1 b/.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1 index d34fd3b9..709d5dec 100644 --- a/.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1 +++ b/.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1 @@ -590,6 +590,7 @@ function Get-WorkflowInventoryItem { } else { [ordered]@{} } + Permissions = ConvertTo-PermissionValue -Value (Get-MapValue -Map $job -Name 'permissions') Environment = Get-MapValue -Map $job -Name 'environment' Condition = Get-MapValue -Map $job -Name 'if' } @@ -658,33 +659,35 @@ function Get-WorkflowInventoryItem { ) [pscustomobject]@{ - Repository = $WorkflowFile.Repository - DefaultBranch = $WorkflowFile.DefaultBranch - Archived = $WorkflowFile.Archived - RepositoryUrl = $WorkflowFile.RepositoryUrl - WorkflowPath = $WorkflowFile.WorkflowPath - WorkflowUrl = $WorkflowFile.WorkflowUrl - SearchQuery = $WorkflowFile.SearchQuery - Status = 'Parsed' - Error = $null - WorkflowName = Get-MapValue -Map $workflow -Name 'name' - RunName = Get-MapValue -Map $workflow -Name 'run-name' - Events = @(Get-MapKey -Map $trigger | Sort-Object) - Schedules = @($schedule | ForEach-Object { Get-MapValue -Map $_ -Name 'cron' }) - PushBranches = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'branches') - PushBranchesIgnore = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'branches-ignore') - PushPaths = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'paths') - PushPathsIgnore = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'paths-ignore') - PullRequestBranches = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'branches') - PullRequestTypes = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'types') - ConcurrencyGroup = $concurrencyGroup - CancelInProgress = $cancelInProgress - Permissions = $permissions - ProcessJobs = @($processJobs) - AdditionalJobs = @($allJobNames | Where-Object { $_ -notin $processJobNames }) - VersionComments = $versionComments - TargetReference = $ExpectedTargetReference - MatchesTarget = if ($ExpectedTargetReference) { + Repository = $WorkflowFile.Repository + DefaultBranch = $WorkflowFile.DefaultBranch + Archived = $WorkflowFile.Archived + RepositoryUrl = $WorkflowFile.RepositoryUrl + WorkflowPath = $WorkflowFile.WorkflowPath + WorkflowUrl = $WorkflowFile.WorkflowUrl + SearchQuery = $WorkflowFile.SearchQuery + Status = 'Parsed' + Error = $null + WorkflowName = Get-MapValue -Map $workflow -Name 'name' + RunName = Get-MapValue -Map $workflow -Name 'run-name' + Events = @(Get-MapKey -Map $trigger | Sort-Object) + Schedules = @($schedule | ForEach-Object { Get-MapValue -Map $_ -Name 'cron' }) + PushBranches = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'branches') + PushBranchesIgnore = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'branches-ignore') + PushPaths = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'paths') + PushPathsIgnore = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'paths-ignore') + PullRequestBranches = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'branches') + PullRequestTypes = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'types') + PullRequestPaths = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'paths') + PullRequestPathsIgnore = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'paths-ignore') + ConcurrencyGroup = $concurrencyGroup + CancelInProgress = $cancelInProgress + Permissions = $permissions + ProcessJobs = @($processJobs) + AdditionalJobs = @($allJobNames | Where-Object { $_ -notin $processJobNames }) + VersionComments = $versionComments + TargetReference = $ExpectedTargetReference + MatchesTarget = if ($ExpectedTargetReference) { @($processJobs | Where-Object { -not $_.MatchesTarget }).Count -eq 0 } else { $null diff --git a/.github/scripts/tests/Get-ProcessPSModuleWorkflowInventory.Tests.ps1 b/.github/scripts/tests/Get-ProcessPSModuleWorkflowInventory.Tests.ps1 index a834d138..d772f266 100644 --- a/.github/scripts/tests/Get-ProcessPSModuleWorkflowInventory.Tests.ps1 +++ b/.github/scripts/tests/Get-ProcessPSModuleWorkflowInventory.Tests.ps1 @@ -40,6 +40,10 @@ permissions: jobs: Process-PSModule: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + permissions: + contents: read + pages: write + id-token: write uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 with: Debug: true @@ -95,12 +99,17 @@ Describe 'Get-ProcessPSModuleWorkflowInventory' { $result[0].Events | Should -Be @('pull_request', 'push', 'schedule', 'workflow_dispatch') $result[0].PushBranches | Should -Be @('main') $result[0].PullRequestTypes | Should -Be @('opened', 'synchronize') + $result[0].PullRequestPaths | Should -BeNullOrEmpty + $result[0].PullRequestPathsIgnore | Should -BeNullOrEmpty $result[0].ConcurrencyGroup | Should -Be '${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}' $result[0].CancelInProgress | Should -BeFalse $result[0].ProcessJobs[0].Reference | Should -Be 'v8' $result[0].ProcessJobs[0].MatchesTarget | Should -BeTrue $result[0].MatchesTarget | Should -BeTrue $result[0].ProcessJobs[0].Condition | Should -Match 'head.repo.full_name' + $result[0].ProcessJobs[0].Permissions.contents | Should -Be 'read' + $result[0].ProcessJobs[0].Permissions.pages | Should -Be 'write' + $result[0].ProcessJobs[0].Permissions.'id-token' | Should -Be 'write' $result[0].ProcessJobs[0].Inputs.Keys | Should -Contain 'Debug' $result[0].ProcessJobs[0].SecretMappings.Keys | Should -Be @( 'PSGALLERY_API_KEY'