Skip to content

Commit

Permalink
#511 add 'Extract Task spec' CodeAction
Browse files Browse the repository at this point in the history
Signed-off-by: Yevhen Vydolob <yvydolob@redhat.com>
  • Loading branch information
evidolob committed Apr 6, 2021
1 parent ecac5da commit a14e7e1
Show file tree
Hide file tree
Showing 7 changed files with 306 additions and 42 deletions.
6 changes: 3 additions & 3 deletions src/model/pipeline/pipeline-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -229,9 +229,9 @@ export class PipelineTask extends NodeTknElement {

get taskSpec(): EmbeddedTask {
if (!this._taskSpec) {
const taskSpecNode = findNodeByKey<YamlSequence>('taskSpec', this.node as YamlMap)
const taskSpecNode = findNodeAndKeyByKeyValue<YamlNode, YamlSequence>('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;
Expand Down
10 changes: 8 additions & 2 deletions src/model/pipeline/task-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,6 +23,12 @@ export class EmbeddedTask extends NodeTknElement {
// private _sidecars: TknArray<Sidecar>;
// private _workspaces: TknArray<WorkspaceDeclaration>;
private _results: TknArray<TaskResult>;
keyNode: TknElement

constructor(parent: TknElement, keyNode: YamlNode, node: YamlSequence) {
super(parent, node);
this.keyNode = new TknKeyElement(parent, keyNode);
}

get results(): TknArray<TaskResult> {
if (!this._results) {
Expand Down
18 changes: 18 additions & 0 deletions src/util/tekton-vfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,24 @@ export class TektonVFSProvider implements FileSystemProvider {
};
}

async saveTektonDocument(doc: VirtualDocument): Promise<void | string> {
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();
Expand Down
185 changes: 156 additions & 29 deletions src/yaml-support/tkn-code-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,60 +18,64 @@ 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;
taskKind?: string;
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<vscode.CodeAction[]> {
const result = [];
const tknDocs = yamlLocator.getTknDocuments(document);
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<vscode.CodeAction> {
return vscode.window.withProgress({location: vscode.ProgressLocation.Notification, cancellable: false, title: `Loading '${codeAction.taskRefName}' Task...` }, async (): Promise<vscode.CodeAction> => {
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<vscode.CodeAction> {
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],
}
}

Expand All @@ -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<InlineTaskAction> {
return vscode.window.withProgress({location: vscode.ProgressLocation.Notification, cancellable: false, title: `Loading '${codeAction.taskRefName}' Task...` }, async (): Promise<vscode.CodeAction> => {
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<vscode.CodeAction> {
return vscode.window.withProgress({location: vscode.ProgressLocation.Notification, cancellable: false, title: 'Extracting Task...' }, async (): Promise<vscode.CodeAction> => {
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){
Expand Down
35 changes: 31 additions & 4 deletions test/text-document-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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.');
Expand Down
44 changes: 44 additions & 0 deletions test/yaml-support/extract-task-pipeline.yaml
Original file line number Diff line number Diff line change
@@ -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!"
Loading

0 comments on commit a14e7e1

Please sign in to comment.