qingmei2/blogs

[译] 编写AndroidStudio插件(四):整合Jira

qingmei2 opened this issue · 0 comments

[译] 编写AndroidStudio插件(四):集成Jira

原文:Write an Android Studio Plugin Part 4: Jira Integration
作者:Marcos Holgado
译者:却把清梅嗅
《编写AndroidStudio插件》系列是 IntelliJ IDEA 官方推荐的学习IDE插件开发的博客专栏,希望对有需要的读者有所帮助。

在本系列的第三部分中,我们学习了如何使用Component对数据进行持久化,并利用这些数据来创建新的设置页面。在今天的文章中,我们将使用这些数据将Jira与我们的插件快速集成在一起。

请记住,您可以在GitHub上找到本系列的所有代码,还可以在对应的分支上查看每篇文章的相关代码,本文的代码在Part4分支中。

https://github.com/marcosholgado/plugin-medium

我们要做什么?

今天这篇文章的目的是解释如何将第三方API和库集成到插件中。我将应用一个简单的MVP模式,您可以更改为MVC或任何您喜欢的开发模式。

今天,我们将Jira集成到我们的插件中,我们要做的是能够在Android Studio中将Jira Scrum板上的issue移至下一栏。因为Jiraissue ID将基于我们当前的git分支,所以我们的插件将从我们当前的分支中解析issue ID,而不是强迫用户手动输入或从其他位置选择issue ID

在开始前,我们先进行一些假设。

  • 1、在移动issue(比如记录时间等)之前,您无需填写任何必填字段,否则你的UI将需要额外的字段供用户输入;
  • 2、我们将使用Jira Cloud Platform API v3
  • 3、为了简单起见,我们还将使用 基本身份验证(Basic auth) ,因为我们的目标是学习如何集成第三方工具,而不是如何在Jira中进行正确的身份验证。

除非您正在构建仅供内部使用的工具(例如脚本和机器人),否则我们(Jira)不建议使用基本身份验证。

这就是我的面板页在Jira中展示出来的样子,issue只能向前推进,并且只能从一列移至下一列,您不能跳过流程中的任何一列。

第一步

首先,我们将在设置页面中添加更多字段。根据我们的目标,我们需要添加一个新的regex字段,其中将包含一个正则表达式以从当前分支中提取issue ID。我们还将需要一个Jira URL字段,该字段将用作API调用的基本URL。最后,Jira API需要使用auth令牌而不是密码,为了清楚起见,我将旧密码字段的名称更改为token

这些变动应该简单直观,如下所示:

@State(name = "JiraConfiguration",
        storages = [Storage(value = "jiraConfiguration.xml")])
class JiraComponent(project: Project? = null) :
        AbstractProjectComponent(project),
        Serializable,
        PersistentStateComponent<JiraComponent> {

    var username: String = ""
    var token: String = ""
    var url: String = ""
    var regex: String = ""

    override fun getState(): JiraComponent? = this

    override fun loadState(state: JiraComponent) =
            XmlSerializerUtil.copyBean(state, this)

    companion object {
        fun getInstance(project: Project): JiraComponent =
                project.getComponent(JiraComponent::class.java)
    }
}
class JiraSettings(private val project: Project): Configurable, DocumentListener {
    private val tokenField: JPasswordField = JPasswordField()
    private val txtUsername: JTextField = JTextField()
    private val txtUrl: JTextField = JTextField()
    private val txtRegEx: JTextField = JTextField()

    private var modified = false

    override fun isModified(): Boolean = modified

    override fun getDisplayName(): String = "MyPlugin Jira"

    override fun apply() {
        val config = JiraComponent.getInstance(project)
        config.username = txtUsername.text
        config.token = String(tokenField.password)
        config.url = txtUrl.text
        config.regex = txtRegEx.text

        modified = false
    }

    override fun changedUpdate(e: DocumentEvent?) {
        modified = true
    }

    override fun insertUpdate(e: DocumentEvent?) {
        modified = true
    }

    override fun removeUpdate(e: DocumentEvent?) {
        modified = true
    }

