diff --git a/src/main/scala/gitbucket/core/api/ApiLabel.scala b/src/main/scala/gitbucket/core/api/ApiLabel.scala new file mode 100644 index 0000000..2d1842b --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiLabel.scala @@ -0,0 +1,21 @@ +package gitbucket.core.api + +import gitbucket.core.model.Label +import gitbucket.core.util.RepositoryName + +/** + * https://developer.github.com/v3/issues/labels/ + */ +case class ApiLabel( + name: String, + color: String)(repositoryName: RepositoryName){ + var url = ApiPath(s"/api/v3/repos/${repositoryName.fullName}/labels/${name}") +} + +object ApiLabel{ + def apply(label:Label, repositoryName: RepositoryName): ApiLabel = + ApiLabel( + name = label.labelName, + color = label.color + )(repositoryName) +} \ No newline at end of file diff --git a/src/main/scala/gitbucket/core/api/CreateALabel.scala b/src/main/scala/gitbucket/core/api/CreateALabel.scala new file mode 100644 index 0000000..cfd71d1 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/CreateALabel.scala @@ -0,0 +1,18 @@ +package gitbucket.core.api + +/** + * https://developer.github.com/v3/issues/labels/#create-a-label + * api form + */ +case class CreateALabel( + name: String, + color: String +) { + def isValid: Boolean = { + name.length<=100 && + !name.startsWith("_") && + !name.startsWith("-") && + color.length==6 && + color.matches("[a-fA-F0-9+_.]+") + } +} \ No newline at end of file diff --git a/src/main/scala/gitbucket/core/api/JsonFormat.scala b/src/main/scala/gitbucket/core/api/JsonFormat.scala index 0733ba4..611db3b 100644 --- a/src/main/scala/gitbucket/core/api/JsonFormat.scala +++ b/src/main/scala/gitbucket/core/api/JsonFormat.scala @@ -31,6 +31,7 @@ FieldSerializer[ApiPullRequest.Commit]() + FieldSerializer[ApiIssue]() + FieldSerializer[ApiComment]() + + FieldSerializer[ApiLabel]() + ApiBranchProtection.enforcementLevelSerializer def apiPathSerializer(c: Context) = new CustomSerializer[ApiPath](format => diff --git a/src/main/scala/gitbucket/core/controller/LabelsController.scala b/src/main/scala/gitbucket/core/controller/LabelsController.scala index 29d8400..97e2242 100644 --- a/src/main/scala/gitbucket/core/controller/LabelsController.scala +++ b/src/main/scala/gitbucket/core/controller/LabelsController.scala @@ -1,12 +1,13 @@ package gitbucket.core.controller +import gitbucket.core.api.{ApiError, CreateALabel, ApiLabel, JsonFormat} import gitbucket.core.issues.labels.html import gitbucket.core.service.{RepositoryService, AccountService, IssuesService, LabelsService} -import gitbucket.core.util.{ReferrerAuthenticator, CollaboratorsAuthenticator} +import gitbucket.core.util.{LockUtil, RepositoryName, ReferrerAuthenticator, CollaboratorsAuthenticator} import gitbucket.core.util.Implicits._ import io.github.gitbucket.scalatra.forms._ import org.scalatra.i18n.Messages -import org.scalatra.Ok +import org.scalatra.{UnprocessableEntity, Created, Ok} class LabelsController extends LabelsControllerBase with LabelsService with IssuesService with RepositoryService with AccountService @@ -19,7 +20,7 @@ case class LabelForm(labelName: String, color: String) val labelForm = mapping( - "labelName" -> trim(label("Label name", text(required, labelName, maxlength(100)))), + "labelName" -> trim(label("Label name", text(required, labelName, uniqueLabelName, maxlength(100)))), "labelColor" -> trim(label("Color", text(required, color))) )(LabelForm.apply) @@ -31,6 +32,26 @@ hasWritePermission(repository.owner, repository.name, context.loginAccount)) }) + /** + * List all labels for this repository + * https://developer.github.com/v3/issues/labels/#list-all-labels-for-this-repository + */ + get("/api/v3/repos/:owner/:repository/labels")(referrersOnly { repository => + getLabels(repository.owner, repository.name).map { label => + JsonFormat(ApiLabel(label, RepositoryName(repository))) + } + }) + + /** + * Get a single label + * https://developer.github.com/v3/issues/labels/#get-a-single-label + */ + get("/api/v3/repos/:owner/:repository/labels/:labelName")(referrersOnly { repository => + getLabel(repository.owner, repository.name, params("labelName")).map { label => + JsonFormat(ApiLabel(label, RepositoryName(repository))) + } getOrElse NotFound() + }) + ajaxGet("/:owner/:repository/issues/labels/new")(collaboratorsOnly { repository => html.edit(None, repository) }) @@ -45,6 +66,31 @@ hasWritePermission(repository.owner, repository.name, context.loginAccount)) }) + /** + * Create a label + * https://developer.github.com/v3/issues/labels/#create-a-label + */ + post("/api/v3/repos/:owner/:repository/labels")(collaboratorsOnly { repository => + (for{ + data <- extractFromJsonBody[CreateALabel] if data.isValid + } yield { + LockUtil.lock(RepositoryName(repository).fullName) { + if (getLabel(repository.owner, repository.name, data.name).isEmpty) { + val labelId = createLabel(repository.owner, repository.name, data.name, data.color) + getLabel(repository.owner, repository.name, labelId).map { label => + Created(JsonFormat(ApiLabel(label, RepositoryName(repository)))) + } getOrElse NotFound() + } else { + // TODO ApiError should support errors field to enhance compatibility of GitHub API + UnprocessableEntity(ApiError( + "Validation Failed", + Some("https://developer.github.com/v3/issues/labels/#create-a-label") + )) + } + } + }) getOrElse NotFound() + }) + ajaxGet("/:owner/:repository/issues/labels/:labelId/edit")(collaboratorsOnly { repository => getLabel(repository.owner, repository.name, params("labelId").toInt).map { label => html.edit(Some(label), repository) @@ -61,12 +107,51 @@ hasWritePermission(repository.owner, repository.name, context.loginAccount)) }) + /** + * Update a label + * https://developer.github.com/v3/issues/labels/#update-a-label + */ + patch("/api/v3/repos/:owner/:repository/labels/:labelName")(collaboratorsOnly { repository => + (for{ + data <- extractFromJsonBody[CreateALabel] if data.isValid + } yield { + LockUtil.lock(RepositoryName(repository).fullName) { + getLabel(repository.owner, repository.name, params("labelName")).map { label => + if (getLabel(repository.owner, repository.name, data.name).isEmpty) { + updateLabel(repository.owner, repository.name, label.labelId, data.name, data.color) + JsonFormat(ApiLabel( + getLabel(repository.owner, repository.name, label.labelId).get, + RepositoryName(repository))) + } else { + // TODO ApiError should support errors field to enhance compatibility of GitHub API + UnprocessableEntity(ApiError( + "Validation Failed", + Some("https://developer.github.com/v3/issues/labels/#create-a-label"))) + } + } getOrElse NotFound() + } + }) getOrElse NotFound() + }) + ajaxPost("/:owner/:repository/issues/labels/:labelId/delete")(collaboratorsOnly { repository => deleteLabel(repository.owner, repository.name, params("labelId").toInt) Ok() }) /** + * Delete a label + * https://developer.github.com/v3/issues/labels/#delete-a-label + */ + delete("/api/v3/repos/:owner/:repository/labels/:labelName")(collaboratorsOnly { repository => + LockUtil.lock(RepositoryName(repository).fullName) { + getLabel(repository.owner, repository.name, params("labelName")).map { label => + deleteLabel(repository.owner, repository.name, label.labelId) + Ok() + } getOrElse NotFound() + } + }) + + /** * Constraint for the identifier such as user name, repository name or page name. */ private def labelName: Constraint = new Constraint(){ @@ -80,4 +165,12 @@ } } + private def uniqueLabelName: Constraint = new Constraint(){ + override def validate(name: String, value: String, params: Map[String, String], messages: Messages): Option[String] = { + val owner = params("owner") + val repository = params("repository") + getLabel(owner, repository, value).map(_ => "Name has already been taken.") + } + } + } diff --git a/src/main/scala/gitbucket/core/model/BasicTemplate.scala b/src/main/scala/gitbucket/core/model/BasicTemplate.scala index 2b13764..97fcb75 100644 --- a/src/main/scala/gitbucket/core/model/BasicTemplate.scala +++ b/src/main/scala/gitbucket/core/model/BasicTemplate.scala @@ -26,12 +26,16 @@ trait LabelTemplate extends BasicTemplate { self: Table[_] => val labelId = column[Int]("LABEL_ID") + val labelName = column[String]("LABEL_NAME") def byLabel(owner: String, repository: String, labelId: Int) = byRepository(owner, repository) && (this.labelId === labelId.bind) def byLabel(userName: Column[String], repositoryName: Column[String], labelId: Column[Int]) = byRepository(userName, repositoryName) && (this.labelId === labelId) + + def byLabel(owner: String, repository: String, labelName: String) = + byRepository(userName, repositoryName) && (this.labelName === labelName.bind) } trait MilestoneTemplate extends BasicTemplate { self: Table[_] => diff --git a/src/main/scala/gitbucket/core/model/Labels.scala b/src/main/scala/gitbucket/core/model/Labels.scala index 0143c9e..84a4e6d 100644 --- a/src/main/scala/gitbucket/core/model/Labels.scala +++ b/src/main/scala/gitbucket/core/model/Labels.scala @@ -7,7 +7,7 @@ class Labels(tag: Tag) extends Table[Label](tag, "LABEL") with LabelTemplate { override val labelId = column[Int]("LABEL_ID", O AutoInc) - val labelName = column[String]("LABEL_NAME") + override val labelName = column[String]("LABEL_NAME") val color = column[String]("COLOR") def * = (userName, repositoryName, labelId, labelName, color) <> (Label.tupled, Label.unapply) diff --git a/src/main/scala/gitbucket/core/service/LabelsService.scala b/src/main/scala/gitbucket/core/service/LabelsService.scala index 35b5d2d..f8026e0 100644 --- a/src/main/scala/gitbucket/core/service/LabelsService.scala +++ b/src/main/scala/gitbucket/core/service/LabelsService.scala @@ -12,6 +12,9 @@ def getLabel(owner: String, repository: String, labelId: Int)(implicit s: Session): Option[Label] = Labels.filter(_.byPrimaryKey(owner, repository, labelId)).firstOption + def getLabel(owner: String, repository: String, labelName: String)(implicit s: Session): Option[Label] = + Labels.filter(_.byLabel(owner, repository, labelName)).firstOption + def createLabel(owner: String, repository: String, labelName: String, color: String)(implicit s: Session): Int = Labels returning Labels.map(_.labelId) += Label( userName = owner, diff --git a/src/test/scala/gitbucket/core/api/JsonFormatSpec.scala b/src/test/scala/gitbucket/core/api/JsonFormatSpec.scala index 5395da6..38dee6a 100644 --- a/src/test/scala/gitbucket/core/api/JsonFormatSpec.scala +++ b/src/test/scala/gitbucket/core/api/JsonFormatSpec.scala @@ -209,6 +209,15 @@ "url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/commits/$sha1/status" }""" + val apiLabel = ApiLabel( + name = "bug", + color = "f29513")(RepositoryName("octocat","Hello-World")) + val apiLabelJson = s"""{ + "name": "bug", + "color": "f29513", + "url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/labels/bug" + }""" + val apiIssue = ApiIssue( number = 1347, title = "Found a bug", @@ -411,6 +420,9 @@ "apiCombinedCommitStatus" in { JsonFormat(apiCombinedCommitStatus) must beFormatted(apiCombinedCommitStatusJson) } + "apiLabel" in { + JsonFormat(apiLabel) must beFormatted(apiLabelJson) + } "apiIssue" in { JsonFormat(apiIssue) must beFormatted(apiIssueJson) JsonFormat(apiIssuePR) must beFormatted(apiIssuePRJson) diff --git a/src/test/scala/gitbucket/core/service/LabelsServiceSpec.scala b/src/test/scala/gitbucket/core/service/LabelsServiceSpec.scala new file mode 100644 index 0000000..3a6ccc8 --- /dev/null +++ b/src/test/scala/gitbucket/core/service/LabelsServiceSpec.scala @@ -0,0 +1,114 @@ +package gitbucket.core.service + +import gitbucket.core.model._ + +import org.specs2.mutable.Specification + +class LabelsServiceSpec extends Specification with ServiceSpecBase { + "getLabels" should { + "be empty when not have any labels" in { withTestDB { implicit session => + generateNewUserWithDBRepository("user1", "repo1") + + generateNewUserWithDBRepository("user1", "repo2") + dummyService.createLabel("user1", "repo2", "label1", "000000") + + generateNewUserWithDBRepository("user2", "repo1") + dummyService.createLabel("user2", "repo1", "label1", "000000") + + dummyService.getLabels("user1", "repo1") must haveSize(0) + }} + "return contained labels" in { withTestDB { implicit session => + generateNewUserWithDBRepository("user1", "repo1") + val labelId1 = dummyService.createLabel("user1", "repo1", "label1", "000000") + val labelId2 = dummyService.createLabel("user1", "repo1", "label2", "ffffff") + + generateNewUserWithDBRepository("user1", "repo2") + dummyService.createLabel("user1", "repo2", "label1", "000000") + + generateNewUserWithDBRepository("user2", "repo1") + dummyService.createLabel("user2", "repo1", "label1", "000000") + + def getLabels = dummyService.getLabels("user1", "repo1") + + getLabels must haveSize(2) + getLabels must_== List( + Label("user1", "repo1", labelId1, "label1", "000000"), + Label("user1", "repo1", labelId2, "label2", "ffffff")) + }} + } + "getLabel" should { + "return None when the label not exist" in { withTestDB { implicit session => + generateNewUserWithDBRepository("user1", "repo1") + + dummyService.getLabel("user1", "repo1", 1) must beNone + dummyService.getLabel("user1", "repo1", "label1") must beNone + }} + "return a label fetched by label id" in { withTestDB { implicit session => + generateNewUserWithDBRepository("user1", "repo1") + val labelId1 = dummyService.createLabel("user1", "repo1", "label1", "000000") + dummyService.createLabel("user1", "repo1", "label2", "ffffff") + + generateNewUserWithDBRepository("user1", "repo2") + dummyService.createLabel("user1", "repo2", "label1", "000000") + + generateNewUserWithDBRepository("user2", "repo1") + dummyService.createLabel("user2", "repo1", "label1", "000000") + + def getLabel = dummyService.getLabel("user1", "repo1", labelId1) + getLabel must_== Some(Label("user1", "repo1", labelId1, "label1", "000000")) + }} + "return a label fetched by label name" in { withTestDB { implicit session => + generateNewUserWithDBRepository("user1", "repo1") + val labelId1 = dummyService.createLabel("user1", "repo1", "label1", "000000") + dummyService.createLabel("user1", "repo1", "label2", "ffffff") + + generateNewUserWithDBRepository("user1", "repo2") + dummyService.createLabel("user1", "repo2", "label1", "000000") + + generateNewUserWithDBRepository("user2", "repo1") + dummyService.createLabel("user2", "repo1", "label1", "000000") + + def getLabel = dummyService.getLabel("user1", "repo1", "label1") + getLabel must_== Some(Label("user1", "repo1", labelId1, "label1", "000000")) + }} + } + "createLabel" should { + "return accurate label id" in { withTestDB { implicit session => + generateNewUserWithDBRepository("user1", "repo1") + generateNewUserWithDBRepository("user1", "repo2") + generateNewUserWithDBRepository("user2", "repo1") + dummyService.createLabel("user1", "repo1", "label1", "000000") + dummyService.createLabel("user1", "repo2", "label1", "000000") + dummyService.createLabel("user2", "repo1", "label1", "000000") + val labelId = dummyService.createLabel("user1", "repo1", "label2", "000000") + labelId must_== 4 + def getLabel = dummyService.getLabel("user1", "repo1", labelId) + getLabel must_== Some(Label("user1", "repo1", labelId, "label2", "000000")) + }} + } + "updateLabel" should { + "change target label" in { withTestDB { implicit session => + generateNewUserWithDBRepository("user1", "repo1") + generateNewUserWithDBRepository("user1", "repo2") + generateNewUserWithDBRepository("user2", "repo1") + val labelId = dummyService.createLabel("user1", "repo1", "label1", "000000") + dummyService.createLabel("user1", "repo2", "label1", "000000") + dummyService.createLabel("user2", "repo1", "label1", "000000") + dummyService.updateLabel("user1", "repo1", labelId, "updated-label", "ffffff") + def getLabel = dummyService.getLabel("user1", "repo1", labelId) + getLabel must_== Some(Label("user1", "repo1", labelId, "updated-label", "ffffff")) + }} + } + "deleteLabel" should { + "remove target label" in { withTestDB { implicit session => + generateNewUserWithDBRepository("user1", "repo1") + generateNewUserWithDBRepository("user1", "repo2") + generateNewUserWithDBRepository("user2", "repo1") + val labelId = dummyService.createLabel("user1", "repo1", "label1", "000000") + dummyService.createLabel("user1", "repo2", "label1", "000000") + dummyService.createLabel("user2", "repo1", "label1", "000000") + dummyService.deleteLabel("user1", "repo1", labelId) + dummyService.getLabel("user1", "repo1", labelId) must beNone + }} + } +} diff --git a/src/test/scala/gitbucket/core/service/ServiceSpecBase.scala b/src/test/scala/gitbucket/core/service/ServiceSpecBase.scala index f5fd9ea..a7b383d 100644 --- a/src/test/scala/gitbucket/core/service/ServiceSpecBase.scala +++ b/src/test/scala/gitbucket/core/service/ServiceSpecBase.scala @@ -38,7 +38,7 @@ def user(name:String)(implicit s:Session):Account = AccountService.getAccountByUserName(name).get lazy val dummyService = new RepositoryService with AccountService with IssuesService with PullRequestService - with CommitStatusService (){} + with CommitStatusService with LabelsService (){} def generateNewUserWithDBRepository(userName:String, repositoryName:String)(implicit s:Session):Account = { val ac = AccountService.getAccountByUserName(userName).getOrElse(generateNewAccount(userName))