原文: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
移至下一栏。因为Jira
的issue ID
将基于我们当前的git
分支,所以我们的插件将从我们当前的分支中解析issue ID
,而不是强迫用户手动输入或从其他位置选择issue ID
。
在开始前,我们先进行一些假设。
issue
(比如记录时间等)之前,您无需填写任何必填字段,否则你的UI
将需要额外的字段供用户输入;Jira Cloud Platform API v3
;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 API
和Dagger2
的网络请求用作DI
框架(您知道我有点喜欢Dagger
)。
首先,我们将在action package
中创建一个新的package
,以将Action
与其余代码进一步分开,然后创建所有提及的文件。
我们的Model
将非常简单,因为我们只需要处理Transition
,并且由于不处理在Transition
的必填字段。我们唯一需要的信息是Transition id
和Transition 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)
顾名思义,该文件将成为具有所需UI
的JPanel
。我们不会在此处创建任何 OK 或 Cancel 按钮,因为这将作为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
}
让我们确保刚才所创用户界面显示的正确性,我们首先需要将JiraMoveDialog
与JiraMovePanel
链接起来,因此我们来实现JiraMoveDialog
。
首先,我们需要继承DialogWrapper
,IntelliJ
提供了这个包装器,我们应该将其用于插件中的所有模式的对话框。IntelliJ
还提供了一些免费功能,例如OK
或Cancel
按钮,因此我们不必在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
,并且应该看到以下的内容:
到目前为止,我们已经完成了UI
工作,让我们开始使用Presenter
并将Dagger2
集成到项目中。
注意:如果不需要,您不必使用
Dagger
,但我建议像在其他任何项目中一样,使用某种形式的依赖注入。
要添加Dagger
,我们只需像通常那样,在build.gradle
文件中添加依赖项即可。别忘了也添加kapt
。请注意,由于intellij-gradle-plugin
尚不支持implementation
或api
,因此我们尚未使用它们。
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>Git4Ideadepends>
添加了所有依赖后,我们可以专注于依赖注入。首先,我将创建一个新的Dagger Component
以及一个Module
,将来它将帮助我们进行测试,并使我们的架构更整洁。
现在,我们将只注入我们正在处理的project
,即JiraMoveDialog
(View
层)和保存我们的设置的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()
并获取当前分支的名称。之后,通过使用存储在我们设置中的正则表达式,我们可以匹配并找到Jira
中issue
的ID
。
我将在设置中存储的正则表达式为[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
。
剩下唯一要做的事情就是为Jira
的issue
获取下一个Transition
,并在用户按下OK
按钮时移动issue
。为此,我们将使用Retrofit
和RxJava
。
和往常一样,首先将新的依赖声明在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
}
现在我们可以通过Dagger
将JiraService
的依赖向外暴露:
@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
一起使用,以获取给定issue
的Transition
。
请注意,我们使用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)
}
立即运行插件,完成需要的配置后,效果如下:
最后一步,当用户按下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 Repo的Part4
分支中可用。
在下一篇文章中,我们将整理一下代码,并创建一些util
方法以在代码中复用。我们还将研究如何以比硬编码更好的方式存储字符串。
在此之前,如果您有任何问题,请随时发表评论或在Twitter上关注我。
Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 GitHub。
如果您觉得文章还差了那么点东西,也请通过 关注 督促我写出更好的文章——万一哪天我进步了呢?