    override fun createComponent(): JComponent {

        val mainPanel = JPanel()
        mainPanel.setBounds(0, 0, 452, 254)
        mainPanel.layout = null

        val lblUsername = JLabel("Username")
        lblUsername.setBounds(30, 25, 83, 16)
        mainPanel.add(lblUsername)

        val lblPassword = JLabel("Token")
        lblPassword.setBounds(30, 74, 83, 16)
        mainPanel.add(lblPassword)

        val lblUrl = JLabel("Jira URL")
        lblUrl.setBounds(30, 123, 83, 16)
        mainPanel.add(lblUrl)

        val lblRegEx = JLabel("RegEx")
        lblRegEx.setBounds(30, 172, 83, 16)
        mainPanel.add(lblRegEx)

        txtUsername.setBounds(125, 20, 291, 26)
        txtUsername.columns = 10
        mainPanel.add(txtUsername)

        tokenField.setBounds(125, 69, 291, 26)
        mainPanel.add(tokenField)

        txtUrl.setBounds(125, 118, 291, 26)
        txtUrl.columns = 10
        mainPanel.add(txtUrl)

        txtRegEx.setBounds(125, 167, 291, 26)
        txtRegEx.columns = 10
        mainPanel.add(txtRegEx)

        val config = JiraComponent.getInstance(project)
        txtUsername.text = config.username
        tokenField.text = config.token
        txtUrl.text = config.url
        txtRegEx.text = config.regex

        tokenField.document?.addDocumentListener(this)
        txtUsername.document?.addDocumentListener(this)
        txtUrl.document?.addDocumentListener(this)
        txtRegEx.document?.addDocumentListener(this)

        return mainPanel
    }
}

在进行下一步之前,请确保这些更改确实有效,并且新字段的数据已正确进行了保存。

第二步:您仍在编写代码!

现在,我们可以创建一个新的Action,将所有代码插入其中,然后转到下一篇文章,但我们不会这样做,因为:

您仍在编写代码! -Marcos Holgado(就是我!)

仅仅因为这是一个插件,并不意味着您不必对其进行维护或遵循任何编码规范。我看到太多插件,其中所有代码都在一个Action中。我不明白,如果您不想将所有代码都放在Action中,为什么还要这么做?

今天,我将使用MVP模式,但您可以使用任何您喜欢的模式,只要您遵循经典的编码规范,就不会有太大的不同。我们的看起来像这样:

我们的JiraMoveAction将创建一个新的JiraMoveDialog,它将具有一个JiraMoveDialogPresenter,该JiraMoveDialogPresenter将与Model,网络等进行通信。此外,JiraMoveDialog将创建一个JiraMovePanel,其唯一原因是要分离更多的UI层,我将解释说在步骤4中。

除此之外,我们将使用Retrofit将对Jira APIDagger2的网络请求用作DI框架(您知道我有点喜欢Dagger)。

首先,我们将在action package中创建一个新的package,以将Action与其余代码进一步分开,然后创建所有提及的文件。

第三步:Models

我们的Model将非常简单,因为我们只需要处理Transition,并且由于不处理在Transition的必填字段。我们唯一需要的信息是Transition idTransition name。如果您需要配置其他东西(例如添加评论),请点击这里查看文档。

我将把这些Model放在network包下的Models.kt文件中,实现如下:

data class Transition(val id: String, val name: String = "") {
    override fun toString(): String = name
}

data class TransitionsResponse(val transitions: List<Transition>)

data class TransitionData(val transition: Transition)

第四步:JiraMovePanel

顾名思义,该文件将成为具有所需UIJPanel。我们不会在此处创建任何 OKCancel 按钮,因为这将作为JiraMoveDialog的一部分出现,但我将在下一小节中进行讨论。

现在,我们将创建一个非常简单的UI,其包含一个combobox(用于显示可用的Transition)和一个 text field(用于显示issue ID),这个text field让用户根据需要手动进行配置。

我们的JiraMovePanel类继承自JPanel,根据上文,我们将在Eclipse中创建UI,复制粘贴代码并将其转换为Kotlin

与我们在设置页面上所做的操作相比,存在一些差异,这是因为我们继承了JPanel,我们已经在Panel中,因此我们可以直接调用add()

我们还必须重写getPreferredSize()来设置Panel的大小,不要忘记这样做!

