├── src ├── test │ ├── resources │ │ ├── fake_work_config │ │ └── test_work_log │ └── java │ │ └── com │ │ └── wclausen │ │ └── work │ │ ├── fake │ │ ├── TestResources.kt │ │ ├── FakeWorkStateManager.kt │ │ ├── FakeCommandWorkflow.kt │ │ ├── FakeConfigManager.kt │ │ ├── FakeJiraService.kt │ │ └── FakeGitService.kt │ │ ├── command │ │ ├── base │ │ │ └── MainWorkflowTest.kt │ │ ├── init │ │ │ ├── InitWorkflowTest.kt │ │ │ └── CreateConfigWorkflowTest.kt │ │ ├── commit │ │ │ └── CommitWorkflowTest.kt │ │ ├── comment │ │ │ └── CommentWorkflowTest.kt │ │ └── start │ │ │ └── StartWorkflowTest.kt │ │ ├── workflowext │ │ └── WorkflowExtensions.kt │ │ ├── config │ │ └── RealConfigManagerTest.kt │ │ └── base │ │ └── RealWorkStateManagerTest.kt └── main │ └── java │ └── com │ └── wclausen │ └── work │ ├── jira │ ├── model │ │ ├── IssueComment.kt │ │ ├── IssueData.kt │ │ ├── JiraProjectId.kt │ │ ├── JiraComment.kt │ │ ├── IssueResponse.kt │ │ ├── PaginatedUsers.kt │ │ ├── IssueFields.kt │ │ ├── JiraUser.kt │ │ ├── JqlSearchResult.kt │ │ └── JiraIssueType.kt │ ├── JiraService.kt │ └── JiraClient.kt │ ├── config │ ├── Config.kt │ ├── ConfigInfo.kt │ ├── ConfigFileInfo.kt │ └── ConfigManager.kt │ ├── kotlinext │ └── Do.kt │ ├── Main.kt │ ├── inject │ ├── Qualifiers.kt │ └── AppComponent.kt │ ├── task │ └── TaskManager.kt │ ├── command │ ├── base │ │ ├── CommandWorkflow.kt │ │ ├── WorkCommand.kt │ │ ├── Command.kt │ │ ├── CommandOutputWorkflow.kt │ │ └── MainWorkflow.kt │ ├── diff │ │ └── DiffCommand.kt │ ├── start │ │ ├── StartCommand.kt │ │ └── StartWorkflow.kt │ ├── done │ │ └── DoneCommand.kt │ ├── comment │ │ ├── CommentCommand.kt │ │ └── CommentWorkflow.kt │ ├── init │ │ ├── InitCommand.kt │ │ ├── NoOpCommandWorkflow.kt │ │ ├── InitWorkflow.kt │ │ └── CreateConfigWorkflow.kt │ ├── update │ │ ├── UpdateCommand.kt │ │ └── UpdateWorkflow.kt │ └── commit │ │ ├── CommitCommand.kt │ │ └── CommitWorkflow.kt │ ├── git │ ├── GitService.kt │ └── RealGitService.kt │ └── base │ ├── WorkState.kt │ ├── WorkUpdate.kt │ ├── MainCommandOutputWorkflowRunner.kt │ ├── CommandOutputWorkflowRunner.kt │ ├── CommandWorkflowRunner.kt │ └── WorkStateManager.kt ├── gradle.properties ├── settings.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .idea ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── vcs.xml ├── compiler.xml ├── .gitignore ├── misc.xml ├── gradle.xml ├── jarRepositories.xml └── uiDesigner.xml ├── README.md ├── .gitignore ├── gradlew.bat └── gradlew /src/test/resources/fake_work_config: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'work-cli' 2 | 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wclausen/work-cli/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/test/resources/test_work_log: -------------------------------------------------------------------------------- 1 | {"update":"Started a new task","taskId":"TEST-1","taskDescription":"This is a test","goal":"Done when done"} 2 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/jira/model/IssueComment.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.jira.api.model 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | data class IssueComment(val body: String) 7 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/jira/model/IssueData.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.jira.api.model 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | data class IssueData(val fields: IssueFields) 7 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/jira/model/JiraProjectId.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.jira.api.model 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | data class JiraProjectId(val key: String) 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/jira/model/JiraComment.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.jira.api.model 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | data class JiraComment( 7 | val self: String, 8 | val id: String 9 | ) 10 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/jira/model/IssueResponse.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.jira.api.model 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | data class IssueResponse( 7 | val id: String, 8 | val key: String 9 | ) 10 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/jira/model/PaginatedUsers.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.jira.api.model 2 | 3 | import com.squareup.moshi.JsonClass 4 | import com.wclausen.work.jira.api.model.JiraUser 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class PaginatedUsers( 8 | val values: List) 9 | -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/fake/TestResources.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.fake 2 | 3 | import java.nio.file.Paths 4 | 5 | object TestResources { 6 | val fakeConfigPath = Paths.get(javaClass.getResource("/fake_work_config").toURI()) 7 | val fakeLogPath = Paths.get(javaClass.getResource("/test_work_log").toURI()) 8 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/config/Config.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.config 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | data class Config(val jira: JiraConfig) 7 | 8 | @JsonClass(generateAdapter = true) 9 | data class JiraConfig(val jira_email: String, val jira_api_token: String) -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/jira/model/IssueFields.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.jira.api.model 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | data class IssueFields( 7 | val summary: String, 8 | val project: JiraProjectId, 9 | val issuetype: JiraIssueType 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/jira/model/JiraUser.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.jira.api.model 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | data class JiraUser( 7 | val self: String, 8 | val key: String?, 9 | val accountId: String, 10 | val emailAddress: String) 11 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/kotlinext/Do.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.kotlinext 2 | 3 | /** 4 | * Enables forcing `when` statements to be exhaustive by turning them into expressions 5 | * 6 | * Usage: 7 | * when ($sealedClassVar) {...} 8 | * becomes 9 | * Do exhaustive when ($sealedClassVar) {...} 10 | */ 11 | object Do { 12 | infix fun exhaustive(any: Any?) { 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/Main.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work 2 | 3 | import com.wclausen.work.inject.DaggerAppComponent 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.FlowPreview 6 | 7 | val appComponent = DaggerAppComponent.create() 8 | 9 | @ExperimentalCoroutinesApi 10 | @FlowPreview 11 | fun main(args: Array) = 12 | appComponent.workCommand.main(args) 13 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/inject/Qualifiers.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.inject 2 | 3 | import javax.inject.Qualifier 4 | 5 | @Qualifier 6 | annotation class StartCommandRunner 7 | 8 | @Qualifier 9 | annotation class InitCommandRunner 10 | 11 | @Qualifier 12 | annotation class CommentCommandRunner 13 | 14 | @Qualifier 15 | annotation class CommitCommandRunner 16 | 17 | @Qualifier 18 | annotation class ConfigFile 19 | 20 | @Qualifier 21 | annotation class WorkLogFile -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/task/TaskManager.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.task 2 | 3 | import javax.inject.Inject 4 | 5 | interface TaskManager { 6 | fun hasCurrentTask(): Boolean 7 | 8 | fun getCurrentTask(): String? 9 | } 10 | 11 | class RealTaskManager @Inject constructor() : TaskManager { 12 | override fun hasCurrentTask(): Boolean { 13 | return false 14 | } 15 | 16 | override fun getCurrentTask(): String? { 17 | return "WORK-1" 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/base/CommandWorkflow.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.base 2 | 3 | import com.github.michaelbull.result.Result 4 | import com.squareup.workflow.StatefulWorkflow 5 | import com.squareup.workflow.Workflow 6 | 7 | interface CommandWorkflow> : Workflow 8 | 9 | abstract class StatefulCommandWorkflow> : CommandWorkflow, StatefulWorkflow() 10 | 11 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/diff/DiffCommand.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.diff 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | 5 | /** 6 | * Command to finish working on a task. By default 7 | * - runs arc diff 8 | * - adds comment to jira issue with link to created diff 9 | * - changes status of jira issue to "in review" 10 | * 11 | * Usage: $ work diff 12 | */ 13 | class DiffCommand : CliktCommand(name = "diff"){ 14 | override fun run() { 15 | // TODO: implement diff functionality 16 | } 17 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/start/StartCommand.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.start 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | import com.wclausen.work.base.MainCommandOutputWorkflowRunner 5 | import com.wclausen.work.inject.StartCommandRunner 6 | import javax.inject.Inject 7 | 8 | class StartCommand @Inject constructor(@StartCommandRunner private val workflowRunner: MainCommandOutputWorkflowRunner) : 9 | CliktCommand(name = "start") { 10 | 11 | override fun run() { 12 | workflowRunner.run() 13 | } 14 | 15 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/base/WorkCommand.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.base 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | 5 | /** 6 | * Base command for running any top-level initialization needed by other commands 7 | */ 8 | class WorkCommand : CliktCommand(name = "work") { 9 | 10 | /** This will be executed prior to delegation to other command classes. 11 | * Could be a good place to initialize object graphs/determine high-level 12 | * state i.e. [WorkflowState] 13 | */ 14 | override fun run() { 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/done/DoneCommand.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.done 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | 5 | /** 6 | * Command to finish working on a task. By default 7 | * - prompts for final comment of jira issue 8 | * - adds comment to issue 9 | * - updates issue status to "done" 10 | * - checks out master and pulls from origin 11 | * 12 | * Usage: $ work done 13 | */ 14 | class DoneCommand : CliktCommand(name = "done"){ 15 | override fun run() { 16 | // TODO: implement done functionality 17 | } 18 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/jira/model/JqlSearchResult.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.jira.api.model 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | data class JqlSearchResult(val issues: List) 7 | 8 | @JsonClass(generateAdapter = true) 9 | data class IssueBean( 10 | val id: String, 11 | val self: String, 12 | val key: String, 13 | val fields: JqlSearchResultIssueFields) 14 | 15 | @JsonClass(generateAdapter = true) 16 | data class JqlSearchResultIssueFields( 17 | val summary: String, 18 | val description: String? 19 | ) 20 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/git/GitService.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.git 2 | 3 | import com.github.michaelbull.result.Result 4 | import org.eclipse.jgit.lib.Ref 5 | import org.eclipse.jgit.revwalk.RevCommit 6 | 7 | interface GitService { 8 | 9 | suspend fun checkout(branchName: String): Result 10 | 11 | fun commitProgress(message: String): Result 12 | 13 | sealed class GitError(message: String, cause: Throwable) : Throwable(cause){ 14 | class CheckoutFailedError(cause: Throwable) : GitError("Failed to checkout branch", cause) 15 | class CommitFailedError(cause: Throwable) : GitError("Failed to commit to git", cause) 16 | } 17 | 18 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/config/ConfigInfo.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.config 2 | 3 | import java.io.File 4 | import java.nio.file.Path 5 | import kotlin.reflect.KClass 6 | 7 | /** 8 | * Class that enables creation of multiple types of configs 9 | * 10 | * Currently, there's only one type of com.wclausen.work.config, a global [Config] for the CLI, but 11 | * it seemed possible that the CLI could manage multiple types of configs (like a com.wclausen.work.config 12 | * for Jira info and a com.wclausen.work.config for Git repo info), so this class is used to support 13 | * serializing/deserializing configs of multiple types. 14 | */ 15 | data class ConfigInfo(val configFile: File, val configClazz: KClass) 16 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/jira/model/JiraIssueType.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.jira.api.model 2 | 3 | import com.squareup.moshi.FromJson 4 | import com.squareup.moshi.JsonClass 5 | import com.squareup.moshi.ToJson 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class JiraIssueType(val id: JiraIssueTypeId) 9 | 10 | enum class JiraIssueTypeId(val id: String) { 11 | TASK("10002") 12 | } 13 | 14 | class JiraIssueTypeIdAdapter { 15 | @ToJson 16 | fun toJson(jiraIssueTypeId: JiraIssueTypeId): String { 17 | return jiraIssueTypeId.id 18 | } 19 | 20 | @FromJson 21 | fun fromJson(json: String): JiraIssueTypeId { 22 | return JiraIssueTypeId.values() 23 | .first { it.id == json } 24 | } 25 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/base/Command.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.base 2 | 3 | import com.wclausen.work.config.Config 4 | 5 | /** 6 | * Class that encapsulates types of commands that are issued by [CommandWorkflow] instances to be executed by the terminal 7 | */ 8 | sealed class Command { 9 | 10 | data class Prompt(val prompt: String, val nextAction: ((String) -> Unit) = {}) : Command() 11 | 12 | data class Echo(val output: String) : Command() 13 | 14 | data class ExecuteCommand( 15 | val args: Array, 16 | val config: Config, 17 | val onResult: ((Int) -> Unit)?) : Command() { 18 | } 19 | 20 | data class MultipleCommands(val commands: List) : Command() 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/comment/CommentCommand.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.comment 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | import com.wclausen.work.base.MainCommandOutputWorkflowRunner 5 | import com.wclausen.work.inject.CommentCommandRunner 6 | import javax.inject.Inject 7 | 8 | /** 9 | * Adds a comment to the Jira issue currently being worked on 10 | * 11 | * Usage: $ work comment -m {message} 12 | * 13 | */ 14 | class CommentCommand @Inject constructor(@CommentCommandRunner private val commentWorkflowRunner: MainCommandOutputWorkflowRunner) : 15 | CliktCommand(name = "comment") { 16 | 17 | // TODO: implement support for -m argument 18 | 19 | override fun run() { 20 | commentWorkflowRunner.run() 21 | } 22 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/init/InitCommand.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.init 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | import com.wclausen.work.base.MainCommandOutputWorkflowRunner 5 | import com.wclausen.work.inject.InitCommandRunner 6 | import javax.inject.Inject 7 | 8 | typealias CreateConfigWorkflowRunner = MainCommandOutputWorkflowRunner 9 | 10 | /** 11 | * Command to initialize CLI tool. 12 | * 13 | * Usage: $ work init 14 | * 15 | * Prompts for jira credentials (email/api token) and git directory info 16 | */ 17 | class InitCommand @Inject constructor( 18 | @InitCommandRunner private val workflowRunner: CreateConfigWorkflowRunner 19 | ) : CliktCommand(name = "init") { 20 | 21 | override fun run() { 22 | workflowRunner.run() 23 | } 24 | } -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # work-cli (WIP) 2 | Repo for CLI to streamline workflows with jira + git by combining related actions into a single tool 3 | 4 | Commands will include: 5 | - `$ work init` (specify config info like jira username/api token, git directory info) 6 | - `$ work start` (start a jira task and checkout a branch with the task key) 7 | - `$ work comment` (add comment to current jira issue) 8 | - `$ work commit` (commit in progress work) 9 | - `$ work update` (combination of comment and commit) 10 | - `$ work diff` (wrapper for arc diff, updates jira command with link to diff and changes issue status to "in review") 11 | - `$ work done` (optionally provide final comment on issue, changes issue status to "done") 12 | 13 | This tool is built in Kotlin using the Clikt library for command line parsing and Square's Workflow library for driving the underlying logic. 14 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/config/ConfigFileInfo.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.config 2 | 3 | import java.nio.file.Paths 4 | 5 | object ConfigFileInfo { 6 | val configDirectoryName = System.getProperty("user.home") + "/.workflow/" 7 | val configFileName = "workflow.properties" 8 | 9 | val configDirectoryPath = Paths.get(configDirectoryName) 10 | val configFilePath = Paths.get(configDirectoryName + configFileName) 11 | 12 | val configFile = configFilePath.toFile() 13 | } 14 | 15 | object WorkLogFileInfo { 16 | val workLogDirectoryName = System.getProperty("user.home") + "/.workflow/" 17 | val workLogFileName = "work_log" 18 | 19 | val workLogDirectoryPath = Paths.get(workLogDirectoryName) 20 | val workLogFilePath = Paths.get(workLogDirectoryName + workLogFileName) 21 | 22 | val workLogFile = workLogFilePath.toFile() 23 | } -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/fake/FakeWorkStateManager.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.fake 2 | 3 | import com.github.michaelbull.result.Ok 4 | import com.github.michaelbull.result.Result 5 | import com.wclausen.work.base.WorkState 6 | import com.wclausen.work.base.WorkStateManager 7 | import com.wclausen.work.base.WorkUpdate 8 | 9 | class FakeWorkStateManager : WorkStateManager { 10 | 11 | var currentWorkState = Ok(WorkState.Executing("TEST-1")) 12 | var logUpdateResult = Ok(Unit) 13 | var lastLogUpdate: WorkUpdate? = null 14 | 15 | override fun getWorkflowState(): Result { 16 | return currentWorkState 17 | } 18 | 19 | override fun writeUpdateToLog(update: WorkUpdate): Result { 20 | lastLogUpdate = update 21 | return logUpdateResult 22 | } 23 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/base/WorkState.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.base 2 | 3 | /** 4 | * Meant to encapsulate the highest level of state in the system. 5 | * 6 | * This state determines what commands are valid at startup. For example, if a user tries to run 7 | * `work start` from an [WorkState.Uninitialized] then they should be prompted to first 8 | * initialize the CLI tool (provide jira creds, specify git repo, etc). 9 | * 10 | * TODO: actually use this state to determine valid actions, persist across invocations of CLI 11 | * 12 | */ 13 | sealed class WorkState() { 14 | 15 | object Uninitialized : WorkState() 16 | 17 | object Waiting : WorkState() 18 | 19 | data class Executing(val taskId: String, val goal: String) : WorkState() 20 | 21 | } 22 | 23 | fun WorkState.requireExecuting(): WorkState.Executing { 24 | return this as WorkState.Executing 25 | } -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 13 | 14 | 19 | 20 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/init/NoOpCommandWorkflow.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.init 2 | 3 | import com.squareup.workflow.RenderContext 4 | import com.squareup.workflow.Snapshot 5 | import com.wclausen.work.base.WorkState 6 | import com.wclausen.work.command.base.CommandOutputWorkflow 7 | import com.wclausen.work.command.base.Output 8 | 9 | /** 10 | * Workflow that does nothing. 11 | * 12 | * This class exists for use by the InitCommand workflow runner. The runner performs initialization checks 13 | * automatically, so the init command itself doesn't need to do any additional work, hence a NoOp command. 14 | */ 15 | class NoOpCommandWorkflow : CommandOutputWorkflow() { 16 | override fun initialState(props: WorkState, snapshot: Snapshot?) = Unit 17 | 18 | override fun render(props: WorkState, state: Unit, context: RenderContext>) = 19 | Unit 20 | 21 | override fun snapshotState(state: Unit) = Snapshot.EMPTY 22 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/java,gradle 3 | # Edit at https://www.gitignore.io/?templates=java,gradle 4 | 5 | ### Java ### 6 | # Compiled class file 7 | *.class 8 | 9 | # Log file 10 | *.log 11 | 12 | # BlueJ files 13 | *.ctxt 14 | 15 | # Mobile Tools for Java (J2ME) 16 | .mtj.tmp/ 17 | 18 | # Package Files # 19 | *.jar 20 | *.war 21 | *.nar 22 | *.ear 23 | *.zip 24 | *.tar.gz 25 | *.rar 26 | 27 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 28 | hs_err_pid* 29 | 30 | ### Gradle ### 31 | .gradle 32 | build/ 33 | 34 | # Ignore Gradle GUI config 35 | gradle-app.setting 36 | 37 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 38 | !gradle-wrapper.jar 39 | 40 | # Cache of project 41 | .gradletasknamecache 42 | 43 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 44 | # gradle/wrapper/gradle-wrapper.properties 45 | 46 | ### Gradle Patch ### 47 | **/build/ 48 | 49 | # End of https://www.gitignore.io/api/java,gradle 50 | -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/fake/FakeCommandWorkflow.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.fake 2 | 3 | import com.squareup.workflow.RenderContext 4 | import com.squareup.workflow.Snapshot 5 | import com.squareup.workflow.Worker 6 | import com.squareup.workflow.action 7 | import com.wclausen.work.base.WorkState 8 | import com.wclausen.work.command.base.Command 9 | import com.wclausen.work.command.base.CommandOutputWorkflow 10 | import com.wclausen.work.command.base.Output 11 | 12 | class FakeCommandWorkflow : CommandOutputWorkflow() { 13 | override fun initialState(props: WorkState, snapshot: Snapshot?) { 14 | } 15 | 16 | override fun render(props: WorkState, state: Unit, context: RenderContext>) { 17 | context.runningWorker(Worker.from { 18 | Unit 19 | }) { 20 | action { 21 | setOutput(Output.InProgress(Command.Echo("Running command..."))) 22 | } 23 | } 24 | } 25 | 26 | override fun snapshotState(state: Unit): Snapshot { 27 | return Snapshot.EMPTY 28 | } 29 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/jira/JiraService.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.jira 2 | 3 | import com.wclausen.work.jira.api.model.IssueComment 4 | import com.wclausen.work.jira.api.model.IssueData 5 | import com.wclausen.work.jira.api.model.IssueResponse 6 | import com.wclausen.work.jira.api.model.JiraComment 7 | import com.wclausen.work.jira.api.model.JiraUser 8 | import com.wclausen.work.jira.api.model.JqlSearchResult 9 | import retrofit2.http.* 10 | 11 | interface JiraService { 12 | @POST("issue") 13 | suspend fun createIssue(@Body data: IssueData): IssueResponse 14 | 15 | @GET("myself") 16 | suspend fun getCurrentUser(): JiraUser 17 | 18 | // TODO: remove default value for jql when the assignee field is read from config 19 | @GET("search") 20 | suspend fun getTasksForCurrentUser( 21 | @Query("fields") fields: Array = arrayOf("summary", "description", "project"), 22 | @Query("jql") jql: String = ""): JqlSearchResult 23 | 24 | @POST("issue/{id}/comment") 25 | suspend fun commentOnIssue(@Path("id") id: String, @Body comment: IssueComment): JiraComment 26 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/git/RealGitService.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.git 2 | 3 | import com.github.michaelbull.result.Result 4 | import com.github.michaelbull.result.mapError 5 | import com.github.michaelbull.result.runCatching 6 | import org.eclipse.jgit.api.Git 7 | import org.eclipse.jgit.lib.Ref 8 | import org.eclipse.jgit.revwalk.RevCommit 9 | 10 | class RealGitService(private val gitClient: Git) : GitService { 11 | 12 | override suspend fun checkout(branchName: String): Result = 13 | runCatching { 14 | val branchAlreadyExists = 15 | gitClient.branchList().call().any { it.name.contains(branchName) } 16 | gitClient.checkout().setName(branchName).setCreateBranch(!branchAlreadyExists).call() 17 | }.mapError { GitService.GitError.CheckoutFailedError(it) } 18 | 19 | override fun commitProgress(message: String): Result = 20 | runCatching { 21 | gitClient.commit().setAll(true).setMessage(message).call() 22 | }.mapError { GitService.GitError.CommitFailedError(it) } 23 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/update/UpdateCommand.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.update 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | import com.wclausen.work.base.CommandWorkflowRunner 5 | import com.wclausen.work.git.RealGitService 6 | import com.wclausen.work.jira.realJiraService 7 | import com.wclausen.work.task.RealTaskManager 8 | import kotlinx.coroutines.channels.ConflatedBroadcastChannel 9 | import org.eclipse.jgit.api.Git 10 | import org.eclipse.jgit.lib.RepositoryBuilder 11 | import java.io.File 12 | 13 | class UpdateCommand : CliktCommand(name = "update") { 14 | override fun run() { 15 | val workingDir = File("/Users/wclausen/code/git_messaround") 16 | val repo = RepositoryBuilder() 17 | .findGitDir(workingDir) 18 | .build() 19 | val currentTask = RealTaskManager().getCurrentTask()!! 20 | println( 21 | CommandWorkflowRunner( 22 | ConflatedBroadcastChannel(Unit), 23 | UpdateWorkflow( 24 | currentTask, realJiraService, RealGitService(Git(repo)) 25 | ) 26 | ).run()) 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/base/WorkUpdate.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.base 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | sealed class WorkUpdate(@Json(name = "type") val updateType: UpdateType) { 7 | @JsonClass(generateAdapter = true) 8 | data class Start(val taskId: String, val taskDescription: String, val goal: String) : 9 | WorkUpdate(UpdateType.START_NEW_TASK) 10 | 11 | @JsonClass(generateAdapter = true) 12 | data class JiraComment(val taskId: String, val comment: String, val goal: String) : 13 | WorkUpdate(UpdateType.JIRA_COMMENT) 14 | 15 | @JsonClass(generateAdapter = true) 16 | data class GitCommit(val taskId: String, val message: String, val goal: String) : 17 | WorkUpdate(UpdateType.GIT_COMMIT) 18 | 19 | @JsonClass(generateAdapter = true) 20 | data class FinishedTask(val taskId: String) : WorkUpdate(UpdateType.FINISHED_TASK) 21 | 22 | } 23 | 24 | enum class UpdateType(val message: String) { 25 | START_NEW_TASK("Started a new task"), JIRA_COMMENT("Added jira update"), GIT_COMMIT("Commited to git"), FINISHED_TASK( 26 | "Finished task" 27 | ) 28 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/base/MainCommandOutputWorkflowRunner.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.base 2 | 3 | import com.wclausen.work.command.base.ExitCode 4 | import com.wclausen.work.command.base.MainWorkflow 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.channels.ConflatedBroadcastChannel 7 | 8 | /** 9 | * Class that runs Command workflows that issue commands through the Workflow's OutputT. 10 | * 11 | * This contrasts with [CommandWorkflowRunner] where running Workflows issue commands through 12 | * RenderingT 13 | * 14 | * The reason for adding this class and changing the way Commands are issued is that it *should* 15 | * make it easier to compose workflows if they issue Commands through outputs rather than 16 | * renderings. This goes back to the fact that the UI framework for the application (the terminal) 17 | * is not declarative, which breaks the normal expectations of the render loop. 18 | * 19 | */ 20 | @ExperimentalCoroutinesApi 21 | class MainCommandOutputWorkflowRunner constructor( 22 | mainWorkflow: MainWorkflow<*> 23 | ) : CommandOutputWorkflowRunner( 24 | ConflatedBroadcastChannel(Unit), mainWorkflow 25 | ) 26 | -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/fake/FakeConfigManager.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.fake 2 | 3 | import com.github.michaelbull.result.Ok 4 | import com.github.michaelbull.result.Result 5 | import com.wclausen.work.config.Config 6 | import com.wclausen.work.config.ConfigManager 7 | import com.wclausen.work.config.JiraConfig 8 | import java.nio.file.Path 9 | 10 | class FakeConfigManager(configLocation: Path) : ConfigManager { 11 | 12 | companion object { 13 | val okConfig = Ok( 14 | Config( 15 | JiraConfig( 16 | jira_email = "fake@test.com", jira_api_token = "test_api_token" 17 | ) 18 | ) 19 | ) 20 | } 21 | 22 | override val configLocation: Path = configLocation 23 | var getConfigResult: Result = okConfig 24 | var createConfigResult: Result = okConfig 25 | 26 | override fun createConfig(config: Config): Result { 27 | return createConfigResult 28 | } 29 | 30 | override fun getConfig(): Result { 31 | return getConfigResult 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/base/CommandOutputWorkflow.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.base 2 | 3 | import com.github.michaelbull.result.Result 4 | import com.squareup.workflow.StatefulWorkflow 5 | import com.wclausen.work.base.WorkUpdate 6 | 7 | /** 8 | * Command that captures the possible types of output from Workflows. 9 | * 10 | * A Workflow will either emit a [Command] or a final result as outputs. When the output is a 11 | * [Command], the [CommandOutputWorkflowRunner] will execute it. When the output is a final result, 12 | * it will be handled by the parent workflow and indicates the child has no more work left to do. 13 | */ 14 | sealed class Output { 15 | 16 | data class InProgress(val command: Command) : Output() 17 | 18 | data class Log(val workUpdate: WorkUpdate) : Output() 19 | 20 | data class Final(val result: Result) : Output() 21 | } 22 | 23 | /** 24 | * For simplifying declarations of CommandOutputWorkflows 25 | * 26 | * Note, eventually this is intended to replace the CommandWorkflow and once that happens 27 | * the name of this typealias will change to CommandWorkflow 28 | */ 29 | typealias CommandOutputWorkflow = StatefulWorkflow, Unit> -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/command/base/MainWorkflowTest.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.base 2 | 3 | import com.squareup.workflow.testing.testFromStart 4 | import com.wclausen.work.command.init.CreateConfigWorkflow 5 | import com.wclausen.work.command.init.InitWorkflow 6 | import com.wclausen.work.fake.FakeCommandWorkflow 7 | import com.wclausen.work.fake.FakeConfigManager 8 | import com.wclausen.work.fake.FakeWorkStateManager 9 | import com.wclausen.work.fake.TestResources 10 | import com.wclausen.work.workflowext.assertContainsMessage 11 | import com.wclausen.work.workflowext.first 12 | import org.junit.Rule 13 | import org.junit.Test 14 | import org.junit.rules.TemporaryFolder 15 | 16 | class MainWorkflowTest { 17 | 18 | @get:Rule 19 | val temporaryFolder = TemporaryFolder() 20 | 21 | @Test 22 | fun `GIVEN initial work state of executing WHEN starting workflow THEN runs command`() { 23 | val fakeWorkStateManager = FakeWorkStateManager() 24 | MainWorkflow( 25 | fakeWorkStateManager, 26 | InitWorkflow( 27 | CreateConfigWorkflow( 28 | FakeConfigManager( 29 | temporaryFolder.newFile().toPath() 30 | ) 31 | ), TestResources.fakeLogPath 32 | ), 33 | FakeCommandWorkflow() 34 | ).testFromStart { 35 | first().assertContainsMessage("Running command...") 36 | } 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/commit/CommitCommand.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.commit 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | import com.wclausen.work.base.MainCommandOutputWorkflowRunner 5 | import com.wclausen.work.inject.CommitCommandRunner 6 | import com.wclausen.work.task.RealTaskManager 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.FlowPreview 9 | import org.eclipse.jgit.lib.RepositoryBuilder 10 | import java.io.File 11 | import javax.inject.Inject 12 | 13 | /** 14 | * Commits modified files to Git repo. 15 | * 16 | * Usage: $ work commit -m {message} 17 | * 18 | * Under the hood just runs `git commit -am {message}`. Won't add untracked filesnn 19 | */ 20 | class CommitCommand @Inject constructor(@CommitCommandRunner private val commitWorkflowRunner: MainCommandOutputWorkflowRunner) : 21 | CliktCommand(name = "commit") { 22 | 23 | @FlowPreview 24 | @ExperimentalCoroutinesApi 25 | override fun run() { 26 | commitWorkflowRunner.run() 27 | val workingDir = 28 | File(System.getProperty("user.dir")) // TODO: make this read from com.wclausen.work.config 29 | val repo = RepositoryBuilder().findGitDir(workingDir).build() 30 | val currentTask = RealTaskManager().getCurrentTask()!! 31 | // println( 32 | // CommandWorkflowRunner( 33 | // ConflatedBroadcastChannel(Unit), 34 | // CommitWorkflow(currentTask, RealGitService(Git(repo))) 35 | // ).run()) 36 | } 37 | } -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/command/init/InitWorkflowTest.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.init 2 | 3 | import com.github.michaelbull.result.Err 4 | import com.squareup.workflow.testing.testFromStart 5 | import com.wclausen.work.config.ConfigManager 6 | import com.wclausen.work.fake.FakeConfigManager 7 | import com.wclausen.work.fake.TestResources 8 | import com.wclausen.work.workflowext.assertIsPrompt 9 | import com.wclausen.work.workflowext.first 10 | import org.junit.Rule 11 | import org.junit.Test 12 | import org.junit.rules.TemporaryFolder 13 | 14 | class InitWorkflowTest { 15 | 16 | @get:Rule 17 | val temporaryFolder = TemporaryFolder() 18 | 19 | @Test 20 | fun `GIVEN no config file WHEN starting workflow THEN prompts for config info`() { 21 | testConfigError(ConfigManager.Error.ConfigLoadingError.NoConfigFileError("file not found")) 22 | } 23 | 24 | @Test 25 | fun `GIVEN config read error WHEN starting workflow THEN prompts for config info`() { 26 | testConfigError(ConfigManager.Error.ConfigLoadingError.ParsingFileError("bad json in file")) 27 | } 28 | 29 | private fun testConfigError(configLoadingError: ConfigManager.Error.ConfigLoadingError) { 30 | val configManager = FakeConfigManager(TestResources.fakeConfigPath) 31 | configManager.getConfigResult = Err(configLoadingError) 32 | InitWorkflow( 33 | CreateConfigWorkflow(configManager), TestResources.fakeLogPath 34 | ).testFromStart { 35 | first().assertIsPrompt(CreateConfigWorkflow.GET_USERNAME_PROMPT) 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/jira/JiraClient.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.jira 2 | 3 | import com.github.michaelbull.result.get 4 | import com.github.michaelbull.result.map 5 | import com.squareup.moshi.Moshi 6 | import com.wclausen.work.config.Config 7 | import com.wclausen.work.config.ConfigFileInfo 8 | import com.wclausen.work.config.RealConfigManager 9 | import com.wclausen.work.jira.api.model.JiraIssueTypeIdAdapter 10 | import okhttp3.Interceptor 11 | import okhttp3.OkHttpClient 12 | import okhttp3.Response 13 | import retrofit2.Retrofit 14 | import retrofit2.converter.moshi.MoshiConverterFactory 15 | import java.util.Base64 16 | 17 | class JiraCreds(val username: String, val apiToken: String) { 18 | 19 | // Returns base64 encoded string for "username:jira_api_token" 20 | fun encoded(): String { 21 | return Base64.getEncoder().encodeToString((username + ":" + apiToken).toByteArray()) 22 | } 23 | } 24 | 25 | val interceptor = object : Interceptor { 26 | val creds = RealConfigManager(ConfigFileInfo.configFilePath).getConfig().map { 27 | it.toJiraCreds() 28 | }.get() 29 | 30 | override fun intercept(chain: Interceptor.Chain): Response { 31 | val request = 32 | chain.request().newBuilder().addHeader("Authorization", "Basic " + creds!!.encoded()) 33 | .addHeader("Content-Type", "application/json") 34 | .addHeader("Accept", "application/json").build() 35 | return chain.proceed(request) 36 | } 37 | 38 | } 39 | 40 | private fun Config.toJiraCreds(): JiraCreds { 41 | return JiraCreds(jira.jira_email, jira.jira_api_token) 42 | } 43 | 44 | val httpClient = OkHttpClient.Builder() 45 | // .addInterceptor(HttpLoggingInterceptor().setLevel( HttpLoggingInterceptor.Level.BODY)) 46 | .addInterceptor(interceptor).build() 47 | 48 | val moshi = Moshi.Builder().add(JiraIssueTypeIdAdapter()).build() 49 | 50 | val retrofit = Retrofit.Builder().baseUrl("https://wclausen.atlassian.net/rest/api/2/") 51 | .addConverterFactory(MoshiConverterFactory.create(moshi)).client(httpClient).build() 52 | 53 | val realJiraService = retrofit.create(JiraService::class.java) -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/fake/FakeJiraService.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.fake 2 | 3 | import com.wclausen.work.jira.JiraService 4 | import com.wclausen.work.jira.api.model.IssueBean 5 | import com.wclausen.work.jira.api.model.IssueComment 6 | import com.wclausen.work.jira.api.model.IssueData 7 | import com.wclausen.work.jira.api.model.IssueResponse 8 | import com.wclausen.work.jira.api.model.JiraComment 9 | import com.wclausen.work.jira.api.model.JiraUser 10 | import com.wclausen.work.jira.api.model.JqlSearchResult 11 | import com.wclausen.work.jira.api.model.JqlSearchResultIssueFields 12 | 13 | class FakeJiraService : JiraService { 14 | 15 | var throws = false 16 | var lastCreateIssueData: IssueData? = null 17 | var createIssueResponse: ((IssueData) -> IssueResponse) = { 18 | lastCreateIssueData = it 19 | IssueResponse("10003", "MF-775") 20 | } 21 | 22 | var getTasksForCurrentUserResponse: (() -> JqlSearchResult) = { 23 | maybeThrow() 24 | JqlSearchResult( 25 | listOf( 26 | IssueBean( 27 | "10001", 28 | "user_self", 29 | "WORK-1", 30 | JqlSearchResultIssueFields("Some task summary", "some task description") 31 | ) 32 | ) 33 | ) 34 | } 35 | 36 | private fun maybeThrow() { 37 | if (throws) { 38 | throw Exception() 39 | } 40 | } 41 | 42 | override suspend fun createIssue(data: IssueData) = createIssueResponse.invoke(data) 43 | 44 | override suspend fun getCurrentUser(): JiraUser { 45 | maybeThrow() 46 | return JiraUser( 47 | "user_self", "user_key", "1234567", "some_email@fake.com" 48 | ) 49 | } 50 | 51 | override suspend fun getTasksForCurrentUser(fields: Array, jql: String): JqlSearchResult { 52 | maybeThrow() 53 | return getTasksForCurrentUserResponse.invoke() 54 | } 55 | 56 | override suspend fun commentOnIssue(id: String, comment: IssueComment): JiraComment { 57 | maybeThrow() 58 | return JiraComment("some_url", "some_comment_id") 59 | } 60 | 61 | } -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/fake/FakeGitService.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.fake 2 | 3 | import com.github.michaelbull.result.Err 4 | import com.github.michaelbull.result.Ok 5 | import com.github.michaelbull.result.Result 6 | import com.github.michaelbull.result.mapError 7 | import com.wclausen.work.git.GitService 8 | import org.eclipse.jgit.lib.AnyObjectId 9 | import org.eclipse.jgit.lib.ObjectId 10 | import org.eclipse.jgit.lib.Ref 11 | import org.eclipse.jgit.revwalk.RevCommit 12 | 13 | class FakeGitService : GitService { 14 | 15 | var throws = false 16 | var lastBranchCheckedOut: String? = null 17 | override suspend fun checkout(branchName: String): Result { 18 | lastBranchCheckedOut = branchName 19 | return if (!throws) Ok(FakeRef()) else Err(GitService.GitError.CheckoutFailedError(Exception())) 20 | } 21 | 22 | override fun commitProgress(message: String): Result = 23 | com.github.michaelbull.result.runCatching { 24 | maybeThrow() 25 | FakeRevCommit() 26 | }.mapError { GitService.GitError.CommitFailedError(it) } 27 | 28 | private fun maybeThrow() { 29 | if (throws) { 30 | throw Exception() 31 | } 32 | } 33 | 34 | } 35 | 36 | class FakeRevCommit : RevCommit(object : AnyObjectId() { 37 | override fun toObjectId(): ObjectId { 38 | TODO("Not yet implemented") 39 | } 40 | 41 | }) 42 | 43 | class FakeRef : Ref { 44 | override fun getPeeledObjectId(): ObjectId { 45 | TODO("Not yet implemented") 46 | } 47 | 48 | override fun getTarget(): Ref { 49 | TODO("Not yet implemented") 50 | } 51 | 52 | override fun getName(): String { 53 | return "" 54 | } 55 | 56 | override fun getStorage(): Ref.Storage { 57 | TODO("Not yet implemented") 58 | } 59 | 60 | override fun getLeaf(): Ref { 61 | TODO("Not yet implemented") 62 | } 63 | 64 | override fun getObjectId(): ObjectId { 65 | TODO("Not yet implemented") 66 | } 67 | 68 | override fun isPeeled(): Boolean { 69 | TODO("Not yet implemented") 70 | } 71 | 72 | override fun isSymbolic(): Boolean { 73 | TODO("Not yet implemented") 74 | } 75 | 76 | } 77 | 78 | -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/workflowext/WorkflowExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.workflowext 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import com.squareup.workflow.testing.WorkflowTester 5 | import com.wclausen.work.command.base.Command 6 | import com.wclausen.work.command.base.Output 7 | 8 | 9 | fun WorkflowTester.first(): OutputT = 10 | awaitNextOutput() 11 | 12 | fun WorkflowTester.then(): OutputT = 13 | awaitNextOutput() 14 | 15 | fun Output.assertIsPrompt(expectedText: String): Command.Prompt { 16 | val output = this as Output.InProgress 17 | try { 18 | val prompt = output.command as Command.Prompt 19 | return prompt.assertIsPrompt(expectedText) 20 | } catch (e: ClassCastException) { 21 | throw IllegalArgumentException( 22 | "Expected Command.Prompt with text $expectedText. Received: ${output.command}", 23 | e 24 | ) 25 | } 26 | } 27 | 28 | fun Command.Prompt.assertIsPrompt(expectedText: String): Command.Prompt { 29 | assertThat(prompt).isEqualTo(expectedText) 30 | return this 31 | } 32 | 33 | fun Command.Prompt.thenUserInputs(text: String) { 34 | nextAction(text) 35 | } 36 | 37 | fun Output.assertIsMessage(expectedText: String) { 38 | val output = this as Output.InProgress 39 | val echo = output.command as Command.Echo 40 | echo.assertIsMessage(expectedText) 41 | } 42 | 43 | fun Command.Echo.assertIsMessage(expectedText: String) { 44 | assertThat(output).isEqualTo(expectedText) 45 | } 46 | 47 | fun Output.assertContainsMessage(expectedText: String) { 48 | val output = this as Output.InProgress 49 | val echo = output.command as Command.Echo 50 | echo.assertContainsMessage(expectedText) 51 | } 52 | 53 | fun Command.Echo.assertContainsMessage(expectedText: String) { 54 | assertThat(output).contains(expectedText) 55 | } 56 | 57 | fun Output.multipleCommands(vararg asserts: (Command) -> Unit) { 58 | val output = this as Output.InProgress 59 | val multipleCommands = output.command as Command.MultipleCommands 60 | for (i in asserts.indices) { 61 | asserts[i].invoke(multipleCommands.commands[i]) 62 | } 63 | } 64 | 65 | fun Output.assertFinishes() { 66 | assertThat(this).isInstanceOf(Output.Final::class.java) 67 | } -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/command/commit/CommitWorkflowTest.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.commit 2 | 3 | import com.squareup.workflow.testing.testFromStart 4 | import com.wclausen.work.base.WorkState 5 | import com.wclausen.work.fake.FakeGitService 6 | import com.wclausen.work.workflowext.assertFinishes 7 | import com.wclausen.work.workflowext.assertIsMessage 8 | import com.wclausen.work.workflowext.assertIsPrompt 9 | import com.wclausen.work.workflowext.first 10 | import com.wclausen.work.workflowext.then 11 | import com.wclausen.work.workflowext.thenUserInputs 12 | import org.junit.Test 13 | 14 | class CommitWorkflowTest { 15 | 16 | @Test 17 | fun `GIVEN waiting for task WHEN executing command THEN returns error`() { 18 | CommitWorkflow(FakeGitService()).testFromStart(WorkState.Waiting) { 19 | first().assertIsMessage(CommitWorkflow.NO_TASK_ERROR_MESSAGE) 20 | 21 | then().assertFinishes() 22 | } 23 | } 24 | 25 | @Test 26 | fun `GIVEN cli not initialized WHEN executing command THEN returns error`() { 27 | CommitWorkflow(FakeGitService()).testFromStart(WorkState.Uninitialized) { 28 | first().assertIsMessage(CommitWorkflow.NO_TASK_ERROR_MESSAGE) 29 | 30 | then().assertFinishes() 31 | } 32 | } 33 | 34 | @Test 35 | fun `GIVEN no errors WHEN executing command THEN completes successfully`() { 36 | CommitWorkflow(FakeGitService()).testFromStart(WorkState.Executing("TEST-1")) { 37 | first().assertIsPrompt(CommitWorkflow.getPromptMessage("TEST-1")) 38 | .thenUserInputs("some comment") 39 | 40 | then().assertIsMessage(CommitWorkflow.COMMIT_IN_PROGRESS_MESSAGE) 41 | 42 | then().assertIsMessage(CommitWorkflow.SUCCESS_MESSAGE) 43 | } 44 | } 45 | 46 | @Test 47 | fun `GIVEN git fails WHEN committing THEN reports failure to user`() { 48 | val gitService = FakeGitService() 49 | gitService.throws = true 50 | val taskId = "TEST-1" 51 | CommitWorkflow(gitService).testFromStart(WorkState.Executing(taskId)) { 52 | first().assertIsPrompt(CommitWorkflow.getPromptMessage(taskId)) 53 | .thenUserInputs("some comment") 54 | 55 | then().assertIsMessage(CommitWorkflow.COMMIT_IN_PROGRESS_MESSAGE) 56 | 57 | then().assertIsMessage(CommitWorkflow.FAILED_TO_COMMIT_MESSAGE) 58 | 59 | then().assertFinishes() 60 | } 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/command/comment/CommentWorkflowTest.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.comment 2 | 3 | import com.squareup.workflow.testing.testFromStart 4 | import com.wclausen.work.base.WorkState 5 | import com.wclausen.work.commands.comment.CommentWorkflow 6 | import com.wclausen.work.fake.FakeJiraService 7 | import com.wclausen.work.workflowext.assertFinishes 8 | import com.wclausen.work.workflowext.assertIsMessage 9 | import com.wclausen.work.workflowext.assertIsPrompt 10 | import com.wclausen.work.workflowext.first 11 | import com.wclausen.work.workflowext.then 12 | import com.wclausen.work.workflowext.thenUserInputs 13 | import org.junit.Test 14 | 15 | 16 | class CommentWorkflowTest { 17 | 18 | @Test 19 | fun `GIVEN waiting for task WHEN executing command THEN returns error`() { 20 | CommentWorkflow(FakeJiraService()).testFromStart(WorkState.Waiting) { 21 | first().assertIsMessage(CommentWorkflow.NO_TASK_ERROR_MESSAGE) 22 | 23 | then().assertFinishes() 24 | } 25 | } 26 | 27 | @Test 28 | fun `GIVEN cli not initialized WHEN executing command THEN returns error`() { 29 | CommentWorkflow(FakeJiraService()).testFromStart(WorkState.Uninitialized) { 30 | first().assertIsMessage(CommentWorkflow.NO_TASK_ERROR_MESSAGE) 31 | 32 | then().assertFinishes() 33 | } 34 | } 35 | 36 | @Test 37 | fun `GIVEN no errors WHEN executing command THEN completes successfully`() { 38 | CommentWorkflow(FakeJiraService()).testFromStart(WorkState.Executing("TEST-1")) { 39 | first().assertIsPrompt(CommentWorkflow.COMMENT_PROMPT).thenUserInputs("some comment") 40 | 41 | then().assertIsMessage(CommentWorkflow.SENDING_COMMENT_TO_JIRA_MESSAGE) 42 | 43 | then().assertIsMessage(CommentWorkflow.SUCCESS_MESSAGE) 44 | } 45 | } 46 | 47 | @Test 48 | fun `GIVEN jira fails WHEN sending comment THEN reports failure to user`() { 49 | val jiraService = FakeJiraService() 50 | jiraService.throws = true 51 | CommentWorkflow(jiraService).testFromStart(WorkState.Executing("TEST-1")) { 52 | first().assertIsPrompt(CommentWorkflow.COMMENT_PROMPT).thenUserInputs("some comment") 53 | 54 | then().assertIsMessage(CommentWorkflow.SENDING_COMMENT_TO_JIRA_MESSAGE) 55 | 56 | then().assertIsMessage(CommentWorkflow.FAILED_TO_UPDATE_JIRA) 57 | 58 | then().assertFinishes() 59 | } 60 | } 61 | 62 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/init/InitWorkflow.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.init 2 | 3 | import com.github.michaelbull.result.Ok 4 | import com.squareup.workflow.RenderContext 5 | import com.squareup.workflow.Snapshot 6 | import com.squareup.workflow.Worker 7 | import com.squareup.workflow.action 8 | import com.wclausen.work.base.WorkState 9 | import com.wclausen.work.command.base.Command 10 | import com.wclausen.work.command.base.CommandOutputWorkflow 11 | import com.wclausen.work.command.base.Output 12 | import com.wclausen.work.command.start.output 13 | import com.wclausen.work.inject.WorkLogFile 14 | import com.wclausen.work.kotlinext.Do 15 | import java.nio.file.Path 16 | import javax.inject.Inject 17 | 18 | class InitWorkflow @Inject constructor( 19 | private val createConfigWorkflow: CreateConfigWorkflow, @WorkLogFile private val logPath: Path 20 | ) : CommandOutputWorkflow() { 21 | sealed class State { 22 | class CreateConfig : State() 23 | class CreateWorkUpdateLog : State() 24 | } 25 | 26 | override fun initialState(props: Unit, snapshot: Snapshot?) = State.CreateConfig() 27 | 28 | override fun render( 29 | props: Unit, state: State, context: RenderContext> 30 | ) { 31 | Do exhaustive when (state) { 32 | is State.CreateConfig -> context.renderChild(createConfigWorkflow, Unit) { 33 | when (it) { 34 | is Output.InProgress, is Output.Log -> sendOutputToParent(it) 35 | is Output.Final -> goToState(State.CreateWorkUpdateLog()) 36 | } 37 | } 38 | is State.CreateWorkUpdateLog -> { 39 | context.output(Command.Echo("Creating work log: $logPath")) 40 | context.runningWorker(Worker.from { 41 | val logFile = logPath.toFile() 42 | if (!logFile.exists()) { 43 | logFile.createNewFile() 44 | } 45 | Unit 46 | }) { 47 | com.squareup.workflow.action { 48 | setOutput(Output.Final(Ok(WorkState.Waiting))) 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | private fun goToState(state: State) = action { 56 | nextState = state 57 | } 58 | 59 | private fun sendOutputToParent(output: Output) = action { 60 | setOutput(output as Output) 61 | } 62 | 63 | override fun snapshotState(state: State) = Snapshot.EMPTY 64 | } -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/config/RealConfigManagerTest.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.config 2 | 3 | import com.github.michaelbull.result.get 4 | import com.github.michaelbull.result.getError 5 | import com.google.common.truth.Truth.assertThat 6 | import com.squareup.moshi.Moshi 7 | import com.wclausen.work.config.RealConfigManager.Companion.FAILED_TO_PARSE_JSON_MESSAGE 8 | import com.wclausen.work.config.RealConfigManager.Companion.UNABLE_TO_OPEN_CONFIG_FILE_MESSAGE 9 | import okio.buffer 10 | import okio.sink 11 | import org.junit.Rule 12 | import org.junit.Test 13 | import org.junit.rules.TemporaryFolder 14 | 15 | class RealConfigManagerTest { 16 | 17 | @get:Rule 18 | val tempFolder = TemporaryFolder() 19 | 20 | @Test 21 | fun `GIVEN missing file WHEN reading config THEN reports unable to open`() { 22 | val missingFile = tempFolder.newFile("missing_file") 23 | missingFile.delete() 24 | val error = RealConfigManager(missingFile.toPath()).getConfig().getError()!! 25 | assertThat(error.message).contains(UNABLE_TO_OPEN_CONFIG_FILE_MESSAGE) 26 | } 27 | 28 | @Test 29 | fun `GIVEN empty file WHEN reading config THEN reports unable to parse json`() { 30 | val emptyFile = tempFolder.newFile("empty_file") 31 | val error = RealConfigManager(emptyFile.toPath()).getConfig().getError()!! 32 | assertThat(error.message).contains(FAILED_TO_PARSE_JSON_MESSAGE) 33 | } 34 | 35 | @Test 36 | fun `GIVEN malformed json in file WHEN reading config THEN reports unable to parse json`() { 37 | val badJsonFile = tempFolder.newFile("bad_json") 38 | badJsonFile.sink().buffer() 39 | .writeUtf8("{ \"a_field\": \"some_value\"}") // wrong json contents 40 | .close() 41 | val error = RealConfigManager(badJsonFile.toPath()).getConfig().getError()!! 42 | assertThat(error.message).contains(RealConfigManager.FAILED_TO_PARSE_JSON_MESSAGE) 43 | } 44 | 45 | @Test 46 | fun `GIVEN correct json in file WHEN reading config THEN returns result`() { 47 | val configFile = tempFolder.newFile("workflow.com.wclausen.work.config") 48 | val expectedConfig = Config(JiraConfig("some_email@fake.com", "asdfjkl;12345")) 49 | configFile.sink() 50 | .buffer() 51 | .writeUtf8(expectedConfig.asJson()) // wrong json contents 52 | .close() 53 | val config = RealConfigManager(configFile.toPath()).getConfig().get()!! 54 | assertThat(config).isEqualTo(expectedConfig) 55 | } 56 | 57 | } 58 | 59 | private fun Config.asJson(): String { 60 | val moshi = Moshi.Builder() 61 | .build() 62 | val adapter = moshi.adapter(Config::class.java) 63 | return adapter.toJson(this) 64 | } 65 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 33 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 34 | 35 | @rem Find java.exe 36 | if defined JAVA_HOME goto findJavaFromJavaHome 37 | 38 | set JAVA_EXE=java.exe 39 | %JAVA_EXE% -version >NUL 2>&1 40 | if "%ERRORLEVEL%" == "0" goto init 41 | 42 | echo. 43 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 44 | echo. 45 | echo Please set the JAVA_HOME variable in your environment to match the 46 | echo location of your Java installation. 47 | 48 | goto fail 49 | 50 | :findJavaFromJavaHome 51 | set JAVA_HOME=%JAVA_HOME:"=% 52 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 53 | 54 | if exist "%JAVA_EXE%" goto init 55 | 56 | echo. 57 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 58 | echo. 59 | echo Please set the JAVA_HOME variable in your environment to match the 60 | echo location of your Java installation. 61 | 62 | goto fail 63 | 64 | :init 65 | @rem Get command-line arguments, handling Windows variants 66 | 67 | if not "%OS%" == "Windows_NT" goto win9xME_args 68 | 69 | :win9xME_args 70 | @rem Slurp the command line arguments. 71 | set CMD_LINE_ARGS= 72 | set _SKIP=2 73 | 74 | :win9xME_args_slurp 75 | if "x%~1" == "x" goto execute 76 | 77 | set CMD_LINE_ARGS=%* 78 | 79 | :execute 80 | @rem Setup the command line 81 | 82 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 83 | 84 | @rem Execute Gradle 85 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 86 | 87 | :end 88 | @rem End local scope for the variables with windows NT shell 89 | if "%ERRORLEVEL%"=="0" goto mainEnd 90 | 91 | :fail 92 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 93 | rem the _cmd.exe /c_ return code! 94 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 95 | exit /b 1 96 | 97 | :mainEnd 98 | if "%OS%"=="Windows_NT" endlocal 99 | 100 | :omega 101 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/base/CommandOutputWorkflowRunner.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.base 2 | 3 | import com.github.ajalt.clikt.output.TermUi 4 | import com.squareup.workflow.launchWorkflowIn 5 | import com.wclausen.work.command.base.Command 6 | import com.wclausen.work.command.base.CommandOutputWorkflow 7 | import com.wclausen.work.command.base.Output 8 | import com.wclausen.work.kotlinext.Do 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.ExperimentalCoroutinesApi 11 | import kotlinx.coroutines.FlowPreview 12 | import kotlinx.coroutines.Job 13 | import kotlinx.coroutines.async 14 | import kotlinx.coroutines.cancelChildren 15 | import kotlinx.coroutines.channels.BroadcastChannel 16 | import kotlinx.coroutines.flow.asFlow 17 | import kotlinx.coroutines.flow.filter 18 | import kotlinx.coroutines.flow.first 19 | import kotlinx.coroutines.flow.produceIn 20 | import kotlinx.coroutines.launch 21 | import kotlinx.coroutines.runBlocking 22 | 23 | /** 24 | * Class that runs Command workflows that issue commands through the Workflow's OutputT. 25 | * 26 | * This contrasts with [CommandWorkflowRunner] where running Workflows issue commands through 27 | * RenderingT 28 | * 29 | * The reason for adding this class and changing the way Commands are issued is that it *should* 30 | * make it easier to compose workflows if they issue Commands through outputs rather than 31 | * renderings. This goes back to the fact that the UI framework for the application (the terminal) 32 | * is not declarative, which breaks the normal expectations of the render loop. 33 | * 34 | */ 35 | @ExperimentalCoroutinesApi 36 | abstract class CommandOutputWorkflowRunner constructor( 37 | private val inputs: BroadcastChannel, 38 | private val commandWorkflow: CommandOutputWorkflow 39 | ) { 40 | 41 | @FlowPreview 42 | fun run() = runBlocking { 43 | val workJob = Job() 44 | val result = launchWorkflowIn( 45 | CoroutineScope(workJob), 46 | commandWorkflow, 47 | inputs.asFlow(), 48 | null) 49 | { session -> 50 | val outputs = session.outputs 51 | .produceIn(this) 52 | 53 | launch { 54 | while (true) { 55 | val output = outputs.receive() 56 | Do exhaustive when (output) { 57 | is Output.InProgress -> handleCommand(output.command) 58 | is Output.Final<*> -> Unit // do nothing, handled later 59 | is Output.Log -> Unit // Do nothing, handled by MainWorkflow 60 | } 61 | } 62 | } 63 | 64 | return@launchWorkflowIn async { session.outputs.filter { it is Output.Final<*> }.first() } 65 | } 66 | 67 | val done = result.await() 68 | workJob.cancelChildren() 69 | done 70 | } 71 | 72 | private fun handleCommand(rendering: Command) { 73 | Do exhaustive when (rendering) { 74 | is Command.Prompt -> { 75 | val result = TermUi.prompt(rendering.prompt)!! 76 | rendering.nextAction(result) 77 | } 78 | is Command.Echo -> TermUi.echo(rendering.output) 79 | is Command.ExecuteCommand -> 80 | WrapperCommand(rendering.onResult) 81 | .main(rendering.args) 82 | is Command.MultipleCommands -> 83 | rendering.commands.forEach { 84 | handleCommand(it) 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/base/MainWorkflow.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.base 2 | 3 | import com.github.michaelbull.result.Err 4 | import com.github.michaelbull.result.Ok 5 | import com.github.michaelbull.result.mapBoth 6 | import com.squareup.workflow.RenderContext 7 | import com.squareup.workflow.Snapshot 8 | import com.squareup.workflow.WorkflowAction 9 | import com.squareup.workflow.action 10 | import com.wclausen.work.base.WorkState 11 | import com.wclausen.work.base.WorkStateManager 12 | import com.wclausen.work.base.WorkStateManager.Error.ReadStateError.ConfigError 13 | import com.wclausen.work.command.init.InitWorkflow 14 | import com.wclausen.work.kotlinext.Do 15 | 16 | class MainWorkflow>( 17 | private val workStateManager: WorkStateManager, 18 | private val initWorkflow: InitWorkflow, 19 | private val commandWorkflow: CommandT 20 | ) : CommandOutputWorkflow() { 21 | sealed class State { 22 | object Startup : State() 23 | class RunningCommand(val workState: WorkState) : State() 24 | } 25 | 26 | override fun initialState(props: Unit, snapshot: Snapshot?): State { 27 | return workStateManager.getWorkflowState().mapBoth(success = { workState -> 28 | State.RunningCommand(workState) 29 | }, failure = { 30 | return when (it) { 31 | is ConfigError, is WorkStateManager.Error.ReadStateError.NoLogFileError -> State.Startup 32 | is WorkStateManager.Error.ReadStateError.EmptyLogFileError, is WorkStateManager.Error.ReadStateError.MalformedLogError -> State.RunningCommand( 33 | WorkState.Waiting 34 | ) 35 | } 36 | }) 37 | } 38 | 39 | override fun render(props: Unit, state: State, context: RenderContext>) { 40 | Do exhaustive when (state) { 41 | State.Startup -> context.renderChild(initWorkflow, Unit) { output -> 42 | when (output) { 43 | is Output.InProgress -> continueInitialization(output) 44 | is Output.Final -> runCommand(output) 45 | is Output.Log -> throw IllegalStateException("Received log request from initialization. Should only receive log requests during command execution...") 46 | } 47 | } 48 | is State.RunningCommand -> context.renderChild( 49 | commandWorkflow, state.workState 50 | ) { output -> 51 | when (output) { 52 | is Output.InProgress -> continueCommand(output) 53 | is Output.Final -> finish(output) 54 | is Output.Log -> { 55 | workStateManager.writeUpdateToLog(output.workUpdate) 56 | WorkflowAction.noAction() 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | private fun finish(output: Output.Final<*>) = action { 64 | output.result.mapBoth({ 65 | setOutput(Output.Final(Ok(ExitCode(0)))) 66 | }, { error -> 67 | setOutput(Output.Final(Err(error))) 68 | }) 69 | } 70 | 71 | private fun runCommand(output: Output.Final) = action { 72 | output.result.mapBoth({ config -> 73 | nextState = State.RunningCommand(config) 74 | }, { error -> 75 | setOutput(Output.Final(Err(error))) 76 | }) 77 | } 78 | 79 | private fun continueCommand(output: Output.InProgress) = justPassOutputAlong(output) 80 | 81 | private fun continueInitialization(output: Output.InProgress) = justPassOutputAlong(output) 82 | 83 | // When a child workflow emits an in-progress output, we simply notify our parent and let the child keep working 84 | private fun justPassOutputAlong(output: Output.InProgress) = action { 85 | setOutput(output) 86 | } 87 | 88 | override fun snapshotState(state: State): Snapshot { 89 | return Snapshot.EMPTY 90 | } 91 | } 92 | 93 | data class ExitCode(val code: Int) 94 | 95 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/base/CommandWorkflowRunner.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.base 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | import com.github.ajalt.clikt.output.TermUi 5 | import com.github.ajalt.clikt.parameters.arguments.argument 6 | import com.github.ajalt.clikt.parameters.arguments.multiple 7 | import com.github.ajalt.clikt.parameters.options.option 8 | import com.github.ajalt.clikt.parameters.options.required 9 | import com.github.michaelbull.result.Result 10 | import com.github.michaelbull.result.andThen 11 | import com.github.michaelbull.result.getOrElse 12 | import com.github.michaelbull.result.mapBoth 13 | import com.github.michaelbull.result.runCatching 14 | import com.squareup.workflow.launchWorkflowIn 15 | import com.wclausen.work.command.base.Command 16 | import com.wclausen.work.command.base.CommandWorkflow 17 | import com.wclausen.work.kotlinext.Do 18 | import kotlinx.coroutines.CoroutineScope 19 | import kotlinx.coroutines.ExperimentalCoroutinesApi 20 | import kotlinx.coroutines.FlowPreview 21 | import kotlinx.coroutines.Job 22 | import kotlinx.coroutines.async 23 | import kotlinx.coroutines.cancelChildren 24 | import kotlinx.coroutines.channels.BroadcastChannel 25 | import kotlinx.coroutines.flow.asFlow 26 | import kotlinx.coroutines.flow.first 27 | import kotlinx.coroutines.flow.map 28 | import kotlinx.coroutines.flow.produceIn 29 | import kotlinx.coroutines.launch 30 | import kotlinx.coroutines.runBlocking 31 | import kotlinx.coroutines.selects.selectUnbiased 32 | import javax.inject.Inject 33 | 34 | @ExperimentalCoroutinesApi 35 | class CommandWorkflowRunner> 36 | @Inject constructor( 37 | private val inputs: BroadcastChannel, 38 | private val commandWorkflow: CommandWorkflow 39 | ) { 40 | 41 | @FlowPreview 42 | fun run() = runBlocking { 43 | val workJob = Job() 44 | val result = launchWorkflowIn( 45 | CoroutineScope(workJob), 46 | commandWorkflow, 47 | inputs.asFlow(), 48 | null) 49 | { session -> 50 | val renderings = session.renderingsAndSnapshots 51 | .map { it.rendering } 52 | .produceIn(this) 53 | launch { 54 | while (true) { 55 | val rendering = selectUnbiased { 56 | renderings.onReceive { it } 57 | } 58 | 59 | handleCommand(rendering) 60 | } 61 | } 62 | 63 | return@launchWorkflowIn async { session.outputs.first() } 64 | } 65 | 66 | val done = result.await() 67 | workJob.cancelChildren() 68 | done 69 | } 70 | 71 | private fun handleCommand(rendering: Command) { 72 | Do exhaustive when (rendering) { 73 | is Command.Prompt -> { 74 | val result = TermUi.prompt(rendering.prompt)!! 75 | rendering.nextAction(result) 76 | } 77 | is Command.Echo -> TermUi.echo(rendering.output) 78 | is Command.ExecuteCommand -> 79 | WrapperCommand(rendering.onResult) 80 | .main(rendering.args) 81 | is Command.MultipleCommands -> 82 | rendering.commands.forEach { 83 | handleCommand(it) 84 | } 85 | } 86 | } 87 | } 88 | 89 | class WrapperCommand(private val onResult: ((Int) -> Unit)?) : CliktCommand() { 90 | 91 | val command by option(help = "?").required() 92 | val arguments by argument().multiple() 93 | 94 | override fun run() { 95 | val cmd = (listOf(command) + arguments).joinToString(" ") 96 | val executionResult = startProcess(cmd) 97 | .andThen(::printResults) 98 | .andThen(::getExitCode) 99 | println(executionResult.mapBoth({ "Success"}, { it.printStackTrace() })) 100 | onResult?.invoke(executionResult.getOrElse { 1 }) 101 | } 102 | 103 | private fun getExitCode(process: Process) = runCatching { 104 | process.waitFor() 105 | } 106 | 107 | 108 | private fun printResults(process: Process) = runCatching { 109 | println(process.inputStream.bufferedReader().readText()) 110 | process 111 | } 112 | 113 | private fun startProcess(cmd: String) = runCatching { 114 | Runtime.getRuntime().exec(cmd) 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/command/start/StartWorkflowTest.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.start 2 | 3 | import com.squareup.workflow.testing.testFromStart 4 | import com.squareup.workflow.testing.testFromState 5 | import com.wclausen.work.base.WorkState 6 | import com.wclausen.work.command.base.Command 7 | import com.wclausen.work.command.start.StartWorkflow.Companion.LOADING_TASKS_FAILED_MESSAGE 8 | import com.wclausen.work.fake.FakeGitService 9 | import com.wclausen.work.fake.FakeJiraService 10 | import com.wclausen.work.workflowext.assertContainsMessage 11 | import com.wclausen.work.workflowext.assertFinishes 12 | import com.wclausen.work.workflowext.assertIsMessage 13 | import com.wclausen.work.workflowext.assertIsPrompt 14 | import com.wclausen.work.workflowext.first 15 | import com.wclausen.work.workflowext.multipleCommands 16 | import com.wclausen.work.workflowext.then 17 | import kotlinx.coroutines.test.runBlockingTest 18 | import org.junit.Test 19 | 20 | class StartWorkflowTest { 21 | 22 | @Test 23 | fun `GIVEN no errors WHEN workflow runs THEN completes successfully`() { 24 | val fakeJiraService = FakeJiraService() 25 | StartWorkflow(fakeJiraService, FakeGitService()).testFromStart(WorkState.Waiting) { 26 | first().assertIsMessage(StartWorkflow.LOADING_TASKS_MESSAGE) 27 | then().assertIsMessage(formattedTaskList(fakeJiraService.getTasksForCurrentUserResponse().issues)) 28 | then().assertIsPrompt("Please select a task").nextAction("1") 29 | then().multipleCommands({ 30 | (it as Command.Echo).assertContainsMessage("Selected task: ") 31 | }, { 32 | (it as Command.Prompt).assertIsPrompt("Describe the finished state of the task") 33 | .nextAction("done") 34 | }) 35 | 36 | then().assertIsMessage("Saved goal") 37 | then().assertContainsMessage("Checked out branch") 38 | } 39 | } 40 | 41 | @Test 42 | fun `GIVEN error loading jira tasks WHEN running start command THEN shows error`() { 43 | val fakeJiraService = FakeJiraService() 44 | fakeJiraService.throws = true 45 | StartWorkflow(fakeJiraService, FakeGitService()).testFromStart(WorkState.Waiting) { 46 | first().assertIsMessage(StartWorkflow.LOADING_TASKS_MESSAGE) 47 | then().assertIsMessage(LOADING_TASKS_FAILED_MESSAGE) 48 | } 49 | } 50 | 51 | @Test 52 | fun `GIVEN invalid task selection WHEN selecting tasks THEN prompts for selection again`() = 53 | runBlockingTest { 54 | val fakeJiraService = FakeJiraService() 55 | val tasks = fakeJiraService.getTasksForCurrentUser().issues 56 | StartWorkflow(fakeJiraService, FakeGitService()).testFromState( 57 | WorkState.Waiting, StartWorkflow.State.TaskSelectionNeeded.FirstTime(tasks) 58 | ) { 59 | first().assertIsMessage(formattedTaskList(tasks)) 60 | then().assertIsPrompt("Please select a task") 61 | .nextAction("10000") // make invalid selction, number larger than number of tasks 62 | then().assertContainsMessage("Your selection was invalid. Please select within") 63 | then().assertIsPrompt("Please select a task").nextAction("10001") 64 | then().assertContainsMessage("Your selection was invalid. Please select within") 65 | then().assertIsPrompt("Please select a task") 66 | } 67 | } 68 | 69 | @Test 70 | fun `GIVEN error WHEN checking out branch THEN show error and return`() = runBlockingTest { 71 | val fakeJiraService = FakeJiraService() 72 | val tasks = fakeJiraService.getTasksForCurrentUser().issues 73 | val selectedTask = tasks[0] 74 | val gitService = FakeGitService() 75 | gitService.throws = true 76 | StartWorkflow(fakeJiraService, gitService).testFromState( 77 | WorkState.Waiting, StartWorkflow.State.TaskSelected( 78 | selectedTask 79 | ) 80 | ) { 81 | first().multipleCommands({ 82 | (it as Command.Echo).assertIsMessage("Selected task: ${selectedTask.key}") 83 | }, { 84 | (it as Command.Prompt).assertIsPrompt("Describe the finished state of the task") 85 | .nextAction("some goal") 86 | }) 87 | then().assertIsMessage("Failed to checkout branch for task") 88 | then().assertFinishes() 89 | } 90 | } 91 | } 92 | 93 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/init/CreateConfigWorkflow.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.init 2 | 3 | import com.github.michaelbull.result.Ok 4 | import com.github.michaelbull.result.Result 5 | import com.github.michaelbull.result.get 6 | import com.squareup.workflow.RenderContext 7 | import com.squareup.workflow.Snapshot 8 | import com.squareup.workflow.Worker 9 | import com.squareup.workflow.WorkflowAction 10 | import com.squareup.workflow.action 11 | import com.wclausen.work.command.base.Command 12 | import com.wclausen.work.command.base.CommandOutputWorkflow 13 | import com.wclausen.work.command.base.Output 14 | import com.wclausen.work.config.Config 15 | import com.wclausen.work.config.ConfigManager 16 | import com.wclausen.work.config.JiraConfig 17 | import com.wclausen.work.kotlinext.Do 18 | 19 | class CreateConfigWorkflow( 20 | private val configManager: ConfigManager 21 | ) : CommandOutputWorkflow() { 22 | 23 | companion object { 24 | const val GET_USERNAME_PROMPT = 25 | "Please enter your jira username (e.g. wclausen@dropbox.com)" 26 | const val GET_JIRA_API_TOKEN_PROMPT = "Please enter your jira api token" 27 | const val SAVING_CONFIG_MESSAGE = "Saving config file at: " 28 | } 29 | 30 | sealed class State() { 31 | object GetJiraUserName : State() 32 | data class GetJiraPassword(val jiraUsername: String) : State() 33 | data class SavingConfigFile(val config: Config) : State() 34 | data class Finished(val result: Result) : State() 35 | } 36 | 37 | override fun initialState(props: Unit, snapshot: Snapshot?): State { 38 | return configManager.getConfig().get()?.let { config -> 39 | State.Finished(Ok(config)) 40 | } ?: State.GetJiraUserName 41 | } 42 | 43 | override fun render( 44 | props: Unit, state: State, context: RenderContext> 45 | ) { 46 | Do exhaustive when (state) { 47 | State.GetJiraUserName -> context.outputPromptForInfo(GET_USERNAME_PROMPT) { username -> 48 | action { 49 | nextState = State.GetJiraPassword(username) 50 | } 51 | } 52 | is State.GetJiraPassword -> context.outputPromptForInfo(GET_JIRA_API_TOKEN_PROMPT) { token -> 53 | action { 54 | nextState = 55 | State.SavingConfigFile(Config(JiraConfig(state.jiraUsername, token))) 56 | } 57 | } 58 | is State.SavingConfigFile -> { 59 | context.saveConfig(state.config) 60 | } 61 | is State.Finished -> { 62 | context.runningWorker(Worker.from { 63 | state.result 64 | }) { 65 | action { 66 | setOutput(Output.Final(it)) 67 | } 68 | } 69 | } 70 | } 71 | } 72 | 73 | private fun RenderContext>.outputPromptForInfo( 74 | promptText: String, onResponse: (String) -> WorkflowAction> 75 | ) { 76 | runningWorker(Worker.from { 77 | Command.Prompt(promptText) { response -> 78 | this.actionSink.send(onResponse.invoke(response)) 79 | } 80 | }, promptText) { 81 | action { 82 | setOutput(Output.InProgress(it)) 83 | } 84 | } 85 | } 86 | 87 | private fun RenderContext>.saveConfig( 88 | config: Config 89 | ) { 90 | runningWorker(Worker.create { 91 | emit(Output.InProgress(Command.Echo(SAVING_CONFIG_MESSAGE + configManager.configLocation))) 92 | emit(Output.Final(configManager.createConfig(config))) 93 | }, "save_config") { output -> 94 | action { 95 | Do exhaustive when (output) { 96 | is Output.InProgress -> setOutput(output) 97 | is Output.Log -> IllegalStateException("Received unexpected Output.Log value") 98 | is Output.Final -> nextState = 99 | State.Finished(output.result as Result) 100 | } 101 | } 102 | } 103 | } 104 | 105 | override fun snapshotState(state: State): Snapshot { 106 | return Snapshot.EMPTY 107 | } 108 | } 109 | 110 | 111 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/commit/CommitWorkflow.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.commit 2 | 3 | import com.github.michaelbull.result.Err 4 | import com.github.michaelbull.result.Ok 5 | import com.github.michaelbull.result.mapBoth 6 | import com.github.michaelbull.result.mapError 7 | import com.squareup.workflow.RenderContext 8 | import com.squareup.workflow.Snapshot 9 | import com.squareup.workflow.Worker 10 | import com.squareup.workflow.action 11 | import com.wclausen.work.base.WorkState 12 | import com.wclausen.work.base.requireExecuting 13 | import com.wclausen.work.command.base.Command 14 | import com.wclausen.work.command.base.CommandOutputWorkflow 15 | import com.wclausen.work.command.base.Output 16 | import com.wclausen.work.command.start.output 17 | import com.wclausen.work.command.start.sendToParent 18 | import com.wclausen.work.git.GitService 19 | import javax.inject.Inject 20 | 21 | class CommitWorkflow @Inject constructor( 22 | private val gitService: GitService 23 | ) : CommandOutputWorkflow() { 24 | 25 | companion object { 26 | const val SUCCESS_MESSAGE = "Successfully committed to git" 27 | const val NO_TASK_ERROR_MESSAGE = "You must select a task before committing" 28 | const val COMMIT_IN_PROGRESS_MESSAGE = "Committing to git" 29 | const val FAILED_TO_COMMIT_MESSAGE = "Failed to commit to git" 30 | 31 | fun getPromptMessage(currentTask: String) = "Enter your commit message for $currentTask" 32 | } 33 | 34 | sealed class State { 35 | class PromptForCommitMessage : State() 36 | class ReceivedCommitMessage(val message: String) : State() 37 | sealed class Finished(val message: String) : State() { 38 | class Success : Finished(SUCCESS_MESSAGE) 39 | class Error(val error: CommitWorkflow.Error) : Finished(error.explanation) 40 | } 41 | } 42 | 43 | sealed class Error(val explanation: String, cause: Throwable) : Throwable(explanation, cause) { 44 | class InvalidInitialState : Error(NO_TASK_ERROR_MESSAGE, IllegalStateException()) 45 | class CommitFailedError(cause: Throwable) : Error(FAILED_TO_COMMIT_MESSAGE, cause) 46 | } 47 | 48 | override fun initialState(props: WorkState, snapshot: Snapshot?): State { 49 | return when (props) { 50 | WorkState.Uninitialized, WorkState.Waiting -> State.Finished.Error(Error.InvalidInitialState()) 51 | is WorkState.Executing -> State.PromptForCommitMessage() 52 | } 53 | } 54 | 55 | override fun render( 56 | props: WorkState, 57 | state: State, 58 | context: RenderContext> 59 | ) { 60 | val executingProps = props.requireExecuting() 61 | when (state) { 62 | is State.PromptForCommitMessage -> { 63 | context.output(Command.Prompt(getPromptMessage(executingProps.taskId)) { message -> 64 | context.actionSink.send(action { 65 | nextState = State.ReceivedCommitMessage(message) 66 | }) 67 | }) 68 | } 69 | is State.ReceivedCommitMessage -> { 70 | context.output(Command.Echo(COMMIT_IN_PROGRESS_MESSAGE)) 71 | context.commitToGit(state.message, gitService) 72 | } 73 | is State.Finished -> { 74 | context.output(Command.Echo(state.message)) 75 | context.finish(state) 76 | } 77 | } 78 | } 79 | 80 | override fun snapshotState(state: State): Snapshot { 81 | return Snapshot.EMPTY 82 | } 83 | 84 | } 85 | 86 | private fun RenderContext.commitToGit( 87 | message: String, 88 | gitService: GitService 89 | ) { 90 | runningWorker(Worker.create { 91 | emit(gitService.commitProgress(message) 92 | .mapError { CommitWorkflow.Error.CommitFailedError(it) }) 93 | }) { 94 | action { 95 | nextState = it.mapBoth({ CommitWorkflow.State.Finished.Success() }, 96 | { CommitWorkflow.State.Finished.Error(it) }) 97 | } 98 | } 99 | 100 | } 101 | 102 | private fun RenderContext>.finish(result: CommitWorkflow.State.Finished) { 103 | val output = when (result) { 104 | is CommitWorkflow.State.Finished.Success -> Ok(Unit) 105 | is CommitWorkflow.State.Finished.Error -> Err(result.error) 106 | } 107 | sendToParent(Output.Final(output)) 108 | } 109 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/base/WorkStateManager.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.base 2 | 3 | import com.github.michaelbull.result.Result 4 | import com.github.michaelbull.result.andThen 5 | import com.github.michaelbull.result.map 6 | import com.github.michaelbull.result.mapError 7 | import com.github.michaelbull.result.runCatching 8 | import com.squareup.moshi.Moshi 9 | import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory 10 | import com.wclausen.work.config.ConfigManager 11 | import com.wclausen.work.inject.WorkLogFile 12 | import okio.BufferedSource 13 | import okio.buffer 14 | import okio.sink 15 | import okio.source 16 | import java.nio.file.Path 17 | import java.nio.file.StandardOpenOption.APPEND 18 | 19 | interface WorkStateManager { 20 | 21 | fun getWorkflowState(): Result 22 | 23 | fun writeUpdateToLog(update: WorkUpdate): Result 24 | 25 | sealed class Error() { 26 | 27 | sealed class ReadStateError() : Error() { 28 | 29 | class ConfigError : ReadStateError() 30 | 31 | class NoLogFileError : ReadStateError() 32 | 33 | class EmptyLogFileError : ReadStateError() 34 | 35 | data class MalformedLogError(val cause: Throwable) : ReadStateError() 36 | 37 | } 38 | 39 | class WorkUpdateWriteFailed : Error() 40 | 41 | } 42 | } 43 | 44 | class RealWorkStateManager( 45 | @WorkLogFile private val logFile: Path, private val configManager: ConfigManager 46 | ) : WorkStateManager { 47 | 48 | companion object { 49 | val moshi = Moshi.Builder().add( 50 | PolymorphicJsonAdapterFactory.of(WorkUpdate::class.java, "update") 51 | .withSubtype(WorkUpdate.Start::class.java, UpdateType.START_NEW_TASK.message) 52 | .withSubtype( 53 | WorkUpdate.JiraComment::class.java, UpdateType.JIRA_COMMENT.message 54 | ).withSubtype(WorkUpdate.GitCommit::class.java, UpdateType.GIT_COMMIT.message) 55 | .withSubtype( 56 | WorkUpdate.FinishedTask::class.java, UpdateType.FINISHED_TASK.message 57 | ) 58 | ).build() 59 | } 60 | 61 | override fun getWorkflowState(): Result { 62 | return configManager.getConfig() 63 | .mapError { WorkStateManager.Error.ReadStateError.ConfigError() }.andThen { openLog() } 64 | .andThen(::readLastLine).andThen(::parseLastWorkUpdate).map { mapToWorkflowState(it) } 65 | } 66 | 67 | private fun mapToWorkflowState(workUpdate: WorkUpdate): WorkState = when (workUpdate) { 68 | is WorkUpdate.Start -> WorkState.Executing( 69 | taskId = workUpdate.taskId, 70 | goal = workUpdate.goal 71 | ) 72 | is WorkUpdate.JiraComment -> WorkState.Executing( 73 | taskId = workUpdate.taskId, 74 | goal = workUpdate.goal 75 | ) 76 | is WorkUpdate.GitCommit -> WorkState.Executing(workUpdate.taskId, workUpdate.goal) 77 | is WorkUpdate.FinishedTask -> WorkState.Waiting 78 | } 79 | 80 | override fun writeUpdateToLog(update: WorkUpdate): Result { 81 | val jsonParser = moshi.adapter(WorkUpdate::class.java) 82 | return runCatching { 83 | logFile.sink(APPEND).buffer().writeUtf8(jsonParser.toJson(update) + "\n").flush() 84 | Unit 85 | }.mapError { WorkStateManager.Error.WorkUpdateWriteFailed() } 86 | } 87 | 88 | private fun parseLastWorkUpdate(stateJson: String): Result = 89 | runCatching { 90 | val configJsonAdapter = moshi.adapter(WorkUpdate::class.java) 91 | configJsonAdapter.fromJson(stateJson)!! 92 | }.mapError { WorkStateManager.Error.ReadStateError.MalformedLogError(it) } 93 | 94 | private fun readLastLine(source: BufferedSource): Result = 95 | runCatching { 96 | var line: String? = null 97 | while (!source.exhausted()) { 98 | line = source.readUtf8Line() 99 | } 100 | line ?: throw Exception("Empty log") 101 | }.mapError { WorkStateManager.Error.ReadStateError.EmptyLogFileError() } 102 | 103 | private fun openLog(): Result = 104 | runCatching { 105 | logFile.source().buffer() 106 | }.mapError { WorkStateManager.Error.ReadStateError.NoLogFileError() } 107 | 108 | 109 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/config/ConfigManager.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.config 2 | 3 | import com.github.michaelbull.result.Result 4 | import com.github.michaelbull.result.andThen 5 | import com.github.michaelbull.result.mapError 6 | import com.github.michaelbull.result.runCatching 7 | import com.squareup.moshi.Moshi 8 | import com.wclausen.work.inject.ConfigFile 9 | import okio.BufferedSource 10 | import okio.buffer 11 | import okio.source 12 | import java.io.FileNotFoundException 13 | import java.nio.file.Files 14 | import java.nio.file.Path 15 | import javax.inject.Inject 16 | 17 | interface ConfigManager { 18 | 19 | val configLocation: Path 20 | 21 | fun createConfig(config: Config): Result 22 | 23 | fun getConfig(): Result 24 | 25 | sealed class Error(message: String, cause: Throwable) : Throwable(message, cause) { 26 | 27 | class FailedToCreateConfig(configLocation: Path, cause: Throwable) : 28 | Error("Failed to create config file at: $configLocation", cause) 29 | 30 | class FailedToWriteToConfigFile(configLocation: Path, cause: Throwable) : 31 | Error("Failed to write config data at: $configLocation", cause) 32 | 33 | sealed class ConfigLoadingError(message: String, cause: Throwable) : Error(message, cause) { 34 | 35 | class NoConfigFileError(message: String) : 36 | ConfigLoadingError(message, FileNotFoundException()) 37 | 38 | class ParsingFileError(message: String) : 39 | ConfigLoadingError(message, IllegalStateException()) 40 | 41 | } 42 | 43 | } 44 | 45 | } 46 | 47 | // TODO: inject com.wclausen.work.config file info rather than pulling from ConfigFileInfo object 48 | class RealConfigManager @Inject constructor(@ConfigFile configPath: Path) : ConfigManager { 49 | 50 | override val configLocation = configPath 51 | 52 | companion object { 53 | const val UNABLE_TO_OPEN_CONFIG_FILE_MESSAGE = "Failed to open file" 54 | const val FAILED_TO_READ_CONFIG_MESSAGE = 55 | "Failed to read lines from com.wclausen.work.config" 56 | const val FAILED_TO_PARSE_JSON_MESSAGE = "Failed to parse com.wclausen.work.config data" 57 | } 58 | 59 | override fun createConfig(config: Config): Result { 60 | return createConfigFileInFileSystem().andThen { writeConfigFields(it, config) } 61 | } 62 | 63 | override fun getConfig(): Result { 64 | return openFile().andThen(::readLines).andThen(::parseJson) 65 | } 66 | 67 | private fun openFile() = runCatching { 68 | configLocation.source().buffer() 69 | }.mapError { it.toConfigError(UNABLE_TO_OPEN_CONFIG_FILE_MESSAGE) } 70 | 71 | private fun readLines(bufferedSource: BufferedSource) = runCatching { 72 | bufferedSource.readUtf8() 73 | }.mapError { it.toConfigError(FAILED_TO_READ_CONFIG_MESSAGE) } 74 | 75 | private fun parseJson(json: String) = runCatching { 76 | val moshi = Moshi.Builder().build() 77 | val configJsonAdapter = moshi.adapter(Config::class.java) 78 | configJsonAdapter.fromJson(json)!! 79 | }.mapError { it.toConfigError(FAILED_TO_PARSE_JSON_MESSAGE) } 80 | 81 | private fun writeConfigFields(configPath: Path, config: Config) = runCatching { 82 | val moshi = Moshi.Builder().build() 83 | val configJsonAdapter = moshi.adapter(Config::class.java).indent(" ") 84 | val configContents = configJsonAdapter.toJson(config) 85 | val configWriter = Files.newBufferedWriter(configPath) 86 | configWriter.write(configContents) 87 | configWriter.flush() 88 | config 89 | }.mapError { ConfigManager.Error.FailedToWriteToConfigFile(configPath, it) } 90 | 91 | private fun createConfigFileInFileSystem() = runCatching { 92 | if (Files.notExists(configLocation)) { 93 | val configDirectory = configLocation.parent 94 | println("Creating workflow.properties file at: $configDirectory") 95 | Files.createDirectories(configDirectory) 96 | Files.createFile(configLocation) 97 | } else { 98 | println("Config file found at: $configLocation") 99 | configLocation 100 | } 101 | }.mapError { ConfigManager.Error.FailedToCreateConfig(configLocation, it) } 102 | 103 | } 104 | 105 | private fun Throwable.toConfigError(helperMessage: String) = if (this is FileNotFoundException) { 106 | ConfigManager.Error.ConfigLoadingError.NoConfigFileError(helperMessage + "\n" + message) 107 | } else { 108 | ConfigManager.Error.ConfigLoadingError.ParsingFileError(helperMessage + "\n" + message) 109 | } 110 | -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/base/RealWorkStateManagerTest.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.base 2 | 3 | import com.github.michaelbull.result.Err 4 | import com.github.michaelbull.result.get 5 | import com.github.michaelbull.result.getError 6 | import com.google.common.truth.Truth.assertThat 7 | import com.wclausen.work.config.ConfigManager 8 | import com.wclausen.work.fake.FakeConfigManager 9 | import com.wclausen.work.fake.TestResources 10 | import org.junit.Test 11 | 12 | class RealWorkStateManagerTest { 13 | 14 | val workStateManager = RealWorkStateManager( 15 | TestResources.fakeLogPath, FakeConfigManager(TestResources.fakeConfigPath) 16 | ) 17 | 18 | @Test 19 | fun `GIVEN last update was start task WHEN reading state THEN state is executing`() { 20 | workStateManager.writeUpdateToLog( 21 | WorkUpdate.Start( 22 | "TEST-1", "This is a test", "Done when done" 23 | ) 24 | ) 25 | assertThat( 26 | workStateManager.getWorkflowState().get() 27 | ).isInstanceOf(WorkState.Executing::class.java) 28 | } 29 | 30 | @Test 31 | fun `GIVEN last update was jira comment WHEN reading state THEN state is executing`() { 32 | workStateManager.writeUpdateToLog( 33 | WorkUpdate.JiraComment( 34 | "TEST-1", "Made some progress", "Finish the job" 35 | ) 36 | ) 37 | assertThat( 38 | workStateManager.getWorkflowState().get() 39 | ).isInstanceOf(WorkState.Executing::class.java) 40 | } 41 | 42 | @Test 43 | fun `GIVEN last update was finished task WHEN reading state THEN state is waiting`() { 44 | workStateManager.writeUpdateToLog( 45 | WorkUpdate.FinishedTask( 46 | "TEST-1" 47 | ) 48 | ) 49 | assertThat( 50 | workStateManager.getWorkflowState().get() 51 | ).isInstanceOf(WorkState.Waiting::class.java) 52 | } 53 | 54 | @Test 55 | fun `GIVEN bad config WHEN reading state THEN state is uninitialized`() { 56 | val configManager = FakeConfigManager(TestResources.fakeConfigPath) 57 | configManager.getConfigResult = 58 | Err(ConfigManager.Error.ConfigLoadingError.ParsingFileError("Bad config")) 59 | val workStateManager = RealWorkStateManager( 60 | TestResources.fakeLogPath, configManager 61 | ) 62 | workStateManager.writeUpdateToLog( 63 | WorkUpdate.FinishedTask( 64 | "TEST-1" 65 | ) 66 | ) 67 | assertThat( 68 | workStateManager.getWorkflowState().getError() 69 | ).isInstanceOf(WorkStateManager.Error.ReadStateError.ConfigError::class.java) 70 | } 71 | 72 | @Test 73 | fun `GIVEN multiple updates in log WHEN reading state THEN returns only most recent update`() { 74 | workStateManager.writeUpdateToLog( 75 | WorkUpdate.Start( 76 | "TEST-1", "This is a test", "Done when done" 77 | ) 78 | ) 79 | workStateManager.writeUpdateToLog( 80 | WorkUpdate.JiraComment( 81 | "TEST-1", "Made some progress", "Finish the job" 82 | ) 83 | ) 84 | workStateManager.writeUpdateToLog( 85 | WorkUpdate.FinishedTask( 86 | "TEST-1" 87 | ) 88 | ) 89 | val result = workStateManager.getWorkflowState() 90 | assertThat( 91 | result.get() 92 | ).isInstanceOf(WorkState.Waiting::class.java) 93 | } 94 | 95 | @Test 96 | fun `each update type logs correctly`() { 97 | UpdateType.values().map { it.toTestWorkUpdate() }.forEach { 98 | workStateManager.writeUpdateToLog(it) 99 | assertThat(workStateManager.getWorkflowState().get()).isInstanceOf( 100 | mapToWorkflowState(it)::class.java 101 | ) 102 | } 103 | } 104 | 105 | private fun mapToWorkflowState(workUpdate: WorkUpdate): WorkState = when (workUpdate) { 106 | is WorkUpdate.Start -> WorkState.Executing(taskId = workUpdate.taskId) 107 | is WorkUpdate.JiraComment -> WorkState.Executing(taskId = workUpdate.taskId) 108 | is WorkUpdate.GitCommit -> WorkState.Executing(workUpdate.taskId) 109 | is WorkUpdate.FinishedTask -> WorkState.Waiting 110 | } 111 | 112 | } 113 | 114 | private fun UpdateType.toTestWorkUpdate(): WorkUpdate { 115 | return when (this) { 116 | UpdateType.START_NEW_TASK -> WorkUpdate.Start("TEST-1", "some description", "some goal") 117 | UpdateType.JIRA_COMMENT -> WorkUpdate.JiraComment( 118 | "TEST-1", "(test) did some work", "some goal" 119 | ) 120 | UpdateType.FINISHED_TASK -> WorkUpdate.FinishedTask("TEST-1") 121 | UpdateType.GIT_COMMIT -> WorkUpdate.GitCommit( 122 | "TEST-1", "(test) updated some files", "some goal" 123 | ) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/update/UpdateWorkflow.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.update 2 | 3 | import com.github.michaelbull.result.Ok 4 | import com.github.michaelbull.result.Result 5 | import com.squareup.workflow.RenderContext 6 | import com.squareup.workflow.Snapshot 7 | import com.squareup.workflow.action 8 | import com.wclausen.work.base.WorkState 9 | import com.wclausen.work.command.base.Command 10 | import com.wclausen.work.command.base.StatefulCommandWorkflow 11 | import com.wclausen.work.git.GitService 12 | import com.wclausen.work.jira.JiraService 13 | import com.wclausen.work.jira.api.model.IssueComment 14 | import kotlinx.coroutines.CoroutineScope 15 | import kotlinx.coroutines.Dispatchers 16 | import kotlinx.coroutines.launch 17 | import java.util.concurrent.atomic.AtomicInteger 18 | 19 | class UpdateWorkflow( 20 | private val issueId: String, 21 | private val jiraService: JiraService, 22 | private val gitService: GitService 23 | ) : StatefulCommandWorkflow>() { 24 | 25 | private var responsesCounter = AtomicInteger(0) 26 | 27 | sealed class Error(message: String, cause: Throwable) : Throwable(message, cause) { 28 | class JiraFailedToUpdate(cause: Throwable) : 29 | Error("Failed when trying to update jira issue with comment", cause) 30 | 31 | class GitCommitFailed(cause: Throwable) : 32 | Error("Failed when committing git files, check your git repo status", cause) 33 | } 34 | 35 | sealed class State { 36 | class GetJiraComment : State() 37 | class GetGitCommit(val jiraComment: String) : State() 38 | class Waiting(val commitMsg: String) : State() 39 | class GitFinishedUpdating() : State() 40 | class JiraFinishedUpdating() : State() 41 | class Error(val message: String) : State() 42 | class FinishedBackgroundWork(val message: String) : State() 43 | } 44 | 45 | override fun initialState(props: Unit, snapshot: Snapshot?): State { 46 | return State.GetJiraComment() 47 | } 48 | 49 | override fun render( 50 | props: Unit, 51 | state: State, 52 | context: RenderContext> 53 | ): Command { 54 | return when (state) { 55 | is State.GetJiraComment -> { 56 | Command.Prompt("Please enter a jira comment") { 57 | context.actionSink.send(action { 58 | nextState = State.GetGitCommit(it) 59 | }) 60 | } 61 | } 62 | is State.GetGitCommit -> { 63 | sendCommentToJira(state.jiraComment, context) 64 | Command.MultipleCommands(listOf( 65 | Command.Echo("Sending jira comment"), 66 | Command.Prompt("Please enter a commit message") {commitMsg -> 67 | context.actionSink.send(action { 68 | nextState = State.Waiting(commitMsg) 69 | }) 70 | } 71 | )) 72 | } 73 | is State.Waiting -> { 74 | commit(state.commitMsg, context) 75 | Command.Echo("Committing to git") 76 | } 77 | is State.GitFinishedUpdating -> Command.Echo("completed git commit") 78 | is State.JiraFinishedUpdating -> Command.Echo("Comment added to jira") 79 | is State.Error -> Command.Echo(state.message) 80 | is State.FinishedBackgroundWork -> Command.Echo(state.message) 81 | 82 | } 83 | } 84 | 85 | private fun commit(message: String, context: RenderContext>) { 86 | CoroutineScope(Dispatchers.IO).launch { 87 | gitService.commitProgress(message) 88 | context.actionSink.send(action { 89 | responsesCounter.getAndIncrement() 90 | nextState = if (responsesCounter.get() == 2) { 91 | setOutput(Ok(WorkState.Executing(issueId, "GOAL"))) 92 | State.FinishedBackgroundWork("Commit finished") 93 | } else { 94 | State.GitFinishedUpdating() 95 | } 96 | }) 97 | } 98 | } 99 | 100 | private fun sendCommentToJira( 101 | comment: String, 102 | context: RenderContext> 103 | ) { 104 | CoroutineScope(Dispatchers.IO).launch { 105 | jiraService.commentOnIssue(issueId, IssueComment(comment)) 106 | context.actionSink.send(action { 107 | responsesCounter.getAndIncrement() 108 | nextState = if (responsesCounter.get() == 2) { 109 | setOutput( 110 | Ok( 111 | WorkState.Executing( 112 | issueId, 113 | "GOAL" 114 | ) 115 | ) 116 | ) // TODO: make update goal take WorkState as props 117 | State.FinishedBackgroundWork("Jira finished") 118 | } else { 119 | State.JiraFinishedUpdating() 120 | } 121 | }) 122 | } 123 | } 124 | 125 | override fun snapshotState(state: State): Snapshot { 126 | return Snapshot.EMPTY 127 | } 128 | 129 | } 130 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/comment/CommentWorkflow.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.commands.comment 2 | 3 | import com.github.michaelbull.result.Err 4 | import com.github.michaelbull.result.Ok 5 | import com.github.michaelbull.result.Result 6 | import com.github.michaelbull.result.mapBoth 7 | import com.github.michaelbull.result.mapError 8 | import com.github.michaelbull.result.runCatching 9 | import com.squareup.workflow.RenderContext 10 | import com.squareup.workflow.Snapshot 11 | import com.squareup.workflow.Worker 12 | import com.squareup.workflow.WorkflowAction 13 | import com.squareup.workflow.action 14 | import com.wclausen.work.base.WorkState 15 | import com.wclausen.work.base.WorkUpdate 16 | import com.wclausen.work.base.requireExecuting 17 | import com.wclausen.work.command.base.Command 18 | import com.wclausen.work.command.base.CommandOutputWorkflow 19 | import com.wclausen.work.command.base.Output 20 | import com.wclausen.work.command.start.log 21 | import com.wclausen.work.command.start.output 22 | import com.wclausen.work.command.start.sendToParent 23 | import com.wclausen.work.jira.JiraService 24 | import com.wclausen.work.jira.api.model.IssueComment 25 | import com.wclausen.work.jira.api.model.JiraComment 26 | import com.wclausen.work.kotlinext.Do 27 | import javax.inject.Inject 28 | 29 | class CommentWorkflow @Inject constructor( 30 | private val jiraService: JiraService 31 | ) : CommandOutputWorkflow() { 32 | 33 | companion object { 34 | const val NO_TASK_ERROR_MESSAGE = "You must select a task before commenting" 35 | const val COMMENT_PROMPT = "Enter your comment" 36 | const val SENDING_COMMENT_TO_JIRA_MESSAGE = "Sending comment to Jira" 37 | const val SUCCESS_MESSAGE = "Successfully added comment" 38 | const val FAILED_TO_UPDATE_JIRA = "Attempt to update jira comment failed" 39 | } 40 | 41 | sealed class State { 42 | class PromptForComment : State() 43 | class ReceivedComment(val comment: String) : State() 44 | sealed class Finished(val message: String) : State() { 45 | class Success : Finished(SUCCESS_MESSAGE) 46 | class Error(val error: CommentWorkflow.Error) : Finished(error.message!!) 47 | } 48 | } 49 | 50 | sealed class Error(message: String, cause: Throwable) : Throwable(message, cause) { 51 | class JiraCommentFailed(cause: Throwable) : Error(FAILED_TO_UPDATE_JIRA, cause) 52 | class InvalidInitialState : Error(NO_TASK_ERROR_MESSAGE, IllegalStateException()) 53 | } 54 | 55 | override fun initialState(props: WorkState, snapshot: Snapshot?): State { 56 | return when (props) { 57 | WorkState.Uninitialized, WorkState.Waiting -> { 58 | State.Finished.Error(Error.InvalidInitialState()) 59 | } 60 | is WorkState.Executing -> State.PromptForComment() 61 | } 62 | } 63 | 64 | override fun render( 65 | props: WorkState, state: State, context: RenderContext> 66 | ) { 67 | val executingProps = props.requireExecuting() 68 | Do exhaustive when (state) { 69 | is State.PromptForComment -> context.output(Command.Prompt(COMMENT_PROMPT) { message -> 70 | context.actionSink.send(action { 71 | nextState = State.ReceivedComment(message) 72 | }) 73 | }) 74 | is State.ReceivedComment -> { 75 | context.output(Command.Echo(SENDING_COMMENT_TO_JIRA_MESSAGE)) 76 | context.sendCommentToJira(executingProps.taskId, state.comment, jiraService) { 77 | it.goesToNextState({ 78 | State.Finished.Success() 79 | }) 80 | } 81 | } 82 | is State.Finished -> { 83 | context.log( 84 | WorkUpdate.JiraComment( 85 | executingProps.taskId, 86 | state.message, 87 | executingProps.goal 88 | ) 89 | ) 90 | context.output(Command.Echo(state.message)) 91 | context.finish(state) 92 | } 93 | } 94 | } 95 | 96 | override fun snapshotState(state: State): Snapshot { 97 | return Snapshot.EMPTY 98 | } 99 | 100 | private fun RenderContext.sendCommentToJira( 101 | taskId: String, 102 | comment: String, 103 | jiraService: JiraService, 104 | andThen: (Result) -> WorkflowAction 105 | ) { 106 | runningWorker(Worker.from { 107 | runCatching { 108 | jiraService.commentOnIssue(taskId, IssueComment(comment)) 109 | }.mapError { Error.JiraCommentFailed(it) } 110 | }) { 111 | andThen.invoke(it) 112 | } 113 | } 114 | 115 | } 116 | 117 | private fun Result.goesToNextState( 118 | successState: (V) -> CommentWorkflow.State, failureState: (E) -> CommentWorkflow.State = { 119 | CommentWorkflow.State.Finished.Error(it) 120 | } 121 | ) = action> { 122 | nextState = mapBoth(success = { 123 | successState.invoke(it) 124 | }, failure = { 125 | failureState.invoke(it) 126 | }) 127 | } 128 | 129 | private fun RenderContext>.finish(result: CommentWorkflow.State.Finished) { 130 | val output = when (result) { 131 | is CommentWorkflow.State.Finished.Success -> Ok(Unit) 132 | is CommentWorkflow.State.Finished.Error -> Err(result.error) 133 | } 134 | sendToParent(Output.Final(output)) 135 | } -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/inject/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.inject 2 | 3 | import com.github.ajalt.clikt.core.subcommands 4 | import com.wclausen.work.base.MainCommandOutputWorkflowRunner 5 | import com.wclausen.work.base.RealWorkStateManager 6 | import com.wclausen.work.base.WorkStateManager 7 | import com.wclausen.work.command.base.MainWorkflow 8 | import com.wclausen.work.command.base.WorkCommand 9 | import com.wclausen.work.command.comment.CommentCommand 10 | import com.wclausen.work.command.commit.CommitCommand 11 | import com.wclausen.work.command.commit.CommitWorkflow 12 | import com.wclausen.work.command.diff.DiffCommand 13 | import com.wclausen.work.command.done.DoneCommand 14 | import com.wclausen.work.command.init.CreateConfigWorkflow 15 | import com.wclausen.work.command.init.InitCommand 16 | import com.wclausen.work.command.init.InitWorkflow 17 | import com.wclausen.work.command.init.NoOpCommandWorkflow 18 | import com.wclausen.work.command.start.StartCommand 19 | import com.wclausen.work.command.start.StartWorkflow 20 | import com.wclausen.work.command.update.UpdateCommand 21 | import com.wclausen.work.commands.comment.CommentWorkflow 22 | import com.wclausen.work.config.ConfigFileInfo 23 | import com.wclausen.work.config.ConfigManager 24 | import com.wclausen.work.config.RealConfigManager 25 | import com.wclausen.work.config.WorkLogFileInfo 26 | import com.wclausen.work.git.GitService 27 | import com.wclausen.work.git.RealGitService 28 | import com.wclausen.work.jira.JiraService 29 | import com.wclausen.work.jira.realJiraService 30 | import com.wclausen.work.task.RealTaskManager 31 | import com.wclausen.work.task.TaskManager 32 | import dagger.Component 33 | import dagger.Module 34 | import dagger.Provides 35 | import kotlinx.coroutines.ExperimentalCoroutinesApi 36 | import org.eclipse.jgit.api.Git 37 | import org.eclipse.jgit.lib.RepositoryBuilder 38 | import java.io.File 39 | import java.nio.file.Path 40 | 41 | @Module 42 | class AppModule { 43 | 44 | @Provides 45 | fun taskManager(): TaskManager = RealTaskManager() 46 | 47 | @Provides 48 | @ConfigFile 49 | fun configFile(): Path = ConfigFileInfo.configFilePath 50 | 51 | @Provides 52 | @WorkLogFile 53 | fun workLogFile(): Path = WorkLogFileInfo.workLogFilePath 54 | 55 | @Provides 56 | fun configManager(@ConfigFile configFile: Path): ConfigManager = RealConfigManager(configFile) 57 | 58 | @Provides 59 | fun workStateManager( 60 | @WorkLogFile workLogFile: Path, 61 | configManager: ConfigManager 62 | ): WorkStateManager = RealWorkStateManager(workLogFile, configManager) 63 | 64 | @Provides 65 | fun jiraService(): JiraService = realJiraService 66 | 67 | @Provides 68 | fun gitClient(): Git { 69 | val workingDir = File(System.getProperty("user.dir")) 70 | val repo = RepositoryBuilder().findGitDir(workingDir).build() 71 | return Git(repo) 72 | } 73 | 74 | @Provides 75 | fun gitService(git: Git): GitService = RealGitService(git) 76 | 77 | @Provides 78 | fun createConfigWorkflow(configManager: ConfigManager): CreateConfigWorkflow = 79 | CreateConfigWorkflow(configManager) 80 | 81 | @Provides 82 | fun initCommandWorkflow( 83 | workStateManager: WorkStateManager, initWorkflow: InitWorkflow 84 | ): MainWorkflow = 85 | MainWorkflow(workStateManager, initWorkflow, NoOpCommandWorkflow()) 86 | 87 | @ExperimentalCoroutinesApi 88 | @Provides 89 | @InitCommandRunner 90 | fun initWorkflowRunner(initWorkflow: MainWorkflow): MainCommandOutputWorkflowRunner { 91 | return MainCommandOutputWorkflowRunner(initWorkflow) 92 | } 93 | 94 | @Provides 95 | fun startCommandWorkflow( 96 | workStateManager: WorkStateManager, initWorkflow: InitWorkflow, startWorkflow: StartWorkflow 97 | ): MainWorkflow = MainWorkflow(workStateManager, initWorkflow, startWorkflow) 98 | 99 | 100 | @ExperimentalCoroutinesApi 101 | @Provides 102 | @StartCommandRunner 103 | fun startWorkflowRunner(startWorkflow: MainWorkflow): MainCommandOutputWorkflowRunner { 104 | return MainCommandOutputWorkflowRunner(startWorkflow) 105 | } 106 | 107 | @Provides 108 | fun commentCommandWorkflow( 109 | workStateManager: WorkStateManager, 110 | initWorkflow: InitWorkflow, 111 | commentWorkflow: CommentWorkflow 112 | ): MainWorkflow = MainWorkflow(workStateManager, initWorkflow, commentWorkflow) 113 | 114 | 115 | @ExperimentalCoroutinesApi 116 | @Provides 117 | @CommentCommandRunner 118 | fun commentWorkflowRunner(commentWorkflow: MainWorkflow): MainCommandOutputWorkflowRunner { 119 | return MainCommandOutputWorkflowRunner(commentWorkflow) 120 | } 121 | 122 | @Provides 123 | fun commitCommandWorkflow( 124 | workStateManager: WorkStateManager, 125 | initWorkflow: InitWorkflow, 126 | commitWorkflow: CommitWorkflow 127 | ): MainWorkflow = MainWorkflow(workStateManager, initWorkflow, commitWorkflow) 128 | 129 | 130 | @ExperimentalCoroutinesApi 131 | @Provides 132 | @CommitCommandRunner 133 | fun commitWorkflowRunner(commitWorkflow: MainWorkflow): MainCommandOutputWorkflowRunner { 134 | return MainCommandOutputWorkflowRunner(commitWorkflow) 135 | } 136 | 137 | @Provides 138 | fun workCommand( 139 | initCommand: InitCommand, 140 | startCommand: StartCommand, 141 | commentCommand: CommentCommand, 142 | commitCommand: CommitCommand 143 | ): WorkCommand = WorkCommand().subcommands( 144 | initCommand, 145 | startCommand, 146 | commentCommand, 147 | commitCommand, 148 | UpdateCommand(), 149 | DiffCommand(), 150 | DoneCommand() 151 | ) 152 | 153 | } 154 | 155 | @Component(modules = [AppModule::class]) 156 | interface AppComponent { 157 | val workCommand: WorkCommand 158 | } -------------------------------------------------------------------------------- /src/test/java/com/wclausen/work/command/init/CreateConfigWorkflowTest.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.init 2 | 3 | import com.github.michaelbull.result.Err 4 | import com.github.michaelbull.result.expectError 5 | import com.github.michaelbull.result.get 6 | import com.google.common.truth.Truth.assertThat 7 | import com.squareup.workflow.testing.testFromStart 8 | import com.wclausen.work.command.base.Command 9 | import com.wclausen.work.command.base.Output 10 | import com.wclausen.work.config.Config 11 | import com.wclausen.work.config.ConfigManager 12 | import com.wclausen.work.config.RealConfigManager 13 | import com.wclausen.work.fake.FakeConfigManager 14 | import com.wclausen.work.fake.TestResources 15 | import com.wclausen.work.workflowext.assertIsPrompt 16 | import com.wclausen.work.workflowext.first 17 | import com.wclausen.work.workflowext.then 18 | import org.junit.Rule 19 | import org.junit.Test 20 | import org.junit.rules.TemporaryFolder 21 | import java.io.IOException 22 | 23 | class CreateConfigWorkflowTest { 24 | 25 | @get:Rule 26 | val temporaryFolder = TemporaryFolder() 27 | 28 | @Test 29 | fun `GIVEN no errors WHEN running workflow THEN completes entire flow`() { 30 | // Test of entire flow, mostly to show the kinds of tests possible with workflow :) 31 | val expected_username = "some_username" 32 | val expected_token = "some_token" 33 | val tempConfigPath = TestResources.fakeConfigPath 34 | val createConfigWorkflow = CreateConfigWorkflow(RealConfigManager(tempConfigPath)) 35 | createConfigWorkflow.testFromStart { 36 | first().asksForUsername().whenUsernameProvided(expected_username) 37 | then().asksForToken().whenTokenProvided(expected_token) 38 | then().emitsSavingFileMessage() 39 | then() 40 | .emitsConfig() 41 | .withDetails( 42 | username = expected_username, 43 | token = expected_token) 44 | } 45 | } 46 | 47 | @Test 48 | fun `GIVEN io error from config creation WHEN running workflow THEN reports error`() { 49 | // Test of entire flow, mostly to show the kinds of tests possible with workflow :) 50 | val expected_username = "some_username" 51 | val expected_token = "some_token" 52 | val tempConfigPath = temporaryFolder.newFile().toPath() 53 | val throwingConfigCreator = FakeConfigManager(tempConfigPath) 54 | throwingConfigCreator.getConfigResult = 55 | Err(ConfigManager.Error.ConfigLoadingError.NoConfigFileError("No config present")) 56 | throwingConfigCreator.createConfigResult = 57 | Err(ConfigManager.Error.FailedToCreateConfig(tempConfigPath, IOException())) 58 | val createConfigWorkflow = CreateConfigWorkflow(throwingConfigCreator) 59 | createConfigWorkflow.testFromStart { 60 | first().asksForUsername().whenUsernameProvided(expected_username) 61 | then().asksForToken().whenTokenProvided(expected_token) 62 | then().emitsSavingFileMessage() 63 | then() 64 | .emitsError() 65 | .withMessage("Failed to create config file") 66 | } 67 | } 68 | 69 | @Test 70 | fun `GIVEN io error writing config data WHEN running workflow THEN reports error`() { 71 | // Test of entire flow, mostly to show the kinds of tests possible with workflow :) 72 | val expected_username = "some_username" 73 | val expected_token = "some_token" 74 | val tempConfigPath = temporaryFolder.newFile().toPath() 75 | val throwingConfigCreator = FakeConfigManager(tempConfigPath) 76 | throwingConfigCreator.getConfigResult = 77 | Err(ConfigManager.Error.ConfigLoadingError.NoConfigFileError("No config present")) 78 | throwingConfigCreator.createConfigResult = 79 | Err(ConfigManager.Error.FailedToWriteToConfigFile(tempConfigPath, IOException())) 80 | val createConfigWorkflow = CreateConfigWorkflow(throwingConfigCreator) 81 | createConfigWorkflow.testFromStart { 82 | first().asksForUsername().whenUsernameProvided(expected_username) 83 | then().asksForToken().whenTokenProvided(expected_token) 84 | then().emitsSavingFileMessage() 85 | then() 86 | .emitsError() 87 | .withMessage("Failed to write config data") 88 | } 89 | } 90 | } 91 | 92 | private fun Throwable.withMessage(expected: String) { 93 | assertThat(message).contains(expected) 94 | } 95 | 96 | private fun Output.emitsError(): Throwable { 97 | assertThat(this).isInstanceOf(Output.Final::class.java) 98 | return (this as Output.Final).result.expectError { "expected error but not present" } 99 | } 100 | 101 | private fun Output.emitsSavingFileMessage() { 102 | assertThat(this).isInstanceOf(Output.InProgress::class.java) 103 | val command = (this as Output.InProgress).command 104 | assertThat(command).isInstanceOf(Command.Echo::class.java) 105 | val message = (command as Command.Echo).output 106 | assertThat(message).startsWith(CreateConfigWorkflow.SAVING_CONFIG_MESSAGE) 107 | } 108 | 109 | private fun Config.withDetails(username: String, token : String) { 110 | assertThat(jira.jira_email).isEqualTo(username) 111 | assertThat(jira.jira_api_token).isEqualTo(token) 112 | } 113 | 114 | private fun Output.emitsConfig(): Config { 115 | assertThat(this).isInstanceOf(Output.Final::class.java) 116 | return (this as Output.Final).result.get() as Config 117 | } 118 | 119 | private fun Command.Prompt.whenTokenProvided(token: String) = nextAction.invoke(token) 120 | 121 | private fun Output.asksForToken(): Command.Prompt { 122 | return assertIsPrompt(CreateConfigWorkflow.GET_JIRA_API_TOKEN_PROMPT) 123 | } 124 | 125 | private fun Command.Prompt.whenUsernameProvided(username: String) = nextAction.invoke(username) 126 | 127 | private fun Output.asksForUsername(): Command.Prompt { 128 | return assertIsPrompt(CreateConfigWorkflow.GET_USERNAME_PROMPT) 129 | } 130 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | # Determine the Java command to use to start the JVM. 86 | if [ -n "$JAVA_HOME" ] ; then 87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 88 | # IBM's JDK on AIX uses strange locations for the executables 89 | JAVACMD="$JAVA_HOME/jre/sh/java" 90 | else 91 | JAVACMD="$JAVA_HOME/bin/java" 92 | fi 93 | if [ ! -x "$JAVACMD" ] ; then 94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 95 | 96 | Please set the JAVA_HOME variable in your environment to match the 97 | location of your Java installation." 98 | fi 99 | else 100 | JAVACMD="java" 101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 102 | 103 | Please set the JAVA_HOME variable in your environment to match the 104 | location of your Java installation." 105 | fi 106 | 107 | # Increase the maximum file descriptors if we can. 108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 109 | MAX_FD_LIMIT=`ulimit -H -n` 110 | if [ $? -eq 0 ] ; then 111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 112 | MAX_FD="$MAX_FD_LIMIT" 113 | fi 114 | ulimit -n $MAX_FD 115 | if [ $? -ne 0 ] ; then 116 | warn "Could not set maximum file descriptor limit: $MAX_FD" 117 | fi 118 | else 119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 120 | fi 121 | fi 122 | 123 | # For Darwin, add options to specify how the application appears in the dock 124 | if $darwin; then 125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 126 | fi 127 | 128 | # For Cygwin or MSYS, switch paths to Windows format before running java 129 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 132 | JAVACMD=`cygpath --unix "$JAVACMD"` 133 | 134 | # We build the pattern for arguments to be converted via cygpath 135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 136 | SEP="" 137 | for dir in $ROOTDIRSRAW ; do 138 | ROOTDIRS="$ROOTDIRS$SEP$dir" 139 | SEP="|" 140 | done 141 | OURCYGPATTERN="(^($ROOTDIRS))" 142 | # Add a user-defined pattern to the cygpath arguments 143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 145 | fi 146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 147 | i=0 148 | for arg in "$@" ; do 149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 151 | 152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 154 | else 155 | eval `echo args$i`="\"$arg\"" 156 | fi 157 | i=`expr $i + 1` 158 | done 159 | case $i in 160 | 0) set -- ;; 161 | 1) set -- "$args0" ;; 162 | 2) set -- "$args0" "$args1" ;; 163 | 3) set -- "$args0" "$args1" "$args2" ;; 164 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 165 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 166 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 167 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 168 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 169 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 170 | esac 171 | fi 172 | 173 | # Escape application args 174 | save () { 175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 176 | echo " " 177 | } 178 | APP_ARGS=`save "$@"` 179 | 180 | # Collect all arguments for the java command, following the shell quoting and substitution rules 181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 182 | 183 | exec "$JAVACMD" "$@" 184 | -------------------------------------------------------------------------------- /.idea/uiDesigner.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /src/main/java/com/wclausen/work/command/start/StartWorkflow.kt: -------------------------------------------------------------------------------- 1 | package com.wclausen.work.command.start 2 | 3 | import com.github.michaelbull.result.Err 4 | import com.github.michaelbull.result.Ok 5 | import com.github.michaelbull.result.Result 6 | import com.github.michaelbull.result.map 7 | import com.github.michaelbull.result.mapBoth 8 | import com.github.michaelbull.result.mapError 9 | import com.github.michaelbull.result.runCatching 10 | import com.squareup.workflow.RenderContext 11 | import com.squareup.workflow.Snapshot 12 | import com.squareup.workflow.Worker 13 | import com.squareup.workflow.WorkflowAction 14 | import com.squareup.workflow.action 15 | import com.wclausen.work.base.WorkState 16 | import com.wclausen.work.base.WorkUpdate 17 | import com.wclausen.work.command.base.Command 18 | import com.wclausen.work.command.base.CommandOutputWorkflow 19 | import com.wclausen.work.command.base.Output 20 | import com.wclausen.work.git.GitService 21 | import com.wclausen.work.jira.JiraService 22 | import com.wclausen.work.jira.api.model.IssueBean 23 | import com.wclausen.work.kotlinext.Do 24 | import kotlinx.coroutines.CoroutineScope 25 | import kotlinx.coroutines.Dispatchers 26 | import kotlinx.coroutines.async 27 | import kotlinx.coroutines.delay 28 | import org.eclipse.jgit.lib.Ref 29 | import java.util.UUID 30 | import javax.inject.Inject 31 | 32 | typealias RenderingContext = RenderContext> 33 | 34 | class StartWorkflow @Inject constructor( 35 | private val jiraService: JiraService, private val gitService: GitService 36 | ) : CommandOutputWorkflow() { 37 | 38 | companion object { 39 | const val LOADING_TASKS_MESSAGE = "Loading tasks from jira..." 40 | 41 | const val LOADING_TASKS_FAILED_MESSAGE = "Failed when loading jira tasks for user" 42 | const val TASK_SELECTION_FAILED_MESSAGE = "Selected task is not within bounds" 43 | const val GIT_BRANCH_CHECKOUT_FAILED_MESSAGE = "Failed to checkout branch for task" 44 | } 45 | 46 | sealed class State { 47 | class NoTasks : State() 48 | sealed class TaskSelectionNeeded(val tasks: List, val workerKey: String = "") : 49 | State() { 50 | class FirstTime(tasks: List) : TaskSelectionNeeded(tasks) 51 | class AfterInvalidSelection(tasks: List) : TaskSelectionNeeded( 52 | tasks, UUID.randomUUID().toString() 53 | ) 54 | } 55 | 56 | class TaskSelected(val selectedTask: IssueBean) : State() 57 | class Success(val selectedTask: IssueBean, val goal: String, val branchRef: Ref) : State() 58 | data class Error(val error: StartWorkflow.Error) : State() 59 | } 60 | 61 | sealed class Error(message: String, throwable: Throwable) : Throwable(message, throwable) { 62 | class LoadTasksError(throwable: Throwable) : Error( 63 | LOADING_TASKS_FAILED_MESSAGE, throwable 64 | ) 65 | 66 | class TaskSelectionError(throwable: Throwable) : Error( 67 | TASK_SELECTION_FAILED_MESSAGE, throwable 68 | ) 69 | 70 | class CheckoutBranchError(cause: GitService.GitError) : Error( 71 | GIT_BRANCH_CHECKOUT_FAILED_MESSAGE, cause 72 | ) 73 | } 74 | 75 | override fun initialState(props: WorkState, snapshot: Snapshot?): State { 76 | return State.NoTasks() 77 | } 78 | 79 | override fun render(props: WorkState, state: State, context: RenderingContext) { 80 | Do exhaustive when (state) { 81 | is State.NoTasks -> { 82 | context.output(loadingTasksMessage()) 83 | context.loadTasks { result -> 84 | result.goesToNextState({ State.TaskSelectionNeeded.FirstTime(it.sortedBy { it.key }) }) 85 | } 86 | } 87 | is State.TaskSelectionNeeded -> { 88 | context.output( 89 | taskSelectionInfoMessage(state), workerKey = state.workerKey + "info" 90 | ) 91 | context.output(getTaskSelectionPromptCommand(state.tasks, context) { result -> 92 | result.goesToNextState({ State.TaskSelected(it) }, 93 | { State.TaskSelectionNeeded.AfterInvalidSelection(state.tasks) }) 94 | }, workerKey = state.workerKey) 95 | } 96 | is State.TaskSelected -> { 97 | promptForGoalAndCheckoutBranch(context, state) { 98 | it.mapError { 99 | Error.CheckoutBranchError(GitService.GitError.CheckoutFailedError(it)) 100 | }.goesToNextState({ 101 | State.Success( 102 | selectedTask = state.selectedTask, 103 | goal = it.second, 104 | branchRef = it.first 105 | ) 106 | }) 107 | } 108 | } 109 | is State.Success -> { 110 | context.log( 111 | WorkUpdate.Start( 112 | state.selectedTask.key, 113 | state.selectedTask.fields.summary, 114 | state.goal 115 | ) 116 | ) 117 | context.output(savedGoalMessage()) 118 | context.output(checkedOutBranchMessage(state.branchRef)) 119 | context.finish() 120 | } 121 | is State.Error -> { 122 | context.output(Command.Echo(state.error.message!!)) 123 | context.finish() 124 | } 125 | } 126 | } 127 | 128 | private fun checkoutBranch(context: RenderingContext, branchName: String) { 129 | context.runningWorker(Worker.from { 130 | gitService.checkout(branchName) 131 | }, "checkout_branch") { result -> 132 | action { 133 | result.mapBoth({ setOutput(Output.InProgress(Command.Echo("Checked out branch: $it"))) }, 134 | { setOutput(Output.InProgress(Command.Echo("Checkout failed: $it"))) }) 135 | } 136 | } 137 | } 138 | 139 | private fun savedGoalMessage() = Command.Echo("Saved goal") 140 | 141 | private fun checkedOutBranchMessage(branchRef: Ref) = 142 | Command.Echo("Checked out branch: ${branchRef.name}") 143 | 144 | //region [State.NoTasks] functions 145 | private fun loadingTasksMessage() = Command.Echo(LOADING_TASKS_MESSAGE) 146 | 147 | private suspend fun getTasksFromJira() = runCatching { 148 | // TODO: this won't return anything until I read current user email from config 149 | jiraService.getTasksForCurrentUser() 150 | }.mapError { Error.LoadTasksError(it) } 151 | 152 | private fun RenderingContext.loadTasks(andThen: (Result, Error>) -> WorkflowAction>) { 153 | runningWorker(Worker.create { 154 | val taskSummaries = getTasksFromJira().map { it.issues } 155 | emit(taskSummaries) 156 | }, "load_tasks") { 157 | andThen.invoke(it) 158 | } 159 | } 160 | //endregion 161 | 162 | private fun taskSelectionInfoMessage(state: State.TaskSelectionNeeded): Command.Echo { 163 | return when (state) { 164 | is State.TaskSelectionNeeded.FirstTime -> showTasks(state.tasks) 165 | is State.TaskSelectionNeeded.AfterInvalidSelection -> invalidSelectionMessage(state.tasks) 166 | } 167 | } 168 | 169 | private fun showTasks(tasks: List) = Command.Echo(formattedTaskList(tasks)) 170 | 171 | private fun invalidSelectionMessage(tasks: List) = 172 | Command.Echo("Your selection was invalid. Please select within [${1..tasks.size}]") 173 | 174 | private fun getTaskSelectionPromptCommand( 175 | tasks: List, 176 | context: RenderingContext, 177 | nextAction: (Result) -> WorkflowAction> 178 | ): Command { 179 | return Command.Prompt("Please select a task") { 180 | context.actionSink.send( 181 | nextAction.invoke(validateSelection( 182 | it, 1..tasks.size 183 | ).map { tasks[it - 1] }) 184 | ) 185 | } 186 | } 187 | 188 | private fun validateSelection( 189 | selectedTaskString: String, selectionRange: IntRange 190 | ): Result = runCatching { 191 | val selectedTaskIdx = selectedTaskString.toInt() 192 | if (selectionRange.contains(selectedTaskIdx)) { 193 | selectedTaskIdx 194 | } else { 195 | throw IllegalArgumentException("Selection of task must be within bounds") 196 | } 197 | }.mapError { Error.TaskSelectionError(it) } 198 | 199 | private fun promptForGoalAndCheckoutBranch( 200 | context: RenderingContext, 201 | state: State.TaskSelected, 202 | nextAction: (Result, Throwable>) -> WorkflowAction> 203 | ) { 204 | context.runningWorker(Worker.create { 205 | val gitResult = CoroutineScope(Dispatchers.IO).async { 206 | gitService.checkout(state.selectedTask.key) 207 | } 208 | var goal = "" 209 | emit(Output.InProgress(promptForGoalCommands(state, onResult = { 210 | saveGoal(it) 211 | goal = it 212 | }))) 213 | while (goal == "") { 214 | delay(100) 215 | } 216 | val result = gitResult.await().map { it to goal } 217 | emit(Output.Final(result)) 218 | }) { 219 | when (it) { 220 | is Output.InProgress, is Output.Log -> sendToParent(it) 221 | is Output.Final -> nextAction.invoke(it.result) 222 | } 223 | } 224 | } 225 | 226 | private fun saveGoal(goal: String) { 227 | 228 | } 229 | 230 | private fun promptForGoalCommands( 231 | state: State.TaskSelected, onResult: (String) -> Unit 232 | ): Command.MultipleCommands { 233 | return Command.MultipleCommands( 234 | listOf( 235 | showSelectedTask(state.selectedTask.key), 236 | promptForTaskGoal(state.selectedTask, onResult) 237 | ) 238 | ) 239 | } 240 | 241 | 242 | private fun showSelectedTask(selectedTaskId: String): Command = 243 | Command.Echo("Selected task: $selectedTaskId") 244 | 245 | private fun promptForTaskGoal(selectedTask: IssueBean, onResult: (String) -> Unit): Command { 246 | return Command.Prompt("Describe the finished state of the task", onResult) 247 | } 248 | 249 | private fun finish(it: Error.LoadTasksError) = action { 250 | setOutput(Output.Final(Err(it))) 251 | } 252 | 253 | private fun showError() = action { 254 | setOutput(Output.InProgress(Command.Echo("Failed with error"))) 255 | } 256 | 257 | private fun sendToParent(output: Output) = action { 258 | Do exhaustive when (output) { 259 | is Output.InProgress -> setOutput(output) 260 | is Output.Log -> setOutput(output) 261 | is Output.Final -> { 262 | output.result.mapBoth({ 263 | setOutput(Output.Final(Ok(Unit))) 264 | }, { 265 | setOutput(Output.Final(Err(it))) 266 | }) 267 | } 268 | } 269 | } 270 | 271 | override fun snapshotState(state: State) = Snapshot.EMPTY 272 | 273 | private fun Result.goesToNextState( 274 | successState: (V) -> State, failureState: (E) -> State = { 275 | State.Error(it) 276 | } 277 | ) = action { 278 | nextState = mapBoth(success = { 279 | successState.invoke(it) 280 | }, failure = { 281 | failureState.invoke(it) 282 | }) 283 | } 284 | } 285 | 286 | private fun RenderingContext.finish() { 287 | sendToParent(Output.Final(Ok(Unit))) 288 | } 289 | 290 | fun RenderContext>.sendToParent(output: Output.Final) { 291 | runningWorker(Worker.from { output }) { 292 | action { 293 | setOutput(it) 294 | } 295 | } 296 | } 297 | 298 | fun RenderContext>.log(workUpdate: WorkUpdate) { 299 | runningWorker(Worker.from { 300 | Output.Log(workUpdate) 301 | }) { 302 | action { 303 | setOutput(it) 304 | } 305 | } 306 | } 307 | 308 | fun RenderContext>.output( 309 | command: Command, workerKey: String = command.toString() 310 | ) { 311 | runningWorker(Worker.from { 312 | Output.InProgress(command) 313 | }, workerKey) { 314 | action { 315 | setOutput(it) 316 | } 317 | } 318 | } 319 | 320 | private fun List.toSummaries() = mapIndexed { idx, issue -> 321 | "\t" + issue.key + ": " + issue.fields.summary 322 | } 323 | 324 | fun formattedTaskList(tasks: List) = 325 | "Your current tasks are:\n" + tasks.toSummaries().joinToString("\n") --------------------------------------------------------------------------------