[github] Ability to edit PR reviewers, assignees, labels

This commit is contained in:
Ivan Semenov
2019-01-31 13:07:38 +03:00
parent 01728bda47
commit 669ce035a8
20 changed files with 723 additions and 60 deletions
@@ -173,6 +173,11 @@ public final class HttpRequests {
return new RequestBuilderImpl(url, connection -> ((HttpURLConnection)connection).setRequestMethod("DELETE"));
}
@NotNull
public static RequestBuilder delete(@NotNull String url, @Nullable String contentType) {
return requestWithBody(url, "DELETE", contentType, null);
}
@NotNull
public static RequestBuilder post(@NotNull String url, @Nullable String contentType) {
return requestWithBody(url, "POST", contentType, null);
@@ -525,8 +530,8 @@ public final class HttpRequests {
if (builder.myThrowStatusCodeException) {
URLConnection connection = request.myConnection;
if (connection instanceof HttpURLConnection && ((HttpURLConnection)connection).getRequestMethod().equals("POST")) {
// getResponseCode is not checked on connect for POST, because write must be performed before read
if (connection.getDoOutput()) {
// getResponseCode is not checked on connect, because write must be performed before read
HttpURLConnection urlConnection = (HttpURLConnection)connection;
int responseCode = urlConnection.getResponseCode();
if (responseCode >= 400) {
@@ -596,12 +601,13 @@ public final class HttpRequests {
return connection;
}
HttpURLConnection httpURLConnection = (HttpURLConnection)connection;
String method = httpURLConnection.getRequestMethod();
if (method.equals("POST") || method.equals("PUT")) {
if (connection.getDoOutput()) {
return connection;
}
HttpURLConnection httpURLConnection = (HttpURLConnection)connection;
String method = httpURLConnection.getRequestMethod();
LOG.assertTrue(method.equals("GET") || method.equals("HEAD") || method.equals("DELETE"),
"'" + method + "' not supported; please use GET, HEAD, DELETE, PUT or POST");
@@ -48,9 +48,6 @@ sealed class GithubApiRequest<T>(val url: String) {
inline fun <reified T> json(url: String, acceptMimeType: String? = null): Get<T> =
Json(url, T::class.java, acceptMimeType)
inline fun <reified T> jsonList(url: String, acceptMimeType: String? = null): Get<List<T>> =
JsonList(url, T::class.java, acceptMimeType)
inline fun <reified T> jsonPage(url: String, acceptMimeType: String? = null): Get<GithubResponsePage<T>> =
JsonPage(url, T::class.java, acceptMimeType)
@@ -133,6 +130,8 @@ sealed class GithubApiRequest<T>(val url: String) {
companion object {
inline fun <reified T> json(url: String, body: Any? = null): Put<T> = Json(url, body, T::class.java)
inline fun <reified T> jsonList(url: String, body: Any): Put<List<T>> = JsonList(url, T::class.java, body)
}
open class Json<T>(url: String, body: Any?, clazz: Class<T>) : Put<T>(body?.let { GithubApiContentHelper.toJson(it) },
@@ -143,6 +142,17 @@ sealed class GithubApiRequest<T>(val url: String) {
override fun extractResult(response: GithubApiResponse): T = parseJsonResponse(response, typeToken)
}
open class JsonList<T>(url: String, clazz: Class<T>, body: Any?)
: Put<List<T>>(body?.let { GithubApiContentHelper.toJson(it) },
GithubApiContentHelper.JSON_MIME_TYPE,
url,
GithubApiContentHelper.V3_JSON_MIME_TYPE) {
private val typeToken = TypeToken.getParameterized(List::class.java, clazz) as TypeToken<List<T>>
override fun extractResult(response: GithubApiResponse): List<T> = parseJsonResponse(response, typeToken)
}
}
abstract class Patch<T> @JvmOverloads constructor(override val body: String,
@@ -163,10 +173,23 @@ sealed class GithubApiRequest<T>(val url: String) {
}
}
open class Delete(url: String) : GithubApiRequest<Unit>(url) {
override val acceptMimeType: String? = null
abstract class Delete<T> @JvmOverloads constructor(override val body: String?,
override val bodyMimeType: String,
url: String,
override val acceptMimeType: String? = null) : GithubApiRequest.WithBody<T>(url) {
override fun extractResult(response: GithubApiResponse) {}
companion object {
inline fun <reified T> json(url: String, body: Any? = null): Delete<T> = Json(url, body, T::class.java)
}
open class Json<T>(url: String, body: Any? = null, clazz: Class<T>) : Delete<T>(body?.let { GithubApiContentHelper.toJson(it) },
GithubApiContentHelper.JSON_MIME_TYPE,
url,
GithubApiContentHelper.V3_JSON_MIME_TYPE) {
private val typeToken = TypeToken.get(clazz)
override fun extractResult(response: GithubApiResponse): T = parseJsonResponse(response, typeToken)
}
}
companion object {
@@ -149,7 +149,9 @@ sealed class GithubApiRequestExecutor {
is GithubApiRequest.Put -> HttpRequests.put(request.url, request.bodyMimeType)
is GithubApiRequest.Patch -> HttpRequests.patch(request.url, request.bodyMimeType)
is GithubApiRequest.Head -> HttpRequests.head(request.url)
is GithubApiRequest.Delete -> HttpRequests.delete(request.url)
is GithubApiRequest.Delete -> {
if (request.body == null) HttpRequests.delete(request.url) else HttpRequests.delete(request.url, request.bodyMimeType)
}
else -> throw UnsupportedOperationException("${request.javaClass} is not supported")
}
.connectTimeout(githubSettings.connectionTimeout)
@@ -100,7 +100,7 @@ object GithubApiRequests {
delete(getUrl(server, urlSuffix, "/$username/$repoName")).withOperationName("delete repository $username/$repoName")
@JvmStatic
fun delete(url: String) = Delete(url).withOperationName("delete repository at $url")
fun delete(url: String) = Delete.json<Unit>(url).withOperationName("delete repository at $url")
object Branches : Entity("/branches") {
@JvmStatic
@@ -136,8 +136,50 @@ object GithubApiRequests {
fun get(url: String) = Get.jsonPage<GithubRepo>(url).withOperationName("get forks")
}
object Assignees : Entity("/assignees") {
@JvmStatic
fun pages(server: GithubServerPath, username: String, repoName: String) =
GithubApiPagesLoader.Request(get(server, username, repoName), ::get)
@JvmOverloads
@JvmStatic
fun get(server: GithubServerPath, username: String, repoName: String, pagination: GithubRequestPagination? = null) =
get(getUrl(server, Repos.urlSuffix, "/$username/$repoName", urlSuffix, getQuery(pagination?.toString().orEmpty())))
@JvmStatic
fun get(url: String) = Get.jsonPage<GithubUser>(url).withOperationName("get assignees")
}
object Labels : Entity("/labels") {
@JvmStatic
fun pages(server: GithubServerPath, username: String, repoName: String) =
GithubApiPagesLoader.Request(get(server, username, repoName), ::get)
@JvmOverloads
@JvmStatic
fun get(server: GithubServerPath, username: String, repoName: String, pagination: GithubRequestPagination? = null) =
get(getUrl(server, Repos.urlSuffix, "/$username/$repoName", urlSuffix, getQuery(pagination?.toString().orEmpty())))
@JvmStatic
fun get(url: String) = Get.jsonPage<GithubIssueLabel>(url).withOperationName("get assignees")
}
object Collaborators : Entity("/collaborators") {
@JvmStatic
fun pages(server: GithubServerPath, username: String, repoName: String) =
GithubApiPagesLoader.Request(get(server, username, repoName), ::get)
@JvmOverloads
@JvmStatic
fun get(server: GithubServerPath, username: String, repoName: String, pagination: GithubRequestPagination? = null) =
get(getUrl(server, Repos.urlSuffix, "/$username/$repoName", urlSuffix, getQuery(pagination?.toString().orEmpty())))
@JvmStatic
fun get(url: String) = Get.jsonPage<GithubUserWithPermissions>(url).withOperationName("get collaborators")
@JvmStatic
fun add(server: GithubServerPath, username: String, repoName: String, collaborator: String) =
Put.json<Any>(getUrl(server, Repos.urlSuffix, "/$username/$repoName", urlSuffix, "/", collaborator))
@@ -180,6 +222,11 @@ object GithubApiRequests {
Patch.json<GithubIssue>(getUrl(server, Repos.urlSuffix, "/$username/$repoName", urlSuffix, "/", id),
GithubChangeIssueStateRequest(if (open) "open" else "closed"))
@JvmStatic
fun updateAssignees(server: GithubServerPath, username: String, repoName: String, id: String, assignees: Collection<String>) =
Patch.json<GithubIssue>(getUrl(server, Repos.urlSuffix, "/$username/$repoName", urlSuffix, "/", id),
GithubAssigneesCollectionRequest(assignees))
object Comments : Entity("/comments") {
@JvmStatic
fun create(server: GithubServerPath, username: String, repoName: String, issueId: String, body: String) =
@@ -203,6 +250,13 @@ object GithubApiRequests {
fun get(url: String) = Get.jsonPage<GithubIssueCommentWithHtml>(url, GithubApiContentHelper.V3_HTML_JSON_MIME_TYPE)
.withOperationName("get comments for issue")
}
object Labels : Entity("/labels") {
@JvmStatic
fun replace(server: GithubServerPath, username: String, repoName: String, issueId: String, labels: Collection<String>) =
Put.jsonList<GithubIssueLabel>(getUrl(server, Repos.urlSuffix, "/$username/$repoName", Issues.urlSuffix, "/", issueId, urlSuffix),
GithubLabelsCollectionRequest(labels))
}
}
object PullRequests : Entity("/pulls") {
@@ -265,6 +319,18 @@ object GithubApiRequests {
.withOperationName("rebase and merge pull request ${pullRequest.number}")
private fun getMergeUrl(pullRequest: GithubPullRequest) = pullRequest.url + "/merge"
object Reviewers : Entity("/requested_reviewers") {
@JvmStatic
fun add(server: GithubServerPath, username: String, repoName: String, number: Long, reviewers: Collection<String>) =
Post.json<Unit>(getUrl(server, Repos.urlSuffix, "/$username/$repoName", PullRequests.urlSuffix, "/$number", urlSuffix),
GithubReviewersCollectionRequest(reviewers, listOf<String>()))
@JvmStatic
fun remove(server: GithubServerPath, username: String, repoName: String, number: Long, reviewers: Collection<String>) =
Delete.json<Unit>(getUrl(server, Repos.urlSuffix, "/$username/$repoName", PullRequests.urlSuffix, "/$number", urlSuffix),
GithubReviewersCollectionRequest(reviewers, listOf<String>()))
}
}
}
@@ -280,7 +346,7 @@ object GithubApiRequests {
.withOperationName("get gist $id")
@JvmStatic
fun delete(server: GithubServerPath, id: String) = Delete(getUrl(server, urlSuffix, "/$id"))
fun delete(server: GithubServerPath, id: String) = Delete.json<Unit>(getUrl(server, urlSuffix, "/$id"))
.withOperationName("delete gist $id")
}
@@ -19,7 +19,7 @@ import org.jetbrains.io.mandatory.RestModel;
@RestModel
@SuppressWarnings("UnusedDeclaration")
public class GithubIssueLabel {
private Long id;
@Mandatory private Long id;
private String nodeId;
private String url;
@Mandatory private String name;
@@ -35,4 +35,21 @@ public class GithubIssueLabel {
public String getColor() {
return color;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof GithubIssueLabel)) return false;
GithubIssueLabel label = (GithubIssueLabel)o;
if (!id.equals(label.id)) return false;
return true;
}
@Override
public int hashCode() {
return id.hashCode();
}
}
@@ -0,0 +1,24 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.plugins.github.api.data;
import org.jetbrains.io.mandatory.Mandatory;
import org.jetbrains.io.mandatory.RestModel;
@RestModel
public class GithubPermissions {
@Mandatory private Boolean admin;
@Mandatory private Boolean pull;
@Mandatory private Boolean push;
public boolean isAdmin() {
return admin;
}
public boolean isPull() {
return pull;
}
public boolean isPush() {
return push;
}
}
@@ -5,33 +5,13 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.io.mandatory.Mandatory;
import org.jetbrains.io.mandatory.RestModel;
//example/GithubRepoWithPermissions.json
@RestModel
@SuppressWarnings("UnusedDeclaration")
public class GithubRepoWithPermissions extends GithubRepo {
@Mandatory private Permissions permissions;
@Mandatory private GithubPermissions permissions;
@NotNull
public Permissions getPermissions() {
public GithubPermissions getPermissions() {
return permissions;
}
@RestModel
public static class Permissions {
@Mandatory private Boolean admin;
@Mandatory private Boolean pull;
@Mandatory private Boolean push;
public boolean isAdmin() {
return admin;
}
public boolean isPull() {
return pull;
}
public boolean isPush() {
return push;
}
}
}
@@ -0,0 +1,17 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.plugins.github.api.data;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.io.mandatory.Mandatory;
import org.jetbrains.io.mandatory.RestModel;
@RestModel
@SuppressWarnings("UnusedDeclaration")
public class GithubUserWithPermissions extends GithubUser {
@Mandatory private GithubPermissions permissions;
@NotNull
public GithubPermissions getPermissions() {
return permissions;
}
}
@@ -0,0 +1,15 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.plugins.github.api.requests;
import org.jetbrains.annotations.NotNull;
import java.util.Collection;
@SuppressWarnings({"FieldCanBeLocal", "UnusedDeclaration"})
public class GithubAssigneesCollectionRequest {
@NotNull private final Collection<String> assignees;
public GithubAssigneesCollectionRequest(@NotNull Collection<String> assignees) {
this.assignees = assignees;
}
}
@@ -0,0 +1,15 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.plugins.github.api.requests;
import org.jetbrains.annotations.NotNull;
import java.util.Collection;
@SuppressWarnings({"FieldCanBeLocal", "UnusedDeclaration"})
public class GithubLabelsCollectionRequest {
@NotNull private final Collection<String> labels;
public GithubLabelsCollectionRequest(@NotNull Collection<String> labels) {
this.labels = labels;
}
}
@@ -0,0 +1,18 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.plugins.github.api.requests;
import org.jetbrains.annotations.NotNull;
import java.util.Collection;
@SuppressWarnings({"FieldCanBeLocal", "UnusedDeclaration"})
public class GithubReviewersCollectionRequest {
@NotNull private final Collection<String> reviewers;
@NotNull private final Collection<String> team_reviewers;
public GithubReviewersCollectionRequest(@NotNull Collection<String> reviewers,
@NotNull Collection<String> team_reviewers) {
this.reviewers = reviewers;
this.team_reviewers = team_reviewers;
}
}
@@ -25,6 +25,7 @@ import org.jetbrains.plugins.github.pullrequest.config.GithubPullRequestsProject
import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsBusyStateTrackerImpl
import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsDataLoader
import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsLoader
import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsMetadataServiceImpl
import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsSecurityServiceImpl
import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsStateServiceImpl
import org.jetbrains.plugins.github.pullrequest.ui.GithubPullRequestChangesComponent
@@ -73,13 +74,16 @@ internal class GithubPullRequestsComponentFactory(private val project: Project,
private val securityService = GithubPullRequestsSecurityServiceImpl(sharedProjectSettings, accountDetails, repoDetails)
private val busyStateTracker = GithubPullRequestsBusyStateTrackerImpl()
private val metadataService = GithubPullRequestsMetadataServiceImpl(project, progressManager, dataLoader, busyStateTracker,
requestExecutor,
avatarIconsProviderFactory, account.server, repoDetails.fullPath)
private val stateService = GithubPullRequestsStateServiceImpl(project, progressManager, dataLoader, busyStateTracker, requestExecutor,
account.server, repoDetails.fullPath)
private val changes = GithubPullRequestChangesComponent(project, pullRequestUiSettings).apply {
diffAction.registerCustomShortcutSet(this@GithubPullRequestsComponent, this@GithubPullRequestsComponent)
}
private val details = GithubPullRequestDetailsComponent(dataLoader, securityService, busyStateTracker, stateService,
private val details = GithubPullRequestDetailsComponent(dataLoader, securityService, busyStateTracker, metadataService, stateService,
avatarIconsProviderFactory)
private val preview = GithubPullRequestPreviewComponent(changes, details)
@@ -0,0 +1,16 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.plugins.github.pullrequest.data.service
import org.jetbrains.annotations.CalledInAwt
import javax.swing.JComponent
interface GithubPullRequestsMetadataService {
@CalledInAwt
fun adjustReviewers(pullRequest: Long, parentComponent: JComponent)
@CalledInAwt
fun adjustAssignees(pullRequest: Long, parentComponent: JComponent)
@CalledInAwt
fun adjustLabels(pullRequest: Long, parentComponent: JComponent)
}
@@ -0,0 +1,311 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.plugins.github.pullrequest.data.service
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.progress.EmptyProgressIndicator
import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.progress.Task
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.popup.JBPopupListener
import com.intellij.openapi.ui.popup.LightweightWindowEvent
import com.intellij.openapi.ui.popup.PopupChooserBuilder
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.text.StringUtil
import com.intellij.ui.CollectionListModel
import com.intellij.ui.ColorUtil
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBList
import com.intellij.util.ui.ColorIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.StatusText
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import org.jetbrains.plugins.github.api.GithubApiRequestExecutor
import org.jetbrains.plugins.github.api.GithubApiRequests
import org.jetbrains.plugins.github.api.GithubFullPath
import org.jetbrains.plugins.github.api.GithubServerPath
import org.jetbrains.plugins.github.api.data.GithubIssueLabel
import org.jetbrains.plugins.github.api.data.GithubPullRequestDetailed
import org.jetbrains.plugins.github.api.data.GithubPullRequestDetailedWithHtml
import org.jetbrains.plugins.github.api.data.GithubUser
import org.jetbrains.plugins.github.api.util.GithubApiPagesLoader
import org.jetbrains.plugins.github.pullrequest.avatars.CachingGithubAvatarIconsProvider
import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsBusyStateTracker
import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsDataLoader
import org.jetbrains.plugins.github.util.*
import java.awt.Component
import java.util.concurrent.CompletableFuture
import java.util.function.Consumer
import java.util.function.Function
import javax.swing.*
class GithubPullRequestsMetadataServiceImpl internal constructor(private val project: Project,
private val progressManager: ProgressManager,
private val dataLoader: GithubPullRequestsDataLoader,
private val busyStateTracker: GithubPullRequestsBusyStateTracker,
private val requestExecutor: GithubApiRequestExecutor,
private val avatarIconsProviderFactory: CachingGithubAvatarIconsProvider.Factory,
private val serverPath: GithubServerPath,
private val repoPath: GithubFullPath)
: GithubPullRequestsMetadataService {
private val repoCollaboratorsWithPushAccess: List<GithubUser> by lazy {
GithubApiPagesLoader
.loadAll(requestExecutor, progressManager.progressIndicator,
GithubApiRequests.Repos.Collaborators.pages(serverPath, repoPath.user, repoPath.repository))
.filter { it.permissions.isPush }
}
private val repoIssuesAssignees: List<GithubUser> by lazy {
GithubApiPagesLoader.loadAll(requestExecutor, progressManager.progressIndicator,
GithubApiRequests.Repos.Assignees.pages(serverPath, repoPath.user, repoPath.repository))
}
private val repoIssuesLabels: List<GithubIssueLabel> by lazy {
GithubApiPagesLoader.loadAll(requestExecutor, progressManager.progressIndicator,
GithubApiRequests.Repos.Labels.pages(serverPath, repoPath.user, repoPath.repository))
}
override fun adjustReviewers(pullRequest: Long, parentComponent: JComponent) {
showUsersChooser(pullRequest, "Reviewers", parentComponent,
{ _, details -> repoCollaboratorsWithPushAccess.filter { details.user != it } }) { it.requestedReviewers }
.handleOnEdt(getAdjustmentHandler(pullRequest, "reviewer") { delta, indicator ->
if (delta.removedItems.isNotEmpty()) {
indicator.text2 = "Removing reviewers"
requestExecutor.execute(indicator,
GithubApiRequests.Repos.PullRequests.Reviewers
.remove(serverPath, repoPath.user, repoPath.repository, pullRequest,
delta.removedItems.map { it.login }))
}
if (delta.newItems.isNotEmpty()) {
indicator.text2 = "Adding reviewers"
requestExecutor.execute(indicator,
GithubApiRequests.Repos.PullRequests.Reviewers
.add(serverPath, repoPath.user, repoPath.repository, pullRequest,
delta.newItems.map { it.login }))
}
})
}
override fun adjustAssignees(pullRequest: Long, parentComponent: JComponent) {
showUsersChooser(pullRequest, "Assignees", parentComponent, { _, _ -> repoIssuesAssignees }) { it.assignees }
.handleOnEdt(getAdjustmentHandler(pullRequest, "assignee") { delta, indicator ->
requestExecutor.execute(indicator,
GithubApiRequests.Repos.Issues
.updateAssignees(serverPath, repoPath.user, repoPath.repository, pullRequest.toString(),
delta.newCollection.map { it.login }))
})
}
override fun adjustLabels(pullRequest: Long, parentComponent: JComponent) {
showChooser(pullRequest, "Labels", parentComponent,
{ SelectionListCellRenderer.Labels() }, { it.name },
{ _, _ -> repoIssuesLabels }, { it.labels.orEmpty() })
.handleOnEdt(getAdjustmentHandler(pullRequest, "label") { delta, indicator ->
requestExecutor.execute(indicator,
GithubApiRequests.Repos.Issues.Labels
.replace(serverPath, repoPath.user, repoPath.repository, pullRequest.toString(),
delta.newCollection.map { it.name }))
})
}
private fun showUsersChooser(pullRequest: Long,
popupTitle: String,
parentComponent: JComponent,
availableListProvider: (ProgressIndicator, GithubPullRequestDetailed) -> List<GithubUser>,
currentListExtractor: (GithubPullRequestDetailed) -> List<GithubUser>)
: CompletableFuture<CollectionDelta<GithubUser>> {
return showChooser(pullRequest, popupTitle, parentComponent, { list ->
val avatarIconsProvider = avatarIconsProviderFactory.create(JBUI.uiIntValue("GitHub.Avatars.Size", 20), list)
SelectionListCellRenderer.Users(avatarIconsProvider)
}, { it.login }, availableListProvider, currentListExtractor)
}
private fun <T> showChooser(pullRequest: Long,
popupTitle: String,
parentComponent: JComponent,
cellRendererFactory: (JList<SelectableWrapper<T>>) -> ListCellRenderer<SelectableWrapper<T>>,
speedSearchNamer: (T) -> String,
availableListProvider: (ProgressIndicator, GithubPullRequestDetailed) -> List<T>,
currentListExtractor: (GithubPullRequestDetailed) -> List<T>)
: CompletableFuture<CollectionDelta<T>> {
val listModel = CollectionListModel<SelectableWrapper<T>>()
val list = JBList<SelectableWrapper<T>>(listModel)
val builder = PopupChooserBuilder<SelectableWrapper<T>>(list)
.setTitle(popupTitle)
.setResizable(true)
.setMovable(true)
.setNamerForFiltering { speedSearchNamer(it.value) }
.setAutoSelectIfEmpty(false)
.setCloseOnEnter(false)
.setRenderer(cellRendererFactory(list))
.setItemsChosenCallback {
for (item in it) {
item.selected = !item.selected
}
list.repaint()
}
val popup = builder.createPopup()
val updater = builder.backgroundUpdater
updater.paintBusy(true)
list.emptyText.text = "Loading..."
var originalSelection: Set<T> = setOf()
val progressIndicator = EmptyProgressIndicator()
Disposer.register(popup, Disposable { progressIndicator.cancel() })
val loadingFuture = GithubAsyncUtil
.futureOfMutable { dataLoader.getDataProvider(pullRequest).detailsRequest }
.thenComposeAsync(Function { details: GithubPullRequestDetailedWithHtml ->
originalSelection = currentListExtractor(details).toHashSet()
progressManager.submitBackgroundTask(project, "Load List Of Possibilities", true, progressIndicator) {
availableListProvider(it, details)
}
})
.thenAcceptAsync(Consumer { possibilities ->
listModel.replaceAll(possibilities
.map { SelectableWrapper(it, originalSelection.contains(it)) }
.sortedBy { !it.selected })
updater.paintBusy(false)
list.emptyText.text = StatusText.DEFAULT_EMPTY_TEXT
popup.pack(true, true)
}, EDT_EXECUTOR)
.exceptionally {
updater.paintBusy(false)
list.emptyText.clear()
list.emptyText.appendText("Can't load the list", SimpleTextAttributes.ERROR_ATTRIBUTES)
list.emptyText.appendSecondaryText(it.message.orEmpty(), SimpleTextAttributes.ERROR_ATTRIBUTES, null)
throw it
}
Disposer.register(popup, Disposable {
loadingFuture.cancel(true)
})
val result = CompletableFuture<CollectionDelta<T>>()
popup.addListener(object : JBPopupListener {
override fun onClosed(event: LightweightWindowEvent) {
if (!loadingFuture.isDone || loadingFuture.isCancelled || loadingFuture.isCompletedExceptionally) {
result.cancel(true)
return
}
val selected = listModel.items.filter { it.selected }.map { it.value }
result.complete(CollectionDelta(originalSelection, selected))
}
})
popup.showUnderneathOf(parentComponent)
return result
}
private fun <T> getAdjustmentHandler(pullRequest: Long,
entityName: String,
adjuster: (CollectionDelta<T>, ProgressIndicator) -> Unit): (CollectionDelta<T>?, Throwable?) -> Unit {
return handler@{ delta, error ->
if (error != null) {
if (!GithubAsyncUtil.isCancellation(error))
GithubNotifications.showError(project, "Failed to adjust list of ${StringUtil.pluralize(entityName)}", error)
return@handler
}
if (delta == null || delta.isEmpty) {
return@handler
}
if (!busyStateTracker.acquire(pullRequest)) return@handler
progressManager.run(object : Task.Backgroundable(project, "Adjusting List Of ${StringUtil.pluralize(entityName).capitalize()}",
true) {
override fun run(indicator: ProgressIndicator) {
adjuster(delta, indicator)
}
override fun onThrowable(error: Throwable) {
GithubNotifications.showError(project, "Failed to adjust list of ${StringUtil.pluralize(entityName)}", error)
}
override fun onFinished() {
busyStateTracker.release(pullRequest)
dataLoader.reloadDetails(pullRequest)
}
})
}
}
private data class SelectableWrapper<T>(val value: T, var selected: Boolean = false)
private sealed class SelectionListCellRenderer<T>
: ListCellRenderer<SelectableWrapper<T>>, BorderLayoutPanel() {
private val mainLabel = JLabel()
private val checkIconLabel = JLabel()
init {
checkIconLabel.iconTextGap = JBUI.scale(UIUtil.DEFAULT_VGAP)
addToLeft(checkIconLabel)
addToCenter(mainLabel)
border = JBUI.Borders.empty(2)
}
override fun getListCellRendererComponent(list: JList<out SelectableWrapper<T>>,
value: SelectableWrapper<T>,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean): Component {
font = list.font
foreground = UIUtil.getListForeground(isSelected, list.hasFocus())
background = UIUtil.getListBackground(isSelected, list.hasFocus())
mainLabel.foreground = foreground
mainLabel.font = font
mainLabel.text = getText(value.value)
mainLabel.icon = getIcon(value.value)
if (value.selected) {
checkIconLabel.icon = AllIcons.Actions.Checked
checkIconLabel.border = JBUI.Borders.empty(0, 4)
}
else {
checkIconLabel.icon = null
checkIconLabel.border = JBUI.Borders.empty(0, 10)
}
return this
}
abstract fun getText(value: T): String
abstract fun getIcon(value: T): Icon
class Users(private val iconsProvider: CachingGithubAvatarIconsProvider)
: SelectionListCellRenderer<GithubUser>() {
override fun getText(value: GithubUser) = value.login
override fun getIcon(value: GithubUser) = iconsProvider.getIcon(value)
}
class Labels
: SelectionListCellRenderer<GithubIssueLabel>() {
override fun getText(value: GithubIssueLabel) = value.name
override fun getIcon(value: GithubIssueLabel) = ColorIcon(16, ColorUtil.fromHex(value.color))
}
}
private class CollectionDelta<out T>(oldCollection: Collection<T>, val newCollection: Collection<T>) {
val newItems: Collection<T> = newCollection - oldCollection
val removedItems: Collection<T> = oldCollection - newCollection
val isEmpty = newItems.isEmpty() && removedItems.isEmpty()
}
}
@@ -14,6 +14,7 @@ import org.jetbrains.plugins.github.api.data.GithubPullRequestDetailedWithHtml
import org.jetbrains.plugins.github.pullrequest.avatars.CachingGithubAvatarIconsProvider
import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsBusyStateTracker
import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsDataLoader
import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsMetadataService
import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsSecurityService
import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsStateService
import org.jetbrains.plugins.github.pullrequest.ui.details.GithubPullRequestDetailsModel
@@ -23,12 +24,13 @@ import java.awt.BorderLayout
internal class GithubPullRequestDetailsComponent(private val dataLoader: GithubPullRequestsDataLoader,
securityService: GithubPullRequestsSecurityService,
busyStateTracker: GithubPullRequestsBusyStateTracker,
metadataService: GithubPullRequestsMetadataService,
stateService: GithubPullRequestsStateService,
iconProviderFactory: CachingGithubAvatarIconsProvider.Factory)
: GithubDataLoadingComponent<GithubPullRequestDetailedWithHtml>(), Disposable {
private val detailsModel = GithubPullRequestDetailsModel()
private val detailsPanel = GithubPullRequestDetailsPanel(detailsModel, securityService, busyStateTracker, stateService,
private val detailsPanel = GithubPullRequestDetailsPanel(detailsModel, securityService, busyStateTracker, metadataService, stateService,
iconProviderFactory)
private val loadingPanel = JBLoadingPanel(BorderLayout(), this, ProgressWindow.DEFAULT_PROGRESS_DIALOG_POSTPONE_TIME_MILLIS).apply {
@@ -62,6 +64,8 @@ internal class GithubPullRequestDetailsComponent(private val dataLoader: GithubP
dataLoader.reloadDetails(result.number)
}
}
validate()
repaint()
}
override fun handleError(error: Throwable) {
@@ -17,6 +17,7 @@ import net.miginfocom.layout.LC
import net.miginfocom.swing.MigLayout
import org.jetbrains.plugins.github.pullrequest.avatars.CachingGithubAvatarIconsProvider
import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsBusyStateTracker
import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsMetadataService
import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsSecurityService
import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsStateService
import java.awt.Graphics
@@ -28,6 +29,7 @@ import javax.swing.JPanel
internal class GithubPullRequestDetailsPanel(model: GithubPullRequestDetailsModel,
securityService: GithubPullRequestsSecurityService,
busyStateTracker: GithubPullRequestsBusyStateTracker,
metadataService: GithubPullRequestsMetadataService,
stateService: GithubPullRequestsStateService,
iconProviderFactory: CachingGithubAvatarIconsProvider.Factory)
: JPanel(), ComponentWithEmptyText, Disposable {
@@ -37,7 +39,7 @@ internal class GithubPullRequestDetailsPanel(model: GithubPullRequestDetailsMode
}
private val iconsProvider = iconProviderFactory.create(JBValue.UIInteger("Profile.Icon.Size", 20), this)
private val metaPanel = GithubPullRequestMetadataPanel(model, iconsProvider).apply {
private val metaPanel = GithubPullRequestMetadataPanel(model, securityService, busyStateTracker, metadataService, iconsProvider).apply {
border = JBUI.Borders.empty(4, 8, 4, 8)
}
private val descriptionPanel = GithubPullRequestDescriptionPanel(model).apply {
@@ -2,6 +2,7 @@
package org.jetbrains.plugins.github.pullrequest.ui.details
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.ui.components.panels.Wrapper
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
@@ -9,21 +10,28 @@ import net.miginfocom.layout.CC
import net.miginfocom.layout.LC
import net.miginfocom.swing.MigLayout
import org.jetbrains.plugins.github.api.data.GithubIssueLabel
import org.jetbrains.plugins.github.api.data.GithubPullRequestDetailed
import org.jetbrains.plugins.github.api.data.GithubUser
import org.jetbrains.plugins.github.pullrequest.avatars.CachingGithubAvatarIconsProvider
import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsBusyStateTracker
import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsMetadataService
import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsSecurityService
import org.jetbrains.plugins.github.util.GithubUIUtil
import javax.swing.JLabel
import javax.swing.JPanel
import javax.swing.SwingConstants
internal class GithubPullRequestMetadataPanel(private val model: GithubPullRequestDetailsModel,
private val securityService: GithubPullRequestsSecurityService,
private val busyStateTracker: GithubPullRequestsBusyStateTracker,
private val metadataService: GithubPullRequestsMetadataService,
private val iconsProvider: CachingGithubAvatarIconsProvider)
: JPanel(), Disposable {
private val directionPanel = GithubPullRequestDirectionPanel()
private val reviewersHandle = LabeledListPanelHandle.create("No Reviewers", "Reviewers:", ::createUserLabel)
private val assigneesHandle = LabeledListPanelHandle.create("Unassigned", "Assignees:", ::createUserLabel)
private val labelsHandle = LabeledListPanelHandle.create("No Labels", "Labels:", ::createLabelLabel)
private val reviewersHandle = ReviewersListPanelHandle()
private val assigneesHandle = AssigneesListPanelHandle()
private val labelsHandle = LabelsListPanelHandle()
init {
isOpaque = false
@@ -42,10 +50,11 @@ internal class GithubPullRequestMetadataPanel(private val model: GithubPullReque
model.addDetailsChangedListener(this) {
directionPanel.direction = model.details?.let { it.head to it.base }
reviewersHandle.list = model.details?.requestedReviewers
assigneesHandle.list = model.details?.assignees
labelsHandle.list = model.details?.labels
}
Disposer.register(this, reviewersHandle)
Disposer.register(this, assigneesHandle)
Disposer.register(this, labelsHandle)
}
private fun addListPanel(handle: LabeledListPanelHandle<*>) {
@@ -53,13 +62,48 @@ internal class GithubPullRequestMetadataPanel(private val model: GithubPullReque
add(handle.panel, CC().minWidth("0").growX().pushX().wrap())
}
override fun dispose() {}
private inner class ReviewersListPanelHandle
: LabeledListPanelHandle<GithubUser>(model, securityService, busyStateTracker, "No Reviewers", "Reviewers:") {
override fun extractItems(details: GithubPullRequestDetailed): List<GithubUser> = details.requestedReviewers
override fun getItemComponent(item: GithubUser) = createUserLabel(item)
override fun editList() {
model.details?.run { metadataService.adjustReviewers(number, editButton) }
}
}
private inner class AssigneesListPanelHandle
: LabeledListPanelHandle<GithubUser>(model, securityService, busyStateTracker, "Unassigned", "Assignees:") {
override fun extractItems(details: GithubPullRequestDetailed): List<GithubUser> = details.assignees
override fun getItemComponent(item: GithubUser) = createUserLabel(item)
override fun editList() {
model.details?.run { metadataService.adjustAssignees(number, editButton) }
}
}
private fun createUserLabel(user: GithubUser) = JLabel(user.login, iconsProvider.getIcon(user), SwingConstants.LEFT).apply {
border = JBUI.Borders.empty(UIUtil.DEFAULT_VGAP, UIUtil.DEFAULT_HGAP / 2, UIUtil.DEFAULT_VGAP, UIUtil.DEFAULT_HGAP / 2)
}
private inner class LabelsListPanelHandle
: LabeledListPanelHandle<GithubIssueLabel>(model, securityService, busyStateTracker, "No Labels", "Labels:") {
override fun extractItems(details: GithubPullRequestDetailed): List<GithubIssueLabel>? = details.labels
override fun getItemComponent(item: GithubIssueLabel) = createLabelLabel(item)
override fun editList() {
model.details?.run { metadataService.adjustLabels(number, editButton) }
}
}
private fun createLabelLabel(label: GithubIssueLabel) = Wrapper(GithubUIUtil.createIssueLabelLabel(label)).apply {
border = JBUI.Borders.empty(UIUtil.DEFAULT_VGAP + 1, UIUtil.DEFAULT_HGAP / 2, UIUtil.DEFAULT_VGAP + 2, UIUtil.DEFAULT_HGAP / 2)
}
override fun dispose() {}
}
@@ -1,39 +1,96 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.plugins.github.pullrequest.ui.details
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.ui.popup.IconButton
import com.intellij.ui.InplaceButton
import com.intellij.ui.components.panels.NonOpaquePanel
import com.intellij.util.IconUtil
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.JBUI.Panels.simplePanel
import com.intellij.util.ui.UIUtil
import org.jetbrains.plugins.github.api.data.GithubPullRequestDetailed
import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsBusyStateTracker
import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsSecurityService
import org.jetbrains.plugins.github.pullrequest.ui.WrapLayout
import org.jetbrains.plugins.github.util.GithubUtil.Delegates.equalVetoingObservable
import java.awt.Cursor
import java.awt.FlowLayout
import java.awt.event.ActionListener
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JLabel
internal abstract class LabeledListPanelHandle<T>(emptyText: String, notEmptyText: String) {
internal abstract class LabeledListPanelHandle<T>(private val model: GithubPullRequestDetailsModel,
private val securityService: GithubPullRequestsSecurityService,
private val busyStateTracker: GithubPullRequestsBusyStateTracker,
emptyText: String, notEmptyText: String)
: Disposable {
val label = JLabel().apply {
foreground = UIUtil.getContextHelpForeground()
border = JBUI.Borders.empty(UIUtil.DEFAULT_VGAP + 2, 0, UIUtil.DEFAULT_VGAP + 2, UIUtil.DEFAULT_HGAP / 2)
}
val panel = NonOpaquePanel(WrapLayout(FlowLayout.LEADING, 0, 0))
var list: List<T>? by equalVetoingObservable<List<T>?>(null) { newList ->
protected val editButton = InplaceButton(IconButton(null,
resizeSquareIcon(AllIcons.General.Inline_edit),
resizeSquareIcon(AllIcons.General.Inline_edit_hovered)),
ActionListener { (::editList)() }).apply {
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
isVisible = securityService.isCurrentUserWithPushAccess()
}
private fun resizeSquareIcon(icon: Icon): Icon {
val scale = 20f / icon.iconHeight
return IconUtil.scale(icon, editButton, scale)
}
private var list: List<T>? by equalVetoingObservable<List<T>?>(null) { newList ->
label.text = newList?.let { if (it.isEmpty()) emptyText else notEmptyText }
label.isVisible = newList != null
panel.removeAll()
panel.isVisible = newList != null
if (newList != null) for (item in newList) {
panel.add(getListItemComponent(item))
if (newList != null) {
if (newList.isEmpty()) {
panel.add(editButton)
}
else {
for (item in newList.dropLast(1)) {
panel.add(getListItemComponent(item))
}
panel.add(getListItemComponent(newList.last(), true))
}
}
}
abstract fun getListItemComponent(item: T): JComponent
companion object {
inline fun <T> create(emptyText: String, notEmptyText: String, crossinline componentProvider: (T) -> JComponent) =
object : LabeledListPanelHandle<T>(emptyText, notEmptyText) {
override fun getListItemComponent(item: T) = componentProvider(item)
}
init {
model.addDetailsChangedListener(this) {
list = model.details?.let(::extractItems)
updateButton()
}
busyStateTracker.addPullRequestBusyStateListener(this) {
updateButton()
}
}
private fun updateButton() {
editButton.isEnabled = !(model.details?.number?.let(busyStateTracker::isBusy) ?: true)
}
private fun getListItemComponent(item: T, last: Boolean = false) =
if (!last) getItemComponent(item)
else simplePanel(getItemComponent(item)).addToRight(editButton).apply {
isOpaque = false
}
abstract fun extractItems(details: GithubPullRequestDetailed): List<T>?
abstract fun getItemComponent(item: T): JComponent
abstract fun editList()
override fun dispose() {}
}
@@ -4,6 +4,9 @@ package org.jetbrains.plugins.github.util
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.progress.ProcessCanceledException
import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.progress.Task
import com.intellij.openapi.project.Project
import java.util.concurrent.*
import java.util.function.BiFunction
@@ -33,6 +36,23 @@ object GithubAsyncUtil {
return result
}
@JvmStatic
fun <T> futureOfMutable(futureSupplier: () -> CompletableFuture<T>): CompletableFuture<T> {
val result = CompletableFuture<T>()
handleToOtherIfCancelled(futureSupplier, result)
return result
}
private fun <T> handleToOtherIfCancelled(futureSupplier: () -> CompletableFuture<T>, other: CompletableFuture<T>) {
futureSupplier().handle { result, error ->
if (result != null) other.complete(result)
if (error != null) {
if (isCancellation(error)) handleToOtherIfCancelled(futureSupplier, other)
other.completeExceptionally(error.cause)
}
}
}
fun isCancellation(error: Throwable): Boolean {
return error is ProcessCanceledException
|| error is CancellationException
@@ -41,6 +61,28 @@ object GithubAsyncUtil {
}
}
fun <T> ProgressManager.submitBackgroundTask(project: Project,
title: String,
canBeCancelled: Boolean,
progressIndicator: ProgressIndicator,
process: (indicator: ProgressIndicator) -> T): CompletableFuture<T> {
val future = CompletableFuture<T>()
runProcessWithProgressAsynchronously(object : Task.Backgroundable(project, title, canBeCancelled) {
override fun run(indicator: ProgressIndicator) {
future.complete(process(indicator))
}
override fun onCancel() {
future.cancel(true)
}
override fun onThrowable(error: Throwable) {
future.completeExceptionally(error)
}
}, progressIndicator)
return future
}
fun <T> CompletableFuture<T>.handleOnEdt(handler: (T?, Throwable?) -> Unit): CompletableFuture<Unit> =
handleAsync(BiFunction<T?, Throwable?, Unit> { result: T?, error: Throwable? ->
handler(result, error)
@@ -41,7 +41,7 @@ object GithubUIUtil {
}
}
fun createIssueLabelLabel(label: GithubIssueLabel): JBLabel = JBLabel(" ${label.name} ", UIUtil.ComponentStyle.MINI).apply {
fun createIssueLabelLabel(label: GithubIssueLabel): JBLabel = JBLabel(" ${label.name} ", UIUtil.ComponentStyle.SMALL).apply {
val apiColor = ColorUtil.fromHex(label.color)
background = JBColor(apiColor, ColorUtil.darker(apiColor, 3))
foreground = computeForeground(background)