最后,我添加了一些方法,这些方法将从JiraMoveDialog中调用以更改字段的值,最终文件如下所示:

class JiraMovePanel : JPanel() {

    private val comboTransitions = ComboBox<Transition>()
    val txtIssue = JTextField()

    init {
        initComponents()
    }

    private fun initComponents() {
        layout = null

        val lblJiraTicket = JLabel("Issue")
        lblJiraTicket.setBounds(25, 33, 77, 16)
        add(lblJiraTicket)

        txtIssue.setBounds(114, 28, 183, 26)
        add(txtIssue)

        val lblTransition = JLabel("Transition")
        lblTransition.setBounds(25, 75, 77, 16)
        add(lblTransition)

        comboTransitions.setBounds(114, 71, 183, 27)
        add(comboTransitions)
    }

    override fun getPreferredSize() = Dimension(300, 110)

    fun addTransition(transition: Transition) = comboTransitions.addItem(transition)

    fun setIssue(issue: String) {
        txtIssue.text = issue
    }

    fun getTransition() : Transition = comboTransitions.selectedItem as Transition
}

第五步:展示UI

让我们确保刚才所创用户界面显示的正确性,我们首先需要将JiraMoveDialogJiraMovePanel链接起来,因此我们来实现JiraMoveDialog

首先,我们需要继承DialogWrapperIntelliJ提供了这个包装器,我们应该将其用于插件中的所有模式的对话框。IntelliJ还提供了一些免费功能,例如OKCancel按钮,因此我们不必在JPanel中创建它们。

我们还必须重写createCenterPanel()以返回刚刚创建的Panel,并在初始化对话框时调用init()。现在,这是我们的JiraMoveDialog类:

class JiraMoveDialog constructor(val project: Project):
        DialogWrapper(true) {

    init {
        init()
    }

    override fun createCenterPanel(): JComponent? {
        return JiraMovePanel()
    }
}

现在,我们可以在我们的JiraMoveAction中创建一个新对话框,这对您现在来说应该很简单,因此我不再赘述。

class JiraMoveAction : AnAction() {

    override fun actionPerformed(event: AnActionEvent) {
        val dialog = JiraMoveDialog(event.project!!)
        dialog.show()
    }
}

最后一步是将新的Action添加到plugin.xml文件中。

您现在可以调试插件,执行Action,并且应该看到以下的内容:

第六步:DI 和获取 Git 分支

到目前为止,我们已经完成了UI工作,让我们开始使用Presenter并将Dagger2集成到项目中。

注意:如果不需要,您不必使用Dagger,但我建议像在其他任何项目中一样,使用某种形式的依赖注入。

要添加Dagger,我们只需像通常那样,在build.gradle文件中添加依赖项即可。别忘了也添加kapt。请注意,由于intellij-gradle-plugin尚不支持implementationapi,因此我们尚未使用它们。

apply plugin: 'kotlin-kapt'
dependencies {
    compile 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.3.10'

    compile 'com.google.dagger:dagger:2.20'
    kapt 'com.google.dagger:dagger-compiler:2.20'
}

我们还希望从IDE中获取当前分支,为此,我们需要添加插件依赖。每当您需要第三方插件依赖(例如android插件或任何其他插件)时,都必须将该插件添加到gradle文件的插件列表中。在我们的例子中,我们将需要git4idea插件。

intellij {
    version '2018.1.6'
    plugins = ['git4idea']
    alternativeIdePath '/Applications/Android Studio.app'
}

我们还必须在plugin.xml文件中添加依赖。

<depends>Git4Idea</depends>

添加了所有依赖后,我们可以专注于依赖注入。首先,我将创建一个新的Dagger Component以及一个Module,将来它将帮助我们进行测试,并使我们的架构更整洁。

现在,我们将只注入我们正在处理的project,即JiraMoveDialogView层)和保存我们的设置的JiraComponent。 我还将Component命名为JiraDIComponent,因此我们不会把它和用于保存设置的JiraComponent相混淆。

