diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts index 07b3823e7b..acf1351004 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts @@ -30,8 +30,14 @@ export const getRepoSourceDirectory = ( } return workspacePath; }; +export type RepoSpec = { + repo: string; + host: string; + owner: string; + organization?: string; +}; -export const parseRepoUrl = (repoUrl: string) => { +export const parseRepoUrl = (repoUrl: string): RepoSpec => { let parsed; try { parsed = new URL(`https://${repoUrl}`); @@ -55,7 +61,7 @@ export const parseRepoUrl = (repoUrl: string) => { ); } - const organization = parsed.searchParams.get('organization'); + const organization = parsed.searchParams.get('organization') ?? undefined; return { host, owner, repo, organization }; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts index 014fdf2d99..edef02064f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts @@ -24,6 +24,7 @@ import { ConfigReader, JsonObject } from '@backstage/config'; import { StorageTaskBroker } from './StorageTaskBroker'; import { DatabaseTaskStore } from './DatabaseTaskStore'; import { createTemplateAction, TemplateActionRegistry } from '../actions'; +import { RepoSpec } from '../actions/builtin/publish/util'; async function createStore(): Promise { const manager = SingleConnectionDatabaseManager.fromConfig( @@ -174,6 +175,7 @@ describe('TaskWorker', () => { it('should parse strings as objects if possible', async () => { const inputAction = createTemplateAction<{ address: { line1: string }; + list: string[]; address2: string; }>({ id: 'test-input', @@ -195,10 +197,21 @@ describe('TaskWorker', () => { address2: { type: 'string', }, + list: { + type: 'array', + items: { + type: 'string', + }, + }, }, }, }, async handler(ctx) { + if (ctx.input.list.length !== 1) { + throw new Error( + `expected list to have length "1" got ${ctx.input.list.length}`, + ); + } if (ctx.input.address.line1 !== 'line 1') { throw new Error( `expected address.line1 to be "line 1" got ${ctx.input.address.line1}`, @@ -231,6 +244,7 @@ describe('TaskWorker', () => { action: 'test-input', input: { address: JSON.stringify({ line1: 'line 1' }), + list: JSON.stringify(['hey!']), address2: '{"not valid"}', }, }, @@ -249,4 +263,102 @@ describe('TaskWorker', () => { expect((event?.body?.output as JsonObject).result).toBe('line 1'); }); + + // TODO(blam): Can delete this test when we make the helpers a public API + it('should provide a repoUrlParse helper for the templates', async () => { + const inputAction = createTemplateAction<{ + destination: RepoSpec; + }>({ + id: 'test-input', + schema: { + input: { + type: 'object', + required: ['destination'], + properties: { + destination: { + title: 'destination', + type: 'object', + properties: { + repo: { + type: 'string', + }, + host: { + type: 'string', + }, + owner: { + type: 'string', + }, + organization: { + type: 'string', + }, + }, + }, + }, + }, + }, + async handler(ctx) { + ctx.output('host', ctx.input.destination.host); + ctx.output('repo', ctx.input.destination.repo); + ctx.output('owner', ctx.input.destination.owner); + + if (ctx.input.destination.host !== 'github.com') { + throw new Error( + `expected host to be "github.com" got ${ctx.input.destination.host}`, + ); + } + + if (ctx.input.destination.repo !== 'repo') { + throw new Error( + `expected repo to be "repo" got ${ctx.input.destination.repo}`, + ); + } + + if (ctx.input.destination.owner !== 'owner') { + throw new Error( + `expected repo to be "owner" got ${ctx.input.destination.owner}`, + ); + } + }, + }); + actionRegistry.register(inputAction); + + const broker = new StorageTaskBroker(storage, logger); + const taskWorker = new TaskWorker({ + logger, + workingDirectory: os.tmpdir(), + actionRegistry, + taskBroker: broker, + }); + + const { taskId } = await broker.dispatch({ + steps: [ + { + id: 'test-input', + name: 'test-input', + action: 'test-input', + input: { + destination: '{{ parseRepoUrl parameters.repoUrl }}', + }, + }, + ], + output: { + host: '{{ steps.test-input.output.host }}', + repo: '{{ steps.test-input.output.repo }}', + owner: '{{ steps.test-input.output.owner }}', + }, + values: { + repoUrl: 'github.com?repo=repo&owner=owner', + }, + }); + + const task = await broker.claim(); + await taskWorker.runOneTask(task); + + const { events } = await storage.listEvents({ taskId }); + const event = events.find(e => e.type === 'completion'); + + expect((event?.body?.output as JsonObject).host).toBe('github.com'); + expect((event?.body?.output as JsonObject).repo).toBe('repo'); + expect((event?.body?.output as JsonObject).owner).toBe('owner'); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index aef9e22ada..14752e1988 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -23,8 +23,9 @@ import { TaskBroker, Task } from './types'; import fs from 'fs-extra'; import path from 'path'; import { TemplateActionRegistry } from '../actions/TemplateActionRegistry'; -import * as handlebars from 'handlebars'; +import * as Handlebars from 'handlebars'; import { InputError } from '@backstage/errors'; +import { parseRepoUrl } from '../actions/builtin/publish/util'; type Options = { logger: Logger; @@ -34,7 +35,20 @@ type Options = { }; export class TaskWorker { - constructor(private readonly options: Options) {} + private readonly handlebars: typeof Handlebars; + + constructor(private readonly options: Options) { + this.handlebars = Handlebars.create(); + + // TODO(blam): this should be a public facing API but it's a little + // scary right now, so we're going to lock it off like the component API is + // in the frontend until we can work out a nice way to do it. + this.handlebars.registerHelper('parseRepoUrl', repoUrl => { + return JSON.stringify(parseRepoUrl(repoUrl)); + }); + + this.handlebars.registerHelper('json', obj => JSON.stringify(obj)); + } start() { (async () => { @@ -102,7 +116,7 @@ export class TaskWorker { step.input && JSON.parse(JSON.stringify(step.input), (_key, value) => { if (typeof value === 'string') { - const templated = handlebars.compile(value, { + const templated = this.handlebars.compile(value, { noEscape: true, strict: true, data: false, @@ -110,9 +124,13 @@ export class TaskWorker { })(templateCtx); // If it smells like a JSON object then give it a parse as an object and if it fails return the string - if (templated.startsWith('{') && templated.endsWith('}')) { + if ( + (templated.startsWith('{') && templated.endsWith('}')) || + (templated.startsWith('[') && templated.endsWith(']')) + ) { try { - // Don't recursively JSON parse the values of this string. Shouldn't need to, don't want to encourage the use of returning handlebars from somewhere else + // Don't recursively JSON parse the values of this string. + // Shouldn't need to, don't want to encourage the use of returning handlebars from somewhere else return JSON.parse(templated); } catch { return templated; @@ -183,7 +201,7 @@ export class TaskWorker { JSON.stringify(task.spec.output), (_key, value) => { if (typeof value === 'string') { - return handlebars.compile(value, { + return this.handlebars.compile(value, { noEscape: true, strict: true, data: false,