diff --git a/src/model/pipeline/pipeline-model.ts b/src/model/pipeline/pipeline-model.ts index 43c78a13..86a0b21b 100644 --- a/src/model/pipeline/pipeline-model.ts +++ b/src/model/pipeline/pipeline-model.ts @@ -6,7 +6,7 @@ import { TknElement, TknArray, TknBaseRootElement, NodeTknElement, TknStringElement, TknValueElement, TknParam, TknKeyElement } from '../common'; import { TknElementType } from '../element-type'; import { YamlMap, YamlSequence, YamlNode, isSequence } from '../../yaml-support/yaml-locator'; -import { pipelineYaml, getYamlMappingValue, findNodeByKey } from '../../yaml-support/tkn-yaml'; +import { pipelineYaml, getYamlMappingValue, findNodeByKey, findNodeAndKeyByKeyValue } from '../../yaml-support/tkn-yaml'; import { EmbeddedTask } from './task-model'; const ephemeralMap: YamlMap = { @@ -229,9 +229,9 @@ export class PipelineTask extends NodeTknElement { get taskSpec(): EmbeddedTask { if (!this._taskSpec) { - const taskSpecNode = findNodeByKey('taskSpec', this.node as YamlMap) + const taskSpecNode = findNodeAndKeyByKeyValue('taskSpec', this.node as YamlMap) if (taskSpecNode) { - this._taskSpec = new EmbeddedTask(this, taskSpecNode); + this._taskSpec = new EmbeddedTask(this, taskSpecNode[0], taskSpecNode[1]); } } return this._taskSpec; diff --git a/src/model/pipeline/task-model.ts b/src/model/pipeline/task-model.ts index b957ef78..cc35fa13 100644 --- a/src/model/pipeline/task-model.ts +++ b/src/model/pipeline/task-model.ts @@ -4,8 +4,8 @@ *-----------------------------------------------------------------------------------------------*/ import { findNodeByKey } from '../../yaml-support/tkn-yaml'; -import { YamlMap, YamlSequence } from '../../yaml-support/yaml-locator'; -import { NodeTknElement, TknArray, TknElement, TknStringElement } from '../common'; +import { YamlMap, YamlNode, YamlSequence } from '../../yaml-support/yaml-locator'; +import { NodeTknElement, TknArray, TknElement, TknKeyElement, TknStringElement } from '../common'; import { TknElementType } from '../element-type'; export class EmbeddedTask extends NodeTknElement { @@ -23,6 +23,12 @@ export class EmbeddedTask extends NodeTknElement { // private _sidecars: TknArray; // private _workspaces: TknArray; private _results: TknArray; + keyNode: TknElement + + constructor(parent: TknElement, keyNode: YamlNode, node: YamlSequence) { + super(parent, node); + this.keyNode = new TknKeyElement(parent, keyNode); + } get results(): TknArray { if (!this._results) { diff --git a/src/util/tekton-vfs.ts b/src/util/tekton-vfs.ts index ea7a0d03..5a30ff16 100644 --- a/src/util/tekton-vfs.ts +++ b/src/util/tekton-vfs.ts @@ -158,6 +158,24 @@ export class TektonVFSProvider implements FileSystemProvider { }; } + async saveTektonDocument(doc: VirtualDocument): Promise { + const tempPath = os.tmpdir(); + const fsPath = path.join(tempPath, doc.uri.fsPath); + try { + await fsx.ensureFile(fsPath); + await fsx.writeFile(fsPath, doc.getText()); + + const result = await this.updateK8sResource(fsPath); + if (result.error) { + return getStderrString(result.error); + } + + } finally { + await fsx.unlink(fsPath); + } + + } + } export const tektonVfsProvider = new TektonVFSProvider(); diff --git a/src/yaml-support/tkn-code-actions.ts b/src/yaml-support/tkn-code-actions.ts index 63ac4e93..0f7948a7 100644 --- a/src/yaml-support/tkn-code-actions.ts +++ b/src/yaml-support/tkn-code-actions.ts @@ -18,7 +18,10 @@ interface ProviderMetadata { getProviderMetadata(): vscode.CodeActionProviderMetadata; } -interface TaskInlineAction extends vscode.CodeAction{ +const INLINE_TASK = vscode.CodeActionKind.RefactorInline.append('TektonTask'); +const EXTRACT_TASK = vscode.CodeActionKind.RefactorExtract.append('TektonTask'); + +interface InlineTaskAction extends vscode.CodeAction { taskRefStartPosition?: vscode.Position; taskRefEndPosition?: vscode.Position; taskRefName?: string; @@ -26,6 +29,17 @@ interface TaskInlineAction extends vscode.CodeAction{ documentUri?: vscode.Uri; } +function isTaskInlineAction(action: vscode.CodeAction): action is InlineTaskAction { + return action.kind.contains(INLINE_TASK); +} + +interface ExtractTaskAction extends vscode.CodeAction { + documentUri?: vscode.Uri; + taskSpecText?: string; + taskSpecStartPosition?: vscode.Position; + taskSpecEndPosition?: vscode.Position; +} + class PipelineCodeActionProvider implements vscode.CodeActionProvider { provideCodeActions(document: vscode.TextDocument, range: vscode.Range | vscode.Selection): vscode.ProviderResult { const result = []; @@ -33,45 +47,35 @@ class PipelineCodeActionProvider implements vscode.CodeActionProvider { for (const tknDoc of tknDocs) { const selectedElement = this.findTask(tknDoc, range.start); if (selectedElement) { - const taskRefName = selectedElement.taskRef?.name.value - if (!taskRefName){ - continue; + + const inlineAction = this.getInlineAction(selectedElement, document); + if (inlineAction) { + result.push(inlineAction); + } + + const extractAction = this.getExtractTaskAction(selectedElement, document); + if (extractAction) { + result.push(extractAction); } - const action: TaskInlineAction = new vscode.CodeAction(`Inline '${taskRefName}' Task spec`, vscode.CodeActionKind.RefactorInline.append('TektonTask')); - const startPos = document.positionAt(selectedElement.taskRef?.keyNode?.startPosition); - const endPos = document.positionAt(selectedElement.taskRef?.endPosition); - action.taskRefStartPosition = startPos; - action.taskRefEndPosition = endPos; - action.taskRefName = taskRefName; - action.taskKind = selectedElement.taskRef?.kind.value; - action.documentUri = document.uri; - result.push(action); } } return result; } - resolveCodeAction?(codeAction: TaskInlineAction): Thenable { - return vscode.window.withProgress({location: vscode.ProgressLocation.Notification, cancellable: false, title: `Loading '${codeAction.taskRefName}' Task...` }, async (): Promise => { - const uri = tektonFSUri(codeAction.taskKind === TektonYamlType.ClusterTask ? ContextType.CLUSTERTASK : ContextType.TASK , codeAction.taskRefName, 'yaml'); - try { - const taskDoc = await tektonVfsProvider.loadTektonDocument(uri, false); - codeAction.edit = new vscode.WorkspaceEdit(); - codeAction.edit.replace(codeAction.documentUri, - new vscode.Range(codeAction.taskRefStartPosition, codeAction.taskRefEndPosition), - this.extractTaskDef(taskDoc, codeAction.taskRefStartPosition.character, codeAction.taskRefEndPosition.character)); - } catch (err){ - vscode.window.showErrorMessage('Cannot get Tekton Task definition: ' + err.toString()); - telemetryLogError('resolveCodeAction', `Cannot get '${codeAction.taskRefName}' Task definition`); - } - return codeAction; - }); + resolveCodeAction?(codeAction: vscode.CodeAction): Thenable { + if (isTaskInlineAction(codeAction)){ + return this.resolveInlineAction(codeAction); + } + + if (codeAction.kind.contains(EXTRACT_TASK)) { + return this.resolveExtractTaskAction(codeAction); + } } getProviderMetadata(): vscode.CodeActionProviderMetadata { return { - providedCodeActionKinds: [vscode.CodeActionKind.RefactorInline.append('TektonTask')], + providedCodeActionKinds: [INLINE_TASK, EXTRACT_TASK], } } @@ -97,6 +101,129 @@ class PipelineCodeActionProvider implements vscode.CodeActionProvider { } + private getInlineAction(selectedElement: PipelineTask, document: vscode.TextDocument): InlineTaskAction | undefined { + const taskRefName = selectedElement.taskRef?.name.value + if (!taskRefName){ + return; + } + const action: InlineTaskAction = new vscode.CodeAction(`Inline '${taskRefName}' Task spec`, INLINE_TASK); + const startPos = document.positionAt(selectedElement.taskRef?.keyNode?.startPosition); + const endPos = document.positionAt(selectedElement.taskRef?.endPosition); + action.taskRefStartPosition = startPos; + action.taskRefEndPosition = endPos; + action.taskRefName = taskRefName; + action.taskKind = selectedElement.taskRef?.kind.value; + action.documentUri = document.uri; + + return action; + } + + private async resolveInlineAction(codeAction: InlineTaskAction): Promise { + return vscode.window.withProgress({location: vscode.ProgressLocation.Notification, cancellable: false, title: `Loading '${codeAction.taskRefName}' Task...` }, async (): Promise => { + const uri = tektonFSUri(codeAction.taskKind === TektonYamlType.ClusterTask ? ContextType.CLUSTERTASK : ContextType.TASK , codeAction.taskRefName, 'yaml'); + try { + const taskDoc = await tektonVfsProvider.loadTektonDocument(uri, false); + codeAction.edit = new vscode.WorkspaceEdit(); + codeAction.edit.replace(codeAction.documentUri, + new vscode.Range(codeAction.taskRefStartPosition, codeAction.taskRefEndPosition), + this.extractTaskDef(taskDoc, codeAction.taskRefStartPosition.character, codeAction.taskRefEndPosition.character)); + } catch (err){ + vscode.window.showErrorMessage('Cannot get Tekton Task definition: ' + err.toString()); + telemetryLogError('resolveCodeAction', `Cannot get '${codeAction.taskRefName}' Task definition`); + } + return codeAction; + }); + } + + private getExtractTaskAction(selectedElement: PipelineTask, document: vscode.TextDocument): ExtractTaskAction | undefined { + const taskSpec = selectedElement.taskSpec; + if (!taskSpec) { + return; + } + const startPos = document.positionAt(taskSpec.keyNode?.startPosition); + let taskSpecStartPos = document.positionAt(taskSpec.startPosition); + // start replace from stat of the line + taskSpecStartPos = document.lineAt(taskSpecStartPos.line).range.start; + let endPos = document.positionAt(taskSpec.endPosition); + + // if last line is contains only spaces then replace til previous line + const lastLine = document.getText(new vscode.Range(endPos.line, 0, endPos.line, endPos.character)); + if (lastLine.trim().length === 0) { + endPos = document.lineAt(endPos.line - 1).range.end; + } + + const action: ExtractTaskAction = new vscode.CodeAction(`Extract '${selectedElement.name.value}' Task spec`, EXTRACT_TASK); + action.documentUri = document.uri; + action.taskSpecStartPosition = startPos; + action.taskSpecEndPosition = endPos; + action.taskSpecText = document.getText(new vscode.Range(taskSpecStartPos, endPos)); + + return action; + } + + private async resolveExtractTaskAction(action: ExtractTaskAction): Promise { + return vscode.window.withProgress({location: vscode.ProgressLocation.Notification, cancellable: false, title: 'Extracting Task...' }, async (): Promise => { + try { + const name = await vscode.window.showInputBox({ignoreFocusOut: false, prompt: 'Provide Task Name' }); + const type = await vscode.window.showQuickPick(['Task', 'ClusterTask'], {placeHolder: 'Select Task Type:', canPickMany: false}); + const virtDoc = this.getDocForExtractedTask(name, type, action.taskSpecText); + + const saveError = await tektonVfsProvider.saveTektonDocument(virtDoc); + if (saveError) { + console.error(saveError); + throw new Error(saveError); + } + const newUri = tektonFSUri(type, name, 'yaml'); + await vscode.commands.executeCommand('vscode.open', newUri); + const indentation = ' '.repeat(action.taskSpecStartPosition.character); + action.edit = new vscode.WorkspaceEdit(); + action.edit.replace(action.documentUri, new vscode.Range(action.taskSpecStartPosition, action.taskSpecEndPosition), + `taskRef: + ${indentation}name: ${name} + ${indentation}kind: ${type}`); + } catch (err) { + console.error(err); + } + + return action; + }); + } + + private getDocForExtractedTask(name: string, type: string, content: string): VirtualDocument { + + const lines = content.split('\n'); + const firstLine = lines[0].trimLeft(); + const indentation = lines[0].length - firstLine.length; + lines[0] = firstLine; + for (let i = 1; i < lines.length; i++) { + lines[i] = lines[i].slice(indentation); + } + content = lines.join('\n'); + + const taskPart = jsYaml.load(content); + let metadataPart: {} = undefined; + if (taskPart.metadata) { + metadataPart = taskPart.metadata; + delete taskPart['metadata']; + } + const specContent = jsYaml.dump(taskPart); + return { + version: 1, + uri: vscode.Uri.file(`file:///extracted/task/${name}.yaml`), + getText: () => { + return `apiVersion: tekton.dev/v1beta1 +kind: ${type} +metadata: + name: ${name} + ${metadataPart ? jsYaml.dump(metadataPart) : ''} +spec: + ${specContent} +`; + } + } + + } + private extractTaskDef(taskDoc: VirtualDocument, startPos: number, endPos): string { const task: Task = jsYaml.safeLoad(taskDoc.getText()) as Task; if (!task){ diff --git a/test/text-document-mock.ts b/test/text-document-mock.ts index fdb3d789..d9d9ae07 100644 --- a/test/text-document-mock.ts +++ b/test/text-document-mock.ts @@ -15,7 +15,7 @@ export class TestTextDocument implements vscode.TextDocument { isDirty: boolean; isClosed: boolean; - private text; + private text: string; constructor(public uri: vscode.Uri, text: string) { this.text = text.replace(/\r\n/gm, '\n'); // normalize end of line @@ -27,11 +27,34 @@ export class TestTextDocument implements vscode.TextDocument { eol = vscode.EndOfLine.LF; lineCount: number; + private get lines(): string[] { + return this.text.split('\n'); + } + lineAt(position: number | vscode.Position): vscode.TextLine { - throw new Error('Method not implemented.'); + const line = typeof position === 'number' ? position : position.line + const text = this.lines[line]; + return { + text, + range: new vscode.Range(line, 0, line, text.length) + } as vscode.TextLine; } + offsetAt(position: vscode.Position): number { - throw new Error('Method not implemented.'); + const lines = this.text.split('\n'); + let currentOffSet = 0; + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + if (position.line === i) { + if (l.length < position.character) { + throw new Error(`Position ${JSON.stringify(position)} is out of range. Line [${i}] only has length ${l.length}.`); + } + return currentOffSet + position.character; + } else { + currentOffSet += l.length + 1; + } + } + throw new Error(`Position ${JSON.stringify(position)} is out of range. Document only has ${lines.length} lines.`); } positionAt(offset: number): vscode.Position { @@ -48,7 +71,11 @@ export class TestTextDocument implements vscode.TextDocument { throw new Error('Cannot find position!'); } getText(range?: vscode.Range): string { - return this.text; + if (!range) + return this.text; + const offset = this.offsetAt(range.start); + const length = this.offsetAt(range.end) - offset; + return this.text.substr(offset, length); } getWordRangeAtPosition(position: vscode.Position, regex?: RegExp): vscode.Range { throw new Error('Method not implemented.'); diff --git a/test/yaml-support/extract-task-pipeline.yaml b/test/yaml-support/extract-task-pipeline.yaml new file mode 100644 index 00000000..401654a1 --- /dev/null +++ b/test/yaml-support/extract-task-pipeline.yaml @@ -0,0 +1,44 @@ +apiVersion: tekton.dev/v1beta1 +kind: Pipeline +metadata: + generateName: optional-workspace-when- +spec: + workspaces: + - name: message-of-the-day + optional: true + description: | + If a workspace is provided here then every file at the root of the workspace + will be printed. + tasks: + - name: print-motd + when: + - input: "$(workspaces.message-of-the-day.bound)" + operator: in + values: ["true"] + workspaces: + - name: message-of-the-day + workspace: message-of-the-day + taskSpec: + workspaces: + - name: message-of-the-day + optional: true + steps: + - image: alpine + script: | + #!/usr/bin/env ash + for f in "$(workspaces.message-of-the-day.path)"/* ; do + echo "Message from $f:" + cat "$f" + echo "" # add newline + done + - name: print-default-motd + when: + - input: "$(workspaces.message-of-the-day.bound)" + operator: in + values: ["false"] + taskSpec: + steps: + - name: print-default + image: alpine + script: | + echo "No message-of-the-day workspace was provided. This is the default MOTD instead!" diff --git a/test/yaml-support/tkn-code-actions.test.ts b/test/yaml-support/tkn-code-actions.test.ts index c7683cdb..04ea417e 100644 --- a/test/yaml-support/tkn-code-actions.test.ts +++ b/test/yaml-support/tkn-code-actions.test.ts @@ -30,15 +30,16 @@ suite('Tekton CodeActions', () => { sandbox.restore(); }); + test('CodeAction for Pipeline should exist', () => { + expect(codeActionProvider.isSupports(TektonYamlType.Pipeline)).to.be.true; + expect(codeActionProvider.getProvider(TektonYamlType.Pipeline)).to.not.be.undefined; + }); + suite('Inline Task', () => { let loadTektonDocumentStub: sinon.SinonStub; setup(() => { loadTektonDocumentStub = sandbox.stub(tektonVfsProvider, 'loadTektonDocument'); }); - test('CodeAction for Pipeline should exist', () => { - expect(codeActionProvider.isSupports(TektonYamlType.Pipeline)).to.be.true; - expect(codeActionProvider.getProvider(TektonYamlType.Pipeline)).to.not.be.undefined; - }); test('Provider should provide Inline Code Action', async () => { const yaml = await fs.readFile(path.join(__dirname, '..', '..', '..', 'test', 'yaml-support', 'conditional-pipeline.yaml'), 'utf8'); @@ -66,4 +67,45 @@ suite('Tekton CodeActions', () => { expect(edit.get(fileUri)[0]).to.deep.equal(vscode.TextEdit.replace(new vscode.Range(29, 6, 30, 25), 'taskSpec:\n metadata:\n annotations: {}\n steps:\n - image: ubuntu\n name: echo\n resources: {}\n script: echo hello\n ')) }); }); + + suite('Extract Task Action', () => { + + let saveTektonDocumentStub: sinon.SinonStub; + let showInputBoxStub: sinon.SinonStub; + let showQuickPickStub: sinon.SinonStub; + + setup(() => { + saveTektonDocumentStub = sandbox.stub(tektonVfsProvider, 'saveTektonDocument'); + showInputBoxStub = sandbox.stub(vscode.window, 'showInputBox'); + showQuickPickStub = sandbox.stub(vscode.window, 'showQuickPick'); + }); + + test('Provider should provide Extract Task Action', async () => { + const yaml = await fs.readFile(path.join(__dirname, '..', '..', '..', 'test', 'yaml-support', 'extract-task-pipeline.yaml'), 'utf8'); + const doc = new TestTextDocument(vscode.Uri.parse('/home/someextract-task-pipeline.yaml'), yaml); + + const result = codeActionProvider.getProvider(TektonYamlType.Pipeline).provideCodeActions(doc, new vscode.Range(24, 18, 24, 18), undefined, undefined) as vscode.CodeAction[]; + expect(result).is.not.empty; + const inlineAction = result.find(it => it.title.startsWith('Extract')); + expect(inlineAction.title).to.be.equal('Extract \'print-motd\' Task spec'); + }); + + test('Provider should resolve Extract Task CodeAction', async () => { + const yaml = await fs.readFile(path.join(__dirname, '..', '..', '..', 'test', 'yaml-support', 'extract-task-pipeline.yaml'), 'utf8'); + const fileUri = vscode.Uri.parse('/home/someextract-task-pipeline.yaml'); + const doc = new TestTextDocument(fileUri, yaml); + + saveTektonDocumentStub.resolves(); + showInputBoxStub.resolves('foo-name'); + showQuickPickStub.resolves('Task'); + + const result = codeActionProvider.getProvider(TektonYamlType.Pipeline).provideCodeActions(doc, new vscode.Range(24, 17, 24, 17), undefined, undefined) as vscode.CodeAction[]; + const inlineAction = result.find(it => it.title.startsWith('Extract')); + const resultAction = await codeActionProvider.getProvider(TektonYamlType.Pipeline).resolveCodeAction(inlineAction, undefined); + const edit = resultAction.edit; + expect(edit.has(fileUri)).is.true; + expect(edit.get(fileUri)).has.length(1); + expect(edit.get(fileUri)[0]).to.deep.equal(vscode.TextEdit.replace(new vscode.Range(20, 6, 32, 18), 'taskRef:\n name: foo-name\n kind: Task')) + }); + }); });