@Component(modules = [JiraModule::class])
interface JiraDIComponent {
    fun inject(jiraMoveDialog: JiraMoveDialog)
}
@Module
class JiraModule(
        private val view: JiraMoveDialog,
        private val project: Project
) {
    @Provides
    fun provideView() : JiraMoveDialog = view

    @Provides
    fun provideProject() : Project = project

    @Provides
    fun provideComponent() : JiraComponent =
            JiraComponent.getInstance(project)
}

如果您对依赖注入或Dagger不熟悉,建议您看一下Jake Wharton的演讲:
https://www.youtube.com/watch?v=plK0zyRLIP8

现在,我们可以使用Dagger创建Presenter并将所需一切注入到构造函数中,要获得当前正在使用的分支实际上非常简单,只需从当前项目中获得一个repository manager即可。 通常,您将有一个repository,因此您只需调用first()并获取当前分支的名称。之后,通过使用存储在我们设置中的正则表达式,我们可以匹配并找到JiraissueID

我将在设置中存储的正则表达式为[a-zA-Z] +-[0-9] +,因为Jira ID的格式为Project-Number(即DROID-12),并且我为分支命名作为DROID-12-this-is-a-bug

class JiraMoveDialogPresenter @Inject constructor(
        private val view: JiraMoveDialog,
        private val project: Project,
        private val component: JiraComponent
) {

    fun load() {
        getBranch()
    }

    private fun getBranch() {
        val repositoryManager = GitRepositoryManager.getInstance(project)
        val repository = repositoryManager.repositories.first()
        val ticket = repository.currentBranch!!.name
        val match = Regex(component.regex).find(ticket)
        match?.let {
            view.setIssue(match.value)
        }
    }
}

回到JiraMoveDialog,我们必须注入Presenter,并实现setIssue()方法以根据Git分支更改字段的值。为此,我们将创建一个JPanel的变量,而非在createCenterPanel()上返回新的JPanel,然后可以使用该Panel来更改字段的值。

我将isModal设置为true。每当我们将modal设置为true时,我们都会阻止UI,因此用户必须退出我们的对话框才能再次与IDE交互,如果需要,可以随意更改该值。然后,我们调用presenter.load()IDE中获取分支,和之前一样,我们还须调用init()

class JiraMoveDialog constructor(project: Project):
        DialogWrapper(true) {

    @Inject
    lateinit var presenter: JiraMoveDialogPresenter
    private val panel : JiraMovePanel = JiraMovePanel()

    init {
        DaggerJiraDIComponent.builder()
                .jiraModule(JiraModule(this, project))
                .build().inject(this)
        isModal = true
        presenter.load()
        init()
    }

    override fun createCenterPanel(): JComponent? = panel

    fun setIssue(issue: String) = panel.setIssue(issue)
}

如果现在运行插件,并在设置中使用我之前提到的正则表达式,您将看到,每当启动JiraMoveAction时,它都会根据当前分支将issue字段设置为Jira正确的issue ID

第七步:用Retrofit和RxJava请求网络

剩下唯一要做的事情就是为Jiraissue获取下一个Transition,并在用户按下OK按钮时移动issue。为此,我们将使用RetrofitRxJava

和往常一样,首先将新的依赖声明在build.gradle文件中。

compile 'com.squareup.retrofit2:retrofit:2.5.0'
compile 'com.squareup.retrofit2:adapter-rxjava2:2.5.0'
compile 'com.squareup.retrofit2:converter-gson:2.5.0'
compile 'io.reactivex.rxjava2:rxjava:2.2.5'
compile 'com.github.akarnokd:rxjava2-swing:0.3.3'  // 译者注:注意这个compile

最后compile的依赖,可能会让你耳目一新,在RxJava中,我们需要一组Scheduler来进行订阅和观察。我们显然不能使用Android的,而是需要在事件分发线程或EDT中运行代码,新库将为我们提供EDT的调度程序。

我们将从编写JiraService开始,查看Jira API文档后,实现起来并不复杂:

interface JiraService {

    @GET("issue/{issueId}/transitions")
    fun getTransitions(@Header("Authorization") authKey: String,
                       @Path("issueId") issueId: String): Single<TransitionsResponse>

    @POST("issue/{issueId}/transitions")
    fun doTransition(@Header("Authorization") authKey: String,
                     @Path("issueId") issueId: String,
                     @Body transitionData: TransitionData): Completable
}

