Tasks plugin (#3013)

* Create Tasks model

* Run custom task

* Create tasks fixture test

* Add task options

* Rename load arguments for Tasks

* Foprmat code

* WIP: Change tasks to work with multiple files

* Run task without options

* Parse options with regex

* Remove optional and required options

* Move Task model to ProjectAutomation

* WIP: Editing tasks

* Editing tasks

* Add task documentation

* WIP tests

* Add tests

* Format code

* Rename task command to exec

* Create PluginTasks

* Add tasks plugin fixture

* Add tests

* Add documentation

* Format code

* Edit CHANGELOG

* Fix typo

* Update projects/docs/docs/plugins/creating-plugins.md

Co-authored-by: Luis Padron <luis.padron@compass.com>

Co-authored-by: Luis Padron <luis.padron@compass.com>
Co-authored-by: Pedro Piñera Buendía <pedro@ppinera.es>
This commit is contained in:
Marek Fořt 2021-06-01 13:09:38 +02:00 committed by GitHub
parent dc664d0ec4
commit e69f70b03f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 292 additions and 21 deletions

View File

@ -104,6 +104,8 @@ jobs:
'scaffold',
'test',
'up',
'graph',
'tasks'
]
steps:
- uses: actions/checkout@v1

View File

@ -6,6 +6,7 @@ Please, check out guidelines: https://keepachangelog.com/en/1.0.0/
### Added
- Add possibility to share tasks via a plugin [#3013](https://github.com/tuist/tuist/pull/3013) by [@fortmarek](https://github.com/fortmarek)
- Add option to `Scaffolding` for copy folder with option `.directory(path: "destinationContainerFolder", sourcePath: "sourceFolder")`. [#2985](https://github.com/tuist/tuist/pull/2985) by [@santi-d](https://github.com/santi-d)
- Add possibility to specify version of Swift in the `Config.swift` manifest file. [#2998](https://github.com/tuist/tuist/pull/2998) by [@laxmorek](https://github.com/laxmorek)
- Add `tuist run` command which allows running schemes of a project. [#2917](https://github.com/tuist/tuist/pull/2917) by [@luispadron](https://github.com/luispadron)

View File

@ -9,7 +9,7 @@ public struct Task {
}
public init(
options: [Option],
options: [Option] = [],
task: @escaping ([String: String]) throws -> Void
) {
self.options = options

View File

@ -0,0 +1,18 @@
import Foundation
import TSCBasic
/// Tasks plugin model
public struct PluginTasks: Equatable {
/// Name of the plugin.
public let name: String
/// Path to `Tasks` directory where all tasks are located.
public let path: AbsolutePath
public init(
name: String,
path: AbsolutePath
) {
self.name = name
self.path = path
}
}

View File

@ -12,6 +12,9 @@ public struct Plugins: Equatable {
/// List of paths pointing to resource templates
public let resourceSynthesizers: [PluginResourceSynthesizer]
/// List of tasks plugins.
public let tasks: [PluginTasks]
/// Creates a `Plugins`.
///
/// - Parameters:
@ -21,17 +24,20 @@ public struct Plugins: Equatable {
public init(
projectDescriptionHelpers: [ProjectDescriptionHelpersPlugin],
templatePaths: [AbsolutePath],
resourceSynthesizers: [PluginResourceSynthesizer]
resourceSynthesizers: [PluginResourceSynthesizer],
tasks: [PluginTasks]
) {
self.projectDescriptionHelpers = projectDescriptionHelpers
templateDirectories = templatePaths
self.resourceSynthesizers = resourceSynthesizers
self.tasks = tasks
}
/// An empty `Plugins`.
public static let none: Plugins = .init(
projectDescriptionHelpers: [],
templatePaths: [],
resourceSynthesizers: []
resourceSynthesizers: [],
tasks: []
)
}

View File

@ -6,12 +6,14 @@ public extension Plugins {
static func test(
projectDescriptionHelpers: [ProjectDescriptionHelpersPlugin] = [],
templatePaths: [AbsolutePath] = [],
resourceSynthesizers: [PluginResourceSynthesizer] = []
resourceSynthesizers: [PluginResourceSynthesizer] = [],
tasks: [PluginTasks] = []
) -> Plugins {
Plugins(
projectDescriptionHelpers: projectDescriptionHelpers,
templatePaths: templatePaths,
resourceSynthesizers: resourceSynthesizers
resourceSynthesizers: resourceSynthesizers,
tasks: tasks
)
}
}

View File

@ -29,13 +29,19 @@ enum ExecError: FatalError, Equatable {
struct ExecService {
private let manifestLoader: ManifestLoading
private let tasksLocator: TasksLocating
private let pluginService: PluginServicing
private let configLoader: ConfigLoading
init(
manifestLoader: ManifestLoading = ManifestLoader(),
tasksLocator: TasksLocating = TasksLocator()
tasksLocator: TasksLocating = TasksLocator(),
pluginService: PluginServicing = PluginService(),
configLoader: ConfigLoading = ConfigLoader(manifestLoader: ManifestLoader())
) {
self.manifestLoader = manifestLoader
self.tasksLocator = tasksLocator
self.pluginService = pluginService
self.configLoader = configLoader
}
func run(
@ -88,7 +94,12 @@ struct ExecService {
// MARK: - Helpers
private func task(with name: String, path: AbsolutePath) throws -> AbsolutePath {
let tasks: [String: AbsolutePath] = try tasksLocator.locateTasks(at: path)
let config = try configLoader.loadConfig(path: path)
let plugins = try pluginService.loadPlugins(using: config)
let tasksPaths: [AbsolutePath] = try tasksLocator.locateTasks(at: path)
+ plugins.tasks.map(\.path)
.flatMap(FileHandler.shared.contentsOfDirectory)
let tasks: [String: AbsolutePath] = tasksPaths
.reduce(into: [:]) { acc, current in
acc[current.basenameWithoutExt.camelCaseToKebabCase()] = current
}

View File

@ -91,10 +91,19 @@ public final class PluginService: PluginServicing {
.filter { _, path in FileHandler.shared.exists(path) }
.map(PluginResourceSynthesizer.init)
let tasks = zip(
(localPluginManifests + remotePluginManifests).map(\.name),
pluginPaths
.map { $0.appending(component: Constants.tasksDirectoryName) }
)
.filter { _, path in FileHandler.shared.exists(path) }
.map(PluginTasks.init)
return Plugins(
projectDescriptionHelpers: localProjectDescriptionHelperPlugins + remoteProjectDescriptionHelperPlugins,
templatePaths: templatePaths,
resourceSynthesizers: resourceSynthesizerPlugins
resourceSynthesizers: resourceSynthesizerPlugins,
tasks: tasks
)
}

View File

@ -1,7 +1,9 @@
import Foundation
import TSCBasic
import TuistCore
import TuistGraph
import TuistLoaderTesting
import TuistPluginTesting
import TuistSupport
import TuistSupportTesting
import TuistTasksTesting
@ -12,21 +14,25 @@ import XCTest
final class TaskServiceTests: TuistUnitTestCase {
private var manifestLoader: MockManifestLoader!
private var tasksLocator: MockTasksLocator!
private var pluginService: MockPluginService!
private var subject: ExecService!
override func setUp() {
super.setUp()
manifestLoader = MockManifestLoader()
tasksLocator = MockTasksLocator()
pluginService = MockPluginService()
subject = ExecService(
manifestLoader: manifestLoader,
tasksLocator: tasksLocator
tasksLocator: tasksLocator,
pluginService: pluginService
)
}
override func tearDown() {
manifestLoader = nil
tasksLocator = nil
pluginService = nil
subject = nil
super.tearDown()
@ -90,17 +96,7 @@ final class TaskServiceTests: TuistUnitTestCase {
let path = try temporaryPath()
let taskPath = path.appending(component: "TaskA.swift")
try fileHandler.write(
"""
import ProjectAutomation
import Foundation
let task = Task(
options: [.option("option-a"), .option("option-b")]
) { options in
// some task code
}
""",
Self.taskContent,
path: taskPath,
atomically: true
)
@ -216,4 +212,59 @@ final class TaskServiceTests: TuistUnitTestCase {
)
)
}
func test_run_when_task_from_plugin() throws {
// Given
let path = try temporaryPath()
let tasksDirectory = path.appending(component: "TaskPlugins")
try fileHandler.createFolder(tasksDirectory)
let taskPath = tasksDirectory.appending(component: "TaskA.swift")
try fileHandler.write(
Self.taskContent,
path: taskPath,
atomically: true
)
pluginService.loadPluginsStub = { _ in
Plugins(
projectDescriptionHelpers: [],
templatePaths: [],
resourceSynthesizers: [],
tasks: [
PluginTasks(name: "Plugins", path: tasksDirectory),
]
)
}
manifestLoader.taskLoadArgumentsStub = {
[
"load",
$0.pathString,
]
}
system.succeedCommand(
"load",
taskPath.pathString,
"--tuist-task",
"{}"
)
// When / Then
XCTAssertNoThrow(
try subject.run(
"task-a",
options: [:],
path: path.pathString
)
)
}
private static let taskContent = """
import ProjectAutomation
import Foundation
let task = Task(
options: [.option("option-a"), .option("option-b")]
) { options in
// some task code
}
"""
}

View File

@ -165,6 +165,67 @@ final class PluginServiceTests: TuistTestCase {
XCTAssertEqual(plugins, expectedPlugins)
}
func test_loadPlugins_WHEN_localTasks() throws {
// Given
let pluginPath = try temporaryPath()
let pluginName = "TestPlugin"
let tasksPath = pluginPath.appending(components: Constants.tasksDirectoryName)
try makeDirectories(tasksPath)
// When
manifestLoader.loadConfigStub = { _ in
.test(plugins: [.local(path: .relativeToRoot(pluginPath.pathString))])
}
manifestLoader.loadPluginStub = { _ in
ProjectDescription.Plugin(name: pluginName)
}
let config = mockConfig(plugins: [TuistGraph.PluginLocation.local(path: pluginPath.pathString)])
// Then
let plugins = try subject.loadPlugins(using: config)
let expectedPlugins = Plugins.test(
tasks: [
PluginTasks(name: pluginName, path: tasksPath),
]
)
XCTAssertEqual(plugins, expectedPlugins)
}
func test_loadPlugins_WHEN_gitTasks() throws {
// Given
let pluginGitUrl = "https://url/to/repo.git"
let pluginGitId = "1.0.0"
let pluginFingerprint = "\(pluginGitUrl)-\(pluginGitId)".md5
let cachedPluginPath = cacheDirectoriesProvider.pluginCacheDirectory.appending(components: pluginFingerprint)
let pluginName = "TestPlugin"
let tasksPath = cachedPluginPath.appending(components: Constants.tasksDirectoryName)
try makeDirectories(tasksPath)
// When
manifestLoader.loadConfigStub = { _ in
.test(plugins: [ProjectDescription.PluginLocation.git(url: pluginGitUrl, tag: pluginGitId)])
}
manifestLoader.loadPluginStub = { _ in
ProjectDescription.Plugin(name: pluginName)
}
let config = mockConfig(plugins: [TuistGraph.PluginLocation.gitWithTag(url: pluginGitUrl, tag: pluginGitId)])
// Then
let plugins = try subject.loadPlugins(using: config)
let expectedPlugins = Plugins.test(
tasks: [
PluginTasks(name: pluginName, path: tasksPath),
]
)
XCTAssertEqual(plugins, expectedPlugins)
}
func test_loadPlugins_WHEN_localTemplate() throws {
// Given
let pluginPath = try temporaryPath()

View File

@ -53,6 +53,22 @@ In order for Tuist to locate the templates for a plugin, they must be placed in
└── ...
```
### Tasks
Tasks serve as automation files written in Swift that can be easily edited in Xcode. Similarly to templates, Tuist finds all tasks defined in `Tasks`.
For example, if you wanted to create a plugin task for releasing apps, you can create a file called `ReleaseApp.swift` in the `Tasks` directory of your plugin.
To read more about how you can define tasks themselves, head over to the [Tasks documentation](/commands/task).
```
.
├── ...
├── Plugin.swift
├── Tasks
├───── ReleaseApp.swift
├───── ...
└── ...
```
## ResourceSynthesizers
ResourceSynthesizer plugins are for sharing & reusing templates for [synthesizing resources](/guides/resources/). If you want to use one of the predefined resource synthesizers, the template must also adhere to a specific naming.

View File

@ -77,6 +77,11 @@ let config = Config(
)
```
#### Tasks
To use a task plugin, simply import the plugin in `Config.swift` and it will be automatically available by running `tuist exec my-plugin-task`.
You can read more about tasks [here](/commands/task).
#### Project description helpers
You can import a project description helper plugin with the name defined in the [`Plugin.swift`](/plugins/creating-plugins/) manifest, which can then be used in a project manfiest:

View File

@ -7,4 +7,10 @@ Feature: Run tasks
Then content of a file named file.txt should be equal to File created with a task
Then tuist runs a task create-file with attribute file-name as custom-file
Then content of a file named custom-file.txt should be equal to File created with a task
Scenario: The project is an application with plugins (app_with_plugins)
Given that tuist is available
And I have a working directory
Then I copy the fixture app_with_plugins into the working directory
Then tuist runs a task create-file
Then content of a file named file.txt should be equal to File created with a plugin

View File

@ -0,0 +1,72 @@
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Xcode ###
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
### Xcode Patch ###
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcworkspace/contents.xcworkspacedata
/*.gcno
### Projects ###
*.xcodeproj
*.xcworkspace
### Tuist derived files ###
graph.dot
Derived/
### Tuist managed dependencies ###
Tuist/Dependencies
*.txt

View File

@ -0,0 +1,11 @@
import ProjectAutomation
import Foundation
let task = Task() { options in
try "File created with a plugin".write(
to: URL(fileURLWithPath: "plugin-file.txt"),
atomically: true,
encoding: .utf8
)
print("File created with a plugin!")
}