├── 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 |
4 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 |
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 |
--------------------------------------------------------------------------------
/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")
--------------------------------------------------------------------------------