现在我们可以通过DaggerJiraService的依赖向外暴露:

@Provides
fun providesJiraService(component: JiraComponent) : JiraService {
    val jiraURL = component.url
    val retrofit = Retrofit.Builder()
            .addConverterFactory(GsonConverterFactory.create())
            .addCallAdapterFactory(
                RxJava2CallAdapterFactory.create())
            .baseUrl(jiraURL)
            .build()

    return retrofit.create(JiraService::class.java)
}

Presenter中,我们现在可以注入JiraService并将其与RxJava一起使用,以获取给定issueTransition

请注意,我们使用SwingSchedulers.edt()进行线程切换。代码非常简单,使用Basic Auth,我们将获得所有Transition的响应,然后将其传递到视图(JiraMoveDialog),以将其添加到组合框。

如果发生error,我们在view.error()处理,它将显示带有错误详细信息的通知弹窗。

private fun getTransitions() {
    val auth = getAuthCode()
    disposable = jiraService.getTransitions(auth, issue)
            .subscribeOn(Schedulers.io())
            .observeOn(SwingSchedulers.edt())
            .subscribe(
                    { response ->
                        view.setTransitions(response.transitions)
                    },
                    { error ->
                        view.error(error)
                    }
            )
}

private fun getAuthCode() : String {
    val username = component.username
    val token = component.token
    val data: ByteArray =  
        "$username:$token".toByteArray(Charsets.UTF_8)
    return "Basic ${Base64.encode(data)}"
}

对话框的新方法可以设置Transition并显示error

fun setTransitions(transitionList: List<Transition>) {
    for(transition in transitionList) {
        panel.addTransition(transition)
    }
}

fun error(throwable: Throwable) {
    val noti = NotificationGroup("myplugin",   
        NotificationDisplayType.BALLOON, true)
    noti.createNotification("Error", throwable.localizedMessage,
        NotificationType.ERROR, null).notify(project)
}

立即运行插件,完成需要的配置后,效果如下:

第八步:执行 Transitions

最后一步,当用户按下OK按钮时,将issue移到组合框中显示的Transition中,Presenter中的代码再次使用RxJava,如下所示。

fun doTransition(selectedItem: Transition, issue: String) {
    val auth = getAuthCode()
    val transition = TransitionData(selectedItem)

    disposable = jiraService.doTransition(auth, issue, transition)
            .subscribeOn(Schedulers.io())
            .observeOn(SwingSchedulers.edt())
            .subscribe(
                    {
                        view.success()
                    },
                    { error ->
                        view.error(error)
                    }
            )
}

现在,在对话框中,我们必须重写doOKAction()以执行从Presenter传递过来的Transition,我们还创建了success()以关闭对话框并通知用户。

override fun doOKAction() =   
    presenter.doTransition(
        panel.getTransition(), panel.txtIssue.text
    )
fun success() {
    close(DialogWrapper.OK_EXIT_CODE)

    val noti = NotificationGroup("myplugin",    
        NotificationDisplayType.BALLOON, true)

    noti.createNotification("Success", "Issue moved",  
        NotificationType.INFORMATION, null).notify(project)
}

如果您现在运行插件,则无需离开Android Studio就可以进行Jira的更新。如果一切顺利,您现在应该会看到此弹出窗口,最重要的是,Jira中的issue现在应该移至下一阶段。

这就是所有的内容了!如有需要,您现在可以进行更多扩展,并创建Action以使所有issue都处于ToDo状态、显示说明并在选择它们后使用正确的issue id创建新分支(或查看我的Droidcon UK演讲以获取代码)。

请记住,本文的代码在该系列GitHub RepoPart4分支中可用。

下一篇文章中,我们将整理一下代码,并创建一些util方法以在代码中复用。我们还将研究如何以比硬编码更好的方式存储字符串。

在此之前,如果您有任何问题,请随时发表评论或在Twitter上关注我。


《编写AndroidStudio插件》译文系列

关于译者

Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 GitHub

如果您觉得文章还差了那么点东西,也请通过 关注 督促我写出更好的文章——万一哪天我进步了呢?