diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 4228558a..99586f8a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,28 +7,20 @@ assignees: '' --- -## Describe the bug +## What happened? -A clear and concise description of what the bug is. +Please provide as much info as possible. +Not doing so may result in your bug not being addressed in a timely manner. -Formatting tips: GitHub supports Markdown: https://guides.github.com/features/mastering-markdown/ +## What did you expect to happen? -## To Reproduce -Provide a link to a live example, or an unambiguous set of steps to reproduce this bug. Include configuration, logs, etc. to reproduce, if relevant. +## How can we reproduce it (as minimally and precisely as possible)? -1. -2. -3. -4. +Minimal code helps us to identify the problem faster. -## Expected behavior +## Anything else we need to know? -A clear and concise description of what you expected to happen. - -## Possible Solution - -Not obligatory, but suggest a fix/reason for the bug, or ideas how to implement: the addition or change. ## Your Environment @@ -36,16 +28,5 @@ Include as many relevant details about the environment you experienced the probl * go-jira version (git tag or sha): * Go version (`go version`): -* Jira version: * Jira type (cloud or on-premise): -* Involved Jira plugins: -* Operating System and version: - -## Additional context - -Add any other context about the problem here. - -How has this issue affected you? What are you trying to accomplish? -Providing context helps us come up with a solution that is most useful in the real world. - - +* Jira version / Api version: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 0dcdd1f0..00000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,4 +0,0 @@ -contact_links: - - name: Report a bug/feature request for the Jira Command Line Client - url: https://github.com/go-jira/jira/issues - about: This is the issue tracker for the Jira command-line client in Go. If you are using this, please report issues there. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index edc78bb8..16e8fd73 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,24 +1,16 @@ --- name: Feature request -about: Suggest an idea for this project +about: Suggest a feature for this project title: '' labels: '' assignees: '' --- -## Is your feature request related to a problem? Please describe. +## What would you like to be added? -A clear and concise description of what the problem is. Ex. I'm always using this feature but am missing [...] -## Describe the solution you'd like +## Why is this needed? -A clear and concise description of what you want to happen. -## Describe alternatives you've considered - -A clear and concise description of any alternative solutions or features you've considered. - -## Additional context - -Add any other context or screenshots about the feature request here. \ No newline at end of file +## Anything else we need to know? \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4a3bf0dc..f18673d6 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,32 +1,25 @@ -# Description +#### What type of PR is this? -Please describe _what does this Pull Request fix or add?_. + -## Example: +#### What this PR does / why we need it: -Let us know how users can use or test this functionality. -```go -// Example code +#### Which issue(s) this PR fixes: -``` +Fixes # -# Checklist +#### Special notes for your reviewer: -* [ ] Unit or Integration tests added - * [ ] Good Path - * [ ] Error Path -* [ ] Commits follow conventions described here: - * [ ] [Conventional Commits 1.0.0](https://conventionalcommits.org/en/v1.0.0-beta.4/#summary) - * [ ] [The seven rules of a great Git commit message](https://chris.beams.io/posts/git-commit/#seven-rules) -* [ ] Commits are squashed such that - * [ ] There is 1 commit per isolated change -* [ ] I've not made extraneous commits/changes that are unrelated to my change. + +#### Additional documentation e.g., usage docs, etc.: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 83f62324..d433f2dc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,9 +6,9 @@ updates: - package-ecosystem: "gomod" directory: "/" schedule: - interval: "daily" + interval: "monthly" - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" \ No newline at end of file + interval: "monthly" diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..7d38c713 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,3 @@ +jira-onpremise: onpremise/**/* +jira-cloud: cloud/**/* +documentation: docs/**/* \ No newline at end of file diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 00000000..7de518cd --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,16 @@ +name: Documentation +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: 3.x + - run: pip install mkdocs-material + - run: mkdocs gh-deploy --force \ No newline at end of file diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml deleted file mode 100644 index 88eb1e3e..00000000 --- a/.github/workflows/greetings.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Greetings -on: [pull_request, issues] - -jobs: - greeting: - runs-on: ubuntu-latest - steps: - - uses: actions/first-interaction@v1 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - issue-message: 'Hi! Thank you for taking the time to create your first issue! Really cool to see you here for the first time. Please give us a bit of time to review it.' - pr-message: 'Great! Thank you for taking the time to create your first pull request. It is always a pleasure to see people like you who spent time contributing. Please give us a bit of time to review it!' \ No newline at end of file diff --git a/.github/workflows/label-merge-conflicts.yml b/.github/workflows/label-merge-conflicts.yml new file mode 100644 index 00000000..048cbff0 --- /dev/null +++ b/.github/workflows/label-merge-conflicts.yml @@ -0,0 +1,21 @@ +name: Auto-label merge conflicts + +on: + workflow_dispatch: + schedule: + - cron: "*/15 * * * *" + +# limit permissions +permissions: + contents: read + pull-requests: write + +jobs: + conflicts: + runs-on: ubuntu-latest + + steps: + - uses: mschilde/auto-label-merge-conflicts@v2.0 + with: + CONFLICT_LABEL_NAME: conflicts + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" \ No newline at end of file diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml new file mode 100644 index 00000000..b37fcb9d --- /dev/null +++ b/.github/workflows/label.yml @@ -0,0 +1,19 @@ +name: Label pull requests + +on: + pull_request: + workflow_dispatch: + +# limit permissions +permissions: + contents: read + pull-requests: write + +jobs: + build: + runs-on: ubuntu-latest + if: (github.actor != 'dependabot[bot]') + steps: + - uses: actions/labeler@v4 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" \ No newline at end of file diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 94d4a7df..cdd49093 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -3,45 +3,71 @@ name: Tests on: push: branches: - - master + - main pull_request: workflow_dispatch: schedule: - cron: "5 1 * * *" jobs: - test: - name: Test and lint + unit-test: + name: Unit tests strategy: fail-fast: false matrix: - go-version: [ '1.18', '1.17' ] - os: ["windows-latest", "ubuntu-latest", "macOS-latest"] + go: [ '1.19', '1.18' ] + os: [ 'windows-latest', 'ubuntu-latest', 'macOS-latest' ] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 - - uses: actions/setup-go@v2 + - uses: actions/setup-go@v3 with: - go-version: ${{ matrix.go-version }} + go-version: ${{ matrix.go }} - # Caching go modules to speed up the run - - uses: actions/cache@v3.0.1 + - name: Run Unit tests (Go ${{ matrix.go }}) + run: make test + + fmt: + name: go fmt + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v3 with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- + go-version: 1.19 - - name: Run go fmt + - name: Run go fmt (Go ${{ matrix.go }}) if: runner.os != 'Windows' run: diff -u <(echo -n) <(gofmt -d -s .) + vet: + name: go vet + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v3 + with: + go-version: 1.19 + - name: Run go vet run: make vet - - name: Run staticcheck - run: make staticcheck + staticcheck: + name: staticcheck + runs-on: ubuntu-latest - - name: Run Unit tests. - run: make test + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v3 + with: + go-version: 1.19 + + - name: Run staticcheck (Go ${{ matrix.go }}) + uses: dominikh/staticcheck-action@v1.3.0 + with: + version: "2022.1" + install-go: false + cache-key: staticcheck-cache \ No newline at end of file diff --git a/.gitignore b/.gitignore index e2006062..9d26f476 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,7 @@ _testmain.go *.prof *.iml .idea -.DS_Store \ No newline at end of file +.DS_Store + +# Code coverage +coverage.txt \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5915ef43..d9e4e3c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,427 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [2.0]() (UNRELEASED) + +Version 2.0 is a bigger change with the main goal to make this library more reliable and future safe. +See https://github.com/andygrunwald/go-jira/issues/489 for details. + +### Migration + +#### Split of clients + +We moved from 1 client that handles On-Premise and Cloud to 2 clients that handle either On-Premise or Cloud. +Previously you used this library like: + +```go +import ( + "github.com/andygrunwald/go-jira" +) +``` + +In the new version, you need to decide if you interact with the Jira On-Premise or Jira Cloud version. +For the cloud version, you will import this library like + +```go +import ( + jira "github.com/andygrunwald/go-jira/cloud" +) +``` + +For On-Premise it looks like + +```go +import ( + jira "github.com/andygrunwald/go-jira/onpremise" +) +``` + +#### Init a new client + +The order of arguments in the `jira.NewClient` has changed: + +1. The base URL of your JIRA instance +2. A HTTP client (optional) + +Before: + +```go +jira.NewClient(nil, "https://issues.apache.org/jira/") +``` + +After: + +```go +jira.NewClient("https://issues.apache.org/jira/", nil) +``` + +#### User Agent + +The client will identify itself via a UserAgent `go-jira/2.0.0`. + +#### `NewRawRequestWithContext` removed, `NewRawRequest` requires `context` + +The function `client.NewRawRequestWithContext()` has been removed. +`client.NewRawRequest()` accepts now a context as the first argument. +This is a drop in replacement. + +Before: + +```go +client.NewRawRequestWithContext(context.Background(), "GET", .....) +``` + +After: + +```go +client.NewRawRequest(context.Background(), "GET", .....) +``` + +For people who used `jira.NewRawRequest()`: You need to pass a context as the first argument. +Like + +```go +client.NewRawRequest(context.Background(), "GET", .....) +``` + +#### `NewRequestWithContext` removed, `NewRequest` requires `context` + +The function `client.NewRequestWithContext()` has been removed. +`client.NewRequest()` accepts now a context as the first argument. +This is a drop in replacement. + +Before: + +```go +client.NewRequestWithContext(context.Background(), "GET", .....) +``` + +After: + +```go +client.NewRequest(context.Background(), "GET", .....) +``` + +For people who used `jira.NewRequest()`: You need to pass a context as the first argument. +Like + +```go +client.NewRequest(context.Background(), "GET", .....) +``` + +#### `NewMultiPartRequestWithContext` removed, `NewMultiPartRequest` requires `context` + +The function `client.NewMultiPartRequestWithContext()` has been removed. +`client.NewMultiPartRequest()` accepts now a context as the first argument. +This is a drop in replacement. + +Before: + +```go +client.NewMultiPartRequestWithContext(context.Background(), "GET", .....) +``` + +After: + +```go +client.NewMultiPartRequest(context.Background(), "GET", .....) +``` + +For people who used `jira.NewMultiPartRequest()`: You need to pass a context as the first argument. +Like + +```go +client.NewMultiPartRequest(context.Background(), "GET", .....) +``` + +#### `context` is a first class citizen + +All API methods require a `context` as first argument. + +In the v1, some methods had a `...WithContext` suffix. +These methods have been removed. + +If you used a service like + +```go +client.Issue.CreateWithContext(ctx, ...) +``` + +the new call would be + +```go +client.Issue.Create(ctx, ...) +``` + +If you used API calls without a context, like + +```go +client.Issue.Create(...) +``` + +the new call would be + +```go +client.Issue.Create(ctx, ...) +``` + +#### `BoardService.GetAllSprints` removed, `BoardService.GetAllSprintsWithOptions` renamed + +The function `client.BoardService.GetAllSprintsWithOptions()` has been renamed to `client.BoardService.GetAllSprints()`. + +##### If you used `client.BoardService.GetAllSprints()`: + +Before: + +```go +client.Board.GetAllSprints(context.Background(), "123") +``` + +After: + +```go +client.Board.GetAllSprints(context.Background(), "123", nil) +``` + +##### If you used `client.BoardService.GetAllSprintsWithOptions()`: + +Before: + +```go +client.Board.GetAllSprintsWithOptions(context.Background(), 123, &GetAllSprintsOptions{State: "active,future"}) +``` + +After: + +```go +client.Board.GetAllSprints(context.Background(), 123, &GetAllSprintsOptions{State: "active,future"}) +``` + +#### `GroupService.Get` removed, `GroupService.GetWithOptions` renamed + +The function `client.GroupService.GetWithOptions()` has been renamed to `client.GroupService.Get()`. + +##### If you used `client.GroupService.Get()`: + +Before: + +```go +client.Group.Get(context.Background(), "default") +``` + +After: + +```go +client.Group.Get(context.Background(), "default", nil) +``` + +##### If you used `client.GroupService.GetWithOptions()`: + +Before: + +```go +client.Group.GetWithOptions(context.Background(), "default", &GroupSearchOptions{StartAt: 0, MaxResults: 2}) +``` + +After: + +```go +client.Group.Get(context.Background(), "default", &GroupSearchOptions{StartAt: 0, MaxResults: 2}) +``` + +#### `Issue.Update` removed, `Issue.UpdateWithOptions` renamed + +The function `client.Issue.UpdateWithOptions()` has been renamed to `client.Issue.Update()`. + +##### If you used `client.Issue.Update()`: + +Before: + +```go +client.Issue.Update(context.Background(), issue) +``` + +After: + +```go +client.Issue.Update(context.Background(), issue, nil) +``` + +##### If you used `client.Issue.UpdateWithOptions()`: + +Before: + +```go +client.Issue.UpdateWithOptions(context.Background(), issue, nil) +``` + +After: + +```go +client.Issue.Update(context.Background(), issue, nil) +``` + +#### `Issue.GetCreateMeta` removed, `Issue.GetCreateMetaWithOptions` renamed + +The function `client.Issue.GetCreateMetaWithOptions()` has been renamed to `client.Issue.GetCreateMeta()`. + +##### If you used `client.Issue.GetCreateMeta()`: + +Before: + +```go +client.Issue.GetCreateMeta(context.Background(), "SPN") +``` + +After: + +```go +client.Issue.GetCreateMetaWithOptions(ctx, &GetQueryOptions{ProjectKeys: "SPN", Expand: "projects.issuetypes.fields"}) +``` + +##### If you used `client.Issue.GetCreateMetaWithOptions()`: + +Before: + +```go +client.Issue.GetCreateMetaWithOptions(ctx, &GetQueryOptions{ProjectKeys: "SPN", Expand: "projects.issuetypes.fields"}) +``` + +After: + +```go +client.Issue.GetCreateMeta(ctx, &GetQueryOptions{ProjectKeys: "SPN", Expand: "projects.issuetypes.fields"}) +``` + +#### `Project.GetList` removed, `Project.ListWithOptions` renamed + +The function `client.Project.ListWithOptions()` has been renamed to `client.Project.GetAll()`. + +##### If you used `client.Project.GetList()`: + +Before: + +```go +client.Project.GetList(context.Background()) +``` + +After: + +```go +client.Project.GetAll(context.Background(), nil) +``` + +##### If you used `client.Project.ListWithOptions()`: + +Before: + +```go +client.Project.ListWithOptions(ctx, &GetQueryOptions{}) +``` + +After: + +```go +client.Project.GetAll(ctx, &GetQueryOptions{}) +``` + +#### Cloud/Authentication: `BearerAuthTransport` removed, `PATAuthTransport` removed + +If you used `BearerAuthTransport` or `PATAuthTransport` for authentication, please replace it with `BasicAuthTransport`. + +Before: + +```go +tp := jira.BearerAuthTransport{ + Token: "token", +} +client, err := jira.NewClient("https://...", tp.Client()) +``` + +or + +```go +tp := jira.PATAuthTransport{ + Token: "token", +} +client, err := jira.NewClient("https://...", tp.Client()) +``` + +After: + +```go +tp := jira.BasicAuthTransport{ + Username: "username", + APIToken: "token", +} +client, err := jira.NewClient("https://...", tp.Client()) +``` + +#### Cloud/Authentication: `BasicAuthTransport.Password` was renamed to `BasicAuthTransport.APIToken` + +Before: + +```go +tp := jira.BasicAuthTransport{ + Username: "username", + Password: "token", +} +client, err := jira.NewClient("https://...", tp.Client()) +``` + +After: + +```go +tp := jira.BasicAuthTransport{ + Username: "username", + APIToken: "token", +} +client, err := jira.NewClient("https://...", tp.Client()) +``` + +### Breaking changes + +* Jira On-Premise and Jira Cloud have now different clients, because the API differs +* `client.NewRawRequestWithContext()` has been removed in favor of `client.NewRawRequest()`, which requires now a context as first argument +* `client.NewRequestWithContext()` has been removed in favor of `client.NewRequest()`, which requires now a context as first argument +* `client.NewMultiPartRequestWithContext()` has been removed in favor of `client.NewMultiPartRequest()`, which requires now a context as first argument +* `context` is now a first class citizen in all API calls. Functions that had a suffix like `...WithContext` have been removed entirely. The API methods support the context now as first argument. +* `BoardService.GetAllSprints` has been removed and `BoardService.GetAllSprintsWithOptions` has been renamed to `BoardService.GetAllSprints` +* `GroupService.Get` has been removed and `GroupService.GetWithOptions` has been renamed to `GroupService.Get` +* `Issue.Update` has been removed and `Issue.UpdateWithOptions` has been renamed to `Issue.Update` +* `Issue.GetCreateMeta` has been removed and `Issue.GetCreateMetaWithOptions` has been renamed to `Issue.GetCreateMeta` +* `Project.GetList` has been removed and `Project.ListWithOptions` has been renamed to `Project.GetAll` +* Cloud/Authentication: Removed `BearerAuthTransport`, because it was a (kind of) duplicate of `BasicAuthTransport` +* Cloud/Authentication: Removed `PATAuthTransport`, because it was a (kind of) duplicate of `BasicAuthTransport` +* Cloud/Authentication: `BasicAuthTransport.Password` was renamed to `BasicAuthTransport.APIToken` +* Cloud/Authentication: Removes `CookieAuthTransport` and `AuthenticationService`, because this type of auth is not supported by the Jira cloud offering +* Cloud/Component: The type `CreateComponentOptions` was renamed to `ComponentCreateOptions` +* Cloud/User: Renamed `User.GetSelf` to `User.GetCurrentUser` +* Cloud/Group: Renamed `Group.Add` to `Group.AddUserByGroupName` +* Cloud/Group: Renamed `Group.Remove` to `Group.RemoveUserByGroupName` + +### Features + +* UserAgent: Client HTTP calls are now identifable via a User Agent. This user agent can be configured (default: `go-jira/2.0.0`) +* The underlying used HTTP client for API calls can be retrieved via `client.Client()` +* API-Version: Official support for Jira Cloud API in [version 3](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/) + +### Bug Fixes + +* README: Fixed all (broken) links + +### API-Endpoints + +* Workflow status categories: Revisited and fully implemented for Cloud and On Premise (incl. examples) + +### Other + +* Replace all "GET", "POST", ... with http.MethodGet (and related) constants +* Development: Added `make` commands to collect (unit) test coverage +* Internal: Replaced `io.ReadAll` and `json.Unmarshal` with `json.NewDecoder` + +### Changes + ## [1.13.0](https://github.com/andygrunwald/go-jira/compare/v1.11.1...v1.13.0) (2020-10-25) diff --git a/Makefile b/Makefile index 928c554c..2ff82e3c 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,15 @@ help: ## Outputs the help. .PHONY: test test: ## Runs all unit, integration and example tests. - go test -race -v ./... + go test -v -race ./... + +.PHONY: test-coverage +test-coverage: ## Runs all unit tests + gathers code coverage + go test -v -race -coverprofile coverage.txt ./... + +.PHONY: test-coverage-html +test-coverage-html: test-coverage ## Runs all unit tests + gathers code coverage + displays them in your default browser + go tool cover -html=coverage.txt .PHONY: vet vet: ## Runs go vet (to detect suspicious constructs). @@ -18,8 +26,12 @@ fmt: ## Runs go fmt (to check for go coding guidelines). .PHONY: staticcheck staticcheck: ## Runs static analysis to prevend bugs, foster code simplicity, performance and editor integration. - go get -u honnef.co/go/tools/cmd/staticcheck + go install honnef.co/go/tools/cmd/staticcheck@latest staticcheck ./... .PHONY: all all: test vet fmt staticcheck ## Runs all source code quality targets (like test, vet, fmt, staticcheck) + +.PHONY: docs-serve +docs-serve: ## Runs the documentation development server (based on mkdocs) + mkdocs serve \ No newline at end of file diff --git a/README.md b/README.md index 571d4c30..5f091552 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,40 @@ # go-jira -[![GoDoc](https://godoc.org/github.com/andygrunwald/go-jira?status.svg)](https://godoc.org/github.com/andygrunwald/go-jira) +[![GoDoc](https://pkg.go.dev/badge/github.com/andygrunwald/go-jira?utm_source=godoc)](https://pkg.go.dev/github.com/andygrunwald/go-jira) [![Build Status](https://github.com/andygrunwald/go-jira/actions/workflows/testing.yml/badge.svg)](https://github.com/andygrunwald/go-jira/actions/workflows/testing.yml) -[![Go Report Card](https://goreportcard.com/badge/github.com/andygrunwald/go-jira)](https://goreportcard.com/report/github.com/andygrunwald/go-jira) +[![Go Report Card](https://img.shields.io/badge/go%20report-A+-brightgreen.svg?style=flat)](https://goreportcard.com/report/github.com/andygrunwald/go-jira) -[Go](https://golang.org/) client library for [Atlassian Jira](https://www.atlassian.com/software/jira). +[Go](https://go.dev/) client library for [Atlassian Jira](https://www.atlassian.com/software/jira). ![Go client library for Atlassian Jira](./img/logo_small.png "Go client library for Atlassian Jira.") +## :warning: State of this library :warning: + +**v2 of this library is in development.** +**v2 will contain breaking changes :warning:** +**The current main branch can contains the development version of v2.** + +The goals of v2 are: + +* idiomatic go usage +* proper documentation +* being compliant with different kinds of Atlassian Jira products (on-premise vs. cloud) +* remove flaws introduced during the early times of this library + +See our milestone [Road to v2](https://github.com/andygrunwald/go-jira/milestone/1) and provide feedback in [Development is kicking: Road to v2 🚀 #489](https://github.com/andygrunwald/go-jira/issues/489). +Attention: The current `main` branch represents the v2 development version - we treat this version as unstable and breaking changes are expected. + +**If you want to stay more stable, please use v1.\*** - See our [releases](https://github.com/andygrunwald/go-jira/releases). +Latest stable release: [v1.16.0](https://github.com/andygrunwald/go-jira/releases/tag/v1.16.0) + ## Features -* Authentication (HTTP Basic, OAuth, Session Cookie) +* Authentication (HTTP Basic, OAuth, Session Cookie, Bearer (for PATs)) * Create and retrieve issues * Create and retrieve issue transitions (status updates) * Call every API endpoint of the Jira, even if it is not directly implemented in this library -This package is not Jira API complete (yet), but you can call every API endpoint you want. See [Call a not implemented API endpoint](#call-a-not-implemented-api-endpoint) how to do this. For all possible API endpoints of Jira have a look at [latest Jira REST API documentation](https://docs.atlassian.com/jira/REST/latest/). +This package is not Jira API complete (yet), but you can call every API endpoint you want. See [Call a not implemented API endpoint](#call-a-not-implemented-api-endpoint) how to do this. For all possible API endpoints of Jira have a look at [latest Jira REST API documentation](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/). ## Requirements @@ -29,38 +48,20 @@ of Go are officially supported. It is go gettable -```bash +```sh go get github.com/andygrunwald/go-jira ``` -For stable versions you can use one of our tags with [gopkg.in](http://labix.org/gopkg.in). E.g. - -```go -package main - -import ( - jira "gopkg.in/andygrunwald/go-jira.v1" -) -... -``` - -(optional) to run unit / example tests: - -```bash -cd $GOPATH/src/github.com/andygrunwald/go-jira -go test -v ./... -``` - ## API -Please have a look at the [GoDoc documentation](https://godoc.org/github.com/andygrunwald/go-jira) for a detailed API description. +Please have a look at the [GoDoc documentation](https://pkg.go.dev/github.com/andygrunwald/go-jira) for a detailed API description. -The [latest Jira REST API documentation](https://docs.atlassian.com/jira/REST/latest/) was the base document for this package. +The [latest Jira REST API documentation](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/) was the base document for this package. ## Examples Further a few examples how the API can be used. -A few more examples are available in the [GoDoc examples section](https://godoc.org/github.com/andygrunwald/go-jira#pkg-examples). +A few more examples are available in the [GoDoc examples section](https://pkg.go.dev/github.com/andygrunwald/go-jira#section-directories). ### Get a single issue @@ -91,36 +92,46 @@ func main() { ### Authentication The `go-jira` library does not handle most authentication directly. Instead, authentication should be handled within -an `http.Client`. That client can then be passed into the `NewClient` function when creating a jira client. +an `http.Client`. That client can then be passed into the `NewClient` function when creating a jira client. For convenience, capability for basic and cookie-based authentication is included in the main library. #### Token (Jira on Atlassian Cloud) -Token-based authentication uses the basic authentication scheme, with a user-generated API token in place of a user's password. You can generate a token for your user [here](https://id.atlassian.com/manage-profile/security/api-tokens). Additional information about Atlassian Cloud API tokens can be found [here](https://confluence.atlassian.com/cloud/api-tokens-938839638.html). +Token-based authentication uses the basic authentication scheme, with a user-generated API token in place of a user's password. You can generate a token for your user [here](https://id.atlassian.com/manage-profile/security/api-tokens). Additional information about Atlassian Cloud API tokens can be found [here](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/). -A more thorough, [runnable example](examples/basicauth/main.go) is provided in the examples directory. +A more thorough, [runnable example](cloud/examples/basic_auth/main.go) is provided in the examples directory. ```go func main() { tp := jira.BasicAuthTransport{ - Username: "username", - Password: "token", + Username: "", + APIToken: "", } client, err := jira.NewClient(tp.Client(), "https://my.jira.com") - u, _, err := client.User.Get("some_user") + u, _, err = client.User.GetCurrentUser(context.Background()) - fmt.Printf("\nEmail: %v\nSuccess!\n", u.EmailAddress) + fmt.Printf("Email: %v\n", u.EmailAddress) + fmt.Println("Success!") } ``` +#### Bearer - Personal Access Tokens (self-hosted Jira) + +For **self-hosted Jira** (v8.14 and later), Personal Access Tokens (PATs) were introduced. +Similar to the API tokens, PATs are a safe alternative to using username and password for authentication with scripts and integrations. +PATs use the Bearer authentication scheme. +Read more about Jira PATs [here](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html). + +See [examples/bearerauth](onpremise/examples/bearerauth/main.go) for how to use the Bearer authentication scheme with Jira in Go. + #### Basic (self-hosted Jira) Password-based API authentication works for self-hosted Jira **only**, and has been [deprecated for users of Atlassian Cloud](https://developer.atlassian.com/cloud/jira/platform/deprecation-notice-basic-auth-and-cookie-based-auth/). -The above token authentication example may be used, substituting a user's password for a generated token. +Depending on your version of Jira, either of the above token authentication examples may be used, substituting a user's password for a generated token. #### Authenticate with OAuth @@ -192,6 +203,7 @@ import ( ) func main() { + testIssueID := "FART-1" base := "https://my.jira.com" tp := jira.BasicAuthTransport{ Username: "username", @@ -203,12 +215,12 @@ func main() { panic(err) } - issue, _, _ := jiraClient.Issue.Get("FART-1", nil) + issue, _, _ := jiraClient.Issue.Get(testIssueID, nil) currentStatus := issue.Fields.Status.Name fmt.Printf("Current status: %s\n", currentStatus) var transitionID string - possibleTransitions, _, _ := jiraClient.Issue.GetTransitions("FART-1") + possibleTransitions, _, _ := jiraClient.Issue.GetTransitions(testIssueID) for _, v := range possibleTransitions { if v.Name == "In Progress" { transitionID = v.ID @@ -216,19 +228,18 @@ func main() { } } - jiraClient.Issue.DoTransition("FART-1", transitionID) + jiraClient.Issue.DoTransition(testIssueID, transitionID) issue, _, _ = jiraClient.Issue.Get(testIssueID, nil) fmt.Printf("Status after transition: %+v\n", issue.Fields.Status.Name) } ``` -### Get all the issues for JQL with Pagination -Jira API has limit on maxResults it can return. You may have a usecase where you need to get all issues for given JQL. -This example shows reference implementation of GetAllIssues function which does pagination on Jira API to get all the issues for given JQL - -please look at [Pagination Example](https://github.com/andygrunwald/go-jira/blob/master/examples/pagination/main.go) +### Get all the issues for JQL with Pagination +Jira API has limit on maxResults it can return. You may have a usecase where you need to get all issues for given JQL. +This example shows reference implementation of GetAllIssues function which does pagination on Jira API to get all the issues for given JQL. +Please look at [Pagination Example](https://github.com/andygrunwald/go-jira/blob/main/cloud/examples/pagination/main.go) ### Call a not implemented API endpoint @@ -277,7 +288,9 @@ func main() { * [andygrunwald/jitic](https://github.com/andygrunwald/jitic) - The Jira Ticket Checker -## Code structure +## Development + +### Code structure The code structure of this package was inspired by [google/go-github](https://github.com/google/go-github). @@ -285,6 +298,20 @@ There is one main part (the client). Based on this main client the other endpoints, like Issues or Authentication are extracted in services. E.g. `IssueService` or `AuthenticationService`. These services own a responsibility of the single endpoints / usecases of Jira. +### Unit testing + +To run the local unit tests, execute + +```sh +$ make test +``` + +To run the local unit tests and view the unit test code coverage in your local web browser, execute + +```sh +$ make test-coverage-html +``` + ## Contribution We ❤️ PR's @@ -299,23 +326,46 @@ A few examples: * Implement a new feature or endpoint * Sharing the love of [go-jira](https://github.com/andygrunwald/go-jira) and help people to get use to it -If you are new to pull requests, checkout [Collaborating on projects using issues and pull requests / Creating a pull request](https://help.github.com/articles/creating-a-pull-request/). +If you are new to pull requests, checkout [Collaborating on projects using issues and pull requests / Creating a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). + +### Supported Go versions + +We follow the [Go Release Policy](https://go.dev/doc/devel/release#policy): + +> Each major Go release is supported until there are two newer major releases. For example, Go 1.5 was supported until the Go 1.7 release, and Go 1.6 was supported until the Go 1.8 release. We fix critical problems, including [critical security problems](https://go.dev/security/), in supported releases as needed by issuing minor revisions (for example, Go 1.6.1, Go 1.6.2, and so on). + +### Supported Jira versions + +#### Jira Server (On-Premise solution) -### Dependency management +We follow the [Atlassian Support End of Life Policy](https://confluence.atlassian.com/support/atlassian-support-end-of-life-policy-201851003.html): -`go-jira` uses `go modules` for dependency management. After cloning the repo, it's easy to make sure you have the correct dependencies by running `go mod tidy`. +> Atlassian supports feature versions for two years after the first major iteration of that version was released (for example, we support Jira Core 7.2.x for 2 years after Jira 7.2.0 was released). -For adding new dependencies, updating dependencies, and other operations, the [Daily workflow](https://github.com/golang/go/wiki/Modules#daily-workflow) is a good place to start. +#### Jira Cloud + +We support Jira Cloud API in [version 3](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/). +Even if this API version is _currently_ in beta (by Atlassian): + +[Version 2](https://developer.atlassian.com/cloud/jira/platform/rest/v2/) and version 3 of the API offer the same collection of operations. +However, version 3 provides support for the [Atlassian Document Format (ADF)](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/) in a subset of the API. + +### Official Jira API documentation + +* [Jira Server (On-Premise solution)](https://developer.atlassian.com/server/jira/platform/rest-apis/) +* Jira Cloud API in [version 2](https://developer.atlassian.com/cloud/jira/platform/rest/v2/intro/) +* Jira Cloud API in [version 3](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/) ### Sandbox environment for testing Jira offers sandbox test environments at http://go.atlassian.com/cloud-dev. -You can read more about them at https://developer.atlassian.com/blog/2016/04/cloud-ecosystem-dev-env/. +You can read more about them at https://blog.developer.atlassian.com/cloud-ecosystem-dev-env/. ## Releasing Install [standard-version](https://github.com/conventional-changelog/standard-version) + ```bash npm i -g standard-version ``` diff --git a/cloud/README.md b/cloud/README.md new file mode 100644 index 00000000..0f395ea9 --- /dev/null +++ b/cloud/README.md @@ -0,0 +1,5 @@ +# Jira: Cloud client + +The API client library for cloud hosted Jira instances by Atlassian. + +For further information, please switch to the [README.md in the root folder](../README.md). \ No newline at end of file diff --git a/cloud/auth_transport.go b/cloud/auth_transport.go new file mode 100644 index 00000000..a2d76ca7 --- /dev/null +++ b/cloud/auth_transport.go @@ -0,0 +1,17 @@ +package cloud + +import "net/http" + +// cloneRequest returns a clone of the provided *http.Request. +// The clone is a shallow copy of the struct and its Header map. +func cloneRequest(r *http.Request) *http.Request { + // shallow copy of the struct + r2 := new(http.Request) + *r2 = *r + // deep copy of the Header + r2.Header = make(http.Header, len(r.Header)) + for k, s := range r.Header { + r2.Header[k] = append([]string(nil), s...) + } + return r2 +} diff --git a/cloud/auth_transport_basic_auth.go b/cloud/auth_transport_basic_auth.go new file mode 100644 index 00000000..fb8a1cbc --- /dev/null +++ b/cloud/auth_transport_basic_auth.go @@ -0,0 +1,42 @@ +package cloud + +import "net/http" + +// BasicAuthTransport is an http.RoundTripper that authenticates all requests +// using HTTP Basic Authentication with the provided username and a Personal API Token. +// +// Jira docs: https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/ +// Create a token: https://id.atlassian.com/manage-profile/security/api-tokens +type BasicAuthTransport struct { + Username string + APIToken string + + // Transport is the underlying HTTP transport to use when making requests. + // It will default to http.DefaultTransport if nil. + Transport http.RoundTripper +} + +// RoundTrip implements the RoundTripper interface. We just add the +// basic auth information and return the RoundTripper for this transport type. +func (t *BasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := cloneRequest(req) // per RoundTripper contract + + req2.SetBasicAuth(t.Username, t.APIToken) + return t.transport().RoundTrip(req2) +} + +// Client returns an *http.Client that makes requests that are authenticated +// using HTTP Basic Authentication. This is a nice little bit of sugar +// so we can just get the client instead of creating the client in the calling code. +// If it's necessary to send more information on client init, the calling code can +// always skip this and set the transport itself. +func (t *BasicAuthTransport) Client() *http.Client { + return &http.Client{Transport: t} +} + +func (t *BasicAuthTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return http.DefaultTransport +} diff --git a/cloud/auth_transport_basic_auth_test.go b/cloud/auth_transport_basic_auth_test.go new file mode 100644 index 00000000..0ef1c854 --- /dev/null +++ b/cloud/auth_transport_basic_auth_test.go @@ -0,0 +1,52 @@ +package cloud + +import ( + "context" + "net/http" + "testing" +) + +func TestBasicAuthTransport(t *testing.T) { + setup() + defer teardown() + + username, apiToken := "username", "api_token" + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + if !ok { + t.Errorf("request does not contain basic auth credentials") + } + if u != username { + t.Errorf("request contained basic auth username %q, want %q", u, username) + } + if p != apiToken { + t.Errorf("request contained basic auth password %q, want %q", p, apiToken) + } + }) + + tp := &BasicAuthTransport{ + Username: username, + APIToken: apiToken, + } + + basicAuthClient, _ := NewClient(testServer.URL, tp.Client()) + req, _ := basicAuthClient.NewRequest(context.Background(), http.MethodGet, ".", nil) + basicAuthClient.Do(req, nil) +} + +func TestBasicAuthTransport_transport(t *testing.T) { + // default transport + tp := &BasicAuthTransport{} + if tp.transport() != http.DefaultTransport { + t.Errorf("Expected http.DefaultTransport to be used.") + } + + // custom transport + tp = &BasicAuthTransport{ + Transport: &http.Transport{}, + } + if tp.transport() == http.DefaultTransport { + t.Errorf("Expected custom transport to be used.") + } +} diff --git a/cloud/auth_transport_jwt.go b/cloud/auth_transport_jwt.go new file mode 100644 index 00000000..dc0ac492 --- /dev/null +++ b/cloud/auth_transport_jwt.go @@ -0,0 +1,87 @@ +package cloud + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" + + jwt "github.com/golang-jwt/jwt/v4" +) + +// JWTAuthTransport is an http.RoundTripper that authenticates all requests +// using Jira's JWT based authentication. +// +// NOTE: this form of auth should be used by add-ons installed from the Atlassian marketplace. +// +// Jira docs: https://developer.atlassian.com/cloud/jira/platform/understanding-jwt +// Examples in other languages: +// +// https://bitbucket.org/atlassian/atlassian-jwt-ruby/src/d44a8e7a4649e4f23edaa784402655fda7c816ea/lib/atlassian/jwt.rb +// https://bitbucket.org/atlassian/atlassian-jwt-py/src/master/atlassian_jwt/url_utils.py +type JWTAuthTransport struct { + Secret []byte + Issuer string + + // Transport is the underlying HTTP transport to use when making requests. + // It will default to http.DefaultTransport if nil. + Transport http.RoundTripper +} + +func (t *JWTAuthTransport) Client() *http.Client { + return &http.Client{Transport: t} +} + +func (t *JWTAuthTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return http.DefaultTransport +} + +// RoundTrip adds the session object to the request. +func (t *JWTAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := cloneRequest(req) // per RoundTripper contract + exp := time.Duration(59) * time.Second + qsh := t.createQueryStringHash(req.Method, req2.URL) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": t.Issuer, + "iat": time.Now().Unix(), + "exp": time.Now().Add(exp).Unix(), + "qsh": qsh, + }) + + jwtStr, err := token.SignedString(t.Secret) + if err != nil { + return nil, fmt.Errorf("jwtAuth: error signing JWT: %w", err) + } + + req2.Header.Set("Authorization", fmt.Sprintf("JWT %s", jwtStr)) + return t.transport().RoundTrip(req2) +} + +func (t *JWTAuthTransport) createQueryStringHash(httpMethod string, jiraURL *url.URL) string { + canonicalRequest := t.canonicalizeRequest(httpMethod, jiraURL) + h := sha256.Sum256([]byte(canonicalRequest)) + return hex.EncodeToString(h[:]) +} + +func (t *JWTAuthTransport) canonicalizeRequest(httpMethod string, jiraURL *url.URL) string { + path := "/" + strings.Replace(strings.Trim(jiraURL.Path, "/"), "&", "%26", -1) + + var canonicalQueryString []string + for k, v := range jiraURL.Query() { + if k == "jwt" { + continue + } + param := url.QueryEscape(k) + value := url.QueryEscape(strings.Join(v, "")) + canonicalQueryString = append(canonicalQueryString, strings.Replace(strings.Join([]string{param, value}, "="), "+", "%20", -1)) + } + sort.Strings(canonicalQueryString) + return fmt.Sprintf("%s&%s&%s", strings.ToUpper(httpMethod), path, strings.Join(canonicalQueryString, "&")) +} diff --git a/cloud/auth_transport_jwt_test.go b/cloud/auth_transport_jwt_test.go new file mode 100644 index 00000000..6a680709 --- /dev/null +++ b/cloud/auth_transport_jwt_test.go @@ -0,0 +1,32 @@ +package cloud + +import ( + "context" + "net/http" + "strings" + "testing" +) + +func TestJWTAuthTransport_HeaderContainsJWT(t *testing.T) { + setup() + defer teardown() + + sharedSecret := []byte("ssshh,it's a secret") + issuer := "add-on.key" + + jwtTransport := &JWTAuthTransport{ + Secret: sharedSecret, + Issuer: issuer, + } + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // look for the presence of the JWT in the header + val := r.Header.Get("Authorization") + if !strings.Contains(val, "JWT ") { + t.Errorf("request does not contain JWT in the Auth header") + } + }) + + jwtClient, _ := NewClient(testServer.URL, jwtTransport.Client()) + jwtClient.Issue.Get(context.Background(), "TEST-1", nil) +} diff --git a/cloud/board.go b/cloud/board.go new file mode 100644 index 00000000..1505831d --- /dev/null +++ b/cloud/board.go @@ -0,0 +1,286 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" + "time" +) + +// BoardService handles Agile Boards for the Jira instance / API. +// +// Jira API docs: https://docs.atlassian.com/jira-software/REST/server/ +type BoardService service + +// BoardsList reflects a list of agile boards +type BoardsList struct { + MaxResults int `json:"maxResults" structs:"maxResults"` + StartAt int `json:"startAt" structs:"startAt"` + Total int `json:"total" structs:"total"` + IsLast bool `json:"isLast" structs:"isLast"` + Values []Board `json:"values" structs:"values"` +} + +// Board represents a Jira agile board +type Board struct { + ID int `json:"id,omitempty" structs:"id,omitempty"` + Self string `json:"self,omitempty" structs:"self,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitemtpy"` + Type string `json:"type,omitempty" structs:"type,omitempty"` + Location BoardLocation `json:"location,omitempty" structs:"location,omitempty"` + FilterID int `json:"filterId,omitempty" structs:"filterId,omitempty"` +} + +// BoardLocation represents the location of a Jira board +type BoardLocation struct { + ProjectID int `json:"projectId"` + UserID int `json:"userId"` + UserAccountID string `json:"userAccountId"` + DisplayName string `json:"displayName"` + ProjectName string `json:"projectName"` + ProjectKey string `json:"projectKey"` + ProjectTypeKey string `json:"projectTypeKey"` + Name string `json:"name"` +} + +// BoardListOptions specifies the optional parameters to the BoardService.GetList +type BoardListOptions struct { + // BoardType filters results to boards of the specified type. + // Valid values: scrum, kanban. + BoardType string `url:"type,omitempty"` + // Name filters results to boards that match or partially match the specified name. + Name string `url:"name,omitempty"` + // ProjectKeyOrID filters results to boards that are relevant to a project. + // Relevance meaning that the JQL filter defined in board contains a reference to a project. + ProjectKeyOrID string `url:"projectKeyOrId,omitempty"` + + SearchOptions +} + +// GetAllSprintsOptions specifies the optional parameters to the BoardService.GetList +type GetAllSprintsOptions struct { + // State filters results to sprints in the specified states, comma-separate list + State string `url:"state,omitempty"` + + SearchOptions +} + +// SprintsList reflects a list of agile sprints +type SprintsList struct { + MaxResults int `json:"maxResults" structs:"maxResults"` + StartAt int `json:"startAt" structs:"startAt"` + Total int `json:"total" structs:"total"` + IsLast bool `json:"isLast" structs:"isLast"` + Values []Sprint `json:"values" structs:"values"` +} + +// Sprint represents a sprint on Jira agile board +type Sprint struct { + ID int `json:"id" structs:"id"` + Name string `json:"name" structs:"name"` + CompleteDate *time.Time `json:"completeDate" structs:"completeDate"` + EndDate *time.Time `json:"endDate" structs:"endDate"` + StartDate *time.Time `json:"startDate" structs:"startDate"` + OriginBoardID int `json:"originBoardId" structs:"originBoardId"` + Self string `json:"self" structs:"self"` + State string `json:"state" structs:"state"` + Goal string `json:"goal,omitempty" structs:"goal"` +} + +// BoardConfiguration represents a boardConfiguration of a jira board +type BoardConfiguration struct { + ID int `json:"id"` + Name string `json:"name"` + Self string `json:"self"` + Location BoardConfigurationLocation `json:"location"` + Filter BoardConfigurationFilter `json:"filter"` + SubQuery BoardConfigurationSubQuery `json:"subQuery"` + ColumnConfig BoardConfigurationColumnConfig `json:"columnConfig"` +} + +// BoardConfigurationFilter reference to the filter used by the given board. +type BoardConfigurationFilter struct { + ID string `json:"id"` + Self string `json:"self"` +} + +// BoardConfigurationSubQuery (Kanban only) - JQL subquery used by the given board. +type BoardConfigurationSubQuery struct { + Query string `json:"query"` +} + +// BoardConfigurationLocation reference to the container that the board is located in +type BoardConfigurationLocation struct { + Type string `json:"type"` + Key string `json:"key"` + ID string `json:"id"` + Self string `json:"self"` + Name string `json:"name"` +} + +// BoardConfigurationColumnConfig lists the columns for a given board in the order defined in the column configuration +// with constrainttype (none, issueCount, issueCountExclSubs) +type BoardConfigurationColumnConfig struct { + Columns []BoardConfigurationColumn `json:"columns"` + ConstraintType string `json:"constraintType"` +} + +// BoardConfigurationColumn lists the name of the board with the statuses that maps to a particular column +type BoardConfigurationColumn struct { + Name string `json:"name"` + Status []BoardConfigurationColumnStatus `json:"statuses"` + Min int `json:"min,omitempty"` + Max int `json:"max,omitempty"` +} + +// BoardConfigurationColumnStatus represents a status in the column configuration +type BoardConfigurationColumnStatus struct { + ID string `json:"id"` + Self string `json:"self"` +} + +// GetAllBoards will returns all boards. This only includes boards that the user has permission to view. +// +// Jira API docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/board-getAllBoards +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *BoardService) GetAllBoards(ctx context.Context, opt *BoardListOptions) (*BoardsList, *Response, error) { + apiEndpoint := "rest/agile/1.0/board" + url, err := addOptions(apiEndpoint, opt) + if err != nil { + return nil, nil, err + } + req, err := s.client.NewRequest(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, nil, err + } + + boards := new(BoardsList) + resp, err := s.client.Do(req, boards) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return boards, resp, err +} + +// GetBoard returns the board for the given board ID. +// This board will only be returned if the user has permission to view it. +// Admins without the view permission will see the board as a private one, so will see only a subset of the board's data (board location for instance). +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-get +func (s *BoardService) GetBoard(ctx context.Context, boardID int64) (*Board, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%v", boardID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + board := new(Board) + resp, err := s.client.Do(req, board) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return board, resp, nil +} + +// CreateBoard creates a new board. Board name, type and filter Id is required. +// name - Must be less than 255 characters. +// type - Valid values: scrum, kanban +// filterId - Id of a filter that the user has permissions to view. +// Note, if the user does not have the 'Create shared objects' permission and tries to create a shared board, a private +// board will be created instead (remember that board sharing depends on the filter sharing). +// +// Jira API docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/board-createBoard +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *BoardService) CreateBoard(ctx context.Context, board *Board) (*Board, *Response, error) { + apiEndpoint := "rest/agile/1.0/board" + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, board) + if err != nil { + return nil, nil, err + } + + responseBoard := new(Board) + resp, err := s.client.Do(req, responseBoard) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return responseBoard, resp, nil +} + +// DeleteBoard will delete an agile board. +// +// Jira API docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/board-deleteBoard +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *BoardService) DeleteBoard(ctx context.Context, boardID int) (*Board, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%v", boardID) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + err = NewJiraError(resp, err) + } + return nil, resp, err +} + +// GetAllSprints returns all sprints from a board, for a given board ID. +// This only includes sprints that the user has permission to view. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-sprint-get +func (s *BoardService) GetAllSprints(ctx context.Context, boardID int64, options *GetAllSprintsOptions) (*SprintsList, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%d/sprint", boardID) + url, err := addOptions(apiEndpoint, options) + if err != nil { + return nil, nil, err + } + req, err := s.client.NewRequest(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, nil, err + } + + result := new(SprintsList) + resp, err := s.client.Do(req, result) + if err != nil { + err = NewJiraError(resp, err) + } + + return result, resp, err +} + +// GetBoardConfiguration will return a board configuration for a given board Id +// Jira API docs:https://developer.atlassian.com/cloud/jira/software/rest/#api-rest-agile-1-0-board-boardId-configuration-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *BoardService) GetBoardConfiguration(ctx context.Context, boardID int) (*BoardConfiguration, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%d/configuration", boardID) + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + + if err != nil { + return nil, nil, err + } + + result := new(BoardConfiguration) + resp, err := s.client.Do(req, result) + if err != nil { + err = NewJiraError(resp, err) + } + + return result, resp, err + +} diff --git a/board_test.go b/cloud/board_test.go similarity index 68% rename from board_test.go rename to cloud/board_test.go index 11a5dc2d..c37978a8 100644 --- a/board_test.go +++ b/cloud/board_test.go @@ -1,28 +1,29 @@ -package jira +package cloud import ( + "context" "fmt" - "io/ioutil" "net/http" + "os" "testing" ) func TestBoardService_GetAllBoards(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/agile/1.0/board" + testapiEndpoint := "/rest/agile/1.0/board" - raw, err := ioutil.ReadFile("./mocks/all_boards.json") + raw, err := os.ReadFile("../testing/mock-data/all_boards.json") if err != nil { t.Error(err.Error()) } - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) fmt.Fprint(w, string(raw)) }) - projects, _, err := testClient.Board.GetAllBoards(nil) + projects, _, err := testClient.Board.GetAllBoards(context.Background(), nil) if projects == nil { t.Error("Expected boards list. Boards list is nil") } @@ -35,15 +36,15 @@ func TestBoardService_GetAllBoards(t *testing.T) { func TestBoardService_GetAllBoards_WithFilter(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/agile/1.0/board" + testapiEndpoint := "/rest/agile/1.0/board" - raw, err := ioutil.ReadFile("./mocks/all_boards_filtered.json") + raw, err := os.ReadFile("../testing/mock-data/all_boards_filtered.json") if err != nil { t.Error(err.Error()) } - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) testRequestParams(t, r, map[string]string{"type": "scrum", "name": "Test", "startAt": "1", "maxResults": "10", "projectKeyOrId": "TE"}) fmt.Fprint(w, string(raw)) }) @@ -56,7 +57,7 @@ func TestBoardService_GetAllBoards_WithFilter(t *testing.T) { boardsListOptions.StartAt = 1 boardsListOptions.MaxResults = 10 - projects, _, err := testClient.Board.GetAllBoards(boardsListOptions) + projects, _, err := testClient.Board.GetAllBoards(context.Background(), boardsListOptions) if projects == nil { t.Error("Expected boards list. Boards list is nil") } @@ -68,15 +69,15 @@ func TestBoardService_GetAllBoards_WithFilter(t *testing.T) { func TestBoardService_GetBoard(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/agile/1.0/board/1" + testapiEndpoint := "/rest/agile/1.0/board/1" - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) fmt.Fprint(w, `{"id":4,"self":"https://test.jira.org/rest/agile/1.0/board/1","name":"Test Weekly","type":"scrum"}`) }) - board, _, err := testClient.Board.GetBoard(1) + board, _, err := testClient.Board.GetBoard(context.Background(), 1) if board == nil { t.Error("Expected board list. Board list is nil") } @@ -91,12 +92,12 @@ func TestBoardService_GetBoard_WrongID(t *testing.T) { testAPIEndpoint := "/rest/api/2/board/99999999" testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, nil) }) - board, resp, err := testClient.Board.GetBoard(99999999) + board, resp, err := testClient.Board.GetBoard(context.Background(), 99999999) if board != nil { t.Errorf("Expected nil. Got %s", err) } @@ -113,7 +114,7 @@ func TestBoardService_CreateBoard(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/agile/1.0/board", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/agile/1.0/board") w.WriteHeader(http.StatusCreated) @@ -125,7 +126,7 @@ func TestBoardService_CreateBoard(t *testing.T) { Type: "kanban", FilterID: 17, } - issue, _, err := testClient.Board.CreateBoard(b) + issue, _, err := testClient.Board.CreateBoard(context.Background(), b) if issue == nil { t.Error("Expected board. Board is nil") } @@ -138,14 +139,14 @@ func TestBoardService_DeleteBoard(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/agile/1.0/board/1", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/agile/1.0/board/1") w.WriteHeader(http.StatusNoContent) fmt.Fprint(w, `{}`) }) - _, resp, err := testClient.Board.DeleteBoard(1) + _, resp, err := testClient.Board.DeleteBoard(context.Background(), 1) if resp.StatusCode != 204 { t.Error("Expected board not deleted.") } @@ -160,50 +161,18 @@ func TestBoardService_GetAllSprints(t *testing.T) { testAPIEndpoint := "/rest/agile/1.0/board/123/sprint" - raw, err := ioutil.ReadFile("./mocks/sprints.json") + raw, err := os.ReadFile("../testing/mock-data/sprints_filtered.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, string(raw)) }) - sprints, _, err := testClient.Board.GetAllSprints("123") - - if err != nil { - t.Errorf("Got error: %v", err) - } - - if sprints == nil { - t.Error("Expected sprint list. Got nil.") - } - - if len(sprints) != 4 { - t.Errorf("Expected 4 transitions. Got %d", len(sprints)) - } -} - -func TestBoardService_GetAllSprintsWithOptions(t *testing.T) { - setup() - defer teardown() - - testAPIEndpoint := "/rest/agile/1.0/board/123/sprint" - - raw, err := ioutil.ReadFile("./mocks/sprints_filtered.json") - if err != nil { - t.Error(err.Error()) - } - - testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEndpoint) - fmt.Fprint(w, string(raw)) - }) - - sprints, _, err := testClient.Board.GetAllSprintsWithOptions(123, &GetAllSprintsOptions{State: "active,future"}) + sprints, _, err := testClient.Board.GetAllSprints(context.Background(), 123, &GetAllSprintsOptions{State: "active,future"}) if err != nil { t.Errorf("Got error: %v", err) } @@ -223,18 +192,18 @@ func TestBoardService_GetBoardConfigoration(t *testing.T) { defer teardown() testAPIEndpoint := "/rest/agile/1.0/board/35/configuration" - raw, err := ioutil.ReadFile("./mocks/board_configuration.json") + raw, err := os.ReadFile("../testing/mock-data/board_configuration.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, string(raw)) }) - boardConfiguration, _, err := testClient.Board.GetBoardConfiguration(35) + boardConfiguration, _, err := testClient.Board.GetBoardConfiguration(context.Background(), 35) if err != nil { t.Errorf("Got error: %v", err) } diff --git a/cloud/component.go b/cloud/component.go new file mode 100644 index 00000000..61b25b9b --- /dev/null +++ b/cloud/component.go @@ -0,0 +1,104 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" +) + +// ComponentService represents project components. +// Use it to get, create, update, and delete project components. +// Also get components for project and get a count of issues by component. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-project-components/#api-group-project-components +type ComponentService service + +const ( + AssigneeTypeProjectLead = "PROJECT_LEAD" + AssigneeTypeComponentLead = "COMPONENT_LEAD" + AssigneeTypeUnassigned = "UNASSIGNED" + AssigneeTypeProjectDefault = "PROJECT_DEFAULT" +) + +// ComponentCreateOptions are passed to the ComponentService.Create function to create a new Jira component +type ComponentCreateOptions struct { + // Name: The unique name for the component in the project. + // Required when creating a component. + // Optional when updating a component. + // The maximum length is 255 characters. + Name string `json:"name,omitempty" structs:"name,omitempty"` + + // Description: The description for the component. + // Optional when creating or updating a component. + Description string `json:"description,omitempty" structs:"description,omitempty"` + + // LeadAccountId: The accountId of the component's lead user. + // The accountId uniquely identifies the user across all Atlassian products. + // For example, 5b10ac8d82e05b22cc7d4ef5. + LeadAccountId string `json:"leadAccountId,omitempty" structs:"leadAccountId,omitempty"` + + // AssigneeType: The nominal user type used to determine the assignee for issues created with this component. + // Can take the following values: + // PROJECT_LEAD the assignee to any issues created with this component is nominally the lead for the project the component is in. + // COMPONENT_LEAD the assignee to any issues created with this component is nominally the lead for the component. + // UNASSIGNED an assignee is not set for issues created with this component. + // PROJECT_DEFAULT the assignee to any issues created with this component is nominally the default assignee for the project that the component is in. + // + // Default value: PROJECT_DEFAULT. + // Optional when creating or updating a component. + AssigneeType string `json:"assigneeType,omitempty" structs:"assigneeType,omitempty"` + + // Project: The key of the project the component is assigned to. + // Required when creating a component. + // Can't be updated. + Project string `json:"project,omitempty" structs:"project,omitempty"` +} + +// Create creates a component. +// Use components to provide containers for issues within a project. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-project-components/#api-rest-api-3-component-post +func (s *ComponentService) Create(ctx context.Context, options *ComponentCreateOptions) (*ProjectComponent, *Response, error) { + apiEndpoint := "rest/api/3/component" + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, options) + if err != nil { + return nil, nil, err + } + + component := new(ProjectComponent) + resp, err := s.client.Do(req, component) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return component, resp, nil +} + +// Get returns a component for the given componentID. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-project-components/#api-rest-api-3-component-id-get +func (s *ComponentService) Get(ctx context.Context, componentID string) (*ProjectComponent, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/3/component/%s", componentID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + component := new(ProjectComponent) + resp, err := s.client.Do(req, component) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return component, resp, nil +} + +// TODO Add "Update component" method. See https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-project-components/#api-rest-api-3-component-id-put + +// TODO Add "Delete component" method. See https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-project-components/#api-rest-api-3-component-id-delete + +// TODO Add "Get component issues count" method. See https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-project-components/#api-rest-api-3-component-id-relatedissuecounts-get + +// TODO Add "Get project components paginated" method. See https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-project-components/#api-rest-api-3-project-projectidorkey-component-get + +// TODO Add "Get project components" method. See https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-project-components/#api-rest-api-3-project-projectidorkey-components-get diff --git a/cloud/component_test.go b/cloud/component_test.go new file mode 100644 index 00000000..3c8305a1 --- /dev/null +++ b/cloud/component_test.go @@ -0,0 +1,81 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestComponentService_Create_Success(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/3/component" + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, testAPIEndpoint) + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{ "self": "http://www.example.com/jira/rest/api/2/component/10000", "id": "10000", "name": "Component 1", "description": "This is a Jira component", "lead": { "self": "http://www.example.com/jira/rest/api/2/user?username=fred", "name": "fred", "avatarUrls": { "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" }, "displayName": "Fred F. User", "active": false }, "assigneeType": "PROJECT_LEAD", "assignee": { "self": "http://www.example.com/jira/rest/api/2/user?username=fred", "name": "fred", "avatarUrls": { "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" }, "displayName": "Fred F. User", "active": false }, "realAssigneeType": "PROJECT_LEAD", "realAssignee": { "self": "http://www.example.com/jira/rest/api/2/user?username=fred", "name": "fred", "avatarUrls": { "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" }, "displayName": "Fred F. User", "active": false }, "isAssigneeTypeValid": false, "project": "HSP", "projectId": 10000 }`) + }) + + component, _, err := testClient.Component.Create(context.Background(), &ComponentCreateOptions{ + Name: "foo-bar", + }) + if component == nil { + t.Error("Expected component. Component is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestComponentService_Get(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/3/component/42102" + + raw, err := os.ReadFile("../testing/mock-data/component_get.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + component, _, err := testClient.Component.Get(context.Background(), "42102") + if err != nil { + t.Errorf("Error given: %s", err) + } + if component == nil { + t.Error("Expected component. Component is nil") + } +} + +func TestComponentService_Get_NoComponent(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/3/component/99999999" + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, nil) + }) + + component, resp, err := testClient.Component.Get(context.Background(), "99999999") + + if component != nil { + t.Errorf("Expected nil. Got %+v", component) + } + if resp.Status == "404" { + t.Errorf("Expected status 404. Got %s", resp.Status) + } + if err == nil { + t.Error("No error given. Expected one") + } +} diff --git a/customer.go b/cloud/customer.go similarity index 78% rename from customer.go rename to cloud/customer.go index 3df31c1c..07fecbc4 100644 --- a/customer.go +++ b/cloud/customer.go @@ -1,4 +1,4 @@ -package jira +package cloud import ( "context" @@ -6,9 +6,7 @@ import ( ) // CustomerService handles ServiceDesk customers for the Jira instance / API. -type CustomerService struct { - client *Client -} +type CustomerService service // Customer represents a ServiceDesk customer. type Customer struct { @@ -38,10 +36,13 @@ type CustomerList struct { Expands []string `json:"_expands,omitempty" structs:"_expands,omitempty"` } -// CreateWithContext creates a ServiceDesk customer. +// Create creates a ServiceDesk customer. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-customer/#api-rest-servicedeskapi-customer-post -func (c *CustomerService) CreateWithContext(ctx context.Context, email, displayName string) (*Customer, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (c *CustomerService) Create(ctx context.Context, email, displayName string) (*Customer, *Response, error) { const apiEndpoint = "rest/servicedeskapi/customer" payload := struct { @@ -52,7 +53,7 @@ func (c *CustomerService) CreateWithContext(ctx context.Context, email, displayN DisplayName: displayName, } - req, err := c.client.NewRequestWithContext(ctx, http.MethodPost, apiEndpoint, payload) + req, err := c.client.NewRequest(ctx, http.MethodPost, apiEndpoint, payload) if err != nil { return nil, nil, err } @@ -65,8 +66,3 @@ func (c *CustomerService) CreateWithContext(ctx context.Context, email, displayN return responseCustomer, resp, nil } - -// Create wraps CreateWithContext using the background context. -func (c *CustomerService) Create(email, displayName string) (*Customer, *Response, error) { - return c.CreateWithContext(context.Background(), email, displayName) -} diff --git a/customer_test.go b/cloud/customer_test.go similarity index 90% rename from customer_test.go rename to cloud/customer_test.go index d8c3714d..ecbfee95 100644 --- a/customer_test.go +++ b/cloud/customer_test.go @@ -1,6 +1,7 @@ -package jira +package cloud import ( + "context" "fmt" "net/http" "testing" @@ -16,7 +17,7 @@ func TestCustomerService_Create(t *testing.T) { ) testMux.HandleFunc("/rest/servicedeskapi/customer", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/servicedeskapi/customer") w.WriteHeader(http.StatusOK) @@ -41,7 +42,7 @@ func TestCustomerService_Create(t *testing.T) { }`, wantEmailAddress, wantDisplayName) }) - gotCustomer, _, err := testClient.Customer.Create(wantEmailAddress, wantDisplayName) + gotCustomer, _, err := testClient.Customer.Create(context.Background(), wantEmailAddress, wantDisplayName) if err != nil { t.Fatal(err) } diff --git a/error.go b/cloud/error.go similarity index 81% rename from error.go rename to cloud/error.go index c7bc2e58..7a8ca2c7 100644 --- a/error.go +++ b/cloud/error.go @@ -1,13 +1,11 @@ -package jira +package cloud import ( "bytes" "encoding/json" "fmt" - "io/ioutil" + "io" "strings" - - "github.com/pkg/errors" ) // Error message from Jira @@ -21,27 +19,26 @@ type Error struct { // NewJiraError creates a new jira Error func NewJiraError(resp *Response, httpError error) error { if resp == nil { - return errors.Wrap(httpError, "No response returned") + return fmt.Errorf("no response returned: %w", httpError) } defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { - return errors.Wrap(err, httpError.Error()) + return fmt.Errorf("%s: %w", httpError.Error(), err) } jerr := Error{HTTPError: httpError} contentType := resp.Header.Get("Content-Type") if strings.HasPrefix(contentType, "application/json") { err = json.Unmarshal(body, &jerr) if err != nil { - httpError = errors.Wrap(errors.New("could not parse JSON"), httpError.Error()) - return errors.Wrap(err, httpError.Error()) + return fmt.Errorf("%s: could not parse JSON: %w", httpError.Error(), err) } } else { if httpError == nil { return fmt.Errorf("got response status %s:%s", resp.Status, string(body)) } - return errors.Wrap(httpError, fmt.Sprintf("%s: %s", resp.Status, string(body))) + return fmt.Errorf("%s: %s: %w", resp.Status, string(body), httpError) } return &jerr diff --git a/error_test.go b/cloud/error_test.go similarity index 91% rename from error_test.go rename to cloud/error_test.go index fab8b0fe..dcc51ee5 100644 --- a/error_test.go +++ b/cloud/error_test.go @@ -1,6 +1,7 @@ -package jira +package cloud import ( + "context" "errors" "fmt" "net/http" @@ -17,7 +18,7 @@ func TestError_NewJiraError(t *testing.T) { fmt.Fprint(w, `{"errorMessages":["Issue does not exist or you do not have permission to see it."],"errors":{}}`) }) - req, _ := testClient.NewRequest("GET", "/", nil) + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) resp, _ := testClient.Do(req, nil) err := NewJiraError(resp, errors.New("Original http error")) @@ -38,8 +39,8 @@ func TestError_NoResponse(t *testing.T) { t.Errorf("Expected the original error message: Got\n%s\n", msg) } - if !strings.Contains(msg, "No response") { - t.Errorf("Expected the 'No response' error message: Got\n%s\n", msg) + if !strings.Contains(msg, "no response returned") { + t.Errorf("Expected the 'no response returned' error message: Got\n%s\n", msg) } } @@ -51,7 +52,7 @@ func TestError_NoJSON(t *testing.T) { fmt.Fprint(w, `Original message body`) }) - req, _ := testClient.NewRequest("GET", "/", nil) + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) resp, _ := testClient.Do(req, nil) err := NewJiraError(resp, errors.New("Original http error")) @@ -71,7 +72,7 @@ func TestError_Unauthorized_NilError(t *testing.T) { fmt.Fprint(w, `User is not authorized`) }) - req, _ := testClient.NewRequest("GET", "/", nil) + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) resp, _ := testClient.Do(req, nil) err := NewJiraError(resp, nil) @@ -90,7 +91,7 @@ func TestError_BadJSON(t *testing.T) { fmt.Fprint(w, `Not JSON`) }) - req, _ := testClient.NewRequest("GET", "/", nil) + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) resp, _ := testClient.Do(req, nil) err := NewJiraError(resp, errors.New("Original http error")) diff --git a/cloud/examples/addlabel/main.go b/cloud/examples/addlabel/main.go new file mode 100644 index 00000000..dc6a3979 --- /dev/null +++ b/cloud/examples/addlabel/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" + "syscall" + + jira "github.com/andygrunwald/go-jira/v2/cloud" + "golang.org/x/term" +) + +func main() { + r := bufio.NewReader(os.Stdin) + + fmt.Print("Jira URL: ") + jiraURL, _ := r.ReadString('\n') + + fmt.Print("Jira Username: ") + username, _ := r.ReadString('\n') + + fmt.Print("Jira Password: ") + bytePassword, _ := term.ReadPassword(int(syscall.Stdin)) + password := string(bytePassword) + + fmt.Print("Jira Issue ID: ") + issueId, _ := r.ReadString('\n') + issueId = strings.TrimSpace(issueId) + + fmt.Print("Label: ") + label, _ := r.ReadString('\n') + label = strings.TrimSpace(label) + + tp := jira.BasicAuthTransport{ + Username: strings.TrimSpace(username), + APIToken: strings.TrimSpace(password), + } + + client, err := jira.NewClient(strings.TrimSpace(jiraURL), tp.Client()) + if err != nil { + fmt.Printf("\nerror: %v\n", err) + return + } + + type Labels struct { + Add string `json:"add" structs:"add"` + } + + type Update struct { + Labels []Labels `json:"labels" structs:"labels"` + } + + c := map[string]interface{}{ + "update": Update{ + Labels: []Labels{ + { + Add: label, + }, + }, + }, + } + + resp, err := client.Issue.UpdateIssue(context.Background(), issueId, c) + + if err != nil { + fmt.Println(err) + } + body, _ := io.ReadAll(resp.Body) + fmt.Println(string(body)) + + issue, _, _ := client.Issue.Get(context.Background(), issueId, nil) + + fmt.Printf("Issue: %s:%s\n", issue.Key, issue.Fields.Summary) + fmt.Printf("\tLabels: %+v\n", issue.Fields.Labels) +} diff --git a/cloud/examples/basic_auth/main.go b/cloud/examples/basic_auth/main.go new file mode 100644 index 00000000..bafa6a03 --- /dev/null +++ b/cloud/examples/basic_auth/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "fmt" + + jira "github.com/andygrunwald/go-jira/v2/cloud" +) + +func main() { + jiraURL := "https://go-jira-opensource.atlassian.net/" + + // Jira docs: https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/ + // Create a new API token: https://id.atlassian.com/manage-profile/security/api-tokens + tp := jira.BasicAuthTransport{ + Username: "", + APIToken: "", + } + client, err := jira.NewClient(jiraURL, tp.Client()) + if err != nil { + panic(err) + } + + u, _, err := client.User.GetCurrentUser(context.Background()) + if err != nil { + panic(err) + } + + fmt.Printf("Email: %v\n", u.EmailAddress) + fmt.Println("Success!") +} diff --git a/cloud/examples/component_create/main.go b/cloud/examples/component_create/main.go new file mode 100644 index 00000000..5f40a61b --- /dev/null +++ b/cloud/examples/component_create/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "fmt" + + jira "github.com/andygrunwald/go-jira/v2/cloud" +) + +func main() { + jiraURL := "https://go-jira-opensource.atlassian.net/" + + // Jira docs: https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/ + // Create a new API token: https://id.atlassian.com/manage-profile/security/api-tokens + tp := jira.BasicAuthTransport{ + Username: "", + APIToken: "", + } + client, err := jira.NewClient(jiraURL, tp.Client()) + if err != nil { + panic(err) + } + + c := &jira.ComponentCreateOptions{ + Name: "Dummy component", + AssigneeType: jira.AssigneeTypeUnassigned, + Project: "BUG", + } + component, _, err := client.Component.Create(context.Background(), c) + if err != nil { + panic(err) + } + + fmt.Printf("component: %+v\n", component) + fmt.Println("Success!") +} diff --git a/cloud/examples/component_get/main.go b/cloud/examples/component_get/main.go new file mode 100644 index 00000000..187cf405 --- /dev/null +++ b/cloud/examples/component_get/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "fmt" + + jira "github.com/andygrunwald/go-jira/v2/cloud" +) + +func main() { + jiraURL := "https://go-jira-opensource.atlassian.net/" + + // Jira docs: https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/ + // Create a new API token: https://id.atlassian.com/manage-profile/security/api-tokens + tp := jira.BasicAuthTransport{ + Username: "", + APIToken: "", + } + client, err := jira.NewClient(jiraURL, tp.Client()) + if err != nil { + panic(err) + } + + component, _, err := client.Component.Get(context.Background(), "10000") + if err != nil { + panic(err) + } + + fmt.Printf("component: %+v\n", component) + fmt.Println("Success!") +} diff --git a/cloud/examples/create/main.go b/cloud/examples/create/main.go new file mode 100644 index 00000000..df175331 --- /dev/null +++ b/cloud/examples/create/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "syscall" + + jira "github.com/andygrunwald/go-jira/v2/cloud" + "golang.org/x/term" +) + +func main() { + r := bufio.NewReader(os.Stdin) + + fmt.Print("Jira URL: ") + jiraURL, _ := r.ReadString('\n') + + fmt.Print("Jira Username: ") + username, _ := r.ReadString('\n') + + fmt.Print("Jira Password: ") + bytePassword, _ := term.ReadPassword(int(syscall.Stdin)) + password := string(bytePassword) + + tp := jira.BasicAuthTransport{ + Username: strings.TrimSpace(username), + APIToken: strings.TrimSpace(password), + } + + client, err := jira.NewClient(strings.TrimSpace(jiraURL), tp.Client()) + if err != nil { + fmt.Printf("\nerror: %v\n", err) + return + } + + i := jira.Issue{ + Fields: &jira.IssueFields{ + Assignee: &jira.User{ + AccountID: "my-user-account-id", + }, + Reporter: &jira.User{ + AccountID: "your-user-account-id", + }, + Description: "Test Issue", + Type: jira.IssueType{ + Name: "Bug", + }, + Project: jira.Project{ + Key: "PROJ1", + }, + Summary: "Just a demo issue", + }, + } + + issue, _, err := client.Issue.Create(context.Background(), &i) + if err != nil { + panic(err) + } + + fmt.Printf("%s: %+v\n", issue.Key, issue.Self) +} diff --git a/cloud/examples/createwithcustomfields/main.go b/cloud/examples/createwithcustomfields/main.go new file mode 100644 index 00000000..4c1b0354 --- /dev/null +++ b/cloud/examples/createwithcustomfields/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "syscall" + + jira "github.com/andygrunwald/go-jira/v2/cloud" + "github.com/trivago/tgo/tcontainer" + "golang.org/x/term" +) + +func main() { + r := bufio.NewReader(os.Stdin) + + fmt.Print("Jira URL: ") + jiraURL, _ := r.ReadString('\n') + + fmt.Print("Jira Username: ") + username, _ := r.ReadString('\n') + + fmt.Print("Jira Password: ") + bytePassword, _ := term.ReadPassword(int(syscall.Stdin)) + password := string(bytePassword) + + fmt.Print("Custom field name (i.e. customfield_10220): ") + customFieldName, _ := r.ReadString('\n') + + fmt.Print("Custom field value: ") + customFieldValue, _ := r.ReadString('\n') + + tp := jira.BasicAuthTransport{ + Username: strings.TrimSpace(username), + APIToken: strings.TrimSpace(password), + } + + client, err := jira.NewClient(strings.TrimSpace(jiraURL), tp.Client()) + if err != nil { + fmt.Printf("\nerror: %v\n", err) + os.Exit(1) + } + + unknowns := tcontainer.NewMarshalMap() + unknowns[customFieldName] = customFieldValue + + i := jira.Issue{ + Fields: &jira.IssueFields{ + Assignee: &jira.User{ + Name: "myuser", + }, + Reporter: &jira.User{ + Name: "youruser", + }, + Description: "Test Issue", + Type: jira.IssueType{ + Name: "Bug", + }, + Project: jira.Project{ + Key: "PROJ1", + }, + Summary: "Just a demo issue", + Unknowns: unknowns, + }, + } + + issue, _, err := client.Issue.Create(context.Background(), &i) + if err != nil { + panic(err) + } + + fmt.Printf("%s: %v\n", issue.Key, issue.Self) +} diff --git a/examples/do/main.go b/cloud/examples/do/main.go similarity index 53% rename from examples/do/main.go rename to cloud/examples/do/main.go index 750e77d3..161b6d89 100644 --- a/examples/do/main.go +++ b/cloud/examples/do/main.go @@ -1,14 +1,16 @@ package main import ( + "context" "fmt" + "net/http" - jira "github.com/andygrunwald/go-jira" + jira "github.com/andygrunwald/go-jira/v2/cloud" ) func main() { - jiraClient, _ := jira.NewClient(nil, "https://jira.atlassian.com/") - req, _ := jiraClient.NewRequest("GET", "/rest/api/2/project", nil) + jiraClient, _ := jira.NewClient("https://jira.atlassian.com/", nil) + req, _ := jiraClient.NewRequest(context.Background(), http.MethodGet, "/rest/api/2/project", nil) projects := new([]jira.Project) res, err := jiraClient.Do(req, projects) diff --git a/examples/ignorecerts/main.go b/cloud/examples/ignorecerts/main.go similarity index 63% rename from examples/ignorecerts/main.go rename to cloud/examples/ignorecerts/main.go index 6fcc602e..db7b774d 100644 --- a/examples/ignorecerts/main.go +++ b/cloud/examples/ignorecerts/main.go @@ -1,11 +1,12 @@ package main import ( + "context" "crypto/tls" "fmt" "net/http" - jira "github.com/andygrunwald/go-jira" + jira "github.com/andygrunwald/go-jira/v2/cloud" ) func main() { @@ -14,8 +15,8 @@ func main() { } client := &http.Client{Transport: tr} - jiraClient, _ := jira.NewClient(client, "https://issues.apache.org/jira/") - issue, _, _ := jiraClient.Issue.Get("MESOS-3325", nil) + jiraClient, _ := jira.NewClient("https://issues.apache.org/jira/", client) + issue, _, _ := jiraClient.Issue.Get(context.Background(), "MESOS-3325", nil) fmt.Printf("%s: %+v\n", issue.Key, issue.Fields.Summary) fmt.Printf("Type: %s\n", issue.Fields.Type.Name) diff --git a/examples/jql/main.go b/cloud/examples/jql/main.go similarity index 74% rename from examples/jql/main.go rename to cloud/examples/jql/main.go index 9cb05f54..c224eaf7 100644 --- a/examples/jql/main.go +++ b/cloud/examples/jql/main.go @@ -1,19 +1,20 @@ package main import ( + "context" "fmt" - jira "github.com/andygrunwald/go-jira" + jira "github.com/andygrunwald/go-jira/v2/cloud" ) func main() { - jiraClient, _ := jira.NewClient(nil, "https://issues.apache.org/jira/") + jiraClient, _ := jira.NewClient("https://issues.apache.org/jira/", nil) // Running JQL query jql := "project = Mesos and type = Bug and Status NOT IN (Resolved)" fmt.Printf("Usecase: Running a JQL query '%s'\n", jql) - issues, resp, err := jiraClient.Issue.Search(jql, nil) + issues, resp, err := jiraClient.Issue.Search(context.Background(), jql, nil) if err != nil { panic(err) } @@ -25,7 +26,7 @@ func main() { // Running an empty JQL query to get all tickets jql = "" fmt.Printf("Usecase: Running an empty JQL query to get all tickets\n") - issues, resp, err = jiraClient.Issue.Search(jql, nil) + issues, resp, err = jiraClient.Issue.Search(context.Background(), jql, nil) if err != nil { panic(err) } diff --git a/examples/newclient/main.go b/cloud/examples/newclient/main.go similarity index 50% rename from examples/newclient/main.go rename to cloud/examples/newclient/main.go index e0bec521..d1895eb3 100644 --- a/examples/newclient/main.go +++ b/cloud/examples/newclient/main.go @@ -1,14 +1,15 @@ package main import ( + "context" "fmt" - jira "github.com/andygrunwald/go-jira" + jira "github.com/andygrunwald/go-jira/v2/cloud" ) func main() { - jiraClient, _ := jira.NewClient(nil, "https://issues.apache.org/jira/") - issue, _, _ := jiraClient.Issue.Get("MESOS-3325", nil) + jiraClient, _ := jira.NewClient("https://issues.apache.org/jira/", nil) + issue, _, _ := jiraClient.Issue.Get(context.Background(), "MESOS-3325", nil) fmt.Printf("%s: %+v\n", issue.Key, issue.Fields.Summary) fmt.Printf("Type: %s\n", issue.Fields.Type.Name) diff --git a/examples/pagination/main.go b/cloud/examples/pagination/main.go similarity index 83% rename from examples/pagination/main.go rename to cloud/examples/pagination/main.go index 571e9e12..15995e33 100644 --- a/examples/pagination/main.go +++ b/cloud/examples/pagination/main.go @@ -1,9 +1,10 @@ package main import ( + "context" "fmt" - jira "github.com/andygrunwald/go-jira" + jira "github.com/andygrunwald/go-jira/v2/cloud" ) // GetAllIssues will implement pagination of api and get all the issues. @@ -19,7 +20,7 @@ func GetAllIssues(client *jira.Client, searchString string) ([]jira.Issue, error StartAt: last, } - chunk, resp, err := client.Issue.Search(searchString, opt) + chunk, resp, err := client.Issue.Search(context.Background(), searchString, opt) if err != nil { return nil, err } @@ -38,7 +39,7 @@ func GetAllIssues(client *jira.Client, searchString string) ([]jira.Issue, error } func main() { - jiraClient, err := jira.NewClient(nil, "https://issues.apache.org/jira/") + jiraClient, err := jira.NewClient("https://issues.apache.org/jira/", nil) if err != nil { panic(err) } diff --git a/cloud/examples/renderedfields/main.go b/cloud/examples/renderedfields/main.go new file mode 100644 index 00000000..b9ec26b4 --- /dev/null +++ b/cloud/examples/renderedfields/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "net/http" + "os" + "strings" + "syscall" + + "golang.org/x/term" + + jira "github.com/andygrunwald/go-jira/v2/cloud" +) + +func main() { + r := bufio.NewReader(os.Stdin) + + fmt.Print("Jira URL: ") + jiraURL, _ := r.ReadString('\n') + + fmt.Print("Jira Issue key: ") + key, _ := r.ReadString('\n') + key = strings.TrimSpace(key) + + fmt.Print("Jira Username: ") + username, _ := r.ReadString('\n') + + fmt.Print("Jira Password: ") + bytePassword, _ := term.ReadPassword(int(syscall.Stdin)) + password := string(bytePassword) + + var tp *http.Client + + if strings.TrimSpace(username) == "" { + tp = nil + } else { + + ba := jira.BasicAuthTransport{ + Username: strings.TrimSpace(username), + APIToken: strings.TrimSpace(password), + } + tp = ba.Client() + } + + client, err := jira.NewClient(strings.TrimSpace(jiraURL), tp) + if err != nil { + fmt.Printf("\nerror: %v\n", err) + return + } + + fmt.Printf("Targeting %s for issue %s\n", strings.TrimSpace(jiraURL), key) + + options := &jira.GetQueryOptions{Expand: "renderedFields"} + u, _, err := client.Issue.Get(context.Background(), key, options) + + if err != nil { + fmt.Printf("\n==> error: %v\n", err) + return + } + + fmt.Printf("RenderedFields: %+v\n", *u.RenderedFields) + + for _, c := range u.RenderedFields.Comments.Comments { + fmt.Printf(" %+v\n", c) + } +} diff --git a/cloud/examples/searchpages/main.go b/cloud/examples/searchpages/main.go new file mode 100644 index 00000000..d3a11981 --- /dev/null +++ b/cloud/examples/searchpages/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "log" + "os" + "strings" + "syscall" + "time" + + jira "github.com/andygrunwald/go-jira/v2/cloud" + "golang.org/x/term" +) + +func main() { + r := bufio.NewReader(os.Stdin) + + fmt.Print("Jira URL: ") + jiraURL, _ := r.ReadString('\n') + + fmt.Print("Jira Username: ") + username, _ := r.ReadString('\n') + + fmt.Print("Jira Password: ") + bytePassword, _ := term.ReadPassword(int(syscall.Stdin)) + password := string(bytePassword) + + fmt.Print("\nJira Project Key: ") // e.g. TES or WOW + jiraPK, _ := r.ReadString('\n') + + tp := jira.BasicAuthTransport{ + Username: strings.TrimSpace(username), + APIToken: strings.TrimSpace(password), + } + + client, err := jira.NewClient(strings.TrimSpace(jiraURL), tp.Client()) + if err != nil { + log.Fatal(err) + } + + var issues []jira.Issue + + // appendFunc will append jira issues to []jira.Issue + appendFunc := func(i jira.Issue) (err error) { + issues = append(issues, i) + return err + } + + // SearchPages will page through results and pass each issue to appendFunc + // In this example, we'll search for all the issues in the target project + err = client.Issue.SearchPages(context.Background(), fmt.Sprintf(`project=%s`, strings.TrimSpace(jiraPK)), nil, appendFunc) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%d issues found.\n", len(issues)) + + for _, i := range issues { + t := time.Time(i.Fields.Created) // convert go-jira.Time to time.Time for manipulation + date := t.Format("2006-01-02") + clock := t.Format("15:04") + fmt.Printf("Creation Date: %s\nCreation Time: %s\nIssue Key: %s\nIssue Summary: %s\n\n", date, clock, i.Key, i.Fields.Summary) + } + +} diff --git a/cloud/examples/statuscategories/main.go b/cloud/examples/statuscategories/main.go new file mode 100644 index 00000000..9a9f7f76 --- /dev/null +++ b/cloud/examples/statuscategories/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "log" + + jira "github.com/andygrunwald/go-jira/v2/cloud" +) + +func main() { + jiraClient, err := jira.NewClient("https://mattermost.atlassian.net/", nil) + if err != nil { + panic(err) + } + + // Showcase of StatusCategory.GetList: + // Getting all status categories + categories, resp, err := jiraClient.StatusCategory.GetList(context.TODO()) + if err != nil { + log.Println(resp.StatusCode) + panic(err) + } + + for _, statusCategory := range categories { + log.Println(statusCategory) + } + + // Showcase of StatusCategory.Get + // Getting a single status category + category, resp, err := jiraClient.StatusCategory.Get(context.TODO(), "1") + if err != nil { + log.Println(resp.StatusCode) + panic(err) + } + + log.Println(category) +} diff --git a/field.go b/cloud/field.go similarity index 78% rename from field.go rename to cloud/field.go index b14057d9..f25f49f6 100644 --- a/field.go +++ b/cloud/field.go @@ -1,13 +1,14 @@ -package jira +package cloud -import "context" +import ( + "context" + "net/http" +) // FieldService handles fields for the Jira instance / API. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-Field -type FieldService struct { - client *Client -} +type FieldService service // Field represents a field of a Jira issue. type Field struct { @@ -31,12 +32,15 @@ type FieldSchema struct { CustomID int64 `json:"customId,omitempty" structs:"customId,omitempty"` } -// GetListWithContext gets all fields from Jira +// GetList gets all fields from Jira // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-field-get -func (s *FieldService) GetListWithContext(ctx context.Context) ([]Field, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *FieldService) GetList(ctx context.Context) ([]Field, *Response, error) { apiEndpoint := "rest/api/2/field" - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -48,8 +52,3 @@ func (s *FieldService) GetListWithContext(ctx context.Context) ([]Field, *Respon } return fieldList, resp, nil } - -// GetList wraps GetListWithContext using the background context. -func (s *FieldService) GetList() ([]Field, *Response, error) { - return s.GetListWithContext(context.Background()) -} diff --git a/cloud/field_test.go b/cloud/field_test.go new file mode 100644 index 00000000..1aa13058 --- /dev/null +++ b/cloud/field_test.go @@ -0,0 +1,33 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestFieldService_GetList(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/field" + + raw, err := os.ReadFile("../testing/mock-data/all_fields.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, string(raw)) + }) + + fields, _, err := testClient.Field.GetList(context.Background()) + if fields == nil { + t.Error("Expected field list. Field list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/filter.go b/cloud/filter.go similarity index 74% rename from filter.go rename to cloud/filter.go index f40f3a58..69bfab8f 100644 --- a/filter.go +++ b/cloud/filter.go @@ -1,8 +1,9 @@ -package jira +package cloud import ( "context" "fmt" + "net/http" "github.com/google/go-querystring/query" ) @@ -10,9 +11,7 @@ import ( // FilterService handles fields for the Jira instance / API. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-group-Filter -type FilterService struct { - client *Client -} +type FilterService service // Filter represents a Filter in Jira type Filter struct { @@ -120,12 +119,15 @@ type FilterSearchOptions struct { Expand string `url:"expand,omitempty"` } -// GetListWithContext retrieves all filters from Jira -func (fs *FilterService) GetListWithContext(ctx context.Context) ([]*Filter, *Response, error) { +// GetList retrieves all filters from Jira +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (fs *FilterService) GetList(ctx context.Context) ([]*Filter, *Response, error) { options := &GetQueryOptions{} apiEndpoint := "rest/api/2/filter" - req, err := fs.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := fs.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -145,15 +147,13 @@ func (fs *FilterService) GetListWithContext(ctx context.Context) ([]*Filter, *Re return filters, resp, err } -// GetList wraps GetListWithContext using the background context. -func (fs *FilterService) GetList() ([]*Filter, *Response, error) { - return fs.GetListWithContext(context.Background()) -} - -// GetFavouriteListWithContext retrieves the user's favourited filters from Jira -func (fs *FilterService) GetFavouriteListWithContext(ctx context.Context) ([]*Filter, *Response, error) { +// GetFavouriteList retrieves the user's favourited filters from Jira +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (fs *FilterService) GetFavouriteList(ctx context.Context) ([]*Filter, *Response, error) { apiEndpoint := "rest/api/2/filter/favourite" - req, err := fs.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := fs.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -166,15 +166,13 @@ func (fs *FilterService) GetFavouriteListWithContext(ctx context.Context) ([]*Fi return filters, resp, err } -// GetFavouriteList wraps GetFavouriteListWithContext using the background context. -func (fs *FilterService) GetFavouriteList() ([]*Filter, *Response, error) { - return fs.GetFavouriteListWithContext(context.Background()) -} - -// GetWithContext retrieves a single Filter from Jira -func (fs *FilterService) GetWithContext(ctx context.Context, filterID int) (*Filter, *Response, error) { +// Get retrieves a single Filter from Jira +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (fs *FilterService) Get(ctx context.Context, filterID int) (*Filter, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/filter/%d", filterID) - req, err := fs.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := fs.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -188,21 +186,19 @@ func (fs *FilterService) GetWithContext(ctx context.Context, filterID int) (*Fil return filter, resp, err } -// Get wraps GetWithContext using the background context. -func (fs *FilterService) Get(filterID int) (*Filter, *Response, error) { - return fs.GetWithContext(context.Background(), filterID) -} - -// GetMyFiltersWithContext retrieves the my Filters. +// GetMyFilters retrieves the my Filters. // // https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-rest-api-3-filter-my-get -func (fs *FilterService) GetMyFiltersWithContext(ctx context.Context, opts *GetMyFiltersQueryOptions) ([]*Filter, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (fs *FilterService) GetMyFilters(ctx context.Context, opts *GetMyFiltersQueryOptions) ([]*Filter, *Response, error) { apiEndpoint := "rest/api/3/filter/my" url, err := addOptions(apiEndpoint, opts) if err != nil { return nil, nil, err } - req, err := fs.client.NewRequestWithContext(ctx, "GET", url, nil) + req, err := fs.client.NewRequest(ctx, http.MethodGet, url, nil) if err != nil { return nil, nil, err } @@ -216,21 +212,19 @@ func (fs *FilterService) GetMyFiltersWithContext(ctx context.Context, opts *GetM return filters, resp, nil } -// GetMyFilters wraps GetMyFiltersWithContext using the background context. -func (fs *FilterService) GetMyFilters(opts *GetMyFiltersQueryOptions) ([]*Filter, *Response, error) { - return fs.GetMyFiltersWithContext(context.Background(), opts) -} - -// SearchWithContext will search for filter according to the search options +// Search will search for filter according to the search options // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-rest-api-3-filter-search-get -func (fs *FilterService) SearchWithContext(ctx context.Context, opt *FilterSearchOptions) (*FiltersList, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (fs *FilterService) Search(ctx context.Context, opt *FilterSearchOptions) (*FiltersList, *Response, error) { apiEndpoint := "rest/api/3/filter/search" url, err := addOptions(apiEndpoint, opt) if err != nil { return nil, nil, err } - req, err := fs.client.NewRequestWithContext(ctx, "GET", url, nil) + req, err := fs.client.NewRequest(ctx, http.MethodGet, url, nil) if err != nil { return nil, nil, err } @@ -244,8 +238,3 @@ func (fs *FilterService) SearchWithContext(ctx context.Context, opt *FilterSearc return filters, resp, err } - -// Search wraps SearchWithContext using the background context. -func (fs *FilterService) Search(opt *FilterSearchOptions) (*FiltersList, *Response, error) { - return fs.SearchWithContext(context.Background(), opt) -} diff --git a/filter_test.go b/cloud/filter_test.go similarity index 72% rename from filter_test.go rename to cloud/filter_test.go index 1b3a6903..e4fa5f32 100644 --- a/filter_test.go +++ b/cloud/filter_test.go @@ -1,9 +1,10 @@ -package jira +package cloud import ( + "context" "fmt" - "io/ioutil" "net/http" + "os" "testing" ) @@ -11,17 +12,17 @@ func TestFilterService_GetList(t *testing.T) { setup() defer teardown() testAPIEndpoint := "/rest/api/2/filter" - raw, err := ioutil.ReadFile("./mocks/all_filters.json") + raw, err := os.ReadFile("../testing/mock-data/all_filters.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(writer http.ResponseWriter, request *http.Request) { - testMethod(t, request, "GET") + testMethod(t, request, http.MethodGet) testRequestURL(t, request, testAPIEndpoint) fmt.Fprint(writer, string(raw)) }) - filters, _, err := testClient.Filter.GetList() + filters, _, err := testClient.Filter.GetList(context.Background()) if filters == nil { t.Error("Expected Filters list. Filters list is nil") } @@ -34,17 +35,17 @@ func TestFilterService_Get(t *testing.T) { setup() defer teardown() testAPIEndpoint := "/rest/api/2/filter/10000" - raw, err := ioutil.ReadFile("./mocks/filter.json") + raw, err := os.ReadFile("../testing/mock-data/filter.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(writer http.ResponseWriter, request *http.Request) { - testMethod(t, request, "GET") + testMethod(t, request, http.MethodGet) testRequestURL(t, request, testAPIEndpoint) fmt.Fprint(writer, string(raw)) }) - filter, _, err := testClient.Filter.Get(10000) + filter, _, err := testClient.Filter.Get(context.Background(), 10000) if filter == nil { t.Errorf("Expected Filter, got nil") } @@ -58,17 +59,17 @@ func TestFilterService_GetFavouriteList(t *testing.T) { setup() defer teardown() testAPIEndpoint := "/rest/api/2/filter/favourite" - raw, err := ioutil.ReadFile("./mocks/favourite_filters.json") + raw, err := os.ReadFile("../testing/mock-data/favourite_filters.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(writer http.ResponseWriter, request *http.Request) { - testMethod(t, request, "GET") + testMethod(t, request, http.MethodGet) testRequestURL(t, request, testAPIEndpoint) fmt.Fprint(writer, string(raw)) }) - filters, _, err := testClient.Filter.GetFavouriteList() + filters, _, err := testClient.Filter.GetFavouriteList(context.Background()) if filters == nil { t.Error("Expected Filters list. Filters list is nil") } @@ -81,18 +82,18 @@ func TestFilterService_GetMyFilters(t *testing.T) { setup() defer teardown() testAPIEndpoint := "/rest/api/3/filter/my" - raw, err := ioutil.ReadFile("./mocks/my_filters.json") + raw, err := os.ReadFile("../testing/mock-data/my_filters.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(writer http.ResponseWriter, request *http.Request) { - testMethod(t, request, "GET") + testMethod(t, request, http.MethodGet) testRequestURL(t, request, testAPIEndpoint) fmt.Fprint(writer, string(raw)) }) opts := GetMyFiltersQueryOptions{} - filters, _, err := testClient.Filter.GetMyFilters(&opts) + filters, _, err := testClient.Filter.GetMyFilters(context.Background(), &opts) if err != nil { t.Errorf("Error given: %s", err) } @@ -105,18 +106,18 @@ func TestFilterService_Search(t *testing.T) { setup() defer teardown() testAPIEndpoint := "/rest/api/3/filter/search" - raw, err := ioutil.ReadFile("./mocks/search_filters.json") + raw, err := os.ReadFile("../testing/mock-data/search_filters.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(writer http.ResponseWriter, request *http.Request) { - testMethod(t, request, "GET") + testMethod(t, request, http.MethodGet) testRequestURL(t, request, testAPIEndpoint) fmt.Fprint(writer, string(raw)) }) opt := FilterSearchOptions{} - filters, _, err := testClient.Filter.Search(&opt) + filters, _, err := testClient.Filter.Search(context.Background(), &opt) if err != nil { t.Errorf("Error given: %s", err) } diff --git a/cloud/group.go b/cloud/group.go new file mode 100644 index 00000000..38736fae --- /dev/null +++ b/cloud/group.go @@ -0,0 +1,146 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// GroupService handles Groups for the Jira instance / API. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/server/#api/2/group +type GroupService service + +// groupMembersResult is only a small wrapper around the Group* methods +// to be able to parse the results +type groupMembersResult struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Members []GroupMember `json:"values"` +} + +// Group represents a Jira group +type Group struct { + Name string `json:"name,omitempty" structs:"name,omitempty"` + Self string `json:"self,omitempty" structs:"self,omitempty"` + Users GroupMembers `json:"users,omitempty" structs:"users,omitempty"` + Expand string `json:"expand,omitempty" structs:"expand,omitempty"` +} + +// GroupMembers represent members in a Jira group +type GroupMembers struct { + Size int `json:"size,omitempty" structs:"size,omitempty"` + Items []GroupMember `json:"items,omitempty" structs:"items,omitempty"` + MaxResults int `json:"max-results,omitempty" structs:"max-results.omitempty"` + StartIndex int `json:"start-index,omitempty" structs:"start-index,omitempty"` + EndIndex int `json:"end-index,omitempty" structs:"end-index,omitempty"` +} + +// GroupMember reflects a single member of a group +type GroupMember struct { + Self string `json:"self,omitempty"` + Name string `json:"name,omitempty"` + Key string `json:"key,omitempty"` + AccountID string `json:"accountId,omitempty"` + EmailAddress string `json:"emailAddress,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Active bool `json:"active,omitempty"` + TimeZone string `json:"timeZone,omitempty"` + AccountType string `json:"accountType,omitempty"` +} + +// GroupSearchOptions specifies the optional parameters for the Get Group methods +type GroupSearchOptions struct { + StartAt int + MaxResults int + IncludeInactiveUsers bool +} + +// Get returns a paginated list of members of the specified group and its subgroups. +// Users in the page are ordered by user names. +// User of this resource is required to have sysadmin or admin permissions. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/server/#api/2/group-getUsersFromGroup +// +// WARNING: This API only returns the first page of group members +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *GroupService) Get(ctx context.Context, name string, options *GroupSearchOptions) ([]GroupMember, *Response, error) { + var apiEndpoint string + if options == nil { + apiEndpoint = fmt.Sprintf("/rest/api/2/group/member?groupname=%s", url.QueryEscape(name)) + } else { + // TODO use addOptions + apiEndpoint = fmt.Sprintf( + "/rest/api/2/group/member?groupname=%s&startAt=%d&maxResults=%d&includeInactiveUsers=%t", + url.QueryEscape(name), + options.StartAt, + options.MaxResults, + options.IncludeInactiveUsers, + ) + } + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + group := new(groupMembersResult) + resp, err := s.client.Do(req, group) + if err != nil { + return nil, resp, err + } + return group.Members, resp, nil +} + +// Add adds a user to a group. +// +// The account ID of the user, which uniquely identifies the user across all Atlassian products. +// For example, 5b10ac8d82e05b22cc7d4ef5. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-groups/#api-rest-api-3-group-user-post +func (s *GroupService) AddUserByGroupName(ctx context.Context, groupName string, accountID string) (*Group, *Response, error) { + apiEndpoint := fmt.Sprintf("/rest/api/3/group/user?groupname=%s", groupName) + var user struct { + AccountID string `json:"accountId"` + } + user.AccountID = accountID + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, &user) + if err != nil { + return nil, nil, err + } + + responseGroup := new(Group) + resp, err := s.client.Do(req, responseGroup) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return responseGroup, resp, nil +} + +// Remove removes a user from a group. +// +// The account ID of the user, which uniquely identifies the user across all Atlassian products. +// For example, 5b10ac8d82e05b22cc7d4ef5. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-groups/#api-rest-api-3-group-user-delete +// Caller must close resp.Body +func (s *GroupService) RemoveUserByGroupName(ctx context.Context, groupName string, accountID string) (*Response, error) { + apiEndpoint := fmt.Sprintf("/rest/api/3/group/user?groupname=%s&accountId=%s", groupName, accountID) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} diff --git a/cloud/group_test.go b/cloud/group_test.go new file mode 100644 index 00000000..e37f79e5 --- /dev/null +++ b/cloud/group_test.go @@ -0,0 +1,97 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" + "testing" +) + +func TestGroupService_GetPage(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/group/member", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/group/member?groupname=default") + startAt := r.URL.Query().Get("startAt") + if startAt == "0" { + fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/group/member?includeInactiveUsers=false&maxResults=2&groupname=default&startAt=0","nextPage":"`+testServer.URL+`/rest/api/2/group/member?groupname=default&includeInactiveUsers=false&maxResults=2&startAt=2","maxResults":2,"startAt":0,"total":4,"isLast":false,"values":[{"self":"http://www.example.com/jira/rest/api/2/user?username=michael","name":"michael","key":"michael","emailAddress":"michael@example.com","displayName":"MichaelScofield","active":true,"timeZone":"Australia/Sydney"},{"self":"http://www.example.com/jira/rest/api/2/user?username=alex","name":"alex","key":"alex","emailAddress":"alex@example.com","displayName":"AlexanderMahone","active":true,"timeZone":"Australia/Sydney"}]}`) + } else if startAt == "2" { + fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/group/member?includeInactiveUsers=false&maxResults=2&groupname=default&startAt=2","maxResults":2,"startAt":2,"total":4,"isLast":true,"values":[{"self":"http://www.example.com/jira/rest/api/2/user?username=michael","name":"michael","key":"michael","emailAddress":"michael@example.com","displayName":"MichaelScofield","active":true,"timeZone":"Australia/Sydney"},{"self":"http://www.example.com/jira/rest/api/2/user?username=alex","name":"alex","key":"alex","emailAddress":"alex@example.com","displayName":"AlexanderMahone","active":true,"timeZone":"Australia/Sydney"}]}`) + } else { + t.Errorf("startAt %s", startAt) + } + }) + if page, resp, err := testClient.Group.Get(context.Background(), "default", &GroupSearchOptions{ + StartAt: 0, + MaxResults: 2, + IncludeInactiveUsers: false, + }); err != nil { + t.Errorf("Error given: %s %s", err, testServer.URL) + } else if page == nil || len(page) != 2 { + t.Error("Expected members. Group.Members is not 2 or is nil") + } else { + if resp.StartAt != 0 { + t.Errorf("Expect Result StartAt to be 0, but is %d", resp.StartAt) + } + if resp.MaxResults != 2 { + t.Errorf("Expect Result MaxResults to be 2, but is %d", resp.MaxResults) + } + if resp.Total != 4 { + t.Errorf("Expect Result Total to be 4, but is %d", resp.Total) + } + if page, resp, err := testClient.Group.Get(context.Background(), "default", &GroupSearchOptions{ + StartAt: 2, + MaxResults: 2, + IncludeInactiveUsers: false, + }); err != nil { + t.Errorf("Error give: %s %s", err, testServer.URL) + } else if page == nil || len(page) != 2 { + t.Error("Expected members. Group.Members is not 2 or is nil") + } else { + if resp.StartAt != 2 { + t.Errorf("Expect Result StartAt to be 2, but is %d", resp.StartAt) + } + if resp.MaxResults != 2 { + t.Errorf("Expect Result MaxResults to be 2, but is %d", resp.MaxResults) + } + if resp.Total != 4 { + t.Errorf("Expect Result Total to be 4, but is %d", resp.Total) + } + } + } +} + +func TestGroupService_Add(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/3/group/user", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/3/group/user?groupname=default") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"name":"default","self":"http://www.example.com/jira/rest/api/2/group?groupname=default","users":{"size":1,"items":[],"max-results":50,"start-index":0,"end-index":0},"expand":"users"}`) + }) + + if group, _, err := testClient.Group.AddUserByGroupName(context.Background(), "default", "5b10ac8d82e05b22cc7d4ef5"); err != nil { + t.Errorf("Error given: %s", err) + } else if group == nil { + t.Error("Expected group. Group is nil") + } +} + +func TestGroupService_Remove(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/3/group/user", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/api/3/group/user?groupname=default") + + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"name":"default","self":"http://www.example.com/jira/rest/api/2/group?groupname=default","users":{"size":1,"items":[],"max-results":50,"start-index":0,"end-index":0},"expand":"users"}`) + }) + + if _, err := testClient.Group.RemoveUserByGroupName(context.Background(), "default", "5b10ac8d82e05b22cc7d4ef5"); err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/issue.go b/cloud/issue.go similarity index 72% rename from issue.go rename to cloud/issue.go index 0aa03b70..e7db3c73 100644 --- a/issue.go +++ b/cloud/issue.go @@ -1,4 +1,4 @@ -package jira +package cloud import ( "bytes" @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "mime/multipart" "net/http" "net/url" @@ -28,9 +27,7 @@ const ( // IssueService handles Issues for the Jira instance / API. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue -type IssueService struct { - client *Client -} +type IssueService service // UpdateQueryOptions specifies the optional parameters to the Edit issue type UpdateQueryOptions struct { @@ -483,6 +480,9 @@ type Comment struct { Updated string `json:"updated,omitempty" structs:"updated,omitempty"` Created string `json:"created,omitempty" structs:"created,omitempty"` Visibility CommentVisibility `json:"visibility,omitempty" structs:"visibility,omitempty"` + + // A list of comment properties. Optional on create and update. + Properties []EntityProperty `json:"properties,omitempty" structs:"properties,omitempty"` } // FixVersion represents a software release in which an issue is fixed. @@ -608,17 +608,20 @@ type RemoteLinkStatus struct { Icon *RemoteLinkIcon `json:"icon,omitempty" structs:"icon,omitempty"` } -// GetWithContext returns a full representation of the issue for the given issue key. +// Get returns a full representation of the issue for the given issue key. // Jira will attempt to identify the issue by the issueIdOrKey path parameter. // This can be an issue id, or an issue key. // If the issue cannot be found via an exact match, Jira will also look for the issue in a case-insensitive way, or by looking to see if the issue was moved. // -// The given options will be appended to the query string +// # The given options will be appended to the query string // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-getIssue -func (s *IssueService) GetWithContext(ctx context.Context, issueID string, options *GetQueryOptions) (*Issue, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) Get(ctx context.Context, issueID string, options *GetQueryOptions) (*Issue, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s", issueID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -641,18 +644,16 @@ func (s *IssueService) GetWithContext(ctx context.Context, issueID string, optio return issue, resp, nil } -// Get wraps GetWithContext using the background context. -func (s *IssueService) Get(issueID string, options *GetQueryOptions) (*Issue, *Response, error) { - return s.GetWithContext(context.Background(), issueID, options) -} - -// DownloadAttachmentWithContext returns a Response of an attachment for a given attachmentID. +// DownloadAttachment returns a Response of an attachment for a given attachmentID. // The attachment is in the Response.Body of the response. // This is an io.ReadCloser. // Caller must close resp.Body. -func (s *IssueService) DownloadAttachmentWithContext(ctx context.Context, attachmentID string) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) DownloadAttachment(ctx context.Context, attachmentID string) (*Response, error) { apiEndpoint := fmt.Sprintf("secure/attachment/%s/", attachmentID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, err } @@ -666,14 +667,11 @@ func (s *IssueService) DownloadAttachmentWithContext(ctx context.Context, attach return resp, nil } -// DownloadAttachment wraps DownloadAttachmentWithContext using the background context. -// Caller must close resp.Body -func (s *IssueService) DownloadAttachment(attachmentID string) (*Response, error) { - return s.DownloadAttachmentWithContext(context.Background(), attachmentID) -} - -// PostAttachmentWithContext uploads r (io.Reader) as an attachment to a given issueID -func (s *IssueService) PostAttachmentWithContext(ctx context.Context, issueID string, r io.Reader, attachmentName string) (*[]Attachment, *Response, error) { +// PostAttachment uploads r (io.Reader) as an attachment to a given issueID +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) PostAttachment(ctx context.Context, issueID string, r io.Reader, attachmentName string) (*[]Attachment, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/attachments", issueID) b := new(bytes.Buffer) @@ -692,7 +690,7 @@ func (s *IssueService) PostAttachmentWithContext(ctx context.Context, issueID st } writer.Close() - req, err := s.client.NewMultiPartRequestWithContext(ctx, "POST", apiEndpoint, b) + req, err := s.client.NewMultiPartRequest(ctx, http.MethodPost, apiEndpoint, b) if err != nil { return nil, nil, err } @@ -710,17 +708,15 @@ func (s *IssueService) PostAttachmentWithContext(ctx context.Context, issueID st return attachment, resp, nil } -// PostAttachment wraps PostAttachmentWithContext using the background context. -func (s *IssueService) PostAttachment(issueID string, r io.Reader, attachmentName string) (*[]Attachment, *Response, error) { - return s.PostAttachmentWithContext(context.Background(), issueID, r, attachmentName) -} - -// DeleteAttachmentWithContext deletes an attachment of a given attachmentID +// DeleteAttachment deletes an attachment of a given attachmentID // Caller must close resp.Body -func (s *IssueService) DeleteAttachmentWithContext(ctx context.Context, attachmentID string) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) DeleteAttachment(ctx context.Context, attachmentID string) (*Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/attachment/%s", attachmentID) - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) if err != nil { return nil, err } @@ -734,18 +730,15 @@ func (s *IssueService) DeleteAttachmentWithContext(ctx context.Context, attachme return resp, nil } -// DeleteAttachment wraps DeleteAttachmentWithContext using the background context. -// Caller must close resp.Body -func (s *IssueService) DeleteAttachment(attachmentID string) (*Response, error) { - return s.DeleteAttachmentWithContext(context.Background(), attachmentID) -} - -// DeleteLinkWithContext deletes a link of a given linkID +// DeleteLink deletes a link of a given linkID // Caller must close resp.Body -func (s *IssueService) DeleteLinkWithContext(ctx context.Context, linkID string) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) DeleteLink(ctx context.Context, linkID string) (*Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issueLink/%s", linkID) - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) if err != nil { return nil, err } @@ -759,20 +752,17 @@ func (s *IssueService) DeleteLinkWithContext(ctx context.Context, linkID string) return resp, nil } -// DeleteLink wraps DeleteLinkWithContext using the background context. -// Caller must close resp.Body -func (s *IssueService) DeleteLink(linkID string) (*Response, error) { - return s.DeleteLinkWithContext(context.Background(), linkID) -} - -// GetWorklogsWithContext gets all the worklogs for an issue. +// GetWorklogs gets all the worklogs for an issue. // This method is especially important if you need to read all the worklogs, not just the first page. // // https://docs.atlassian.com/jira/REST/cloud/#api/2/issue/{issueIdOrKey}/worklog-getIssueWorklog -func (s *IssueService) GetWorklogsWithContext(ctx context.Context, issueID string, options ...func(*http.Request) error) (*Worklog, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetWorklogs(ctx context.Context, issueID string, options ...func(*http.Request) error) (*Worklog, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/worklog", issueID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -789,13 +779,11 @@ func (s *IssueService) GetWorklogsWithContext(ctx context.Context, issueID strin return v, resp, err } -// GetWorklogs wraps GetWorklogsWithContext using the background context. -func (s *IssueService) GetWorklogs(issueID string, options ...func(*http.Request) error) (*Worklog, *Response, error) { - return s.GetWorklogsWithContext(context.Background(), issueID, options...) -} - // Applies query options to http request. // This helper is meant to be used with all "QueryOptions" structs. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func WithQueryOptions(options interface{}) func(*http.Request) error { q, err := query.Values(options) if err != nil { @@ -810,53 +798,52 @@ func WithQueryOptions(options interface{}) func(*http.Request) error { } } -// CreateWithContext creates an issue or a sub-task from a JSON representation. +// Create creates an issue or a sub-task from a JSON representation. // Creating a sub-task is similar to creating a regular issue, with two important differences: // The issueType field must correspond to a sub-task issue type and you must provide a parent field in the issue create request containing the id or key of the parent issue. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-createIssues -func (s *IssueService) CreateWithContext(ctx context.Context, issue *Issue) (*Issue, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) Create(ctx context.Context, issue *Issue) (*Issue, *Response, error) { apiEndpoint := "rest/api/2/issue" - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, issue) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, issue) if err != nil { return nil, nil, err } + resp, err := s.client.Do(req, nil) if err != nil { // incase of error return the resp for further inspection return nil, resp, err } + defer resp.Body.Close() responseIssue := new(Issue) - defer resp.Body.Close() - data, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, resp, fmt.Errorf("could not read the returned data") - } - err = json.Unmarshal(data, responseIssue) + err = json.NewDecoder(resp.Body).Decode(&responseIssue) if err != nil { - return nil, resp, fmt.Errorf("could not unmarshall the data into struct") + return nil, resp, err } - return responseIssue, resp, nil -} -// Create wraps CreateWithContext using the background context. -func (s *IssueService) Create(issue *Issue) (*Issue, *Response, error) { - return s.CreateWithContext(context.Background(), issue) + return responseIssue, resp, nil } -// UpdateWithOptionsWithContext updates an issue from a JSON representation, +// Update updates an issue from a JSON representation, // while also specifying query params. The issue is found by key. // -// Jira API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/issue-editIssue +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issues/#api-rest-api-2-issue-issueidorkey-put // Caller must close resp.Body -func (s *IssueService) UpdateWithOptionsWithContext(ctx context.Context, issue *Issue, opts *UpdateQueryOptions) (*Issue, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) Update(ctx context.Context, issue *Issue, opts *UpdateQueryOptions) (*Issue, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%v", issue.Key) url, err := addOptions(apiEndpoint, opts) if err != nil { return nil, nil, err } - req, err := s.client.NewRequestWithContext(ctx, "PUT", url, issue) + req, err := s.client.NewRequest(ctx, http.MethodPut, url, issue) if err != nil { return nil, nil, err } @@ -872,31 +859,16 @@ func (s *IssueService) UpdateWithOptionsWithContext(ctx context.Context, issue * return &ret, resp, nil } -// UpdateWithOptions wraps UpdateWithOptionsWithContext using the background context. -// Caller must close resp.Body -func (s *IssueService) UpdateWithOptions(issue *Issue, opts *UpdateQueryOptions) (*Issue, *Response, error) { - return s.UpdateWithOptionsWithContext(context.Background(), issue, opts) -} - -// UpdateWithContext updates an issue from a JSON representation. The issue is found by key. -// -// Jira API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/issue-editIssue -func (s *IssueService) UpdateWithContext(ctx context.Context, issue *Issue) (*Issue, *Response, error) { - return s.UpdateWithOptionsWithContext(ctx, issue, nil) -} - -// Update wraps UpdateWithContext using the background context. -func (s *IssueService) Update(issue *Issue) (*Issue, *Response, error) { - return s.UpdateWithContext(context.Background(), issue) -} - -// UpdateIssueWithContext updates an issue from a JSON representation. The issue is found by key. +// UpdateIssue updates an issue from a JSON representation. The issue is found by key. // // https://docs.atlassian.com/jira/REST/7.4.0/#api/2/issue-editIssue // Caller must close resp.Body -func (s *IssueService) UpdateIssueWithContext(ctx context.Context, jiraID string, data map[string]interface{}) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) UpdateIssue(ctx context.Context, jiraID string, data map[string]interface{}) (*Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%v", jiraID) - req, err := s.client.NewRequestWithContext(ctx, "PUT", apiEndpoint, data) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndpoint, data) if err != nil { return nil, err } @@ -910,18 +882,15 @@ func (s *IssueService) UpdateIssueWithContext(ctx context.Context, jiraID string return resp, nil } -// UpdateIssue wraps UpdateIssueWithContext using the background context. -// Caller must close resp.Body -func (s *IssueService) UpdateIssue(jiraID string, data map[string]interface{}) (*Response, error) { - return s.UpdateIssueWithContext(context.Background(), jiraID, data) -} - -// AddCommentWithContext adds a new comment to issueID. +// AddComment adds a new comment to issueID. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-addComment -func (s *IssueService) AddCommentWithContext(ctx context.Context, issueID string, comment *Comment) (*Comment, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) AddComment(ctx context.Context, issueID string, comment *Comment) (*Comment, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/comment", issueID) - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, comment) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, comment) if err != nil { return nil, nil, err } @@ -936,22 +905,20 @@ func (s *IssueService) AddCommentWithContext(ctx context.Context, issueID string return responseComment, resp, nil } -// AddComment wraps AddCommentWithContext using the background context. -func (s *IssueService) AddComment(issueID string, comment *Comment) (*Comment, *Response, error) { - return s.AddCommentWithContext(context.Background(), issueID, comment) -} - -// UpdateCommentWithContext updates the body of a comment, identified by comment.ID, on the issueID. +// UpdateComment updates the body of a comment, identified by comment.ID, on the issueID. // // Jira API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/issue/{issueIdOrKey}/comment-updateComment -func (s *IssueService) UpdateCommentWithContext(ctx context.Context, issueID string, comment *Comment) (*Comment, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) UpdateComment(ctx context.Context, issueID string, comment *Comment) (*Comment, *Response, error) { reqBody := struct { Body string `json:"body"` }{ Body: comment.Body, } apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/comment/%s", issueID, comment.ID) - req, err := s.client.NewRequestWithContext(ctx, "PUT", apiEndpoint, reqBody) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndpoint, reqBody) if err != nil { return nil, nil, err } @@ -965,17 +932,15 @@ func (s *IssueService) UpdateCommentWithContext(ctx context.Context, issueID str return responseComment, resp, nil } -// UpdateComment wraps UpdateCommentWithContext using the background context. -func (s *IssueService) UpdateComment(issueID string, comment *Comment) (*Comment, *Response, error) { - return s.UpdateCommentWithContext(context.Background(), issueID, comment) -} - -// DeleteCommentWithContext Deletes a comment from an issueID. +// DeleteComment Deletes a comment from an issueID. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-api-3-issue-issueIdOrKey-comment-id-delete -func (s *IssueService) DeleteCommentWithContext(ctx context.Context, issueID, commentID string) error { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) DeleteComment(ctx context.Context, issueID, commentID string) error { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/comment/%s", issueID, commentID) - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) if err != nil { return err } @@ -990,17 +955,15 @@ func (s *IssueService) DeleteCommentWithContext(ctx context.Context, issueID, co return nil } -// DeleteComment wraps DeleteCommentWithContext using the background context. -func (s *IssueService) DeleteComment(issueID, commentID string) error { - return s.DeleteCommentWithContext(context.Background(), issueID, commentID) -} - -// AddWorklogRecordWithContext adds a new worklog record to issueID. +// AddWorklogRecord adds a new worklog record to issueID. // // https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-issue-issueIdOrKey-worklog-post -func (s *IssueService) AddWorklogRecordWithContext(ctx context.Context, issueID string, record *WorklogRecord, options ...func(*http.Request) error) (*WorklogRecord, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) AddWorklogRecord(ctx context.Context, issueID string, record *WorklogRecord, options ...func(*http.Request) error) (*WorklogRecord, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/worklog", issueID) - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, record) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, record) if err != nil { return nil, nil, err } @@ -1022,17 +985,15 @@ func (s *IssueService) AddWorklogRecordWithContext(ctx context.Context, issueID return responseRecord, resp, nil } -// AddWorklogRecord wraps AddWorklogRecordWithContext using the background context. -func (s *IssueService) AddWorklogRecord(issueID string, record *WorklogRecord, options ...func(*http.Request) error) (*WorklogRecord, *Response, error) { - return s.AddWorklogRecordWithContext(context.Background(), issueID, record, options...) -} - -// UpdateWorklogRecordWithContext updates a worklog record. +// UpdateWorklogRecord updates a worklog record. // // https://docs.atlassian.com/software/jira/docs/api/REST/7.1.2/#api/2/issue-updateWorklog -func (s *IssueService) UpdateWorklogRecordWithContext(ctx context.Context, issueID, worklogID string, record *WorklogRecord, options ...func(*http.Request) error) (*WorklogRecord, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) UpdateWorklogRecord(ctx context.Context, issueID, worklogID string, record *WorklogRecord, options ...func(*http.Request) error) (*WorklogRecord, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/worklog/%s", issueID, worklogID) - req, err := s.client.NewRequestWithContext(ctx, "PUT", apiEndpoint, record) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndpoint, record) if err != nil { return nil, nil, err } @@ -1054,18 +1015,16 @@ func (s *IssueService) UpdateWorklogRecordWithContext(ctx context.Context, issue return responseRecord, resp, nil } -// UpdateWorklogRecord wraps UpdateWorklogRecordWithContext using the background context. -func (s *IssueService) UpdateWorklogRecord(issueID, worklogID string, record *WorklogRecord, options ...func(*http.Request) error) (*WorklogRecord, *Response, error) { - return s.UpdateWorklogRecordWithContext(context.Background(), issueID, worklogID, record, options...) -} - -// AddLinkWithContext adds a link between two issues. +// AddLink adds a link between two issues. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issueLink // Caller must close resp.Body -func (s *IssueService) AddLinkWithContext(ctx context.Context, issueLink *IssueLink) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) AddLink(ctx context.Context, issueLink *IssueLink) (*Response, error) { apiEndpoint := "rest/api/2/issueLink" - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, issueLink) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, issueLink) if err != nil { return nil, err } @@ -1078,16 +1037,13 @@ func (s *IssueService) AddLinkWithContext(ctx context.Context, issueLink *IssueL return resp, err } -// AddLink wraps AddLinkWithContext using the background context. -// Caller must close resp.Body -func (s *IssueService) AddLink(issueLink *IssueLink) (*Response, error) { - return s.AddLinkWithContext(context.Background(), issueLink) -} - -// SearchWithContext will search for tickets according to the jql +// Search will search for tickets according to the jql // // Jira API docs: https://developer.atlassian.com/jiradev/jira-apis/jira-rest-apis/jira-rest-api-tutorials/jira-rest-api-example-query-issues -func (s *IssueService) SearchWithContext(ctx context.Context, jql string, options *SearchOptions) ([]Issue, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) Search(ctx context.Context, jql string, options *SearchOptions) ([]Issue, *Response, error) { u := url.URL{ Path: "rest/api/2/search", } @@ -1116,7 +1072,7 @@ func (s *IssueService) SearchWithContext(ctx context.Context, jql string, option u.RawQuery = uv.Encode() - req, err := s.client.NewRequestWithContext(ctx, "GET", u.String(), nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return []Issue{}, nil, err } @@ -1129,15 +1085,13 @@ func (s *IssueService) SearchWithContext(ctx context.Context, jql string, option return v.Issues, resp, err } -// Search wraps SearchWithContext using the background context. -func (s *IssueService) Search(jql string, options *SearchOptions) ([]Issue, *Response, error) { - return s.SearchWithContext(context.Background(), jql, options) -} - -// SearchPagesWithContext will get issues from all pages in a search +// SearchPages will get issues from all pages in a search // // Jira API docs: https://developer.atlassian.com/jiradev/jira-apis/jira-rest-apis/jira-rest-api-tutorials/jira-rest-api-example-query-issues -func (s *IssueService) SearchPagesWithContext(ctx context.Context, jql string, options *SearchOptions, f func(Issue) error) error { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) SearchPages(ctx context.Context, jql string, options *SearchOptions, f func(Issue) error) error { if options == nil { options = &SearchOptions{ StartAt: 0, @@ -1149,7 +1103,7 @@ func (s *IssueService) SearchPagesWithContext(ctx context.Context, jql string, o options.MaxResults = 50 } - issues, resp, err := s.SearchWithContext(ctx, jql, options) + issues, resp, err := s.Search(ctx, jql, options) if err != nil { return err } @@ -1171,22 +1125,20 @@ func (s *IssueService) SearchPagesWithContext(ctx context.Context, jql string, o } options.StartAt += resp.MaxResults - issues, resp, err = s.SearchWithContext(ctx, jql, options) + issues, resp, err = s.Search(ctx, jql, options) if err != nil { return err } } } -// SearchPages wraps SearchPagesWithContext using the background context. -func (s *IssueService) SearchPages(jql string, options *SearchOptions, f func(Issue) error) error { - return s.SearchPagesWithContext(context.Background(), jql, options, f) -} - -// GetCustomFieldsWithContext returns a map of customfield_* keys with string values -func (s *IssueService) GetCustomFieldsWithContext(ctx context.Context, issueID string) (CustomFields, *Response, error) { +// GetCustomFields returns a map of customfield_* keys with string values +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetCustomFields(ctx context.Context, issueID string) (CustomFields, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s", issueID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -1220,18 +1172,16 @@ func (s *IssueService) GetCustomFieldsWithContext(ctx context.Context, issueID s return cf, resp, nil } -// GetCustomFields wraps GetCustomFieldsWithContext using the background context. -func (s *IssueService) GetCustomFields(issueID string) (CustomFields, *Response, error) { - return s.GetCustomFieldsWithContext(context.Background(), issueID) -} - -// GetTransitionsWithContext gets a list of the transitions possible for this issue by the current user, +// GetTransitions gets a list of the transitions possible for this issue by the current user, // along with fields that are required and their types. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-getTransitions -func (s *IssueService) GetTransitionsWithContext(ctx context.Context, id string) ([]Transition, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetTransitions(ctx context.Context, id string) ([]Transition, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/transitions?expand=transitions.fields", id) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -1244,38 +1194,35 @@ func (s *IssueService) GetTransitionsWithContext(ctx context.Context, id string) return result.Transitions, resp, err } -// GetTransitions wraps GetTransitionsWithContext using the background context. -func (s *IssueService) GetTransitions(id string) ([]Transition, *Response, error) { - return s.GetTransitionsWithContext(context.Background(), id) -} - -// DoTransitionWithContext performs a transition on an issue. +// DoTransition performs a transition on an issue. // When performing the transition you can update or set other issue fields. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-doTransition -func (s *IssueService) DoTransitionWithContext(ctx context.Context, ticketID, transitionID string) (*Response, error) { +// Caller must close Response.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) DoTransition(ctx context.Context, ticketID, transitionID string) (*Response, error) { payload := CreateTransitionPayload{ Transition: TransitionPayload{ ID: transitionID, }, } - return s.DoTransitionWithPayloadWithContext(ctx, ticketID, payload) -} - -// DoTransition wraps DoTransitionWithContext using the background context. -func (s *IssueService) DoTransition(ticketID, transitionID string) (*Response, error) { - return s.DoTransitionWithContext(context.Background(), ticketID, transitionID) + return s.DoTransitionWithPayload(ctx, ticketID, payload) } -// DoTransitionWithPayloadWithContext performs a transition on an issue using any payload. +// DoTransitionWithPayload performs a transition on an issue using any payload. // When performing the transition you can update or set other issue fields. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-doTransition // Caller must close resp.Body -func (s *IssueService) DoTransitionWithPayloadWithContext(ctx context.Context, ticketID, payload interface{}) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) DoTransitionWithPayload(ctx context.Context, ticketID, payload interface{}) (*Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/transitions", ticketID) - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, payload) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, payload) if err != nil { return nil, err } @@ -1288,22 +1235,21 @@ func (s *IssueService) DoTransitionWithPayloadWithContext(ctx context.Context, t return resp, err } -// DoTransitionWithPayload wraps DoTransitionWithPayloadWithContext using the background context. -// Caller must close resp.Body -func (s *IssueService) DoTransitionWithPayload(ticketID, payload interface{}) (*Response, error) { - return s.DoTransitionWithPayloadWithContext(context.Background(), ticketID, payload) -} - // InitIssueWithMetaAndFields returns Issue with with values from fieldsConfig properly set. -// * metaProject should contain metaInformation about the project where the issue should be created. -// * metaIssuetype is the MetaInformation about the Issuetype that needs to be created. -// * fieldsConfig is a key->value pair where key represents the name of the field as seen in the UI -// And value is the string value for that particular key. +// - metaProject should contain metaInformation about the project where the issue should be created. +// - metaIssuetype is the MetaInformation about the Issuetype that needs to be created. +// - fieldsConfig is a key->value pair where key represents the name of the field as seen in the UI +// And value is the string value for that particular key. +// // Note: This method doesn't verify that the fieldsConfig is complete with mandatory fields. The fieldsConfig is -// supposed to be already verified with MetaIssueType.CheckCompleteAndAvailable. It will however return -// error if the key is not found. -// All values will be packed into Unknowns. This is much convenient. If the struct fields needs to be -// configured as well, marshalling and unmarshalling will set the proper fields. +// +// supposed to be already verified with MetaIssueType.CheckCompleteAndAvailable. It will however return +// error if the key is not found. +// All values will be packed into Unknowns. This is much convenient. If the struct fields needs to be +// configured as well, marshalling and unmarshalling will set the proper fields. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func InitIssueWithMetaAndFields(metaProject *MetaProject, metaIssuetype *MetaIssueType, fieldsConfig map[string]string) (*Issue, error) { issue := new(Issue) issueFields := new(IssueFields) @@ -1373,9 +1319,12 @@ func InitIssueWithMetaAndFields(metaProject *MetaProject, metaIssuetype *MetaIss return issue, nil } -// DeleteWithContext will delete a specified issue. +// Delete will delete a specified issue. // Caller must close resp.Body -func (s *IssueService) DeleteWithContext(ctx context.Context, issueID string) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) Delete(ctx context.Context, issueID string) (*Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s", issueID) // to enable deletion of subtasks; without this, the request will fail if the issue has subtasks @@ -1383,7 +1332,7 @@ func (s *IssueService) DeleteWithContext(ctx context.Context, issueID string) (* deletePayload["deleteSubtasks"] = "true" content, _ := json.Marshal(deletePayload) - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndpoint, content) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, content) if err != nil { return nil, err } @@ -1392,19 +1341,16 @@ func (s *IssueService) DeleteWithContext(ctx context.Context, issueID string) (* return resp, err } -// Delete wraps DeleteWithContext using the background context. -// Caller must close resp.Body -func (s *IssueService) Delete(issueID string) (*Response, error) { - return s.DeleteWithContext(context.Background(), issueID) -} - -// GetWatchersWithContext wil return all the users watching/observing the given issue +// GetWatchers wil return all the users watching/observing the given issue // // Jira API docs: https://docs.atlassian.com/software/jira/docs/api/REST/latest/#api/2/issue-getIssueWatchers -func (s *IssueService) GetWatchersWithContext(ctx context.Context, issueID string) (*[]User, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetWatchers(ctx context.Context, issueID string) (*[]User, *Response, error) { watchesAPIEndpoint := fmt.Sprintf("rest/api/2/issue/%s/watchers", issueID) - req, err := s.client.NewRequestWithContext(ctx, "GET", watchesAPIEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, watchesAPIEndpoint, nil) if err != nil { return nil, nil, err } @@ -1419,7 +1365,7 @@ func (s *IssueService) GetWatchersWithContext(ctx context.Context, issueID strin for _, watcher := range watches.Watchers { var user *User if watcher.AccountID != "" { - user, resp, err = s.client.User.GetByAccountID(watcher.AccountID) + user, resp, err = s.client.User.GetByAccountID(context.Background(), watcher.AccountID) if err != nil { return nil, resp, NewJiraError(resp, err) } @@ -1430,19 +1376,17 @@ func (s *IssueService) GetWatchersWithContext(ctx context.Context, issueID strin return &result, resp, nil } -// GetWatchers wraps GetWatchersWithContext using the background context. -func (s *IssueService) GetWatchers(issueID string) (*[]User, *Response, error) { - return s.GetWatchersWithContext(context.Background(), issueID) -} - -// AddWatcherWithContext adds watcher to the given issue +// AddWatcher adds watcher to the given issue // // Jira API docs: https://docs.atlassian.com/software/jira/docs/api/REST/latest/#api/2/issue-addWatcher // Caller must close resp.Body -func (s *IssueService) AddWatcherWithContext(ctx context.Context, issueID string, userName string) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) AddWatcher(ctx context.Context, issueID string, userName string) (*Response, error) { apiEndPoint := fmt.Sprintf("rest/api/2/issue/%s/watchers", issueID) - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndPoint, userName) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndPoint, userName) if err != nil { return nil, err } @@ -1455,20 +1399,17 @@ func (s *IssueService) AddWatcherWithContext(ctx context.Context, issueID string return resp, err } -// AddWatcher wraps AddWatcherWithContext using the background context. -// Caller must close resp.Body -func (s *IssueService) AddWatcher(issueID string, userName string) (*Response, error) { - return s.AddWatcherWithContext(context.Background(), issueID, userName) -} - -// RemoveWatcherWithContext removes given user from given issue +// RemoveWatcher removes given user from given issue // // Jira API docs: https://docs.atlassian.com/software/jira/docs/api/REST/latest/#api/2/issue-removeWatcher // Caller must close resp.Body -func (s *IssueService) RemoveWatcherWithContext(ctx context.Context, issueID string, userName string) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) RemoveWatcher(ctx context.Context, issueID string, userName string) (*Response, error) { apiEndPoint := fmt.Sprintf("rest/api/2/issue/%s/watchers", issueID) - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndPoint, userName) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndPoint, userName) if err != nil { return nil, err } @@ -1481,20 +1422,17 @@ func (s *IssueService) RemoveWatcherWithContext(ctx context.Context, issueID str return resp, err } -// RemoveWatcher wraps RemoveWatcherWithContext using the background context. -// Caller must close resp.Body -func (s *IssueService) RemoveWatcher(issueID string, userName string) (*Response, error) { - return s.RemoveWatcherWithContext(context.Background(), issueID, userName) -} - -// UpdateAssigneeWithContext updates the user assigned to work on the given issue +// UpdateAssignee updates the user assigned to work on the given issue // // Jira API docs: https://docs.atlassian.com/software/jira/docs/api/REST/7.10.2/#api/2/issue-assign // Caller must close resp.Body -func (s *IssueService) UpdateAssigneeWithContext(ctx context.Context, issueID string, assignee *User) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) UpdateAssignee(ctx context.Context, issueID string, assignee *User) (*Response, error) { apiEndPoint := fmt.Sprintf("rest/api/2/issue/%s/assignee", issueID) - req, err := s.client.NewRequestWithContext(ctx, "PUT", apiEndPoint, assignee) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndPoint, assignee) if err != nil { return nil, err } @@ -1507,12 +1445,8 @@ func (s *IssueService) UpdateAssigneeWithContext(ctx context.Context, issueID st return resp, err } -// UpdateAssignee wraps UpdateAssigneeWithContext using the background context. -// Caller must close resp.Body -func (s *IssueService) UpdateAssignee(issueID string, assignee *User) (*Response, error) { - return s.UpdateAssigneeWithContext(context.Background(), issueID, assignee) -} - +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func (c ChangelogHistory) CreatedTime() (time.Time, error) { var t time.Time // Ignore null @@ -1523,12 +1457,15 @@ func (c ChangelogHistory) CreatedTime() (time.Time, error) { return t, err } -// GetRemoteLinksWithContext gets remote issue links on the issue. +// GetRemoteLinks gets remote issue links on the issue. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-getRemoteIssueLinks -func (s *IssueService) GetRemoteLinksWithContext(ctx context.Context, id string) (*[]RemoteLink, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetRemoteLinks(ctx context.Context, id string) (*[]RemoteLink, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/remotelink", id) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -1541,18 +1478,15 @@ func (s *IssueService) GetRemoteLinksWithContext(ctx context.Context, id string) return result, resp, err } -// GetRemoteLinks wraps GetRemoteLinksWithContext using the background context. -// Caller must close resp.Body -func (s *IssueService) GetRemoteLinks(id string) (*[]RemoteLink, *Response, error) { - return s.GetRemoteLinksWithContext(context.Background(), id) -} - -// AddRemoteLinkWithContext adds a remote link to issueID. +// AddRemoteLink adds a remote link to issueID. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issue-issueIdOrKey-remotelink-post -func (s *IssueService) AddRemoteLinkWithContext(ctx context.Context, issueID string, remotelink *RemoteLink) (*RemoteLink, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) AddRemoteLink(ctx context.Context, issueID string, remotelink *RemoteLink) (*RemoteLink, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/remotelink", issueID) - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, remotelink) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, remotelink) if err != nil { return nil, nil, err } @@ -1567,17 +1501,16 @@ func (s *IssueService) AddRemoteLinkWithContext(ctx context.Context, issueID str return responseRemotelink, resp, nil } -// AddRemoteLink wraps AddRemoteLinkWithContext using the background context. -func (s *IssueService) AddRemoteLink(issueID string, remotelink *RemoteLink) (*RemoteLink, *Response, error) { - return s.AddRemoteLinkWithContext(context.Background(), issueID, remotelink) -} - -// UpdateRemoteLinkWithContext updates a remote issue link by linkID. +// UpdateRemoteLink updates a remote issue link by linkID. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issue-remote-links/#api-rest-api-2-issue-issueidorkey-remotelink-linkid-put -func (s *IssueService) UpdateRemoteLinkWithContext(ctx context.Context, issueID string, linkID int, remotelink *RemoteLink) (*Response, error) { +// Caller must close Response.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) UpdateRemoteLink(ctx context.Context, issueID string, linkID int, remotelink *RemoteLink) (*Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/remotelink/%d", issueID, linkID) - req, err := s.client.NewRequestWithContext(ctx, "PUT", apiEndpoint, remotelink) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndpoint, remotelink) if err != nil { return nil, err } @@ -1590,8 +1523,3 @@ func (s *IssueService) UpdateRemoteLinkWithContext(ctx context.Context, issueID return resp, nil } - -// UpdateRemoteLink wraps UpdateRemoteLinkWithContext using the background context. -func (s *IssueService) UpdateRemoteLink(issueID string, linkID int, remotelink *RemoteLink) (*Response, error) { - return s.UpdateRemoteLinkWithContext(context.Background(), issueID, linkID, remotelink) -} diff --git a/issue_test.go b/cloud/issue_test.go similarity index 92% rename from issue_test.go rename to cloud/issue_test.go index ceeff9a8..a8ad6487 100644 --- a/issue_test.go +++ b/cloud/issue_test.go @@ -1,11 +1,12 @@ -package jira +package cloud import ( + "context" "encoding/json" "fmt" "io" - "io/ioutil" "net/http" + "os" "reflect" "strings" "testing" @@ -19,13 +20,13 @@ func TestIssueService_Get_Success(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issue/10002") fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) }) - issue, _, err := testClient.Issue.Get("10002", nil) + issue, _, err := testClient.Issue.Get(context.Background(), "10002", nil) if issue == nil { t.Error("Expected issue. Issue is nil") } @@ -38,7 +39,7 @@ func TestIssueService_Get_WithQuerySuccess(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issue/10002?expand=foo") fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) @@ -47,7 +48,7 @@ func TestIssueService_Get_WithQuerySuccess(t *testing.T) { opt := &GetQueryOptions{ Expand: "foo", } - issue, _, err := testClient.Issue.Get("10002", opt) + issue, _, err := testClient.Issue.Get(context.Background(), "10002", opt) if issue == nil { t.Error("Expected issue. Issue is nil") } @@ -60,7 +61,7 @@ func TestIssueService_Create(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/issue") w.WriteHeader(http.StatusCreated) @@ -72,7 +73,7 @@ func TestIssueService_Create(t *testing.T) { Description: "example bug report", }, } - issue, _, err := testClient.Issue.Create(i) + issue, _, err := testClient.Issue.Create(context.Background(), i) if issue == nil { t.Error("Expected issue. Issue is nil") } @@ -85,7 +86,7 @@ func TestIssueService_CreateThenGet(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/issue") w.WriteHeader(http.StatusCreated) @@ -98,7 +99,7 @@ func TestIssueService_CreateThenGet(t *testing.T) { Created: Time(time.Now()), }, } - issue, _, err := testClient.Issue.Create(i) + issue, _, err := testClient.Issue.Create(context.Background(), i) if issue == nil { t.Error("Expected issue. Issue is nil") } @@ -107,7 +108,7 @@ func TestIssueService_CreateThenGet(t *testing.T) { } testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issue/10002") bytes, err := json.Marshal(issue) @@ -120,7 +121,7 @@ func TestIssueService_CreateThenGet(t *testing.T) { } }) - issue2, _, err := testClient.Issue.Get("10002", nil) + issue2, _, err := testClient.Issue.Get(context.Background(), "10002", nil) if issue2 == nil { t.Error("Expected issue. Issue is nil") } @@ -133,7 +134,7 @@ func TestIssueService_Update(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/PROJ-9001", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "PUT") + testMethod(t, r, http.MethodPut) testRequestURL(t, r, "/rest/api/2/issue/PROJ-9001") w.WriteHeader(http.StatusNoContent) @@ -145,7 +146,7 @@ func TestIssueService_Update(t *testing.T) { Description: "example bug report", }, } - issue, _, err := testClient.Issue.Update(i) + issue, _, err := testClient.Issue.Update(context.Background(), i, nil) if issue == nil { t.Error("Expected issue. Issue is nil") } @@ -158,7 +159,7 @@ func TestIssueService_UpdateIssue(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/PROJ-9001", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "PUT") + testMethod(t, r, http.MethodPut) testRequestURL(t, r, "/rest/api/2/issue/PROJ-9001") w.WriteHeader(http.StatusNoContent) @@ -167,7 +168,7 @@ func TestIssueService_UpdateIssue(t *testing.T) { i := make(map[string]interface{}) fields := make(map[string]interface{}) i["fields"] = fields - resp, err := testClient.Issue.UpdateIssue(jID, i) + resp, err := testClient.Issue.UpdateIssue(context.Background(), jID, i) if resp == nil { t.Error("Expected resp. resp is nil") } @@ -181,7 +182,7 @@ func TestIssueService_AddComment(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10000/comment", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/issue/10000/comment") w.WriteHeader(http.StatusCreated) @@ -195,7 +196,7 @@ func TestIssueService_AddComment(t *testing.T) { Value: "Administrators", }, } - comment, _, err := testClient.Issue.AddComment("10000", c) + comment, _, err := testClient.Issue.AddComment(context.Background(), "10000", c) if comment == nil { t.Error("Expected Comment. Comment is nil") } @@ -208,7 +209,7 @@ func TestIssueService_UpdateComment(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10000/comment/10001", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "PUT") + testMethod(t, r, http.MethodPut) testRequestURL(t, r, "/rest/api/2/issue/10000/comment/10001") w.WriteHeader(http.StatusCreated) @@ -223,7 +224,7 @@ func TestIssueService_UpdateComment(t *testing.T) { Value: "Administrators", }, } - comment, _, err := testClient.Issue.UpdateComment("10000", c) + comment, _, err := testClient.Issue.UpdateComment(context.Background(), "10000", c) if comment == nil { t.Error("Expected Comment. Comment is nil") } @@ -236,14 +237,14 @@ func TestIssueService_DeleteComment(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10000/comment/10001", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/api/2/issue/10000/comment/10001") w.WriteHeader(http.StatusNoContent) fmt.Fprint(w, `{}`) }) - err := testClient.Issue.DeleteComment("10000", "10001") + err := testClient.Issue.DeleteComment(context.Background(), "10000", "10001") if err != nil { t.Errorf("Error given: %s", err) } @@ -253,7 +254,7 @@ func TestIssueService_AddWorklogRecord(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10000/worklog", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/issue/10000/worklog") w.WriteHeader(http.StatusCreated) @@ -262,7 +263,7 @@ func TestIssueService_AddWorklogRecord(t *testing.T) { r := &WorklogRecord{ TimeSpent: "1h", } - record, _, err := testClient.Issue.AddWorklogRecord("10000", r) + record, _, err := testClient.Issue.AddWorklogRecord(context.Background(), "10000", r) if record == nil { t.Error("Expected Record. Record is nil") } @@ -275,7 +276,7 @@ func TestIssueService_UpdateWorklogRecord(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10000/worklog/1", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "PUT") + testMethod(t, r, http.MethodPut) testRequestURL(t, r, "/rest/api/2/issue/10000/worklog/1") w.WriteHeader(http.StatusOK) @@ -284,7 +285,7 @@ func TestIssueService_UpdateWorklogRecord(t *testing.T) { r := &WorklogRecord{ TimeSpent: "1h", } - record, _, err := testClient.Issue.UpdateWorklogRecord("10000", "1", r) + record, _, err := testClient.Issue.UpdateWorklogRecord(context.Background(), "10000", "1", r) if record == nil { t.Error("Expected Record. Record is nil") } @@ -297,7 +298,7 @@ func TestIssueService_AddLink(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issueLink", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/issueLink") w.WriteHeader(http.StatusOK) @@ -321,7 +322,7 @@ func TestIssueService_AddLink(t *testing.T) { }, }, } - resp, err := testClient.Issue.AddLink(il) + resp, err := testClient.Issue.AddLink(context.Background(), il) if err != nil { t.Errorf("Error given: %s", err) } @@ -338,13 +339,13 @@ func TestIssueService_Get_Fields(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issue/10002") fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"labels":["test"],"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"epic": {"id": 19415,"key": "EPIC-77","self": "https://example.atlassian.net/rest/agile/1.0/epic/19415","name": "Epic Name","summary": "Do it","color": {"key": "color_11"},"done": false},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) }) - issue, _, err := testClient.Issue.Get("10002", nil) + issue, _, err := testClient.Issue.Get(context.Background(), "10002", nil) if err != nil { t.Errorf("Error given: %s", err) } @@ -368,13 +369,13 @@ func TestIssueService_Get_RenderedFields(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issue/10002") fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"labels":["test"],"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"epic": {"id": 19415,"key": "EPIC-77","self": "https://example.atlassian.net/rest/agile/1.0/epic/19415","name": "Epic Name","summary": "Do it","color": {"key": "color_11"},"done": false},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{},"renderedFields":{"resolutiondate":"In 1 week","updated":"2 hours ago","comment":{"comments":[{"body":"This is HTML"}]}}}`) }) - issue, _, err := testClient.Issue.Get("10002", nil) + issue, _, err := testClient.Issue.Get(context.Background(), "10002", nil) if err != nil { t.Errorf("Error given: %s", err) } @@ -401,14 +402,14 @@ func TestIssueService_DownloadAttachment(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/secure/attachment/", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/secure/attachment/10000/") w.WriteHeader(http.StatusOK) w.Write([]byte(testAttachment)) }) - resp, err := testClient.Issue.DownloadAttachment("10000") + resp, err := testClient.Issue.DownloadAttachment(context.Background(), "10000") if err != nil { t.Errorf("Error given: %s", err) } @@ -418,7 +419,7 @@ func TestIssueService_DownloadAttachment(t *testing.T) { } defer resp.Body.Close() - attachment, err := ioutil.ReadAll(resp.Body) + attachment, err := io.ReadAll(resp.Body) if err != nil { t.Error("Expected attachment text", err) } @@ -436,13 +437,13 @@ func TestIssueService_DownloadAttachment_BadStatus(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/secure/attachment/", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/secure/attachment/10000/") w.WriteHeader(http.StatusForbidden) }) - resp, err := testClient.Issue.DownloadAttachment("10000") + resp, err := testClient.Issue.DownloadAttachment(context.Background(), "10000") if resp == nil { t.Error("Expected response. Response is nil") return @@ -463,7 +464,7 @@ func TestIssueService_PostAttachment(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10000/attachments", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/issue/10000/attachments") status := http.StatusOK @@ -477,7 +478,7 @@ func TestIssueService_PostAttachment(t *testing.T) { status = http.StatusNoContent } else { // Read the file into memory - data, err := ioutil.ReadAll(file) + data, err := io.ReadAll(file) if err != nil { status = http.StatusInternalServerError } @@ -491,7 +492,7 @@ func TestIssueService_PostAttachment(t *testing.T) { reader := strings.NewReader(testAttachment) - issue, resp, err := testClient.Issue.PostAttachment("10000", reader, "attachment") + issue, resp, err := testClient.Issue.PostAttachment(context.Background(), "10000", reader, "attachment") if issue == nil { t.Error("Expected response. Response is nil") @@ -512,13 +513,13 @@ func TestIssueService_PostAttachment_NoResponse(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10000/attachments", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/issue/10000/attachments") w.WriteHeader(http.StatusOK) }) reader := strings.NewReader(testAttachment) - _, _, err := testClient.Issue.PostAttachment("10000", reader, "attachment") + _, _, err := testClient.Issue.PostAttachment(context.Background(), "10000", reader, "attachment") if err == nil { t.Errorf("Error expected: %s", err) @@ -531,14 +532,14 @@ func TestIssueService_PostAttachment_NoFilename(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10000/attachments", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/issue/10000/attachments") w.WriteHeader(http.StatusOK) fmt.Fprint(w, `[{"self":"http://jira/jira/rest/api/2/attachment/228924","id":"228924","filename":"example.jpg","author":{"self":"http://jira/jira/rest/api/2/user?username=test","name":"test","emailAddress":"test@test.com","avatarUrls":{"16x16":"http://jira/jira/secure/useravatar?size=small&avatarId=10082","48x48":"http://jira/jira/secure/useravatar?avatarId=10082"},"displayName":"Tester","active":true},"created":"2016-05-24T00:25:17.000-0700","size":32280,"mimeType":"image/jpeg","content":"http://jira/jira/secure/attachment/228924/example.jpg","thumbnail":"http://jira/jira/secure/thumbnail/228924/_thumb_228924.png"}]`) }) reader := strings.NewReader(testAttachment) - _, _, err := testClient.Issue.PostAttachment("10000", reader, "") + _, _, err := testClient.Issue.PostAttachment(context.Background(), "10000", reader, "") if err != nil { t.Errorf("Error expected: %s", err) @@ -549,13 +550,13 @@ func TestIssueService_PostAttachment_NoAttachment(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10000/attachments", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/issue/10000/attachments") w.WriteHeader(http.StatusOK) fmt.Fprint(w, `[{"self":"http://jira/jira/rest/api/2/attachment/228924","id":"228924","filename":"example.jpg","author":{"self":"http://jira/jira/rest/api/2/user?username=test","name":"test","emailAddress":"test@test.com","avatarUrls":{"16x16":"http://jira/jira/secure/useravatar?size=small&avatarId=10082","48x48":"http://jira/jira/secure/useravatar?avatarId=10082"},"displayName":"Tester","active":true},"created":"2016-05-24T00:25:17.000-0700","size":32280,"mimeType":"image/jpeg","content":"http://jira/jira/secure/attachment/228924/example.jpg","thumbnail":"http://jira/jira/secure/thumbnail/228924/_thumb_228924.png"}]`) }) - _, _, err := testClient.Issue.PostAttachment("10000", nil, "attachment") + _, _, err := testClient.Issue.PostAttachment(context.Background(), "10000", nil, "attachment") if err != nil { t.Errorf("Error given: %s", err) @@ -566,14 +567,14 @@ func TestIssueService_DeleteAttachment(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/attachment/10054", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/api/2/attachment/10054") w.WriteHeader(http.StatusNoContent) fmt.Fprint(w, `{}`) }) - resp, err := testClient.Issue.DeleteAttachment("10054") + resp, err := testClient.Issue.DeleteAttachment(context.Background(), "10054") if resp.StatusCode != 204 { t.Error("Expected attachment not deleted.") if resp.StatusCode == 403 { @@ -593,14 +594,14 @@ func TestIssueService_DeleteLink(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issueLink/10054", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/api/2/issueLink/10054") w.WriteHeader(http.StatusNoContent) fmt.Fprint(w, `{}`) }) - resp, err := testClient.Issue.DeleteLink("10054") + resp, err := testClient.Issue.DeleteLink(context.Background(), "10054") if resp.StatusCode != 204 { t.Error("Expected link not deleted.") if resp.StatusCode == 403 { @@ -620,14 +621,14 @@ func TestIssueService_Search(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/search?expand=foo&jql=type+%3D+Bug+and+Status+NOT+IN+%28Resolved%29&maxResults=40&startAt=1") w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"expand": "schema,names","startAt": 1,"maxResults": 40,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) }) opt := &SearchOptions{StartAt: 1, MaxResults: 40, Expand: "foo"} - _, resp, err := testClient.Issue.Search("type = Bug and Status NOT IN (Resolved)", opt) + _, resp, err := testClient.Issue.Search(context.Background(), "type = Bug and Status NOT IN (Resolved)", opt) if resp == nil { t.Errorf("Response given: %+v", resp) @@ -651,14 +652,14 @@ func TestIssueService_SearchEmptyJQL(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/search?expand=foo&maxResults=40&startAt=1") w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"expand": "schema,names","startAt": 1,"maxResults": 40,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) }) opt := &SearchOptions{StartAt: 1, MaxResults: 40, Expand: "foo"} - _, resp, err := testClient.Issue.Search("", opt) + _, resp, err := testClient.Issue.Search(context.Background(), "", opt) if resp == nil { t.Errorf("Response given: %+v", resp) @@ -682,12 +683,12 @@ func TestIssueService_Search_WithoutPaging(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/search?jql=something") w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"expand": "schema,names","startAt": 0,"maxResults": 50,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) }) - _, resp, err := testClient.Issue.Search("something", nil) + _, resp, err := testClient.Issue.Search(context.Background(), "something", nil) if resp == nil { t.Errorf("Response given: %+v", resp) @@ -711,7 +712,7 @@ func TestIssueService_SearchPages(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) if r.URL.String() == "/rest/api/2/search?expand=foo&jql=something&maxResults=2&startAt=1&validateQuery=warn" { w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"expand": "schema,names","startAt": 1,"maxResults": 2,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) @@ -731,7 +732,7 @@ func TestIssueService_SearchPages(t *testing.T) { opt := &SearchOptions{StartAt: 1, MaxResults: 2, Expand: "foo", ValidateQuery: "warn"} issues := make([]Issue, 0) - err := testClient.Issue.SearchPages("something", opt, func(issue Issue) error { + err := testClient.Issue.SearchPages(context.Background(), "something", opt, func(issue Issue) error { issues = append(issues, issue) return nil }) @@ -749,7 +750,7 @@ func TestIssueService_SearchPages_EmptyResult(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) if r.URL.String() == "/rest/api/2/search?expand=foo&jql=something&maxResults=50&startAt=1&validateQuery=warn" { w.WriteHeader(http.StatusOK) // This is what Jira outputs when the &maxResult= issue occurs. It used to cause SearchPages to go into an endless loop. @@ -762,7 +763,7 @@ func TestIssueService_SearchPages_EmptyResult(t *testing.T) { opt := &SearchOptions{StartAt: 1, MaxResults: 50, Expand: "foo", ValidateQuery: "warn"} issues := make([]Issue, 0) - err := testClient.Issue.SearchPages("something", opt, func(issue Issue) error { + err := testClient.Issue.SearchPages(context.Background(), "something", opt, func(issue Issue) error { issues = append(issues, issue) return nil }) @@ -777,12 +778,12 @@ func TestIssueService_GetCustomFields(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issue/10002") fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"customfield_123":"test","watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) }) - issue, _, err := testClient.Issue.GetCustomFields("10002") + issue, _, err := testClient.Issue.GetCustomFields(context.Background(), "10002") if err != nil { t.Errorf("Error given: %s", err) } @@ -799,12 +800,12 @@ func TestIssueService_GetComplexCustomFields(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issue/10002") fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"customfield_123":{"self":"http://www.example.com/jira/rest/api/2/customFieldOption/123","value":"test","id":"123"},"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) }) - issue, _, err := testClient.Issue.GetCustomFields("10002") + issue, _, err := testClient.Issue.GetCustomFields(context.Background(), "10002") if err != nil { t.Errorf("Error given: %s", err) } @@ -823,18 +824,18 @@ func TestIssueService_GetTransitions(t *testing.T) { testAPIEndpoint := "/rest/api/2/issue/123/transitions" - raw, err := ioutil.ReadFile("./mocks/transitions.json") + raw, err := os.ReadFile("../testing/mock-data/transitions.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, string(raw)) }) - transitions, _, err := testClient.Issue.GetTransitions("123") + transitions, _, err := testClient.Issue.GetTransitions(context.Background(), "123") if err != nil { t.Errorf("Got error: %v", err) @@ -862,7 +863,7 @@ func TestIssueService_DoTransition(t *testing.T) { transitionID := "22" testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, testAPIEndpoint) decoder := json.NewDecoder(r.Body) @@ -876,7 +877,7 @@ func TestIssueService_DoTransition(t *testing.T) { t.Errorf("Expected %s to be in payload, got %s instead", transitionID, payload.Transition.ID) } }) - _, err := testClient.Issue.DoTransition("123", transitionID) + _, err := testClient.Issue.DoTransition(context.Background(), "123", transitionID) if err != nil { t.Errorf("Got error: %v", err) @@ -907,7 +908,7 @@ func TestIssueService_DoTransitionWithPayload(t *testing.T) { } testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, testAPIEndpoint) decoder := json.NewDecoder(r.Body) @@ -935,7 +936,7 @@ func TestIssueService_DoTransitionWithPayload(t *testing.T) { t.Errorf("Expected %s to be in payload, got %s instead", transitionID, transition["id"]) } }) - _, err := testClient.Issue.DoTransitionWithPayload("123", customPayload) + _, err := testClient.Issue.DoTransitionWithPayload(context.Background(), "123", customPayload) if err != nil { t.Errorf("Got error: %v", err) @@ -944,70 +945,69 @@ func TestIssueService_DoTransitionWithPayload(t *testing.T) { func TestIssueFields_TestMarshalJSON_PopulateUnknownsSuccess(t *testing.T) { data := `{ - "customfield_123":"test", - "description":"example bug report", - "project":{ - "self":"http://www.example.com/jira/rest/api/2/project/EX", + "customfield_123":"test", + "description":"example bug report", + "project":{ + "self":"http://www.example.com/jira/rest/api/2/project/EX", + "id":"10000", + "key":"EX", + "name":"Example", + "avatarUrls":{ + "48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000", + "24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000", + "16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000", + "32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000" + }, + "projectCategory":{ + "self":"http://www.example.com/jira/rest/api/2/projectCategory/10000", + "id":"10000", + "name":"FIRST", + "description":"First Project Category" + } + }, + "issuelinks":[ + { + "id":"10001", + "type":{ "id":"10000", - "key":"EX", - "name":"Example", - "avatarUrls":{ - "48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000", - "24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000", - "16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000", - "32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000" + "name":"Dependent", + "inward":"depends on", + "outward":"is depended by" }, - "projectCategory":{ - "self":"http://www.example.com/jira/rest/api/2/projectCategory/10000", - "id":"10000", - "name":"FIRST", - "description":"First Project Category" + "outwardIssue":{ + "id":"10004L", + "key":"PRJ-2", + "self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2", + "fields":{ + "status":{ + "iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png", + "name":"Open" + } + } } }, - "issuelinks":[ - { - "id":"10001", - "type":{ - "id":"10000", - "name":"Dependent", - "inward":"depends on", - "outward":"is depended by" - }, - "outwardIssue":{ - "id":"10004L", - "key":"PRJ-2", - "self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2", - "fields":{ - "status":{ - "iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png", - "name":"Open" - } - } - } + { + "id":"10002", + "type":{ + "id":"10000", + "name":"Dependent", + "inward":"depends on", + "outward":"is depended by" }, - { - "id":"10002", - "type":{ - "id":"10000", - "name":"Dependent", - "inward":"depends on", - "outward":"is depended by" - }, - "inwardIssue":{ - "id":"10004", - "key":"PRJ-3", - "self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3", - "fields":{ - "status":{ - "iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png", - "name":"Open" - } - } + "inwardIssue":{ + "id":"10004", + "key":"PRJ-3", + "self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3", + "fields":{ + "status":{ + "iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png", + "name":"Open" } } - ] - - }` + } + } + ] + }` i := new(IssueFields) err := json.Unmarshal([]byte(data), i) @@ -1442,14 +1442,14 @@ func TestIssueService_Delete(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/api/2/issue/10002") w.WriteHeader(http.StatusNoContent) fmt.Fprint(w, `{}`) }) - resp, err := testClient.Issue.Delete("10002") + resp, err := testClient.Issue.Delete(context.Background(), "10002") if resp.StatusCode != 204 { t.Error("Expected issue not deleted.") } @@ -1558,7 +1558,7 @@ func TestIssueService_GetWorklogs(t *testing.T) { t.Run(tc.name, func(t *testing.T) { uri := fmt.Sprintf(tc.uri, tc.issueId) testMux.HandleFunc(uri, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, uri) _, _ = fmt.Fprint(w, tc.response) }) @@ -1567,9 +1567,9 @@ func TestIssueService_GetWorklogs(t *testing.T) { var err error if tc.option != nil { - worklog, _, err = testClient.Issue.GetWorklogs(tc.issueId, WithQueryOptions(tc.option)) + worklog, _, err = testClient.Issue.GetWorklogs(context.Background(), tc.issueId, WithQueryOptions(tc.option)) } else { - worklog, _, err = testClient.Issue.GetWorklogs(tc.issueId) + worklog, _, err = testClient.Issue.GetWorklogs(context.Background(), tc.issueId) } if err != nil && !cmp.Equal(err, tc.err) { @@ -1587,14 +1587,14 @@ func TestIssueService_GetWatchers(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002/watchers", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issue/10002/watchers") fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000","accountId": "000000000000000000000000","displayName":"Fred F. User","active":false}]}`) }) testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/user?accountId=000000000000000000000000") fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000","key":"fred","accountId": "000000000000000000000000", @@ -1606,7 +1606,7 @@ func TestIssueService_GetWatchers(t *testing.T) { }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}`) }) - watchers, _, err := testClient.Issue.GetWatchers("10002") + watchers, _, err := testClient.Issue.GetWatchers(context.Background(), "10002") if err != nil { t.Errorf("Error given: %s", err) return @@ -1628,14 +1628,14 @@ func TestIssueService_DeprecatedGetWatchers(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002/watchers", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issue/10002/watchers") fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000", "accountId": "000000000000000000000000", "displayName":"Fred F. User","active":false}]}`) }) testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/user?accountId=000000000000000000000000") fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000", "accountId": "000000000000000000000000", "key": "", "name": "", "emailAddress":"fred@example.com","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", @@ -1646,7 +1646,7 @@ func TestIssueService_DeprecatedGetWatchers(t *testing.T) { }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}`) }) - watchers, _, err := testClient.Issue.GetWatchers("10002") + watchers, _, err := testClient.Issue.GetWatchers(context.Background(), "10002") if err != nil { t.Errorf("Error given: %s", err) return @@ -1668,13 +1668,13 @@ func TestIssueService_UpdateAssignee(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002/assignee", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "PUT") + testMethod(t, r, http.MethodPut) testRequestURL(t, r, "/rest/api/2/issue/10002/assignee") w.WriteHeader(http.StatusNoContent) }) - resp, err := testClient.Issue.UpdateAssignee("10002", &User{ + resp, err := testClient.Issue.UpdateAssignee(context.Background(), "10002", &User{ Name: "test-username", }) @@ -1690,13 +1690,13 @@ func TestIssueService_Get_Fields_Changelog(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issue/10002") fmt.Fprint(w, `{"expand":"changelog","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","changelog":{"startAt": 0,"maxResults": 1, "total": 1, "histories": [{"id": "10002", "author": {"self": "http://www.example.com/jira/rest/api/2/user?username=fred", "name": "fred", "key": "fred", "emailAddress": "fred@example.com", "avatarUrls": {"48x48": "http://www.example.com/secure/useravatar?ownerId=fred&avatarId=33072", "24x24": "http://www.example.com/secure/useravatar?size=small&ownerId=fred&avatarId=33072", "16x16": "http://www.example.com/secure/useravatar?size=xsmall&ownerId=fred&avatarId=33072", "32x32": "http://www.example.com/secure/useravatar?size=medium&ownerId=fred&avatarId=33072"},"displayName":"Fred","active": true,"timeZone":"Australia/Sydney"},"created":"2018-06-20T16:50:35.000+0300","items":[{"field":"Rank","fieldtype":"custom","from":"","fromString":"","to":"","toString":"Ranked higher"}]}]}}`) }) - issue, _, _ := testClient.Issue.Get("10002", &GetQueryOptions{Expand: "changelog"}) + issue, _, _ := testClient.Issue.Get(context.Background(), "10002", &GetQueryOptions{Expand: "changelog"}) if issue == nil { t.Error("Expected issue. Issue is nil") return @@ -1721,13 +1721,13 @@ func TestIssueService_Get_Transitions(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issue/10002") fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/api/latest/issue/10002","key":"EX-1","transitions":[{"id":"121","name":"Start","to":{"self":"http://www.example.com/rest/api/2/status/10444","description":"","iconUrl":"http://www.example.com/images/icons/statuses/inprogress.png","name":"In progress","id":"10444","statusCategory":{"self":"http://www.example.com/rest/api/2/statuscategory/4","id":4,"key":"indeterminate","colorName":"yellow","name":"In Progress"}}}]}`) }) - issue, _, _ := testClient.Issue.Get("10002", &GetQueryOptions{Expand: "transitions"}) + issue, _, _ := testClient.Issue.Get(context.Background(), "10002", &GetQueryOptions{Expand: "transitions"}) if issue == nil { t.Error("Expected issue. Issue is nil") return @@ -1752,13 +1752,13 @@ func TestIssueService_Get_Fields_AffectsVersions(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issue/10002") fmt.Fprint(w, `{"fields":{"versions":[{"self":"http://www.example.com/jira/rest/api/2/version/10705","id":"10705","description":"test description","name":"2.1.0-rc3","archived":false,"released":false,"releaseDate":"2018-09-30"}]}}`) }) - issue, _, err := testClient.Issue.Get("10002", nil) + issue, _, err := testClient.Issue.Get(context.Background(), "10002", nil) if err != nil { t.Errorf("Error given: %s", err) } @@ -1787,18 +1787,18 @@ func TestIssueService_GetRemoteLinks(t *testing.T) { testAPIEndpoint := "/rest/api/2/issue/123/remotelink" - raw, err := ioutil.ReadFile("./mocks/remote_links.json") + raw, err := os.ReadFile("../testing/mock-data/remote_links.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, string(raw)) }) - remoteLinks, _, err := testClient.Issue.GetRemoteLinks("123") + remoteLinks, _, err := testClient.Issue.GetRemoteLinks(context.Background(), "123") if err != nil { t.Errorf("Got error: %v", err) } @@ -1821,7 +1821,7 @@ func TestIssueService_AddRemoteLink(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/10000/remotelink", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/issue/10000/remotelink") w.WriteHeader(http.StatusCreated) @@ -1852,7 +1852,7 @@ func TestIssueService_AddRemoteLink(t *testing.T) { }, }, } - record, _, err := testClient.Issue.AddRemoteLink("10000", r) + record, _, err := testClient.Issue.AddRemoteLink(context.Background(), "10000", r) if record == nil { t.Error("Expected Record. Record is nil") } @@ -1865,7 +1865,7 @@ func TestIssueService_UpdateRemoteLink(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issue/100/remotelink/200", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "PUT") + testMethod(t, r, http.MethodPut) testRequestURL(t, r, "/rest/api/2/issue/100/remotelink/200") w.WriteHeader(http.StatusNoContent) @@ -1895,7 +1895,7 @@ func TestIssueService_UpdateRemoteLink(t *testing.T) { }, }, } - _, err := testClient.Issue.UpdateRemoteLink("100", 200, r) + _, err := testClient.Issue.UpdateRemoteLink(context.Background(), "100", 200, r) if err != nil { t.Errorf("Error given: %s", err) } diff --git a/cloud/issuelinktype.go b/cloud/issuelinktype.go new file mode 100644 index 00000000..f50e5430 --- /dev/null +++ b/cloud/issuelinktype.go @@ -0,0 +1,122 @@ +package cloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// IssueLinkTypeService handles issue link types for the Jira instance / API. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-group-Issue-link-types +type IssueLinkTypeService service + +// GetList gets all of the issue link types from Jira. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueLinkTypeService) GetList(ctx context.Context) ([]IssueLinkType, *Response, error) { + apiEndpoint := "rest/api/2/issueLinkType" + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + linkTypeList := []IssueLinkType{} + resp, err := s.client.Do(req, &linkTypeList) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + return linkTypeList, resp, nil +} + +// Get gets info of a specific issue link type from Jira. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-issueLinkTypeId-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueLinkTypeService) Get(ctx context.Context, ID string) (*IssueLinkType, *Response, error) { + apiEndPoint := fmt.Sprintf("rest/api/2/issueLinkType/%s", ID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) + if err != nil { + return nil, nil, err + } + + linkType := new(IssueLinkType) + resp, err := s.client.Do(req, linkType) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + return linkType, resp, nil +} + +// Create creates an issue link type in Jira. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-post +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueLinkTypeService) Create(ctx context.Context, linkType *IssueLinkType) (*IssueLinkType, *Response, error) { + apiEndpoint := "/rest/api/2/issueLinkType" + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, linkType) + if err != nil { + return nil, nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + return nil, resp, err + } + defer resp.Body.Close() + + responseLinkType := new(IssueLinkType) + err = json.NewDecoder(resp.Body).Decode(&responseLinkType) + if err != nil { + return nil, resp, err + } + + return linkType, resp, nil +} + +// Update updates an issue link type. The issue is found by key. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-issueLinkTypeId-put +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueLinkTypeService) Update(ctx context.Context, linkType *IssueLinkType) (*IssueLinkType, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issueLinkType/%s", linkType.ID) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndpoint, linkType) + if err != nil { + return nil, nil, err + } + resp, err := s.client.Do(req, nil) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + ret := *linkType + return &ret, resp, nil +} + +// Delete deletes an issue link type based on provided ID. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-issueLinkTypeId-delete +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueLinkTypeService) Delete(ctx context.Context, ID string) (*Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issueLinkType/%s", ID) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + return resp, err +} diff --git a/issuelinktype_test.go b/cloud/issuelinktype_test.go similarity index 78% rename from issuelinktype_test.go rename to cloud/issuelinktype_test.go index 56786629..1bcbbc67 100644 --- a/issuelinktype_test.go +++ b/cloud/issuelinktype_test.go @@ -1,9 +1,10 @@ -package jira +package cloud import ( + "context" "fmt" - "io/ioutil" "net/http" + "os" "testing" ) @@ -12,17 +13,17 @@ func TestIssueLinkTypeService_GetList(t *testing.T) { defer teardown() testAPIEndpoint := "/rest/api/2/issueLinkType" - raw, err := ioutil.ReadFile("./mocks/all_issuelinktypes.json") + raw, err := os.ReadFile("../testing/mock-data/all_issuelinktypes.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, string(raw)) }) - linkTypes, _, err := testClient.IssueLinkType.GetList() + linkTypes, _, err := testClient.IssueLinkType.GetList(context.Background()) if linkTypes == nil { t.Error("Expected issueLinkType list. LinkTypes is nil") } @@ -35,14 +36,14 @@ func TestIssueLinkTypeService_Get(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issueLinkType/123", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/issueLinkType/123") fmt.Fprint(w, `{"id": "123","name": "Blocked","inward": "Blocked","outward": "Blocked", "self": "https://www.example.com/jira/rest/api/2/issueLinkType/123"}`) }) - if linkType, _, err := testClient.IssueLinkType.Get("123"); err != nil { + if linkType, _, err := testClient.IssueLinkType.Get(context.Background(), "123"); err != nil { t.Errorf("Error given: %s", err) } else if linkType == nil { t.Error("Expected linkType. LinkType is nil") @@ -53,7 +54,7 @@ func TestIssueLinkTypeService_Create(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issueLinkType", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/issueLinkType") w.WriteHeader(http.StatusCreated) @@ -67,7 +68,7 @@ func TestIssueLinkTypeService_Create(t *testing.T) { Outward: "causes", } - if linkType, _, err := testClient.IssueLinkType.Create(lt); err != nil { + if linkType, _, err := testClient.IssueLinkType.Create(context.Background(), lt); err != nil { t.Errorf("Error given: %s", err) } else if linkType == nil { t.Error("Expected linkType. LinkType is nil") @@ -78,7 +79,7 @@ func TestIssueLinkTypeService_Update(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issueLinkType/100", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "PUT") + testMethod(t, r, http.MethodPut) testRequestURL(t, r, "/rest/api/2/issueLinkType/100") w.WriteHeader(http.StatusNoContent) @@ -91,7 +92,7 @@ func TestIssueLinkTypeService_Update(t *testing.T) { Outward: "causes", } - if linkType, _, err := testClient.IssueLinkType.Update(lt); err != nil { + if linkType, _, err := testClient.IssueLinkType.Update(context.Background(), lt); err != nil { t.Errorf("Error given: %s", err) } else if linkType == nil { t.Error("Expected linkType. LinkType is nil") @@ -102,13 +103,13 @@ func TestIssueLinkTypeService_Delete(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/issueLinkType/100", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/api/2/issueLinkType/100") w.WriteHeader(http.StatusNoContent) }) - resp, err := testClient.IssueLinkType.Delete("100") + resp, err := testClient.IssueLinkType.Delete(context.Background(), "100") if resp.StatusCode != http.StatusNoContent { t.Error("Expected issue not deleted.") } diff --git a/cloud/jira.go b/cloud/jira.go new file mode 100644 index 00000000..e0b9bfb3 --- /dev/null +++ b/cloud/jira.go @@ -0,0 +1,299 @@ +package cloud + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strings" + "sync" + + "github.com/google/go-querystring/query" +) + +const ( + ClientVersion = "2.0.0" + + defaultUserAgent = "go-jira" + "/" + ClientVersion +) + +// A Client manages communication with the Jira API. +type Client struct { + clientMu sync.Mutex // clientMu protects the client during calls that modify it. + client *http.Client // HTTP client used to communicate with the API. + + // Base URL for API requests. + // Should be set to a domain endpoint of the Jira instance. + // BaseURL should always be specified with a trailing slash. + BaseURL *url.URL + + // User agent used when communicating with the Jira API. + UserAgent string + + // Reuse a single struct instead of allocating one for each service on the heap. + common service + + // Services used for talking to different parts of the Jira API. + Issue *IssueService + Project *ProjectService + Board *BoardService + Sprint *SprintService + User *UserService + Group *GroupService + Version *VersionService + Priority *PriorityService + Field *FieldService + Component *ComponentService + Resolution *ResolutionService + StatusCategory *StatusCategoryService + Filter *FilterService + Role *RoleService + PermissionScheme *PermissionSchemeService + Status *StatusService + IssueLinkType *IssueLinkTypeService + Organization *OrganizationService + ServiceDesk *ServiceDeskService + Customer *CustomerService + Request *RequestService +} + +// service is the base structure to bundle API services +// under a sub-struct. +type service struct { + client *Client +} + +// Client returns the http.Client used by this Jira client. +func (c *Client) Client() *http.Client { + c.clientMu.Lock() + defer c.clientMu.Unlock() + clientCopy := *c.client + return &clientCopy +} + +// NewClient returns a new Jira API client with provided base URL (often is your Jira hostname) +// If a nil httpClient is provided, a new http.Client will be used. +// To use API methods which require authentication, provide an http.Client that will perform the authentication for you (such as that provided by the golang.org/x/oauth2 library). +// baseURL is the HTTP endpoint of your Jira instance and should always be specified with a trailing slash. +func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { + if httpClient == nil { + httpClient = &http.Client{} + } + + baseEndpoint, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + // ensure the baseURL contains a trailing slash so that all paths are preserved in later calls + if !strings.HasSuffix(baseEndpoint.Path, "/") { + baseEndpoint.Path += "/" + } + + c := &Client{ + client: httpClient, + BaseURL: baseEndpoint, + UserAgent: defaultUserAgent, + } + c.common.client = c + + c.Issue = (*IssueService)(&c.common) + c.Project = (*ProjectService)(&c.common) + c.Board = (*BoardService)(&c.common) + c.Sprint = (*SprintService)(&c.common) + c.User = (*UserService)(&c.common) + c.Group = (*GroupService)(&c.common) + c.Version = (*VersionService)(&c.common) + c.Priority = (*PriorityService)(&c.common) + c.Field = (*FieldService)(&c.common) + c.Component = (*ComponentService)(&c.common) + c.Resolution = (*ResolutionService)(&c.common) + c.StatusCategory = (*StatusCategoryService)(&c.common) + c.Filter = (*FilterService)(&c.common) + c.Role = (*RoleService)(&c.common) + c.PermissionScheme = (*PermissionSchemeService)(&c.common) + c.Status = (*StatusService)(&c.common) + c.IssueLinkType = (*IssueLinkTypeService)(&c.common) + c.Organization = (*OrganizationService)(&c.common) + c.ServiceDesk = (*ServiceDeskService)(&c.common) + c.Customer = (*CustomerService)(&c.common) + c.Request = (*RequestService)(&c.common) + + return c, nil +} + +// TODO Do we need it? +// NewRawRequest creates an API request. +// A relative URL can be provided in urlStr, in which case it is resolved relative to the baseURL of the Client. +// Allows using an optional native io.Reader for sourcing the request body. +func (c *Client) NewRawRequest(ctx context.Context, method, urlStr string, body io.Reader) (*http.Request, error) { + rel, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + // Relative URLs should be specified without a preceding slash since baseURL will have the trailing slash + rel.Path = strings.TrimLeft(rel.Path, "/") + + u := c.BaseURL.ResolveReference(rel) + + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + return req, nil +} + +// NewRequest creates an API request. +// A relative URL can be provided in urlStr, in which case it is resolved relative to the BaseURL of the Client. +// If specified, the value pointed to by body is JSON encoded and included as the request body. +func (c *Client) NewRequest(ctx context.Context, method, urlStr string, body interface{}) (*http.Request, error) { + rel, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + // Relative URLs should be specified without a preceding slash since BaseURL will have the trailing slash + rel.Path = strings.TrimLeft(rel.Path, "/") + + u := c.BaseURL.ResolveReference(rel) + + // TODO This part is the difference between NewRawRequestWithContext + // Check if we can get this working in one function + var buf io.ReadWriter + if body != nil { + buf = new(bytes.Buffer) + err = json.NewEncoder(buf).Encode(body) + if err != nil { + return nil, err + } + } + + req, err := http.NewRequestWithContext(ctx, method, u.String(), buf) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + return req, nil +} + +// addOptions adds the parameters in opts as URL query parameters to s. opts +// must be a struct whose fields may contain "url" tags. +func addOptions(s string, opts interface{}) (string, error) { + v := reflect.ValueOf(opts) + if v.Kind() == reflect.Ptr && v.IsNil() { + return s, nil + } + + u, err := url.Parse(s) + if err != nil { + return s, err + } + + qs, err := query.Values(opts) + if err != nil { + return s, err + } + + u.RawQuery = qs.Encode() + return u.String(), nil +} + +// NewMultiPartRequest creates an API request including a multi-part file. +// A relative URL can be provided in urlStr, in which case it is resolved relative to the baseURL of the Client. +// If specified, the value pointed to by buf is a multipart form. +func (c *Client) NewMultiPartRequest(ctx context.Context, method, urlStr string, buf *bytes.Buffer) (*http.Request, error) { + rel, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + // Relative URLs should be specified without a preceding slash since baseURL will have the trailing slash + rel.Path = strings.TrimLeft(rel.Path, "/") + + u := c.BaseURL.ResolveReference(rel) + + req, err := http.NewRequestWithContext(ctx, method, u.String(), buf) + if err != nil { + return nil, err + } + + // Set required headers + req.Header.Set("X-Atlassian-Token", "nocheck") + + return req, nil +} + +// Do sends an API request and returns the API response. +// The API response is JSON decoded and stored in the value pointed to by v, or returned as an error if an API error has occurred. +func (c *Client) Do(req *http.Request, v interface{}) (*Response, error) { + httpResp, err := c.client.Do(req) + if err != nil { + return nil, err + } + + err = CheckResponse(httpResp) + if err != nil { + // Even though there was an error, we still return the response + // in case the caller wants to inspect it further + return newResponse(httpResp, nil), err + } + + if v != nil { + // Open a NewDecoder and defer closing the reader only if there is a provided interface to decode to + defer httpResp.Body.Close() + err = json.NewDecoder(httpResp.Body).Decode(v) + } + + resp := newResponse(httpResp, v) + return resp, err +} + +// CheckResponse checks the API response for errors, and returns them if present. +// A response is considered an error if it has a status code outside the 200 range. +// The caller is responsible to analyze the response body. +// The body can contain JSON (if the error is intended) or xml (sometimes Jira just failes). +func CheckResponse(r *http.Response) error { + if c := r.StatusCode; 200 <= c && c <= 299 { + return nil + } + + err := fmt.Errorf("request failed. Please analyze the request body for more details. Status code: %d", r.StatusCode) + return err +} + +// Response represents Jira API response. It wraps http.Response returned from +// API and provides information about paging. +type Response struct { + *http.Response + + StartAt int + MaxResults int + Total int +} + +func newResponse(r *http.Response, v interface{}) *Response { + resp := &Response{Response: r} + resp.populatePageValues(v) + return resp +} + +// Sets paging values if response json was parsed to searchResult type +// (can be extended with other types if they also need paging info) +func (r *Response) populatePageValues(v interface{}) { + switch value := v.(type) { + case *searchResult: + r.StartAt = value.StartAt + r.MaxResults = value.MaxResults + r.Total = value.Total + case *groupMembersResult: + r.StartAt = value.StartAt + r.MaxResults = value.MaxResults + r.Total = value.Total + } +} diff --git a/cloud/jira_test.go b/cloud/jira_test.go new file mode 100644 index 00000000..61d4b81a --- /dev/null +++ b/cloud/jira_test.go @@ -0,0 +1,340 @@ +package cloud + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + "time" +) + +const ( + testJiraInstanceURL = "https://issues.apache.org/jira/" +) + +var ( + // testMux is the HTTP request multiplexer used with the test server. + testMux *http.ServeMux + + // testClient is the Jira client being tested. + testClient *Client + + // testServer is a test HTTP server used to provide mock API responses. + testServer *httptest.Server +) + +// setup sets up a test HTTP server along with a jira.Client that is configured to talk to that test server. +// Tests should register handlers on mux which provide mock responses for the API method being tested. +func setup() { + // Test server + testMux = http.NewServeMux() + testServer = httptest.NewServer(testMux) + + // jira client configured to use test server + testClient, _ = NewClient(testServer.URL, nil) +} + +// teardown closes the test HTTP server. +func teardown() { + testServer.Close() +} + +func testMethod(t *testing.T, r *http.Request, want string) { + if got := r.Method; got != want { + t.Errorf("Request method: %v, want %v", got, want) + } +} + +func testRequestURL(t *testing.T, r *http.Request, want string) { + if got := r.URL.String(); !strings.HasPrefix(got, want) { + t.Errorf("Request URL: %v, want %v", got, want) + } +} + +func testRequestParams(t *testing.T, r *http.Request, want map[string]string) { + params := r.URL.Query() + + if len(params) != len(want) { + t.Errorf("Request params: %d, want %d", len(params), len(want)) + } + + for key, val := range want { + if got := params.Get(key); val != got { + t.Errorf("Request params: %s, want %s", got, val) + } + + } + +} + +func TestNewClient_WrongUrl(t *testing.T) { + c, err := NewClient("://issues.apache.org/jira/", nil) + + if err == nil { + t.Error("Expected an error. Got none") + } + if c != nil { + t.Errorf("Expected no client. Got %+v", c) + } +} + +func TestNewClient_WithHttpClient(t *testing.T) { + httpClient := http.DefaultClient + httpClient.Timeout = 10 * time.Minute + + c, err := NewClient(testJiraInstanceURL, httpClient) + if err != nil { + t.Errorf("Got an error: %s", err) + } + if c == nil { + t.Error("Expected a client. Got none") + return + } + if !reflect.DeepEqual(c.client, httpClient) { + t.Errorf("HTTP clients are not equal. Injected %+v, got %+v", httpClient, c.client) + } +} + +func TestNewClient_WithServices(t *testing.T) { + c, err := NewClient(testJiraInstanceURL, nil) + + if err != nil { + t.Errorf("Got an error: %s", err) + } + if c.Issue == nil { + t.Error("No IssueService provided") + } + if c.Project == nil { + t.Error("No ProjectService provided") + } + if c.Board == nil { + t.Error("No BoardService provided") + } + if c.Sprint == nil { + t.Error("No SprintService provided") + } + if c.User == nil { + t.Error("No UserService provided") + } + if c.Group == nil { + t.Error("No GroupService provided") + } + if c.Version == nil { + t.Error("No VersionService provided") + } + if c.Priority == nil { + t.Error("No PriorityService provided") + } + if c.Resolution == nil { + t.Error("No ResolutionService provided") + } + if c.StatusCategory == nil { + t.Error("No StatusCategoryService provided") + } +} + +func TestCheckResponse(t *testing.T) { + codes := []int{ + http.StatusOK, http.StatusPartialContent, 299, + } + + for _, c := range codes { + r := &http.Response{ + StatusCode: c, + } + if err := CheckResponse(r); err != nil { + t.Errorf("CheckResponse throws an error: %s", err) + } + } +} + +func TestClient_NewRequest(t *testing.T) { + c, err := NewClient(testJiraInstanceURL, nil) + if err != nil { + t.Errorf("An error occurred. Expected nil. Got %+v.", err) + } + + inURL, outURL := "rest/api/2/issue/", testJiraInstanceURL+"rest/api/2/issue/" + inBody, outBody := &Issue{Key: "MESOS"}, `{"key":"MESOS"}`+"\n" + req, _ := c.NewRequest(context.Background(), http.MethodGet, inURL, inBody) + + // Test that relative URL was expanded + if got, want := req.URL.String(), outURL; got != want { + t.Errorf("NewRequest(%q) URL is %v, want %v", inURL, got, want) + } + + // Test that body was JSON encoded + body, _ := io.ReadAll(req.Body) + if got, want := string(body), outBody; got != want { + t.Errorf("NewRequest(%v) Body is %v, want %v", inBody, got, want) + } +} + +func TestClient_NewRawRequest(t *testing.T) { + c, err := NewClient(testJiraInstanceURL, nil) + if err != nil { + t.Errorf("An error occurred. Expected nil. Got %+v.", err) + } + + inURL, outURL := "rest/api/2/issue/", testJiraInstanceURL+"rest/api/2/issue/" + + outBody := `{"key":"MESOS"}` + "\n" + inBody := outBody + req, _ := c.NewRawRequest(context.Background(), http.MethodGet, inURL, strings.NewReader(outBody)) + + // Test that relative URL was expanded + if got, want := req.URL.String(), outURL; got != want { + t.Errorf("NewRawRequest(%q) URL is %v, want %v", inURL, got, want) + } + + // Test that body was JSON encoded + body, _ := io.ReadAll(req.Body) + if got, want := string(body), outBody; got != want { + t.Errorf("NewRawRequest(%v) Body is %v, want %v", inBody, got, want) + } +} + +func testURLParseError(t *testing.T, err error) { + if err == nil { + t.Errorf("Expected error to be returned") + } + if err, ok := err.(*url.Error); !ok || err.Op != "parse" { + t.Errorf("Expected URL parse error, got %+v", err) + } +} + +func TestClient_NewRequest_BadURL(t *testing.T) { + c, err := NewClient(testJiraInstanceURL, nil) + if err != nil { + t.Errorf("An error occurred. Expected nil. Got %+v.", err) + } + _, err = c.NewRequest(context.Background(), http.MethodGet, ":", nil) + testURLParseError(t, err) +} + +// If a nil body is passed to jira.NewRequest, make sure that nil is also passed to http.NewRequest. +// In most cases, passing an io.Reader that returns no content is fine, +// since there is no difference between an HTTP request body that is an empty string versus one that is not set at all. +// However in certain cases, intermediate systems may treat these differently resulting in subtle errors. +func TestClient_NewRequest_EmptyBody(t *testing.T) { + c, err := NewClient(testJiraInstanceURL, nil) + if err != nil { + t.Errorf("An error occurred. Expected nil. Got %+v.", err) + } + req, err := c.NewRequest(context.Background(), http.MethodGet, "/", nil) + if err != nil { + t.Fatalf("NewRequest returned unexpected error: %v", err) + } + if req.Body != nil { + t.Fatalf("constructed request contains a non-nil Body") + } +} + +func TestClient_NewMultiPartRequest(t *testing.T) { + c, err := NewClient(testJiraInstanceURL, nil) + if err != nil { + t.Errorf("An error occurred. Expected nil. Got %+v.", err) + } + + inURL := "rest/api/2/issue/" + inBuf := bytes.NewBufferString("teststring") + req, err := c.NewMultiPartRequest(context.Background(), http.MethodGet, inURL, inBuf) + + if err != nil { + t.Errorf("An error occurred. Expected nil. Got %+v.", err) + } + + if req.Header.Get("X-Atlassian-Token") != "nocheck" { + t.Errorf("An error occurred. Unexpected X-Atlassian-Token header value. Expected nocheck, actual %s.", req.Header.Get("X-Atlassian-Token")) + } +} + +func TestClient_Do(t *testing.T) { + setup() + defer teardown() + + type foo struct { + A string + } + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if m := http.MethodGet; m != r.Method { + t.Errorf("Request method = %v, want %v", r.Method, m) + } + fmt.Fprint(w, `{"A":"a"}`) + }) + + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) + body := new(foo) + testClient.Do(req, body) + + want := &foo{"a"} + if !reflect.DeepEqual(body, want) { + t.Errorf("Response body = %v, want %v", body, want) + } +} + +func TestClient_Do_HTTPResponse(t *testing.T) { + setup() + defer teardown() + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if m := http.MethodGet; m != r.Method { + t.Errorf("Request method = %v, want %v", r.Method, m) + } + fmt.Fprint(w, `{"A":"a"}`) + }) + + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) + res, _ := testClient.Do(req, nil) + _, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("Error on parsing HTTP Response = %v", err.Error()) + } else if res.StatusCode != 200 { + t.Errorf("Response code = %v, want %v", res.StatusCode, 200) + } +} + +func TestClient_Do_HTTPError(t *testing.T) { + setup() + defer teardown() + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Bad Request", 400) + }) + + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) + _, err := testClient.Do(req, nil) + + if err == nil { + t.Error("Expected HTTP 400 error.") + } +} + +// Test handling of an error caused by the internal http client's Do() function. +// A redirect loop is pretty unlikely to occur within the Jira API, but does allow us to exercise the right code path. +func TestClient_Do_RedirectLoop(t *testing.T) { + setup() + defer teardown() + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/", http.StatusFound) + }) + + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) + _, err := testClient.Do(req, nil) + + if err == nil { + t.Error("Expected error to be returned.") + } + if err, ok := err.(*url.Error); !ok { + t.Errorf("Expected a URL error; got %+v.", err) + } +} diff --git a/metaissue.go b/cloud/metaissue.go similarity index 69% rename from metaissue.go rename to cloud/metaissue.go index 560a2f05..05884597 100644 --- a/metaissue.go +++ b/cloud/metaissue.go @@ -1,8 +1,9 @@ -package jira +package cloud import ( "context" "fmt" + "net/http" "strings" "github.com/google/go-querystring/query" @@ -48,21 +49,14 @@ type MetaIssueType struct { Fields tcontainer.MarshalMap `json:"fields,omitempty"` } -// GetCreateMetaWithContext makes the api call to get the meta information required to create a ticket -func (s *IssueService) GetCreateMetaWithContext(ctx context.Context, projectkeys string) (*CreateMetaInfo, *Response, error) { - return s.GetCreateMetaWithOptionsWithContext(ctx, &GetQueryOptions{ProjectKeys: projectkeys, Expand: "projects.issuetypes.fields"}) -} - -// GetCreateMeta wraps GetCreateMetaWithContext using the background context. -func (s *IssueService) GetCreateMeta(projectkeys string) (*CreateMetaInfo, *Response, error) { - return s.GetCreateMetaWithContext(context.Background(), projectkeys) -} - -// GetCreateMetaWithOptionsWithContext makes the api call to get the meta information without requiring to have a projectKey -func (s *IssueService) GetCreateMetaWithOptionsWithContext(ctx context.Context, options *GetQueryOptions) (*CreateMetaInfo, *Response, error) { +// GetCreateMeta makes the api call to get the meta information without requiring to have a projectKey +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetCreateMeta(ctx context.Context, options *GetQueryOptions) (*CreateMetaInfo, *Response, error) { apiEndpoint := "rest/api/2/issue/createmeta" - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -84,16 +78,14 @@ func (s *IssueService) GetCreateMetaWithOptionsWithContext(ctx context.Context, return meta, resp, nil } -// GetCreateMetaWithOptions wraps GetCreateMetaWithOptionsWithContext using the background context. -func (s *IssueService) GetCreateMetaWithOptions(options *GetQueryOptions) (*CreateMetaInfo, *Response, error) { - return s.GetCreateMetaWithOptionsWithContext(context.Background(), options) -} - -// GetEditMetaWithContext makes the api call to get the edit meta information for an issue -func (s *IssueService) GetEditMetaWithContext(ctx context.Context, issue *Issue) (*EditMetaInfo, *Response, error) { +// GetEditMeta makes the api call to get the edit meta information for an issue +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetEditMeta(ctx context.Context, issue *Issue) (*EditMetaInfo, *Response, error) { apiEndpoint := fmt.Sprintf("/rest/api/2/issue/%s/editmeta", issue.Key) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -108,13 +100,11 @@ func (s *IssueService) GetEditMetaWithContext(ctx context.Context, issue *Issue) return meta, resp, nil } -// GetEditMeta wraps GetEditMetaWithContext using the background context. -func (s *IssueService) GetEditMeta(issue *Issue) (*EditMetaInfo, *Response, error) { - return s.GetEditMetaWithContext(context.Background(), issue) -} - // GetProjectWithName returns a project with "name" from the meta information received. If not found, this returns nil. // The comparison of the name is case insensitive. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func (m *CreateMetaInfo) GetProjectWithName(name string) *MetaProject { for _, m := range m.Projects { if strings.EqualFold(m.Name, name) { @@ -126,6 +116,9 @@ func (m *CreateMetaInfo) GetProjectWithName(name string) *MetaProject { // GetProjectWithKey returns a project with "name" from the meta information received. If not found, this returns nil. // The comparison of the name is case insensitive. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func (m *CreateMetaInfo) GetProjectWithKey(key string) *MetaProject { for _, m := range m.Projects { if strings.EqualFold(m.Key, key) { @@ -137,6 +130,9 @@ func (m *CreateMetaInfo) GetProjectWithKey(key string) *MetaProject { // GetIssueTypeWithName returns an IssueType with name from a given MetaProject. If not found, this returns nil. // The comparison of the name is case insensitive +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func (p *MetaProject) GetIssueTypeWithName(name string) *MetaIssueType { for _, m := range p.IssueTypes { if strings.EqualFold(m.Name, name) { @@ -148,21 +144,26 @@ func (p *MetaProject) GetIssueTypeWithName(name string) *MetaIssueType { // GetMandatoryFields returns a map of all the required fields from the MetaIssueTypes. // if a field returned by the api was: -// "customfield_10806": { -// "required": true, -// "schema": { -// "type": "any", -// "custom": "com.pyxis.greenhopper.jira:gh-epic-link", -// "customId": 10806 -// }, -// "name": "Epic Link", -// "hasDefaultValue": false, -// "operations": [ -// "set" -// ] -// } +// +// "customfield_10806": { +// "required": true, +// "schema": { +// "type": "any", +// "custom": "com.pyxis.greenhopper.jira:gh-epic-link", +// "customId": 10806 +// }, +// "name": "Epic Link", +// "hasDefaultValue": false, +// "operations": [ +// "set" +// ] +// } +// // the returned map would have "Epic Link" as the key and "customfield_10806" as value. // This choice has been made so that the it is easier to generate the create api request later. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func (t *MetaIssueType) GetMandatoryFields() (map[string]string, error) { ret := make(map[string]string) for key := range t.Fields { @@ -183,6 +184,9 @@ func (t *MetaIssueType) GetMandatoryFields() (map[string]string, error) { // GetAllFields returns a map of all the fields for an IssueType. This includes all required and not required. // The key of the returned map is what you see in the form and the value is how it is representated in the jira schema. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func (t *MetaIssueType) GetAllFields() (map[string]string, error) { ret := make(map[string]string) for key := range t.Fields { @@ -198,6 +202,9 @@ func (t *MetaIssueType) GetAllFields() (map[string]string, error) { // CheckCompleteAndAvailable checks if the given fields satisfies the mandatory field required to create a issue for the given type // And also if the given fields are available. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func (t *MetaIssueType) CheckCompleteAndAvailable(config map[string]string) (bool, error) { mandatory, err := t.GetMandatoryFields() if err != nil { diff --git a/metaissue_test.go b/cloud/metaissue_test.go similarity index 60% rename from metaissue_test.go rename to cloud/metaissue_test.go index a77951a4..2182483b 100644 --- a/metaissue_test.go +++ b/cloud/metaissue_test.go @@ -1,384 +1,13 @@ -package jira +package cloud import ( + "context" "fmt" "net/http" "net/url" "testing" ) -func TestIssueService_GetCreateMeta_Success(t *testing.T) { - setup() - defer teardown() - - testAPIEndpoint := "/rest/api/2/issue/createmeta" - - testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEndpoint) - - fmt.Fprint(w, `{ - "expand": "projects", - "projects": [{ - "expand": "issuetypes", - "self": "https://my.jira.com/rest/api/2/project/11300", - "id": "11300", - "key": "SPN", - "name": "Super Project Name", - "avatarUrls": { - "48x48": "https://my.jira.com/secure/projectavatar?pid=11300&avatarId=14405", - "24x24": "https://my.jira.com/secure/projectavatar?size=small&pid=11300&avatarId=14405", - "16x16": "https://my.jira.com/secure/projectavatar?size=xsmall&pid=11300&avatarId=14405", - "32x32": "https://my.jira.com/secure/projectavatar?size=medium&pid=11300&avatarId=14405" - }, - "issuetypes": [{ - "self": "https://my.jira.com/rest/api/2/issuetype/6", - "id": "6", - "description": "An issue which ideally should be able to be completed in one step", - "iconUrl": "https://my.jira.com/secure/viewavatar?size=xsmall&avatarId=14006&avatarType=issuetype", - "name": "Request", - "subtask": false, - "expand": "fields", - "fields": { - "summary": { - "required": true, - "schema": { - "type": "string", - "system": "summary" - }, - "name": "Summary", - "hasDefaultValue": false, - "operations": [ - "set" - ] - }, - "issuetype": { - "required": true, - "schema": { - "type": "issuetype", - "system": "issuetype" - }, - "name": "Issue Type", - "hasDefaultValue": false, - "operations": [ - - ], - "allowedValues": [{ - "self": "https://my.jira.com/rest/api/2/issuetype/6", - "id": "6", - "description": "An issue which ideally should be able to be completed in one step", - "iconUrl": "https://my.jira.com/secure/viewavatar?size=xsmall&avatarId=14006&avatarType=issuetype", - "name": "Request", - "subtask": false, - "avatarId": 14006 - }] - }, - "components": { - "required": true, - "schema": { - "type": "array", - "items": "component", - "system": "components" - }, - "name": "Component/s", - "hasDefaultValue": false, - "operations": [ - "add", - "set", - "remove" - ], - "allowedValues": [{ - "self": "https://my.jira.com/rest/api/2/component/14144", - "id": "14144", - "name": "Build automation", - "description": "Jenkins, webhooks, etc." - }, { - "self": "https://my.jira.com/rest/api/2/component/14149", - "id": "14149", - "name": "Caches and noSQL", - "description": "Cassandra, Memcached, Redis, Twemproxy, Xcache" - }, { - "self": "https://my.jira.com/rest/api/2/component/14152", - "id": "14152", - "name": "Cloud services", - "description": "AWS and similar services" - }, { - "self": "https://my.jira.com/rest/api/2/component/14147", - "id": "14147", - "name": "Code quality tools", - "description": "Code sniffer, Sonar" - }, { - "self": "https://my.jira.com/rest/api/2/component/14156", - "id": "14156", - "name": "Configuration management and provisioning", - "description": "Apache/PHP modules, Consul, Salt" - }, { - "self": "https://my.jira.com/rest/api/2/component/13606", - "id": "13606", - "name": "Cronjobs", - "description": "Cronjobs in general" - }, { - "self": "https://my.jira.com/rest/api/2/component/14150", - "id": "14150", - "name": "Data pipelines and queues", - "description": "Kafka, RabbitMq" - }, { - "self": "https://my.jira.com/rest/api/2/component/14159", - "id": "14159", - "name": "Database", - "description": "MySQL related problems" - }, { - "self": "https://my.jira.com/rest/api/2/component/14314", - "id": "14314", - "name": "Documentation" - }, { - "self": "https://my.jira.com/rest/api/2/component/14151", - "id": "14151", - "name": "Git", - "description": "Bitbucket, GitHub, GitLab, Git in general" - }, { - "self": "https://my.jira.com/rest/api/2/component/14155", - "id": "14155", - "name": "HTTP services", - "description": "CDN, HaProxy, HTTP, Varnish" - }, { - "self": "https://my.jira.com/rest/api/2/component/14154", - "id": "14154", - "name": "Job and service scheduling", - "description": "Chronos, Docker, Marathon, Mesos" - }, { - "self": "https://my.jira.com/rest/api/2/component/14158", - "id": "14158", - "name": "Legacy", - "description": "Everything related to legacy" - }, { - "self": "https://my.jira.com/rest/api/2/component/14157", - "id": "14157", - "name": "Monitoring", - "description": "Collectd, Nagios, Monitoring in general" - }, { - "self": "https://my.jira.com/rest/api/2/component/14148", - "id": "14148", - "name": "Other services" - }, { - "self": "https://my.jira.com/rest/api/2/component/13602", - "id": "13602", - "name": "Package management", - "description": "Composer, Medusa, Satis" - }, { - "self": "https://my.jira.com/rest/api/2/component/14145", - "id": "14145", - "name": "Release", - "description": "Directory config, release queries, rewrite rules" - }, { - "self": "https://my.jira.com/rest/api/2/component/14146", - "id": "14146", - "name": "Staging systems and VMs", - "description": "Stage, QA machines, KVMs,Vagrant" - }, { - "self": "https://my.jira.com/rest/api/2/component/14153", - "id": "14153", - "name": "Blog" - }, { - "self": "https://my.jira.com/rest/api/2/component/14143", - "id": "14143", - "name": "Test automation", - "description": "Testing infrastructure in general" - }, { - "self": "https://my.jira.com/rest/api/2/component/14221", - "id": "14221", - "name": "Internal Infrastructure" - }] - }, - "attachment": { - "required": false, - "schema": { - "type": "array", - "items": "attachment", - "system": "attachment" - }, - "name": "Attachment", - "hasDefaultValue": false, - "operations": [ - - ] - }, - "duedate": { - "required": false, - "schema": { - "type": "date", - "system": "duedate" - }, - "name": "Due Date", - "hasDefaultValue": false, - "operations": [ - "set" - ] - }, - "description": { - "required": false, - "schema": { - "type": "string", - "system": "description" - }, - "name": "Description", - "hasDefaultValue": false, - "operations": [ - "set" - ] - }, - "customfield_10806": { - "required": false, - "schema": { - "type": "any", - "custom": "com.pyxis.greenhopper.jira:gh-epic-link", - "customId": 10806 - }, - "name": "Epic Link", - "hasDefaultValue": false, - "operations": [ - "set" - ] - }, - "project": { - "required": true, - "schema": { - "type": "project", - "system": "project" - }, - "name": "Project", - "hasDefaultValue": false, - "operations": [ - "set" - ], - "allowedValues": [{ - "self": "https://my.jira.com/rest/api/2/project/11300", - "id": "11300", - "key": "SPN", - "name": "Super Project Name", - "avatarUrls": { - "48x48": "https://my.jira.com/secure/projectavatar?pid=11300&avatarId=14405", - "24x24": "https://my.jira.com/secure/projectavatar?size=small&pid=11300&avatarId=14405", - "16x16": "https://my.jira.com/secure/projectavatar?size=xsmall&pid=11300&avatarId=14405", - "32x32": "https://my.jira.com/secure/projectavatar?size=medium&pid=11300&avatarId=14405" - }, - "projectCategory": { - "self": "https://my.jira.com/rest/api/2/projectCategory/10100", - "id": "10100", - "description": "", - "name": "Product & Development" - } - }] - }, - "assignee": { - "required": true, - "schema": { - "type": "user", - "system": "assignee" - }, - "name": "Assignee", - "autoCompleteUrl": "https://my.jira.com/rest/api/latest/user/assignable/search?issueKey=null&username=", - "hasDefaultValue": true, - "operations": [ - "set" - ] - }, - "priority": { - "required": false, - "schema": { - "type": "priority", - "system": "priority" - }, - "name": "Priority", - "hasDefaultValue": true, - "operations": [ - "set" - ], - "allowedValues": [{ - "self": "https://my.jira.com/rest/api/2/priority/1", - "iconUrl": "https://my.jira.com/images/icons/priorities/blocker.svg", - "name": "Immediate", - "id": "1" - }, { - "self": "https://my.jira.com/rest/api/2/priority/2", - "iconUrl": "https://my.jira.com/images/icons/priorities/critical.svg", - "name": "Urgent", - "id": "2" - }, { - "self": "https://my.jira.com/rest/api/2/priority/3", - "iconUrl": "https://my.jira.com/images/icons/priorities/major.svg", - "name": "High", - "id": "3" - }, { - "self": "https://my.jira.com/rest/api/2/priority/6", - "iconUrl": "https://my.jira.com/images/icons/priorities/moderate.svg", - "name": "Moderate", - "id": "6" - }, { - "self": "https://my.jira.com/rest/api/2/priority/4", - "iconUrl": "https://my.jira.com/images/icons/priorities/minor.svg", - "name": "Normal", - "id": "4" - }, { - "self": "https://my.jira.com/rest/api/2/priority/5", - "iconUrl": "https://my.jira.com/images/icons/priorities/trivial.svg", - "name": "Low", - "id": "5" - }] - }, - "labels": { - "required": false, - "schema": { - "type": "array", - "items": "string", - "system": "labels" - }, - "name": "Labels", - "autoCompleteUrl": "https://my.jira.com/rest/api/1.0/labels/suggest?query=", - "hasDefaultValue": false, - "operations": [ - "add", - "set", - "remove" - ] - } - } - }] - }] - }`) - }) - - issue, _, err := testClient.Issue.GetCreateMeta("SPN") - if err != nil { - t.Errorf("Expected nil error but got %s", err) - } - - if len(issue.Projects) != 1 { - t.Errorf("Expected 1 project, got %d", len(issue.Projects)) - } - for _, project := range issue.Projects { - if len(project.IssueTypes) != 1 { - t.Errorf("Expected 1 issueTypes, got %d", len(project.IssueTypes)) - } - for _, issueTypes := range project.IssueTypes { - requiredFields := 0 - fields := issueTypes.Fields - for _, value := range fields { - for key, value := range value.(map[string]interface{}) { - if key == "required" && value == true { - requiredFields = requiredFields + 1 - } - } - - } - if requiredFields != 5 { - t.Errorf("Expected 5 required fields from Create Meta information, got %d", requiredFields) - } - } - } - -} - func TestIssueService_GetEditMeta_Success(t *testing.T) { setup() defer teardown() @@ -386,7 +15,7 @@ func TestIssueService_GetEditMeta_Success(t *testing.T) { testAPIEndpoint := "/rest/api/2/issue/PROJ-9001/editmeta" testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, `{ @@ -420,7 +49,7 @@ func TestIssueService_GetEditMeta_Success(t *testing.T) { }`) }) - editMeta, _, err := testClient.Issue.GetEditMeta(&Issue{Key: "PROJ-9001"}) + editMeta, _, err := testClient.Issue.GetEditMeta(context.Background(), &Issue{Key: "PROJ-9001"}) if err != nil { t.Errorf("Expected nil error but got %s", err) } @@ -446,7 +75,7 @@ func TestIssueService_GetEditMeta_Success(t *testing.T) { } func TestIssueService_GetEditMeta_Fail(t *testing.T) { - _, _, err := testClient.Issue.GetEditMeta(&Issue{Key: "PROJ-9001"}) + _, _, err := testClient.Issue.GetEditMeta(context.Background(), &Issue{Key: "PROJ-9001"}) if err == nil { t.Error("Expected to receive an error, received nil instead") } @@ -456,14 +85,14 @@ func TestIssueService_GetEditMeta_Fail(t *testing.T) { } } -func TestMetaIssueType_GetCreateMetaWithOptions(t *testing.T) { +func TestMetaIssueType_GetCreateMeta(t *testing.T) { setup() defer teardown() testAPIEndpoint := "/rest/api/2/issue/createmeta" testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, `{ @@ -797,7 +426,7 @@ func TestMetaIssueType_GetCreateMetaWithOptions(t *testing.T) { }`) }) - issue, _, err := testClient.Issue.GetCreateMetaWithOptions(&GetQueryOptions{Expand: "projects.issuetypes.fields"}) + issue, _, err := testClient.Issue.GetCreateMeta(context.Background(), &GetQueryOptions{Expand: "projects.issuetypes.fields"}) if err != nil { t.Errorf("Expected nil error but got %s", err) } diff --git a/organization.go b/cloud/organization.go similarity index 58% rename from organization.go rename to cloud/organization.go index 65f222b3..11e040d2 100644 --- a/organization.go +++ b/cloud/organization.go @@ -1,16 +1,15 @@ -package jira +package cloud import ( "context" "fmt" + "net/http" ) // OrganizationService handles Organizations for the Jira instance / API. // // Jira API docs: https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/ -type OrganizationService struct { - client *Client -} +type OrganizationService service // OrganizationCreationDTO is DTO for creat organization API type OrganizationCreationDTO struct { @@ -55,20 +54,23 @@ type PropertyKeys struct { Keys []PropertyKey `json:"keys,omitempty" structs:"keys,omitempty"` } -// GetAllOrganizationsWithContext returns a list of organizations in +// GetAllOrganizations returns a list of organizations in // the Jira Service Management instance. // Use this method when you want to present a list // of organizations or want to locate an organization // by name. // // Jira API docs: https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-group-organization -func (s *OrganizationService) GetAllOrganizationsWithContext(ctx context.Context, start int, limit int, accountID string) (*PagedDTO, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) GetAllOrganizations(ctx context.Context, start int, limit int, accountID string) (*PagedDTO, *Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization?start=%d&limit=%d", start, limit) if accountID != "" { apiEndPoint += fmt.Sprintf("&accountId=%s", accountID) } - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndPoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) req.Header.Set("Accept", "application/json") if err != nil { @@ -85,23 +87,21 @@ func (s *OrganizationService) GetAllOrganizationsWithContext(ctx context.Context return v, resp, nil } -// GetAllOrganizations wraps GetAllOrganizationsWithContext using the background context. -func (s *OrganizationService) GetAllOrganizations(start int, limit int, accountID string) (*PagedDTO, *Response, error) { - return s.GetAllOrganizationsWithContext(context.Background(), start, limit, accountID) -} - -// CreateOrganizationWithContext creates an organization by +// CreateOrganization creates an organization by // passing the name of the organization. // // Jira API docs: https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-post -func (s *OrganizationService) CreateOrganizationWithContext(ctx context.Context, name string) (*Organization, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) CreateOrganization(ctx context.Context, name string) (*Organization, *Response, error) { apiEndPoint := "rest/servicedeskapi/organization" organization := OrganizationCreationDTO{ Name: name, } - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndPoint, organization) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndPoint, organization) req.Header.Set("Accept", "application/json") if err != nil { @@ -118,22 +118,20 @@ func (s *OrganizationService) CreateOrganizationWithContext(ctx context.Context, return o, resp, nil } -// CreateOrganization wraps CreateOrganizationWithContext using the background context. -func (s *OrganizationService) CreateOrganization(name string) (*Organization, *Response, error) { - return s.CreateOrganizationWithContext(context.Background(), name) -} - -// GetOrganizationWithContext returns details of an +// GetOrganization returns details of an // organization. Use this method to get organization // details whenever your application component is // passed an organization ID but needs to display // other organization details. // // Jira API docs: https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-get -func (s *OrganizationService) GetOrganizationWithContext(ctx context.Context, organizationID int) (*Organization, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) GetOrganization(ctx context.Context, organizationID int) (*Organization, *Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d", organizationID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndPoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) req.Header.Set("Accept", "application/json") if err != nil { @@ -150,22 +148,20 @@ func (s *OrganizationService) GetOrganizationWithContext(ctx context.Context, or return o, resp, nil } -// GetOrganization wraps GetOrganizationWithContext using the background context. -func (s *OrganizationService) GetOrganization(organizationID int) (*Organization, *Response, error) { - return s.GetOrganizationWithContext(context.Background(), organizationID) -} - -// DeleteOrganizationWithContext deletes an organization. Note that +// DeleteOrganization deletes an organization. Note that // the organization is deleted regardless // of other associations it may have. // For example, associations with service desks. // // Jira API docs: https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-delete // Caller must close resp.Body -func (s *OrganizationService) DeleteOrganizationWithContext(ctx context.Context, organizationID int) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) DeleteOrganization(ctx context.Context, organizationID int) (*Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d", organizationID) - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndPoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndPoint, nil) if err != nil { return nil, err @@ -180,22 +176,19 @@ func (s *OrganizationService) DeleteOrganizationWithContext(ctx context.Context, return resp, nil } -// DeleteOrganization wraps DeleteOrganizationWithContext using the background context. -// Caller must close resp.Body -func (s *OrganizationService) DeleteOrganization(organizationID int) (*Response, error) { - return s.DeleteOrganizationWithContext(context.Background(), organizationID) -} - -// GetPropertiesKeysWithContext returns the keys of +// GetPropertiesKeys returns the keys of // all properties for an organization. Use this resource // when you need to find out what additional properties // items have been added to an organization. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-property-get -func (s *OrganizationService) GetPropertiesKeysWithContext(ctx context.Context, organizationID int) (*PropertyKeys, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) GetPropertiesKeys(ctx context.Context, organizationID int) (*PropertyKeys, *Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/property", organizationID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndPoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) req.Header.Set("Accept", "application/json") if err != nil { @@ -212,20 +205,18 @@ func (s *OrganizationService) GetPropertiesKeysWithContext(ctx context.Context, return pk, resp, nil } -// GetPropertiesKeys wraps GetPropertiesKeysWithContext using the background context. -func (s *OrganizationService) GetPropertiesKeys(organizationID int) (*PropertyKeys, *Response, error) { - return s.GetPropertiesKeysWithContext(context.Background(), organizationID) -} - -// GetPropertyWithContext returns the value of a property +// GetProperty returns the value of a property // from an organization. Use this method to obtain the JSON // content for an organization's property. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-property-propertykey-get -func (s *OrganizationService) GetPropertyWithContext(ctx context.Context, organizationID int, propertyKey string) (*EntityProperty, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) GetProperty(ctx context.Context, organizationID int, propertyKey string) (*EntityProperty, *Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/property/%s", organizationID, propertyKey) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndPoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) req.Header.Set("Accept", "application/json") if err != nil { @@ -242,21 +233,19 @@ func (s *OrganizationService) GetPropertyWithContext(ctx context.Context, organi return ep, resp, nil } -// GetProperty wraps GetPropertyWithContext using the background context. -func (s *OrganizationService) GetProperty(organizationID int, propertyKey string) (*EntityProperty, *Response, error) { - return s.GetPropertyWithContext(context.Background(), organizationID, propertyKey) -} - -// SetPropertyWithContext sets the value of a +// SetProperty sets the value of a // property for an organization. Use this // resource to store custom data against an organization. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-property-propertykey-put +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. // Caller must close resp.Body -func (s *OrganizationService) SetPropertyWithContext(ctx context.Context, organizationID int, propertyKey string) (*Response, error) { +func (s *OrganizationService) SetProperty(ctx context.Context, organizationID int, propertyKey string) (*Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/property/%s", organizationID, propertyKey) - req, err := s.client.NewRequestWithContext(ctx, "PUT", apiEndPoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndPoint, nil) req.Header.Set("Accept", "application/json") if err != nil { @@ -272,20 +261,17 @@ func (s *OrganizationService) SetPropertyWithContext(ctx context.Context, organi return resp, nil } -// SetProperty wraps SetPropertyWithContext using the background context. -// Caller must close resp.Body -func (s *OrganizationService) SetProperty(organizationID int, propertyKey string) (*Response, error) { - return s.SetPropertyWithContext(context.Background(), organizationID, propertyKey) -} - -// DeletePropertyWithContext removes a property from an organization. +// DeleteProperty removes a property from an organization. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-property-propertykey-delete // Caller must close resp.Body -func (s *OrganizationService) DeletePropertyWithContext(ctx context.Context, organizationID int, propertyKey string) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) DeleteProperty(ctx context.Context, organizationID int, propertyKey string) (*Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/property/%s", organizationID, propertyKey) - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndPoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndPoint, nil) req.Header.Set("Accept", "application/json") if err != nil { @@ -301,23 +287,20 @@ func (s *OrganizationService) DeletePropertyWithContext(ctx context.Context, org return resp, nil } -// DeleteProperty wraps DeletePropertyWithContext using the background context. -// Caller must close resp.Body -func (s *OrganizationService) DeleteProperty(organizationID int, propertyKey string) (*Response, error) { - return s.DeletePropertyWithContext(context.Background(), organizationID, propertyKey) -} - -// GetUsersWithContext returns all the users +// GetUsers returns all the users // associated with an organization. Use this // method where you want to provide a list of // users for an organization or determine if // a user is associated with an organization. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-user-get -func (s *OrganizationService) GetUsersWithContext(ctx context.Context, organizationID int, start int, limit int) (*PagedDTO, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) GetUsers(ctx context.Context, organizationID int, start int, limit int) (*PagedDTO, *Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/user?start=%d&limit=%d", organizationID, start, limit) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndPoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) req.Header.Set("Accept", "application/json") if err != nil { @@ -334,19 +317,17 @@ func (s *OrganizationService) GetUsersWithContext(ctx context.Context, organizat return users, resp, nil } -// GetUsers wraps GetUsersWithContext using the background context. -func (s *OrganizationService) GetUsers(organizationID int, start int, limit int) (*PagedDTO, *Response, error) { - return s.GetUsersWithContext(context.Background(), organizationID, start, limit) -} - -// AddUsersWithContext adds users to an organization. +// AddUsers adds users to an organization. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-user-post // Caller must close resp.Body -func (s *OrganizationService) AddUsersWithContext(ctx context.Context, organizationID int, users OrganizationUsersDTO) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) AddUsers(ctx context.Context, organizationID int, users OrganizationUsersDTO) (*Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/user", organizationID) - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndPoint, users) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndPoint, users) if err != nil { return nil, err @@ -361,20 +342,17 @@ func (s *OrganizationService) AddUsersWithContext(ctx context.Context, organizat return resp, nil } -// AddUsers wraps AddUsersWithContext using the background context. -// Caller must close resp.Body -func (s *OrganizationService) AddUsers(organizationID int, users OrganizationUsersDTO) (*Response, error) { - return s.AddUsersWithContext(context.Background(), organizationID, users) -} - -// RemoveUsersWithContext removes users from an organization. +// RemoveUsers removes users from an organization. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-user-delete // Caller must close resp.Body -func (s *OrganizationService) RemoveUsersWithContext(ctx context.Context, organizationID int, users OrganizationUsersDTO) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) RemoveUsers(ctx context.Context, organizationID int, users OrganizationUsersDTO) (*Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/user", organizationID) - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndPoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndPoint, nil) req.Header.Set("Accept", "application/json") if err != nil { @@ -389,9 +367,3 @@ func (s *OrganizationService) RemoveUsersWithContext(ctx context.Context, organi return resp, nil } - -// RemoveUsers wraps RemoveUsersWithContext using the background context. -// Caller must close resp.Body -func (s *OrganizationService) RemoveUsers(organizationID int, users OrganizationUsersDTO) (*Response, error) { - return s.RemoveUsersWithContext(context.Background(), organizationID, users) -} diff --git a/organization_test.go b/cloud/organization_test.go similarity index 87% rename from organization_test.go rename to cloud/organization_test.go index 1af51edd..64c12312 100644 --- a/organization_test.go +++ b/cloud/organization_test.go @@ -1,24 +1,25 @@ -package jira +package cloud import ( + "context" "encoding/json" "fmt" "net/http" "testing" ) -func TestOrganizationService_GetAllOrganizationsWithContext(t *testing.T) { +func TestOrganizationService_GetAllOrganizations(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/organization", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/servicedeskapi/organization") w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{ "_expands": [], "size": 1, "start": 1, "limit": 1, "isLastPage": false, "_links": { "base": "https://your-domain.atlassian.net/rest/servicedeskapi", "context": "context", "next": "https://your-domain.atlassian.net/rest/servicedeskapi/organization?start=2&limit=1", "prev": "https://your-domain.atlassian.net/rest/servicedeskapi/organization?start=0&limit=1" }, "values": [ { "id": "1", "name": "Charlie Cakes Franchises", "_links": { "self": "https://your-domain.atlassian.net/rest/servicedeskapi/organization/1" } } ] }`) }) - result, _, err := testClient.Organization.GetAllOrganizations(0, 50, "") + result, _, err := testClient.Organization.GetAllOrganizations(context.Background(), 0, 50, "") if result == nil { t.Error("Expected Organizations. Result is nil") @@ -35,7 +36,7 @@ func TestOrganizationService_CreateOrganization(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/organization", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/servicedeskapi/organization") o := new(OrganizationCreationDTO) @@ -45,7 +46,7 @@ func TestOrganizationService_CreateOrganization(t *testing.T) { }) name := "MyOrg" - o, _, err := testClient.Organization.CreateOrganization(name) + o, _, err := testClient.Organization.CreateOrganization(context.Background(), name) if o == nil { t.Error("Expected Organization. Result is nil") @@ -62,7 +63,7 @@ func TestOrganizationService_GetOrganization(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/organization/1", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/servicedeskapi/organization/1") w.WriteHeader(http.StatusOK) @@ -70,7 +71,7 @@ func TestOrganizationService_GetOrganization(t *testing.T) { }) id := 1 - o, _, err := testClient.Organization.GetOrganization(id) + o, _, err := testClient.Organization.GetOrganization(context.Background(), id) if err != nil { t.Errorf("Error given: %s", err) @@ -87,13 +88,13 @@ func TestOrganizationService_DeleteOrganization(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/organization/1", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/servicedeskapi/organization/1") w.WriteHeader(http.StatusNoContent) }) - _, err := testClient.Organization.DeleteOrganization(1) + _, err := testClient.Organization.DeleteOrganization(context.Background(), 1) if err != nil { t.Errorf("Error given: %s", err) @@ -104,7 +105,7 @@ func TestOrganizationService_GetPropertiesKeys(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/organization/1/property", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/servicedeskapi/organization/1/property") w.WriteHeader(http.StatusOK) @@ -118,7 +119,7 @@ func TestOrganizationService_GetPropertiesKeys(t *testing.T) { }`) }) - pk, _, err := testClient.Organization.GetPropertiesKeys(1) + pk, _, err := testClient.Organization.GetPropertiesKeys(context.Background(), 1) if err != nil { t.Errorf("Error given: %s", err) @@ -135,7 +136,7 @@ func TestOrganizationService_GetProperty(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/organization/1/property/organization.attributes", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/servicedeskapi/organization/1/property/organization.attributes") w.WriteHeader(http.StatusOK) @@ -149,7 +150,7 @@ func TestOrganizationService_GetProperty(t *testing.T) { }) key := "organization.attributes" - ep, _, err := testClient.Organization.GetProperty(1, key) + ep, _, err := testClient.Organization.GetProperty(context.Background(), 1, key) if err != nil { t.Errorf("Error given: %s", err) @@ -166,14 +167,14 @@ func TestOrganizationService_SetProperty(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/organization/1/property/organization.attributes", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "PUT") + testMethod(t, r, http.MethodPut) testRequestURL(t, r, "/rest/servicedeskapi/organization/1/property/organization.attributes") w.WriteHeader(http.StatusOK) }) key := "organization.attributes" - _, err := testClient.Organization.SetProperty(1, key) + _, err := testClient.Organization.SetProperty(context.Background(), 1, key) if err != nil { t.Errorf("Error given: %s", err) @@ -184,14 +185,14 @@ func TestOrganizationService_DeleteProperty(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/organization/1/property/organization.attributes", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/servicedeskapi/organization/1/property/organization.attributes") w.WriteHeader(http.StatusOK) }) key := "organization.attributes" - _, err := testClient.Organization.DeleteProperty(1, key) + _, err := testClient.Organization.DeleteProperty(context.Background(), 1, key) if err != nil { t.Errorf("Error given: %s", err) @@ -202,7 +203,7 @@ func TestOrganizationService_GetUsers(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/organization/1/user", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/servicedeskapi/organization/1/user") w.WriteHeader(http.StatusOK) @@ -261,7 +262,7 @@ func TestOrganizationService_GetUsers(t *testing.T) { }`) }) - users, _, err := testClient.Organization.GetUsers(1, 0, 50) + users, _, err := testClient.Organization.GetUsers(context.Background(), 1, 0, 50) if err != nil { t.Errorf("Error given: %s", err) @@ -282,7 +283,7 @@ func TestOrganizationService_AddUsers(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/organization/1/user", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/servicedeskapi/organization/1/user") w.WriteHeader(http.StatusNoContent) @@ -294,7 +295,7 @@ func TestOrganizationService_AddUsers(t *testing.T) { "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3a01db05e2a66fa80bd", }, } - _, err := testClient.Organization.AddUsers(1, users) + _, err := testClient.Organization.AddUsers(context.Background(), 1, users) if err != nil { t.Errorf("Error given: %s", err) @@ -305,7 +306,7 @@ func TestOrganizationService_RemoveUsers(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/organization/1/user", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/servicedeskapi/organization/1/user") w.WriteHeader(http.StatusNoContent) @@ -317,7 +318,7 @@ func TestOrganizationService_RemoveUsers(t *testing.T) { "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3a01db05e2a66fa80bd", }, } - _, err := testClient.Organization.RemoveUsers(1, users) + _, err := testClient.Organization.RemoveUsers(context.Background(), 1, users) if err != nil { t.Errorf("Error given: %s", err) diff --git a/permissionscheme.go b/cloud/permissionscheme.go similarity index 61% rename from permissionscheme.go rename to cloud/permissionscheme.go index 7af5a8bf..c8c3f316 100644 --- a/permissionscheme.go +++ b/cloud/permissionscheme.go @@ -1,16 +1,16 @@ -package jira +package cloud import ( "context" "fmt" + "net/http" ) // PermissionSchemeService handles permissionschemes for the Jira instance / API. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-group-Permissionscheme -type PermissionSchemeService struct { - client *Client -} +type PermissionSchemeService service + type PermissionSchemes struct { PermissionSchemes []PermissionScheme `json:"permissionSchemes" structs:"permissionSchemes"` } @@ -28,12 +28,15 @@ type Holder struct { Expand string `json:"expand" structs:"expand"` } -// GetListWithContext returns a list of all permission schemes +// GetList returns a list of all permission schemes // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-api-3-permissionscheme-get -func (s *PermissionSchemeService) GetListWithContext(ctx context.Context) (*PermissionSchemes, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *PermissionSchemeService) GetList(ctx context.Context) (*PermissionSchemes, *Response, error) { apiEndpoint := "/rest/api/3/permissionscheme" - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -48,17 +51,15 @@ func (s *PermissionSchemeService) GetListWithContext(ctx context.Context) (*Perm return pss, resp, nil } -// GetList wraps GetListWithContext using the background context. -func (s *PermissionSchemeService) GetList() (*PermissionSchemes, *Response, error) { - return s.GetListWithContext(context.Background()) -} - -// GetWithContext returns a full representation of the permission scheme for the schemeID +// Get returns a full representation of the permission scheme for the schemeID // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-api-3-permissionscheme-schemeId-get -func (s *PermissionSchemeService) GetWithContext(ctx context.Context, schemeID int) (*PermissionScheme, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *PermissionSchemeService) Get(ctx context.Context, schemeID int) (*PermissionScheme, *Response, error) { apiEndpoint := fmt.Sprintf("/rest/api/3/permissionscheme/%d", schemeID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -75,8 +76,3 @@ func (s *PermissionSchemeService) GetWithContext(ctx context.Context, schemeID i return ps, resp, nil } - -// Get wraps GetWithContext using the background context. -func (s *PermissionSchemeService) Get(schemeID int) (*PermissionScheme, *Response, error) { - return s.GetWithContext(context.Background(), schemeID) -} diff --git a/permissionschemes_test.go b/cloud/permissionscheme_test.go similarity index 64% rename from permissionschemes_test.go rename to cloud/permissionscheme_test.go index 8efc1230..6074774b 100644 --- a/permissionschemes_test.go +++ b/cloud/permissionscheme_test.go @@ -1,9 +1,10 @@ -package jira +package cloud import ( + "context" "fmt" - "io/ioutil" "net/http" + "os" "testing" ) @@ -12,17 +13,17 @@ func TestPermissionSchemeService_GetList(t *testing.T) { defer teardown() testAPIEndpoint := "/rest/api/3/permissionscheme" - raw, err := ioutil.ReadFile("./mocks/all_permissionschemes.json") + raw, err := os.ReadFile("../testing/mock-data/all_permissionschemes.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, string(raw)) }) - permissionScheme, _, err := testClient.PermissionScheme.GetList() + permissionScheme, _, err := testClient.PermissionScheme.GetList(context.Background()) if err != nil { t.Errorf("Error given: %v", err) } @@ -40,17 +41,17 @@ func TestPermissionSchemeService_GetList_NoList(t *testing.T) { defer teardown() testAPIEndpoint := "/rest/api/3/permissionscheme" - raw, err := ioutil.ReadFile("./mocks/no_permissionschemes.json") + raw, err := os.ReadFile("../testing/mock-data/no_permissionschemes.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, string(raw)) }) - permissionScheme, _, err := testClient.PermissionScheme.GetList() + permissionScheme, _, err := testClient.PermissionScheme.GetList(context.Background()) if permissionScheme != nil { t.Errorf("Expected permissionScheme list has %d entries but should be nil", len(permissionScheme.PermissionSchemes)) } @@ -62,18 +63,18 @@ func TestPermissionSchemeService_GetList_NoList(t *testing.T) { func TestPermissionSchemeService_Get(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/api/3/permissionscheme/10100" - raw, err := ioutil.ReadFile("./mocks/permissionscheme.json") + testapiEndpoint := "/rest/api/3/permissionscheme/10100" + raw, err := os.ReadFile("../testing/mock-data/permissionscheme.json") if err != nil { t.Error(err.Error()) } - testMux.HandleFunc(testAPIEdpoint, func(writer http.ResponseWriter, request *http.Request) { - testMethod(t, request, "GET") - testRequestURL(t, request, testAPIEdpoint) + testMux.HandleFunc(testapiEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testapiEndpoint) fmt.Fprint(writer, string(raw)) }) - permissionScheme, _, err := testClient.PermissionScheme.Get(10100) + permissionScheme, _, err := testClient.PermissionScheme.Get(context.Background(), 10100) if permissionScheme == nil { t.Errorf("Expected permissionscheme, got nil") } @@ -85,18 +86,18 @@ func TestPermissionSchemeService_Get(t *testing.T) { func TestPermissionSchemeService_Get_NoScheme(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/api/3/permissionscheme/99999" - raw, err := ioutil.ReadFile("./mocks/no_permissionscheme.json") + testapiEndpoint := "/rest/api/3/permissionscheme/99999" + raw, err := os.ReadFile("../testing/mock-data/no_permissionscheme.json") if err != nil { t.Error(err.Error()) } - testMux.HandleFunc(testAPIEdpoint, func(writer http.ResponseWriter, request *http.Request) { - testMethod(t, request, "GET") - testRequestURL(t, request, testAPIEdpoint) + testMux.HandleFunc(testapiEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testapiEndpoint) fmt.Fprint(writer, string(raw)) }) - permissionScheme, _, err := testClient.PermissionScheme.Get(99999) + permissionScheme, _, err := testClient.PermissionScheme.Get(context.Background(), 99999) if permissionScheme != nil { t.Errorf("Expected nil, got permissionschme %v", permissionScheme) } diff --git a/priority.go b/cloud/priority.go similarity index 69% rename from priority.go rename to cloud/priority.go index a7b12a41..fe24a415 100644 --- a/priority.go +++ b/cloud/priority.go @@ -1,13 +1,14 @@ -package jira +package cloud -import "context" +import ( + "context" + "net/http" +) // PriorityService handles priorities for the Jira instance / API. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-Priority -type PriorityService struct { - client *Client -} +type PriorityService service // Priority represents a priority of a Jira issue. // Typical types are "Normal", "Moderate", "Urgent", ... @@ -20,12 +21,15 @@ type Priority struct { Description string `json:"description,omitempty" structs:"description,omitempty"` } -// GetListWithContext gets all priorities from Jira +// GetList gets all priorities from Jira // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-priority-get -func (s *PriorityService) GetListWithContext(ctx context.Context) ([]Priority, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *PriorityService) GetList(ctx context.Context) ([]Priority, *Response, error) { apiEndpoint := "rest/api/2/priority" - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -37,8 +41,3 @@ func (s *PriorityService) GetListWithContext(ctx context.Context) ([]Priority, * } return priorityList, resp, nil } - -// GetList wraps GetListWithContext using the background context. -func (s *PriorityService) GetList() ([]Priority, *Response, error) { - return s.GetListWithContext(context.Background()) -} diff --git a/cloud/priority_test.go b/cloud/priority_test.go new file mode 100644 index 00000000..2c5d21f4 --- /dev/null +++ b/cloud/priority_test.go @@ -0,0 +1,33 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestPriorityService_GetList(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/priority" + + raw, err := os.ReadFile("../testing/mock-data/all_priorities.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, string(raw)) + }) + + priorities, _, err := testClient.Priority.GetList(context.Background()) + if priorities == nil { + t.Error("Expected priority list. Priority list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/project.go b/cloud/project.go similarity index 71% rename from project.go rename to cloud/project.go index f1000c81..add12bf9 100644 --- a/project.go +++ b/cloud/project.go @@ -1,8 +1,9 @@ -package jira +package cloud import ( "context" "fmt" + "net/http" "github.com/google/go-querystring/query" ) @@ -10,9 +11,7 @@ import ( // ProjectService handles projects for the Jira instance / API. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/project -type ProjectService struct { - client *Client -} +type ProjectService service // ProjectList represent a list of Projects type ProjectList []struct { @@ -81,25 +80,16 @@ type PermissionScheme struct { Permissions []Permission `json:"permissions" structs:"permissions,omitempty"` } -// GetListWithContext gets all projects form Jira +// GetAll returns all projects form Jira with optional query params, like &GetQueryOptions{Expand: "issueTypes"} to get +// a list of all projects and their supported issuetypes. // -// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/project-getAllProjects -func (s *ProjectService) GetListWithContext(ctx context.Context) (*ProjectList, *Response, error) { - return s.ListWithOptionsWithContext(ctx, &GetQueryOptions{}) -} - -// GetList wraps GetListWithContext using the background context. -func (s *ProjectService) GetList() (*ProjectList, *Response, error) { - return s.GetListWithContext(context.Background()) -} - -// ListWithOptionsWithContext gets all projects form Jira with optional query params, like &GetQueryOptions{Expand: "issueTypes"} to get -// a list of all projects and their supported issuetypes +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-projects/#api-rest-api-2-project-get // -// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/project-getAllProjects -func (s *ProjectService) ListWithOptionsWithContext(ctx context.Context, options *GetQueryOptions) (*ProjectList, *Response, error) { +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ProjectService) GetAll(ctx context.Context, options *GetQueryOptions) (*ProjectList, *Response, error) { apiEndpoint := "rest/api/2/project" - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -122,19 +112,17 @@ func (s *ProjectService) ListWithOptionsWithContext(ctx context.Context, options return projectList, resp, nil } -// ListWithOptions wraps ListWithOptionsWithContext using the background context. -func (s *ProjectService) ListWithOptions(options *GetQueryOptions) (*ProjectList, *Response, error) { - return s.ListWithOptionsWithContext(context.Background(), options) -} - -// GetWithContext returns a full representation of the project for the given issue key. +// Get returns a full representation of the project for the given issue key. // Jira will attempt to identify the project by the projectIdOrKey path parameter. // This can be an project id, or an project key. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/project-getProject -func (s *ProjectService) GetWithContext(ctx context.Context, projectID string) (*Project, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ProjectService) Get(ctx context.Context, projectID string) (*Project, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/project/%s", projectID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -149,19 +137,17 @@ func (s *ProjectService) GetWithContext(ctx context.Context, projectID string) ( return project, resp, nil } -// Get wraps GetWithContext using the background context. -func (s *ProjectService) Get(projectID string) (*Project, *Response, error) { - return s.GetWithContext(context.Background(), projectID) -} - -// GetPermissionSchemeWithContext returns a full representation of the permission scheme for the project +// GetPermissionScheme returns a full representation of the permission scheme for the project // Jira will attempt to identify the project by the projectIdOrKey path parameter. // This can be an project id, or an project key. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/project-getProject -func (s *ProjectService) GetPermissionSchemeWithContext(ctx context.Context, projectID string) (*PermissionScheme, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ProjectService) GetPermissionScheme(ctx context.Context, projectID string) (*PermissionScheme, *Response, error) { apiEndpoint := fmt.Sprintf("/rest/api/2/project/%s/permissionscheme", projectID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -175,8 +161,3 @@ func (s *ProjectService) GetPermissionSchemeWithContext(ctx context.Context, pro return ps, resp, nil } - -// GetPermissionScheme wraps GetPermissionSchemeWithContext using the background context. -func (s *ProjectService) GetPermissionScheme(projectID string) (*PermissionScheme, *Response, error) { - return s.GetPermissionSchemeWithContext(context.Background(), projectID) -} diff --git a/project_test.go b/cloud/project_test.go similarity index 52% rename from project_test.go rename to cloud/project_test.go index 6858b400..d3c877cf 100644 --- a/project_test.go +++ b/cloud/project_test.go @@ -1,52 +1,29 @@ -package jira +package cloud import ( + "context" "fmt" - "io/ioutil" "net/http" + "os" "testing" ) -func TestProjectService_GetList(t *testing.T) { +func TestProjectService_GetAll(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/api/2/project" + testapiEndpoint := "/rest/api/2/project" - raw, err := ioutil.ReadFile("./mocks/all_projects.json") + raw, err := os.ReadFile("../testing/mock-data/all_projects.json") if err != nil { t.Error(err.Error()) } - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) - fmt.Fprint(w, string(raw)) - }) - - projects, _, err := testClient.Project.GetList() - if projects == nil { - t.Error("Expected project list. Project list is nil") - } - if err != nil { - t.Errorf("Error given: %s", err) - } -} - -func TestProjectService_ListWithOptions(t *testing.T) { - setup() - defer teardown() - testAPIEdpoint := "/rest/api/2/project" - - raw, err := ioutil.ReadFile("./mocks/all_projects.json") - if err != nil { - t.Error(err.Error()) - } - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/project?expand=issueTypes") fmt.Fprint(w, string(raw)) }) - projects, _, err := testClient.Project.ListWithOptions(&GetQueryOptions{Expand: "issueTypes"}) + projects, _, err := testClient.Project.GetAll(context.Background(), &GetQueryOptions{Expand: "issueTypes"}) if projects == nil { t.Error("Expected project list. Project list is nil") } @@ -58,19 +35,19 @@ func TestProjectService_ListWithOptions(t *testing.T) { func TestProjectService_Get(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/api/2/project/12310505" + testapiEndpoint := "/rest/api/2/project/12310505" - raw, err := ioutil.ReadFile("./mocks/project.json") + raw, err := os.ReadFile("../testing/mock-data/project.json") if err != nil { t.Error(err.Error()) } - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) fmt.Fprint(w, string(raw)) }) - projects, _, err := testClient.Project.Get("12310505") + projects, _, err := testClient.Project.Get(context.Background(), "12310505") if err != nil { t.Errorf("Error given: %s", err) } @@ -86,15 +63,15 @@ func TestProjectService_Get(t *testing.T) { func TestProjectService_Get_NoProject(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/api/2/project/99999999" + testapiEndpoint := "/rest/api/2/project/99999999" - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) fmt.Fprint(w, nil) }) - projects, resp, err := testClient.Project.Get("99999999") + projects, resp, err := testClient.Project.Get(context.Background(), "99999999") if projects != nil { t.Errorf("Expected nil. Got %+v", projects) } @@ -110,15 +87,15 @@ func TestProjectService_Get_NoProject(t *testing.T) { func TestProjectService_GetPermissionScheme_Failure(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/api/2/project/99999999/permissionscheme" + testapiEndpoint := "/rest/api/2/project/99999999/permissionscheme" - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) fmt.Fprint(w, nil) }) - permissionScheme, resp, err := testClient.Project.GetPermissionScheme("99999999") + permissionScheme, resp, err := testClient.Project.GetPermissionScheme(context.Background(), "99999999") if permissionScheme != nil { t.Errorf("Expected nil. Got %+v", permissionScheme) } @@ -134,11 +111,11 @@ func TestProjectService_GetPermissionScheme_Failure(t *testing.T) { func TestProjectService_GetPermissionScheme_Success(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/api/2/project/99999999/permissionscheme" + testapiEndpoint := "/rest/api/2/project/99999999/permissionscheme" - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) fmt.Fprint(w, `{ "expand": "permissions,user,group,projectRole,field,all", "id": 10201, @@ -148,7 +125,7 @@ func TestProjectService_GetPermissionScheme_Success(t *testing.T) { }`) }) - permissionScheme, resp, err := testClient.Project.GetPermissionScheme("99999999") + permissionScheme, resp, err := testClient.Project.GetPermissionScheme(context.Background(), "99999999") if permissionScheme.ID != 10201 { t.Errorf("Expected Permission Scheme ID. Got %+v", permissionScheme) } diff --git a/request.go b/cloud/request.go similarity index 75% rename from request.go rename to cloud/request.go index a933a57a..30f8d030 100644 --- a/request.go +++ b/cloud/request.go @@ -1,14 +1,13 @@ -package jira +package cloud import ( "context" "fmt" + "net/http" ) // RequestService handles ServiceDesk customer requests for the Jira instance / API. -type RequestService struct { - client *Client -} +type RequestService service // Request represents a ServiceDesk customer request. type Request struct { @@ -56,10 +55,13 @@ type RequestComment struct { Expands []string `json:"_expands,omitempty" structs:"_expands,omitempty"` } -// CreateWithContext creates a new request. +// Create creates a new request. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-request/#api-rest-servicedeskapi-request-post -func (r *RequestService) CreateWithContext(ctx context.Context, requester string, participants []string, request *Request) (*Request, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (r *RequestService) Create(ctx context.Context, requester string, participants []string, request *Request) (*Request, *Response, error) { apiEndpoint := "rest/servicedeskapi/request" payload := struct { @@ -78,7 +80,7 @@ func (r *RequestService) CreateWithContext(ctx context.Context, requester string payload.FieldValues[field.FieldID] = field.Value } - req, err := r.client.NewRequestWithContext(ctx, "POST", apiEndpoint, payload) + req, err := r.client.NewRequest(ctx, http.MethodPost, apiEndpoint, payload) if err != nil { return nil, nil, err } @@ -92,18 +94,16 @@ func (r *RequestService) CreateWithContext(ctx context.Context, requester string return responseRequest, resp, nil } -// Create wraps CreateWithContext using the background context. -func (r *RequestService) Create(requester string, participants []string, request *Request) (*Request, *Response, error) { - return r.CreateWithContext(context.Background(), requester, participants, request) -} - -// CreateCommentWithContext creates a comment on a request. +// CreateComment creates a comment on a request. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-request/#api-rest-servicedeskapi-request-issueidorkey-comment-post -func (r *RequestService) CreateCommentWithContext(ctx context.Context, issueIDOrKey string, comment *RequestComment) (*RequestComment, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (r *RequestService) CreateComment(ctx context.Context, issueIDOrKey string, comment *RequestComment) (*RequestComment, *Response, error) { apiEndpoint := fmt.Sprintf("rest/servicedeskapi/request/%v/comment", issueIDOrKey) - req, err := r.client.NewRequestWithContext(ctx, "POST", apiEndpoint, comment) + req, err := r.client.NewRequest(ctx, http.MethodPost, apiEndpoint, comment) if err != nil { return nil, nil, err } @@ -116,8 +116,3 @@ func (r *RequestService) CreateCommentWithContext(ctx context.Context, issueIDOr return responseComment, resp, nil } - -// CreateComment wraps CreateCommentWithContext using the background context. -func (r *RequestService) CreateComment(issueIDOrKey string, comment *RequestComment) (*RequestComment, *Response, error) { - return r.CreateCommentWithContext(context.Background(), issueIDOrKey, comment) -} diff --git a/request_test.go b/cloud/request_test.go similarity index 95% rename from request_test.go rename to cloud/request_test.go index 89c73612..3cfdb61e 100644 --- a/request_test.go +++ b/cloud/request_test.go @@ -1,6 +1,7 @@ -package jira +package cloud import ( + "context" "encoding/json" "net/http" "reflect" @@ -22,7 +23,7 @@ func TestRequestService_Create(t *testing.T) { ) testMux.HandleFunc("/rest/servicedeskapi/request", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/servicedeskapi/request") var payload struct { @@ -126,7 +127,7 @@ func TestRequestService_Create(t *testing.T) { }, } - _, _, err := testClient.Request.Create(wantRequester, wantParticipants, request) + _, _, err := testClient.Request.Create(context.Background(), wantRequester, wantParticipants, request) if err != nil { t.Fatal(err) } @@ -145,7 +146,7 @@ func TestRequestService_CreateComment(t *testing.T) { defer teardown() testMux.HandleFunc("/rest/servicedeskapi/request/HELPDESK-1/comment", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/servicedeskapi/request/HELPDESK-1/comment") w.Write([]byte(`{ @@ -192,7 +193,7 @@ func TestRequestService_CreateComment(t *testing.T) { Public: true, } - _, _, err := testClient.Request.CreateComment("HELPDESK-1", comment) + _, _, err := testClient.Request.CreateComment(context.Background(), "HELPDESK-1", comment) if err != nil { t.Fatal(err) } diff --git a/resolution.go b/cloud/resolution.go similarity index 63% rename from resolution.go rename to cloud/resolution.go index e23d5650..57327a70 100644 --- a/resolution.go +++ b/cloud/resolution.go @@ -1,13 +1,14 @@ -package jira +package cloud -import "context" +import ( + "context" + "net/http" +) // ResolutionService handles resolutions for the Jira instance / API. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-Resolution -type ResolutionService struct { - client *Client -} +type ResolutionService service // Resolution represents a resolution of a Jira issue. // Typical types are "Fixed", "Suspended", "Won't Fix", ... @@ -18,12 +19,15 @@ type Resolution struct { Name string `json:"name" structs:"name"` } -// GetListWithContext gets all resolutions from Jira +// GetList gets all resolutions from Jira // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-resolution-get -func (s *ResolutionService) GetListWithContext(ctx context.Context) ([]Resolution, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ResolutionService) GetList(ctx context.Context) ([]Resolution, *Response, error) { apiEndpoint := "rest/api/2/resolution" - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -35,8 +39,3 @@ func (s *ResolutionService) GetListWithContext(ctx context.Context) ([]Resolutio } return resolutionList, resp, nil } - -// GetList wraps GetListWithContext using the background context. -func (s *ResolutionService) GetList() ([]Resolution, *Response, error) { - return s.GetListWithContext(context.Background()) -} diff --git a/cloud/resolution_test.go b/cloud/resolution_test.go new file mode 100644 index 00000000..fdc58044 --- /dev/null +++ b/cloud/resolution_test.go @@ -0,0 +1,33 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestResolutionService_GetList(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/resolution" + + raw, err := os.ReadFile("../testing/mock-data/all_resolutions.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, string(raw)) + }) + + resolution, _, err := testClient.Resolution.GetList(context.Background()) + if resolution == nil { + t.Error("Expected resolution list. Resolution list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/role.go b/cloud/role.go similarity index 67% rename from role.go rename to cloud/role.go index 66d223ff..3fd18d83 100644 --- a/role.go +++ b/cloud/role.go @@ -1,16 +1,15 @@ -package jira +package cloud import ( "context" "fmt" + "net/http" ) // RoleService handles roles for the Jira instance / API. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-group-Role -type RoleService struct { - client *Client -} +type RoleService service // Role represents a Jira product role type Role struct { @@ -36,12 +35,15 @@ type ActorUser struct { AccountID string `json:"accountId" structs:"accountId"` } -// GetListWithContext returns a list of all available project roles +// GetList returns a list of all available project roles // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-api-3-role-get -func (s *RoleService) GetListWithContext(ctx context.Context) (*[]Role, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *RoleService) GetList(ctx context.Context) (*[]Role, *Response, error) { apiEndpoint := "rest/api/3/role" - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -54,17 +56,15 @@ func (s *RoleService) GetListWithContext(ctx context.Context) (*[]Role, *Respons return roles, resp, err } -// GetList wraps GetListWithContext using the background context. -func (s *RoleService) GetList() (*[]Role, *Response, error) { - return s.GetListWithContext(context.Background()) -} - -// GetWithContext retreives a single Role from Jira +// Get retreives a single Role from Jira // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-api-3-role-id-get -func (s *RoleService) GetWithContext(ctx context.Context, roleID int) (*Role, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *RoleService) Get(ctx context.Context, roleID int) (*Role, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/3/role/%d", roleID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -80,8 +80,3 @@ func (s *RoleService) GetWithContext(ctx context.Context, roleID int) (*Role, *R return role, resp, err } - -// Get wraps GetWithContext using the background context. -func (s *RoleService) Get(roleID int) (*Role, *Response, error) { - return s.GetWithContext(context.Background(), roleID) -} diff --git a/role_test.go b/cloud/role_test.go similarity index 60% rename from role_test.go rename to cloud/role_test.go index 6e801fec..20f7e8db 100644 --- a/role_test.go +++ b/cloud/role_test.go @@ -1,9 +1,10 @@ -package jira +package cloud import ( + "context" "fmt" - "io/ioutil" "net/http" + "os" "testing" ) @@ -12,18 +13,18 @@ func TestRoleService_GetList_NoList(t *testing.T) { defer teardown() testAPIEndpoint := "/rest/api/3/role" - raw, err := ioutil.ReadFile("./mocks/no_roles.json") + raw, err := os.ReadFile("../testing/mock-data/no_roles.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, string(raw)) }) - roles, _, err := testClient.Role.GetList() + roles, _, err := testClient.Role.GetList(context.Background()) if roles != nil { t.Errorf("Expected role list has %d entries but should be nil", len(*roles)) } @@ -37,17 +38,17 @@ func TestRoleService_GetList(t *testing.T) { defer teardown() testAPIEndpoint := "/rest/api/3/role" - raw, err := ioutil.ReadFile("./mocks/all_roles.json") + raw, err := os.ReadFile("../testing/mock-data/all_roles.json") if err != nil { t.Error(err.Error()) } testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, string(raw)) }) - roles, _, err := testClient.Role.GetList() + roles, _, err := testClient.Role.GetList(context.Background()) if err != nil { t.Errorf("Error given: %v", err) } @@ -63,18 +64,18 @@ func TestRoleService_GetList(t *testing.T) { func TestRoleService_Get_NoRole(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/api/3/role/99999" - raw, err := ioutil.ReadFile("./mocks/no_role.json") + testapiEndpoint := "/rest/api/3/role/99999" + raw, err := os.ReadFile("../testing/mock-data/no_role.json") if err != nil { t.Error(err.Error()) } - testMux.HandleFunc(testAPIEdpoint, func(writer http.ResponseWriter, request *http.Request) { - testMethod(t, request, "GET") - testRequestURL(t, request, testAPIEdpoint) + testMux.HandleFunc(testapiEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testapiEndpoint) fmt.Fprint(writer, string(raw)) }) - role, _, err := testClient.Role.Get(99999) + role, _, err := testClient.Role.Get(context.Background(), 99999) if role != nil { t.Errorf("Expected nil, got role %v", role) } @@ -86,18 +87,18 @@ func TestRoleService_Get_NoRole(t *testing.T) { func TestRoleService_Get(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/api/3/role/10002" - raw, err := ioutil.ReadFile("./mocks/role.json") + testapiEndpoint := "/rest/api/3/role/10002" + raw, err := os.ReadFile("../testing/mock-data/role.json") if err != nil { t.Error(err.Error()) } - testMux.HandleFunc(testAPIEdpoint, func(writer http.ResponseWriter, request *http.Request) { - testMethod(t, request, "GET") - testRequestURL(t, request, testAPIEdpoint) + testMux.HandleFunc(testapiEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testapiEndpoint) fmt.Fprint(writer, string(raw)) }) - role, _, err := testClient.Role.Get(10002) + role, _, err := testClient.Role.Get(context.Background(), 10002) if role == nil { t.Errorf("Expected Role, got nil") } diff --git a/servicedesk.go b/cloud/servicedesk.go similarity index 54% rename from servicedesk.go rename to cloud/servicedesk.go index 7fbffc0b..dac54f7a 100644 --- a/servicedesk.go +++ b/cloud/servicedesk.go @@ -1,36 +1,37 @@ -package jira +package cloud import ( "context" "encoding/json" "fmt" "io" - "io/ioutil" + "net/http" "github.com/google/go-querystring/query" ) // ServiceDeskService handles ServiceDesk for the Jira instance / API. -type ServiceDeskService struct { - client *Client -} +type ServiceDeskService service // ServiceDeskOrganizationDTO is a DTO for ServiceDesk organizations type ServiceDeskOrganizationDTO struct { OrganizationID int `json:"organizationId,omitempty" structs:"organizationId,omitempty"` } -// GetOrganizationsWithContext returns a list of +// GetOrganizations returns a list of // all organizations associated with a service desk. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-servicedesk-servicedeskid-organization-get -func (s *ServiceDeskService) GetOrganizationsWithContext(ctx context.Context, serviceDeskID interface{}, start int, limit int, accountID string) (*PagedDTO, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ServiceDeskService) GetOrganizations(ctx context.Context, serviceDeskID interface{}, start int, limit int, accountID string) (*PagedDTO, *Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/servicedesk/%v/organization?start=%d&limit=%d", serviceDeskID, start, limit) if accountID != "" { apiEndPoint += fmt.Sprintf("&accountId=%s", accountID) } - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndPoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) req.Header.Set("Accept", "application/json") if err != nil { @@ -47,26 +48,24 @@ func (s *ServiceDeskService) GetOrganizationsWithContext(ctx context.Context, se return orgs, resp, nil } -// GetOrganizations wraps GetOrganizationsWithContext using the background context. -func (s *ServiceDeskService) GetOrganizations(serviceDeskID interface{}, start int, limit int, accountID string) (*PagedDTO, *Response, error) { - return s.GetOrganizationsWithContext(context.Background(), serviceDeskID, start, limit, accountID) -} - -// AddOrganizationWithContext adds an organization to +// AddOrganization adds an organization to // a service desk. If the organization ID is already // associated with the service desk, no change is made // and the resource returns a 204 success code. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-servicedesk-servicedeskid-organization-post // Caller must close resp.Body -func (s *ServiceDeskService) AddOrganizationWithContext(ctx context.Context, serviceDeskID interface{}, organizationID int) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ServiceDeskService) AddOrganization(ctx context.Context, serviceDeskID interface{}, organizationID int) (*Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/servicedesk/%v/organization", serviceDeskID) organization := ServiceDeskOrganizationDTO{ OrganizationID: organizationID, } - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndPoint, organization) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndPoint, organization) if err != nil { return nil, err @@ -81,27 +80,24 @@ func (s *ServiceDeskService) AddOrganizationWithContext(ctx context.Context, ser return resp, nil } -// AddOrganization wraps AddOrganizationWithContext using the background context. -// Caller must close resp.Body -func (s *ServiceDeskService) AddOrganization(serviceDeskID interface{}, organizationID int) (*Response, error) { - return s.AddOrganizationWithContext(context.Background(), serviceDeskID, organizationID) -} - -// RemoveOrganizationWithContext removes an organization +// RemoveOrganization removes an organization // from a service desk. If the organization ID does not // match an organization associated with the service desk, // no change is made and the resource returns a 204 success code. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-servicedesk-servicedeskid-organization-delete // Caller must close resp.Body -func (s *ServiceDeskService) RemoveOrganizationWithContext(ctx context.Context, serviceDeskID interface{}, organizationID int) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ServiceDeskService) RemoveOrganization(ctx context.Context, serviceDeskID interface{}, organizationID int) (*Response, error) { apiEndPoint := fmt.Sprintf("rest/servicedeskapi/servicedesk/%v/organization", serviceDeskID) organization := ServiceDeskOrganizationDTO{ OrganizationID: organizationID, } - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndPoint, organization) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndPoint, organization) if err != nil { return nil, err @@ -116,16 +112,13 @@ func (s *ServiceDeskService) RemoveOrganizationWithContext(ctx context.Context, return resp, nil } -// RemoveOrganization wraps RemoveOrganizationWithContext using the background context. -// Caller must close resp.Body -func (s *ServiceDeskService) RemoveOrganization(serviceDeskID interface{}, organizationID int) (*Response, error) { - return s.RemoveOrganizationWithContext(context.Background(), serviceDeskID, organizationID) -} - -// AddCustomersWithContext adds customers to the given service desk. +// AddCustomers adds customers to the given service desk. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-servicedesk/#api-rest-servicedeskapi-servicedesk-servicedeskid-customer-post -func (s *ServiceDeskService) AddCustomersWithContext(ctx context.Context, serviceDeskID interface{}, acountIDs ...string) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ServiceDeskService) AddCustomers(ctx context.Context, serviceDeskID interface{}, acountIDs ...string) (*Response, error) { apiEndpoint := fmt.Sprintf("rest/servicedeskapi/servicedesk/%v/customer", serviceDeskID) payload := struct { @@ -133,7 +126,7 @@ func (s *ServiceDeskService) AddCustomersWithContext(ctx context.Context, servic }{ AccountIDs: acountIDs, } - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, payload) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, payload) if err != nil { return nil, err } @@ -144,20 +137,18 @@ func (s *ServiceDeskService) AddCustomersWithContext(ctx context.Context, servic } defer resp.Body.Close() - _, _ = io.Copy(ioutil.Discard, resp.Body) + _, _ = io.Copy(io.Discard, resp.Body) return resp, nil } -// AddCustomers wraps AddCustomersWithContext using the background context. -func (s *ServiceDeskService) AddCustomers(serviceDeskID interface{}, acountIDs ...string) (*Response, error) { - return s.AddCustomersWithContext(context.Background(), serviceDeskID, acountIDs...) -} - -// RemoveCustomersWithContext removes customers to the given service desk. +// RemoveCustomers removes customers to the given service desk. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-servicedesk/#api-rest-servicedeskapi-servicedesk-servicedeskid-customer-delete -func (s *ServiceDeskService) RemoveCustomersWithContext(ctx context.Context, serviceDeskID interface{}, acountIDs ...string) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ServiceDeskService) RemoveCustomers(ctx context.Context, serviceDeskID interface{}, acountIDs ...string) (*Response, error) { apiEndpoint := fmt.Sprintf("rest/servicedeskapi/servicedesk/%v/customer", serviceDeskID) payload := struct { @@ -165,7 +156,7 @@ func (s *ServiceDeskService) RemoveCustomersWithContext(ctx context.Context, ser }{ AccountIDs: acountIDs, } - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndpoint, payload) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, payload) if err != nil { return nil, err } @@ -176,22 +167,20 @@ func (s *ServiceDeskService) RemoveCustomersWithContext(ctx context.Context, ser } defer resp.Body.Close() - _, _ = io.Copy(ioutil.Discard, resp.Body) + _, _ = io.Copy(io.Discard, resp.Body) return resp, nil } -// RemoveCustomers wraps RemoveCustomersWithContext using the background context. -func (s *ServiceDeskService) RemoveCustomers(serviceDeskID interface{}, acountIDs ...string) (*Response, error) { - return s.RemoveCustomersWithContext(context.Background(), serviceDeskID, acountIDs...) -} - -// ListCustomersWithContext lists customers for a ServiceDesk. +// ListCustomers lists customers for a ServiceDesk. // // https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-servicedesk/#api-rest-servicedeskapi-servicedesk-servicedeskid-customer-get -func (s *ServiceDeskService) ListCustomersWithContext(ctx context.Context, serviceDeskID interface{}, options *CustomerListOptions) (*CustomerList, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ServiceDeskService) ListCustomers(ctx context.Context, serviceDeskID interface{}, options *CustomerListOptions) (*CustomerList, *Response, error) { apiEndpoint := fmt.Sprintf("rest/servicedeskapi/servicedesk/%v/customer", serviceDeskID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -220,8 +209,3 @@ func (s *ServiceDeskService) ListCustomersWithContext(ctx context.Context, servi return customerList, resp, nil } - -// ListCustomers wraps ListCustomersWithContext using the background context. -func (s *ServiceDeskService) ListCustomers(serviceDeskID interface{}, options *CustomerListOptions) (*CustomerList, *Response, error) { - return s.ListCustomersWithContext(context.Background(), serviceDeskID, options) -} diff --git a/servicedesk_test.go b/cloud/servicedesk_test.go similarity index 90% rename from servicedesk_test.go rename to cloud/servicedesk_test.go index 909ede3d..dc4c3f5d 100644 --- a/servicedesk_test.go +++ b/cloud/servicedesk_test.go @@ -1,6 +1,7 @@ -package jira +package cloud import ( + "context" "encoding/json" "fmt" "net/http" @@ -14,7 +15,7 @@ func TestServiceDeskService_GetOrganizations(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/servicedesk/10001/organization", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/servicedeskapi/servicedesk/10001/organization") w.WriteHeader(http.StatusOK) @@ -56,7 +57,7 @@ func TestServiceDeskService_GetOrganizations(t *testing.T) { }`) }) - orgs, _, err := testClient.ServiceDesk.GetOrganizations(10001, 3, 3, "") + orgs, _, err := testClient.ServiceDesk.GetOrganizations(context.Background(), 10001, 3, 3, "") if orgs == nil { t.Error("Expected Organizations. Result is nil") @@ -73,13 +74,13 @@ func TestServiceDeskService_AddOrganizations(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/servicedesk/10001/organization", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/servicedeskapi/servicedesk/10001/organization") w.WriteHeader(http.StatusNoContent) }) - _, err := testClient.ServiceDesk.AddOrganization(10001, 1) + _, err := testClient.ServiceDesk.AddOrganization(context.Background(), 10001, 1) if err != nil { t.Errorf("Error given: %s", err) @@ -90,13 +91,13 @@ func TestServiceDeskService_RemoveOrganizations(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/servicedesk/10001/organization", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/servicedeskapi/servicedesk/10001/organization") w.WriteHeader(http.StatusNoContent) }) - _, err := testClient.ServiceDesk.RemoveOrganization(10001, 1) + _, err := testClient.ServiceDesk.RemoveOrganization(context.Background(), 10001, 1) if err != nil { t.Errorf("Error given: %s", err) @@ -107,7 +108,7 @@ func TestServiceDeskServiceStringServiceDeskID_GetOrganizations(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/servicedesk/TEST/organization", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/servicedeskapi/servicedesk/TEST/organization") w.WriteHeader(http.StatusOK) @@ -149,7 +150,7 @@ func TestServiceDeskServiceStringServiceDeskID_GetOrganizations(t *testing.T) { }`) }) - orgs, _, err := testClient.ServiceDesk.GetOrganizations("TEST", 3, 3, "") + orgs, _, err := testClient.ServiceDesk.GetOrganizations(context.Background(), "TEST", 3, 3, "") if orgs == nil { t.Error("Expected Organizations. Result is nil") @@ -166,13 +167,13 @@ func TestServiceDeskServiceStringServiceDeskID_AddOrganizations(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/servicedesk/TEST/organization", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/servicedeskapi/servicedesk/TEST/organization") w.WriteHeader(http.StatusNoContent) }) - _, err := testClient.ServiceDesk.AddOrganization("TEST", 1) + _, err := testClient.ServiceDesk.AddOrganization(context.Background(), "TEST", 1) if err != nil { t.Errorf("Error given: %s", err) @@ -183,13 +184,13 @@ func TestServiceDeskServiceStringServiceDeskID_RemoveOrganizations(t *testing.T) setup() defer teardown() testMux.HandleFunc("/rest/servicedeskapi/servicedesk/TEST/organization", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/servicedeskapi/servicedesk/TEST/organization") w.WriteHeader(http.StatusNoContent) }) - _, err := testClient.ServiceDesk.RemoveOrganization("TEST", 1) + _, err := testClient.ServiceDesk.RemoveOrganization(context.Background(), "TEST", 1) if err != nil { t.Errorf("Error given: %s", err) @@ -226,7 +227,7 @@ func TestServiceDeskService_AddCustomers(t *testing.T) { ) testMux.HandleFunc(fmt.Sprintf("/rest/servicedeskapi/servicedesk/%v/customer", test.serviceDeskID), func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, fmt.Sprintf("/rest/servicedeskapi/servicedesk/%v/customer", test.serviceDeskID)) var payload struct { @@ -242,7 +243,7 @@ func TestServiceDeskService_AddCustomers(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - _, err := testClient.ServiceDesk.AddCustomers(test.serviceDeskID, wantAccountIDs...) + _, err := testClient.ServiceDesk.AddCustomers(context.Background(), test.serviceDeskID, wantAccountIDs...) if err != nil { t.Errorf("Error given: %s", err) @@ -292,7 +293,7 @@ func TestServiceDeskService_RemoveCustomers(t *testing.T) { ) testMux.HandleFunc(fmt.Sprintf("/rest/servicedeskapi/servicedesk/%v/customer", test.serviceDeskID), func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, fmt.Sprintf("/rest/servicedeskapi/servicedesk/%v/customer", test.serviceDeskID)) var payload struct { @@ -308,7 +309,7 @@ func TestServiceDeskService_RemoveCustomers(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - _, err := testClient.ServiceDesk.RemoveCustomers(test.serviceDeskID, wantAccountIDs...) + _, err := testClient.ServiceDesk.RemoveCustomers(context.Background(), test.serviceDeskID, wantAccountIDs...) if err != nil { t.Errorf("Error given: %s", err) @@ -361,7 +362,7 @@ func TestServiceDeskService_ListCustomers(t *testing.T) { ) testMux.HandleFunc(fmt.Sprintf("/rest/servicedeskapi/servicedesk/%v/customer", test.serviceDeskID), func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, fmt.Sprintf("/rest/servicedeskapi/servicedesk/%v/customer", test.serviceDeskID)) qs := r.URL.Query() @@ -409,7 +410,7 @@ func TestServiceDeskService_ListCustomers(t *testing.T) { }`)) }) - customerList, _, err := testClient.ServiceDesk.ListCustomers(test.serviceDeskID, wantOptions) + customerList, _, err := testClient.ServiceDesk.ListCustomers(context.Background(), test.serviceDeskID, wantOptions) if err != nil { t.Fatal(err) } diff --git a/sprint.go b/cloud/sprint.go similarity index 57% rename from sprint.go rename to cloud/sprint.go index f0f98d6f..ca4f83a7 100644 --- a/sprint.go +++ b/cloud/sprint.go @@ -1,17 +1,16 @@ -package jira +package cloud import ( "context" "fmt" + "net/http" "github.com/google/go-querystring/query" ) // SprintService handles sprints in Jira Agile API. // See https://docs.atlassian.com/jira-software/REST/cloud/ -type SprintService struct { - client *Client -} +type SprintService service // IssuesWrapper represents a wrapper struct for moving issues to sprint type IssuesWrapper struct { @@ -23,18 +22,21 @@ type IssuesInSprintResult struct { Issues []Issue `json:"issues"` } -// MoveIssuesToSprintWithContext moves issues to a sprint, for a given sprint Id. +// MoveIssuesToSprint moves issues to a sprint, for a given sprint Id. // Issues can only be moved to open or active sprints. // The maximum number of issues that can be moved in one operation is 50. // // Jira API docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/sprint-moveIssuesToSprint // Caller must close resp.Body -func (s *SprintService) MoveIssuesToSprintWithContext(ctx context.Context, sprintID int, issueIDs []string) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *SprintService) MoveIssuesToSprint(ctx context.Context, sprintID int, issueIDs []string) (*Response, error) { apiEndpoint := fmt.Sprintf("rest/agile/1.0/sprint/%d/issue", sprintID) payload := IssuesWrapper{Issues: issueIDs} - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, payload) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, payload) if err != nil { return nil, err @@ -47,21 +49,18 @@ func (s *SprintService) MoveIssuesToSprintWithContext(ctx context.Context, sprin return resp, err } -// MoveIssuesToSprint wraps MoveIssuesToSprintWithContext using the background context. -// Caller must close resp.Body -func (s *SprintService) MoveIssuesToSprint(sprintID int, issueIDs []string) (*Response, error) { - return s.MoveIssuesToSprintWithContext(context.Background(), sprintID, issueIDs) -} - -// GetIssuesForSprintWithContext returns all issues in a sprint, for a given sprint Id. +// GetIssuesForSprint returns all issues in a sprint, for a given sprint Id. // This only includes issues that the user has permission to view. // By default, the returned issues are ordered by rank. // // Jira API Docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/sprint-getIssuesForSprint -func (s *SprintService) GetIssuesForSprintWithContext(ctx context.Context, sprintID int) ([]Issue, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *SprintService) GetIssuesForSprint(ctx context.Context, sprintID int) ([]Issue, *Response, error) { apiEndpoint := fmt.Sprintf("rest/agile/1.0/sprint/%d/issue", sprintID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err @@ -76,25 +75,23 @@ func (s *SprintService) GetIssuesForSprintWithContext(ctx context.Context, sprin return result.Issues, resp, err } -// GetIssuesForSprint wraps GetIssuesForSprintWithContext using the background context. -func (s *SprintService) GetIssuesForSprint(sprintID int) ([]Issue, *Response, error) { - return s.GetIssuesForSprintWithContext(context.Background(), sprintID) -} - -// GetIssueWithContext returns a full representation of the issue for the given issue key. +// GetIssue returns a full representation of the issue for the given issue key. // Jira will attempt to identify the issue by the issueIdOrKey path parameter. // This can be an issue id, or an issue key. // If the issue cannot be found via an exact match, Jira will also look for the issue in a case-insensitive way, or by looking to see if the issue was moved. // -// The given options will be appended to the query string +// # The given options will be appended to the query string // // Jira API docs: https://docs.atlassian.com/jira-software/REST/7.3.1/#agile/1.0/issue-getIssue // // TODO: create agile service for holding all agile apis' implementation -func (s *SprintService) GetIssueWithContext(ctx context.Context, issueID string, options *GetQueryOptions) (*Issue, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *SprintService) GetIssue(ctx context.Context, issueID string, options *GetQueryOptions) (*Issue, *Response, error) { apiEndpoint := fmt.Sprintf("rest/agile/1.0/issue/%s", issueID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err @@ -118,8 +115,3 @@ func (s *SprintService) GetIssueWithContext(ctx context.Context, issueID string, return issue, resp, nil } - -// GetIssue wraps GetIssueWithContext using the background context. -func (s *SprintService) GetIssue(issueID string, options *GetQueryOptions) (*Issue, *Response, error) { - return s.GetIssueWithContext(context.Background(), issueID, options) -} diff --git a/sprint_test.go b/cloud/sprint_test.go similarity index 92% rename from sprint_test.go rename to cloud/sprint_test.go index 64125dbc..dbc02de5 100644 --- a/sprint_test.go +++ b/cloud/sprint_test.go @@ -1,10 +1,11 @@ -package jira +package cloud import ( + "context" "encoding/json" "fmt" - "io/ioutil" "net/http" + "os" "reflect" "testing" ) @@ -18,7 +19,7 @@ func TestSprintService_MoveIssuesToSprint(t *testing.T) { issuesToMove := []string{"KEY-1", "KEY-2"} testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, testAPIEndpoint) decoder := json.NewDecoder(r.Body) @@ -32,7 +33,7 @@ func TestSprintService_MoveIssuesToSprint(t *testing.T) { t.Errorf("Expected %s to be in payload, got %s instead", issuesToMove[0], payload.Issues[0]) } }) - _, err := testClient.Sprint.MoveIssuesToSprint(123, issuesToMove) + _, err := testClient.Sprint.MoveIssuesToSprint(context.Background(), 123, issuesToMove) if err != nil { t.Errorf("Got error: %v", err) @@ -42,19 +43,19 @@ func TestSprintService_MoveIssuesToSprint(t *testing.T) { func TestSprintService_GetIssuesForSprint(t *testing.T) { setup() defer teardown() - testAPIEdpoint := "/rest/agile/1.0/sprint/123/issue" + testapiEndpoint := "/rest/agile/1.0/sprint/123/issue" - raw, err := ioutil.ReadFile("./mocks/issues_in_sprint.json") + raw, err := os.ReadFile("../testing/mock-data/issues_in_sprint.json") if err != nil { t.Error(err.Error()) } - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) fmt.Fprint(w, string(raw)) }) - issues, _, err := testClient.Sprint.GetIssuesForSprint(123) + issues, _, err := testClient.Sprint.GetIssuesForSprint(context.Background(), 123) if err != nil { t.Errorf("Error given: %v", err) } @@ -74,12 +75,12 @@ func TestSprintService_GetIssue(t *testing.T) { testAPIEndpoint := "/rest/agile/1.0/issue/10002" testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, testAPIEndpoint) fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"labels":["test"],"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"sprint": {"id": 37,"self": "http://www.example.com/jira/rest/agile/1.0/sprint/13", "state": "future", "name": "sprint 2"}, "epic": {"id": 19415,"key": "EPIC-77","self": "https://example.atlassian.net/rest/agile/1.0/epic/19415","name": "Epic Name","summary": "Do it","color": {"key": "color_11"},"done": false},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) }) - issue, _, err := testClient.Sprint.GetIssue("10002", nil) + issue, _, err := testClient.Sprint.GetIssue(context.Background(), "10002", nil) if err != nil { t.Errorf("Error given: %s", err) } diff --git a/status.go b/cloud/status.go similarity index 67% rename from status.go rename to cloud/status.go index a3703929..910cb361 100644 --- a/status.go +++ b/cloud/status.go @@ -1,13 +1,14 @@ -package jira +package cloud -import "context" +import ( + "context" + "net/http" +) // StatusService handles staties for the Jira instance / API. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-group-Workflow-statuses -type StatusService struct { - client *Client -} +type StatusService service // Status represents the current status of a Jira issue. // Typical status are "Open", "In Progress", "Closed", ... @@ -21,12 +22,15 @@ type Status struct { StatusCategory StatusCategory `json:"statusCategory" structs:"statusCategory"` } -// GetAllStatusesWithContext returns a list of all statuses associated with workflows. +// GetAllStatuses returns a list of all statuses associated with workflows. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-status-get -func (s *StatusService) GetAllStatusesWithContext(ctx context.Context) ([]Status, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *StatusService) GetAllStatuses(ctx context.Context) ([]Status, *Response, error) { apiEndpoint := "rest/api/2/status" - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err @@ -40,8 +44,3 @@ func (s *StatusService) GetAllStatusesWithContext(ctx context.Context) ([]Status return statusList, resp, nil } - -// GetAllStatuses wraps GetAllStatusesWithContext using the background context. -func (s *StatusService) GetAllStatuses() ([]Status, *Response, error) { - return s.GetAllStatusesWithContext(context.Background()) -} diff --git a/cloud/status_test.go b/cloud/status_test.go new file mode 100644 index 00000000..c217ecf1 --- /dev/null +++ b/cloud/status_test.go @@ -0,0 +1,36 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestStatusService_GetAllStatuses(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/status" + + raw, err := os.ReadFile("../testing/mock-data/all_statuses.json") + if err != nil { + t.Error(err.Error()) + } + + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, string(raw)) + }) + + statusList, _, err := testClient.Status.GetAllStatuses(context.Background()) + + if statusList == nil { + t.Error("Expected statusList. statusList is nill") + } + + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/cloud/statuscategory.go b/cloud/statuscategory.go new file mode 100644 index 00000000..5918f7cd --- /dev/null +++ b/cloud/statuscategory.go @@ -0,0 +1,79 @@ +package cloud + +import ( + "context" + "errors" + "fmt" + "net/http" +) + +// StatusCategoryService handles status categories for the Jira instance / API. +// +// Use it to obtain a list of all status categories and the details of a category. +// Status categories provided a mechanism for categorizing statuses. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-workflow-status-categories/#api-group-workflow-status-categories +type StatusCategoryService service + +// StatusCategory represents the category a status belongs to. +// Those categories can be user defined in every Jira instance. +type StatusCategory struct { + Self string `json:"self" structs:"self"` + ID int `json:"id" structs:"id"` + Name string `json:"name" structs:"name"` + Key string `json:"key" structs:"key"` + ColorName string `json:"colorName" structs:"colorName"` +} + +// These constants are the keys of the default Jira status categories +const ( + StatusCategoryComplete = "done" + StatusCategoryInProgress = "indeterminate" + StatusCategoryToDo = "new" + StatusCategoryUndefined = "undefined" +) + +// GetList returns a list of all status categories. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-workflow-status-categories/#api-rest-api-3-statuscategory-get +func (s *StatusCategoryService) GetList(ctx context.Context) ([]StatusCategory, *Response, error) { + apiEndpoint := "/rest/api/3/statuscategory" + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + var statusCategories []StatusCategory + resp, err := s.client.Do(req, &statusCategories) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return statusCategories, resp, nil +} + +// Get returns a status category. +// Status categories provided a mechanism for categorizing statuses. +// +// statusCategoryID represents the ID or key of the status category. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-workflow-status-categories/#api-rest-api-3-statuscategory-idorkey-get +func (s *StatusCategoryService) Get(ctx context.Context, statusCategoryID string) (*StatusCategory, *Response, error) { + if statusCategoryID == "" { + return nil, nil, errors.New("no status category id set") + } + + apiEndpoint := fmt.Sprintf("/rest/api/3/statuscategory/%v", statusCategoryID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + statusCategory := new(StatusCategory) + resp, err := s.client.Do(req, statusCategory) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return statusCategory, resp, nil +} diff --git a/cloud/statuscategory_test.go b/cloud/statuscategory_test.go new file mode 100644 index 00000000..af7aab80 --- /dev/null +++ b/cloud/statuscategory_test.go @@ -0,0 +1,66 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestStatusCategoryService_GetList(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/3/statuscategory" + + raw, err := os.ReadFile("../testing/mock-data/all_statuscategories.json") + if err != nil { + t.Error(err.Error()) + } + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + statusCategory, _, err := testClient.StatusCategory.GetList(context.Background()) + if statusCategory == nil { + t.Error("Expected statusCategory list. StatusCategory list is nil") + } + if l := len(statusCategory); l != 4 { + t.Errorf("Expected 4 statusCategory list items. Got %d", l) + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestStatusCategoryService_Get(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/3/statuscategory/1" + + raw, err := os.ReadFile("../testing/mock-data/status_category.json") + if err != nil { + t.Error(err.Error()) + } + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + statusCategory, _, err := testClient.StatusCategory.Get(context.Background(), "1") + if err != nil { + t.Errorf("Error given: %s", err) + + } else if statusCategory == nil { + t.Error("Expected status category. StatusCategory is nil") + + // Checking testdata + } else if statusCategory.ColorName != "medium-gray" { + t.Errorf("Expected statusCategory.ColorName to be 'medium-gray'. Got '%s'", statusCategory.ColorName) + } +} diff --git a/types.go b/cloud/types.go similarity index 92% rename from types.go rename to cloud/types.go index b99fc1c3..1dc861c2 100644 --- a/types.go +++ b/cloud/types.go @@ -1,4 +1,4 @@ -package jira +package cloud // Bool is a helper routine that allocates a new bool value // to store v and returns a pointer to it. diff --git a/cloud/user.go b/cloud/user.go new file mode 100644 index 00000000..3453a72e --- /dev/null +++ b/cloud/user.go @@ -0,0 +1,303 @@ +package cloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// UserService handles users for the Jira instance / API. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-group-Users +type UserService service + +// User represents a Jira user. +type User struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + AccountID string `json:"accountId,omitempty" structs:"accountId,omitempty"` + AccountType string `json:"accountType,omitempty" structs:"accountType,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + Key string `json:"key,omitempty" structs:"key,omitempty"` + Password string `json:"-"` + EmailAddress string `json:"emailAddress,omitempty" structs:"emailAddress,omitempty"` + AvatarUrls AvatarUrls `json:"avatarUrls,omitempty" structs:"avatarUrls,omitempty"` + DisplayName string `json:"displayName,omitempty" structs:"displayName,omitempty"` + Active bool `json:"active,omitempty" structs:"active,omitempty"` + TimeZone string `json:"timeZone,omitempty" structs:"timeZone,omitempty"` + Locale string `json:"locale,omitempty" structs:"locale,omitempty"` + Groups UserGroups `json:"groups,omitempty" structs:"groups,omitempty"` + ApplicationRoles ApplicationRoles `json:"applicationRoles,omitempty" structs:"applicationRoles,omitempty"` +} + +// UserGroup represents the group list +type UserGroup struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` +} + +// Groups is a wrapper for UserGroup +type UserGroups struct { + Size int `json:"size,omitempty" structs:"size,omitempty"` + Items []UserGroup `json:"items,omitempty" structs:"items,omitempty"` +} + +// ApplicationRoles is a wrapper for ApplicationRole +type ApplicationRoles struct { + Size int `json:"size,omitempty" structs:"size,omitempty"` + Items []ApplicationRole `json:"items,omitempty" structs:"items,omitempty"` +} + +// ApplicationRole represents a role assigned to a user +type ApplicationRole struct { + Key string `json:"key"` + Groups []string `json:"groups"` + Name string `json:"name"` + DefaultGroups []string `json:"defaultGroups"` + SelectedByDefault bool `json:"selectedByDefault"` + Defined bool `json:"defined"` + NumberOfSeats int `json:"numberOfSeats"` + RemainingSeats int `json:"remainingSeats"` + UserCount int `json:"userCount"` + UserCountDescription string `json:"userCountDescription"` + HasUnlimitedSeats bool `json:"hasUnlimitedSeats"` + Platform bool `json:"platform"` + + // Key `groupDetails` missing - https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-application-roles/#api-rest-api-3-applicationrole-key-get + // Key `defaultGroupsDetails` missing - https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-application-roles/#api-rest-api-3-applicationrole-key-get +} + +type userSearchParam struct { + name string + value string +} + +type userSearch []userSearchParam + +type userSearchF func(userSearch) userSearch + +// Get gets user info from Jira using its Account Id +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-user-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) Get(ctx context.Context, accountId string) (*User, *Response, error) { + apiEndpoint := fmt.Sprintf("/rest/api/2/user?accountId=%s", accountId) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + user := new(User) + resp, err := s.client.Do(req, user) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + return user, resp, nil +} + +// GetByAccountID gets user info from Jira +// Searching by another parameter that is not accountId is deprecated, +// but this method is kept for backwards compatibility +// Jira API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/user-getUser +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) GetByAccountID(ctx context.Context, accountID string) (*User, *Response, error) { + apiEndpoint := fmt.Sprintf("/rest/api/2/user?accountId=%s", accountID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + user := new(User) + resp, err := s.client.Do(req, user) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + return user, resp, nil +} + +// Create creates an user in Jira. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/user-createUser +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) Create(ctx context.Context, user *User) (*User, *Response, error) { + apiEndpoint := "/rest/api/2/user" + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, user) + if err != nil { + return nil, nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + return nil, resp, err + } + defer resp.Body.Close() + + responseUser := new(User) + err = json.NewDecoder(resp.Body).Decode(&responseUser) + if err != nil { + return nil, resp, err + } + + return responseUser, resp, nil +} + +// Delete deletes an user from Jira. +// Returns http.StatusNoContent on success. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-user-delete +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) Delete(ctx context.Context, accountId string) (*Response, error) { + apiEndpoint := fmt.Sprintf("/rest/api/2/user?accountId=%s", accountId) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + return resp, NewJiraError(resp, err) + } + return resp, nil +} + +// GetGroups returns the groups which the user belongs to +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-user-groups-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) GetGroups(ctx context.Context, accountId string) (*[]UserGroup, *Response, error) { + apiEndpoint := fmt.Sprintf("/rest/api/2/user/groups?accountId=%s", accountId) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + userGroups := new([]UserGroup) + resp, err := s.client.Do(req, userGroups) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + return userGroups, resp, nil +} + +// GetCurrentUser returns details for the current user. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-myself/#api-rest-api-3-myself-get +func (s *UserService) GetCurrentUser(ctx context.Context) (*User, *Response, error) { + const apiEndpoint = "rest/api/3/myself" + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + var user User + resp, err := s.client.Do(req, &user) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return &user, resp, nil +} + +// WithMaxResults sets the max results to return +func WithMaxResults(maxResults int) userSearchF { + return func(s userSearch) userSearch { + s = append(s, userSearchParam{name: "maxResults", value: fmt.Sprintf("%d", maxResults)}) + return s + } +} + +// WithStartAt set the start pager +func WithStartAt(startAt int) userSearchF { + return func(s userSearch) userSearch { + s = append(s, userSearchParam{name: "startAt", value: fmt.Sprintf("%d", startAt)}) + return s + } +} + +// WithActive sets the active users lookup +func WithActive(active bool) userSearchF { + return func(s userSearch) userSearch { + s = append(s, userSearchParam{name: "includeActive", value: fmt.Sprintf("%t", active)}) + return s + } +} + +// WithInactive sets the inactive users lookup +func WithInactive(inactive bool) userSearchF { + return func(s userSearch) userSearch { + s = append(s, userSearchParam{name: "includeInactive", value: fmt.Sprintf("%t", inactive)}) + return s + } +} + +// WithUsername sets the username to search +func WithUsername(username string) userSearchF { + return func(s userSearch) userSearch { + s = append(s, userSearchParam{name: "username", value: username}) + return s + } +} + +// WithAccountId sets the account id to search +func WithAccountId(accountId string) userSearchF { + return func(s userSearch) userSearch { + s = append(s, userSearchParam{name: "accountId", value: accountId}) + return s + } +} + +// WithProperty sets the property (Property keys are specified by path) to search +func WithProperty(property string) userSearchF { + return func(s userSearch) userSearch { + s = append(s, userSearchParam{name: "property", value: property}) + return s + } +} + +// Find searches for user info from Jira: +// It can find users by email or display name using the query parameter +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-user-search-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) Find(ctx context.Context, property string, tweaks ...userSearchF) ([]User, *Response, error) { + search := []userSearchParam{ + { + name: "query", + value: property, + }, + } + for _, f := range tweaks { + search = f(search) + } + + var queryString = "" + for _, param := range search { + queryString += param.name + "=" + param.value + "&" + } + + apiEndpoint := fmt.Sprintf("/rest/api/2/user/search?%s", queryString[:len(queryString)-1]) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + users := []User{} + resp, err := s.client.Do(req, &users) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + return users, resp, nil +} diff --git a/cloud/user_test.go b/cloud/user_test.go new file mode 100644 index 00000000..7e5fdb26 --- /dev/null +++ b/cloud/user_test.go @@ -0,0 +1,284 @@ +package cloud + +import ( + "context" + "fmt" + "net/http" + "testing" +) + +func TestUserService_Get_Success(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/user?accountId=000000000000000000000000") + + fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","key":"fred", + "name":"fred","emailAddress":"fred@example.com","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":true,"timeZone":"Australia/Sydney","groups":{"size":3,"items":[ + {"name":"jira-user","self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-user"},{"name":"jira-admin", + "self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-admin"},{"name":"important","self":"http://www.example.com/jira/rest/api/2/group?groupname=important" + }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}`) + }) + + if user, _, err := testClient.User.Get(context.Background(), "000000000000000000000000"); err != nil { + t.Errorf("Error given: %s", err) + } else if user == nil { + t.Error("Expected user. User is nil") + } +} + +func TestUserService_GetByAccountID_Success(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/user?accountId=000000000000000000000000") + + fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000","accountId": "000000000000000000000000", + "name":"fred","emailAddress":"fred@example.com","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":true,"timeZone":"Australia/Sydney","groups":{"size":3,"items":[ + {"name":"jira-user","self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-user"},{"name":"jira-admin", + "self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-admin"},{"name":"important","self":"http://www.example.com/jira/rest/api/2/group?groupname=important" + }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}`) + }) + + if user, _, err := testClient.User.GetByAccountID(context.Background(), "000000000000000000000000"); err != nil { + t.Errorf("Error given: %s", err) + } else if user == nil { + t.Error("Expected user. User is nil") + } +} + +func TestUserService_Create(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/user") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, ` + { + "name": "charlie", + "password": "abracadabra", + "emailAddress": "charlie@atlassian.com", + "displayName": "Charlie of Atlassian", + "applicationRoles": { + "size": 1, + "max-results": 1, + "items": [{ + "key": "jira-software", + "groups": [ + "jira-software-users", + "jira-testers" + ], + "name": "Jira Software", + "defaultGroups": [ + "jira-software-users" + ], + "selectedByDefault": false, + "defined": false, + "numberOfSeats": 10, + "remainingSeats": 5, + "userCount": 5, + "userCountDescription": "5 developers", + "hasUnlimitedSeats": false, + "platform": false + }] + }, + "groups": { + "size": 2, + "max-results": 2, + "items": [{ + "name": "jira-core", + "self": "jira-core" + }, + { + "name": "jira-test", + "self": "jira-test" + } + ] + } + } + `) + }) + + u := &User{ + Name: "charlie", + Password: "abracadabra", + EmailAddress: "charlie@atlassian.com", + DisplayName: "Charlie of Atlassian", + Groups: UserGroups{ + Size: 2, + Items: []UserGroup{ + { + Name: "jira-core", + Self: "jira-core", + }, + { + Name: "jira-test", + Self: "jira-test", + }, + }, + }, + ApplicationRoles: ApplicationRoles{ + Size: 1, + Items: []ApplicationRole{ + { + Key: "jira-software", + Groups: []string{"jira-software-users", "jira-testers"}, + Name: "Jira Software", + DefaultGroups: []string{"jira-software-users"}, + SelectedByDefault: false, + Defined: false, + NumberOfSeats: 10, + RemainingSeats: 5, + UserCount: 5, + UserCountDescription: "5 developers", + HasUnlimitedSeats: false, + Platform: false, + }, + }, + }, + } + + if user, _, err := testClient.User.Create(context.Background(), u); err != nil { + t.Errorf("Error given: %s", err) + } else if user == nil { + t.Error("Expected user. User is nil") + } +} + +func TestUserService_Delete(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/api/2/user?accountId=000000000000000000000000") + + w.WriteHeader(http.StatusNoContent) + }) + + resp, err := testClient.User.Delete(context.Background(), "000000000000000000000000") + if err != nil { + t.Errorf("Error given: %s", err) + } + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("Wrong status code: %d. Expected %d", resp.StatusCode, http.StatusNoContent) + } +} + +func TestUserService_GetGroups(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/user/groups", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/user/groups?accountId=000000000000000000000000") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `[{"name":"jira-software-users","self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000"}]`) + }) + + if groups, _, err := testClient.User.GetGroups(context.Background(), "000000000000000000000000"); err != nil { + t.Errorf("Error given: %s", err) + } else if groups == nil { + t.Error("Expected user groups. []UserGroup is nil") + } +} + +func TestUserService_GetCurrentUser(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/3/myself", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/3/myself") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{ + "self": "https://your-domain.atlassian.net/rest/api/3/user?accountId=5b10a2844c20165700ede21g", + "key": "", + "accountId": "5b10a2844c20165700ede21g", + "accountType": "atlassian", + "name": "", + "emailAddress": "mia@example.com", + "avatarUrls": { + "48x48": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=48&s=48", + "24x24": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=24&s=24", + "16x16": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=16&s=16", + "32x32": "https://avatar-management--avatars.server-location.prod.public.atl-paas.net/initials/MK-5.png?size=32&s=32" + }, + "displayName": "Mia Krystof", + "active": true, + "timeZone": "Australia/Sydney", + "groups": { + "size": 3, + "items": [] + }, + "applicationRoles": { + "size": 1, + "items": [] + } + }`) + }) + + if user, _, err := testClient.User.GetCurrentUser(context.Background()); err != nil { + t.Errorf("Error given: %s", err) + + } else if user == nil { + t.Error("Expected user groups. []UserGroup is nil") + + } else if user.EmailAddress != "mia@example.com" || !user.Active || user.DisplayName != "Mia Krystof" { + t.Errorf("User JSON deserialized incorrectly") + } +} + +func TestUserService_Find_Success(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/user/search", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/user/search?query=fred@example.com") + + fmt.Fprint(w, `[{"self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000","key":"fred", + "name":"fred","emailAddress":"fred@example.com","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":true,"timeZone":"Australia/Sydney","groups":{"size":3,"items":[ + {"name":"jira-user","self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-user"},{"name":"jira-admin", + "self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-admin"},{"name":"important","self":"http://www.example.com/jira/rest/api/2/group?groupname=important" + }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}]`) + }) + + if user, _, err := testClient.User.Find(context.Background(), "fred@example.com"); err != nil { + t.Errorf("Error given: %s", err) + } else if user == nil { + t.Error("Expected user. User is nil") + } +} + +func TestUserService_Find_SuccessParams(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/user/search", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/user/search?query=fred@example.com&startAt=100&maxResults=1000") + + fmt.Fprint(w, `[{"self":"http://www.example.com/jira/rest/api/2/user?query=fred","key":"fred", + "name":"fred","emailAddress":"fred@example.com","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":true,"timeZone":"Australia/Sydney","groups":{"size":3,"items":[ + {"name":"jira-user","self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-user"},{"name":"jira-admin", + "self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-admin"},{"name":"important","self":"http://www.example.com/jira/rest/api/2/group?groupname=important" + }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}]`) + }) + + if user, _, err := testClient.User.Find(context.Background(), "fred@example.com", WithStartAt(100), WithMaxResults(1000)); err != nil { + t.Errorf("Error given: %s", err) + } else if user == nil { + t.Error("Expected user. User is nil") + } +} diff --git a/version.go b/cloud/version.go similarity index 58% rename from version.go rename to cloud/version.go index bca12a45..419d0d57 100644 --- a/version.go +++ b/cloud/version.go @@ -1,18 +1,16 @@ -package jira +package cloud import ( "context" "encoding/json" "fmt" - "io/ioutil" + "net/http" ) // VersionService handles Versions for the Jira instance / API. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/version -type VersionService struct { - client *Client -} +type VersionService service // Version represents a single release version of a project type Version struct { @@ -28,12 +26,15 @@ type Version struct { StartDate string `json:"startDate,omitempty" structs:"startDate,omitempty"` } -// GetWithContext gets version info from Jira +// Get gets version info from Jira // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-version-id-get -func (s *VersionService) GetWithContext(ctx context.Context, versionID int) (*Version, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *VersionService) Get(ctx context.Context, versionID int) (*Version, *Response, error) { apiEndpoint := fmt.Sprintf("/rest/api/2/version/%v", versionID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -46,17 +47,15 @@ func (s *VersionService) GetWithContext(ctx context.Context, versionID int) (*Ve return version, resp, nil } -// Get wraps GetWithContext using the background context. -func (s *VersionService) Get(versionID int) (*Version, *Response, error) { - return s.GetWithContext(context.Background(), versionID) -} - -// CreateWithContext creates a version in Jira. +// Create creates a version in Jira. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-version-post -func (s *VersionService) CreateWithContext(ctx context.Context, version *Version) (*Version, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *VersionService) Create(ctx context.Context, version *Version) (*Version, *Response, error) { apiEndpoint := "/rest/api/2/version" - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, version) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, version) if err != nil { return nil, nil, err } @@ -65,34 +64,27 @@ func (s *VersionService) CreateWithContext(ctx context.Context, version *Version if err != nil { return nil, resp, err } + defer resp.Body.Close() responseVersion := new(Version) - defer resp.Body.Close() - data, err := ioutil.ReadAll(resp.Body) - if err != nil { - e := fmt.Errorf("could not read the returned data") - return nil, resp, NewJiraError(resp, e) - } - err = json.Unmarshal(data, responseVersion) + err = json.NewDecoder(resp.Body).Decode(&responseVersion) if err != nil { - e := fmt.Errorf("could not unmarshall the data into struct") - return nil, resp, NewJiraError(resp, e) + return nil, resp, err } - return responseVersion, resp, nil -} -// Create wraps CreateWithContext using the background context. -func (s *VersionService) Create(version *Version) (*Version, *Response, error) { - return s.CreateWithContext(context.Background(), version) + return responseVersion, resp, nil } -// UpdateWithContext updates a version from a JSON representation. +// Update updates a version from a JSON representation. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-version-id-put // Caller must close resp.Body -func (s *VersionService) UpdateWithContext(ctx context.Context, version *Version) (*Version, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *VersionService) Update(ctx context.Context, version *Version) (*Version, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/version/%v", version.ID) - req, err := s.client.NewRequestWithContext(ctx, "PUT", apiEndpoint, version) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndpoint, version) if err != nil { return nil, nil, err } @@ -107,9 +99,3 @@ func (s *VersionService) UpdateWithContext(ctx context.Context, version *Version ret := *version return &ret, resp, nil } - -// Update wraps UpdateWithContext using the background context. -// Caller must close resp.Body -func (s *VersionService) Update(version *Version) (*Version, *Response, error) { - return s.UpdateWithContext(context.Background(), version) -} diff --git a/version_test.go b/cloud/version_test.go similarity index 87% rename from version_test.go rename to cloud/version_test.go index e00d69e1..b624f1a8 100644 --- a/version_test.go +++ b/cloud/version_test.go @@ -1,6 +1,7 @@ -package jira +package cloud import ( + "context" "fmt" "net/http" "testing" @@ -10,7 +11,7 @@ func TestVersionService_Get_Success(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/version/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/version/10002") fmt.Fprint(w, `{ @@ -28,7 +29,7 @@ func TestVersionService_Get_Success(t *testing.T) { }`) }) - version, _, err := testClient.Version.Get(10002) + version, _, err := testClient.Version.Get(context.Background(), 10002) if version == nil { t.Error("Expected version. Issue is nil") } @@ -41,7 +42,7 @@ func TestVersionService_Create(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/version", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/version") w.WriteHeader(http.StatusCreated) @@ -68,7 +69,7 @@ func TestVersionService_Create(t *testing.T) { StartDate: "2018-07-01", } - version, _, err := testClient.Version.Create(v) + version, _, err := testClient.Version.Create(context.Background(), v) if version == nil { t.Error("Expected version. Version is nil") } @@ -81,7 +82,7 @@ func TestServiceService_Update(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/version/10002", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "PUT") + testMethod(t, r, http.MethodPut) testRequestURL(t, r, "/rest/api/2/version/10002") fmt.Fprint(w, `{ "description": "An excellent updated version", @@ -102,7 +103,7 @@ func TestServiceService_Update(t *testing.T) { Description: "An excellent updated version", } - version, _, err := testClient.Version.Update(v) + version, _, err := testClient.Version.Update(context.Background(), v) if version == nil { t.Error("Expected version. Version is nil") } diff --git a/docs/developing.md b/docs/developing.md new file mode 100644 index 00000000..39fd854f --- /dev/null +++ b/docs/developing.md @@ -0,0 +1,10 @@ +# Development + +## Running unit tests + +To run unit / example tests: + +```bash +cd $GOPATH/src/github.com/andygrunwald/go-jira +make test +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..000ea345 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,17 @@ +# Welcome to MkDocs + +For full documentation visit [mkdocs.org](https://www.mkdocs.org). + +## Commands + +* `mkdocs new [dir-name]` - Create a new project. +* `mkdocs serve` - Start the live-reloading docs server. +* `mkdocs build` - Build the documentation site. +* `mkdocs -h` - Print help message and exit. + +## Project layout + + mkdocs.yml # The configuration file. + docs/ + index.md # The documentation homepage. + ... # Other markdown pages, images and other files. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 00000000..a45da29b --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,15 @@ +# Installation + +## Requirements + +See the [list of supported environments] to validate that your setup is supported. + +## Installation + +It is go gettable + +```bash +go get github.com/andygrunwald/go-jira/v2 +``` + + [list of supported environments]: supported-environments.md \ No newline at end of file diff --git a/docs/supported-environments.md b/docs/supported-environments.md new file mode 100644 index 00000000..368ccaa3 --- /dev/null +++ b/docs/supported-environments.md @@ -0,0 +1,21 @@ +# Supported Environments + +## Go + +We follow the [Go Release Policy](https://go.dev/doc/devel/release#policy): + +> Each major Go release is supported until there are two newer major releases. For example, Go 1.5 was supported until the Go 1.7 release, and Go 1.6 was supported until the Go 1.8 release. We fix critical problems, including [critical security problems](https://go.dev/security), in supported releases as needed by issuing minor revisions (for example, Go 1.6.1, Go 1.6.2, and so on). + +## Jira + +### Jira Server (On-Premise solution) + +We follow the [Atlassian Support End of Life Policy](https://confluence.atlassian.com/support/atlassian-support-end-of-life-policy-201851003.html): + +> Atlassian supports feature versions for two years after the first major iteration of that version was released (for example, we support Jira Core 7.2.x for 2 years after Jira 7.2.0 was released). + +### Jira Cloud + +Officially, we support Jira Cloud API in [version 2](https://developer.atlassian.com/cloud/jira/platform/rest/v2/) + +Jira Cloud API in [version 3](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/) is _currently_ not officially supported, because it is still in beta. \ No newline at end of file diff --git a/field_test.go b/field_test.go deleted file mode 100644 index a2deb533..00000000 --- a/field_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package jira - -import ( - "fmt" - "io/ioutil" - "net/http" - "testing" -) - -func TestFieldService_GetList(t *testing.T) { - setup() - defer teardown() - testAPIEdpoint := "/rest/api/2/field" - - raw, err := ioutil.ReadFile("./mocks/all_fields.json") - if err != nil { - t.Error(err.Error()) - } - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) - fmt.Fprint(w, string(raw)) - }) - - fields, _, err := testClient.Field.GetList() - if fields == nil { - t.Error("Expected field list. Field list is nil") - } - if err != nil { - t.Errorf("Error given: %s", err) - } -} diff --git a/go.mod b/go.mod index 11699c33..a1fbb39b 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,15 @@ -module github.com/andygrunwald/go-jira +module github.com/andygrunwald/go-jira/v2 -go 1.15 +go 1.18 require ( github.com/fatih/structs v1.1.0 github.com/go-test/deep v1.0.8 // indirect - github.com/golang-jwt/jwt/v4 v4.4.1 - github.com/google/go-cmp v0.5.7 + github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/google/go-cmp v0.5.9 github.com/google/go-querystring v1.1.0 - github.com/pkg/errors v0.9.1 github.com/trivago/tgo v1.0.7 - golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d + golang.org/x/term v0.6.0 ) + +require golang.org/x/sys v0.6.0 // indirect diff --git a/go.sum b/go.sum index b18c10e1..37a79c8f 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,16 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= -github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= -golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/issuelinktype.go b/issuelinktype.go deleted file mode 100644 index 24a3ab0e..00000000 --- a/issuelinktype.go +++ /dev/null @@ -1,141 +0,0 @@ -package jira - -import ( - "context" - "encoding/json" - "fmt" - "io/ioutil" -) - -// IssueLinkTypeService handles issue link types for the Jira instance / API. -// -// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-group-Issue-link-types -type IssueLinkTypeService struct { - client *Client -} - -// GetListWithContext gets all of the issue link types from Jira. -// -// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-get -func (s *IssueLinkTypeService) GetListWithContext(ctx context.Context) ([]IssueLinkType, *Response, error) { - apiEndpoint := "rest/api/2/issueLinkType" - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) - if err != nil { - return nil, nil, err - } - - linkTypeList := []IssueLinkType{} - resp, err := s.client.Do(req, &linkTypeList) - if err != nil { - return nil, resp, NewJiraError(resp, err) - } - return linkTypeList, resp, nil -} - -// GetList wraps GetListWithContext using the background context. -func (s *IssueLinkTypeService) GetList() ([]IssueLinkType, *Response, error) { - return s.GetListWithContext(context.Background()) -} - -// GetWithContext gets info of a specific issue link type from Jira. -// -// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-issueLinkTypeId-get -func (s *IssueLinkTypeService) GetWithContext(ctx context.Context, ID string) (*IssueLinkType, *Response, error) { - apiEndPoint := fmt.Sprintf("rest/api/2/issueLinkType/%s", ID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndPoint, nil) - if err != nil { - return nil, nil, err - } - - linkType := new(IssueLinkType) - resp, err := s.client.Do(req, linkType) - if err != nil { - return nil, resp, NewJiraError(resp, err) - } - return linkType, resp, nil -} - -// Get wraps GetWithContext using the background context. -func (s *IssueLinkTypeService) Get(ID string) (*IssueLinkType, *Response, error) { - return s.GetWithContext(context.Background(), ID) -} - -// CreateWithContext creates an issue link type in Jira. -// -// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-post -func (s *IssueLinkTypeService) CreateWithContext(ctx context.Context, linkType *IssueLinkType) (*IssueLinkType, *Response, error) { - apiEndpoint := "/rest/api/2/issueLinkType" - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, linkType) - if err != nil { - return nil, nil, err - } - - resp, err := s.client.Do(req, nil) - if err != nil { - return nil, resp, err - } - - responseLinkType := new(IssueLinkType) - defer resp.Body.Close() - data, err := ioutil.ReadAll(resp.Body) - if err != nil { - e := fmt.Errorf("could not read the returned data") - return nil, resp, NewJiraError(resp, e) - } - err = json.Unmarshal(data, responseLinkType) - if err != nil { - e := fmt.Errorf("could no unmarshal the data into struct") - return nil, resp, NewJiraError(resp, e) - } - return linkType, resp, nil -} - -// Create wraps CreateWithContext using the background context. -func (s *IssueLinkTypeService) Create(linkType *IssueLinkType) (*IssueLinkType, *Response, error) { - return s.CreateWithContext(context.Background(), linkType) -} - -// UpdateWithContext updates an issue link type. The issue is found by key. -// -// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-issueLinkTypeId-put -// Caller must close resp.Body -func (s *IssueLinkTypeService) UpdateWithContext(ctx context.Context, linkType *IssueLinkType) (*IssueLinkType, *Response, error) { - apiEndpoint := fmt.Sprintf("rest/api/2/issueLinkType/%s", linkType.ID) - req, err := s.client.NewRequestWithContext(ctx, "PUT", apiEndpoint, linkType) - if err != nil { - return nil, nil, err - } - resp, err := s.client.Do(req, nil) - if err != nil { - return nil, resp, NewJiraError(resp, err) - } - ret := *linkType - return &ret, resp, nil -} - -// Update wraps UpdateWithContext using the background context. -// Caller must close resp.Body -func (s *IssueLinkTypeService) Update(linkType *IssueLinkType) (*IssueLinkType, *Response, error) { - return s.UpdateWithContext(context.Background(), linkType) -} - -// DeleteWithContext deletes an issue link type based on provided ID. -// -// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-issueLinkTypeId-delete -// Caller must close resp.Body -func (s *IssueLinkTypeService) DeleteWithContext(ctx context.Context, ID string) (*Response, error) { - apiEndpoint := fmt.Sprintf("rest/api/2/issueLinkType/%s", ID) - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndpoint, nil) - if err != nil { - return nil, err - } - - resp, err := s.client.Do(req, nil) - return resp, err -} - -// Delete wraps DeleteWithContext using the background context. -// Caller must close resp.Body -func (s *IssueLinkTypeService) Delete(ID string) (*Response, error) { - return s.DeleteWithContext(context.Background(), ID) -} diff --git a/jira.go b/jira.go deleted file mode 100644 index fe880856..00000000 --- a/jira.go +++ /dev/null @@ -1,643 +0,0 @@ -package jira - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "reflect" - "sort" - "strings" - "time" - - jwt "github.com/golang-jwt/jwt/v4" - "github.com/google/go-querystring/query" - "github.com/pkg/errors" -) - -// httpClient defines an interface for an http.Client implementation so that alternative -// http Clients can be passed in for making requests -type httpClient interface { - Do(request *http.Request) (response *http.Response, err error) -} - -// A Client manages communication with the Jira API. -type Client struct { - // HTTP client used to communicate with the API. - client httpClient - - // Base URL for API requests. - baseURL *url.URL - - // Session storage if the user authenticates with a Session cookie - session *Session - - // Services used for talking to different parts of the Jira API. - Authentication *AuthenticationService - Issue *IssueService - Project *ProjectService - Permissions *PermissionsService - Board *BoardService - Sprint *SprintService - User *UserService - Group *GroupService - Version *VersionService - Priority *PriorityService - Field *FieldService - Component *ComponentService - Resolution *ResolutionService - StatusCategory *StatusCategoryService - Filter *FilterService - Role *RoleService - PermissionScheme *PermissionSchemeService - Status *StatusService - IssueLinkType *IssueLinkTypeService - Organization *OrganizationService - ServiceDesk *ServiceDeskService - Customer *CustomerService - Request *RequestService -} - -// NewClient returns a new Jira API client. -// If a nil httpClient is provided, http.DefaultClient will be used. -// To use API methods which require authentication you can follow the preferred solution and -// provide an http.Client that will perform the authentication for you with OAuth and HTTP Basic (such as that provided by the golang.org/x/oauth2 library). -// As an alternative you can use Session Cookie based authentication provided by this package as well. -// See https://docs.atlassian.com/jira/REST/latest/#authentication -// baseURL is the HTTP endpoint of your Jira instance and should always be specified with a trailing slash. -func NewClient(httpClient httpClient, baseURL string) (*Client, error) { - if httpClient == nil { - httpClient = http.DefaultClient - } - - // ensure the baseURL contains a trailing slash so that all paths are preserved in later calls - if !strings.HasSuffix(baseURL, "/") { - baseURL += "/" - } - - parsedBaseURL, err := url.Parse(baseURL) - if err != nil { - return nil, err - } - - c := &Client{ - client: httpClient, - baseURL: parsedBaseURL, - } - c.Authentication = &AuthenticationService{client: c} - c.Issue = &IssueService{client: c} - c.Project = &ProjectService{client: c} - c.Permissions = &PermissionsService{client: c} - c.Board = &BoardService{client: c} - c.Sprint = &SprintService{client: c} - c.User = &UserService{client: c} - c.Group = &GroupService{client: c} - c.Version = &VersionService{client: c} - c.Priority = &PriorityService{client: c} - c.Field = &FieldService{client: c} - c.Component = &ComponentService{client: c} - c.Resolution = &ResolutionService{client: c} - c.StatusCategory = &StatusCategoryService{client: c} - c.Filter = &FilterService{client: c} - c.Role = &RoleService{client: c} - c.PermissionScheme = &PermissionSchemeService{client: c} - c.Status = &StatusService{client: c} - c.IssueLinkType = &IssueLinkTypeService{client: c} - c.Organization = &OrganizationService{client: c} - c.ServiceDesk = &ServiceDeskService{client: c} - c.Customer = &CustomerService{client: c} - c.Request = &RequestService{client: c} - - return c, nil -} - -// NewRawRequestWithContext creates an API request. -// A relative URL can be provided in urlStr, in which case it is resolved relative to the baseURL of the Client. -// Allows using an optional native io.Reader for sourcing the request body. -func (c *Client) NewRawRequestWithContext(ctx context.Context, method, urlStr string, body io.Reader) (*http.Request, error) { - rel, err := url.Parse(urlStr) - if err != nil { - return nil, err - } - // Relative URLs should be specified without a preceding slash since baseURL will have the trailing slash - rel.Path = strings.TrimLeft(rel.Path, "/") - - u := c.baseURL.ResolveReference(rel) - - req, err := newRequestWithContext(ctx, method, u.String(), body) - if err != nil { - return nil, err - } - - req.Header.Set("Content-Type", "application/json") - - // Set authentication information - if c.Authentication.authType == authTypeSession { - // Set session cookie if there is one - if c.session != nil { - for _, cookie := range c.session.Cookies { - req.AddCookie(cookie) - } - } - } else if c.Authentication.authType == authTypeBasic { - // Set basic auth information - if c.Authentication.username != "" { - req.SetBasicAuth(c.Authentication.username, c.Authentication.password) - } - } - - return req, nil -} - -// NewRawRequest wraps NewRawRequestWithContext using the background context. -func (c *Client) NewRawRequest(method, urlStr string, body io.Reader) (*http.Request, error) { - return c.NewRawRequestWithContext(context.Background(), method, urlStr, body) -} - -// NewRequestWithContext creates an API request. -// A relative URL can be provided in urlStr, in which case it is resolved relative to the baseURL of the Client. -// If specified, the value pointed to by body is JSON encoded and included as the request body. -func (c *Client) NewRequestWithContext(ctx context.Context, method, urlStr string, body interface{}) (*http.Request, error) { - rel, err := url.Parse(urlStr) - if err != nil { - return nil, err - } - // Relative URLs should be specified without a preceding slash since baseURL will have the trailing slash - rel.Path = strings.TrimLeft(rel.Path, "/") - - u := c.baseURL.ResolveReference(rel) - - var buf io.ReadWriter - if body != nil { - buf = new(bytes.Buffer) - err = json.NewEncoder(buf).Encode(body) - if err != nil { - return nil, err - } - } - - req, err := newRequestWithContext(ctx, method, u.String(), buf) - if err != nil { - return nil, err - } - - req.Header.Set("Content-Type", "application/json") - - // Set authentication information - if c.Authentication.authType == authTypeSession { - // Set session cookie if there is one - if c.session != nil { - for _, cookie := range c.session.Cookies { - req.AddCookie(cookie) - } - } - } else if c.Authentication.authType == authTypeBasic { - // Set basic auth information - if c.Authentication.username != "" { - req.SetBasicAuth(c.Authentication.username, c.Authentication.password) - } - } - - return req, nil -} - -// NewRequest wraps NewRequestWithContext using the background context. -func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Request, error) { - return c.NewRequestWithContext(context.Background(), method, urlStr, body) -} - -// addOptions adds the parameters in opt as URL query parameters to s. opt -// must be a struct whose fields may contain "url" tags. -func addOptions(s string, opt interface{}) (string, error) { - v := reflect.ValueOf(opt) - if v.Kind() == reflect.Ptr && v.IsNil() { - return s, nil - } - - u, err := url.Parse(s) - if err != nil { - return s, err - } - - qs, err := query.Values(opt) - if err != nil { - return s, err - } - - u.RawQuery = qs.Encode() - return u.String(), nil -} - -// NewMultiPartRequestWithContext creates an API request including a multi-part file. -// A relative URL can be provided in urlStr, in which case it is resolved relative to the baseURL of the Client. -// If specified, the value pointed to by buf is a multipart form. -func (c *Client) NewMultiPartRequestWithContext(ctx context.Context, method, urlStr string, buf *bytes.Buffer) (*http.Request, error) { - rel, err := url.Parse(urlStr) - if err != nil { - return nil, err - } - // Relative URLs should be specified without a preceding slash since baseURL will have the trailing slash - rel.Path = strings.TrimLeft(rel.Path, "/") - - u := c.baseURL.ResolveReference(rel) - - req, err := newRequestWithContext(ctx, method, u.String(), buf) - if err != nil { - return nil, err - } - - // Set required headers - req.Header.Set("X-Atlassian-Token", "nocheck") - - // Set authentication information - if c.Authentication.authType == authTypeSession { - // Set session cookie if there is one - if c.session != nil { - for _, cookie := range c.session.Cookies { - req.AddCookie(cookie) - } - } - } else if c.Authentication.authType == authTypeBasic { - // Set basic auth information - if c.Authentication.username != "" { - req.SetBasicAuth(c.Authentication.username, c.Authentication.password) - } - } - - return req, nil -} - -// NewMultiPartRequest wraps NewMultiPartRequestWithContext using the background context. -func (c *Client) NewMultiPartRequest(method, urlStr string, buf *bytes.Buffer) (*http.Request, error) { - return c.NewMultiPartRequestWithContext(context.Background(), method, urlStr, buf) -} - -// Do sends an API request and returns the API response. -// The API response is JSON decoded and stored in the value pointed to by v, or returned as an error if an API error has occurred. -func (c *Client) Do(req *http.Request, v interface{}) (*Response, error) { - httpResp, err := c.client.Do(req) - if err != nil { - return nil, err - } - - err = CheckResponse(httpResp) - if err != nil { - // Even though there was an error, we still return the response - // in case the caller wants to inspect it further - return newResponse(httpResp, nil), err - } - - if v != nil { - // Open a NewDecoder and defer closing the reader only if there is a provided interface to decode to - defer httpResp.Body.Close() - err = json.NewDecoder(httpResp.Body).Decode(v) - } - - resp := newResponse(httpResp, v) - return resp, err -} - -// CheckResponse checks the API response for errors, and returns them if present. -// A response is considered an error if it has a status code outside the 200 range. -// The caller is responsible to analyze the response body. -// The body can contain JSON (if the error is intended) or xml (sometimes Jira just failes). -func CheckResponse(r *http.Response) error { - if c := r.StatusCode; 200 <= c && c <= 299 { - return nil - } - - err := fmt.Errorf("request failed. Please analyze the request body for more details. Status code: %d", r.StatusCode) - return err -} - -// GetBaseURL will return you the Base URL. -// This is the same URL as in the NewClient constructor -func (c *Client) GetBaseURL() url.URL { - return *c.baseURL -} - -// Response represents Jira API response. It wraps http.Response returned from -// API and provides information about paging. -type Response struct { - *http.Response - - StartAt int - MaxResults int - Total int -} - -func newResponse(r *http.Response, v interface{}) *Response { - resp := &Response{Response: r} - resp.populatePageValues(v) - return resp -} - -// Sets paging values if response json was parsed to searchResult type -// (can be extended with other types if they also need paging info) -func (r *Response) populatePageValues(v interface{}) { - switch value := v.(type) { - case *searchResult: - r.StartAt = value.StartAt - r.MaxResults = value.MaxResults - r.Total = value.Total - case *groupMembersResult: - r.StartAt = value.StartAt - r.MaxResults = value.MaxResults - r.Total = value.Total - } -} - -// BasicAuthTransport is an http.RoundTripper that authenticates all requests -// using HTTP Basic Authentication with the provided username and password. -type BasicAuthTransport struct { - Username string - Password string - - // Transport is the underlying HTTP transport to use when making requests. - // It will default to http.DefaultTransport if nil. - Transport http.RoundTripper -} - -// RoundTrip implements the RoundTripper interface. We just add the -// basic auth and return the RoundTripper for this transport type. -func (t *BasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req2 := cloneRequest(req) // per RoundTripper contract - - req2.SetBasicAuth(t.Username, t.Password) - return t.transport().RoundTrip(req2) -} - -// Client returns an *http.Client that makes requests that are authenticated -// using HTTP Basic Authentication. This is a nice little bit of sugar -// so we can just get the client instead of creating the client in the calling code. -// If it's necessary to send more information on client init, the calling code can -// always skip this and set the transport itself. -func (t *BasicAuthTransport) Client() *http.Client { - return &http.Client{Transport: t} -} - -func (t *BasicAuthTransport) transport() http.RoundTripper { - if t.Transport != nil { - return t.Transport - } - return http.DefaultTransport -} - -// BearerAuthTransport is a http.RoundTripper that authenticates all requests -// using Jira's bearer (oauth 2.0 (3lo)) based authentication. -type BearerAuthTransport struct { - Token string - - // Transport is the underlying HTTP transport to use when making requests. - // It will default to http.DefaultTransport if nil. - Transport http.RoundTripper -} - -// RoundTrip implements the RoundTripper interface. We just add the -// bearer token and return the RoundTripper for this transport type. -func (t *BearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req2 := cloneRequest(req) // per RoundTripper contract - - req2.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.Token)) - return t.transport().RoundTrip(req2) -} - -// Client returns an *http.Client that makes requests that are authenticated -// using HTTP Basic Authentication. This is a nice little bit of sugar -// so we can just get the client instead of creating the client in the calling code. -// If it's necessary to send more information on client init, the calling code can -// always skip this and set the transport itself. -func (t *BearerAuthTransport) Client() *http.Client { - return &http.Client{Transport: t} -} - -func (t *BearerAuthTransport) transport() http.RoundTripper { - if t.Transport != nil { - return t.Transport - } - return http.DefaultTransport -} - -// PATAuthTransport is an http.RoundTripper that authenticates all requests -// using the Personal Access Token specified. -// See here for more info: https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html -type PATAuthTransport struct { - // Token is the key that was provided by Jira when creating the Personal Access Token. - Token string - - // Transport is the underlying HTTP transport to use when making requests. - // It will default to http.DefaultTransport if nil. - Transport http.RoundTripper -} - -// RoundTrip implements the RoundTripper interface. We just add the -// basic auth and return the RoundTripper for this transport type. -func (t *PATAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req2 := cloneRequest(req) // per RoundTripper contract - req2.Header.Set("Authorization", "Bearer "+t.Token) - return t.transport().RoundTrip(req2) -} - -// Client returns an *http.Client that makes requests that are authenticated -// using HTTP Basic Authentication. This is a nice little bit of sugar -// so we can just get the client instead of creating the client in the calling code. -// If it's necessary to send more information on client init, the calling code can -// always skip this and set the transport itself. -func (t *PATAuthTransport) Client() *http.Client { - return &http.Client{Transport: t} -} - -func (t *PATAuthTransport) transport() http.RoundTripper { - if t.Transport != nil { - return t.Transport - } - return http.DefaultTransport -} - -// CookieAuthTransport is an http.RoundTripper that authenticates all requests -// using Jira's cookie-based authentication. -// -// Note that it is generally preferable to use HTTP BASIC authentication with the REST API. -// However, this resource may be used to mimic the behaviour of Jira's log-in page (e.g. to display log-in errors to a user). -// -// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#auth/1/session -type CookieAuthTransport struct { - Username string - Password string - AuthURL string - - // SessionObject is the authenticated cookie string.s - // It's passed in each call to prove the client is authenticated. - SessionObject []*http.Cookie - - // Transport is the underlying HTTP transport to use when making requests. - // It will default to http.DefaultTransport if nil. - Transport http.RoundTripper -} - -// RoundTrip adds the session object to the request. -func (t *CookieAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { - if t.SessionObject == nil { - err := t.setSessionObject() - if err != nil { - return nil, errors.Wrap(err, "cookieauth: no session object has been set") - } - } - - req2 := cloneRequest(req) // per RoundTripper contract - for _, cookie := range t.SessionObject { - // Don't add an empty value cookie to the request - if cookie.Value != "" { - req2.AddCookie(cookie) - } - } - - return t.transport().RoundTrip(req2) -} - -// Client returns an *http.Client that makes requests that are authenticated -// using cookie authentication -func (t *CookieAuthTransport) Client() *http.Client { - return &http.Client{Transport: t} -} - -// setSessionObject attempts to authenticate the user and set -// the session object (e.g. cookie) -func (t *CookieAuthTransport) setSessionObject() error { - req, err := t.buildAuthRequest() - if err != nil { - return err - } - - var authClient = &http.Client{ - Timeout: time.Second * 60, - } - resp, err := authClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - t.SessionObject = resp.Cookies() - return nil -} - -// getAuthRequest assembles the request to get the authenticated cookie -func (t *CookieAuthTransport) buildAuthRequest() (*http.Request, error) { - body := struct { - Username string `json:"username"` - Password string `json:"password"` - }{ - t.Username, - t.Password, - } - - b := new(bytes.Buffer) - json.NewEncoder(b).Encode(body) - - req, err := http.NewRequest("POST", t.AuthURL, b) - if err != nil { - return nil, err - } - - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (t *CookieAuthTransport) transport() http.RoundTripper { - if t.Transport != nil { - return t.Transport - } - return http.DefaultTransport -} - -// JWTAuthTransport is an http.RoundTripper that authenticates all requests -// using Jira's JWT based authentication. -// -// NOTE: this form of auth should be used by add-ons installed from the Atlassian marketplace. -// -// Jira docs: https://developer.atlassian.com/cloud/jira/platform/understanding-jwt -// Examples in other languages: -// https://bitbucket.org/atlassian/atlassian-jwt-ruby/src/d44a8e7a4649e4f23edaa784402655fda7c816ea/lib/atlassian/jwt.rb -// https://bitbucket.org/atlassian/atlassian-jwt-py/src/master/atlassian_jwt/url_utils.py -type JWTAuthTransport struct { - Secret []byte - Issuer string - - // Transport is the underlying HTTP transport to use when making requests. - // It will default to http.DefaultTransport if nil. - Transport http.RoundTripper -} - -func (t *JWTAuthTransport) Client() *http.Client { - return &http.Client{Transport: t} -} - -func (t *JWTAuthTransport) transport() http.RoundTripper { - if t.Transport != nil { - return t.Transport - } - return http.DefaultTransport -} - -// RoundTrip adds the session object to the request. -func (t *JWTAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req2 := cloneRequest(req) // per RoundTripper contract - exp := time.Duration(59) * time.Second - qsh := t.createQueryStringHash(req.Method, req2.URL) - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "iss": t.Issuer, - "iat": time.Now().Unix(), - "exp": time.Now().Add(exp).Unix(), - "qsh": qsh, - }) - - jwtStr, err := token.SignedString(t.Secret) - if err != nil { - return nil, errors.Wrap(err, "jwtAuth: error signing JWT") - } - - req2.Header.Set("Authorization", fmt.Sprintf("JWT %s", jwtStr)) - return t.transport().RoundTrip(req2) -} - -func (t *JWTAuthTransport) createQueryStringHash(httpMethod string, jiraURL *url.URL) string { - canonicalRequest := t.canonicalizeRequest(httpMethod, jiraURL) - h := sha256.Sum256([]byte(canonicalRequest)) - return hex.EncodeToString(h[:]) -} - -func (t *JWTAuthTransport) canonicalizeRequest(httpMethod string, jiraURL *url.URL) string { - path := "/" + strings.Replace(strings.Trim(jiraURL.Path, "/"), "&", "%26", -1) - - var canonicalQueryString []string - for k, v := range jiraURL.Query() { - if k == "jwt" { - continue - } - param := url.QueryEscape(k) - value := url.QueryEscape(strings.Join(v, "")) - canonicalQueryString = append(canonicalQueryString, strings.Replace(strings.Join([]string{param, value}, "="), "+", "%20", -1)) - } - sort.Strings(canonicalQueryString) - return fmt.Sprintf("%s&%s&%s", strings.ToUpper(httpMethod), path, strings.Join(canonicalQueryString, "&")) -} - -// cloneRequest returns a clone of the provided *http.Request. -// The clone is a shallow copy of the struct and its Header map. -func cloneRequest(r *http.Request) *http.Request { - // shallow copy of the struct - r2 := new(http.Request) - *r2 = *r - // deep copy of the Header - r2.Header = make(http.Header, len(r.Header)) - for k, s := range r.Header { - r2.Header[k] = append([]string(nil), s...) - } - return r2 -} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..61df2695 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,19 @@ +site_name: go-jira - Go client library for Atlassian Jira +repo_url: https://github.com/andygrunwald/go-jira +theme: + name: material + language: en + +plugins: + - search + +extra: +# version: +# provider: mike + social: + - icon: fontawesome/brands/twitter + link: https://twitter.com/andygrunwald + name: Andy Grunwald on Twitter + - icon: fontawesome/brands/github + link: https://github.com/andygrunwald/go-jira + \ No newline at end of file diff --git a/onpremise/README.md b/onpremise/README.md new file mode 100644 index 00000000..3dd39772 --- /dev/null +++ b/onpremise/README.md @@ -0,0 +1,5 @@ +# Jira: On-Premise client + +The API client library for self-hosted Jira instances. + +For further information, please switch to the [README.md in the root folder](../README.md). \ No newline at end of file diff --git a/onpremise/auth_transport.go b/onpremise/auth_transport.go new file mode 100644 index 00000000..f18d1d90 --- /dev/null +++ b/onpremise/auth_transport.go @@ -0,0 +1,17 @@ +package onpremise + +import "net/http" + +// cloneRequest returns a clone of the provided *http.Request. +// The clone is a shallow copy of the struct and its Header map. +func cloneRequest(r *http.Request) *http.Request { + // shallow copy of the struct + r2 := new(http.Request) + *r2 = *r + // deep copy of the Header + r2.Header = make(http.Header, len(r.Header)) + for k, s := range r.Header { + r2.Header[k] = append([]string(nil), s...) + } + return r2 +} diff --git a/onpremise/auth_transport_basic.go b/onpremise/auth_transport_basic.go new file mode 100644 index 00000000..fd638e9b --- /dev/null +++ b/onpremise/auth_transport_basic.go @@ -0,0 +1,39 @@ +package onpremise + +import "net/http" + +// BasicAuthTransport is an http.RoundTripper that authenticates all requests +// using HTTP Basic Authentication with the provided username and password. +type BasicAuthTransport struct { + Username string + Password string + + // Transport is the underlying HTTP transport to use when making requests. + // It will default to http.DefaultTransport if nil. + Transport http.RoundTripper +} + +// RoundTrip implements the RoundTripper interface. We just add the +// basic auth and return the RoundTripper for this transport type. +func (t *BasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := cloneRequest(req) // per RoundTripper contract + + req2.SetBasicAuth(t.Username, t.Password) + return t.transport().RoundTrip(req2) +} + +// Client returns an *http.Client that makes requests that are authenticated +// using HTTP Basic Authentication. This is a nice little bit of sugar +// so we can just get the client instead of creating the client in the calling code. +// If it's necessary to send more information on client init, the calling code can +// always skip this and set the transport itself. +func (t *BasicAuthTransport) Client() *http.Client { + return &http.Client{Transport: t} +} + +func (t *BasicAuthTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return http.DefaultTransport +} diff --git a/onpremise/auth_transport_basic_test.go b/onpremise/auth_transport_basic_test.go new file mode 100644 index 00000000..2c8b2e46 --- /dev/null +++ b/onpremise/auth_transport_basic_test.go @@ -0,0 +1,52 @@ +package onpremise + +import ( + "context" + "net/http" + "testing" +) + +func TestBasicAuthTransport(t *testing.T) { + setup() + defer teardown() + + username, password := "username", "password" + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + if !ok { + t.Errorf("request does not contain basic auth credentials") + } + if u != username { + t.Errorf("request contained basic auth username %q, want %q", u, username) + } + if p != password { + t.Errorf("request contained basic auth password %q, want %q", p, password) + } + }) + + tp := &BasicAuthTransport{ + Username: username, + Password: password, + } + + basicAuthClient, _ := NewClient(testServer.URL, tp.Client()) + req, _ := basicAuthClient.NewRequest(context.Background(), http.MethodGet, ".", nil) + basicAuthClient.Do(req, nil) +} + +func TestBasicAuthTransport_transport(t *testing.T) { + // default transport + tp := &BasicAuthTransport{} + if tp.transport() != http.DefaultTransport { + t.Errorf("Expected http.DefaultTransport to be used.") + } + + // custom transport + tp = &BasicAuthTransport{ + Transport: &http.Transport{}, + } + if tp.transport() == http.DefaultTransport { + t.Errorf("Expected custom transport to be used.") + } +} diff --git a/onpremise/auth_transport_bearer.go b/onpremise/auth_transport_bearer.go new file mode 100644 index 00000000..ceb6d655 --- /dev/null +++ b/onpremise/auth_transport_bearer.go @@ -0,0 +1,41 @@ +package onpremise + +import ( + "fmt" + "net/http" +) + +// BearerAuthTransport is a http.RoundTripper that authenticates all requests +// using Jira's bearer (oauth 2.0 (3lo)) based authentication. +type BearerAuthTransport struct { + Token string + + // Transport is the underlying HTTP transport to use when making requests. + // It will default to http.DefaultTransport if nil. + Transport http.RoundTripper +} + +// RoundTrip implements the RoundTripper interface. We just add the +// bearer token and return the RoundTripper for this transport type. +func (t *BearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := cloneRequest(req) // per RoundTripper contract + + req2.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.Token)) + return t.transport().RoundTrip(req2) +} + +// Client returns an *http.Client that makes requests that are authenticated +// using HTTP Basic Authentication. This is a nice little bit of sugar +// so we can just get the client instead of creating the client in the calling code. +// If it's necessary to send more information on client init, the calling code can +// always skip this and set the transport itself. +func (t *BearerAuthTransport) Client() *http.Client { + return &http.Client{Transport: t} +} + +func (t *BearerAuthTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return http.DefaultTransport +} diff --git a/onpremise/auth_transport_cookie.go b/onpremise/auth_transport_cookie.go new file mode 100644 index 00000000..89ac2a01 --- /dev/null +++ b/onpremise/auth_transport_cookie.go @@ -0,0 +1,107 @@ +package onpremise + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// CookieAuthTransport is an http.RoundTripper that authenticates all requests +// using Jira's cookie-based authentication. +// +// Note that it is generally preferable to use HTTP BASIC authentication with the REST API. +// However, this resource may be used to mimic the behaviour of Jira's log-in page (e.g. to display log-in errors to a user). +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#auth/1/session +type CookieAuthTransport struct { + Username string + Password string + AuthURL string + + // SessionObject is the authenticated cookie string.s + // It's passed in each call to prove the client is authenticated. + SessionObject []*http.Cookie + + // Transport is the underlying HTTP transport to use when making requests. + // It will default to http.DefaultTransport if nil. + Transport http.RoundTripper +} + +// RoundTrip adds the session object to the request. +func (t *CookieAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.SessionObject == nil { + err := t.setSessionObject() + if err != nil { + return nil, fmt.Errorf("cookieauth: no session object has been set: %w", err) + } + } + + req2 := cloneRequest(req) // per RoundTripper contract + for _, cookie := range t.SessionObject { + // Don't add an empty value cookie to the request + if cookie.Value != "" { + req2.AddCookie(cookie) + } + } + + return t.transport().RoundTrip(req2) +} + +// Client returns an *http.Client that makes requests that are authenticated +// using cookie authentication +func (t *CookieAuthTransport) Client() *http.Client { + return &http.Client{Transport: t} +} + +// setSessionObject attempts to authenticate the user and set +// the session object (e.g. cookie) +func (t *CookieAuthTransport) setSessionObject() error { + req, err := t.buildAuthRequest() + if err != nil { + return err + } + + var authClient = &http.Client{ + Timeout: time.Second * 60, + } + resp, err := authClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + t.SessionObject = resp.Cookies() + return nil +} + +// getAuthRequest assembles the request to get the authenticated cookie +func (t *CookieAuthTransport) buildAuthRequest() (*http.Request, error) { + body := struct { + Username string `json:"username"` + Password string `json:"password"` + }{ + t.Username, + t.Password, + } + + b := new(bytes.Buffer) + json.NewEncoder(b).Encode(body) + + // TODO Use a context here + req, err := http.NewRequest(http.MethodPost, t.AuthURL, b) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + return req, nil +} + +func (t *CookieAuthTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return http.DefaultTransport +} diff --git a/onpremise/auth_transport_cookie_test.go b/onpremise/auth_transport_cookie_test.go new file mode 100644 index 00000000..076aadc8 --- /dev/null +++ b/onpremise/auth_transport_cookie_test.go @@ -0,0 +1,120 @@ +package onpremise + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// Test that the cookie in the transport is the cookie returned in the header +func TestCookieAuthTransport_SessionObject_Exists(t *testing.T) { + setup() + defer teardown() + + testCookie := &http.Cookie{Name: "test", Value: "test"} + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + cookies := r.Cookies() + + if len(cookies) < 1 { + t.Errorf("No cookies set") + } + + if cookies[0].Name != testCookie.Name { + t.Errorf("Cookie names don't match, expected %v, got %v", testCookie.Name, cookies[0].Name) + } + + if cookies[0].Value != testCookie.Value { + t.Errorf("Cookie values don't match, expected %v, got %v", testCookie.Value, cookies[0].Value) + } + }) + + tp := &CookieAuthTransport{ + Username: "username", + Password: "password", + AuthURL: "https://some.jira.com/rest/auth/1/session", + SessionObject: []*http.Cookie{testCookie}, + } + + basicAuthClient, _ := NewClient(testServer.URL, tp.Client()) + req, _ := basicAuthClient.NewRequest(context.Background(), http.MethodGet, ".", nil) + basicAuthClient.Do(req, nil) +} + +// Test that an empty cookie in the transport is not returned in the header +func TestCookieAuthTransport_SessionObject_ExistsWithEmptyCookie(t *testing.T) { + setup() + defer teardown() + + emptyCookie := &http.Cookie{Name: "empty_cookie", Value: ""} + testCookie := &http.Cookie{Name: "test", Value: "test"} + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + cookies := r.Cookies() + + if len(cookies) > 1 { + t.Errorf("The empty cookie should not have been added") + } + + if cookies[0].Name != testCookie.Name { + t.Errorf("Cookie names don't match, expected %v, got %v", testCookie.Name, cookies[0].Name) + } + + if cookies[0].Value != testCookie.Value { + t.Errorf("Cookie values don't match, expected %v, got %v", testCookie.Value, cookies[0].Value) + } + }) + + tp := &CookieAuthTransport{ + Username: "username", + Password: "password", + AuthURL: "https://some.jira.com/rest/auth/1/session", + SessionObject: []*http.Cookie{emptyCookie, testCookie}, + } + + basicAuthClient, _ := NewClient(testServer.URL, tp.Client()) + req, _ := basicAuthClient.NewRequest(context.Background(), http.MethodGet, ".", nil) + basicAuthClient.Do(req, nil) +} + +// Test that if no cookie is in the transport, it checks for a cookie +func TestCookieAuthTransport_SessionObject_DoesNotExist(t *testing.T) { + setup() + defer teardown() + + testCookie := &http.Cookie{Name: "does_not_exist", Value: "does_not_exist"} + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + http.SetCookie(w, testCookie) + w.Write([]byte(`OK`)) + })) + defer ts.Close() + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + cookies := r.Cookies() + + if len(cookies) < 1 { + t.Errorf("No cookies set") + } + + if cookies[0].Name != testCookie.Name { + t.Errorf("Cookie names don't match, expected %v, got %v", testCookie.Name, cookies[0].Name) + } + + if cookies[0].Value != testCookie.Value { + t.Errorf("Cookie values don't match, expected %v, got %v", testCookie.Value, cookies[0].Value) + } + }) + + tp := &CookieAuthTransport{ + Username: "username", + Password: "password", + AuthURL: ts.URL, + } + + basicAuthClient, _ := NewClient(testServer.URL, tp.Client()) + req, _ := basicAuthClient.NewRequest(context.Background(), http.MethodGet, ".", nil) + basicAuthClient.Do(req, nil) +} diff --git a/onpremise/auth_transport_jwt.go b/onpremise/auth_transport_jwt.go new file mode 100644 index 00000000..0cb776be --- /dev/null +++ b/onpremise/auth_transport_jwt.go @@ -0,0 +1,87 @@ +package onpremise + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" + + jwt "github.com/golang-jwt/jwt/v4" +) + +// JWTAuthTransport is an http.RoundTripper that authenticates all requests +// using Jira's JWT based authentication. +// +// NOTE: this form of auth should be used by add-ons installed from the Atlassian marketplace. +// +// Jira docs: https://developer.atlassian.com/cloud/jira/platform/understanding-jwt +// Examples in other languages: +// +// https://bitbucket.org/atlassian/atlassian-jwt-ruby/src/d44a8e7a4649e4f23edaa784402655fda7c816ea/lib/atlassian/jwt.rb +// https://bitbucket.org/atlassian/atlassian-jwt-py/src/master/atlassian_jwt/url_utils.py +type JWTAuthTransport struct { + Secret []byte + Issuer string + + // Transport is the underlying HTTP transport to use when making requests. + // It will default to http.DefaultTransport if nil. + Transport http.RoundTripper +} + +func (t *JWTAuthTransport) Client() *http.Client { + return &http.Client{Transport: t} +} + +func (t *JWTAuthTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return http.DefaultTransport +} + +// RoundTrip adds the session object to the request. +func (t *JWTAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := cloneRequest(req) // per RoundTripper contract + exp := time.Duration(59) * time.Second + qsh := t.createQueryStringHash(req.Method, req2.URL) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": t.Issuer, + "iat": time.Now().Unix(), + "exp": time.Now().Add(exp).Unix(), + "qsh": qsh, + }) + + jwtStr, err := token.SignedString(t.Secret) + if err != nil { + return nil, fmt.Errorf("jwtAuth: error signing JWT: %w", err) + } + + req2.Header.Set("Authorization", fmt.Sprintf("JWT %s", jwtStr)) + return t.transport().RoundTrip(req2) +} + +func (t *JWTAuthTransport) createQueryStringHash(httpMethod string, jiraURL *url.URL) string { + canonicalRequest := t.canonicalizeRequest(httpMethod, jiraURL) + h := sha256.Sum256([]byte(canonicalRequest)) + return hex.EncodeToString(h[:]) +} + +func (t *JWTAuthTransport) canonicalizeRequest(httpMethod string, jiraURL *url.URL) string { + path := "/" + strings.Replace(strings.Trim(jiraURL.Path, "/"), "&", "%26", -1) + + var canonicalQueryString []string + for k, v := range jiraURL.Query() { + if k == "jwt" { + continue + } + param := url.QueryEscape(k) + value := url.QueryEscape(strings.Join(v, "")) + canonicalQueryString = append(canonicalQueryString, strings.Replace(strings.Join([]string{param, value}, "="), "+", "%20", -1)) + } + sort.Strings(canonicalQueryString) + return fmt.Sprintf("%s&%s&%s", strings.ToUpper(httpMethod), path, strings.Join(canonicalQueryString, "&")) +} diff --git a/onpremise/auth_transport_jwt_test.go b/onpremise/auth_transport_jwt_test.go new file mode 100644 index 00000000..b74cae0b --- /dev/null +++ b/onpremise/auth_transport_jwt_test.go @@ -0,0 +1,32 @@ +package onpremise + +import ( + "context" + "net/http" + "strings" + "testing" +) + +func TestJWTAuthTransport_HeaderContainsJWT(t *testing.T) { + setup() + defer teardown() + + sharedSecret := []byte("ssshh,it's a secret") + issuer := "add-on.key" + + jwtTransport := &JWTAuthTransport{ + Secret: sharedSecret, + Issuer: issuer, + } + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // look for the presence of the JWT in the header + val := r.Header.Get("Authorization") + if !strings.Contains(val, "JWT ") { + t.Errorf("request does not contain JWT in the Auth header") + } + }) + + jwtClient, _ := NewClient(testServer.URL, jwtTransport.Client()) + jwtClient.Issue.Get(context.Background(), "TEST-1", nil) +} diff --git a/onpremise/auth_transport_personal_access_token.go b/onpremise/auth_transport_personal_access_token.go new file mode 100644 index 00000000..e3b3e8d2 --- /dev/null +++ b/onpremise/auth_transport_personal_access_token.go @@ -0,0 +1,39 @@ +package onpremise + +import "net/http" + +// PATAuthTransport is an http.RoundTripper that authenticates all requests +// using the Personal Access Token specified. +// See here for more info: https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html +type PATAuthTransport struct { + // Token is the key that was provided by Jira when creating the Personal Access Token. + Token string + + // Transport is the underlying HTTP transport to use when making requests. + // It will default to http.DefaultTransport if nil. + Transport http.RoundTripper +} + +// RoundTrip implements the RoundTripper interface. We just add the +// basic auth and return the RoundTripper for this transport type. +func (t *PATAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := cloneRequest(req) // per RoundTripper contract + req2.Header.Set("Authorization", "Bearer "+t.Token) + return t.transport().RoundTrip(req2) +} + +// Client returns an *http.Client that makes requests that are authenticated +// using HTTP Basic Authentication. This is a nice little bit of sugar +// so we can just get the client instead of creating the client in the calling code. +// If it's necessary to send more information on client init, the calling code can +// always skip this and set the transport itself. +func (t *PATAuthTransport) Client() *http.Client { + return &http.Client{Transport: t} +} + +func (t *PATAuthTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return http.DefaultTransport +} diff --git a/onpremise/auth_transport_personal_access_token_test.go b/onpremise/auth_transport_personal_access_token_test.go new file mode 100644 index 00000000..bf047981 --- /dev/null +++ b/onpremise/auth_transport_personal_access_token_test.go @@ -0,0 +1,30 @@ +package onpremise + +import ( + "context" + "net/http" + "testing" +) + +func TestPATAuthTransport_HeaderContainsAuth(t *testing.T) { + setup() + defer teardown() + + token := "shhh, it's a token" + + patTransport := &PATAuthTransport{ + Token: token, + } + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + val := r.Header.Get("Authorization") + expected := "Bearer " + token + if val != expected { + t.Errorf("request does not contain bearer token in the Authorization header.") + } + }) + + client, _ := NewClient(testServer.URL, patTransport.Client()) + client.User.GetSelf(context.Background()) + +} diff --git a/authentication.go b/onpremise/authentication.go similarity index 68% rename from authentication.go rename to onpremise/authentication.go index fcaf8e10..96eeee10 100644 --- a/authentication.go +++ b/onpremise/authentication.go @@ -1,10 +1,9 @@ -package jira +package onpremise import ( "context" "encoding/json" "fmt" - "io/ioutil" "net/http" ) @@ -48,7 +47,7 @@ type Session struct { Cookies []*http.Cookie } -// AcquireSessionCookieWithContext creates a new session for a user in Jira. +// AcquireSessionCookie creates a new session for a user in Jira. // Once a session has been successfully created it can be used to access any of Jira's remote APIs and also the web UI by passing the appropriate HTTP Cookie header. // The header will by automatically applied to every API request. // Note that it is generally preferrable to use HTTP BASIC authentication with the REST API. @@ -57,7 +56,7 @@ type Session struct { // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#auth/1/session // // Deprecated: Use CookieAuthTransport instead -func (s *AuthenticationService) AcquireSessionCookieWithContext(ctx context.Context, username, password string) (bool, error) { +func (s *AuthenticationService) AcquireSessionCookie(ctx context.Context, username, password string) (bool, error) { apiEndpoint := "rest/auth/1/session" body := struct { Username string `json:"username"` @@ -67,24 +66,23 @@ func (s *AuthenticationService) AcquireSessionCookieWithContext(ctx context.Cont password, } - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, body) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, body) if err != nil { return false, err } session := new(Session) resp, err := s.client.Do(req, session) - - if resp != nil { - session.Cookies = resp.Cookies() - } - if err != nil { - return false, fmt.Errorf("auth at Jira instance failed (HTTP(S) request). %s", err) + return false, fmt.Errorf("auth at Jira instance failed (HTTP(S) request). %w", err) } + if resp != nil && resp.StatusCode != 200 { return false, fmt.Errorf("auth at Jira instance failed (HTTP(S) request). Status code: %d", resp.StatusCode) } + if resp != nil { + session.Cookies = resp.Cookies() + } s.client.session = session s.authType = authTypeSession @@ -92,13 +90,6 @@ func (s *AuthenticationService) AcquireSessionCookieWithContext(ctx context.Cont return true, nil } -// AcquireSessionCookie wraps AcquireSessionCookieWithContext using the background context. -// -// Deprecated: Use CookieAuthTransport instead -func (s *AuthenticationService) AcquireSessionCookie(username, password string) (bool, error) { - return s.AcquireSessionCookieWithContext(context.Background(), username, password) -} - // SetBasicAuth sets username and password for the basic auth against the Jira instance. // // Deprecated: Use BasicAuthTransport instead @@ -121,26 +112,26 @@ func (s *AuthenticationService) Authenticated() bool { return false } -// LogoutWithContext logs out the current user that has been authenticated and the session in the client is destroyed. +// Logout logs out the current user that has been authenticated and the session in the client is destroyed. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#auth/1/session // // Deprecated: Use CookieAuthTransport to create base client. Logging out is as simple as not using the // client anymore -func (s *AuthenticationService) LogoutWithContext(ctx context.Context) error { +func (s *AuthenticationService) Logout(ctx context.Context) error { if s.authType != authTypeSession || s.client.session == nil { return fmt.Errorf("no user is authenticated") } apiEndpoint := "rest/auth/1/session" - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) if err != nil { - return fmt.Errorf("creating the request to log the user out failed : %s", err) + return fmt.Errorf("creating the request to log the user out failed : %w", err) } resp, err := s.client.Do(req, nil) if err != nil { - return fmt.Errorf("error sending the logout request: %s", err) + return fmt.Errorf("error sending the logout request: %w", err) } defer resp.Body.Close() if resp.StatusCode != 204 { @@ -154,18 +145,10 @@ func (s *AuthenticationService) LogoutWithContext(ctx context.Context) error { } -// Logout wraps LogoutWithContext using the background context. -// -// Deprecated: Use CookieAuthTransport to create base client. Logging out is as simple as not using the -// client anymore -func (s *AuthenticationService) Logout() error { - return s.LogoutWithContext(context.Background()) -} - -// GetCurrentUserWithContext gets the details of the current user. +// GetCurrentUser gets the details of the current user. // // Jira API docs: https://docs.atlassian.com/jira/REST/latest/#auth/1/session -func (s *AuthenticationService) GetCurrentUserWithContext(ctx context.Context) (*Session, error) { +func (s *AuthenticationService) GetCurrentUser(ctx context.Context) (*Session, error) { if s == nil { return nil, fmt.Errorf("authentication Service is not instantiated") } @@ -174,35 +157,25 @@ func (s *AuthenticationService) GetCurrentUserWithContext(ctx context.Context) ( } apiEndpoint := "rest/auth/1/session" - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { - return nil, fmt.Errorf("could not create request for getting user info : %s", err) + return nil, fmt.Errorf("could not create request for getting user info: %w", err) } resp, err := s.client.Do(req, nil) if err != nil { - return nil, fmt.Errorf("error sending request to get user info : %s", err) + return nil, fmt.Errorf("error sending request to get user info: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("getting user info failed with status : %d", resp.StatusCode) } - ret := new(Session) - data, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("couldn't read body from the response : %s", err) - } - - err = json.Unmarshal(data, &ret) + ret := new(Session) + err = json.NewDecoder(resp.Body).Decode(&ret) if err != nil { - return nil, fmt.Errorf("could not unmarshall received user info : %s", err) + return nil, err } return ret, nil } - -// GetCurrentUser wraps GetCurrentUserWithContext using the background context. -func (s *AuthenticationService) GetCurrentUser() (*Session, error) { - return s.GetCurrentUserWithContext(context.Background()) -} diff --git a/authentication_test.go b/onpremise/authentication_test.go similarity index 82% rename from authentication_test.go rename to onpremise/authentication_test.go index 9235d080..b9299e45 100644 --- a/authentication_test.go +++ b/onpremise/authentication_test.go @@ -1,9 +1,10 @@ -package jira +package onpremise import ( "bytes" + "context" "fmt" - "io/ioutil" + "io" "net/http" "reflect" "testing" @@ -13,9 +14,9 @@ func TestAuthenticationService_AcquireSessionCookie_Failure(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/auth/1/session", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/auth/1/session") - b, err := ioutil.ReadAll(r.Body) + b, err := io.ReadAll(r.Body) if err != nil { t.Errorf("Error in read body: %s", err) } @@ -30,7 +31,7 @@ func TestAuthenticationService_AcquireSessionCookie_Failure(t *testing.T) { w.WriteHeader(http.StatusInternalServerError) }) - res, err := testClient.Authentication.AcquireSessionCookie("foo", "bar") + res, err := testClient.Authentication.AcquireSessionCookie(context.Background(), "foo", "bar") if err == nil { t.Errorf("Expected error, but no error given") } @@ -47,9 +48,9 @@ func TestAuthenticationService_AcquireSessionCookie_Success(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/auth/1/session", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/auth/1/session") - b, err := ioutil.ReadAll(r.Body) + b, err := io.ReadAll(r.Body) if err != nil { t.Errorf("Error in read body: %s", err) } @@ -63,7 +64,7 @@ func TestAuthenticationService_AcquireSessionCookie_Success(t *testing.T) { fmt.Fprint(w, `{"session":{"name":"JSESSIONID","value":"12345678901234567890"},"loginInfo":{"failedLoginCount":10,"loginCount":127,"lastFailedLoginTime":"2016-03-16T04:22:35.386+0000","previousLoginTime":"2016-03-16T04:22:35.386+0000"}}`) }) - res, err := testClient.Authentication.AcquireSessionCookie("foo", "bar") + res, err := testClient.Authentication.AcquireSessionCookie(context.Background(), "foo", "bar") if err != nil { t.Errorf("No error expected. Got %s", err) } @@ -137,10 +138,10 @@ func TestAuthenticationService_GetUserInfo_AccessForbidden_Fail(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/auth/1/session", func(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" { - testMethod(t, r, "POST") + if r.Method == http.MethodPost { + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/auth/1/session") - b, err := ioutil.ReadAll(r.Body) + b, err := io.ReadAll(r.Body) if err != nil { t.Errorf("Error in read body: %s", err) } @@ -154,17 +155,17 @@ func TestAuthenticationService_GetUserInfo_AccessForbidden_Fail(t *testing.T) { fmt.Fprint(w, `{"session":{"name":"JSESSIONID","value":"12345678901234567890"},"loginInfo":{"failedLoginCount":10,"loginCount":127,"lastFailedLoginTime":"2016-03-16T04:22:35.386+0000","previousLoginTime":"2016-03-16T04:22:35.386+0000"}}`) } - if r.Method == "GET" { - testMethod(t, r, "GET") + if r.Method == http.MethodGet { + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/auth/1/session") w.WriteHeader(http.StatusForbidden) } }) - testClient.Authentication.AcquireSessionCookie("foo", "bar") + testClient.Authentication.AcquireSessionCookie(context.Background(), "foo", "bar") - _, err := testClient.Authentication.GetCurrentUser() + _, err := testClient.Authentication.GetCurrentUser(context.Background()) if err == nil { t.Errorf("Non nil error expect, received nil") } @@ -175,10 +176,10 @@ func TestAuthenticationService_GetUserInfo_NonOkStatusCode_Fail(t *testing.T) { defer teardown() testMux.HandleFunc("/rest/auth/1/session", func(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" { - testMethod(t, r, "POST") + if r.Method == http.MethodPost { + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/auth/1/session") - b, err := ioutil.ReadAll(r.Body) + b, err := io.ReadAll(r.Body) if err != nil { t.Errorf("Error in read body: %s", err) } @@ -192,17 +193,17 @@ func TestAuthenticationService_GetUserInfo_NonOkStatusCode_Fail(t *testing.T) { fmt.Fprint(w, `{"session":{"name":"JSESSIONID","value":"12345678901234567890"},"loginInfo":{"failedLoginCount":10,"loginCount":127,"lastFailedLoginTime":"2016-03-16T04:22:35.386+0000","previousLoginTime":"2016-03-16T04:22:35.386+0000"}}`) } - if r.Method == "GET" { - testMethod(t, r, "GET") + if r.Method == http.MethodGet { + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/auth/1/session") //any status but 200 w.WriteHeader(240) } }) - testClient.Authentication.AcquireSessionCookie("foo", "bar") + testClient.Authentication.AcquireSessionCookie(context.Background(), "foo", "bar") - _, err := testClient.Authentication.GetCurrentUser() + _, err := testClient.Authentication.GetCurrentUser(context.Background()) if err == nil { t.Errorf("Non nil error expect, received nil") } @@ -212,7 +213,7 @@ func TestAuthenticationService_GetUserInfo_FailWithoutLogin(t *testing.T) { // no setup() required here testClient = new(Client) - _, err := testClient.Authentication.GetCurrentUser() + _, err := testClient.Authentication.GetCurrentUser(context.Background()) if err == nil { t.Errorf("Expected error, but got %s", err) } @@ -231,10 +232,10 @@ func TestAuthenticationService_GetUserInfo_Success(t *testing.T) { testUserInfo.LoginInfo.PreviousLoginTime = "2016-09-07T11:36:23.476+0200" testMux.HandleFunc("/rest/auth/1/session", func(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" { - testMethod(t, r, "POST") + if r.Method == http.MethodPost { + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/auth/1/session") - b, err := ioutil.ReadAll(r.Body) + b, err := io.ReadAll(r.Body) if err != nil { t.Errorf("Error in read body: %s", err) } @@ -248,16 +249,16 @@ func TestAuthenticationService_GetUserInfo_Success(t *testing.T) { fmt.Fprint(w, `{"session":{"name":"JSESSIONID","value":"12345678901234567890"},"loginInfo":{"failedLoginCount":10,"loginCount":127,"lastFailedLoginTime":"2016-03-16T04:22:35.386+0000","previousLoginTime":"2016-03-16T04:22:35.386+0000"}}`) } - if r.Method == "GET" { - testMethod(t, r, "GET") + if r.Method == http.MethodGet { + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/auth/1/session") fmt.Fprint(w, `{"self":"https://my.jira.com/rest/api/latest/user?username=foo","name":"foo","loginInfo":{"failedLoginCount":12,"loginCount":357,"lastFailedLoginTime":"2016-09-06T16:41:23.949+0200","previousLoginTime":"2016-09-07T11:36:23.476+0200"}}`) } }) - testClient.Authentication.AcquireSessionCookie("foo", "bar") + testClient.Authentication.AcquireSessionCookie(context.Background(), "foo", "bar") - userinfo, err := testClient.Authentication.GetCurrentUser() + userinfo, err := testClient.Authentication.GetCurrentUser(context.Background()) if err != nil { t.Errorf("Nil error expect, received %s", err) } @@ -273,10 +274,10 @@ func TestAuthenticationService_Logout_Success(t *testing.T) { defer teardown() testMux.HandleFunc("/rest/auth/1/session", func(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" { - testMethod(t, r, "POST") + if r.Method == http.MethodPost { + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/auth/1/session") - b, err := ioutil.ReadAll(r.Body) + b, err := io.ReadAll(r.Body) if err != nil { t.Errorf("Error in read body: %s", err) } @@ -290,15 +291,15 @@ func TestAuthenticationService_Logout_Success(t *testing.T) { fmt.Fprint(w, `{"session":{"name":"JSESSIONID","value":"12345678901234567890"},"loginInfo":{"failedLoginCount":10,"loginCount":127,"lastFailedLoginTime":"2016-03-16T04:22:35.386+0000","previousLoginTime":"2016-03-16T04:22:35.386+0000"}}`) } - if r.Method == "DELETE" { + if r.Method == http.MethodDelete { // return 204 w.WriteHeader(http.StatusNoContent) } }) - testClient.Authentication.AcquireSessionCookie("foo", "bar") + testClient.Authentication.AcquireSessionCookie(context.Background(), "foo", "bar") - err := testClient.Authentication.Logout() + err := testClient.Authentication.Logout(context.Background()) if err != nil { t.Errorf("Expected nil error, got %s", err) } @@ -309,12 +310,12 @@ func TestAuthenticationService_Logout_FailWithoutLogin(t *testing.T) { defer teardown() testMux.HandleFunc("/rest/auth/1/session", func(w http.ResponseWriter, r *http.Request) { - if r.Method == "DELETE" { + if r.Method == http.MethodDelete { // 401 w.WriteHeader(http.StatusUnauthorized) } }) - err := testClient.Authentication.Logout() + err := testClient.Authentication.Logout(context.Background()) if err == nil { t.Error("Expected not nil, got nil") } diff --git a/board.go b/onpremise/board.go similarity index 64% rename from board.go rename to onpremise/board.go index 890d6e0a..3fdd1f2e 100644 --- a/board.go +++ b/onpremise/board.go @@ -1,18 +1,16 @@ -package jira +package onpremise import ( "context" "fmt" - "strconv" + "net/http" "time" ) // BoardService handles Agile Boards for the Jira instance / API. // // Jira API docs: https://docs.atlassian.com/jira-software/REST/server/ -type BoardService struct { - client *Client -} +type BoardService service // BoardsList reflects a list of agile boards type BoardsList struct { @@ -127,16 +125,19 @@ type BoardConfigurationColumnStatus struct { Self string `json:"self"` } -// GetAllBoardsWithContext will returns all boards. This only includes boards that the user has permission to view. +// GetAllBoards will returns all boards. This only includes boards that the user has permission to view. // // Jira API docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/board-getAllBoards -func (s *BoardService) GetAllBoardsWithContext(ctx context.Context, opt *BoardListOptions) (*BoardsList, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *BoardService) GetAllBoards(ctx context.Context, opt *BoardListOptions) (*BoardsList, *Response, error) { apiEndpoint := "rest/agile/1.0/board" url, err := addOptions(apiEndpoint, opt) if err != nil { return nil, nil, err } - req, err := s.client.NewRequestWithContext(ctx, "GET", url, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, url, nil) if err != nil { return nil, nil, err } @@ -151,18 +152,16 @@ func (s *BoardService) GetAllBoardsWithContext(ctx context.Context, opt *BoardLi return boards, resp, err } -// GetAllBoards wraps GetAllBoardsWithContext using the background context. -func (s *BoardService) GetAllBoards(opt *BoardListOptions) (*BoardsList, *Response, error) { - return s.GetAllBoardsWithContext(context.Background(), opt) -} - -// GetBoardWithContext will returns the board for the given boardID. +// GetBoard will returns the board for the given boardID. // This board will only be returned if the user has permission to view it. // // Jira API docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/board-getBoard -func (s *BoardService) GetBoardWithContext(ctx context.Context, boardID int) (*Board, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *BoardService) GetBoard(ctx context.Context, boardID int) (*Board, *Response, error) { apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%v", boardID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -177,12 +176,7 @@ func (s *BoardService) GetBoardWithContext(ctx context.Context, boardID int) (*B return board, resp, nil } -// GetBoard wraps GetBoardWithContext using the background context. -func (s *BoardService) GetBoard(boardID int) (*Board, *Response, error) { - return s.GetBoardWithContext(context.Background(), boardID) -} - -// CreateBoardWithContext creates a new board. Board name, type and filter Id is required. +// CreateBoard creates a new board. Board name, type and filter Id is required. // name - Must be less than 255 characters. // type - Valid values: scrum, kanban // filterId - Id of a filter that the user has permissions to view. @@ -190,9 +184,12 @@ func (s *BoardService) GetBoard(boardID int) (*Board, *Response, error) { // board will be created instead (remember that board sharing depends on the filter sharing). // // Jira API docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/board-createBoard -func (s *BoardService) CreateBoardWithContext(ctx context.Context, board *Board) (*Board, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *BoardService) CreateBoard(ctx context.Context, board *Board) (*Board, *Response, error) { apiEndpoint := "rest/agile/1.0/board" - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, board) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, board) if err != nil { return nil, nil, err } @@ -207,18 +204,16 @@ func (s *BoardService) CreateBoardWithContext(ctx context.Context, board *Board) return responseBoard, resp, nil } -// CreateBoard wraps CreateBoardWithContext using the background context. -func (s *BoardService) CreateBoard(board *Board) (*Board, *Response, error) { - return s.CreateBoardWithContext(context.Background(), board) -} - -// DeleteBoardWithContext will delete an agile board. +// DeleteBoard will delete an agile board. // // Jira API docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/board-deleteBoard // Caller must close resp.Body -func (s *BoardService) DeleteBoardWithContext(ctx context.Context, boardID int) (*Board, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *BoardService) DeleteBoard(ctx context.Context, boardID int) (*Board, *Response, error) { apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%v", boardID) - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -230,46 +225,20 @@ func (s *BoardService) DeleteBoardWithContext(ctx context.Context, boardID int) return nil, resp, err } -// DeleteBoard wraps DeleteBoardWithContext using the background context. -// Caller must close resp.Body -func (s *BoardService) DeleteBoard(boardID int) (*Board, *Response, error) { - return s.DeleteBoardWithContext(context.Background(), boardID) -} - -// GetAllSprintsWithContext will return all sprints from a board, for a given board Id. +// GetAllSprints returns all sprints from a board, for a given board ID. // This only includes sprints that the user has permission to view. // -// Jira API docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/board/{boardId}/sprint -func (s *BoardService) GetAllSprintsWithContext(ctx context.Context, boardID string) ([]Sprint, *Response, error) { - id, err := strconv.Atoi(boardID) - if err != nil { - return nil, nil, err - } - - result, response, err := s.GetAllSprintsWithOptions(id, &GetAllSprintsOptions{}) - if err != nil { - return nil, nil, err - } - - return result.Values, response, nil -} - -// GetAllSprints wraps GetAllSprintsWithContext using the background context. -func (s *BoardService) GetAllSprints(boardID string) ([]Sprint, *Response, error) { - return s.GetAllSprintsWithContext(context.Background(), boardID) -} - -// GetAllSprintsWithOptionsWithContext will return sprints from a board, for a given board Id and filtering options -// This only includes sprints that the user has permission to view. +// Jira API docs: https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-sprint-get // -// Jira API docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/board/{boardId}/sprint -func (s *BoardService) GetAllSprintsWithOptionsWithContext(ctx context.Context, boardID int, options *GetAllSprintsOptions) (*SprintsList, *Response, error) { +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *BoardService) GetAllSprints(ctx context.Context, boardID int, options *GetAllSprintsOptions) (*SprintsList, *Response, error) { apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%d/sprint", boardID) url, err := addOptions(apiEndpoint, options) if err != nil { return nil, nil, err } - req, err := s.client.NewRequestWithContext(ctx, "GET", url, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, url, nil) if err != nil { return nil, nil, err } @@ -283,17 +252,15 @@ func (s *BoardService) GetAllSprintsWithOptionsWithContext(ctx context.Context, return result, resp, err } -// GetAllSprintsWithOptions wraps GetAllSprintsWithOptionsWithContext using the background context. -func (s *BoardService) GetAllSprintsWithOptions(boardID int, options *GetAllSprintsOptions) (*SprintsList, *Response, error) { - return s.GetAllSprintsWithOptionsWithContext(context.Background(), boardID, options) -} - -// GetBoardConfigurationWithContext will return a board configuration for a given board Id +// GetBoardConfiguration will return a board configuration for a given board Id // Jira API docs:https://developer.atlassian.com/cloud/jira/software/rest/#api-rest-agile-1-0-board-boardId-configuration-get -func (s *BoardService) GetBoardConfigurationWithContext(ctx context.Context, boardID int) (*BoardConfiguration, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *BoardService) GetBoardConfiguration(ctx context.Context, boardID int) (*BoardConfiguration, *Response, error) { apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%d/configuration", boardID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err @@ -308,8 +275,3 @@ func (s *BoardService) GetBoardConfigurationWithContext(ctx context.Context, boa return result, resp, err } - -// GetBoardConfiguration wraps GetBoardConfigurationWithContext using the background context. -func (s *BoardService) GetBoardConfiguration(boardID int) (*BoardConfiguration, *Response, error) { - return s.GetBoardConfigurationWithContext(context.Background(), boardID) -} diff --git a/onpremise/board_test.go b/onpremise/board_test.go new file mode 100644 index 00000000..44e3e122 --- /dev/null +++ b/onpremise/board_test.go @@ -0,0 +1,235 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestBoardService_GetAllBoards(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/agile/1.0/board" + + raw, err := os.ReadFile("../testing/mock-data/all_boards.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, string(raw)) + }) + + projects, _, err := testClient.Board.GetAllBoards(context.Background(), nil) + if projects == nil { + t.Error("Expected boards list. Boards list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +// Test with params +func TestBoardService_GetAllBoards_WithFilter(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/agile/1.0/board" + + raw, err := os.ReadFile("../testing/mock-data/all_boards_filtered.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + testRequestParams(t, r, map[string]string{"type": "scrum", "name": "Test", "startAt": "1", "maxResults": "10", "projectKeyOrId": "TE"}) + fmt.Fprint(w, string(raw)) + }) + + boardsListOptions := &BoardListOptions{ + BoardType: "scrum", + Name: "Test", + ProjectKeyOrID: "TE", + } + boardsListOptions.StartAt = 1 + boardsListOptions.MaxResults = 10 + + projects, _, err := testClient.Board.GetAllBoards(context.Background(), boardsListOptions) + if projects == nil { + t.Error("Expected boards list. Boards list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestBoardService_GetBoard(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/agile/1.0/board/1" + + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, `{"id":4,"self":"https://test.jira.org/rest/agile/1.0/board/1","name":"Test Weekly","type":"scrum"}`) + }) + + board, _, err := testClient.Board.GetBoard(context.Background(), 1) + if board == nil { + t.Error("Expected board list. Board list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestBoardService_GetBoard_WrongID(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/2/board/99999999" + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, nil) + }) + + board, resp, err := testClient.Board.GetBoard(context.Background(), 99999999) + if board != nil { + t.Errorf("Expected nil. Got %s", err) + } + + if resp.Status == "404" { + t.Errorf("Expected status 404. Got %s", resp.Status) + } + if err == nil { + t.Errorf("Error given: %s", err) + } +} + +func TestBoardService_CreateBoard(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/agile/1.0/board", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/agile/1.0/board") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":17,"self":"https://test.jira.org/rest/agile/1.0/board/17","name":"Test","type":"kanban"}`) + }) + + b := &Board{ + Name: "Test", + Type: "kanban", + FilterID: 17, + } + issue, _, err := testClient.Board.CreateBoard(context.Background(), b) + if issue == nil { + t.Error("Expected board. Board is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestBoardService_DeleteBoard(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/agile/1.0/board/1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/agile/1.0/board/1") + + w.WriteHeader(http.StatusNoContent) + fmt.Fprint(w, `{}`) + }) + + _, resp, err := testClient.Board.DeleteBoard(context.Background(), 1) + if resp.StatusCode != 204 { + t.Error("Expected board not deleted.") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestBoardService_GetAllSprints(t *testing.T) { + setup() + defer teardown() + + testAPIEndpoint := "/rest/agile/1.0/board/123/sprint" + + raw, err := os.ReadFile("../testing/mock-data/sprints_filtered.json") + if err != nil { + t.Error(err.Error()) + } + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + sprints, _, err := testClient.Board.GetAllSprints(context.Background(), 123, &GetAllSprintsOptions{State: "active,future"}) + if err != nil { + t.Errorf("Got error: %v", err) + } + + if sprints == nil { + t.Error("Expected sprint list. Got nil.") + return + } + + if len(sprints.Values) != 1 { + t.Errorf("Expected 1 transition. Got %d", len(sprints.Values)) + } +} + +func TestBoardService_GetBoardConfigoration(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/agile/1.0/board/35/configuration" + + raw, err := os.ReadFile("../testing/mock-data/board_configuration.json") + if err != nil { + t.Error(err.Error()) + } + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + boardConfiguration, _, err := testClient.Board.GetBoardConfiguration(context.Background(), 35) + if err != nil { + t.Errorf("Got error: %v", err) + } + + if boardConfiguration == nil { + t.Error("Expected boardConfiguration. Got nil.") + return + } + + if len(boardConfiguration.ColumnConfig.Columns) != 6 { + t.Errorf("Expected 6 columns. go %d", len(boardConfiguration.ColumnConfig.Columns)) + } + + backlogColumn := boardConfiguration.ColumnConfig.Columns[0] + if backlogColumn.Min != 5 { + t.Errorf("Expected a min of 5 issues in backlog. Got %d", backlogColumn.Min) + } + if backlogColumn.Max != 30 { + t.Errorf("Expected a max of 30 issues in backlog. Got %d", backlogColumn.Max) + } + + inProgressColumn := boardConfiguration.ColumnConfig.Columns[2] + if inProgressColumn.Min != 0 { + t.Errorf("Expected a min of 0 issues in progress. Got %d", inProgressColumn.Min) + } + if inProgressColumn.Max != 0 { + t.Errorf("Expected a max of 0 issues in progress. Got %d", inProgressColumn.Max) + } +} diff --git a/component.go b/onpremise/component.go similarity index 66% rename from component.go rename to onpremise/component.go index b76fe0cf..14ca4504 100644 --- a/component.go +++ b/onpremise/component.go @@ -1,12 +1,13 @@ -package jira +package onpremise -import "context" +import ( + "context" + "net/http" +) // ComponentService handles components for the Jira instance / API.// // Jira API docs: https://docs.atlassian.com/software/jira/docs/api/REST/7.10.1/#api/2/component -type ComponentService struct { - client *Client -} +type ComponentService service // CreateComponentOptions are passed to the ComponentService.Create function to create a new Jira component type CreateComponentOptions struct { @@ -20,10 +21,13 @@ type CreateComponentOptions struct { ProjectID int `json:"projectId,omitempty" structs:"projectId,omitempty"` } -// CreateWithContext creates a new Jira component based on the given options. -func (s *ComponentService) CreateWithContext(ctx context.Context, options *CreateComponentOptions) (*ProjectComponent, *Response, error) { +// Create creates a new Jira component based on the given options. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ComponentService) Create(ctx context.Context, options *CreateComponentOptions) (*ProjectComponent, *Response, error) { apiEndpoint := "rest/api/2/component" - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, options) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, options) if err != nil { return nil, nil, err } @@ -37,8 +41,3 @@ func (s *ComponentService) CreateWithContext(ctx context.Context, options *Creat return component, resp, nil } - -// Create wraps CreateWithContext using the background context. -func (s *ComponentService) Create(options *CreateComponentOptions) (*ProjectComponent, *Response, error) { - return s.CreateWithContext(context.Background(), options) -} diff --git a/component_test.go b/onpremise/component_test.go similarity index 93% rename from component_test.go rename to onpremise/component_test.go index 527cdbe6..28aefaf5 100644 --- a/component_test.go +++ b/onpremise/component_test.go @@ -1,6 +1,7 @@ -package jira +package onpremise import ( + "context" "fmt" "net/http" "testing" @@ -10,14 +11,14 @@ func TestComponentService_Create_Success(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/component", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/component") w.WriteHeader(http.StatusCreated) fmt.Fprint(w, `{ "self": "http://www.example.com/jira/rest/api/2/component/10000", "id": "10000", "name": "Component 1", "description": "This is a Jira component", "lead": { "self": "http://www.example.com/jira/rest/api/2/user?username=fred", "name": "fred", "avatarUrls": { "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" }, "displayName": "Fred F. User", "active": false }, "assigneeType": "PROJECT_LEAD", "assignee": { "self": "http://www.example.com/jira/rest/api/2/user?username=fred", "name": "fred", "avatarUrls": { "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" }, "displayName": "Fred F. User", "active": false }, "realAssigneeType": "PROJECT_LEAD", "realAssignee": { "self": "http://www.example.com/jira/rest/api/2/user?username=fred", "name": "fred", "avatarUrls": { "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" }, "displayName": "Fred F. User", "active": false }, "isAssigneeTypeValid": false, "project": "HSP", "projectId": 10000 }`) }) - component, _, err := testClient.Component.Create(&CreateComponentOptions{ + component, _, err := testClient.Component.Create(context.Background(), &CreateComponentOptions{ Name: "foo-bar", }) if component == nil { diff --git a/onpremise/customer.go b/onpremise/customer.go new file mode 100644 index 00000000..03732ee9 --- /dev/null +++ b/onpremise/customer.go @@ -0,0 +1,68 @@ +package onpremise + +import ( + "context" + "net/http" +) + +// CustomerService handles ServiceDesk customers for the Jira instance / API. +type CustomerService service + +// Customer represents a ServiceDesk customer. +type Customer struct { + AccountID string `json:"accountId,omitempty" structs:"accountId,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + Key string `json:"key,omitempty" structs:"key,omitempty"` + EmailAddress string `json:"emailAddress,omitempty" structs:"emailAddress,omitempty"` + DisplayName string `json:"displayName,omitempty" structs:"displayName,omitempty"` + Active *bool `json:"active,omitempty" structs:"active,omitempty"` + TimeZone string `json:"timeZone,omitempty" structs:"timeZone,omitempty"` + Links *SelfLink `json:"_links,omitempty" structs:"_links,omitempty"` +} + +// CustomerListOptions is the query options for listing customers. +type CustomerListOptions struct { + Query string `url:"query,omitempty"` + Start int `url:"start,omitempty"` + Limit int `url:"limit,omitempty"` +} + +// CustomerList is a page of customers. +type CustomerList struct { + Values []Customer `json:"values,omitempty" structs:"values,omitempty"` + Start int `json:"start,omitempty" structs:"start,omitempty"` + Limit int `json:"limit,omitempty" structs:"limit,omitempty"` + IsLast bool `json:"isLastPage,omitempty" structs:"isLastPage,omitempty"` + Expands []string `json:"_expands,omitempty" structs:"_expands,omitempty"` +} + +// Create creates a ServiceDesk customer. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-customer/#api-rest-servicedeskapi-customer-post +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (c *CustomerService) Create(ctx context.Context, email, displayName string) (*Customer, *Response, error) { + const apiEndpoint = "rest/servicedeskapi/customer" + + payload := struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + }{ + Email: email, + DisplayName: displayName, + } + + req, err := c.client.NewRequest(ctx, http.MethodPost, apiEndpoint, payload) + if err != nil { + return nil, nil, err + } + + responseCustomer := new(Customer) + resp, err := c.client.Do(req, responseCustomer) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return responseCustomer, resp, nil +} diff --git a/onpremise/customer_test.go b/onpremise/customer_test.go new file mode 100644 index 00000000..9cebf77d --- /dev/null +++ b/onpremise/customer_test.go @@ -0,0 +1,57 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "testing" +) + +func TestCustomerService_Create(t *testing.T) { + setup() + defer teardown() + + const ( + wantDisplayName = "Fred F. User" + wantEmailAddress = "fred@example.com" + ) + + testMux.HandleFunc("/rest/servicedeskapi/customer", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/servicedeskapi/customer") + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "accountId": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "name": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "key": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "emailAddress": "%s", + "displayName": "%s", + "active": true, + "timeZone": "Australia/Sydney", + "_links": { + "jiraRest": "https://your-domain.atlassian.net/rest/api/2/user?username=qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/image", + "24x24": "https://avatar-cdn.atlassian.com/image", + "16x16": "https://avatar-cdn.atlassian.com/image", + "32x32": "https://avatar-cdn.atlassian.com/image" + }, + "self": "https://your-domain.atlassian.net/rest/api/2/user?username=qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b" + } + }`, wantEmailAddress, wantDisplayName) + }) + + gotCustomer, _, err := testClient.Customer.Create(context.Background(), wantEmailAddress, wantDisplayName) + if err != nil { + t.Fatal(err) + } + + if want, got := wantDisplayName, gotCustomer.DisplayName; want != got { + t.Fatalf("want display name: %q, got %q", want, got) + } + + if want, got := wantEmailAddress, gotCustomer.EmailAddress; want != got { + t.Fatalf("want email address: %q, got %q", want, got) + } +} diff --git a/onpremise/error.go b/onpremise/error.go new file mode 100644 index 00000000..3894890e --- /dev/null +++ b/onpremise/error.go @@ -0,0 +1,87 @@ +package onpremise + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" +) + +// Error message from Jira +// See https://docs.atlassian.com/jira/REST/cloud/#error-responses +type Error struct { + HTTPError error + ErrorMessages []string `json:"errorMessages"` + Errors map[string]string `json:"errors"` +} + +// NewJiraError creates a new jira Error +func NewJiraError(resp *Response, httpError error) error { + if resp == nil { + return fmt.Errorf("no response returned: %w", httpError) + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("%s: %w", httpError.Error(), err) + } + jerr := Error{HTTPError: httpError} + contentType := resp.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "application/json") { + err = json.Unmarshal(body, &jerr) + if err != nil { + return fmt.Errorf("%s: could not parse JSON: %w", httpError.Error(), err) + } + } else { + if httpError == nil { + return fmt.Errorf("got response status %s:%s", resp.Status, string(body)) + } + return fmt.Errorf("%s: %s: %w", resp.Status, string(body), httpError) + } + + return &jerr +} + +// Error is a short string representing the error +func (e *Error) Error() string { + if len(e.ErrorMessages) > 0 { + // return fmt.Sprintf("%v", e.HTTPError) + return fmt.Sprintf("%s: %v", e.ErrorMessages[0], e.HTTPError) + } + if len(e.Errors) > 0 { + for key, value := range e.Errors { + return fmt.Sprintf("%s - %s: %v", key, value, e.HTTPError) + } + } + return e.HTTPError.Error() +} + +// LongError is a full representation of the error as a string +func (e *Error) LongError() string { + var msg bytes.Buffer + if e.HTTPError != nil { + msg.WriteString("Original:\n") + msg.WriteString(e.HTTPError.Error()) + msg.WriteString("\n") + } + if len(e.ErrorMessages) > 0 { + msg.WriteString("Messages:\n") + for _, v := range e.ErrorMessages { + msg.WriteString(" - ") + msg.WriteString(v) + msg.WriteString("\n") + } + } + if len(e.Errors) > 0 { + for key, value := range e.Errors { + msg.WriteString(" - ") + msg.WriteString(key) + msg.WriteString(" - ") + msg.WriteString(value) + msg.WriteString("\n") + } + } + return msg.String() +} diff --git a/onpremise/error_test.go b/onpremise/error_test.go new file mode 100644 index 00000000..339b9dcd --- /dev/null +++ b/onpremise/error_test.go @@ -0,0 +1,206 @@ +package onpremise + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "testing" +) + +func TestError_NewJiraError(t *testing.T) { + setup() + defer teardown() + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"errorMessages":["Issue does not exist or you do not have permission to see it."],"errors":{}}`) + }) + + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) + resp, _ := testClient.Do(req, nil) + + err := NewJiraError(resp, errors.New("Original http error")) + if err, ok := err.(*Error); !ok { + t.Errorf("Expected jira Error. Got %s", err.Error()) + } + + if !strings.Contains(err.Error(), "Issue does not exist") { + t.Errorf("Expected issue message. Got: %s", err.Error()) + } +} + +func TestError_NoResponse(t *testing.T) { + err := NewJiraError(nil, errors.New("Original http error")) + + msg := err.Error() + if !strings.Contains(msg, "Original http error") { + t.Errorf("Expected the original error message: Got\n%s\n", msg) + } + + if !strings.Contains(msg, "no response returned") { + t.Errorf("Expected the 'no response returned' error message: Got\n%s\n", msg) + } +} + +func TestError_NoJSON(t *testing.T) { + setup() + defer teardown() + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `Original message body`) + }) + + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) + resp, _ := testClient.Do(req, nil) + + err := NewJiraError(resp, errors.New("Original http error")) + msg := err.Error() + + if !strings.Contains(msg, "200 OK: Original message body: Original http error") { + t.Errorf("Expected the HTTP status: Got\n%s\n", msg) + } +} + +func TestError_Unauthorized_NilError(t *testing.T) { + setup() + defer teardown() + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `User is not authorized`) + }) + + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) + resp, _ := testClient.Do(req, nil) + + err := NewJiraError(resp, nil) + msg := err.Error() + if !strings.Contains(msg, "401 Unauthorized:User is not authorized") { + t.Errorf("Expected Unauthorized HTTP status: Got\n%s\n", msg) + } +} + +func TestError_BadJSON(t *testing.T) { + setup() + defer teardown() + + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `Not JSON`) + }) + + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) + resp, _ := testClient.Do(req, nil) + + err := NewJiraError(resp, errors.New("Original http error")) + msg := err.Error() + + if !strings.Contains(msg, "could not parse JSON") { + t.Errorf("Expected the 'could not parse JSON' error message: Got\n%s\n", msg) + } +} + +func TestError_NilOriginalMessage(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Expected an error message. Got a panic (%v)", r) + } + }() + + msgErr := &Error{ + HTTPError: nil, + ErrorMessages: []string{"Issue does not exist"}, + Errors: map[string]string{ + "issuetype": "issue type is required", + "title": "title is required", + }, + } + + _ = msgErr.Error() +} + +func TestError_NilOriginalMessageLongError(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Expected an error message. Got a panic (%v)", r) + } + }() + + msgErr := &Error{ + HTTPError: nil, + ErrorMessages: []string{"Issue does not exist"}, + Errors: map[string]string{ + "issuetype": "issue type is required", + "title": "title is required", + }, + } + + _ = msgErr.LongError() +} + +func TestError_ShortMessage(t *testing.T) { + msgErr := &Error{ + HTTPError: errors.New("Original http error"), + ErrorMessages: []string{"Issue does not exist"}, + Errors: map[string]string{ + "issuetype": "issue type is required", + "title": "title is required", + }, + } + + mapErr := &Error{ + HTTPError: errors.New("Original http error"), + ErrorMessages: nil, + Errors: map[string]string{ + "issuetype": "issue type is required", + "title": "title is required", + }, + } + + noErr := &Error{ + HTTPError: errors.New("Original http error"), + ErrorMessages: nil, + Errors: nil, + } + + err := msgErr.Error() + if err != "Issue does not exist: Original http error" { + t.Errorf("Expected short message. Got %s", err) + } + + err = mapErr.Error() + if !(strings.Contains(err, "issue type is required") || strings.Contains(err, "title is required")) { + t.Errorf("Expected short message. Got %s", err) + } + + err = noErr.Error() + if err != "Original http error" { + t.Errorf("Expected original error message. Got %s", err) + } +} + +func TestError_LongMessage(t *testing.T) { + longError := &Error{ + HTTPError: errors.New("Original http error"), + ErrorMessages: []string{"Issue does not exist."}, + Errors: map[string]string{ + "issuetype": "issue type is required", + "title": "title is required", + }, + } + + msg := longError.LongError() + if !strings.Contains(msg, "Original http error") { + t.Errorf("Expected the error message: Got\n%s\n", msg) + } + + if !strings.Contains(msg, "Issue does not exist") { + t.Errorf("Expected the error message: Got\n%s\n", msg) + } + + if !strings.Contains(msg, "title - title is required") { + t.Errorf("Expected the error map: Got\n%s\n", msg) + } +} diff --git a/onpremise/examples/addlabel/main.go b/onpremise/examples/addlabel/main.go new file mode 100644 index 00000000..0d383ab9 --- /dev/null +++ b/onpremise/examples/addlabel/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" + "syscall" + + jira "github.com/andygrunwald/go-jira/v2/onpremise" + "golang.org/x/term" +) + +func main() { + r := bufio.NewReader(os.Stdin) + + fmt.Print("Jira URL: ") + jiraURL, _ := r.ReadString('\n') + + fmt.Print("Jira Username: ") + username, _ := r.ReadString('\n') + + fmt.Print("Jira Password: ") + bytePassword, _ := term.ReadPassword(int(syscall.Stdin)) + password := string(bytePassword) + + fmt.Print("Jira Issue ID: ") + issueId, _ := r.ReadString('\n') + issueId = strings.TrimSpace(issueId) + + fmt.Print("Label: ") + label, _ := r.ReadString('\n') + label = strings.TrimSpace(label) + + tp := jira.BasicAuthTransport{ + Username: strings.TrimSpace(username), + Password: strings.TrimSpace(password), + } + + client, err := jira.NewClient(strings.TrimSpace(jiraURL), tp.Client()) + if err != nil { + fmt.Printf("\nerror: %v\n", err) + return + } + + type Labels struct { + Add string `json:"add" structs:"add"` + } + + type Update struct { + Labels []Labels `json:"labels" structs:"labels"` + } + + c := map[string]interface{}{ + "update": Update{ + Labels: []Labels{ + { + Add: label, + }, + }, + }, + } + + resp, err := client.Issue.UpdateIssue(context.Background(), issueId, c) + + if err != nil { + fmt.Println(err) + } + body, _ := io.ReadAll(resp.Body) + fmt.Println(string(body)) + + issue, _, _ := client.Issue.Get(context.Background(), issueId, nil) + + fmt.Printf("Issue: %s:%s\n", issue.Key, issue.Fields.Summary) + fmt.Printf("\tLabels: %+v\n", issue.Fields.Labels) +} diff --git a/examples/basicauth/main.go b/onpremise/examples/basicauth/main.go similarity index 77% rename from examples/basicauth/main.go rename to onpremise/examples/basicauth/main.go index 04a0daed..a728e44e 100644 --- a/examples/basicauth/main.go +++ b/onpremise/examples/basicauth/main.go @@ -2,13 +2,15 @@ package main import ( "bufio" + "context" "fmt" - "golang.org/x/term" "os" "strings" "syscall" - jira "github.com/andygrunwald/go-jira" + "golang.org/x/term" + + jira "github.com/andygrunwald/go-jira/v2/onpremise" ) func main() { @@ -29,13 +31,13 @@ func main() { Password: strings.TrimSpace(password), } - client, err := jira.NewClient(tp.Client(), strings.TrimSpace(jiraURL)) + client, err := jira.NewClient(strings.TrimSpace(jiraURL), tp.Client()) if err != nil { fmt.Printf("\nerror: %v\n", err) return } - u, _, err := client.User.Get("admin") + u, _, err := client.User.Get(context.Background(), "admin") if err != nil { fmt.Printf("\nerror: %v\n", err) diff --git a/onpremise/examples/bearerauth/main.go b/onpremise/examples/bearerauth/main.go new file mode 100644 index 00000000..bb351ded --- /dev/null +++ b/onpremise/examples/bearerauth/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "fmt" + + jira "github.com/andygrunwald/go-jira/v2/onpremise" +) + +func main() { + jiraURL := "" + + // See "Using Personal Access Tokens" + // https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html + tp := jira.BearerAuthTransport{ + Token: "", + } + client, err := jira.NewClient(jiraURL, tp.Client()) + if err != nil { + panic(err) + } + + u, _, err := client.User.GetSelf(context.Background()) + if err != nil { + panic(err) + } + + fmt.Printf("Email: %v\n", u.EmailAddress) + fmt.Println("Success!") +} diff --git a/examples/create/main.go b/onpremise/examples/create/main.go similarity index 83% rename from examples/create/main.go rename to onpremise/examples/create/main.go index 00a00c14..b99b7c8c 100644 --- a/examples/create/main.go +++ b/onpremise/examples/create/main.go @@ -2,12 +2,13 @@ package main import ( "bufio" + "context" "fmt" "os" "strings" "syscall" - jira "github.com/andygrunwald/go-jira" + jira "github.com/andygrunwald/go-jira/v2/onpremise" "golang.org/x/term" ) @@ -29,7 +30,7 @@ func main() { Password: strings.TrimSpace(password), } - client, err := jira.NewClient(tp.Client(), strings.TrimSpace(jiraURL)) + client, err := jira.NewClient(strings.TrimSpace(jiraURL), tp.Client()) if err != nil { fmt.Printf("\nerror: %v\n", err) return @@ -54,7 +55,7 @@ func main() { }, } - issue, _, err := client.Issue.Create(&i) + issue, _, err := client.Issue.Create(context.Background(), &i) if err != nil { panic(err) } diff --git a/examples/createwithcustomfields/main.go b/onpremise/examples/createwithcustomfields/main.go similarity index 86% rename from examples/createwithcustomfields/main.go rename to onpremise/examples/createwithcustomfields/main.go index 2a6922a3..2eb954cc 100644 --- a/examples/createwithcustomfields/main.go +++ b/onpremise/examples/createwithcustomfields/main.go @@ -2,12 +2,13 @@ package main import ( "bufio" + "context" "fmt" "os" "strings" "syscall" - jira "github.com/andygrunwald/go-jira" + jira "github.com/andygrunwald/go-jira/v2/onpremise" "github.com/trivago/tgo/tcontainer" "golang.org/x/term" ) @@ -36,7 +37,7 @@ func main() { Password: strings.TrimSpace(password), } - client, err := jira.NewClient(tp.Client(), strings.TrimSpace(jiraURL)) + client, err := jira.NewClient(strings.TrimSpace(jiraURL), tp.Client()) if err != nil { fmt.Printf("\nerror: %v\n", err) os.Exit(1) @@ -65,7 +66,7 @@ func main() { }, } - issue, _, err := client.Issue.Create(&i) + issue, _, err := client.Issue.Create(context.Background(), &i) if err != nil { panic(err) } diff --git a/onpremise/examples/do/main.go b/onpremise/examples/do/main.go new file mode 100644 index 00000000..7c54e275 --- /dev/null +++ b/onpremise/examples/do/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "fmt" + "net/http" + + jira "github.com/andygrunwald/go-jira/v2/onpremise" +) + +func main() { + jiraClient, _ := jira.NewClient("https://jira.atlassian.com/", nil) + req, _ := jiraClient.NewRequest(context.Background(), http.MethodGet, "/rest/api/2/project", nil) + + projects := new([]jira.Project) + res, err := jiraClient.Do(req, projects) + if err != nil { + panic(err) + } + defer res.Body.Close() + + for _, project := range *projects { + fmt.Printf("%s: %s\n", project.Key, project.Name) + } +} diff --git a/onpremise/examples/ignorecerts/main.go b/onpremise/examples/ignorecerts/main.go new file mode 100644 index 00000000..a031e0fe --- /dev/null +++ b/onpremise/examples/ignorecerts/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + + jira "github.com/andygrunwald/go-jira/v2/onpremise" +) + +func main() { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client := &http.Client{Transport: tr} + + jiraClient, _ := jira.NewClient("https://issues.apache.org/jira/", client) + issue, _, _ := jiraClient.Issue.Get(context.Background(), "MESOS-3325", nil) + + fmt.Printf("%s: %+v\n", issue.Key, issue.Fields.Summary) + fmt.Printf("Type: %s\n", issue.Fields.Type.Name) + fmt.Printf("Priority: %s\n", issue.Fields.Priority.Name) + +} diff --git a/onpremise/examples/jql/main.go b/onpremise/examples/jql/main.go new file mode 100644 index 00000000..2509baf4 --- /dev/null +++ b/onpremise/examples/jql/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + "fmt" + + jira "github.com/andygrunwald/go-jira/v2/onpremise" +) + +func main() { + jiraClient, _ := jira.NewClient("https://issues.apache.org/jira/", nil) + + // Running JQL query + + jql := "project = Mesos and type = Bug and Status NOT IN (Resolved)" + fmt.Printf("Usecase: Running a JQL query '%s'\n", jql) + issues, resp, err := jiraClient.Issue.Search(context.Background(), jql, nil) + if err != nil { + panic(err) + } + outputResponse(issues, resp) + + fmt.Println("") + fmt.Println("") + + // Running an empty JQL query to get all tickets + jql = "" + fmt.Printf("Usecase: Running an empty JQL query to get all tickets\n") + issues, resp, err = jiraClient.Issue.Search(context.Background(), jql, nil) + if err != nil { + panic(err) + } + outputResponse(issues, resp) +} + +func outputResponse(issues []jira.Issue, resp *jira.Response) { + fmt.Printf("Call to %s\n", resp.Request.URL) + fmt.Printf("Response Code: %d\n", resp.StatusCode) + fmt.Println("==================================") + for _, i := range issues { + fmt.Printf("%s (%s/%s): %+v\n", i.Key, i.Fields.Type.Name, i.Fields.Priority.Name, i.Fields.Summary) + } +} diff --git a/onpremise/examples/newclient/main.go b/onpremise/examples/newclient/main.go new file mode 100644 index 00000000..35655018 --- /dev/null +++ b/onpremise/examples/newclient/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "context" + "fmt" + + jira "github.com/andygrunwald/go-jira/v2/onpremise" +) + +func main() { + jiraClient, _ := jira.NewClient("https://issues.apache.org/jira/", nil) + issue, _, _ := jiraClient.Issue.Get(context.Background(), "MESOS-3325", nil) + + fmt.Printf("%s: %+v\n", issue.Key, issue.Fields.Summary) + fmt.Printf("Type: %s\n", issue.Fields.Type.Name) + fmt.Printf("Priority: %s\n", issue.Fields.Priority.Name) +} diff --git a/onpremise/examples/pagination/main.go b/onpremise/examples/pagination/main.go new file mode 100644 index 00000000..b4a8b749 --- /dev/null +++ b/onpremise/examples/pagination/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "fmt" + + jira "github.com/andygrunwald/go-jira/v2/onpremise" +) + +// GetAllIssues will implement pagination of api and get all the issues. +// Jira API has limitation as to maxResults it can return at one time. +// You may have usecase where you need to get all the issues according to jql +// This is where this example comes in. +func GetAllIssues(client *jira.Client, searchString string) ([]jira.Issue, error) { + last := 0 + var issues []jira.Issue + for { + opt := &jira.SearchOptions{ + MaxResults: 1000, // Max results can go up to 1000 + StartAt: last, + } + + chunk, resp, err := client.Issue.Search(context.Background(), searchString, opt) + if err != nil { + return nil, err + } + + total := resp.Total + if issues == nil { + issues = make([]jira.Issue, 0, total) + } + issues = append(issues, chunk...) + last = resp.StartAt + len(chunk) + if last >= total { + return issues, nil + } + } + +} + +func main() { + jiraClient, err := jira.NewClient("https://issues.apache.org/jira/", nil) + if err != nil { + panic(err) + } + + jql := "project = Mesos and type = Bug and Status NOT IN (Resolved)" + fmt.Printf("Usecase: Running a JQL query '%s'\n", jql) + + issues, err := GetAllIssues(jiraClient, jql) + if err != nil { + panic(err) + } + fmt.Println(issues) + +} diff --git a/examples/renderedfields/main.go b/onpremise/examples/renderedfields/main.go similarity index 85% rename from examples/renderedfields/main.go rename to onpremise/examples/renderedfields/main.go index b72c946e..98059025 100644 --- a/examples/renderedfields/main.go +++ b/onpremise/examples/renderedfields/main.go @@ -2,14 +2,16 @@ package main import ( "bufio" + "context" "fmt" - "golang.org/x/term" "net/http" "os" "strings" "syscall" - jira "github.com/andygrunwald/go-jira" + "golang.org/x/term" + + jira "github.com/andygrunwald/go-jira/v2/onpremise" ) func main() { @@ -42,7 +44,7 @@ func main() { tp = ba.Client() } - client, err := jira.NewClient(tp, strings.TrimSpace(jiraURL)) + client, err := jira.NewClient(strings.TrimSpace(jiraURL), tp) if err != nil { fmt.Printf("\nerror: %v\n", err) return @@ -51,7 +53,7 @@ func main() { fmt.Printf("Targeting %s for issue %s\n", strings.TrimSpace(jiraURL), key) options := &jira.GetQueryOptions{Expand: "renderedFields"} - u, _, err := client.Issue.Get(key, options) + u, _, err := client.Issue.Get(context.Background(), key, options) if err != nil { fmt.Printf("\n==> error: %v\n", err) diff --git a/examples/searchpages/main.go b/onpremise/examples/searchpages/main.go similarity index 83% rename from examples/searchpages/main.go rename to onpremise/examples/searchpages/main.go index 754a5a07..0e12a661 100644 --- a/examples/searchpages/main.go +++ b/onpremise/examples/searchpages/main.go @@ -2,14 +2,16 @@ package main import ( "bufio" + "context" "fmt" - jira "github.com/andygrunwald/go-jira" - "golang.org/x/term" "log" "os" "strings" "syscall" "time" + + jira "github.com/andygrunwald/go-jira/v2/onpremise" + "golang.org/x/term" ) func main() { @@ -33,7 +35,7 @@ func main() { Password: strings.TrimSpace(password), } - client, err := jira.NewClient(tp.Client(), strings.TrimSpace(jiraURL)) + client, err := jira.NewClient(strings.TrimSpace(jiraURL), tp.Client()) if err != nil { log.Fatal(err) } @@ -48,7 +50,7 @@ func main() { // SearchPages will page through results and pass each issue to appendFunc // In this example, we'll search for all the issues in the target project - err = client.Issue.SearchPages(fmt.Sprintf(`project=%s`, strings.TrimSpace(jiraPK)), nil, appendFunc) + err = client.Issue.SearchPages(context.Background(), fmt.Sprintf(`project=%s`, strings.TrimSpace(jiraPK)), nil, appendFunc) if err != nil { log.Fatal(err) } diff --git a/onpremise/examples/statuscategories/main.go b/onpremise/examples/statuscategories/main.go new file mode 100644 index 00000000..364b7c40 --- /dev/null +++ b/onpremise/examples/statuscategories/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "log" + + jira "github.com/andygrunwald/go-jira/v2/onpremise" +) + +func main() { + jiraClient, err := jira.NewClient("https://issues.apache.org/jira/", nil) + if err != nil { + panic(err) + } + + // Showcase of StatusCategory.GetList: + // Getting all status categories + categories, resp, err := jiraClient.StatusCategory.GetList(context.TODO()) + if err != nil { + log.Println(resp.StatusCode) + panic(err) + } + + for _, statusCategory := range categories { + log.Println(statusCategory) + } + + // Showcase of StatusCategory.Get + // Getting a single status category + category, resp, err := jiraClient.StatusCategory.Get(context.TODO(), "1") + if err != nil { + log.Println(resp.StatusCode) + panic(err) + } + + log.Println(category) +} diff --git a/onpremise/field.go b/onpremise/field.go new file mode 100644 index 00000000..ec548daa --- /dev/null +++ b/onpremise/field.go @@ -0,0 +1,54 @@ +package onpremise + +import ( + "context" + "net/http" +) + +// FieldService handles fields for the Jira instance / API. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-Field +type FieldService service + +// Field represents a field of a Jira issue. +type Field struct { + ID string `json:"id,omitempty" structs:"id,omitempty"` + Key string `json:"key,omitempty" structs:"key,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + Custom bool `json:"custom,omitempty" structs:"custom,omitempty"` + Navigable bool `json:"navigable,omitempty" structs:"navigable,omitempty"` + Searchable bool `json:"searchable,omitempty" structs:"searchable,omitempty"` + ClauseNames []string `json:"clauseNames,omitempty" structs:"clauseNames,omitempty"` + Schema FieldSchema `json:"schema,omitempty" structs:"schema,omitempty"` +} + +// FieldSchema represents a schema of a Jira field. +// Documentation: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issue-fields/#api-rest-api-2-field-get +type FieldSchema struct { + Type string `json:"type,omitempty" structs:"type,omitempty"` + Items string `json:"items,omitempty" structs:"items,omitempty"` + Custom string `json:"custom,omitempty" structs:"custom,omitempty"` + System string `json:"system,omitempty" structs:"system,omitempty"` + CustomID int64 `json:"customId,omitempty" structs:"customId,omitempty"` +} + +// GetList gets all fields from Jira +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-field-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *FieldService) GetList(ctx context.Context) ([]Field, *Response, error) { + apiEndpoint := "rest/api/2/field" + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + fieldList := []Field{} + resp, err := s.client.Do(req, &fieldList) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + return fieldList, resp, nil +} diff --git a/onpremise/field_test.go b/onpremise/field_test.go new file mode 100644 index 00000000..ae8ed4f1 --- /dev/null +++ b/onpremise/field_test.go @@ -0,0 +1,33 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestFieldService_GetList(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/field" + + raw, err := os.ReadFile("../testing/mock-data/all_fields.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, string(raw)) + }) + + fields, _, err := testClient.Field.GetList(context.Background()) + if fields == nil { + t.Error("Expected field list. Field list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/onpremise/filter.go b/onpremise/filter.go new file mode 100644 index 00000000..8876f26b --- /dev/null +++ b/onpremise/filter.go @@ -0,0 +1,240 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + + "github.com/google/go-querystring/query" +) + +// FilterService handles fields for the Jira instance / API. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-group-Filter +type FilterService service + +// Filter represents a Filter in Jira +type Filter struct { + Self string `json:"self"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Owner User `json:"owner"` + Jql string `json:"jql"` + ViewURL string `json:"viewUrl"` + SearchURL string `json:"searchUrl"` + Favourite bool `json:"favourite"` + FavouritedCount int `json:"favouritedCount"` + SharePermissions []interface{} `json:"sharePermissions"` + Subscriptions struct { + Size int `json:"size"` + Items []interface{} `json:"items"` + MaxResults int `json:"max-results"` + StartIndex int `json:"start-index"` + EndIndex int `json:"end-index"` + } `json:"subscriptions"` +} + +// GetMyFiltersQueryOptions specifies the optional parameters for the Get My Filters method +type GetMyFiltersQueryOptions struct { + IncludeFavourites bool `url:"includeFavourites,omitempty"` + Expand string `url:"expand,omitempty"` +} + +// FiltersList reflects a list of filters +type FiltersList struct { + MaxResults int `json:"maxResults" structs:"maxResults"` + StartAt int `json:"startAt" structs:"startAt"` + Total int `json:"total" structs:"total"` + IsLast bool `json:"isLast" structs:"isLast"` + Values []FiltersListItem `json:"values" structs:"values"` +} + +// FiltersListItem represents a Filter of FiltersList in Jira +type FiltersListItem struct { + Self string `json:"self"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Owner User `json:"owner"` + Jql string `json:"jql"` + ViewURL string `json:"viewUrl"` + SearchURL string `json:"searchUrl"` + Favourite bool `json:"favourite"` + FavouritedCount int `json:"favouritedCount"` + SharePermissions []interface{} `json:"sharePermissions"` + Subscriptions []struct { + ID int `json:"id"` + User User `json:"user"` + } `json:"subscriptions"` +} + +// FilterSearchOptions specifies the optional parameters for the Search method +// https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-rest-api-3-filter-search-get +type FilterSearchOptions struct { + // String used to perform a case-insensitive partial match with name. + FilterName string `url:"filterName,omitempty"` + + // User account ID used to return filters with the matching owner.accountId. This parameter cannot be used with owner. + AccountID string `url:"accountId,omitempty"` + + // Group name used to returns filters that are shared with a group that matches sharePermissions.group.groupname. + GroupName string `url:"groupname,omitempty"` + + // Project ID used to returns filters that are shared with a project that matches sharePermissions.project.id. + // Format: int64 + ProjectID int64 `url:"projectId,omitempty"` + + // Orders the results using one of these filter properties. + // - `description` Orders by filter `description`. Note that this ordering works independently of whether the expand to display the description field is in use. + // - `favourite_count` Orders by `favouritedCount`. + // - `is_favourite` Orders by `favourite`. + // - `id` Orders by filter `id`. + // - `name` Orders by filter `name`. + // - `owner` Orders by `owner.accountId`. + // + // Default: `name` + // + // Valid values: id, name, description, owner, favorite_count, is_favorite, -id, -name, -description, -owner, -favorite_count, -is_favorite + OrderBy string `url:"orderBy,omitempty"` + + // The index of the first item to return in a page of results (page offset). + // Default: 0, Format: int64 + StartAt int64 `url:"startAt,omitempty"` + + // The maximum number of items to return per page. The maximum is 100. + // Default: 50, Format: int32 + MaxResults int32 `url:"maxResults,omitempty"` + + // Use expand to include additional information about filter in the response. This parameter accepts multiple values separated by a comma: + // - description Returns the description of the filter. + // - favourite Returns an indicator of whether the user has set the filter as a favorite. + // - favouritedCount Returns a count of how many users have set this filter as a favorite. + // - jql Returns the JQL query that the filter uses. + // - owner Returns the owner of the filter. + // - searchUrl Returns a URL to perform the filter's JQL query. + // - sharePermissions Returns the share permissions defined for the filter. + // - subscriptions Returns the users that are subscribed to the filter. + // - viewUrl Returns a URL to view the filter. + Expand string `url:"expand,omitempty"` +} + +// GetList retrieves all filters from Jira +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (fs *FilterService) GetList(ctx context.Context) ([]*Filter, *Response, error) { + + options := &GetQueryOptions{} + apiEndpoint := "rest/api/2/filter" + req, err := fs.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + q, err := query.Values(options) + if err != nil { + return nil, nil, err + } + req.URL.RawQuery = q.Encode() + + filters := []*Filter{} + resp, err := fs.client.Do(req, &filters) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + return filters, resp, err +} + +// GetFavouriteList retrieves the user's favourited filters from Jira +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (fs *FilterService) GetFavouriteList(ctx context.Context) ([]*Filter, *Response, error) { + apiEndpoint := "rest/api/2/filter/favourite" + req, err := fs.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + filters := []*Filter{} + resp, err := fs.client.Do(req, &filters) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + return filters, resp, err +} + +// Get retrieves a single Filter from Jira +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (fs *FilterService) Get(ctx context.Context, filterID int) (*Filter, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/filter/%d", filterID) + req, err := fs.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + filter := new(Filter) + resp, err := fs.client.Do(req, filter) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return filter, resp, err +} + +// GetMyFilters retrieves the my Filters. +// +// https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-rest-api-3-filter-my-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (fs *FilterService) GetMyFilters(ctx context.Context, opts *GetMyFiltersQueryOptions) ([]*Filter, *Response, error) { + apiEndpoint := "rest/api/3/filter/my" + url, err := addOptions(apiEndpoint, opts) + if err != nil { + return nil, nil, err + } + req, err := fs.client.NewRequest(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, nil, err + } + + filters := []*Filter{} + resp, err := fs.client.Do(req, &filters) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + return filters, resp, nil +} + +// Search will search for filter according to the search options +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-rest-api-3-filter-search-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (fs *FilterService) Search(ctx context.Context, opt *FilterSearchOptions) (*FiltersList, *Response, error) { + apiEndpoint := "rest/api/3/filter/search" + url, err := addOptions(apiEndpoint, opt) + if err != nil { + return nil, nil, err + } + req, err := fs.client.NewRequest(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, nil, err + } + + filters := new(FiltersList) + resp, err := fs.client.Do(req, filters) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return filters, resp, err +} diff --git a/onpremise/filter_test.go b/onpremise/filter_test.go new file mode 100644 index 00000000..55af7fc2 --- /dev/null +++ b/onpremise/filter_test.go @@ -0,0 +1,127 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestFilterService_GetList(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/2/filter" + raw, err := os.ReadFile("../testing/mock-data/all_filters.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testAPIEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testAPIEndpoint) + fmt.Fprint(writer, string(raw)) + }) + + filters, _, err := testClient.Filter.GetList(context.Background()) + if filters == nil { + t.Error("Expected Filters list. Filters list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestFilterService_Get(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/2/filter/10000" + raw, err := os.ReadFile("../testing/mock-data/filter.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testAPIEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testAPIEndpoint) + fmt.Fprint(writer, string(raw)) + }) + + filter, _, err := testClient.Filter.Get(context.Background(), 10000) + if filter == nil { + t.Errorf("Expected Filter, got nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } + +} + +func TestFilterService_GetFavouriteList(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/2/filter/favourite" + raw, err := os.ReadFile("../testing/mock-data/favourite_filters.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testAPIEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testAPIEndpoint) + fmt.Fprint(writer, string(raw)) + }) + + filters, _, err := testClient.Filter.GetFavouriteList(context.Background()) + if filters == nil { + t.Error("Expected Filters list. Filters list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestFilterService_GetMyFilters(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/3/filter/my" + raw, err := os.ReadFile("../testing/mock-data/my_filters.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testAPIEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testAPIEndpoint) + fmt.Fprint(writer, string(raw)) + }) + + opts := GetMyFiltersQueryOptions{} + filters, _, err := testClient.Filter.GetMyFilters(context.Background(), &opts) + if err != nil { + t.Errorf("Error given: %s", err) + } + if filters == nil { + t.Errorf("Expected Filters, got nil") + } +} + +func TestFilterService_Search(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/3/filter/search" + raw, err := os.ReadFile("../testing/mock-data/search_filters.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testAPIEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testAPIEndpoint) + fmt.Fprint(writer, string(raw)) + }) + + opt := FilterSearchOptions{} + filters, _, err := testClient.Filter.Search(context.Background(), &opt) + if err != nil { + t.Errorf("Error given: %s", err) + } + if filters == nil { + t.Errorf("Expected Filters, got nil") + } +} diff --git a/group.go b/onpremise/group.go similarity index 56% rename from group.go rename to onpremise/group.go index f78c6810..82a8c951 100644 --- a/group.go +++ b/onpremise/group.go @@ -1,17 +1,16 @@ -package jira +package onpremise import ( "context" "fmt" + "net/http" "net/url" ) // GroupService handles Groups for the Jira instance / API. // // Jira API docs: https://docs.atlassian.com/jira/REST/server/#api/2/group -type GroupService struct { - client *Client -} +type GroupService service // groupMembersResult is only a small wrapper around the Group* methods // to be able to parse the results @@ -59,44 +58,22 @@ type GroupSearchOptions struct { IncludeInactiveUsers bool } -// GetWithContext returns a paginated list of users who are members of the specified group and its subgroups. +// Get returns a paginated list of members of the specified group and its subgroups. // Users in the page are ordered by user names. // User of this resource is required to have sysadmin or admin permissions. // // Jira API docs: https://docs.atlassian.com/jira/REST/server/#api/2/group-getUsersFromGroup // // WARNING: This API only returns the first page of group members -func (s *GroupService) GetWithContext(ctx context.Context, name string) ([]GroupMember, *Response, error) { - apiEndpoint := fmt.Sprintf("/rest/api/2/group/member?groupname=%s", url.QueryEscape(name)) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) - if err != nil { - return nil, nil, err - } - - group := new(groupMembersResult) - resp, err := s.client.Do(req, group) - if err != nil { - return nil, resp, err - } - - return group.Members, resp, nil -} - -// Get wraps GetWithContext using the background context. -func (s *GroupService) Get(name string) ([]GroupMember, *Response, error) { - return s.GetWithContext(context.Background(), name) -} - -// GetWithOptionsWithContext returns a paginated list of members of the specified group and its subgroups. -// Users in the page are ordered by user names. -// User of this resource is required to have sysadmin or admin permissions. // -// Jira API docs: https://docs.atlassian.com/jira/REST/server/#api/2/group-getUsersFromGroup -func (s *GroupService) GetWithOptionsWithContext(ctx context.Context, name string, options *GroupSearchOptions) ([]GroupMember, *Response, error) { +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *GroupService) Get(ctx context.Context, name string, options *GroupSearchOptions) ([]GroupMember, *Response, error) { var apiEndpoint string if options == nil { apiEndpoint = fmt.Sprintf("/rest/api/2/group/member?groupname=%s", url.QueryEscape(name)) } else { + // TODO use addOptions apiEndpoint = fmt.Sprintf( "/rest/api/2/group/member?groupname=%s&startAt=%d&maxResults=%d&includeInactiveUsers=%t", url.QueryEscape(name), @@ -105,7 +82,7 @@ func (s *GroupService) GetWithOptionsWithContext(ctx context.Context, name strin options.IncludeInactiveUsers, ) } - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -118,21 +95,19 @@ func (s *GroupService) GetWithOptionsWithContext(ctx context.Context, name strin return group.Members, resp, nil } -// GetWithOptions wraps GetWithOptionsWithContext using the background context. -func (s *GroupService) GetWithOptions(name string, options *GroupSearchOptions) ([]GroupMember, *Response, error) { - return s.GetWithOptionsWithContext(context.Background(), name, options) -} - -// AddWithContext adds user to group +// Add adds user to group // // Jira API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/group-addUserToGroup -func (s *GroupService) AddWithContext(ctx context.Context, groupname string, username string) (*Group, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *GroupService) Add(ctx context.Context, groupname string, username string) (*Group, *Response, error) { apiEndpoint := fmt.Sprintf("/rest/api/2/group/user?groupname=%s", groupname) var user struct { Name string `json:"name"` } user.Name = username - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, &user) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, &user) if err != nil { return nil, nil, err } @@ -147,18 +122,16 @@ func (s *GroupService) AddWithContext(ctx context.Context, groupname string, use return responseGroup, resp, nil } -// Add wraps AddWithContext using the background context. -func (s *GroupService) Add(groupname string, username string) (*Group, *Response, error) { - return s.AddWithContext(context.Background(), groupname, username) -} - -// RemoveWithContext removes user from group +// Remove removes user from group // // Jira API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/group-removeUserFromGroup // Caller must close resp.Body -func (s *GroupService) RemoveWithContext(ctx context.Context, groupname string, username string) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *GroupService) Remove(ctx context.Context, groupname string, username string) (*Response, error) { apiEndpoint := fmt.Sprintf("/rest/api/2/group/user?groupname=%s&username=%s", groupname, username) - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) if err != nil { return nil, err } @@ -171,9 +144,3 @@ func (s *GroupService) RemoveWithContext(ctx context.Context, groupname string, return resp, nil } - -// Remove wraps RemoveWithContext using the background context. -// Caller must close resp.Body -func (s *GroupService) Remove(groupname string, username string) (*Response, error) { - return s.RemoveWithContext(context.Background(), groupname, username) -} diff --git a/group_test.go b/onpremise/group_test.go similarity index 73% rename from group_test.go rename to onpremise/group_test.go index e4503ddd..9ffd6bab 100644 --- a/group_test.go +++ b/onpremise/group_test.go @@ -1,31 +1,17 @@ -package jira +package onpremise import ( + "context" "fmt" "net/http" "testing" ) -func TestGroupService_Get(t *testing.T) { - setup() - defer teardown() - testMux.HandleFunc("/rest/api/2/group/member", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, "/rest/api/2/group/member?groupname=default") - fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/group/member?includeInactiveUsers=false&maxResults=50&groupname=default&startAt=0","maxResults":50,"startAt":0,"total":2,"isLast":true,"values":[{"self":"http://www.example.com/jira/rest/api/2/user?username=michael","name":"michael","key":"michael","emailAddress":"michael@example.com","displayName":"MichaelScofield","active":true,"timeZone":"Australia/Sydney"},{"self":"http://www.example.com/jira/rest/api/2/user?username=alex","name":"alex","key":"alex","emailAddress":"alex@example.com","displayName":"AlexanderMahone","active":true,"timeZone":"Australia/Sydney"}]}`) - }) - if members, _, err := testClient.Group.Get("default"); err != nil { - t.Errorf("Error given: %s", err) - } else if members == nil { - t.Error("Expected members. Group.Members is nil") - } -} - func TestGroupService_GetPage(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/group/member", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/group/member?groupname=default") startAt := r.URL.Query().Get("startAt") if startAt == "0" { @@ -36,7 +22,7 @@ func TestGroupService_GetPage(t *testing.T) { t.Errorf("startAt %s", startAt) } }) - if page, resp, err := testClient.Group.GetWithOptions("default", &GroupSearchOptions{ + if page, resp, err := testClient.Group.Get(context.Background(), "default", &GroupSearchOptions{ StartAt: 0, MaxResults: 2, IncludeInactiveUsers: false, @@ -54,7 +40,7 @@ func TestGroupService_GetPage(t *testing.T) { if resp.Total != 4 { t.Errorf("Expect Result Total to be 4, but is %d", resp.Total) } - if page, resp, err := testClient.Group.GetWithOptions("default", &GroupSearchOptions{ + if page, resp, err := testClient.Group.Get(context.Background(), "default", &GroupSearchOptions{ StartAt: 2, MaxResults: 2, IncludeInactiveUsers: false, @@ -80,14 +66,14 @@ func TestGroupService_Add(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/group/user", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/group/user?groupname=default") w.WriteHeader(http.StatusCreated) fmt.Fprint(w, `{"name":"default","self":"http://www.example.com/jira/rest/api/2/group?groupname=default","users":{"size":1,"items":[],"max-results":50,"start-index":0,"end-index":0},"expand":"users"}`) }) - if group, _, err := testClient.Group.Add("default", "theodore"); err != nil { + if group, _, err := testClient.Group.Add(context.Background(), "default", "theodore"); err != nil { t.Errorf("Error given: %s", err) } else if group == nil { t.Error("Expected group. Group is nil") @@ -98,14 +84,14 @@ func TestGroupService_Remove(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/group/user", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/api/2/group/user?groupname=default") w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"name":"default","self":"http://www.example.com/jira/rest/api/2/group?groupname=default","users":{"size":1,"items":[],"max-results":50,"start-index":0,"end-index":0},"expand":"users"}`) }) - if _, err := testClient.Group.Remove("default", "theodore"); err != nil { + if _, err := testClient.Group.Remove(context.Background(), "default", "theodore"); err != nil { t.Errorf("Error given: %s", err) } } diff --git a/onpremise/issue.go b/onpremise/issue.go new file mode 100644 index 00000000..d1bca7aa --- /dev/null +++ b/onpremise/issue.go @@ -0,0 +1,1525 @@ +package onpremise + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "reflect" + "strconv" + "strings" + "time" + + "github.com/fatih/structs" + "github.com/google/go-querystring/query" + "github.com/trivago/tgo/tcontainer" +) + +const ( + // AssigneeAutomatic represents the value of the "Assignee: Automatic" of Jira + AssigneeAutomatic = "-1" +) + +// IssueService handles Issues for the Jira instance / API. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue +type IssueService service + +// UpdateQueryOptions specifies the optional parameters to the Edit issue +type UpdateQueryOptions struct { + NotifyUsers bool `url:"notifyUsers,omitempty"` + OverrideScreenSecurity bool `url:"overrideScreenSecurity,omitempty"` + OverrideEditableFlag bool `url:"overrideEditableFlag,omitempty"` +} + +// Issue represents a Jira issue. +type Issue struct { + Expand string `json:"expand,omitempty" structs:"expand,omitempty"` + ID string `json:"id,omitempty" structs:"id,omitempty"` + Self string `json:"self,omitempty" structs:"self,omitempty"` + Key string `json:"key,omitempty" structs:"key,omitempty"` + Fields *IssueFields `json:"fields,omitempty" structs:"fields,omitempty"` + RenderedFields *IssueRenderedFields `json:"renderedFields,omitempty" structs:"renderedFields,omitempty"` + Changelog *Changelog `json:"changelog,omitempty" structs:"changelog,omitempty"` + Transitions []Transition `json:"transitions,omitempty" structs:"transitions,omitempty"` + Names map[string]string `json:"names,omitempty" structs:"names,omitempty"` +} + +// ChangelogItems reflects one single changelog item of a history item +type ChangelogItems struct { + Field string `json:"field" structs:"field"` + FieldType string `json:"fieldtype" structs:"fieldtype"` + From interface{} `json:"from" structs:"from"` + FromString string `json:"fromString" structs:"fromString"` + To interface{} `json:"to" structs:"to"` + ToString string `json:"toString" structs:"toString"` +} + +// ChangelogHistory reflects one single changelog history entry +type ChangelogHistory struct { + Id string `json:"id" structs:"id"` + Author User `json:"author" structs:"author"` + Created string `json:"created" structs:"created"` + Items []ChangelogItems `json:"items" structs:"items"` +} + +// Changelog reflects the change log of an issue +type Changelog struct { + Histories []ChangelogHistory `json:"histories,omitempty"` +} + +// Attachment represents a Jira attachment +type Attachment struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + ID string `json:"id,omitempty" structs:"id,omitempty"` + Filename string `json:"filename,omitempty" structs:"filename,omitempty"` + Author *User `json:"author,omitempty" structs:"author,omitempty"` + Created string `json:"created,omitempty" structs:"created,omitempty"` + Size int `json:"size,omitempty" structs:"size,omitempty"` + MimeType string `json:"mimeType,omitempty" structs:"mimeType,omitempty"` + Content string `json:"content,omitempty" structs:"content,omitempty"` + Thumbnail string `json:"thumbnail,omitempty" structs:"thumbnail,omitempty"` +} + +// Epic represents the epic to which an issue is associated +// Not that this struct does not process the returned "color" value +type Epic struct { + ID int `json:"id" structs:"id"` + Key string `json:"key" structs:"key"` + Self string `json:"self" structs:"self"` + Name string `json:"name" structs:"name"` + Summary string `json:"summary" structs:"summary"` + Done bool `json:"done" structs:"done"` +} + +// IssueFields represents single fields of a Jira issue. +// Every Jira issue has several fields attached. +type IssueFields struct { + // TODO Missing fields + // * "workratio": -1, + // * "lastViewed": null, + // * "environment": null, + Expand string `json:"expand,omitempty" structs:"expand,omitempty"` + Type IssueType `json:"issuetype,omitempty" structs:"issuetype,omitempty"` + Project Project `json:"project,omitempty" structs:"project,omitempty"` + Environment string `json:"environment,omitempty" structs:"environment,omitempty"` + Resolution *Resolution `json:"resolution,omitempty" structs:"resolution,omitempty"` + Priority *Priority `json:"priority,omitempty" structs:"priority,omitempty"` + Resolutiondate Time `json:"resolutiondate,omitempty" structs:"resolutiondate,omitempty"` + Created Time `json:"created,omitempty" structs:"created,omitempty"` + Duedate Date `json:"duedate,omitempty" structs:"duedate,omitempty"` + Watches *Watches `json:"watches,omitempty" structs:"watches,omitempty"` + Assignee *User `json:"assignee,omitempty" structs:"assignee,omitempty"` + Updated Time `json:"updated,omitempty" structs:"updated,omitempty"` + Description string `json:"description,omitempty" structs:"description,omitempty"` + Summary string `json:"summary,omitempty" structs:"summary,omitempty"` + Creator *User `json:"Creator,omitempty" structs:"Creator,omitempty"` + Reporter *User `json:"reporter,omitempty" structs:"reporter,omitempty"` + Components []*Component `json:"components,omitempty" structs:"components,omitempty"` + Status *Status `json:"status,omitempty" structs:"status,omitempty"` + Progress *Progress `json:"progress,omitempty" structs:"progress,omitempty"` + AggregateProgress *Progress `json:"aggregateprogress,omitempty" structs:"aggregateprogress,omitempty"` + TimeTracking *TimeTracking `json:"timetracking,omitempty" structs:"timetracking,omitempty"` + TimeSpent int `json:"timespent,omitempty" structs:"timespent,omitempty"` + TimeEstimate int `json:"timeestimate,omitempty" structs:"timeestimate,omitempty"` + TimeOriginalEstimate int `json:"timeoriginalestimate,omitempty" structs:"timeoriginalestimate,omitempty"` + Worklog *Worklog `json:"worklog,omitempty" structs:"worklog,omitempty"` + IssueLinks []*IssueLink `json:"issuelinks,omitempty" structs:"issuelinks,omitempty"` + Comments *Comments `json:"comment,omitempty" structs:"comment,omitempty"` + FixVersions []*FixVersion `json:"fixVersions,omitempty" structs:"fixVersions,omitempty"` + AffectsVersions []*AffectsVersion `json:"versions,omitempty" structs:"versions,omitempty"` + Labels []string `json:"labels,omitempty" structs:"labels,omitempty"` + Subtasks []*Subtasks `json:"subtasks,omitempty" structs:"subtasks,omitempty"` + Attachments []*Attachment `json:"attachment,omitempty" structs:"attachment,omitempty"` + Epic *Epic `json:"epic,omitempty" structs:"epic,omitempty"` + Sprint *Sprint `json:"sprint,omitempty" structs:"sprint,omitempty"` + Parent *Parent `json:"parent,omitempty" structs:"parent,omitempty"` + AggregateTimeOriginalEstimate int `json:"aggregatetimeoriginalestimate,omitempty" structs:"aggregatetimeoriginalestimate,omitempty"` + AggregateTimeSpent int `json:"aggregatetimespent,omitempty" structs:"aggregatetimespent,omitempty"` + AggregateTimeEstimate int `json:"aggregatetimeestimate,omitempty" structs:"aggregatetimeestimate,omitempty"` + Unknowns tcontainer.MarshalMap +} + +// MarshalJSON is a custom JSON marshal function for the IssueFields structs. +// It handles Jira custom fields and maps those from / to "Unknowns" key. +func (i *IssueFields) MarshalJSON() ([]byte, error) { + m := structs.Map(i) + unknowns, okay := m["Unknowns"] + if okay { + // if unknowns present, shift all key value from unknown to a level up + for key, value := range unknowns.(tcontainer.MarshalMap) { + m[key] = value + } + delete(m, "Unknowns") + } + return json.Marshal(m) +} + +// UnmarshalJSON is a custom JSON marshal function for the IssueFields structs. +// It handles Jira custom fields and maps those from / to "Unknowns" key. +func (i *IssueFields) UnmarshalJSON(data []byte) error { + + // Do the normal unmarshalling first + // Details for this way: http://choly.ca/post/go-json-marshalling/ + type Alias IssueFields + aux := &struct { + *Alias + }{ + Alias: (*Alias)(i), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + totalMap := tcontainer.NewMarshalMap() + err := json.Unmarshal(data, &totalMap) + if err != nil { + return err + } + + t := reflect.TypeOf(*i) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + tagDetail := field.Tag.Get("json") + if tagDetail == "" { + // ignore if there are no tags + continue + } + options := strings.Split(tagDetail, ",") + + if len(options) == 0 { + return fmt.Errorf("no tags options found for %s", field.Name) + } + // the first one is the json tag + key := options[0] + if _, okay := totalMap.Value(key); okay { + delete(totalMap, key) + } + + } + i = (*IssueFields)(aux.Alias) + // all the tags found in the struct were removed. Whatever is left are unknowns to struct + i.Unknowns = totalMap + return nil + +} + +// IssueRenderedFields represents rendered fields of a Jira issue. +// Not all IssueFields are rendered. +type IssueRenderedFields struct { + // TODO Missing fields + // * "aggregatetimespent": null, + // * "workratio": -1, + // * "lastViewed": null, + // * "aggregatetimeoriginalestimate": null, + // * "aggregatetimeestimate": null, + // * "environment": null, + Resolutiondate string `json:"resolutiondate,omitempty" structs:"resolutiondate,omitempty"` + Created string `json:"created,omitempty" structs:"created,omitempty"` + Duedate string `json:"duedate,omitempty" structs:"duedate,omitempty"` + Updated string `json:"updated,omitempty" structs:"updated,omitempty"` + Comments *Comments `json:"comment,omitempty" structs:"comment,omitempty"` + Description string `json:"description,omitempty" structs:"description,omitempty"` +} + +// IssueType represents a type of a Jira issue. +// Typical types are "Request", "Bug", "Story", ... +type IssueType struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + ID string `json:"id,omitempty" structs:"id,omitempty"` + Description string `json:"description,omitempty" structs:"description,omitempty"` + IconURL string `json:"iconUrl,omitempty" structs:"iconUrl,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + Subtask bool `json:"subtask,omitempty" structs:"subtask,omitempty"` + AvatarID int `json:"avatarId,omitempty" structs:"avatarId,omitempty"` +} + +// Watches represents a type of how many and which user are "observing" a Jira issue to track the status / updates. +type Watches struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + WatchCount int `json:"watchCount,omitempty" structs:"watchCount,omitempty"` + IsWatching bool `json:"isWatching,omitempty" structs:"isWatching,omitempty"` + Watchers []*Watcher `json:"watchers,omitempty" structs:"watchers,omitempty"` +} + +// Watcher represents a simplified user that "observes" the issue +type Watcher struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + AccountID string `json:"accountId,omitempty" structs:"accountId,omitempty"` + DisplayName string `json:"displayName,omitempty" structs:"displayName,omitempty"` + Active bool `json:"active,omitempty" structs:"active,omitempty"` +} + +// AvatarUrls represents different dimensions of avatars / images +type AvatarUrls struct { + Four8X48 string `json:"48x48,omitempty" structs:"48x48,omitempty"` + Two4X24 string `json:"24x24,omitempty" structs:"24x24,omitempty"` + One6X16 string `json:"16x16,omitempty" structs:"16x16,omitempty"` + Three2X32 string `json:"32x32,omitempty" structs:"32x32,omitempty"` +} + +// Component represents a "component" of a Jira issue. +// Components can be user defined in every Jira instance. +type Component struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + ID string `json:"id,omitempty" structs:"id,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + Description string `json:"description,omitempty" structs:"description,omitempty"` +} + +// Progress represents the progress of a Jira issue. +type Progress struct { + Progress int `json:"progress" structs:"progress"` + Total int `json:"total" structs:"total"` + Percent int `json:"percent" structs:"percent"` +} + +// Parent represents the parent of a Jira issue, to be used with subtask issue types. +type Parent struct { + ID string `json:"id,omitempty" structs:"id,omitempty"` + Key string `json:"key,omitempty" structs:"key,omitempty"` +} + +// Time represents the Time definition of Jira as a time.Time of go +type Time time.Time + +func (t Time) Equal(u Time) bool { + return time.Time(t).Equal(time.Time(u)) +} + +// Date represents the Date definition of Jira as a time.Time of go +type Date time.Time + +// Wrapper struct for search result +type transitionResult struct { + Transitions []Transition `json:"transitions" structs:"transitions"` +} + +// Transition represents an issue transition in Jira +type Transition struct { + ID string `json:"id" structs:"id"` + Name string `json:"name" structs:"name"` + To Status `json:"to" structs:"status"` + Fields map[string]TransitionField `json:"fields" structs:"fields"` +} + +// TransitionField represents the value of one Transition +type TransitionField struct { + Required bool `json:"required" structs:"required"` +} + +// CreateTransitionPayload is used for creating new issue transitions +type CreateTransitionPayload struct { + Update TransitionPayloadUpdate `json:"update,omitempty" structs:"update,omitempty"` + Transition TransitionPayload `json:"transition" structs:"transition"` + Fields TransitionPayloadFields `json:"fields" structs:"fields"` +} + +// TransitionPayloadUpdate represents the updates of Transition calls like DoTransition +type TransitionPayloadUpdate struct { + Comment []TransitionPayloadComment `json:"comment,omitempty" structs:"comment,omitempty"` +} + +// TransitionPayloadComment represents comment in Transition payload +type TransitionPayloadComment struct { + Add TransitionPayloadCommentBody `json:"add,omitempty" structs:"add,omitempty"` +} + +// TransitionPayloadCommentBody represents body of comment in payload +type TransitionPayloadCommentBody struct { + Body string `json:"body,omitempty"` +} + +// TransitionPayload represents the request payload of Transition calls like DoTransition +type TransitionPayload struct { + ID string `json:"id" structs:"id"` +} + +// TransitionPayloadFields represents the fields that can be set when executing a transition +type TransitionPayloadFields struct { + Resolution *Resolution `json:"resolution,omitempty" structs:"resolution,omitempty"` +} + +// Option represents an option value in a SelectList or MultiSelect +// custom issue field +type Option struct { + Value string `json:"value" structs:"value"` +} + +// UnmarshalJSON will transform the Jira time into a time.Time +// during the transformation of the Jira JSON response +func (t *Time) UnmarshalJSON(b []byte) error { + // Ignore null, like in the main JSON package. + if string(b) == "null" { + return nil + } + ti, err := time.Parse("\"2006-01-02T15:04:05.999-0700\"", string(b)) + if err != nil { + return err + } + *t = Time(ti) + return nil +} + +// MarshalJSON will transform the time.Time into a Jira time +// during the creation of a Jira request +func (t Time) MarshalJSON() ([]byte, error) { + return []byte(time.Time(t).Format("\"2006-01-02T15:04:05.000-0700\"")), nil +} + +// UnmarshalJSON will transform the Jira date into a time.Time +// during the transformation of the Jira JSON response +func (t *Date) UnmarshalJSON(b []byte) error { + // Ignore null, like in the main JSON package. + if string(b) == "null" { + return nil + } + ti, err := time.Parse("\"2006-01-02\"", string(b)) + if err != nil { + return err + } + *t = Date(ti) + return nil +} + +// MarshalJSON will transform the Date object into a short +// date string as Jira expects during the creation of a +// Jira request +func (t Date) MarshalJSON() ([]byte, error) { + time := time.Time(t) + return []byte(time.Format("\"2006-01-02\"")), nil +} + +// Worklog represents the work log of a Jira issue. +// One Worklog contains zero or n WorklogRecords +// Jira Wiki: https://confluence.atlassian.com/jira/logging-work-on-an-issue-185729605.html +type Worklog struct { + StartAt int `json:"startAt" structs:"startAt"` + MaxResults int `json:"maxResults" structs:"maxResults"` + Total int `json:"total" structs:"total"` + Worklogs []WorklogRecord `json:"worklogs" structs:"worklogs"` +} + +// WorklogRecord represents one entry of a Worklog +type WorklogRecord struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + Author *User `json:"author,omitempty" structs:"author,omitempty"` + UpdateAuthor *User `json:"updateAuthor,omitempty" structs:"updateAuthor,omitempty"` + Comment string `json:"comment,omitempty" structs:"comment,omitempty"` + Created *Time `json:"created,omitempty" structs:"created,omitempty"` + Updated *Time `json:"updated,omitempty" structs:"updated,omitempty"` + Started *Time `json:"started,omitempty" structs:"started,omitempty"` + TimeSpent string `json:"timeSpent,omitempty" structs:"timeSpent,omitempty"` + TimeSpentSeconds int `json:"timeSpentSeconds,omitempty" structs:"timeSpentSeconds,omitempty"` + ID string `json:"id,omitempty" structs:"id,omitempty"` + IssueID string `json:"issueId,omitempty" structs:"issueId,omitempty"` + Properties []EntityProperty `json:"properties,omitempty"` +} + +type EntityProperty struct { + Key string `json:"key"` + Value interface{} `json:"value"` +} + +// TimeTracking represents the timetracking fields of a Jira issue. +type TimeTracking struct { + OriginalEstimate string `json:"originalEstimate,omitempty" structs:"originalEstimate,omitempty"` + RemainingEstimate string `json:"remainingEstimate,omitempty" structs:"remainingEstimate,omitempty"` + TimeSpent string `json:"timeSpent,omitempty" structs:"timeSpent,omitempty"` + OriginalEstimateSeconds int `json:"originalEstimateSeconds,omitempty" structs:"originalEstimateSeconds,omitempty"` + RemainingEstimateSeconds int `json:"remainingEstimateSeconds,omitempty" structs:"remainingEstimateSeconds,omitempty"` + TimeSpentSeconds int `json:"timeSpentSeconds,omitempty" structs:"timeSpentSeconds,omitempty"` +} + +// Subtasks represents all issues of a parent issue. +type Subtasks struct { + ID string `json:"id" structs:"id"` + Key string `json:"key" structs:"key"` + Self string `json:"self" structs:"self"` + Fields IssueFields `json:"fields" structs:"fields"` +} + +// IssueLink represents a link between two issues in Jira. +type IssueLink struct { + ID string `json:"id,omitempty" structs:"id,omitempty"` + Self string `json:"self,omitempty" structs:"self,omitempty"` + Type IssueLinkType `json:"type" structs:"type"` + OutwardIssue *Issue `json:"outwardIssue" structs:"outwardIssue"` + InwardIssue *Issue `json:"inwardIssue" structs:"inwardIssue"` + Comment *Comment `json:"comment,omitempty" structs:"comment,omitempty"` +} + +// IssueLinkType represents a type of a link between to issues in Jira. +// Typical issue link types are "Related to", "Duplicate", "Is blocked by", etc. +type IssueLinkType struct { + ID string `json:"id,omitempty" structs:"id,omitempty"` + Self string `json:"self,omitempty" structs:"self,omitempty"` + Name string `json:"name" structs:"name"` + Inward string `json:"inward" structs:"inward"` + Outward string `json:"outward" structs:"outward"` +} + +// Comments represents a list of Comment. +type Comments struct { + Comments []*Comment `json:"comments,omitempty" structs:"comments,omitempty"` +} + +// Comment represents a comment by a person to an issue in Jira. +type Comment struct { + ID string `json:"id,omitempty" structs:"id,omitempty"` + Self string `json:"self,omitempty" structs:"self,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + Author User `json:"author,omitempty" structs:"author,omitempty"` + Body string `json:"body,omitempty" structs:"body,omitempty"` + UpdateAuthor User `json:"updateAuthor,omitempty" structs:"updateAuthor,omitempty"` + Updated string `json:"updated,omitempty" structs:"updated,omitempty"` + Created string `json:"created,omitempty" structs:"created,omitempty"` + Visibility CommentVisibility `json:"visibility,omitempty" structs:"visibility,omitempty"` + + // A list of comment properties. Optional on create and update. + Properties []EntityProperty `json:"properties,omitempty" structs:"properties,omitempty"` +} + +// FixVersion represents a software release in which an issue is fixed. +type FixVersion struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + ID string `json:"id,omitempty" structs:"id,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + Description string `json:"description,omitempty" structs:"description,omitempty"` + Archived *bool `json:"archived,omitempty" structs:"archived,omitempty"` + Released *bool `json:"released,omitempty" structs:"released,omitempty"` + ReleaseDate string `json:"releaseDate,omitempty" structs:"releaseDate,omitempty"` + UserReleaseDate string `json:"userReleaseDate,omitempty" structs:"userReleaseDate,omitempty"` + ProjectID int `json:"projectId,omitempty" structs:"projectId,omitempty"` // Unlike other IDs, this is returned as a number + StartDate string `json:"startDate,omitempty" structs:"startDate,omitempty"` +} + +// AffectsVersion represents a software release which is affected by an issue. +type AffectsVersion Version + +// CommentVisibility represents he visibility of a comment. +// E.g. Type could be "role" and Value "Administrators" +type CommentVisibility struct { + Type string `json:"type,omitempty" structs:"type,omitempty"` + Value string `json:"value,omitempty" structs:"value,omitempty"` +} + +// SearchOptions specifies the optional parameters to various List methods that +// support pagination. +// Pagination is used for the Jira REST APIs to conserve server resources and limit +// response size for resources that return potentially large collection of items. +// A request to a pages API will result in a values array wrapped in a JSON object with some paging metadata +// Default Pagination options +type SearchOptions struct { + // StartAt: The starting index of the returned projects. Base index: 0. + StartAt int `url:"startAt,omitempty"` + // MaxResults: The maximum number of projects to return per page. Default: 50. + MaxResults int `url:"maxResults,omitempty"` + // Expand: Expand specific sections in the returned issues + Expand string `url:"expand,omitempty"` + Fields []string + // ValidateQuery: The validateQuery param offers control over whether to validate and how strictly to treat the validation. Default: strict. + ValidateQuery string `url:"validateQuery,omitempty"` +} + +// searchResult is only a small wrapper around the Search (with JQL) method +// to be able to parse the results +type searchResult struct { + Issues []Issue `json:"issues" structs:"issues"` + StartAt int `json:"startAt" structs:"startAt"` + MaxResults int `json:"maxResults" structs:"maxResults"` + Total int `json:"total" structs:"total"` +} + +// GetQueryOptions specifies the optional parameters for the Get Issue methods +type GetQueryOptions struct { + // Fields is the list of fields to return for the issue. By default, all fields are returned. + Fields string `url:"fields,omitempty"` + Expand string `url:"expand,omitempty"` + // Properties is the list of properties to return for the issue. By default no properties are returned. + Properties string `url:"properties,omitempty"` + // FieldsByKeys if true then fields in issues will be referenced by keys instead of ids + FieldsByKeys bool `url:"fieldsByKeys,omitempty"` + UpdateHistory bool `url:"updateHistory,omitempty"` + ProjectKeys string `url:"projectKeys,omitempty"` +} + +// GetWorklogsQueryOptions specifies the optional parameters for the Get Worklogs method +type GetWorklogsQueryOptions struct { + StartAt int64 `url:"startAt,omitempty"` + MaxResults int32 `url:"maxResults,omitempty"` + StartedAfter int64 `url:"startedAfter,omitempty"` + Expand string `url:"expand,omitempty"` +} + +type AddWorklogQueryOptions struct { + NotifyUsers bool `url:"notifyUsers,omitempty"` + AdjustEstimate string `url:"adjustEstimate,omitempty"` + NewEstimate string `url:"newEstimate,omitempty"` + ReduceBy string `url:"reduceBy,omitempty"` + Expand string `url:"expand,omitempty"` + OverrideEditableFlag bool `url:"overrideEditableFlag,omitempty"` +} + +// CustomFields represents custom fields of Jira +// This can heavily differ between Jira instances +type CustomFields map[string]string + +// RemoteLink represents remote links which linked to issues +type RemoteLink struct { + ID int `json:"id,omitempty" structs:"id,omitempty"` + Self string `json:"self,omitempty" structs:"self,omitempty"` + GlobalID string `json:"globalId,omitempty" structs:"globalId,omitempty"` + Application *RemoteLinkApplication `json:"application,omitempty" structs:"application,omitempty"` + Relationship string `json:"relationship,omitempty" structs:"relationship,omitempty"` + Object *RemoteLinkObject `json:"object,omitempty" structs:"object,omitempty"` +} + +// RemoteLinkApplication represents remote links application +type RemoteLinkApplication struct { + Type string `json:"type,omitempty" structs:"type,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` +} + +// RemoteLinkObject represents remote link object itself +type RemoteLinkObject struct { + URL string `json:"url,omitempty" structs:"url,omitempty"` + Title string `json:"title,omitempty" structs:"title,omitempty"` + Summary string `json:"summary,omitempty" structs:"summary,omitempty"` + Icon *RemoteLinkIcon `json:"icon,omitempty" structs:"icon,omitempty"` + Status *RemoteLinkStatus `json:"status,omitempty" structs:"status,omitempty"` +} + +// RemoteLinkIcon represents icon displayed next to link +type RemoteLinkIcon struct { + Url16x16 string `json:"url16x16,omitempty" structs:"url16x16,omitempty"` + Title string `json:"title,omitempty" structs:"title,omitempty"` + Link string `json:"link,omitempty" structs:"link,omitempty"` +} + +// RemoteLinkStatus if the link is a resolvable object (issue, epic) - the structure represent its status +type RemoteLinkStatus struct { + Resolved bool `json:"resolved,omitempty" structs:"resolved,omitempty"` + Icon *RemoteLinkIcon `json:"icon,omitempty" structs:"icon,omitempty"` +} + +// Get returns a full representation of the issue for the given issue key. +// Jira will attempt to identify the issue by the issueIdOrKey path parameter. +// This can be an issue id, or an issue key. +// If the issue cannot be found via an exact match, Jira will also look for the issue in a case-insensitive way, or by looking to see if the issue was moved. +// +// # The given options will be appended to the query string +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-getIssue +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) Get(ctx context.Context, issueID string, options *GetQueryOptions) (*Issue, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s", issueID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + if options != nil { + q, err := query.Values(options) + if err != nil { + return nil, nil, err + } + req.URL.RawQuery = q.Encode() + } + + issue := new(Issue) + resp, err := s.client.Do(req, issue) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return issue, resp, nil +} + +// DownloadAttachment returns a Response of an attachment for a given attachmentID. +// The attachment is in the Response.Body of the response. +// This is an io.ReadCloser. +// Caller must close resp.Body. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) DownloadAttachment(ctx context.Context, attachmentID string) (*Response, error) { + apiEndpoint := fmt.Sprintf("secure/attachment/%s/", attachmentID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} + +// PostAttachment uploads r (io.Reader) as an attachment to a given issueID +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) PostAttachment(ctx context.Context, issueID string, r io.Reader, attachmentName string) (*[]Attachment, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/attachments", issueID) + + b := new(bytes.Buffer) + writer := multipart.NewWriter(b) + + fw, err := writer.CreateFormFile("file", attachmentName) + if err != nil { + return nil, nil, err + } + + if r != nil { + // Copy the file + if _, err = io.Copy(fw, r); err != nil { + return nil, nil, err + } + } + writer.Close() + + req, err := s.client.NewMultiPartRequest(ctx, http.MethodPost, apiEndpoint, b) + if err != nil { + return nil, nil, err + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + + // PostAttachment response returns a JSON array (as multiple attachments can be posted) + attachment := new([]Attachment) + resp, err := s.client.Do(req, attachment) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return attachment, resp, nil +} + +// DeleteAttachment deletes an attachment of a given attachmentID +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) DeleteAttachment(ctx context.Context, attachmentID string) (*Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/attachment/%s", attachmentID) + + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} + +// DeleteLink deletes a link of a given linkID +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) DeleteLink(ctx context.Context, linkID string) (*Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issueLink/%s", linkID) + + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} + +// GetWorklogs gets all the worklogs for an issue. +// This method is especially important if you need to read all the worklogs, not just the first page. +// +// https://docs.atlassian.com/jira/REST/cloud/#api/2/issue/{issueIdOrKey}/worklog-getIssueWorklog +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetWorklogs(ctx context.Context, issueID string, options ...func(*http.Request) error) (*Worklog, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/worklog", issueID) + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + for _, option := range options { + err = option(req) + if err != nil { + return nil, nil, err + } + } + + v := new(Worklog) + resp, err := s.client.Do(req, v) + return v, resp, err +} + +// Applies query options to http request. +// This helper is meant to be used with all "QueryOptions" structs. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func WithQueryOptions(options interface{}) func(*http.Request) error { + q, err := query.Values(options) + if err != nil { + return func(*http.Request) error { + return err + } + } + + return func(r *http.Request) error { + r.URL.RawQuery = q.Encode() + return nil + } +} + +// Create creates an issue or a sub-task from a JSON representation. +// Creating a sub-task is similar to creating a regular issue, with two important differences: +// The issueType field must correspond to a sub-task issue type and you must provide a parent field in the issue create request containing the id or key of the parent issue. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-createIssues +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) Create(ctx context.Context, issue *Issue) (*Issue, *Response, error) { + apiEndpoint := "rest/api/2/issue" + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, issue) + if err != nil { + return nil, nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + // incase of error return the resp for further inspection + return nil, resp, err + } + defer resp.Body.Close() + + responseIssue := new(Issue) + err = json.NewDecoder(resp.Body).Decode(&responseIssue) + if err != nil { + return nil, resp, err + } + + return responseIssue, resp, nil +} + +// Update updates an issue from a JSON representation, +// while also specifying query params. The issue is found by key. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issues/#api-rest-api-2-issue-issueidorkey-put +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) Update(ctx context.Context, issue *Issue, opts *UpdateQueryOptions) (*Issue, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%v", issue.Key) + url, err := addOptions(apiEndpoint, opts) + if err != nil { + return nil, nil, err + } + req, err := s.client.NewRequest(ctx, http.MethodPut, url, issue) + if err != nil { + return nil, nil, err + } + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + // This is just to follow the rest of the API's convention of returning an issue. + // Returning the same pointer here is pointless, so we return a copy instead. + ret := *issue + return &ret, resp, nil +} + +// UpdateIssue updates an issue from a JSON representation. The issue is found by key. +// +// https://docs.atlassian.com/jira/REST/7.4.0/#api/2/issue-editIssue +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) UpdateIssue(ctx context.Context, jiraID string, data map[string]interface{}) (*Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%v", jiraID) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndpoint, data) + if err != nil { + return nil, err + } + resp, err := s.client.Do(req, nil) + if err != nil { + return resp, err + } + + // This is just to follow the rest of the API's convention of returning an issue. + // Returning the same pointer here is pointless, so we return a copy instead. + return resp, nil +} + +// AddComment adds a new comment to issueID. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-addComment +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) AddComment(ctx context.Context, issueID string, comment *Comment) (*Comment, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/comment", issueID) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, comment) + if err != nil { + return nil, nil, err + } + + responseComment := new(Comment) + resp, err := s.client.Do(req, responseComment) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return responseComment, resp, nil +} + +// UpdateComment updates the body of a comment, identified by comment.ID, on the issueID. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/issue/{issueIdOrKey}/comment-updateComment +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) UpdateComment(ctx context.Context, issueID string, comment *Comment) (*Comment, *Response, error) { + reqBody := struct { + Body string `json:"body"` + }{ + Body: comment.Body, + } + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/comment/%s", issueID, comment.ID) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndpoint, reqBody) + if err != nil { + return nil, nil, err + } + + responseComment := new(Comment) + resp, err := s.client.Do(req, responseComment) + if err != nil { + return nil, resp, err + } + + return responseComment, resp, nil +} + +// DeleteComment Deletes a comment from an issueID. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-api-3-issue-issueIdOrKey-comment-id-delete +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) DeleteComment(ctx context.Context, issueID, commentID string) error { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/comment/%s", issueID, commentID) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) + if err != nil { + return err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return jerr + } + defer resp.Body.Close() + + return nil +} + +// AddWorklogRecord adds a new worklog record to issueID. +// +// https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-issue-issueIdOrKey-worklog-post +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) AddWorklogRecord(ctx context.Context, issueID string, record *WorklogRecord, options ...func(*http.Request) error) (*WorklogRecord, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/worklog", issueID) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, record) + if err != nil { + return nil, nil, err + } + + for _, option := range options { + err = option(req) + if err != nil { + return nil, nil, err + } + } + + responseRecord := new(WorklogRecord) + resp, err := s.client.Do(req, responseRecord) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return responseRecord, resp, nil +} + +// UpdateWorklogRecord updates a worklog record. +// +// https://docs.atlassian.com/software/jira/docs/api/REST/7.1.2/#api/2/issue-updateWorklog +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) UpdateWorklogRecord(ctx context.Context, issueID, worklogID string, record *WorklogRecord, options ...func(*http.Request) error) (*WorklogRecord, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/worklog/%s", issueID, worklogID) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndpoint, record) + if err != nil { + return nil, nil, err + } + + for _, option := range options { + err = option(req) + if err != nil { + return nil, nil, err + } + } + + responseRecord := new(WorklogRecord) + resp, err := s.client.Do(req, responseRecord) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return responseRecord, resp, nil +} + +// AddLink adds a link between two issues. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issueLink +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) AddLink(ctx context.Context, issueLink *IssueLink) (*Response, error) { + apiEndpoint := "rest/api/2/issueLink" + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, issueLink) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + err = NewJiraError(resp, err) + } + + return resp, err +} + +// Search will search for tickets according to the jql +// +// Jira API docs: https://developer.atlassian.com/jiradev/jira-apis/jira-rest-apis/jira-rest-api-tutorials/jira-rest-api-example-query-issues +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) Search(ctx context.Context, jql string, options *SearchOptions) ([]Issue, *Response, error) { + u := url.URL{ + Path: "rest/api/2/search", + } + uv := url.Values{} + if jql != "" { + uv.Add("jql", jql) + } + + if options != nil { + if options.StartAt != 0 { + uv.Add("startAt", strconv.Itoa(options.StartAt)) + } + if options.MaxResults != 0 { + uv.Add("maxResults", strconv.Itoa(options.MaxResults)) + } + if options.Expand != "" { + uv.Add("expand", options.Expand) + } + if strings.Join(options.Fields, ",") != "" { + uv.Add("fields", strings.Join(options.Fields, ",")) + } + if options.ValidateQuery != "" { + uv.Add("validateQuery", options.ValidateQuery) + } + } + + u.RawQuery = uv.Encode() + + req, err := s.client.NewRequest(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return []Issue{}, nil, err + } + + v := new(searchResult) + resp, err := s.client.Do(req, v) + if err != nil { + err = NewJiraError(resp, err) + } + return v.Issues, resp, err +} + +// SearchPages will get issues from all pages in a search +// +// Jira API docs: https://developer.atlassian.com/jiradev/jira-apis/jira-rest-apis/jira-rest-api-tutorials/jira-rest-api-example-query-issues +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) SearchPages(ctx context.Context, jql string, options *SearchOptions, f func(Issue) error) error { + if options == nil { + options = &SearchOptions{ + StartAt: 0, + MaxResults: 50, + } + } + + if options.MaxResults == 0 { + options.MaxResults = 50 + } + + issues, resp, err := s.Search(ctx, jql, options) + if err != nil { + return err + } + + if len(issues) == 0 { + return nil + } + + for { + for _, issue := range issues { + err = f(issue) + if err != nil { + return err + } + } + + if resp.StartAt+resp.MaxResults >= resp.Total { + return nil + } + + options.StartAt += resp.MaxResults + issues, resp, err = s.Search(ctx, jql, options) + if err != nil { + return err + } + } +} + +// GetCustomFields returns a map of customfield_* keys with string values +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetCustomFields(ctx context.Context, issueID string) (CustomFields, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s", issueID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + issue := new(map[string]interface{}) + resp, err := s.client.Do(req, issue) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + m := *issue + f := m["fields"] + cf := make(CustomFields) + if f == nil { + return cf, resp, nil + } + + if rec, ok := f.(map[string]interface{}); ok { + for key, val := range rec { + if strings.Contains(key, "customfield") { + if valMap, ok := val.(map[string]interface{}); ok { + if v, ok := valMap["value"]; ok { + val = v + } + } + cf[key] = fmt.Sprint(val) + } + } + } + return cf, resp, nil +} + +// GetTransitions gets a list of the transitions possible for this issue by the current user, +// along with fields that are required and their types. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-getTransitions +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetTransitions(ctx context.Context, id string) ([]Transition, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/transitions?expand=transitions.fields", id) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + result := new(transitionResult) + resp, err := s.client.Do(req, result) + if err != nil { + err = NewJiraError(resp, err) + } + return result.Transitions, resp, err +} + +// DoTransition performs a transition on an issue. +// When performing the transition you can update or set other issue fields. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-doTransition +// Caller must close Response.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) DoTransition(ctx context.Context, ticketID, transitionID string) (*Response, error) { + payload := CreateTransitionPayload{ + Transition: TransitionPayload{ + ID: transitionID, + }, + } + return s.DoTransitionWithPayload(ctx, ticketID, payload) +} + +// DoTransitionWithPayload performs a transition on an issue using any payload. +// When performing the transition you can update or set other issue fields. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-doTransition +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) DoTransitionWithPayload(ctx context.Context, ticketID, payload interface{}) (*Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/transitions", ticketID) + + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, payload) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + err = NewJiraError(resp, err) + } + + return resp, err +} + +// InitIssueWithMetaAndFields returns Issue with with values from fieldsConfig properly set. +// - metaProject should contain metaInformation about the project where the issue should be created. +// - metaIssuetype is the MetaInformation about the Issuetype that needs to be created. +// - fieldsConfig is a key->value pair where key represents the name of the field as seen in the UI +// And value is the string value for that particular key. +// +// Note: This method doesn't verify that the fieldsConfig is complete with mandatory fields. The fieldsConfig is +// +// supposed to be already verified with MetaIssueType.CheckCompleteAndAvailable. It will however return +// error if the key is not found. +// All values will be packed into Unknowns. This is much convenient. If the struct fields needs to be +// configured as well, marshalling and unmarshalling will set the proper fields. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func InitIssueWithMetaAndFields(metaProject *MetaProject, metaIssuetype *MetaIssueType, fieldsConfig map[string]string) (*Issue, error) { + issue := new(Issue) + issueFields := new(IssueFields) + issueFields.Unknowns = tcontainer.NewMarshalMap() + + // map the field names the User presented to jira's internal key + allFields, _ := metaIssuetype.GetAllFields() + for key, value := range fieldsConfig { + jiraKey, found := allFields[key] + if !found { + return nil, fmt.Errorf("key %s is not found in the list of fields", key) + } + + valueType, err := metaIssuetype.Fields.String(jiraKey + "/schema/type") + if err != nil { + return nil, err + } + switch valueType { + case "array": + elemType, err := metaIssuetype.Fields.String(jiraKey + "/schema/items") + if err != nil { + return nil, err + } + switch elemType { + case "component": + issueFields.Unknowns[jiraKey] = []Component{{Name: value}} + case "option": + issueFields.Unknowns[jiraKey] = []map[string]string{{"value": value}} + default: + issueFields.Unknowns[jiraKey] = []string{value} + } + case "string": + issueFields.Unknowns[jiraKey] = value + case "date": + issueFields.Unknowns[jiraKey] = value + case "datetime": + issueFields.Unknowns[jiraKey] = value + case "any": + // Treat any as string + issueFields.Unknowns[jiraKey] = value + case "project": + issueFields.Unknowns[jiraKey] = Project{ + Name: metaProject.Name, + ID: metaProject.Id, + } + case "priority": + issueFields.Unknowns[jiraKey] = Priority{Name: value} + case "user": + issueFields.Unknowns[jiraKey] = User{ + Name: value, + } + case "issuetype": + issueFields.Unknowns[jiraKey] = IssueType{ + Name: value, + } + case "option": + issueFields.Unknowns[jiraKey] = Option{ + Value: value, + } + default: + return nil, fmt.Errorf("unknown issue type encountered: %s for %s", valueType, key) + } + } + + issue.Fields = issueFields + + return issue, nil +} + +// Delete will delete a specified issue. +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) Delete(ctx context.Context, issueID string) (*Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s", issueID) + + // to enable deletion of subtasks; without this, the request will fail if the issue has subtasks + deletePayload := make(map[string]interface{}) + deletePayload["deleteSubtasks"] = "true" + content, _ := json.Marshal(deletePayload) + + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, content) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + return resp, err +} + +// GetWatchers wil return all the users watching/observing the given issue +// +// Jira API docs: https://docs.atlassian.com/software/jira/docs/api/REST/latest/#api/2/issue-getIssueWatchers +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetWatchers(ctx context.Context, issueID string) (*[]User, *Response, error) { + watchesAPIEndpoint := fmt.Sprintf("rest/api/2/issue/%s/watchers", issueID) + + req, err := s.client.NewRequest(ctx, http.MethodGet, watchesAPIEndpoint, nil) + if err != nil { + return nil, nil, err + } + + watches := new(Watches) + resp, err := s.client.Do(req, watches) + if err != nil { + return nil, nil, NewJiraError(resp, err) + } + + result := []User{} + for _, watcher := range watches.Watchers { + var user *User + if watcher.AccountID != "" { + user, resp, err = s.client.User.GetByAccountID(context.Background(), watcher.AccountID) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + } + result = append(result, *user) + } + + return &result, resp, nil +} + +// AddWatcher adds watcher to the given issue +// +// Jira API docs: https://docs.atlassian.com/software/jira/docs/api/REST/latest/#api/2/issue-addWatcher +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) AddWatcher(ctx context.Context, issueID string, userName string) (*Response, error) { + apiEndPoint := fmt.Sprintf("rest/api/2/issue/%s/watchers", issueID) + + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndPoint, userName) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + err = NewJiraError(resp, err) + } + + return resp, err +} + +// RemoveWatcher removes given user from given issue +// +// Jira API docs: https://docs.atlassian.com/software/jira/docs/api/REST/latest/#api/2/issue-removeWatcher +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) RemoveWatcher(ctx context.Context, issueID string, userName string) (*Response, error) { + apiEndPoint := fmt.Sprintf("rest/api/2/issue/%s/watchers", issueID) + + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndPoint, userName) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + err = NewJiraError(resp, err) + } + + return resp, err +} + +// UpdateAssignee updates the user assigned to work on the given issue +// +// Jira API docs: https://docs.atlassian.com/software/jira/docs/api/REST/7.10.2/#api/2/issue-assign +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) UpdateAssignee(ctx context.Context, issueID string, assignee *User) (*Response, error) { + apiEndPoint := fmt.Sprintf("rest/api/2/issue/%s/assignee", issueID) + + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndPoint, assignee) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + err = NewJiraError(resp, err) + } + + return resp, err +} + +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (c ChangelogHistory) CreatedTime() (time.Time, error) { + var t time.Time + // Ignore null + if string(c.Created) == "null" { + return t, nil + } + t, err := time.Parse("2006-01-02T15:04:05.999-0700", c.Created) + return t, err +} + +// GetRemoteLinks gets remote issue links on the issue. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-getRemoteIssueLinks +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetRemoteLinks(ctx context.Context, id string) (*[]RemoteLink, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/remotelink", id) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + result := new([]RemoteLink) + resp, err := s.client.Do(req, result) + if err != nil { + err = NewJiraError(resp, err) + } + return result, resp, err +} + +// AddRemoteLink adds a remote link to issueID. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issue-issueIdOrKey-remotelink-post +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) AddRemoteLink(ctx context.Context, issueID string, remotelink *RemoteLink) (*RemoteLink, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/remotelink", issueID) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, remotelink) + if err != nil { + return nil, nil, err + } + + responseRemotelink := new(RemoteLink) + resp, err := s.client.Do(req, responseRemotelink) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return responseRemotelink, resp, nil +} + +// UpdateRemoteLink updates a remote issue link by linkID. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issue-remote-links/#api-rest-api-2-issue-issueidorkey-remotelink-linkid-put +// Caller must close Response.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) UpdateRemoteLink(ctx context.Context, issueID string, linkID int, remotelink *RemoteLink) (*Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/remotelink/%d", issueID, linkID) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndpoint, remotelink) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} diff --git a/onpremise/issue_test.go b/onpremise/issue_test.go new file mode 100644 index 00000000..36b6aa31 --- /dev/null +++ b/onpremise/issue_test.go @@ -0,0 +1,1933 @@ +package onpremise + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/trivago/tgo/tcontainer" +) + +func TestIssueService_Get_Success(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issue/10002") + + fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) + }) + + issue, _, err := testClient.Issue.Get(context.Background(), "10002", nil) + if issue == nil { + t.Error("Expected issue. Issue is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_Get_WithQuerySuccess(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issue/10002?expand=foo") + + fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) + }) + + opt := &GetQueryOptions{ + Expand: "foo", + } + issue, _, err := testClient.Issue.Get(context.Background(), "10002", opt) + if issue == nil { + t.Error("Expected issue. Issue is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_Create(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/issue") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) + }) + + i := &Issue{ + Fields: &IssueFields{ + Description: "example bug report", + }, + } + issue, _, err := testClient.Issue.Create(context.Background(), i) + if issue == nil { + t.Error("Expected issue. Issue is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_CreateThenGet(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/issue") + + w.WriteHeader(http.StatusCreated) + io.Copy(w, r.Body) + }) + + i := &Issue{ + Fields: &IssueFields{ + Description: "example bug report", + Created: Time(time.Now()), + }, + } + issue, _, err := testClient.Issue.Create(context.Background(), i) + if issue == nil { + t.Error("Expected issue. Issue is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } + + testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issue/10002") + + bytes, err := json.Marshal(issue) + if err != nil { + t.Errorf("Error marshaling issue: %s", err) + } + _, err = w.Write(bytes) + if err != nil { + t.Errorf("Error writing response: %s", err) + } + }) + + issue2, _, err := testClient.Issue.Get(context.Background(), "10002", nil) + if issue2 == nil { + t.Error("Expected issue. Issue is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_Update(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/PROJ-9001", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPut) + testRequestURL(t, r, "/rest/api/2/issue/PROJ-9001") + + w.WriteHeader(http.StatusNoContent) + }) + + i := &Issue{ + Key: "PROJ-9001", + Fields: &IssueFields{ + Description: "example bug report", + }, + } + issue, _, err := testClient.Issue.Update(context.Background(), i, nil) + if issue == nil { + t.Error("Expected issue. Issue is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_UpdateIssue(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/PROJ-9001", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPut) + testRequestURL(t, r, "/rest/api/2/issue/PROJ-9001") + + w.WriteHeader(http.StatusNoContent) + }) + jID := "PROJ-9001" + i := make(map[string]interface{}) + fields := make(map[string]interface{}) + i["fields"] = fields + resp, err := testClient.Issue.UpdateIssue(context.Background(), jID, i) + if resp == nil { + t.Error("Expected resp. resp is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } + +} + +func TestIssueService_AddComment(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10000/comment", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/issue/10000/comment") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}`) + }) + + c := &Comment{ + Body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.", + Visibility: CommentVisibility{ + Type: "role", + Value: "Administrators", + }, + } + comment, _, err := testClient.Issue.AddComment(context.Background(), "10000", c) + if comment == nil { + t.Error("Expected Comment. Comment is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_UpdateComment(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10000/comment/10001", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPut) + testRequestURL(t, r, "/rest/api/2/issue/10000/comment/10001") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10001","id":"10001","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}`) + }) + + c := &Comment{ + ID: "10001", + Body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.", + Visibility: CommentVisibility{ + Type: "role", + Value: "Administrators", + }, + } + comment, _, err := testClient.Issue.UpdateComment(context.Background(), "10000", c) + if comment == nil { + t.Error("Expected Comment. Comment is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_DeleteComment(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10000/comment/10001", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/api/2/issue/10000/comment/10001") + + w.WriteHeader(http.StatusNoContent) + fmt.Fprint(w, `{}`) + }) + + err := testClient.Issue.DeleteComment(context.Background(), "10000", "10001") + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_AddWorklogRecord(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10000/worklog", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/issue/10000/worklog") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2018-02-14T22:14:46.003+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2018-02-14T22:14:46.003+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}`) + }) + r := &WorklogRecord{ + TimeSpent: "1h", + } + record, _, err := testClient.Issue.AddWorklogRecord(context.Background(), "10000", r) + if record == nil { + t.Error("Expected Record. Record is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_UpdateWorklogRecord(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10000/worklog/1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPut) + testRequestURL(t, r, "/rest/api/2/issue/10000/worklog/1") + + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/issue/10000/worklog/1","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2018-02-14T22:14:46.003+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2018-02-14T22:14:46.003+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}`) + }) + r := &WorklogRecord{ + TimeSpent: "1h", + } + record, _, err := testClient.Issue.UpdateWorklogRecord(context.Background(), "10000", "1", r) + if record == nil { + t.Error("Expected Record. Record is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_AddLink(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issueLink", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/issueLink") + + w.WriteHeader(http.StatusOK) + }) + + il := &IssueLink{ + Type: IssueLinkType{ + Name: "Duplicate", + }, + InwardIssue: &Issue{ + Key: "HSP-1", + }, + OutwardIssue: &Issue{ + Key: "MKY-1", + }, + Comment: &Comment{ + Body: "Linked related issue!", + Visibility: CommentVisibility{ + Type: "group", + Value: "jira-software-users", + }, + }, + } + resp, err := testClient.Issue.AddLink(context.Background(), il) + if err != nil { + t.Errorf("Error given: %s", err) + } + if resp == nil { + t.Error("Expected response. Response is nil") + return + } + if resp.StatusCode != 200 { + t.Errorf("Expected Status code 200. Given %d", resp.StatusCode) + } +} + +func TestIssueService_Get_Fields(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issue/10002") + + fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"labels":["test"],"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"epic": {"id": 19415,"key": "EPIC-77","self": "https://example.atlassian.net/rest/agile/1.0/epic/19415","name": "Epic Name","summary": "Do it","color": {"key": "color_11"},"done": false},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) + }) + + issue, _, err := testClient.Issue.Get(context.Background(), "10002", nil) + if err != nil { + t.Errorf("Error given: %s", err) + } + if issue == nil { + t.Error("Expected issue. Issue is nil") + return + } + if !reflect.DeepEqual(issue.Fields.Labels, []string{"test"}) { + t.Error("Expected labels for the returned issue") + } + + if len(issue.Fields.Comments.Comments) != 1 { + t.Errorf("Expected one comment, %v found", len(issue.Fields.Comments.Comments)) + } + if issue.Fields.Epic == nil { + t.Error("Epic expected but not found") + } +} + +func TestIssueService_Get_RenderedFields(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issue/10002") + + fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"labels":["test"],"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"epic": {"id": 19415,"key": "EPIC-77","self": "https://example.atlassian.net/rest/agile/1.0/epic/19415","name": "Epic Name","summary": "Do it","color": {"key": "color_11"},"done": false},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{},"renderedFields":{"resolutiondate":"In 1 week","updated":"2 hours ago","comment":{"comments":[{"body":"This is HTML"}]}}}`) + }) + + issue, _, err := testClient.Issue.Get(context.Background(), "10002", nil) + if err != nil { + t.Errorf("Error given: %s", err) + } + if issue == nil { + t.Error("Expected issue. Issue is nil") + return + } + if issue.RenderedFields.Updated != "2 hours ago" { + t.Error("Expected updated to equla '2 hours ago' for rendered field") + } + + if len(issue.RenderedFields.Comments.Comments) != 1 { + t.Errorf("Expected one comment, %v found", len(issue.RenderedFields.Comments.Comments)) + } + comment := issue.RenderedFields.Comments.Comments[0] + if comment.Body != "This is HTML" { + t.Errorf("Wrong comment body returned in RenderedField. Got %s", comment.Body) + } +} + +func TestIssueService_DownloadAttachment(t *testing.T) { + var testAttachment = "Here is an attachment" + + setup() + defer teardown() + testMux.HandleFunc("/secure/attachment/", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/secure/attachment/10000/") + + w.WriteHeader(http.StatusOK) + w.Write([]byte(testAttachment)) + }) + + resp, err := testClient.Issue.DownloadAttachment(context.Background(), "10000") + if err != nil { + t.Errorf("Error given: %s", err) + } + if resp == nil { + t.Error("Expected response. Response is nil") + return + } + defer resp.Body.Close() + + attachment, err := io.ReadAll(resp.Body) + if err != nil { + t.Error("Expected attachment text", err) + } + if string(attachment) != testAttachment { + t.Errorf("Expecting an attachment: %s", string(attachment)) + } + + if resp.StatusCode != 200 { + t.Errorf("Expected Status code 200. Given %d", resp.StatusCode) + } +} + +func TestIssueService_DownloadAttachment_BadStatus(t *testing.T) { + + setup() + defer teardown() + testMux.HandleFunc("/secure/attachment/", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/secure/attachment/10000/") + + w.WriteHeader(http.StatusForbidden) + }) + + resp, err := testClient.Issue.DownloadAttachment(context.Background(), "10000") + if resp == nil { + t.Error("Expected response. Response is nil") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Errorf("Expected Status code %d. Given %d", http.StatusForbidden, resp.StatusCode) + } + if err == nil { + t.Errorf("Error expected") + } +} + +func TestIssueService_PostAttachment(t *testing.T) { + var testAttachment = "Here is an attachment" + + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10000/attachments", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/issue/10000/attachments") + status := http.StatusOK + + file, _, err := r.FormFile("file") + if err != nil { + status = http.StatusNotAcceptable + } + defer file.Close() + + if file == nil { + status = http.StatusNoContent + } else { + // Read the file into memory + data, err := io.ReadAll(file) + if err != nil { + status = http.StatusInternalServerError + } + if string(data) != testAttachment { + status = http.StatusNotAcceptable + } + } + w.WriteHeader(status) + fmt.Fprint(w, `[{"self":"http://jira/jira/rest/api/2/attachment/228924","id":"228924","filename":"example.jpg","author":{"self":"http://jira/jira/rest/api/2/user?username=test","name":"test","emailAddress":"test@test.com","avatarUrls":{"16x16":"http://jira/jira/secure/useravatar?size=small&avatarId=10082","48x48":"http://jira/jira/secure/useravatar?avatarId=10082"},"displayName":"Tester","active":true},"created":"2016-05-24T00:25:17.000-0700","size":32280,"mimeType":"image/jpeg","content":"http://jira/jira/secure/attachment/228924/example.jpg","thumbnail":"http://jira/jira/secure/thumbnail/228924/_thumb_228924.png"}]`) + }) + + reader := strings.NewReader(testAttachment) + + issue, resp, err := testClient.Issue.PostAttachment(context.Background(), "10000", reader, "attachment") + + if issue == nil { + t.Error("Expected response. Response is nil") + } + + if resp.StatusCode != 200 { + t.Errorf("Expected Status code 200. Given %d", resp.StatusCode) + } + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_PostAttachment_NoResponse(t *testing.T) { + var testAttachment = "Here is an attachment" + + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10000/attachments", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/issue/10000/attachments") + w.WriteHeader(http.StatusOK) + }) + reader := strings.NewReader(testAttachment) + + _, _, err := testClient.Issue.PostAttachment(context.Background(), "10000", reader, "attachment") + + if err == nil { + t.Errorf("Error expected: %s", err) + } +} + +func TestIssueService_PostAttachment_NoFilename(t *testing.T) { + var testAttachment = "Here is an attachment" + + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10000/attachments", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/issue/10000/attachments") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `[{"self":"http://jira/jira/rest/api/2/attachment/228924","id":"228924","filename":"example.jpg","author":{"self":"http://jira/jira/rest/api/2/user?username=test","name":"test","emailAddress":"test@test.com","avatarUrls":{"16x16":"http://jira/jira/secure/useravatar?size=small&avatarId=10082","48x48":"http://jira/jira/secure/useravatar?avatarId=10082"},"displayName":"Tester","active":true},"created":"2016-05-24T00:25:17.000-0700","size":32280,"mimeType":"image/jpeg","content":"http://jira/jira/secure/attachment/228924/example.jpg","thumbnail":"http://jira/jira/secure/thumbnail/228924/_thumb_228924.png"}]`) + }) + reader := strings.NewReader(testAttachment) + + _, _, err := testClient.Issue.PostAttachment(context.Background(), "10000", reader, "") + + if err != nil { + t.Errorf("Error expected: %s", err) + } +} + +func TestIssueService_PostAttachment_NoAttachment(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10000/attachments", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/issue/10000/attachments") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `[{"self":"http://jira/jira/rest/api/2/attachment/228924","id":"228924","filename":"example.jpg","author":{"self":"http://jira/jira/rest/api/2/user?username=test","name":"test","emailAddress":"test@test.com","avatarUrls":{"16x16":"http://jira/jira/secure/useravatar?size=small&avatarId=10082","48x48":"http://jira/jira/secure/useravatar?avatarId=10082"},"displayName":"Tester","active":true},"created":"2016-05-24T00:25:17.000-0700","size":32280,"mimeType":"image/jpeg","content":"http://jira/jira/secure/attachment/228924/example.jpg","thumbnail":"http://jira/jira/secure/thumbnail/228924/_thumb_228924.png"}]`) + }) + + _, _, err := testClient.Issue.PostAttachment(context.Background(), "10000", nil, "attachment") + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_DeleteAttachment(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/attachment/10054", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/api/2/attachment/10054") + + w.WriteHeader(http.StatusNoContent) + fmt.Fprint(w, `{}`) + }) + + resp, err := testClient.Issue.DeleteAttachment(context.Background(), "10054") + if resp.StatusCode != 204 { + t.Error("Expected attachment not deleted.") + if resp.StatusCode == 403 { + t.Error("User not permitted to delete attachment") + } + if resp.StatusCode == 404 { + t.Error("Attachment not found") + } + } + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_DeleteLink(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issueLink/10054", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/api/2/issueLink/10054") + + w.WriteHeader(http.StatusNoContent) + fmt.Fprint(w, `{}`) + }) + + resp, err := testClient.Issue.DeleteLink(context.Background(), "10054") + if resp.StatusCode != 204 { + t.Error("Expected link not deleted.") + if resp.StatusCode == 403 { + t.Error("User not permitted to delete link") + } + if resp.StatusCode == 404 { + t.Error("Link not found") + } + } + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_Search(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/search?expand=foo&jql=type+%3D+Bug+and+Status+NOT+IN+%28Resolved%29&maxResults=40&startAt=1") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"expand": "schema,names","startAt": 1,"maxResults": 40,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) + }) + + opt := &SearchOptions{StartAt: 1, MaxResults: 40, Expand: "foo"} + _, resp, err := testClient.Issue.Search(context.Background(), "type = Bug and Status NOT IN (Resolved)", opt) + + if resp == nil { + t.Errorf("Response given: %+v", resp) + } + if err != nil { + t.Errorf("Error given: %s", err) + } + + if resp.StartAt != 1 { + t.Errorf("StartAt should populate with 1, %v given", resp.StartAt) + } + if resp.MaxResults != 40 { + t.Errorf("MaxResults should populate with 40, %v given", resp.MaxResults) + } + if resp.Total != 6 { + t.Errorf("Total should populate with 6, %v given", resp.Total) + } +} + +func TestIssueService_SearchEmptyJQL(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/search?expand=foo&maxResults=40&startAt=1") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"expand": "schema,names","startAt": 1,"maxResults": 40,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) + }) + + opt := &SearchOptions{StartAt: 1, MaxResults: 40, Expand: "foo"} + _, resp, err := testClient.Issue.Search(context.Background(), "", opt) + + if resp == nil { + t.Errorf("Response given: %+v", resp) + } + if err != nil { + t.Errorf("Error given: %s", err) + } + + if resp.StartAt != 1 { + t.Errorf("StartAt should populate with 1, %v given", resp.StartAt) + } + if resp.MaxResults != 40 { + t.Errorf("StartAt should populate with 40, %v given", resp.MaxResults) + } + if resp.Total != 6 { + t.Errorf("StartAt should populate with 6, %v given", resp.Total) + } +} + +func TestIssueService_Search_WithoutPaging(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/search?jql=something") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"expand": "schema,names","startAt": 0,"maxResults": 50,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) + }) + _, resp, err := testClient.Issue.Search(context.Background(), "something", nil) + + if resp == nil { + t.Errorf("Response given: %+v", resp) + } + if err != nil { + t.Errorf("Error given: %s", err) + } + + if resp.StartAt != 0 { + t.Errorf("StartAt should populate with 0, %v given", resp.StartAt) + } + if resp.MaxResults != 50 { + t.Errorf("StartAt should populate with 50, %v given", resp.MaxResults) + } + if resp.Total != 6 { + t.Errorf("StartAt should populate with 6, %v given", resp.Total) + } +} + +func TestIssueService_SearchPages(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + if r.URL.String() == "/rest/api/2/search?expand=foo&jql=something&maxResults=2&startAt=1&validateQuery=warn" { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"expand": "schema,names","startAt": 1,"maxResults": 2,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) + return + } else if r.URL.String() == "/rest/api/2/search?expand=foo&jql=something&maxResults=2&startAt=3&validateQuery=warn" { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"expand": "schema,names","startAt": 3,"maxResults": 2,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) + return + } else if r.URL.String() == "/rest/api/2/search?expand=foo&jql=something&maxResults=2&startAt=5&validateQuery=warn" { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"expand": "schema,names","startAt": 5,"maxResults": 2,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}}]}`) + return + } + + t.Errorf("Unexpected URL: %v", r.URL) + }) + + opt := &SearchOptions{StartAt: 1, MaxResults: 2, Expand: "foo", ValidateQuery: "warn"} + issues := make([]Issue, 0) + err := testClient.Issue.SearchPages(context.Background(), "something", opt, func(issue Issue) error { + issues = append(issues, issue) + return nil + }) + + if err != nil { + t.Errorf("Error given: %s", err) + } + + if len(issues) != 5 { + t.Errorf("Expected 5 issues, %v given", len(issues)) + } +} + +func TestIssueService_SearchPages_EmptyResult(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + if r.URL.String() == "/rest/api/2/search?expand=foo&jql=something&maxResults=50&startAt=1&validateQuery=warn" { + w.WriteHeader(http.StatusOK) + // This is what Jira outputs when the &maxResult= issue occurs. It used to cause SearchPages to go into an endless loop. + fmt.Fprint(w, `{"expand": "schema,names","startAt": 0,"maxResults": 0,"total": 6,"issues": []}`) + return + } + + t.Errorf("Unexpected URL: %v", r.URL) + }) + + opt := &SearchOptions{StartAt: 1, MaxResults: 50, Expand: "foo", ValidateQuery: "warn"} + issues := make([]Issue, 0) + err := testClient.Issue.SearchPages(context.Background(), "something", opt, func(issue Issue) error { + issues = append(issues, issue) + return nil + }) + + if err != nil { + t.Errorf("Error given: %s", err) + } + +} + +func TestIssueService_GetCustomFields(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issue/10002") + fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"customfield_123":"test","watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) + }) + + issue, _, err := testClient.Issue.GetCustomFields(context.Background(), "10002") + if err != nil { + t.Errorf("Error given: %s", err) + } + if issue == nil { + t.Error("Expected Customfields") + } + cf := issue["customfield_123"] + if cf != "test" { + t.Error("Expected \"test\" for custom field") + } +} + +func TestIssueService_GetComplexCustomFields(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issue/10002") + fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"customfield_123":{"self":"http://www.example.com/jira/rest/api/2/customFieldOption/123","value":"test","id":"123"},"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) + }) + + issue, _, err := testClient.Issue.GetCustomFields(context.Background(), "10002") + if err != nil { + t.Errorf("Error given: %s", err) + } + if issue == nil { + t.Error("Expected Customfields") + } + cf := issue["customfield_123"] + if cf != "test" { + t.Error("Expected \"test\" for custom field") + } +} + +func TestIssueService_GetTransitions(t *testing.T) { + setup() + defer teardown() + + testAPIEndpoint := "/rest/api/2/issue/123/transitions" + + raw, err := os.ReadFile("../testing/mock-data/transitions.json") + if err != nil { + t.Error(err.Error()) + } + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + transitions, _, err := testClient.Issue.GetTransitions(context.Background(), "123") + + if err != nil { + t.Errorf("Got error: %v", err) + } + + if transitions == nil { + t.Error("Expected transition list. Got nil.") + } + + if len(transitions) != 2 { + t.Errorf("Expected 2 transitions. Got %d", len(transitions)) + } + + if transitions[0].Fields["summary"].Required != false { + t.Errorf("First transition summary field should not be required") + } +} + +func TestIssueService_DoTransition(t *testing.T) { + setup() + defer teardown() + + testAPIEndpoint := "/rest/api/2/issue/123/transitions" + + transitionID := "22" + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, testAPIEndpoint) + + decoder := json.NewDecoder(r.Body) + var payload CreateTransitionPayload + err := decoder.Decode(&payload) + if err != nil { + t.Errorf("Got error: %v", err) + } + + if payload.Transition.ID != transitionID { + t.Errorf("Expected %s to be in payload, got %s instead", transitionID, payload.Transition.ID) + } + }) + _, err := testClient.Issue.DoTransition(context.Background(), "123", transitionID) + + if err != nil { + t.Errorf("Got error: %v", err) + } +} + +func TestIssueService_DoTransitionWithPayload(t *testing.T) { + setup() + defer teardown() + + testAPIEndpoint := "/rest/api/2/issue/123/transitions" + + transitionID := "22" + + customPayload := map[string]interface{}{ + "update": map[string]interface{}{ + "comment": []map[string]interface{}{ + { + "add": map[string]string{ + "body": "Hello World", + }, + }, + }, + }, + "transition": TransitionPayload{ + ID: transitionID, + }, + } + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, testAPIEndpoint) + + decoder := json.NewDecoder(r.Body) + payload := map[string]interface{}{} + err := decoder.Decode(&payload) + if err != nil { + t.Errorf("Got error: %v", err) + } + + contains := func(key string) bool { + _, ok := payload[key] + return ok + } + + if !contains("update") || !contains("transition") { + t.Fatalf("Excpected update, transition to be in payload, got %s instead", payload) + } + + transition, ok := payload["transition"].(map[string]interface{}) + if !ok { + t.Fatalf("Excpected transition to be in payload, got %s instead", payload["transition"]) + } + + if transition["id"].(string) != transitionID { + t.Errorf("Expected %s to be in payload, got %s instead", transitionID, transition["id"]) + } + }) + _, err := testClient.Issue.DoTransitionWithPayload(context.Background(), "123", customPayload) + + if err != nil { + t.Errorf("Got error: %v", err) + } +} + +func TestIssueFields_TestMarshalJSON_PopulateUnknownsSuccess(t *testing.T) { + data := `{ + "customfield_123":"test", + "description":"example bug report", + "project":{ + "self":"http://www.example.com/jira/rest/api/2/project/EX", + "id":"10000", + "key":"EX", + "name":"Example", + "avatarUrls":{ + "48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000", + "24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000", + "16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000", + "32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000" + }, + "projectCategory":{ + "self":"http://www.example.com/jira/rest/api/2/projectCategory/10000", + "id":"10000", + "name":"FIRST", + "description":"First Project Category" + } + }, + "issuelinks":[ + { + "id":"10001", + "type":{ + "id":"10000", + "name":"Dependent", + "inward":"depends on", + "outward":"is depended by" + }, + "outwardIssue":{ + "id":"10004L", + "key":"PRJ-2", + "self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2", + "fields":{ + "status":{ + "iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png", + "name":"Open" + } + } + } + }, + { + "id":"10002", + "type":{ + "id":"10000", + "name":"Dependent", + "inward":"depends on", + "outward":"is depended by" + }, + "inwardIssue":{ + "id":"10004", + "key":"PRJ-3", + "self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3", + "fields":{ + "status":{ + "iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png", + "name":"Open" + } + } + } + } + ] + }` + + i := new(IssueFields) + err := json.Unmarshal([]byte(data), i) + if err != nil { + t.Errorf("Expected nil error, received %s", err) + } + + if len(i.Unknowns) != 1 { + t.Errorf("Expected 1 unknown field to be present, received %d", len(i.Unknowns)) + } + if i.Description != "example bug report" { + t.Errorf("Expected description to be \"%s\", received \"%s\"", "example bug report", i.Description) + } + +} + +func TestIssueFields_MarshalJSON_OmitsEmptyFields(t *testing.T) { + i := &IssueFields{ + Description: "blahblah", + Type: IssueType{ + Name: "Story", + }, + Labels: []string{"aws-docker"}, + Parent: &Parent{Key: "FOO-300"}, + } + + rawdata, err := json.Marshal(i) + if err != nil { + t.Errorf("Expected nil err, received %s", err) + } + + // convert json to map and see if unset keys are there + issuef := tcontainer.NewMarshalMap() + err = json.Unmarshal(rawdata, &issuef) + if err != nil { + t.Errorf("Expected nil err, received %s", err) + } + + _, err = issuef.Int("issuetype/avatarId") + if err == nil { + t.Error("Expected non nil error, received nil") + } + + // verify Parent nil values are being omitted + _, err = issuef.String("parent/id") + if err == nil { + t.Error("Expected non nil err, received nil") + } + + // verify that the field that should be there, is. + name, err := issuef.String("issuetype/name") + if err != nil { + t.Errorf("Expected nil err, received %s", err) + } + + if name != "Story" { + t.Errorf("Expected Story, received %s", name) + } +} + +func TestIssueFields_MarshalJSON_Success(t *testing.T) { + i := &IssueFields{ + Description: "example bug report", + Unknowns: tcontainer.MarshalMap{ + "customfield_123": "test", + }, + Project: Project{ + Self: "http://www.example.com/jira/rest/api/2/project/EX", + ID: "10000", + Key: "EX", + }, + AffectsVersions: []*AffectsVersion{ + { + ID: "10705", + Name: "2.1.0-rc3", + Self: "http://www.example.com/jira/rest/api/2/version/10705", + ReleaseDate: "2018-09-30", + }, + }, + } + + bytes, err := json.Marshal(i) + if err != nil { + t.Errorf("Expected nil err, received %s", err) + } + + received := new(IssueFields) + // the order of json might be different. so unmarshal it again and compare objects + err = json.Unmarshal(bytes, received) + if err != nil { + t.Errorf("Expected nil err, received %s", err) + } + + if !reflect.DeepEqual(i, received) { + t.Errorf("Received object different from expected. Expected %+v, received %+v", i, received) + } +} + +func TestInitIssueWithMetaAndFields_Success(t *testing.T) { + metaProject := MetaProject{ + Name: "Engineering - Dept", + Id: "ENG", + } + + fields := tcontainer.NewMarshalMap() + fields["summary"] = map[string]interface{}{ + "name": "Summary", + "schema": map[string]interface{}{ + "type": "string", + }, + } + + metaIssueType := MetaIssueType{ + Fields: fields, + } + expectedSummary := "Issue Summary" + fieldConfig := map[string]string{ + "Summary": "Issue Summary", + } + + issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig) + if err != nil { + t.Errorf("Expected nil error, received %s", err) + } + + gotSummary, found := issue.Fields.Unknowns["summary"] + if !found { + t.Errorf("Expected summary to be set in issue. Not set.") + } + + if gotSummary != expectedSummary { + t.Errorf("Expected %s received %s", expectedSummary, gotSummary) + } +} + +func TestInitIssueWithMetaAndFields_ArrayValueType(t *testing.T) { + metaProject := MetaProject{ + Name: "Engineering - Dept", + Id: "ENG", + } + + fields := tcontainer.NewMarshalMap() + fields["component"] = map[string]interface{}{ + "name": "Component/s", + "schema": map[string]interface{}{ + "type": "array", + "items": "component", + }, + } + + metaIssueType := MetaIssueType{ + Fields: fields, + } + + expectedComponent := "Jira automation" + fieldConfig := map[string]string{ + "Component/s": expectedComponent, + } + + issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig) + if err != nil { + t.Errorf("Expected nil error, received %s", err) + } + + c, isArray := issue.Fields.Unknowns["component"].([]Component) + if isArray == false { + t.Error("Expected array, non array object received") + } + + if len(c) != 1 { + t.Errorf("Expected received array to be of length 1. Got %d", len(c)) + } + + gotComponent := c[0].Name + + if err != nil { + t.Errorf("Expected err to be nil, received %s", err) + } + + if gotComponent != expectedComponent { + t.Errorf("Expected %s received %s", expectedComponent, gotComponent) + } +} + +func TestInitIssueWithMetaAndFields_DateValueType(t *testing.T) { + metaProject := MetaProject{ + Name: "Engineering - Dept", + Id: "ENG", + } + + fields := tcontainer.NewMarshalMap() + fields["created"] = map[string]interface{}{ + "name": "Created", + "schema": map[string]interface{}{ + "type": "date", + }, + } + + metaIssueType := MetaIssueType{ + Fields: fields, + } + + expectedCreated := "19 oct 2012" + fieldConfig := map[string]string{ + "Created": expectedCreated, + } + + issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig) + if err != nil { + t.Errorf("Expected nil error, received %s", err) + } + + gotCreated, err := issue.Fields.Unknowns.String("created") + if err != nil { + t.Errorf("Expected err to be nil, received %s", err) + } + + if gotCreated != expectedCreated { + t.Errorf("Expected %s received %s", expectedCreated, gotCreated) + } +} + +func TestInitIssueWithMetaAndFields_UserValueType(t *testing.T) { + metaProject := MetaProject{ + Name: "Engineering - Dept", + Id: "ENG", + } + + fields := tcontainer.NewMarshalMap() + fields["assignee"] = map[string]interface{}{ + "name": "Assignee", + "schema": map[string]interface{}{ + "type": "user", + }, + } + + metaIssueType := MetaIssueType{ + Fields: fields, + } + + expectedAssignee := "jdoe" + fieldConfig := map[string]string{ + "Assignee": expectedAssignee, + } + + issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig) + if err != nil { + t.Errorf("Expected nil error, received %s", err) + } + + a, _ := issue.Fields.Unknowns.Value("assignee") + gotAssignee := a.(User).Name + + if gotAssignee != expectedAssignee { + t.Errorf("Expected %s received %s", expectedAssignee, gotAssignee) + } +} + +func TestInitIssueWithMetaAndFields_ProjectValueType(t *testing.T) { + metaProject := MetaProject{ + Name: "Engineering - Dept", + Id: "ENG", + } + + fields := tcontainer.NewMarshalMap() + fields["project"] = map[string]interface{}{ + "name": "Project", + "schema": map[string]interface{}{ + "type": "project", + }, + } + + metaIssueType := MetaIssueType{ + Fields: fields, + } + + setProject := "somewhere" + fieldConfig := map[string]string{ + "Project": setProject, + } + + issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig) + if err != nil { + t.Errorf("Expected nil error, received %s", err) + } + + a, _ := issue.Fields.Unknowns.Value("project") + gotProject := a.(Project).Name + + if gotProject != metaProject.Name { + t.Errorf("Expected %s received %s", metaProject.Name, gotProject) + } +} + +func TestInitIssueWithMetaAndFields_PriorityValueType(t *testing.T) { + metaProject := MetaProject{ + Name: "Engineering - Dept", + Id: "ENG", + } + + fields := tcontainer.NewMarshalMap() + fields["priority"] = map[string]interface{}{ + "name": "Priority", + "schema": map[string]interface{}{ + "type": "priority", + }, + } + + metaIssueType := MetaIssueType{ + Fields: fields, + } + + expectedPriority := "Normal" + fieldConfig := map[string]string{ + "Priority": expectedPriority, + } + + issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig) + if err != nil { + t.Errorf("Expected nil error, received %s", err) + } + + a, _ := issue.Fields.Unknowns.Value("priority") + gotPriority := a.(Priority).Name + + if gotPriority != expectedPriority { + t.Errorf("Expected %s received %s", expectedPriority, gotPriority) + } +} + +func TestInitIssueWithMetaAndFields_SelectList(t *testing.T) { + metaProject := MetaProject{ + Name: "Engineering - Dept", + Id: "ENG", + } + + fields := tcontainer.NewMarshalMap() + fields["someitem"] = map[string]interface{}{ + "name": "A Select Item", + "schema": map[string]interface{}{ + "type": "option", + }, + } + + metaIssueType := MetaIssueType{ + Fields: fields, + } + + expectedVal := "Value" + fieldConfig := map[string]string{ + "A Select Item": expectedVal, + } + + issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig) + if err != nil { + t.Errorf("Expected nil error, received %s", err) + } + + a, _ := issue.Fields.Unknowns.Value("someitem") + gotVal := a.(Option).Value + + if gotVal != expectedVal { + t.Errorf("Expected %s received %s", expectedVal, gotVal) + } +} + +func TestInitIssueWithMetaAndFields_IssuetypeValueType(t *testing.T) { + metaProject := MetaProject{ + Name: "Engineering - Dept", + Id: "ENG", + } + + fields := tcontainer.NewMarshalMap() + fields["issuetype"] = map[string]interface{}{ + "name": "Issue type", + "schema": map[string]interface{}{ + "type": "issuetype", + }, + } + + metaIssueType := MetaIssueType{ + Fields: fields, + } + + expectedIssuetype := "Bug" + fieldConfig := map[string]string{ + "Issue type": expectedIssuetype, + } + + issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig) + if err != nil { + t.Errorf("Expected nil error, received %s", err) + } + + a, _ := issue.Fields.Unknowns.Value("issuetype") + gotIssuetype := a.(IssueType).Name + + if gotIssuetype != expectedIssuetype { + t.Errorf("Expected %s received %s", expectedIssuetype, gotIssuetype) + } +} + +func TestInitIssueWithmetaAndFields_FailureWithUnknownValueType(t *testing.T) { + metaProject := MetaProject{ + Name: "Engineering - Dept", + Id: "ENG", + } + + fields := tcontainer.NewMarshalMap() + fields["issuetype"] = map[string]interface{}{ + "name": "Issue type", + "schema": map[string]interface{}{ + "type": "randomType", + }, + } + + metaIssueType := MetaIssueType{ + Fields: fields, + } + + fieldConfig := map[string]string{ + "Issue tyoe": "sometype", + } + _, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig) + if err == nil { + t.Error("Expected non nil error, received nil") + } + +} + +func TestIssueService_Delete(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/api/2/issue/10002") + + w.WriteHeader(http.StatusNoContent) + fmt.Fprint(w, `{}`) + }) + + resp, err := testClient.Issue.Delete(context.Background(), "10002") + if resp.StatusCode != 204 { + t.Error("Expected issue not deleted.") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func getTime(original time.Time) *Time { + jiraTime := Time(original) + + return &jiraTime +} + +func TestIssueService_GetWorklogs(t *testing.T) { + setup() + defer teardown() + + tt := []struct { + name string + response string + issueId string + uri string + worklog *Worklog + err error + option *AddWorklogQueryOptions + }{ + { + name: "simple worklog", + response: `{"startAt": 1,"maxResults": 40,"total": 1,"worklogs": [{"id": "3","self": "http://kelpie9:8081/rest/api/2/issue/10002/worklog/3","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","comment":"","started":"2016-03-16T04:22:37.356+0000","timeSpent": "1h","timeSpentSeconds": 3600,"issueId":"10002"}]}`, + issueId: "10002", + uri: "/rest/api/2/issue/%s/worklog", + worklog: &Worklog{ + StartAt: 1, + MaxResults: 40, + Total: 1, + Worklogs: []WorklogRecord{ + { + Self: "http://kelpie9:8081/rest/api/2/issue/10002/worklog/3", + Author: &User{ + Self: "http://www.example.com/jira/rest/api/2/user?username=fred", + Name: "fred", + DisplayName: "Fred F. User", + }, + UpdateAuthor: &User{ + Self: "http://www.example.com/jira/rest/api/2/user?username=fred", + Name: "fred", + DisplayName: "Fred F. User", + }, + Created: getTime(time.Date(2016, time.March, 16, 4, 22, 37, 356000000, time.UTC)), + Started: getTime(time.Date(2016, time.March, 16, 4, 22, 37, 356000000, time.UTC)), + Updated: getTime(time.Date(2016, time.March, 16, 4, 22, 37, 356000000, time.UTC)), + TimeSpent: "1h", + TimeSpentSeconds: 3600, + ID: "3", + IssueID: "10002", + }, + }, + }, + }, + { + name: "expanded worklog", + response: `{"startAt":1,"maxResults":40,"total":1,"worklogs":[{"id":"3","self":"http://kelpie9:8081/rest/api/2/issue/10002/worklog/3","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","comment":"","started":"2016-03-16T04:22:37.356+0000","timeSpent":"1h","timeSpentSeconds":3600,"issueId":"10002","properties":[{"key":"foo","value":{"bar":"baz"}}]}]}`, + issueId: "10002", + uri: "/rest/api/2/issue/%s/worklog?expand=properties", + worklog: &Worklog{ + StartAt: 1, + MaxResults: 40, + Total: 1, + Worklogs: []WorklogRecord{ + { + Self: "http://kelpie9:8081/rest/api/2/issue/10002/worklog/3", + Author: &User{ + Self: "http://www.example.com/jira/rest/api/2/user?username=fred", + Name: "fred", + DisplayName: "Fred F. User", + }, + UpdateAuthor: &User{ + Self: "http://www.example.com/jira/rest/api/2/user?username=fred", + Name: "fred", + DisplayName: "Fred F. User", + }, + Created: getTime(time.Date(2016, time.March, 16, 4, 22, 37, 356000000, time.UTC)), + Started: getTime(time.Date(2016, time.March, 16, 4, 22, 37, 356000000, time.UTC)), + Updated: getTime(time.Date(2016, time.March, 16, 4, 22, 37, 356000000, time.UTC)), + TimeSpent: "1h", + TimeSpentSeconds: 3600, + ID: "3", + IssueID: "10002", + Properties: []EntityProperty{ + { + Key: "foo", + Value: map[string]interface{}{ + "bar": "baz", + }, + }, + }, + }, + }, + }, + option: &AddWorklogQueryOptions{Expand: "properties"}, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + uri := fmt.Sprintf(tc.uri, tc.issueId) + testMux.HandleFunc(uri, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, uri) + _, _ = fmt.Fprint(w, tc.response) + }) + + var worklog *Worklog + var err error + + if tc.option != nil { + worklog, _, err = testClient.Issue.GetWorklogs(context.Background(), tc.issueId, WithQueryOptions(tc.option)) + } else { + worklog, _, err = testClient.Issue.GetWorklogs(context.Background(), tc.issueId) + } + + if err != nil && !cmp.Equal(err, tc.err) { + t.Errorf("unexpected error: %v", err) + } + + if !cmp.Equal(worklog, tc.worklog) { + t.Errorf("unexpected worklog structure: %s", cmp.Diff(worklog, tc.worklog)) + } + }) + } +} + +func TestIssueService_GetWatchers(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002/watchers", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issue/10002/watchers") + + fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000","accountId": "000000000000000000000000","displayName":"Fred F. User","active":false}]}`) + }) + + testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/user?accountId=000000000000000000000000") + + fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000","key":"fred","accountId": "000000000000000000000000", + "emailAddress":"fred@example.com","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":true,"timeZone":"Australia/Sydney","groups":{"size":3,"items":[ + {"name":"jira-user","self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-user"},{"name":"jira-admin", + "self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-admin"},{"name":"important","self":"http://www.example.com/jira/rest/api/2/group?groupname=important" + }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}`) + }) + + watchers, _, err := testClient.Issue.GetWatchers(context.Background(), "10002") + if err != nil { + t.Errorf("Error given: %s", err) + return + } + if watchers == nil { + t.Error("Expected watchers. Watchers is nil") + return + } + if len(*watchers) != 1 { + t.Errorf("Expected 1 watcher, got: %d", len(*watchers)) + return + } + if (*watchers)[0].AccountID != "000000000000000000000000" { + t.Error("Expected watcher accountId 000000000000000000000000") + } +} + +func TestIssueService_DeprecatedGetWatchers(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002/watchers", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issue/10002/watchers") + + fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000", "accountId": "000000000000000000000000", "displayName":"Fred F. User","active":false}]}`) + }) + + testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/user?accountId=000000000000000000000000") + + fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000", "accountId": "000000000000000000000000", "key": "", "name": "", "emailAddress":"fred@example.com","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":true,"timeZone":"Australia/Sydney","groups":{"size":3,"items":[ + {"name":"jira-user","self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-user"},{"name":"jira-admin", + "self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-admin"},{"name":"important","self":"http://www.example.com/jira/rest/api/2/group?groupname=important" + }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}`) + }) + + watchers, _, err := testClient.Issue.GetWatchers(context.Background(), "10002") + if err != nil { + t.Errorf("Error given: %s", err) + return + } + if watchers == nil { + t.Error("Expected watchers. Watchers is nil") + return + } + if len(*watchers) != 1 { + t.Errorf("Expected 1 watcher, got: %d", len(*watchers)) + return + } + if (*watchers)[0].AccountID != "000000000000000000000000" { + t.Error("Expected accountId 000000000000000000000000") + } +} + +func TestIssueService_UpdateAssignee(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002/assignee", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPut) + testRequestURL(t, r, "/rest/api/2/issue/10002/assignee") + + w.WriteHeader(http.StatusNoContent) + }) + + resp, err := testClient.Issue.UpdateAssignee(context.Background(), "10002", &User{ + Name: "test-username", + }) + + if resp.StatusCode != 204 { + t.Error("Expected issue not re-assigned.") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_Get_Fields_Changelog(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issue/10002") + + fmt.Fprint(w, `{"expand":"changelog","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","changelog":{"startAt": 0,"maxResults": 1, "total": 1, "histories": [{"id": "10002", "author": {"self": "http://www.example.com/jira/rest/api/2/user?username=fred", "name": "fred", "key": "fred", "emailAddress": "fred@example.com", "avatarUrls": {"48x48": "http://www.example.com/secure/useravatar?ownerId=fred&avatarId=33072", "24x24": "http://www.example.com/secure/useravatar?size=small&ownerId=fred&avatarId=33072", "16x16": "http://www.example.com/secure/useravatar?size=xsmall&ownerId=fred&avatarId=33072", "32x32": "http://www.example.com/secure/useravatar?size=medium&ownerId=fred&avatarId=33072"},"displayName":"Fred","active": true,"timeZone":"Australia/Sydney"},"created":"2018-06-20T16:50:35.000+0300","items":[{"field":"Rank","fieldtype":"custom","from":"","fromString":"","to":"","toString":"Ranked higher"}]}]}}`) + }) + + issue, _, _ := testClient.Issue.Get(context.Background(), "10002", &GetQueryOptions{Expand: "changelog"}) + if issue == nil { + t.Error("Expected issue. Issue is nil") + return + } + + if len(issue.Changelog.Histories) != 1 { + t.Errorf("Expected one history item, %v found", len(issue.Changelog.Histories)) + } + + if issue.Changelog.Histories[0].Created != "2018-06-20T16:50:35.000+0300" { + t.Errorf("Expected created time of history item 2018-06-20T16:50:35.000+0300, %v got", issue.Changelog.Histories[0].Created) + } + + tm, _ := time.Parse("2006-01-02T15:04:05.999-0700", "2018-06-20T16:50:35.000+0300") + + if ct, _ := issue.Changelog.Histories[0].CreatedTime(); !tm.Equal(ct) { + t.Errorf("Expected CreatedTime func return %v time, %v got", tm, ct) + } +} + +func TestIssueService_Get_Transitions(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issue/10002") + + fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/api/latest/issue/10002","key":"EX-1","transitions":[{"id":"121","name":"Start","to":{"self":"http://www.example.com/rest/api/2/status/10444","description":"","iconUrl":"http://www.example.com/images/icons/statuses/inprogress.png","name":"In progress","id":"10444","statusCategory":{"self":"http://www.example.com/rest/api/2/statuscategory/4","id":4,"key":"indeterminate","colorName":"yellow","name":"In Progress"}}}]}`) + }) + + issue, _, _ := testClient.Issue.Get(context.Background(), "10002", &GetQueryOptions{Expand: "transitions"}) + if issue == nil { + t.Error("Expected issue. Issue is nil") + return + } + + if len(issue.Transitions) != 1 { + t.Errorf("Expected one transition item, %v found", len(issue.Transitions)) + } + + transition := issue.Transitions[0] + + if transition.Name != "Start" { + t.Errorf("Expected 'Start' transition to be available, got %q", transition.Name) + } + + if transition.To.Name != "In progress" { + t.Errorf("Expected transition to lead to status 'In progress', got %q", transition.To.Name) + } +} + +func TestIssueService_Get_Fields_AffectsVersions(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issue/10002") + + fmt.Fprint(w, `{"fields":{"versions":[{"self":"http://www.example.com/jira/rest/api/2/version/10705","id":"10705","description":"test description","name":"2.1.0-rc3","archived":false,"released":false,"releaseDate":"2018-09-30"}]}}`) + }) + + issue, _, err := testClient.Issue.Get(context.Background(), "10002", nil) + if err != nil { + t.Errorf("Error given: %s", err) + } + if issue == nil { + t.Error("Expected issue. Issue is nil") + return + } + if !reflect.DeepEqual(issue.Fields.AffectsVersions, []*AffectsVersion{ + { + ID: "10705", + Name: "2.1.0-rc3", + Self: "http://www.example.com/jira/rest/api/2/version/10705", + ReleaseDate: "2018-09-30", + Released: Bool(false), + Archived: Bool(false), + Description: "test description", + }, + }) { + t.Error("Expected AffectsVersions for the returned issue") + } +} + +func TestIssueService_GetRemoteLinks(t *testing.T) { + setup() + defer teardown() + + testAPIEndpoint := "/rest/api/2/issue/123/remotelink" + + raw, err := os.ReadFile("../testing/mock-data/remote_links.json") + if err != nil { + t.Error(err.Error()) + } + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + remoteLinks, _, err := testClient.Issue.GetRemoteLinks(context.Background(), "123") + if err != nil { + t.Errorf("Got error: %v", err) + } + + if remoteLinks == nil { + t.Error("Expected remote links list. Got nil.") + return + } + + if len(*remoteLinks) != 2 { + t.Errorf("Expected 2 remote links. Got %d", len(*remoteLinks)) + } + + if !(*remoteLinks)[0].Object.Status.Resolved { + t.Errorf("First remote link object status should be resolved") + } +} + +func TestIssueService_AddRemoteLink(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/10000/remotelink", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/issue/10000/remotelink") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id": 10000, "self": "https://your-domain.atlassian.net/rest/api/issue/MKY-1/remotelink/10000"}`) + }) + r := &RemoteLink{ + Application: &RemoteLinkApplication{ + Name: "My Acme Tracker", + Type: "com.acme.tracker", + }, + GlobalID: "system=http://www.mycompany.com/support&id=1", + Relationship: "causes", + Object: &RemoteLinkObject{ + Summary: "Customer support issue", + Icon: &RemoteLinkIcon{ + Url16x16: "http://www.mycompany.com/support/ticket.png", + Title: "Support Ticket", + }, + Title: "TSTSUP-111", + URL: "http://www.mycompany.com/support?id=1", + Status: &RemoteLinkStatus{ + Icon: &RemoteLinkIcon{ + Url16x16: "http://www.mycompany.com/support/resolved.png", + Title: "Case Closed", + Link: "http://www.mycompany.com/support?id=1&details=closed", + }, + Resolved: true, + }, + }, + } + record, _, err := testClient.Issue.AddRemoteLink(context.Background(), "10000", r) + if record == nil { + t.Error("Expected Record. Record is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestIssueService_UpdateRemoteLink(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/100/remotelink/200", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPut) + testRequestURL(t, r, "/rest/api/2/issue/100/remotelink/200") + + w.WriteHeader(http.StatusNoContent) + }) + r := &RemoteLink{ + Application: &RemoteLinkApplication{ + Name: "My Acme Tracker", + Type: "com.acme.tracker", + }, + GlobalID: "system=http://www.mycompany.com/support&id=1", + Relationship: "causes", + Object: &RemoteLinkObject{ + Summary: "Customer support issue", + Icon: &RemoteLinkIcon{ + Url16x16: "http://www.mycompany.com/support/ticket.png", + Title: "Support Ticket", + }, + Title: "TSTSUP-111", + URL: "http://www.mycompany.com/support?id=1", + Status: &RemoteLinkStatus{ + Icon: &RemoteLinkIcon{ + Url16x16: "http://www.mycompany.com/support/resolved.png", + Title: "Case Closed", + Link: "http://www.mycompany.com/support?id=1&details=closed", + }, + Resolved: true, + }, + }, + } + _, err := testClient.Issue.UpdateRemoteLink(context.Background(), "100", 200, r) + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestTime_MarshalJSON(t *testing.T) { + timeFormatParseFrom := "2006-01-02T15:04:05.999Z" + testCases := []struct { + name string + inputTime string + expected string + }{ + { + name: "test without ms", + inputTime: "2020-04-01T01:01:01.000Z", + expected: "\"2020-04-01T01:01:01.000+0000\"", + }, + { + name: "test with ms", + inputTime: "2020-04-01T01:01:01.001Z", + expected: "\"2020-04-01T01:01:01.001+0000\"", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + rawTime, _ := time.Parse(timeFormatParseFrom, tt.inputTime) + time := Time(rawTime) + got, _ := time.MarshalJSON() + if string(got) != tt.expected { + t.Errorf("Time.MarshalJSON() = %v, want %v", string(got), tt.expected) + } + }) + } +} diff --git a/onpremise/issuelinktype.go b/onpremise/issuelinktype.go new file mode 100644 index 00000000..bf4687d2 --- /dev/null +++ b/onpremise/issuelinktype.go @@ -0,0 +1,122 @@ +package onpremise + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// IssueLinkTypeService handles issue link types for the Jira instance / API. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-group-Issue-link-types +type IssueLinkTypeService service + +// GetList gets all of the issue link types from Jira. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueLinkTypeService) GetList(ctx context.Context) ([]IssueLinkType, *Response, error) { + apiEndpoint := "rest/api/2/issueLinkType" + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + linkTypeList := []IssueLinkType{} + resp, err := s.client.Do(req, &linkTypeList) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + return linkTypeList, resp, nil +} + +// Get gets info of a specific issue link type from Jira. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-issueLinkTypeId-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueLinkTypeService) Get(ctx context.Context, ID string) (*IssueLinkType, *Response, error) { + apiEndPoint := fmt.Sprintf("rest/api/2/issueLinkType/%s", ID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) + if err != nil { + return nil, nil, err + } + + linkType := new(IssueLinkType) + resp, err := s.client.Do(req, linkType) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + return linkType, resp, nil +} + +// Create creates an issue link type in Jira. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-post +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueLinkTypeService) Create(ctx context.Context, linkType *IssueLinkType) (*IssueLinkType, *Response, error) { + apiEndpoint := "/rest/api/2/issueLinkType" + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, linkType) + if err != nil { + return nil, nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + return nil, resp, err + } + defer resp.Body.Close() + + responseLinkType := new(IssueLinkType) + err = json.NewDecoder(resp.Body).Decode(&responseLinkType) + if err != nil { + return nil, resp, err + } + + return linkType, resp, nil +} + +// Update updates an issue link type. The issue is found by key. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-issueLinkTypeId-put +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueLinkTypeService) Update(ctx context.Context, linkType *IssueLinkType) (*IssueLinkType, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issueLinkType/%s", linkType.ID) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndpoint, linkType) + if err != nil { + return nil, nil, err + } + resp, err := s.client.Do(req, nil) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + ret := *linkType + return &ret, resp, nil +} + +// Delete deletes an issue link type based on provided ID. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issueLinkType-issueLinkTypeId-delete +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueLinkTypeService) Delete(ctx context.Context, ID string) (*Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issueLinkType/%s", ID) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + return resp, err +} diff --git a/onpremise/issuelinktype_test.go b/onpremise/issuelinktype_test.go new file mode 100644 index 00000000..374bebc5 --- /dev/null +++ b/onpremise/issuelinktype_test.go @@ -0,0 +1,119 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestIssueLinkTypeService_GetList(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/2/issueLinkType" + + raw, err := os.ReadFile("../testing/mock-data/all_issuelinktypes.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + linkTypes, _, err := testClient.IssueLinkType.GetList(context.Background()) + if linkTypes == nil { + t.Error("Expected issueLinkType list. LinkTypes is nil") + } + if err != nil { + t.Errorf("Error give: %s", err) + } +} + +func TestIssueLinkTypeService_Get(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issueLinkType/123", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/issueLinkType/123") + + fmt.Fprint(w, `{"id": "123","name": "Blocked","inward": "Blocked","outward": "Blocked", + "self": "https://www.example.com/jira/rest/api/2/issueLinkType/123"}`) + }) + + if linkType, _, err := testClient.IssueLinkType.Get(context.Background(), "123"); err != nil { + t.Errorf("Error given: %s", err) + } else if linkType == nil { + t.Error("Expected linkType. LinkType is nil") + } +} + +func TestIssueLinkTypeService_Create(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issueLinkType", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/issueLinkType") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":"10021","name":"Problem/Incident","inward":"is caused by", + "outward":"causes","self":"https://www.example.com/jira/rest/api/2/issueLinkType/10021"}`) + }) + + lt := &IssueLinkType{ + Name: "Problem/Incident", + Inward: "is caused by", + Outward: "causes", + } + + if linkType, _, err := testClient.IssueLinkType.Create(context.Background(), lt); err != nil { + t.Errorf("Error given: %s", err) + } else if linkType == nil { + t.Error("Expected linkType. LinkType is nil") + } +} + +func TestIssueLinkTypeService_Update(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issueLinkType/100", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPut) + testRequestURL(t, r, "/rest/api/2/issueLinkType/100") + + w.WriteHeader(http.StatusNoContent) + }) + + lt := &IssueLinkType{ + ID: "100", + Name: "Problem/Incident", + Inward: "is caused by", + Outward: "causes", + } + + if linkType, _, err := testClient.IssueLinkType.Update(context.Background(), lt); err != nil { + t.Errorf("Error given: %s", err) + } else if linkType == nil { + t.Error("Expected linkType. LinkType is nil") + } +} + +func TestIssueLinkTypeService_Delete(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issueLinkType/100", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/api/2/issueLinkType/100") + + w.WriteHeader(http.StatusNoContent) + }) + + resp, err := testClient.IssueLinkType.Delete(context.Background(), "100") + if resp.StatusCode != http.StatusNoContent { + t.Error("Expected issue not deleted.") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/onpremise/jira.go b/onpremise/jira.go new file mode 100644 index 00000000..b98c966f --- /dev/null +++ b/onpremise/jira.go @@ -0,0 +1,351 @@ +package onpremise + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strings" + "sync" + + "github.com/google/go-querystring/query" +) + +const ( + ClientVersion = "2.0.0" + + defaultUserAgent = "go-jira" + "/" + ClientVersion +) + +// A Client manages communication with the Jira API. +type Client struct { + clientMu sync.Mutex // clientMu protects the client during calls that modify it. + client *http.Client // HTTP client used to communicate with the API. + + // Base URL for API requests. + // Should be set to a domain endpoint of the Jira instance. + // BaseURL should always be specified with a trailing slash. + BaseURL *url.URL + + // User agent used when communicating with the Jira API. + UserAgent string + + // Session storage if the user authenticates with a Session cookie + // TODO Needed in Cloud and/or onpremise? + session *Session + + // Reuse a single struct instead of allocating one for each service on the heap. + common service + + // Services used for talking to different parts of the Jira API. + Authentication *AuthenticationService + Issue *IssueService + Project *ProjectService + Board *BoardService + Sprint *SprintService + User *UserService + Group *GroupService + Version *VersionService + Priority *PriorityService + Field *FieldService + Component *ComponentService + Resolution *ResolutionService + StatusCategory *StatusCategoryService + Filter *FilterService + Role *RoleService + PermissionScheme *PermissionSchemeService + Status *StatusService + IssueLinkType *IssueLinkTypeService + Organization *OrganizationService + ServiceDesk *ServiceDeskService + Customer *CustomerService + Request *RequestService +} + +// service is the base structure to bundle API services +// under a sub-struct. +type service struct { + client *Client +} + +// Client returns the http.Client used by this Jira client. +func (c *Client) Client() *http.Client { + c.clientMu.Lock() + defer c.clientMu.Unlock() + clientCopy := *c.client + return &clientCopy +} + +// NewClient returns a new Jira API client with provided base URL (often is your Jira hostname) +// If a nil httpClient is provided, a new http.Client will be used. +// To use API methods which require authentication, provide an http.Client that will perform the authentication for you (such as that provided by the golang.org/x/oauth2 library). +// baseURL is the HTTP endpoint of your Jira instance and should always be specified with a trailing slash. +func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { + if httpClient == nil { + httpClient = &http.Client{} + } + + baseEndpoint, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + // ensure the baseURL contains a trailing slash so that all paths are preserved in later calls + if !strings.HasSuffix(baseEndpoint.Path, "/") { + baseEndpoint.Path += "/" + } + + c := &Client{ + client: httpClient, + BaseURL: baseEndpoint, + UserAgent: defaultUserAgent, + } + c.common.client = c + + // TODO Check if the authentication service is still needed (because of the transports) + c.Authentication = &AuthenticationService{client: c} + c.Issue = (*IssueService)(&c.common) + c.Project = (*ProjectService)(&c.common) + c.Board = (*BoardService)(&c.common) + c.Sprint = (*SprintService)(&c.common) + c.User = (*UserService)(&c.common) + c.Group = (*GroupService)(&c.common) + c.Version = (*VersionService)(&c.common) + c.Priority = (*PriorityService)(&c.common) + c.Field = (*FieldService)(&c.common) + c.Component = (*ComponentService)(&c.common) + c.Resolution = (*ResolutionService)(&c.common) + c.StatusCategory = (*StatusCategoryService)(&c.common) + c.Filter = (*FilterService)(&c.common) + c.Role = (*RoleService)(&c.common) + c.PermissionScheme = (*PermissionSchemeService)(&c.common) + c.Status = (*StatusService)(&c.common) + c.IssueLinkType = (*IssueLinkTypeService)(&c.common) + c.Organization = (*OrganizationService)(&c.common) + c.ServiceDesk = (*ServiceDeskService)(&c.common) + c.Customer = (*CustomerService)(&c.common) + c.Request = (*RequestService)(&c.common) + + return c, nil +} + +// TODO Do we need it? +// NewRawRequest creates an API request. +// A relative URL can be provided in urlStr, in which case it is resolved relative to the baseURL of the Client. +// Allows using an optional native io.Reader for sourcing the request body. +func (c *Client) NewRawRequest(ctx context.Context, method, urlStr string, body io.Reader) (*http.Request, error) { + rel, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + // Relative URLs should be specified without a preceding slash since baseURL will have the trailing slash + rel.Path = strings.TrimLeft(rel.Path, "/") + + u := c.BaseURL.ResolveReference(rel) + + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + // Set authentication information + if c.Authentication.authType == authTypeSession { + // Set session cookie if there is one + if c.session != nil { + for _, cookie := range c.session.Cookies { + req.AddCookie(cookie) + } + } + } else if c.Authentication.authType == authTypeBasic { + // Set basic auth information + if c.Authentication.username != "" { + req.SetBasicAuth(c.Authentication.username, c.Authentication.password) + } + } + + return req, nil +} + +// NewRequest creates an API request. +// A relative URL can be provided in urlStr, in which case it is resolved relative to the BaseURL of the Client. +// If specified, the value pointed to by body is JSON encoded and included as the request body. +func (c *Client) NewRequest(ctx context.Context, method, urlStr string, body interface{}) (*http.Request, error) { + rel, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + // Relative URLs should be specified without a preceding slash since BaseURL will have the trailing slash + rel.Path = strings.TrimLeft(rel.Path, "/") + + u := c.BaseURL.ResolveReference(rel) + + // TODO This part is the difference between NewRawRequestWithContext + // Check if we can get this working in one function + var buf io.ReadWriter + if body != nil { + buf = new(bytes.Buffer) + err = json.NewEncoder(buf).Encode(body) + if err != nil { + return nil, err + } + } + + req, err := http.NewRequestWithContext(ctx, method, u.String(), buf) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + // Set authentication information + if c.Authentication.authType == authTypeSession { + // Set session cookie if there is one + if c.session != nil { + for _, cookie := range c.session.Cookies { + req.AddCookie(cookie) + } + } + } else if c.Authentication.authType == authTypeBasic { + // Set basic auth information + if c.Authentication.username != "" { + req.SetBasicAuth(c.Authentication.username, c.Authentication.password) + } + } + + return req, nil +} + +// addOptions adds the parameters in opts as URL query parameters to s. opts +// must be a struct whose fields may contain "url" tags. +func addOptions(s string, opts interface{}) (string, error) { + v := reflect.ValueOf(opts) + if v.Kind() == reflect.Ptr && v.IsNil() { + return s, nil + } + + u, err := url.Parse(s) + if err != nil { + return s, err + } + + qs, err := query.Values(opts) + if err != nil { + return s, err + } + + u.RawQuery = qs.Encode() + return u.String(), nil +} + +// NewMultiPartRequest creates an API request including a multi-part file. +// A relative URL can be provided in urlStr, in which case it is resolved relative to the baseURL of the Client. +// If specified, the value pointed to by buf is a multipart form. +func (c *Client) NewMultiPartRequest(ctx context.Context, method, urlStr string, buf *bytes.Buffer) (*http.Request, error) { + rel, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + // Relative URLs should be specified without a preceding slash since baseURL will have the trailing slash + rel.Path = strings.TrimLeft(rel.Path, "/") + + u := c.BaseURL.ResolveReference(rel) + + req, err := http.NewRequestWithContext(ctx, method, u.String(), buf) + if err != nil { + return nil, err + } + + // Set required headers + req.Header.Set("X-Atlassian-Token", "nocheck") + + // Set authentication information + if c.Authentication.authType == authTypeSession { + // Set session cookie if there is one + if c.session != nil { + for _, cookie := range c.session.Cookies { + req.AddCookie(cookie) + } + } + } else if c.Authentication.authType == authTypeBasic { + // Set basic auth information + if c.Authentication.username != "" { + req.SetBasicAuth(c.Authentication.username, c.Authentication.password) + } + } + + return req, nil +} + +// Do sends an API request and returns the API response. +// The API response is JSON decoded and stored in the value pointed to by v, or returned as an error if an API error has occurred. +func (c *Client) Do(req *http.Request, v interface{}) (*Response, error) { + httpResp, err := c.client.Do(req) + if err != nil { + return nil, err + } + + err = CheckResponse(httpResp) + if err != nil { + // Even though there was an error, we still return the response + // in case the caller wants to inspect it further + return newResponse(httpResp, nil), err + } + + if v != nil { + // Open a NewDecoder and defer closing the reader only if there is a provided interface to decode to + defer httpResp.Body.Close() + err = json.NewDecoder(httpResp.Body).Decode(v) + } + + resp := newResponse(httpResp, v) + return resp, err +} + +// CheckResponse checks the API response for errors, and returns them if present. +// A response is considered an error if it has a status code outside the 200 range. +// The caller is responsible to analyze the response body. +// The body can contain JSON (if the error is intended) or xml (sometimes Jira just failes). +func CheckResponse(r *http.Response) error { + if c := r.StatusCode; 200 <= c && c <= 299 { + return nil + } + + err := fmt.Errorf("request failed. Please analyze the request body for more details. Status code: %d", r.StatusCode) + return err +} + +// Response represents Jira API response. It wraps http.Response returned from +// API and provides information about paging. +type Response struct { + *http.Response + + StartAt int + MaxResults int + Total int +} + +func newResponse(r *http.Response, v interface{}) *Response { + resp := &Response{Response: r} + resp.populatePageValues(v) + return resp +} + +// Sets paging values if response json was parsed to searchResult type +// (can be extended with other types if they also need paging info) +func (r *Response) populatePageValues(v interface{}) { + switch value := v.(type) { + case *searchResult: + r.StartAt = value.StartAt + r.MaxResults = value.MaxResults + r.Total = value.Total + case *groupMembersResult: + r.StartAt = value.StartAt + r.MaxResults = value.MaxResults + r.Total = value.Total + } +} diff --git a/jira_test.go b/onpremise/jira_test.go similarity index 59% rename from jira_test.go rename to onpremise/jira_test.go index 87ab8df4..745b3895 100644 --- a/jira_test.go +++ b/onpremise/jira_test.go @@ -1,10 +1,11 @@ -package jira +package onpremise import ( "bytes" + "context" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "net/http/httptest" "net/url" @@ -39,7 +40,7 @@ func setup() { testServer = httptest.NewServer(testMux) // jira client configured to use test server - testClient, _ = NewClient(nil, testServer.URL) + testClient, _ = NewClient(testServer.URL, nil) } // teardown closes the test HTTP server. @@ -94,7 +95,7 @@ func testRequestBody(t *testing.T, r *http.Request, want map[string]interface{}) } func TestNewClient_WrongUrl(t *testing.T) { - c, err := NewClient(nil, "://issues.apache.org/jira/") + c, err := NewClient("://issues.apache.org/jira/", nil) if err == nil { t.Error("Expected an error. Got none") @@ -108,7 +109,7 @@ func TestNewClient_WithHttpClient(t *testing.T) { httpClient := http.DefaultClient httpClient.Timeout = 10 * time.Minute - c, err := NewClient(httpClient, testJiraInstanceURL) + c, err := NewClient(testJiraInstanceURL, httpClient) if err != nil { t.Errorf("Got an error: %s", err) } @@ -122,7 +123,7 @@ func TestNewClient_WithHttpClient(t *testing.T) { } func TestNewClient_WithServices(t *testing.T) { - c, err := NewClient(nil, testJiraInstanceURL) + c, err := NewClient(testJiraInstanceURL, nil) if err != nil { t.Errorf("Got an error: %s", err) @@ -181,14 +182,14 @@ func TestCheckResponse(t *testing.T) { } func TestClient_NewRequest(t *testing.T) { - c, err := NewClient(nil, testJiraInstanceURL) + c, err := NewClient(testJiraInstanceURL, nil) if err != nil { t.Errorf("An error occurred. Expected nil. Got %+v.", err) } inURL, outURL := "rest/api/2/issue/", testJiraInstanceURL+"rest/api/2/issue/" inBody, outBody := &Issue{Key: "MESOS"}, `{"key":"MESOS"}`+"\n" - req, _ := c.NewRequest("GET", inURL, inBody) + req, _ := c.NewRequest(context.Background(), http.MethodGet, inURL, inBody) // Test that relative URL was expanded if got, want := req.URL.String(), outURL; got != want { @@ -196,14 +197,14 @@ func TestClient_NewRequest(t *testing.T) { } // Test that body was JSON encoded - body, _ := ioutil.ReadAll(req.Body) + body, _ := io.ReadAll(req.Body) if got, want := string(body), outBody; got != want { t.Errorf("NewRequest(%v) Body is %v, want %v", inBody, got, want) } } func TestClient_NewRawRequest(t *testing.T) { - c, err := NewClient(nil, testJiraInstanceURL) + c, err := NewClient(testJiraInstanceURL, nil) if err != nil { t.Errorf("An error occurred. Expected nil. Got %+v.", err) } @@ -212,7 +213,7 @@ func TestClient_NewRawRequest(t *testing.T) { outBody := `{"key":"MESOS"}` + "\n" inBody := outBody - req, _ := c.NewRawRequest("GET", inURL, strings.NewReader(outBody)) + req, _ := c.NewRawRequest(context.Background(), http.MethodGet, inURL, strings.NewReader(outBody)) // Test that relative URL was expanded if got, want := req.URL.String(), outURL; got != want { @@ -220,7 +221,7 @@ func TestClient_NewRawRequest(t *testing.T) { } // Test that body was JSON encoded - body, _ := ioutil.ReadAll(req.Body) + body, _ := io.ReadAll(req.Body) if got, want := string(body), outBody; got != want { t.Errorf("NewRawRequest(%v) Body is %v, want %v", inBody, got, want) } @@ -236,16 +237,16 @@ func testURLParseError(t *testing.T, err error) { } func TestClient_NewRequest_BadURL(t *testing.T) { - c, err := NewClient(nil, testJiraInstanceURL) + c, err := NewClient(testJiraInstanceURL, nil) if err != nil { t.Errorf("An error occurred. Expected nil. Got %+v.", err) } - _, err = c.NewRequest("GET", ":", nil) + _, err = c.NewRequest(context.Background(), http.MethodGet, ":", nil) testURLParseError(t, err) } func TestClient_NewRequest_SessionCookies(t *testing.T) { - c, err := NewClient(nil, testJiraInstanceURL) + c, err := NewClient(testJiraInstanceURL, nil) if err != nil { t.Errorf("An error occurred. Expected nil. Got %+v.", err) } @@ -256,7 +257,7 @@ func TestClient_NewRequest_SessionCookies(t *testing.T) { inURL := "rest/api/2/issue/" inBody := &Issue{Key: "MESOS"} - req, err := c.NewRequest("GET", inURL, inBody) + req, err := c.NewRequest(context.Background(), http.MethodGet, inURL, inBody) if err != nil { t.Errorf("An error occurred. Expected nil. Got %+v.", err) @@ -274,7 +275,7 @@ func TestClient_NewRequest_SessionCookies(t *testing.T) { } func TestClient_NewRequest_BasicAuth(t *testing.T) { - c, err := NewClient(nil, testJiraInstanceURL) + c, err := NewClient(testJiraInstanceURL, nil) if err != nil { t.Errorf("An error occurred. Expected nil. Got %+v.", err) } @@ -283,7 +284,7 @@ func TestClient_NewRequest_BasicAuth(t *testing.T) { inURL := "rest/api/2/issue/" inBody := &Issue{Key: "MESOS"} - req, err := c.NewRequest("GET", inURL, inBody) + req, err := c.NewRequest(context.Background(), http.MethodGet, inURL, inBody) if err != nil { t.Errorf("An error occurred. Expected nil. Got %+v.", err) @@ -300,11 +301,11 @@ func TestClient_NewRequest_BasicAuth(t *testing.T) { // since there is no difference between an HTTP request body that is an empty string versus one that is not set at all. // However in certain cases, intermediate systems may treat these differently resulting in subtle errors. func TestClient_NewRequest_EmptyBody(t *testing.T) { - c, err := NewClient(nil, testJiraInstanceURL) + c, err := NewClient(testJiraInstanceURL, nil) if err != nil { t.Errorf("An error occurred. Expected nil. Got %+v.", err) } - req, err := c.NewRequest("GET", "/", nil) + req, err := c.NewRequest(context.Background(), http.MethodGet, "/", nil) if err != nil { t.Fatalf("NewRequest returned unexpected error: %v", err) } @@ -314,7 +315,7 @@ func TestClient_NewRequest_EmptyBody(t *testing.T) { } func TestClient_NewMultiPartRequest(t *testing.T) { - c, err := NewClient(nil, testJiraInstanceURL) + c, err := NewClient(testJiraInstanceURL, nil) if err != nil { t.Errorf("An error occurred. Expected nil. Got %+v.", err) } @@ -325,7 +326,7 @@ func TestClient_NewMultiPartRequest(t *testing.T) { inURL := "rest/api/2/issue/" inBuf := bytes.NewBufferString("teststring") - req, err := c.NewMultiPartRequest("GET", inURL, inBuf) + req, err := c.NewMultiPartRequest(context.Background(), http.MethodGet, inURL, inBuf) if err != nil { t.Errorf("An error occurred. Expected nil. Got %+v.", err) @@ -347,7 +348,7 @@ func TestClient_NewMultiPartRequest(t *testing.T) { } func TestClient_NewMultiPartRequest_BasicAuth(t *testing.T) { - c, err := NewClient(nil, testJiraInstanceURL) + c, err := NewClient(testJiraInstanceURL, nil) if err != nil { t.Errorf("An error occurred. Expected nil. Got %+v.", err) } @@ -356,7 +357,7 @@ func TestClient_NewMultiPartRequest_BasicAuth(t *testing.T) { inURL := "rest/api/2/issue/" inBuf := bytes.NewBufferString("teststring") - req, err := c.NewMultiPartRequest("GET", inURL, inBuf) + req, err := c.NewMultiPartRequest(context.Background(), http.MethodGet, inURL, inBuf) if err != nil { t.Errorf("An error occurred. Expected nil. Got %+v.", err) @@ -381,13 +382,13 @@ func TestClient_Do(t *testing.T) { } testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if m := "GET"; m != r.Method { + if m := http.MethodGet; m != r.Method { t.Errorf("Request method = %v, want %v", r.Method, m) } fmt.Fprint(w, `{"A":"a"}`) }) - req, _ := testClient.NewRequest("GET", "/", nil) + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) body := new(foo) testClient.Do(req, body) @@ -402,15 +403,15 @@ func TestClient_Do_HTTPResponse(t *testing.T) { defer teardown() testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if m := "GET"; m != r.Method { + if m := http.MethodGet; m != r.Method { t.Errorf("Request method = %v, want %v", r.Method, m) } fmt.Fprint(w, `{"A":"a"}`) }) - req, _ := testClient.NewRequest("GET", "/", nil) + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) res, _ := testClient.Do(req, nil) - _, err := ioutil.ReadAll(res.Body) + _, err := io.ReadAll(res.Body) if err != nil { t.Errorf("Error on parsing HTTP Response = %v", err.Error()) @@ -427,7 +428,7 @@ func TestClient_Do_HTTPError(t *testing.T) { http.Error(w, "Bad Request", 400) }) - req, _ := testClient.NewRequest("GET", "/", nil) + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) _, err := testClient.Do(req, nil) if err == nil { @@ -445,7 +446,7 @@ func TestClient_Do_RedirectLoop(t *testing.T) { http.Redirect(w, r, "/", http.StatusFound) }) - req, _ := testClient.NewRequest("GET", "/", nil) + req, _ := testClient.NewRequest(context.Background(), http.MethodGet, "/", nil) _, err := testClient.Do(req, nil) if err == nil { @@ -455,226 +456,3 @@ func TestClient_Do_RedirectLoop(t *testing.T) { t.Errorf("Expected a URL error; got %+v.", err) } } - -func TestClient_GetBaseURL_WithURL(t *testing.T) { - u, err := url.Parse(testJiraInstanceURL) - if err != nil { - t.Errorf("URL parsing -> Got an error: %s", err) - } - - c, err := NewClient(nil, testJiraInstanceURL) - if err != nil { - t.Errorf("Client creation -> Got an error: %s", err) - } - if c == nil { - t.Error("Expected a client. Got none") - } - - if b := c.GetBaseURL(); !reflect.DeepEqual(b, *u) { - t.Errorf("Base URLs are not equal. Expected %+v, got %+v", *u, b) - } -} - -func TestBasicAuthTransport(t *testing.T) { - setup() - defer teardown() - - username, password := "username", "password" - - testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - u, p, ok := r.BasicAuth() - if !ok { - t.Errorf("request does not contain basic auth credentials") - } - if u != username { - t.Errorf("request contained basic auth username %q, want %q", u, username) - } - if p != password { - t.Errorf("request contained basic auth password %q, want %q", p, password) - } - }) - - tp := &BasicAuthTransport{ - Username: username, - Password: password, - } - - basicAuthClient, _ := NewClient(tp.Client(), testServer.URL) - req, _ := basicAuthClient.NewRequest("GET", ".", nil) - basicAuthClient.Do(req, nil) -} - -func TestBasicAuthTransport_transport(t *testing.T) { - // default transport - tp := &BasicAuthTransport{} - if tp.transport() != http.DefaultTransport { - t.Errorf("Expected http.DefaultTransport to be used.") - } - - // custom transport - tp = &BasicAuthTransport{ - Transport: &http.Transport{}, - } - if tp.transport() == http.DefaultTransport { - t.Errorf("Expected custom transport to be used.") - } -} - -// Test that the cookie in the transport is the cookie returned in the header -func TestCookieAuthTransport_SessionObject_Exists(t *testing.T) { - setup() - defer teardown() - - testCookie := &http.Cookie{Name: "test", Value: "test"} - - testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - cookies := r.Cookies() - - if len(cookies) < 1 { - t.Errorf("No cookies set") - } - - if cookies[0].Name != testCookie.Name { - t.Errorf("Cookie names don't match, expected %v, got %v", testCookie.Name, cookies[0].Name) - } - - if cookies[0].Value != testCookie.Value { - t.Errorf("Cookie values don't match, expected %v, got %v", testCookie.Value, cookies[0].Value) - } - }) - - tp := &CookieAuthTransport{ - Username: "username", - Password: "password", - AuthURL: "https://some.jira.com/rest/auth/1/session", - SessionObject: []*http.Cookie{testCookie}, - } - - basicAuthClient, _ := NewClient(tp.Client(), testServer.URL) - req, _ := basicAuthClient.NewRequest("GET", ".", nil) - basicAuthClient.Do(req, nil) -} - -// Test that an empty cookie in the transport is not returned in the header -func TestCookieAuthTransport_SessionObject_ExistsWithEmptyCookie(t *testing.T) { - setup() - defer teardown() - - emptyCookie := &http.Cookie{Name: "empty_cookie", Value: ""} - testCookie := &http.Cookie{Name: "test", Value: "test"} - - testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - cookies := r.Cookies() - - if len(cookies) > 1 { - t.Errorf("The empty cookie should not have been added") - } - - if cookies[0].Name != testCookie.Name { - t.Errorf("Cookie names don't match, expected %v, got %v", testCookie.Name, cookies[0].Name) - } - - if cookies[0].Value != testCookie.Value { - t.Errorf("Cookie values don't match, expected %v, got %v", testCookie.Value, cookies[0].Value) - } - }) - - tp := &CookieAuthTransport{ - Username: "username", - Password: "password", - AuthURL: "https://some.jira.com/rest/auth/1/session", - SessionObject: []*http.Cookie{emptyCookie, testCookie}, - } - - basicAuthClient, _ := NewClient(tp.Client(), testServer.URL) - req, _ := basicAuthClient.NewRequest("GET", ".", nil) - basicAuthClient.Do(req, nil) -} - -// Test that if no cookie is in the transport, it checks for a cookie -func TestCookieAuthTransport_SessionObject_DoesNotExist(t *testing.T) { - setup() - defer teardown() - - testCookie := &http.Cookie{Name: "does_not_exist", Value: "does_not_exist"} - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - http.SetCookie(w, testCookie) - w.Write([]byte(`OK`)) - })) - defer ts.Close() - - testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - cookies := r.Cookies() - - if len(cookies) < 1 { - t.Errorf("No cookies set") - } - - if cookies[0].Name != testCookie.Name { - t.Errorf("Cookie names don't match, expected %v, got %v", testCookie.Name, cookies[0].Name) - } - - if cookies[0].Value != testCookie.Value { - t.Errorf("Cookie values don't match, expected %v, got %v", testCookie.Value, cookies[0].Value) - } - }) - - tp := &CookieAuthTransport{ - Username: "username", - Password: "password", - AuthURL: ts.URL, - } - - basicAuthClient, _ := NewClient(tp.Client(), testServer.URL) - req, _ := basicAuthClient.NewRequest("GET", ".", nil) - basicAuthClient.Do(req, nil) -} - -func TestJWTAuthTransport_HeaderContainsJWT(t *testing.T) { - setup() - defer teardown() - - sharedSecret := []byte("ssshh,it's a secret") - issuer := "add-on.key" - - jwtTransport := &JWTAuthTransport{ - Secret: sharedSecret, - Issuer: issuer, - } - - testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // look for the presence of the JWT in the header - val := r.Header.Get("Authorization") - if !strings.Contains(val, "JWT ") { - t.Errorf("request does not contain JWT in the Auth header") - } - }) - - jwtClient, _ := NewClient(jwtTransport.Client(), testServer.URL) - jwtClient.Issue.Get("TEST-1", nil) -} - -func TestPATAuthTransport_HeaderContainsAuth(t *testing.T) { - setup() - defer teardown() - - token := "shhh, it's a token" - - patTransport := &PATAuthTransport{ - Token: token, - } - - testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - val := r.Header.Get("Authorization") - expected := "Bearer " + token - if val != expected { - t.Errorf("request does not contain bearer token in the Authorization header.") - } - }) - - client, _ := NewClient(patTransport.Client(), testServer.URL) - client.User.GetSelf() - -} diff --git a/onpremise/metaissue.go b/onpremise/metaissue.go new file mode 100644 index 00000000..006ea0ab --- /dev/null +++ b/onpremise/metaissue.go @@ -0,0 +1,241 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/google/go-querystring/query" + "github.com/trivago/tgo/tcontainer" +) + +// CreateMetaInfo contains information about fields and their attributed to create a ticket. +type CreateMetaInfo struct { + Expand string `json:"expand,omitempty"` + Projects []*MetaProject `json:"projects,omitempty"` +} + +// EditMetaInfo contains information about fields and their attributed to edit a ticket. +type EditMetaInfo struct { + Fields tcontainer.MarshalMap `json:"fields,omitempty"` +} + +// MetaProject is the meta information about a project returned from createmeta api +type MetaProject struct { + Expand string `json:"expand,omitempty"` + Self string `json:"self,omitempty"` + Id string `json:"id,omitempty"` + Key string `json:"key,omitempty"` + Name string `json:"name,omitempty"` + // omitted avatarUrls + IssueTypes []*MetaIssueType `json:"issuetypes,omitempty"` +} + +// MetaIssueType represents the different issue types a project has. +// +// Note: Fields is interface because this is an object which can +// have arbitraty keys related to customfields. It is not possible to +// expect these for a general way. This will be returning a map. +// Further processing must be done depending on what is required. +type MetaIssueType struct { + Self string `json:"self,omitempty"` + Id string `json:"id,omitempty"` + Description string `json:"description,omitempty"` + IconUrl string `json:"iconurl,omitempty"` + Name string `json:"name,omitempty"` + Subtasks bool `json:"subtask,omitempty"` + Expand string `json:"expand,omitempty"` + Fields tcontainer.MarshalMap `json:"fields,omitempty"` +} + +// GetCreateMeta makes the api call to get the meta information without requiring to have a projectKey +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetCreateMeta(ctx context.Context, options *GetQueryOptions) (*CreateMetaInfo, *Response, error) { + apiEndpoint := "rest/api/2/issue/createmeta" + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + if options != nil { + q, err := query.Values(options) + if err != nil { + return nil, nil, err + } + req.URL.RawQuery = q.Encode() + } + + meta := new(CreateMetaInfo) + resp, err := s.client.Do(req, meta) + + if err != nil { + return nil, resp, err + } + + return meta, resp, nil +} + +// GetEditMeta makes the api call to get the edit meta information for an issue +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *IssueService) GetEditMeta(ctx context.Context, issue *Issue) (*EditMetaInfo, *Response, error) { + apiEndpoint := fmt.Sprintf("/rest/api/2/issue/%s/editmeta", issue.Key) + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + meta := new(EditMetaInfo) + resp, err := s.client.Do(req, meta) + + if err != nil { + return nil, resp, err + } + + return meta, resp, nil +} + +// GetProjectWithName returns a project with "name" from the meta information received. If not found, this returns nil. +// The comparison of the name is case insensitive. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (m *CreateMetaInfo) GetProjectWithName(name string) *MetaProject { + for _, m := range m.Projects { + if strings.EqualFold(m.Name, name) { + return m + } + } + return nil +} + +// GetProjectWithKey returns a project with "name" from the meta information received. If not found, this returns nil. +// The comparison of the name is case insensitive. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (m *CreateMetaInfo) GetProjectWithKey(key string) *MetaProject { + for _, m := range m.Projects { + if strings.EqualFold(m.Key, key) { + return m + } + } + return nil +} + +// GetIssueTypeWithName returns an IssueType with name from a given MetaProject. If not found, this returns nil. +// The comparison of the name is case insensitive +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (p *MetaProject) GetIssueTypeWithName(name string) *MetaIssueType { + for _, m := range p.IssueTypes { + if strings.EqualFold(m.Name, name) { + return m + } + } + return nil +} + +// GetMandatoryFields returns a map of all the required fields from the MetaIssueTypes. +// if a field returned by the api was: +// +// "customfield_10806": { +// "required": true, +// "schema": { +// "type": "any", +// "custom": "com.pyxis.greenhopper.jira:gh-epic-link", +// "customId": 10806 +// }, +// "name": "Epic Link", +// "hasDefaultValue": false, +// "operations": [ +// "set" +// ] +// } +// +// the returned map would have "Epic Link" as the key and "customfield_10806" as value. +// This choice has been made so that the it is easier to generate the create api request later. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (t *MetaIssueType) GetMandatoryFields() (map[string]string, error) { + ret := make(map[string]string) + for key := range t.Fields { + required, err := t.Fields.Bool(key + "/required") + if err != nil { + return nil, err + } + if required { + name, err := t.Fields.String(key + "/name") + if err != nil { + return nil, err + } + ret[name] = key + } + } + return ret, nil +} + +// GetAllFields returns a map of all the fields for an IssueType. This includes all required and not required. +// The key of the returned map is what you see in the form and the value is how it is representated in the jira schema. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (t *MetaIssueType) GetAllFields() (map[string]string, error) { + ret := make(map[string]string) + for key := range t.Fields { + + name, err := t.Fields.String(key + "/name") + if err != nil { + return nil, err + } + ret[name] = key + } + return ret, nil +} + +// CheckCompleteAndAvailable checks if the given fields satisfies the mandatory field required to create a issue for the given type +// And also if the given fields are available. +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (t *MetaIssueType) CheckCompleteAndAvailable(config map[string]string) (bool, error) { + mandatory, err := t.GetMandatoryFields() + if err != nil { + return false, err + } + all, err := t.GetAllFields() + if err != nil { + return false, err + } + + // check templateconfig against mandatory fields + for key := range mandatory { + if _, okay := config[key]; !okay { + var requiredFields []string + for name := range mandatory { + requiredFields = append(requiredFields, name) + } + return false, fmt.Errorf("required field not found in provided jira.fields. Required are: %#v", requiredFields) + } + } + + // check templateConfig against all fields to verify they are available + for key := range config { + if _, okay := all[key]; !okay { + var availableFields []string + for name := range all { + availableFields = append(availableFields, name) + } + return false, fmt.Errorf("fields in jira.fields are not available in jira. Available are: %#v", availableFields) + } + } + + return true, nil +} diff --git a/onpremise/metaissue_test.go b/onpremise/metaissue_test.go new file mode 100644 index 00000000..a1380768 --- /dev/null +++ b/onpremise/metaissue_test.go @@ -0,0 +1,707 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "net/url" + "testing" +) + +func TestIssueService_GetEditMeta_Success(t *testing.T) { + setup() + defer teardown() + + testAPIEndpoint := "/rest/api/2/issue/PROJ-9001/editmeta" + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + + fmt.Fprint(w, `{ + "fields": { + "summary": { + "required": true, + "schema": { + "type": "string", + "system": "summary" + }, + "name": "Summary", + "hasDefaultValue": false, + "operations": [ + "set" + ] + }, + "attachment": { + "required": false, + "schema": { + "type": "array", + "items": "attachment", + "system": "attachment" + }, + "name": "Attachment", + "hasDefaultValue": false, + "operations": [ + + ] + } + } + }`) + }) + + editMeta, _, err := testClient.Issue.GetEditMeta(context.Background(), &Issue{Key: "PROJ-9001"}) + if err != nil { + t.Errorf("Expected nil error but got %s", err) + } + + requiredFields := 0 + fields := editMeta.Fields + for _, value := range fields { + for key, value := range value.(map[string]interface{}) { + if key == "required" && value == true { + requiredFields = requiredFields + 1 + } + } + + } + summary := fields["summary"].(map[string]interface{}) + attachment := fields["attachment"].(map[string]interface{}) + if summary["required"] != true { + t.Error("Expected summary to be required") + } + if attachment["required"] != false { + t.Error("Expected attachment to not be required") + } +} + +func TestIssueService_GetEditMeta_Fail(t *testing.T) { + _, _, err := testClient.Issue.GetEditMeta(context.Background(), &Issue{Key: "PROJ-9001"}) + if err == nil { + t.Error("Expected to receive an error, received nil instead") + } + + if _, ok := err.(*url.Error); !ok { + t.Errorf("Expected to receive an *url.Error, got %T instead", err) + } +} + +func TestMetaIssueType_GetCreateMeta(t *testing.T) { + setup() + defer teardown() + + testAPIEndpoint := "/rest/api/2/issue/createmeta" + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + + fmt.Fprint(w, `{ + "expand": "projects", + "projects": [{ + "expand": "issuetypes", + "self": "https://my.jira.com/rest/api/2/project/11300", + "id": "11300", + "key": "SPN", + "name": "Super Project Name", + "avatarUrls": { + "48x48": "https://my.jira.com/secure/projectavatar?pid=11300&avatarId=14405", + "24x24": "https://my.jira.com/secure/projectavatar?size=small&pid=11300&avatarId=14405", + "16x16": "https://my.jira.com/secure/projectavatar?size=xsmall&pid=11300&avatarId=14405", + "32x32": "https://my.jira.com/secure/projectavatar?size=medium&pid=11300&avatarId=14405" + }, + "issuetypes": [{ + "self": "https://my.jira.com/rest/api/2/issuetype/6", + "id": "6", + "description": "An issue which ideally should be able to be completed in one step", + "iconUrl": "https://my.jira.com/secure/viewavatar?size=xsmall&avatarId=14006&avatarType=issuetype", + "name": "Request", + "subtask": false, + "expand": "fields", + "fields": { + "summary": { + "required": true, + "schema": { + "type": "string", + "system": "summary" + }, + "name": "Summary", + "hasDefaultValue": false, + "operations": [ + "set" + ] + }, + "issuetype": { + "required": true, + "schema": { + "type": "issuetype", + "system": "issuetype" + }, + "name": "Issue Type", + "hasDefaultValue": false, + "operations": [ + + ], + "allowedValues": [{ + "self": "https://my.jira.com/rest/api/2/issuetype/6", + "id": "6", + "description": "An issue which ideally should be able to be completed in one step", + "iconUrl": "https://my.jira.com/secure/viewavatar?size=xsmall&avatarId=14006&avatarType=issuetype", + "name": "Request", + "subtask": false, + "avatarId": 14006 + }] + }, + "components": { + "required": true, + "schema": { + "type": "array", + "items": "component", + "system": "components" + }, + "name": "Component/s", + "hasDefaultValue": false, + "operations": [ + "add", + "set", + "remove" + ], + "allowedValues": [{ + "self": "https://my.jira.com/rest/api/2/component/14144", + "id": "14144", + "name": "Build automation", + "description": "Jenkins, webhooks, etc." + }, { + "self": "https://my.jira.com/rest/api/2/component/14149", + "id": "14149", + "name": "Caches and noSQL", + "description": "Cassandra, Memcached, Redis, Twemproxy, Xcache" + }, { + "self": "https://my.jira.com/rest/api/2/component/14152", + "id": "14152", + "name": "Cloud services", + "description": "AWS and similar services" + }, { + "self": "https://my.jira.com/rest/api/2/component/14147", + "id": "14147", + "name": "Code quality tools", + "description": "Code sniffer, Sonar" + }, { + "self": "https://my.jira.com/rest/api/2/component/14156", + "id": "14156", + "name": "Configuration management and provisioning", + "description": "Apache/PHP modules, Consul, Salt" + }, { + "self": "https://my.jira.com/rest/api/2/component/13606", + "id": "13606", + "name": "Cronjobs", + "description": "Cronjobs in general" + }, { + "self": "https://my.jira.com/rest/api/2/component/14150", + "id": "14150", + "name": "Data pipelines and queues", + "description": "Kafka, RabbitMq" + }, { + "self": "https://my.jira.com/rest/api/2/component/14159", + "id": "14159", + "name": "Database", + "description": "MySQL related problems" + }, { + "self": "https://my.jira.com/rest/api/2/component/14314", + "id": "14314", + "name": "Documentation" + }, { + "self": "https://my.jira.com/rest/api/2/component/14151", + "id": "14151", + "name": "Git", + "description": "Bitbucket, GitHub, GitLab, Git in general" + }, { + "self": "https://my.jira.com/rest/api/2/component/14155", + "id": "14155", + "name": "HTTP services", + "description": "CDN, HaProxy, HTTP, Varnish" + }, { + "self": "https://my.jira.com/rest/api/2/component/14154", + "id": "14154", + "name": "Job and service scheduling", + "description": "Chronos, Docker, Marathon, Mesos" + }, { + "self": "https://my.jira.com/rest/api/2/component/14158", + "id": "14158", + "name": "Legacy", + "description": "Everything related to legacy" + }, { + "self": "https://my.jira.com/rest/api/2/component/14157", + "id": "14157", + "name": "Monitoring", + "description": "Collectd, Nagios, Monitoring in general" + }, { + "self": "https://my.jira.com/rest/api/2/component/14148", + "id": "14148", + "name": "Other services" + }, { + "self": "https://my.jira.com/rest/api/2/component/13602", + "id": "13602", + "name": "Package management", + "description": "Composer, Medusa, Satis" + }, { + "self": "https://my.jira.com/rest/api/2/component/14145", + "id": "14145", + "name": "Release", + "description": "Directory config, release queries, rewrite rules" + }, { + "self": "https://my.jira.com/rest/api/2/component/14146", + "id": "14146", + "name": "Staging systems and VMs", + "description": "Stage, QA machines, KVMs,Vagrant" + }, { + "self": "https://my.jira.com/rest/api/2/component/14153", + "id": "14153", + "name": "Blog" + }, { + "self": "https://my.jira.com/rest/api/2/component/14143", + "id": "14143", + "name": "Test automation", + "description": "Testing infrastructure in general" + }, { + "self": "https://my.jira.com/rest/api/2/component/14221", + "id": "14221", + "name": "Internal Infrastructure" + }] + }, + "attachment": { + "required": false, + "schema": { + "type": "array", + "items": "attachment", + "system": "attachment" + }, + "name": "Attachment", + "hasDefaultValue": false, + "operations": [ + + ] + }, + "duedate": { + "required": false, + "schema": { + "type": "date", + "system": "duedate" + }, + "name": "Due Date", + "hasDefaultValue": false, + "operations": [ + "set" + ] + }, + "description": { + "required": false, + "schema": { + "type": "string", + "system": "description" + }, + "name": "Description", + "hasDefaultValue": false, + "operations": [ + "set" + ] + }, + "customfield_10806": { + "required": false, + "schema": { + "type": "any", + "custom": "com.pyxis.greenhopper.jira:gh-epic-link", + "customId": 10806 + }, + "name": "Epic Link", + "hasDefaultValue": false, + "operations": [ + "set" + ] + }, + "project": { + "required": true, + "schema": { + "type": "project", + "system": "project" + }, + "name": "Project", + "hasDefaultValue": false, + "operations": [ + "set" + ], + "allowedValues": [{ + "self": "https://my.jira.com/rest/api/2/project/11300", + "id": "11300", + "key": "SPN", + "name": "Super Project Name", + "avatarUrls": { + "48x48": "https://my.jira.com/secure/projectavatar?pid=11300&avatarId=14405", + "24x24": "https://my.jira.com/secure/projectavatar?size=small&pid=11300&avatarId=14405", + "16x16": "https://my.jira.com/secure/projectavatar?size=xsmall&pid=11300&avatarId=14405", + "32x32": "https://my.jira.com/secure/projectavatar?size=medium&pid=11300&avatarId=14405" + }, + "projectCategory": { + "self": "https://my.jira.com/rest/api/2/projectCategory/10100", + "id": "10100", + "description": "", + "name": "Product & Development" + } + }] + }, + "assignee": { + "required": true, + "schema": { + "type": "user", + "system": "assignee" + }, + "name": "Assignee", + "autoCompleteUrl": "https://my.jira.com/rest/api/latest/user/assignable/search?issueKey=null&username=", + "hasDefaultValue": true, + "operations": [ + "set" + ] + }, + "priority": { + "required": false, + "schema": { + "type": "priority", + "system": "priority" + }, + "name": "Priority", + "hasDefaultValue": true, + "operations": [ + "set" + ], + "allowedValues": [{ + "self": "https://my.jira.com/rest/api/2/priority/1", + "iconUrl": "https://my.jira.com/images/icons/priorities/blocker.svg", + "name": "Immediate", + "id": "1" + }, { + "self": "https://my.jira.com/rest/api/2/priority/2", + "iconUrl": "https://my.jira.com/images/icons/priorities/critical.svg", + "name": "Urgent", + "id": "2" + }, { + "self": "https://my.jira.com/rest/api/2/priority/3", + "iconUrl": "https://my.jira.com/images/icons/priorities/major.svg", + "name": "High", + "id": "3" + }, { + "self": "https://my.jira.com/rest/api/2/priority/6", + "iconUrl": "https://my.jira.com/images/icons/priorities/moderate.svg", + "name": "Moderate", + "id": "6" + }, { + "self": "https://my.jira.com/rest/api/2/priority/4", + "iconUrl": "https://my.jira.com/images/icons/priorities/minor.svg", + "name": "Normal", + "id": "4" + }, { + "self": "https://my.jira.com/rest/api/2/priority/5", + "iconUrl": "https://my.jira.com/images/icons/priorities/trivial.svg", + "name": "Low", + "id": "5" + }] + }, + "labels": { + "required": false, + "schema": { + "type": "array", + "items": "string", + "system": "labels" + }, + "name": "Labels", + "autoCompleteUrl": "https://my.jira.com/rest/api/1.0/labels/suggest?query=", + "hasDefaultValue": false, + "operations": [ + "add", + "set", + "remove" + ] + } + } + }] + }] + }`) + }) + + issue, _, err := testClient.Issue.GetCreateMeta(context.Background(), &GetQueryOptions{Expand: "projects.issuetypes.fields"}) + if err != nil { + t.Errorf("Expected nil error but got %s", err) + } + + if len(issue.Projects) != 1 { + t.Errorf("Expected 1 project, got %d", len(issue.Projects)) + } + for _, project := range issue.Projects { + if len(project.IssueTypes) != 1 { + t.Errorf("Expected 1 issueTypes, got %d", len(project.IssueTypes)) + } + for _, issueTypes := range project.IssueTypes { + requiredFields := 0 + fields := issueTypes.Fields + for _, value := range fields { + for key, value := range value.(map[string]interface{}) { + if key == "required" && value == true { + requiredFields = requiredFields + 1 + } + } + + } + if requiredFields != 5 { + t.Errorf("Expected 5 required fields from Create Meta information, got %d", requiredFields) + } + } + } +} + +func TestMetaIssueType_GetMandatoryFields(t *testing.T) { + data := make(map[string]interface{}) + + data["summary"] = map[string]interface{}{ + "required": true, + "name": "Summary", + } + + data["components"] = map[string]interface{}{ + "required": true, + "name": "Components", + } + + data["epicLink"] = map[string]interface{}{ + "required": false, + "name": "Epic Link", + } + + m := new(MetaIssueType) + m.Fields = data + + mandatory, err := m.GetMandatoryFields() + if err != nil { + t.Errorf("Expected nil error, received %s", err) + } + + if len(mandatory) != 2 { + t.Errorf("Expected 2 received %+v", mandatory) + } +} + +func TestMetaIssueType_GetMandatoryFields_NonExistentRequiredKey_Fail(t *testing.T) { + data := make(map[string]interface{}) + + data["summary"] = map[string]interface{}{ + "name": "Summary", + } + + m := new(MetaIssueType) + m.Fields = data + + _, err := m.GetMandatoryFields() + if err == nil { + t.Error("Expected non nil errpr, received nil") + } +} + +func TestMetaIssueType_GetMandatoryFields_NonExistentNameKey_Fail(t *testing.T) { + data := make(map[string]interface{}) + + data["summary"] = map[string]interface{}{ + "required": true, + } + + m := new(MetaIssueType) + m.Fields = data + + _, err := m.GetMandatoryFields() + if err == nil { + t.Error("Expected non nil errpr, received nil") + } +} + +func TestMetaIssueType_GetAllFields(t *testing.T) { + data := make(map[string]interface{}) + + data["summary"] = map[string]interface{}{ + "required": true, + "name": "Summary", + } + + data["components"] = map[string]interface{}{ + "required": true, + "name": "Components", + } + + data["epicLink"] = map[string]interface{}{ + "required": false, + "name": "Epic Link", + } + + m := new(MetaIssueType) + m.Fields = data + + mandatory, err := m.GetAllFields() + + if err != nil { + t.Errorf("Expected nil err, received %s", err) + } + + if len(mandatory) != 3 { + t.Errorf("Expected 3 received %+v", mandatory) + } +} + +func TestMetaIssueType_GetAllFields_NonExistingNameKey_Fail(t *testing.T) { + data := make(map[string]interface{}) + + data["summary"] = map[string]interface{}{ + "required": true, + } + + m := new(MetaIssueType) + m.Fields = data + + _, err := m.GetAllFields() + if err == nil { + t.Error("Expected non nil error, received nil") + } +} + +func TestMetaIssueType_CheckCompleteAndAvailable_MandatoryMissing(t *testing.T) { + data := make(map[string]interface{}) + + data["summary"] = map[string]interface{}{ + "required": true, + "name": "Summary", + } + + data["someKey"] = map[string]interface{}{ + "required": false, + "name": "SomeKey", + } + + config := map[string]string{ + "SomeKey": "somevalue", + } + + m := new(MetaIssueType) + m.Fields = data + + ok, err := m.CheckCompleteAndAvailable(config) + if err == nil { + t.Error("Expected non nil error. Received nil") + } + + if ok != false { + t.Error("Expected false, got true") + } + +} + +func TestMetaIssueType_CheckCompleteAndAvailable_NotAvailable(t *testing.T) { + data := make(map[string]interface{}) + + data["summary"] = map[string]interface{}{ + "required": true, + "name": "Summary", + } + + config := map[string]string{ + "Summary": "Issue Summary", + "SomeKey": "somevalue", + } + + m := new(MetaIssueType) + m.Fields = data + + ok, err := m.CheckCompleteAndAvailable(config) + if err == nil { + t.Error("Expected non nil error. Received nil") + } + + if ok != false { + t.Error("Expected false, got true") + } + +} + +func TestMetaIssueType_CheckCompleteAndAvailable_Success(t *testing.T) { + data := make(map[string]interface{}) + + data["summary"] = map[string]interface{}{ + "required": true, + "name": "Summary", + } + + data["someKey"] = map[string]interface{}{ + "required": false, + "name": "SomeKey", + } + + config := map[string]string{ + "SomeKey": "somevalue", + "Summary": "Issue summary", + } + + m := new(MetaIssueType) + m.Fields = data + + ok, err := m.CheckCompleteAndAvailable(config) + if err != nil { + t.Errorf("Expected nil error. Received %s", err) + } + + if ok != true { + t.Error("Expected true, got false") + } + +} + +func TestCreateMetaInfo_GetProjectWithName_Success(t *testing.T) { + metainfo := new(CreateMetaInfo) + metainfo.Projects = append(metainfo.Projects, &MetaProject{ + Name: "SPN", + }) + + project := metainfo.GetProjectWithName("SPN") + if project == nil { + t.Errorf("Expected non nil value, received nil") + } +} + +func TestMetaProject_GetIssueTypeWithName_CaseMismatch_Success(t *testing.T) { + m := new(MetaProject) + m.IssueTypes = append(m.IssueTypes, &MetaIssueType{ + Name: "Bug", + }) + + issuetype := m.GetIssueTypeWithName("BUG") + + if issuetype == nil { + t.Errorf("Expected non nil value, received nil") + } +} + +func TestCreateMetaInfo_GetProjectWithKey_Success(t *testing.T) { + metainfo := new(CreateMetaInfo) + metainfo.Projects = append(metainfo.Projects, &MetaProject{ + Key: "SPNKEY", + }) + + project := metainfo.GetProjectWithKey("SPNKEY") + if project == nil { + t.Errorf("Expected non nil value, received nil") + } +} + +func TestCreateMetaInfo_GetProjectWithKey_NilForNonExistent(t *testing.T) { + metainfo := new(CreateMetaInfo) + metainfo.Projects = append(metainfo.Projects, &MetaProject{ + Key: "SPNKEY", + }) + + project := metainfo.GetProjectWithKey("SPN") + if project != nil { + t.Errorf("Expected nil, received value") + } +} diff --git a/onpremise/organization.go b/onpremise/organization.go new file mode 100644 index 00000000..aae4d2ac --- /dev/null +++ b/onpremise/organization.go @@ -0,0 +1,369 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" +) + +// OrganizationService handles Organizations for the Jira instance / API. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/ +type OrganizationService service + +// OrganizationCreationDTO is DTO for creat organization API +type OrganizationCreationDTO struct { + Name string `json:"name,omitempty" structs:"name,omitempty"` +} + +// SelfLink Stores REST API URL to the organization. +type SelfLink struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` +} + +// Organization contains Organization data +type Organization struct { + ID string `json:"id,omitempty" structs:"id,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + Links *SelfLink `json:"_links,omitempty" structs:"_links,omitempty"` +} + +// OrganizationUsersDTO contains organization user ids +type OrganizationUsersDTO struct { + AccountIds []string `json:"accountIds,omitempty" structs:"accountIds,omitempty"` +} + +// PagedDTO is response of a paged list +type PagedDTO struct { + Size int `json:"size,omitempty" structs:"size,omitempty"` + Start int `json:"start,omitempty" structs:"start,omitempty"` + Limit int `limit:"size,omitempty" structs:"limit,omitempty"` + IsLastPage bool `json:"isLastPage,omitempty" structs:"isLastPage,omitempty"` + Values []interface{} `values:"isLastPage,omitempty" structs:"values,omitempty"` + Expands []string `json:"_expands,omitempty" structs:"_expands,omitempty"` +} + +// PropertyKey contains Property key details. +type PropertyKey struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + Key string `json:"key,omitempty" structs:"key,omitempty"` +} + +// PropertyKeys contains an array of PropertyKey +type PropertyKeys struct { + Keys []PropertyKey `json:"keys,omitempty" structs:"keys,omitempty"` +} + +// GetAllOrganizations returns a list of organizations in +// the Jira Service Management instance. +// Use this method when you want to present a list +// of organizations or want to locate an organization +// by name. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-group-organization +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) GetAllOrganizations(ctx context.Context, start int, limit int, accountID string) (*PagedDTO, *Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization?start=%d&limit=%d", start, limit) + if accountID != "" { + apiEndPoint += fmt.Sprintf("&accountId=%s", accountID) + } + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) + req.Header.Set("Accept", "application/json") + + if err != nil { + return nil, nil, err + } + + v := new(PagedDTO) + resp, err := s.client.Do(req, v) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return v, resp, nil +} + +// CreateOrganization creates an organization by +// passing the name of the organization. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-post +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) CreateOrganization(ctx context.Context, name string) (*Organization, *Response, error) { + apiEndPoint := "rest/servicedeskapi/organization" + + organization := OrganizationCreationDTO{ + Name: name, + } + + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndPoint, organization) + req.Header.Set("Accept", "application/json") + + if err != nil { + return nil, nil, err + } + + o := new(Organization) + resp, err := s.client.Do(req, &o) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return o, resp, nil +} + +// GetOrganization returns details of an +// organization. Use this method to get organization +// details whenever your application component is +// passed an organization ID but needs to display +// other organization details. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) GetOrganization(ctx context.Context, organizationID int) (*Organization, *Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d", organizationID) + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) + req.Header.Set("Accept", "application/json") + + if err != nil { + return nil, nil, err + } + + o := new(Organization) + resp, err := s.client.Do(req, &o) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return o, resp, nil +} + +// DeleteOrganization deletes an organization. Note that +// the organization is deleted regardless +// of other associations it may have. +// For example, associations with service desks. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-delete +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) DeleteOrganization(ctx context.Context, organizationID int) (*Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d", organizationID) + + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndPoint, nil) + + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} + +// GetPropertiesKeys returns the keys of +// all properties for an organization. Use this resource +// when you need to find out what additional properties +// items have been added to an organization. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-property-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) GetPropertiesKeys(ctx context.Context, organizationID int) (*PropertyKeys, *Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/property", organizationID) + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) + req.Header.Set("Accept", "application/json") + + if err != nil { + return nil, nil, err + } + + pk := new(PropertyKeys) + resp, err := s.client.Do(req, &pk) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return pk, resp, nil +} + +// GetProperty returns the value of a property +// from an organization. Use this method to obtain the JSON +// content for an organization's property. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-property-propertykey-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) GetProperty(ctx context.Context, organizationID int, propertyKey string) (*EntityProperty, *Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/property/%s", organizationID, propertyKey) + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) + req.Header.Set("Accept", "application/json") + + if err != nil { + return nil, nil, err + } + + ep := new(EntityProperty) + resp, err := s.client.Do(req, &ep) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return ep, resp, nil +} + +// SetProperty sets the value of a +// property for an organization. Use this +// resource to store custom data against an organization. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-property-propertykey-put +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) SetProperty(ctx context.Context, organizationID int, propertyKey string) (*Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/property/%s", organizationID, propertyKey) + + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndPoint, nil) + req.Header.Set("Accept", "application/json") + + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} + +// DeleteProperty removes a property from an organization. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-property-propertykey-delete +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) DeleteProperty(ctx context.Context, organizationID int, propertyKey string) (*Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/property/%s", organizationID, propertyKey) + + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndPoint, nil) + req.Header.Set("Accept", "application/json") + + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} + +// GetUsers returns all the users +// associated with an organization. Use this +// method where you want to provide a list of +// users for an organization or determine if +// a user is associated with an organization. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-user-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) GetUsers(ctx context.Context, organizationID int, start int, limit int) (*PagedDTO, *Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/user?start=%d&limit=%d", organizationID, start, limit) + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) + req.Header.Set("Accept", "application/json") + + if err != nil { + return nil, nil, err + } + + users := new(PagedDTO) + resp, err := s.client.Do(req, &users) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return users, resp, nil +} + +// AddUsers adds users to an organization. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-user-post +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) AddUsers(ctx context.Context, organizationID int, users OrganizationUsersDTO) (*Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/user", organizationID) + + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndPoint, users) + + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} + +// RemoveUsers removes users from an organization. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-organization-organizationid-user-delete +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *OrganizationService) RemoveUsers(ctx context.Context, organizationID int, users OrganizationUsersDTO) (*Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/organization/%d/user", organizationID) + + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndPoint, nil) + req.Header.Set("Accept", "application/json") + + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} diff --git a/onpremise/organization_test.go b/onpremise/organization_test.go new file mode 100644 index 00000000..a4e58267 --- /dev/null +++ b/onpremise/organization_test.go @@ -0,0 +1,326 @@ +package onpremise + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" +) + +func TestOrganizationService_GetAllOrganizations(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/organization", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/servicedeskapi/organization") + + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{ "_expands": [], "size": 1, "start": 1, "limit": 1, "isLastPage": false, "_links": { "base": "https://your-domain.atlassian.net/rest/servicedeskapi", "context": "context", "next": "https://your-domain.atlassian.net/rest/servicedeskapi/organization?start=2&limit=1", "prev": "https://your-domain.atlassian.net/rest/servicedeskapi/organization?start=0&limit=1" }, "values": [ { "id": "1", "name": "Charlie Cakes Franchises", "_links": { "self": "https://your-domain.atlassian.net/rest/servicedeskapi/organization/1" } } ] }`) + }) + + result, _, err := testClient.Organization.GetAllOrganizations(context.Background(), 0, 50, "") + + if result == nil { + t.Error("Expected Organizations. Result is nil") + } else if result.Size != 1 { + t.Errorf("Expected size to be 1, but got %d", result.Size) + } + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestOrganizationService_CreateOrganization(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/organization", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/servicedeskapi/organization") + + o := new(OrganizationCreationDTO) + json.NewDecoder(r.Body).Decode(&o) + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{ "id": "1", "name": "%s", "_links": { "self": "https://your-domain.atlassian.net/rest/servicedeskapi/organization/1" } }`, o.Name) + }) + + name := "MyOrg" + o, _, err := testClient.Organization.CreateOrganization(context.Background(), name) + + if o == nil { + t.Error("Expected Organization. Result is nil") + } else if o.Name != name { + t.Errorf("Expected name to be %s, but got %s", name, o.Name) + } + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestOrganizationService_GetOrganization(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/organization/1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/servicedeskapi/organization/1") + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ "id": "1", "name": "name", "_links": { "self": "https://your-domain.atlassian.net/rest/servicedeskapi/organization/1" } }`) + }) + + id := 1 + o, _, err := testClient.Organization.GetOrganization(context.Background(), id) + + if err != nil { + t.Errorf("Error given: %s", err) + } + + if o == nil { + t.Error("Expected Organization. Result is nil") + } else if o.Name != "name" { + t.Errorf("Expected name to be name, but got %s", o.Name) + } +} + +func TestOrganizationService_DeleteOrganization(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/organization/1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/servicedeskapi/organization/1") + + w.WriteHeader(http.StatusNoContent) + }) + + _, err := testClient.Organization.DeleteOrganization(context.Background(), 1) + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestOrganizationService_GetPropertiesKeys(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/organization/1/property", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/servicedeskapi/organization/1/property") + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "keys": [ + { + "self": "/rest/servicedeskapi/organization/1/property/propertyKey", + "key": "organization.attributes" + } + ] + }`) + }) + + pk, _, err := testClient.Organization.GetPropertiesKeys(context.Background(), 1) + + if err != nil { + t.Errorf("Error given: %s", err) + } + + if pk == nil { + t.Error("Expected Keys. Result is nil") + } else if pk.Keys[0].Key != "organization.attributes" { + t.Errorf("Expected name to be organization.attributes, but got %s", pk.Keys[0].Key) + } +} + +func TestOrganizationService_GetProperty(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/organization/1/property/organization.attributes", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/servicedeskapi/organization/1/property/organization.attributes") + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "key": "organization.attributes", + "value": { + "phone": "0800-1233456789", + "mail": "charlie@example.com" + } + }`) + }) + + key := "organization.attributes" + ep, _, err := testClient.Organization.GetProperty(context.Background(), 1, key) + + if err != nil { + t.Errorf("Error given: %s", err) + } + + if ep == nil { + t.Error("Expected Entity. Result is nil") + } else if ep.Key != key { + t.Errorf("Expected name to be %s, but got %s", key, ep.Key) + } +} + +func TestOrganizationService_SetProperty(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/organization/1/property/organization.attributes", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPut) + testRequestURL(t, r, "/rest/servicedeskapi/organization/1/property/organization.attributes") + + w.WriteHeader(http.StatusOK) + }) + + key := "organization.attributes" + _, err := testClient.Organization.SetProperty(context.Background(), 1, key) + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestOrganizationService_DeleteProperty(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/organization/1/property/organization.attributes", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/servicedeskapi/organization/1/property/organization.attributes") + + w.WriteHeader(http.StatusOK) + }) + + key := "organization.attributes" + _, err := testClient.Organization.DeleteProperty(context.Background(), 1, key) + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestOrganizationService_GetUsers(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/organization/1/user", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/servicedeskapi/organization/1/user") + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "_expands": [], + "size": 1, + "start": 1, + "limit": 1, + "isLastPage": false, + "_links": { + "base": "https://your-domain.atlassian.net/rest/servicedeskapi", + "context": "context", + "next": "https://your-domain.atlassian.net/rest/servicedeskapi/organization/1/user?start=2&limit=1", + "prev": "https://your-domain.atlassian.net/rest/servicedeskapi/organization/1/user?start=0&limit=1" + }, + "values": [ + { + "accountId": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "name": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "key": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "emailAddress": "fred@example.com", + "displayName": "Fred F. User", + "active": true, + "timeZone": "Australia/Sydney", + "_links": { + "jiraRest": "https://your-domain.atlassian.net/rest/api/2/user?username=qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=48", + "24x24": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=24", + "16x16": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=16", + "32x32": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=32" + }, + "self": "https://your-domain.atlassian.net/rest/api/2/user?username=qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b" + } + }, + { + "accountId": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3a01db05e2a66fa80bd", + "name": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3a01db05e2a66fa80bd", + "key": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3a01db05e2a66fa80bd", + "emailAddress": "bob@example.com", + "displayName": "Bob D. Builder", + "active": true, + "timeZone": "Australia/Sydney", + "_links": { + "jiraRest": "https://your-domain.atlassian.net/rest/api/2/user?username=qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3a01db05e2a66fa80bd", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=48", + "24x24": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=24", + "16x16": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=16", + "32x32": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=32" + }, + "self": "https://your-domain.atlassian.net/rest/api/2/user?username=qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3a01db05e2a66fa80bd" + } + } + ] + }`) + }) + + users, _, err := testClient.Organization.GetUsers(context.Background(), 1, 0, 50) + + if err != nil { + t.Errorf("Error given: %s", err) + } + + if users == nil { + t.Error("Expected Organizations. Result is nil") + } else if users.Size != 1 { + t.Errorf("Expected size to be 1, but got %d", users.Size) + } + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestOrganizationService_AddUsers(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/organization/1/user", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/servicedeskapi/organization/1/user") + + w.WriteHeader(http.StatusNoContent) + }) + + users := OrganizationUsersDTO{ + AccountIds: []string{ + "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3a01db05e2a66fa80bd", + }, + } + _, err := testClient.Organization.AddUsers(context.Background(), 1, users) + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestOrganizationService_RemoveUsers(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/organization/1/user", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/servicedeskapi/organization/1/user") + + w.WriteHeader(http.StatusNoContent) + }) + + users := OrganizationUsersDTO{ + AccountIds: []string{ + "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3a01db05e2a66fa80bd", + }, + } + _, err := testClient.Organization.RemoveUsers(context.Background(), 1, users) + + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/onpremise/permissionscheme.go b/onpremise/permissionscheme.go new file mode 100644 index 00000000..905ed5f8 --- /dev/null +++ b/onpremise/permissionscheme.go @@ -0,0 +1,78 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" +) + +// PermissionSchemeService handles permissionschemes for the Jira instance / API. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-group-Permissionscheme +type PermissionSchemeService service + +type PermissionSchemes struct { + PermissionSchemes []PermissionScheme `json:"permissionSchemes" structs:"permissionSchemes"` +} + +type Permission struct { + ID int `json:"id" structs:"id"` + Self string `json:"expand" structs:"expand"` + Holder Holder `json:"holder" structs:"holder"` + Name string `json:"permission" structs:"permission"` +} + +type Holder struct { + Type string `json:"type" structs:"type"` + Parameter string `json:"parameter" structs:"parameter"` + Expand string `json:"expand" structs:"expand"` +} + +// GetList returns a list of all permission schemes +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-api-3-permissionscheme-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *PermissionSchemeService) GetList(ctx context.Context) (*PermissionSchemes, *Response, error) { + apiEndpoint := "/rest/api/3/permissionscheme" + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + pss := new(PermissionSchemes) + resp, err := s.client.Do(req, &pss) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return pss, resp, nil +} + +// Get returns a full representation of the permission scheme for the schemeID +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-api-3-permissionscheme-schemeId-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *PermissionSchemeService) Get(ctx context.Context, schemeID int) (*PermissionScheme, *Response, error) { + apiEndpoint := fmt.Sprintf("/rest/api/3/permissionscheme/%d", schemeID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + ps := new(PermissionScheme) + resp, err := s.client.Do(req, ps) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + if ps.Self == "" { + return nil, resp, fmt.Errorf("no permissionscheme with ID %d found", schemeID) + } + + return ps, resp, nil +} diff --git a/onpremise/permissionscheme_test.go b/onpremise/permissionscheme_test.go new file mode 100644 index 00000000..1b731ab2 --- /dev/null +++ b/onpremise/permissionscheme_test.go @@ -0,0 +1,107 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestPermissionSchemeService_GetList(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/3/permissionscheme" + + raw, err := os.ReadFile("../testing/mock-data/all_permissionschemes.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + permissionScheme, _, err := testClient.PermissionScheme.GetList(context.Background()) + if err != nil { + t.Errorf("Error given: %v", err) + } + if permissionScheme == nil { + t.Error("Expected permissionScheme list. PermissionScheme list is nil") + return + } + if len(permissionScheme.PermissionSchemes) != 2 { + t.Errorf("Expected %d permissionSchemes but got %d", 2, len(permissionScheme.PermissionSchemes)) + } +} + +func TestPermissionSchemeService_GetList_NoList(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/3/permissionscheme" + + raw, err := os.ReadFile("../testing/mock-data/no_permissionschemes.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + permissionScheme, _, err := testClient.PermissionScheme.GetList(context.Background()) + if permissionScheme != nil { + t.Errorf("Expected permissionScheme list has %d entries but should be nil", len(permissionScheme.PermissionSchemes)) + } + if err == nil { + t.Errorf("No error given") + } +} + +func TestPermissionSchemeService_Get(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/3/permissionscheme/10100" + raw, err := os.ReadFile("../testing/mock-data/permissionscheme.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testapiEndpoint) + fmt.Fprint(writer, string(raw)) + }) + + permissionScheme, _, err := testClient.PermissionScheme.Get(context.Background(), 10100) + if permissionScheme == nil { + t.Errorf("Expected permissionscheme, got nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestPermissionSchemeService_Get_NoScheme(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/3/permissionscheme/99999" + raw, err := os.ReadFile("../testing/mock-data/no_permissionscheme.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testapiEndpoint) + fmt.Fprint(writer, string(raw)) + }) + + permissionScheme, _, err := testClient.PermissionScheme.Get(context.Background(), 99999) + if permissionScheme != nil { + t.Errorf("Expected nil, got permissionschme %v", permissionScheme) + } + if err == nil { + t.Errorf("No error given") + } +} diff --git a/onpremise/priority.go b/onpremise/priority.go new file mode 100644 index 00000000..20de68a2 --- /dev/null +++ b/onpremise/priority.go @@ -0,0 +1,43 @@ +package onpremise + +import ( + "context" + "net/http" +) + +// PriorityService handles priorities for the Jira instance / API. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-Priority +type PriorityService service + +// Priority represents a priority of a Jira issue. +// Typical types are "Normal", "Moderate", "Urgent", ... +type Priority struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + IconURL string `json:"iconUrl,omitempty" structs:"iconUrl,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + ID string `json:"id,omitempty" structs:"id,omitempty"` + StatusColor string `json:"statusColor,omitempty" structs:"statusColor,omitempty"` + Description string `json:"description,omitempty" structs:"description,omitempty"` +} + +// GetList gets all priorities from Jira +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-priority-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *PriorityService) GetList(ctx context.Context) ([]Priority, *Response, error) { + apiEndpoint := "rest/api/2/priority" + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + priorityList := []Priority{} + resp, err := s.client.Do(req, &priorityList) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + return priorityList, resp, nil +} diff --git a/onpremise/priority_test.go b/onpremise/priority_test.go new file mode 100644 index 00000000..70087814 --- /dev/null +++ b/onpremise/priority_test.go @@ -0,0 +1,33 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestPriorityService_GetList(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/priority" + + raw, err := os.ReadFile("../testing/mock-data/all_priorities.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, string(raw)) + }) + + priorities, _, err := testClient.Priority.GetList(context.Background()) + if priorities == nil { + t.Error("Expected priority list. Priority list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/onpremise/project.go b/onpremise/project.go new file mode 100644 index 00000000..4e38da6e --- /dev/null +++ b/onpremise/project.go @@ -0,0 +1,163 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + + "github.com/google/go-querystring/query" +) + +// ProjectService handles projects for the Jira instance / API. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/project +type ProjectService service + +// ProjectList represent a list of Projects +type ProjectList []struct { + Expand string `json:"expand" structs:"expand"` + Self string `json:"self" structs:"self"` + ID string `json:"id" structs:"id"` + Key string `json:"key" structs:"key"` + Name string `json:"name" structs:"name"` + AvatarUrls AvatarUrls `json:"avatarUrls" structs:"avatarUrls"` + ProjectTypeKey string `json:"projectTypeKey" structs:"projectTypeKey"` + ProjectCategory ProjectCategory `json:"projectCategory,omitempty" structs:"projectsCategory,omitempty"` + IssueTypes []IssueType `json:"issueTypes,omitempty" structs:"issueTypes,omitempty"` +} + +// ProjectCategory represents a single project category +type ProjectCategory struct { + Self string `json:"self" structs:"self,omitempty"` + ID string `json:"id" structs:"id,omitempty"` + Name string `json:"name" structs:"name,omitempty"` + Description string `json:"description" structs:"description,omitempty"` +} + +// Project represents a Jira Project. +type Project struct { + Expand string `json:"expand,omitempty" structs:"expand,omitempty"` + Self string `json:"self,omitempty" structs:"self,omitempty"` + ID string `json:"id,omitempty" structs:"id,omitempty"` + Key string `json:"key,omitempty" structs:"key,omitempty"` + Description string `json:"description,omitempty" structs:"description,omitempty"` + Lead User `json:"lead,omitempty" structs:"lead,omitempty"` + Components []ProjectComponent `json:"components,omitempty" structs:"components,omitempty"` + IssueTypes []IssueType `json:"issueTypes,omitempty" structs:"issueTypes,omitempty"` + URL string `json:"url,omitempty" structs:"url,omitempty"` + Email string `json:"email,omitempty" structs:"email,omitempty"` + AssigneeType string `json:"assigneeType,omitempty" structs:"assigneeType,omitempty"` + Versions []Version `json:"versions,omitempty" structs:"versions,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + Roles map[string]string `json:"roles,omitempty" structs:"roles,omitempty"` + AvatarUrls AvatarUrls `json:"avatarUrls,omitempty" structs:"avatarUrls,omitempty"` + ProjectCategory ProjectCategory `json:"projectCategory,omitempty" structs:"projectCategory,omitempty"` +} + +// ProjectComponent represents a single component of a project +type ProjectComponent struct { + Self string `json:"self" structs:"self,omitempty"` + ID string `json:"id" structs:"id,omitempty"` + Name string `json:"name" structs:"name,omitempty"` + Description string `json:"description" structs:"description,omitempty"` + Lead User `json:"lead,omitempty" structs:"lead,omitempty"` + AssigneeType string `json:"assigneeType" structs:"assigneeType,omitempty"` + Assignee User `json:"assignee" structs:"assignee,omitempty"` + RealAssigneeType string `json:"realAssigneeType" structs:"realAssigneeType,omitempty"` + RealAssignee User `json:"realAssignee" structs:"realAssignee,omitempty"` + IsAssigneeTypeValid bool `json:"isAssigneeTypeValid" structs:"isAssigneeTypeValid,omitempty"` + Project string `json:"project" structs:"project,omitempty"` + ProjectID int `json:"projectId" structs:"projectId,omitempty"` +} + +// PermissionScheme represents the permission scheme for the project +type PermissionScheme struct { + Expand string `json:"expand" structs:"expand,omitempty"` + Self string `json:"self" structs:"self,omitempty"` + ID int `json:"id" structs:"id,omitempty"` + Name string `json:"name" structs:"name,omitempty"` + Description string `json:"description" structs:"description,omitempty"` + Permissions []Permission `json:"permissions" structs:"permissions,omitempty"` +} + +// GetAll returns all projects form Jira with optional query params, like &GetQueryOptions{Expand: "issueTypes"} to get +// a list of all projects and their supported issuetypes. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-projects/#api-rest-api-2-project-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ProjectService) GetAll(ctx context.Context, options *GetQueryOptions) (*ProjectList, *Response, error) { + apiEndpoint := "rest/api/2/project" + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + if options != nil { + q, err := query.Values(options) + if err != nil { + return nil, nil, err + } + req.URL.RawQuery = q.Encode() + } + + projectList := new(ProjectList) + resp, err := s.client.Do(req, projectList) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return projectList, resp, nil +} + +// Get returns a full representation of the project for the given issue key. +// Jira will attempt to identify the project by the projectIdOrKey path parameter. +// This can be an project id, or an project key. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/project-getProject +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ProjectService) Get(ctx context.Context, projectID string) (*Project, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/project/%s", projectID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + project := new(Project) + resp, err := s.client.Do(req, project) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return project, resp, nil +} + +// GetPermissionScheme returns a full representation of the permission scheme for the project +// Jira will attempt to identify the project by the projectIdOrKey path parameter. +// This can be an project id, or an project key. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/project-getProject +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ProjectService) GetPermissionScheme(ctx context.Context, projectID string) (*PermissionScheme, *Response, error) { + apiEndpoint := fmt.Sprintf("/rest/api/2/project/%s/permissionscheme", projectID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + ps := new(PermissionScheme) + resp, err := s.client.Do(req, ps) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return ps, resp, nil +} diff --git a/onpremise/project_test.go b/onpremise/project_test.go new file mode 100644 index 00000000..9d17757a --- /dev/null +++ b/onpremise/project_test.go @@ -0,0 +1,139 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestProjectService_GetAll(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/project" + + raw, err := os.ReadFile("../testing/mock-data/all_projects.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/project?expand=issueTypes") + fmt.Fprint(w, string(raw)) + }) + + projects, _, err := testClient.Project.GetAll(context.Background(), &GetQueryOptions{Expand: "issueTypes"}) + if projects == nil { + t.Error("Expected project list. Project list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestProjectService_Get(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/project/12310505" + + raw, err := os.ReadFile("../testing/mock-data/project.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, string(raw)) + }) + + projects, _, err := testClient.Project.Get(context.Background(), "12310505") + if err != nil { + t.Errorf("Error given: %s", err) + } + if projects == nil { + t.Error("Expected project list. Project list is nil") + return + } + if len(projects.Roles) != 9 { + t.Errorf("Expected 9 roles but got %d", len(projects.Roles)) + } +} + +func TestProjectService_Get_NoProject(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/project/99999999" + + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, nil) + }) + + projects, resp, err := testClient.Project.Get(context.Background(), "99999999") + if projects != nil { + t.Errorf("Expected nil. Got %+v", projects) + } + + if resp.Status == "404" { + t.Errorf("Expected status 404. Got %s", resp.Status) + } + if err == nil { + t.Errorf("Error given: %s", err) + } +} + +func TestProjectService_GetPermissionScheme_Failure(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/project/99999999/permissionscheme" + + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, nil) + }) + + permissionScheme, resp, err := testClient.Project.GetPermissionScheme(context.Background(), "99999999") + if permissionScheme != nil { + t.Errorf("Expected nil. Got %+v", permissionScheme) + } + + if resp.Status == "404" { + t.Errorf("Expected status 404. Got %s", resp.Status) + } + if err == nil { + t.Errorf("Error given: %s", err) + } +} + +func TestProjectService_GetPermissionScheme_Success(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/project/99999999/permissionscheme" + + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, `{ + "expand": "permissions,user,group,projectRole,field,all", + "id": 10201, + "self": "https://www.example.com/rest/api/2/permissionscheme/10201", + "name": "Project for specific-users", + "description": "Projects that can only see for people belonging to specific-users group" + }`) + }) + + permissionScheme, resp, err := testClient.Project.GetPermissionScheme(context.Background(), "99999999") + if permissionScheme.ID != 10201 { + t.Errorf("Expected Permission Scheme ID. Got %+v", permissionScheme) + } + + if resp.Status == "404" { + t.Errorf("Expected status 404. Got %s", resp.Status) + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/onpremise/request.go b/onpremise/request.go new file mode 100644 index 00000000..aac3c556 --- /dev/null +++ b/onpremise/request.go @@ -0,0 +1,118 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" +) + +// RequestService handles ServiceDesk customer requests for the Jira instance / API. +type RequestService service + +// Request represents a ServiceDesk customer request. +type Request struct { + IssueID string `json:"issueId,omitempty" structs:"issueId,omitempty"` + IssueKey string `json:"issueKey,omitempty" structs:"issueKey,omitempty"` + TypeID string `json:"requestTypeId,omitempty" structs:"requestTypeId,omitempty"` + ServiceDeskID string `json:"serviceDeskId,omitempty" structs:"serviceDeskId,omitempty"` + Reporter *Customer `json:"reporter,omitempty" structs:"reporter,omitempty"` + FieldValues []RequestFieldValue `json:"requestFieldValues,omitempty" structs:"requestFieldValues,omitempty"` + Status *RequestStatus `json:"currentStatus,omitempty" structs:"currentStatus,omitempty"` + Links *SelfLink `json:"_links,omitempty" structs:"_links,omitempty"` + Expands []string `json:"_expands,omitempty" structs:"_expands,omitempty"` +} + +// RequestFieldValue is a request field. +type RequestFieldValue struct { + FieldID string `json:"fieldId,omitempty" structs:"fieldId,omitempty"` + Label string `json:"label,omitempty" structs:"label,omitempty"` + Value string `json:"value,omitempty" structs:"value,omitempty"` +} + +// RequestDate is the date format used in requests. +type RequestDate struct { + ISO8601 string `json:"iso8601,omitempty" structs:"iso8601,omitempty"` + Jira string `json:"jira,omitempty" structs:"jira,omitempty"` + Friendly string `json:"friendly,omitempty" structs:"friendly,omitempty"` + Epoch int64 `json:"epoch,omitempty" structs:"epoch,omitempty"` +} + +// RequestStatus is the status for a request. +type RequestStatus struct { + Status string + Category string + Date RequestDate +} + +// RequestComment is a comment for a request. +type RequestComment struct { + ID string `json:"id,omitempty" structs:"id,omitempty"` + Body string `json:"body,omitempty" structs:"body,omitempty"` + Public bool `json:"public" structs:"public"` + Author *Customer `json:"author,omitempty" structs:"author,omitempty"` + Created *RequestDate `json:"created,omitempty" structs:"created,omitempty"` + Links *SelfLink `json:"_links,omitempty" structs:"_links,omitempty"` + Expands []string `json:"_expands,omitempty" structs:"_expands,omitempty"` +} + +// Create creates a new request. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-request/#api-rest-servicedeskapi-request-post +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (r *RequestService) Create(ctx context.Context, requester string, participants []string, request *Request) (*Request, *Response, error) { + apiEndpoint := "rest/servicedeskapi/request" + + payload := struct { + *Request + FieldValues map[string]string `json:"requestFieldValues,omitempty"` + Requester string `json:"raiseOnBehalfOf,omitempty"` + Participants []string `json:"requestParticipants,omitempty"` + }{ + Request: request, + FieldValues: make(map[string]string), + Requester: requester, + Participants: participants, + } + + for _, field := range request.FieldValues { + payload.FieldValues[field.FieldID] = field.Value + } + + req, err := r.client.NewRequest(ctx, http.MethodPost, apiEndpoint, payload) + if err != nil { + return nil, nil, err + } + + responseRequest := new(Request) + resp, err := r.client.Do(req, responseRequest) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return responseRequest, resp, nil +} + +// CreateComment creates a comment on a request. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-request/#api-rest-servicedeskapi-request-issueidorkey-comment-post +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (r *RequestService) CreateComment(ctx context.Context, issueIDOrKey string, comment *RequestComment) (*RequestComment, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/servicedeskapi/request/%v/comment", issueIDOrKey) + + req, err := r.client.NewRequest(ctx, http.MethodPost, apiEndpoint, comment) + if err != nil { + return nil, nil, err + } + + responseComment := new(RequestComment) + resp, err := r.client.Do(req, responseComment) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return responseComment, resp, nil +} diff --git a/onpremise/request_test.go b/onpremise/request_test.go new file mode 100644 index 00000000..0adc6344 --- /dev/null +++ b/onpremise/request_test.go @@ -0,0 +1,200 @@ +package onpremise + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func TestRequestService_Create(t *testing.T) { + setup() + defer teardown() + + var ( + wantRequester = "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b" + gotRequester string + + wantParticipants = []string{ + "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + } + gotParticipants []string + ) + + testMux.HandleFunc("/rest/servicedeskapi/request", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/servicedeskapi/request") + + var payload struct { + Requester string `json:"raiseOnBehalfOf,omitempty"` + Participants []string `json:"requestParticipants,omitempty"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + + gotRequester = payload.Requester + gotParticipants = payload.Participants + + w.Write([]byte(`{ + "_expands": [ + "participant", + "status", + "sla", + "requestType", + "serviceDesk", + "attachment", + "action", + "comment" + ], + "issueId": "107001", + "issueKey": "HELPDESK-1", + "requestTypeId": "25", + "serviceDeskId": "10", + "createdDate": { + "iso8601": "2015-10-08T14:42:00+0700", + "jira": "2015-10-08T14:42:00.000+0700", + "friendly": "Monday 14:42 PM", + "epochMillis": 1444290120000 + }, + "reporter": { + "accountId": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "name": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "key": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "emailAddress": "fred@example.com", + "displayName": "Fred F. User", + "active": true, + "timeZone": "Australia/Sydney", + "_links": { + "jiraRest": "https://your-domain.atlassian.net/rest/api/2/user?username=qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9bc3b5bcb0db050c6d7660b28a5b86c9%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", + "24x24": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9bc3b5bcb0db050c6d7660b28a5b86c9%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", + "16x16": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9bc3b5bcb0db050c6d7660b28a5b86c9%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", + "32x32": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9bc3b5bcb0db050c6d7660b28a5b86c9%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" + }, + "self": "https://your-domain.atlassian.net/rest/api/2/user?username=qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b" + } + }, + "requestFieldValues": [ + { + "fieldId": "summary", + "label": "What do you need?", + "value": "Request JSD help via REST" + }, + { + "fieldId": "description", + "label": "Why do you need this?", + "value": "I need a new *mouse* for my Mac", + "renderedValue": { + "html": "

I need a new mouse for my Mac

" + } + } + ], + "currentStatus": { + "status": "Waiting for Support", + "statusCategory": "NEW", + "statusDate": { + "iso8601": "2015-10-08T14:01:00+0700", + "jira": "2015-10-08T14:01:00.000+0700", + "friendly": "Today 14:01 PM", + "epochMillis": 1444287660000 + } + }, + "_links": { + "jiraRest": "https://your-domain.atlassian.net/rest/api/2/issue/107001", + "web": "https://your-domain.atlassian.net/servicedesk/customer/portal/10/HELPDESK-1", + "self": "https://your-domain.atlassian.net/rest/servicedeskapi/request/107001", + "agent": "https://your-domain.atlassian.net/browse/HELPDESK-1" + } + }`)) + }) + + request := &Request{ + ServiceDeskID: "10", + TypeID: "25", + FieldValues: []RequestFieldValue{ + { + FieldID: "summary", + Value: "Request JSD help via REST", + }, + { + FieldID: "description", + Value: "I need a new *mouse* for my Mac", + }, + }, + } + + _, _, err := testClient.Request.Create(context.Background(), wantRequester, wantParticipants, request) + if err != nil { + t.Fatal(err) + } + + if wantRequester != gotRequester { + t.Fatalf("want requester: %q, got %q", wantRequester, gotRequester) + } + + if !reflect.DeepEqual(wantParticipants, gotParticipants) { + t.Fatalf("want participants: %v, got %v", wantParticipants, gotParticipants) + } +} + +func TestRequestService_CreateComment(t *testing.T) { + setup() + defer teardown() + + testMux.HandleFunc("/rest/servicedeskapi/request/HELPDESK-1/comment", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/servicedeskapi/request/HELPDESK-1/comment") + + w.Write([]byte(`{ + "_expands": [ + "attachment", + "renderedBody" + ], + "id": "1000", + "body": "Hello there", + "public": true, + "author": { + "accountId": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "name": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "key": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "emailAddress": "fred@example.com", + "displayName": "Fred F. User", + "active": true, + "timeZone": "Australia/Sydney", + "_links": { + "jiraRest": "https://your-domain.atlassian.net/rest/api/2/user?username=qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9bc3b5bcb0db050c6d7660b28a5b86c9%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", + "24x24": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9bc3b5bcb0db050c6d7660b28a5b86c9%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", + "16x16": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9bc3b5bcb0db050c6d7660b28a5b86c9%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", + "32x32": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9bc3b5bcb0db050c6d7660b28a5b86c9%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" + }, + "self": "https://your-domain.atlassian.net/rest/api/2/user?username=qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b" + } + }, + "created": { + "iso8601": "2015-10-09T10:22:00+0700", + "jira": "2015-10-09T10:22:00.000+0700", + "friendly": "Today 10:22 AM", + "epochMillis": 1444360920000 + }, + "_links": { + "self": "https://your-domain.atlassian.net/rest/servicedeskapi/request/2000/comment/1000" + } + }`)) + }) + + comment := &RequestComment{ + Body: "Hello there", + Public: true, + } + + _, _, err := testClient.Request.CreateComment(context.Background(), "HELPDESK-1", comment) + if err != nil { + t.Fatal(err) + } +} diff --git a/onpremise/resolution.go b/onpremise/resolution.go new file mode 100644 index 00000000..cfca9482 --- /dev/null +++ b/onpremise/resolution.go @@ -0,0 +1,41 @@ +package onpremise + +import ( + "context" + "net/http" +) + +// ResolutionService handles resolutions for the Jira instance / API. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-Resolution +type ResolutionService service + +// Resolution represents a resolution of a Jira issue. +// Typical types are "Fixed", "Suspended", "Won't Fix", ... +type Resolution struct { + Self string `json:"self" structs:"self"` + ID string `json:"id" structs:"id"` + Description string `json:"description" structs:"description"` + Name string `json:"name" structs:"name"` +} + +// GetList gets all resolutions from Jira +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-resolution-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ResolutionService) GetList(ctx context.Context) ([]Resolution, *Response, error) { + apiEndpoint := "rest/api/2/resolution" + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + resolutionList := []Resolution{} + resp, err := s.client.Do(req, &resolutionList) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + return resolutionList, resp, nil +} diff --git a/onpremise/resolution_test.go b/onpremise/resolution_test.go new file mode 100644 index 00000000..378ec5be --- /dev/null +++ b/onpremise/resolution_test.go @@ -0,0 +1,33 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestResolutionService_GetList(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/resolution" + + raw, err := os.ReadFile("../testing/mock-data/all_resolutions.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, string(raw)) + }) + + resolution, _, err := testClient.Resolution.GetList(context.Background()) + if resolution == nil { + t.Error("Expected resolution list. Resolution list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/onpremise/role.go b/onpremise/role.go new file mode 100644 index 00000000..1ee873a8 --- /dev/null +++ b/onpremise/role.go @@ -0,0 +1,82 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" +) + +// RoleService handles roles for the Jira instance / API. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-group-Role +type RoleService service + +// Role represents a Jira product role +type Role struct { + Self string `json:"self" structs:"self"` + Name string `json:"name" structs:"name"` + ID int `json:"id" structs:"id"` + Description string `json:"description" structs:"description"` + Actors []*Actor `json:"actors" structs:"actors"` +} + +// Actor represents a Jira actor +type Actor struct { + ID int `json:"id" structs:"id"` + DisplayName string `json:"displayName" structs:"displayName"` + Type string `json:"type" structs:"type"` + Name string `json:"name" structs:"name"` + AvatarURL string `json:"avatarUrl" structs:"avatarUrl"` + ActorUser *ActorUser `json:"actorUser" structs:"actoruser"` +} + +// ActorUser contains the account id of the actor/user +type ActorUser struct { + AccountID string `json:"accountId" structs:"accountId"` +} + +// GetList returns a list of all available project roles +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-api-3-role-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *RoleService) GetList(ctx context.Context) (*[]Role, *Response, error) { + apiEndpoint := "rest/api/3/role" + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + roles := new([]Role) + resp, err := s.client.Do(req, roles) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + return roles, resp, err +} + +// Get retreives a single Role from Jira +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-api-3-role-id-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *RoleService) Get(ctx context.Context, roleID int) (*Role, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/3/role/%d", roleID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + role := new(Role) + resp, err := s.client.Do(req, role) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + if role.Self == "" { + return nil, resp, fmt.Errorf("no role with ID %d found", roleID) + } + + return role, resp, err +} diff --git a/onpremise/role_test.go b/onpremise/role_test.go new file mode 100644 index 00000000..f8760359 --- /dev/null +++ b/onpremise/role_test.go @@ -0,0 +1,108 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestRoleService_GetList_NoList(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/3/role" + + raw, err := os.ReadFile("../testing/mock-data/no_roles.json") + if err != nil { + t.Error(err.Error()) + } + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + roles, _, err := testClient.Role.GetList(context.Background()) + if roles != nil { + t.Errorf("Expected role list has %d entries but should be nil", len(*roles)) + } + if err == nil { + t.Errorf("No error given") + } +} + +func TestRoleService_GetList(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/3/role" + + raw, err := os.ReadFile("../testing/mock-data/all_roles.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + roles, _, err := testClient.Role.GetList(context.Background()) + if err != nil { + t.Errorf("Error given: %v", err) + } + if roles == nil { + t.Error("Expected role list. Role list is nil") + return + } + if len(*roles) != 2 { + t.Errorf("Expected %d roles but got %d", 2, len(*roles)) + } +} + +func TestRoleService_Get_NoRole(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/3/role/99999" + raw, err := os.ReadFile("../testing/mock-data/no_role.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testapiEndpoint) + fmt.Fprint(writer, string(raw)) + }) + + role, _, err := testClient.Role.Get(context.Background(), 99999) + if role != nil { + t.Errorf("Expected nil, got role %v", role) + } + if err == nil { + t.Errorf("No error given") + } +} + +func TestRoleService_Get(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/3/role/10002" + raw, err := os.ReadFile("../testing/mock-data/role.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testapiEndpoint) + fmt.Fprint(writer, string(raw)) + }) + + role, _, err := testClient.Role.Get(context.Background(), 10002) + if role == nil { + t.Errorf("Expected Role, got nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/onpremise/servicedesk.go b/onpremise/servicedesk.go new file mode 100644 index 00000000..239bedfd --- /dev/null +++ b/onpremise/servicedesk.go @@ -0,0 +1,211 @@ +package onpremise + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/google/go-querystring/query" +) + +// ServiceDeskService handles ServiceDesk for the Jira instance / API. +type ServiceDeskService service + +// ServiceDeskOrganizationDTO is a DTO for ServiceDesk organizations +type ServiceDeskOrganizationDTO struct { + OrganizationID int `json:"organizationId,omitempty" structs:"organizationId,omitempty"` +} + +// GetOrganizations returns a list of +// all organizations associated with a service desk. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-servicedesk-servicedeskid-organization-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ServiceDeskService) GetOrganizations(ctx context.Context, serviceDeskID interface{}, start int, limit int, accountID string) (*PagedDTO, *Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/servicedesk/%v/organization?start=%d&limit=%d", serviceDeskID, start, limit) + if accountID != "" { + apiEndPoint += fmt.Sprintf("&accountId=%s", accountID) + } + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndPoint, nil) + req.Header.Set("Accept", "application/json") + + if err != nil { + return nil, nil, err + } + + orgs := new(PagedDTO) + resp, err := s.client.Do(req, &orgs) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return orgs, resp, nil +} + +// AddOrganization adds an organization to +// a service desk. If the organization ID is already +// associated with the service desk, no change is made +// and the resource returns a 204 success code. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-servicedesk-servicedeskid-organization-post +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ServiceDeskService) AddOrganization(ctx context.Context, serviceDeskID interface{}, organizationID int) (*Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/servicedesk/%v/organization", serviceDeskID) + + organization := ServiceDeskOrganizationDTO{ + OrganizationID: organizationID, + } + + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndPoint, organization) + + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} + +// RemoveOrganization removes an organization +// from a service desk. If the organization ID does not +// match an organization associated with the service desk, +// no change is made and the resource returns a 204 success code. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-organization/#api-rest-servicedeskapi-servicedesk-servicedeskid-organization-delete +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ServiceDeskService) RemoveOrganization(ctx context.Context, serviceDeskID interface{}, organizationID int) (*Response, error) { + apiEndPoint := fmt.Sprintf("rest/servicedeskapi/servicedesk/%v/organization", serviceDeskID) + + organization := ServiceDeskOrganizationDTO{ + OrganizationID: organizationID, + } + + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndPoint, organization) + + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} + +// AddCustomers adds customers to the given service desk. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-servicedesk/#api-rest-servicedeskapi-servicedesk-servicedeskid-customer-post +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ServiceDeskService) AddCustomers(ctx context.Context, serviceDeskID interface{}, acountIDs ...string) (*Response, error) { + apiEndpoint := fmt.Sprintf("rest/servicedeskapi/servicedesk/%v/customer", serviceDeskID) + + payload := struct { + AccountIDs []string `json:"accountIds"` + }{ + AccountIDs: acountIDs, + } + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, payload) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + return resp, NewJiraError(resp, err) + } + + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + + return resp, nil +} + +// RemoveCustomers removes customers to the given service desk. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-servicedesk/#api-rest-servicedeskapi-servicedesk-servicedeskid-customer-delete +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ServiceDeskService) RemoveCustomers(ctx context.Context, serviceDeskID interface{}, acountIDs ...string) (*Response, error) { + apiEndpoint := fmt.Sprintf("rest/servicedeskapi/servicedesk/%v/customer", serviceDeskID) + + payload := struct { + AccountIDs []string `json:"accountIDs"` + }{ + AccountIDs: acountIDs, + } + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, payload) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + return resp, NewJiraError(resp, err) + } + + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + + return resp, nil +} + +// ListCustomers lists customers for a ServiceDesk. +// +// https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-servicedesk/#api-rest-servicedeskapi-servicedesk-servicedeskid-customer-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *ServiceDeskService) ListCustomers(ctx context.Context, serviceDeskID interface{}, options *CustomerListOptions) (*CustomerList, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/servicedeskapi/servicedesk/%v/customer", serviceDeskID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + // this is an experiemntal endpoint + req.Header.Set("X-ExperimentalApi", "opt-in") + + if options != nil { + q, err := query.Values(options) + if err != nil { + return nil, nil, err + } + req.URL.RawQuery = q.Encode() + } + + resp, err := s.client.Do(req, nil) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + defer resp.Body.Close() + + customerList := new(CustomerList) + if err := json.NewDecoder(resp.Body).Decode(customerList); err != nil { + return nil, resp, fmt.Errorf("could not unmarshall the data into struct") + } + + return customerList, resp, nil +} diff --git a/onpremise/servicedesk_test.go b/onpremise/servicedesk_test.go new file mode 100644 index 00000000..d1c3df48 --- /dev/null +++ b/onpremise/servicedesk_test.go @@ -0,0 +1,431 @@ +package onpremise + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "reflect" + "sort" + "strconv" + "testing" +) + +func TestServiceDeskService_GetOrganizations(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/servicedesk/10001/organization", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/servicedeskapi/servicedesk/10001/organization") + + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{ + "_expands": [], + "size": 3, + "start": 3, + "limit": 3, + "isLastPage": false, + "_links": { + "base": "https://your-domain.atlassian.net/rest/servicedeskapi", + "context": "context", + "next": "https://your-domain.atlassian.net/rest/servicedeskapi/servicedesk/10001/organization?start=6&limit=3", + "prev": "https://your-domain.atlassian.net/rest/servicedeskapi/servicedesk/10001/organization?start=0&limit=3" + }, + "values": [ + { + "id": "1", + "name": "Charlie Cakes Franchises", + "_links": { + "self": "https://your-domain.atlassian.net/rest/servicedeskapi/organization/1" + } + }, + { + "id": "2", + "name": "Atlas Coffee Co", + "_links": { + "self": "https://your-domain.atlassian.net/rest/servicedeskapi/organization/2" + } + }, + { + "id": "3", + "name": "The Adjustment Bureau", + "_links": { + "self": "https://your-domain.atlassian.net/rest/servicedeskapi/organization/3" + } + } + ] + }`) + }) + + orgs, _, err := testClient.ServiceDesk.GetOrganizations(context.Background(), 10001, 3, 3, "") + + if orgs == nil { + t.Error("Expected Organizations. Result is nil") + } else if orgs.Size != 3 { + t.Errorf("Expected size to be 3, but got %d", orgs.Size) + } + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestServiceDeskService_AddOrganizations(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/servicedesk/10001/organization", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/servicedeskapi/servicedesk/10001/organization") + + w.WriteHeader(http.StatusNoContent) + }) + + _, err := testClient.ServiceDesk.AddOrganization(context.Background(), 10001, 1) + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestServiceDeskService_RemoveOrganizations(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/servicedesk/10001/organization", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/servicedeskapi/servicedesk/10001/organization") + + w.WriteHeader(http.StatusNoContent) + }) + + _, err := testClient.ServiceDesk.RemoveOrganization(context.Background(), 10001, 1) + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestServiceDeskServiceStringServiceDeskID_GetOrganizations(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/servicedesk/TEST/organization", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/servicedeskapi/servicedesk/TEST/organization") + + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{ + "_expands": [], + "size": 3, + "start": 3, + "limit": 3, + "isLastPage": false, + "_links": { + "base": "https://your-domain.atlassian.net/rest/servicedeskapi", + "context": "context", + "next": "https://your-domain.atlassian.net/rest/servicedeskapi/servicedesk/TEST/organization?start=6&limit=3", + "prev": "https://your-domain.atlassian.net/rest/servicedeskapi/servicedesk/TEST/organization?start=0&limit=3" + }, + "values": [ + { + "id": "1", + "name": "Charlie Cakes Franchises", + "_links": { + "self": "https://your-domain.atlassian.net/rest/servicedeskapi/organization/1" + } + }, + { + "id": "2", + "name": "Atlas Coffee Co", + "_links": { + "self": "https://your-domain.atlassian.net/rest/servicedeskapi/organization/2" + } + }, + { + "id": "3", + "name": "The Adjustment Bureau", + "_links": { + "self": "https://your-domain.atlassian.net/rest/servicedeskapi/organization/3" + } + } + ] + }`) + }) + + orgs, _, err := testClient.ServiceDesk.GetOrganizations(context.Background(), "TEST", 3, 3, "") + + if orgs == nil { + t.Error("Expected Organizations. Result is nil") + } else if orgs.Size != 3 { + t.Errorf("Expected size to be 3, but got %d", orgs.Size) + } + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestServiceDeskServiceStringServiceDeskID_AddOrganizations(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/servicedesk/TEST/organization", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/servicedeskapi/servicedesk/TEST/organization") + + w.WriteHeader(http.StatusNoContent) + }) + + _, err := testClient.ServiceDesk.AddOrganization(context.Background(), "TEST", 1) + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestServiceDeskServiceStringServiceDeskID_RemoveOrganizations(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/servicedeskapi/servicedesk/TEST/organization", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, "/rest/servicedeskapi/servicedesk/TEST/organization") + + w.WriteHeader(http.StatusNoContent) + }) + + _, err := testClient.ServiceDesk.RemoveOrganization(context.Background(), "TEST", 1) + + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestServiceDeskService_AddCustomers(t *testing.T) { + tests := []struct { + name string + serviceDeskID interface{} + }{ + { + name: "string service desk id", + serviceDeskID: "10000", + }, + { + name: "int service desk id", + serviceDeskID: 10000, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + setup() + defer teardown() + + var ( + wantAccountIDs = []string{ + "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3a01db05e2a66fa80bd", + "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + } + gotAccountIDs []string + ) + + testMux.HandleFunc(fmt.Sprintf("/rest/servicedeskapi/servicedesk/%v/customer", test.serviceDeskID), func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, fmt.Sprintf("/rest/servicedeskapi/servicedesk/%v/customer", test.serviceDeskID)) + + var payload struct { + AccountIDs []string `json:"accountIds"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + + gotAccountIDs = append(gotAccountIDs, payload.AccountIDs...) + + w.WriteHeader(http.StatusNoContent) + }) + + _, err := testClient.ServiceDesk.AddCustomers(context.Background(), test.serviceDeskID, wantAccountIDs...) + + if err != nil { + t.Errorf("Error given: %s", err) + } + + if want, got := len(wantAccountIDs), len(gotAccountIDs); want != got { + t.Fatalf("want account id length: %d, got %d", want, got) + } + + sort.Strings(wantAccountIDs) + sort.Strings(gotAccountIDs) + + if !reflect.DeepEqual(wantAccountIDs, gotAccountIDs) { + t.Fatalf("want account ids: %v, got %v", wantAccountIDs, gotAccountIDs) + } + }) + } +} + +func TestServiceDeskService_RemoveCustomers(t *testing.T) { + tests := []struct { + name string + serviceDeskID interface{} + }{ + { + name: "string service desk id", + serviceDeskID: "10000", + }, + { + name: "int service desk id", + serviceDeskID: 10000, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + setup() + defer teardown() + + var ( + wantAccountIDs = []string{ + "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3a01db05e2a66fa80bd", + "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + } + gotAccountIDs []string + ) + + testMux.HandleFunc(fmt.Sprintf("/rest/servicedeskapi/servicedesk/%v/customer", test.serviceDeskID), func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + testRequestURL(t, r, fmt.Sprintf("/rest/servicedeskapi/servicedesk/%v/customer", test.serviceDeskID)) + + var payload struct { + AccountIDs []string `json:"accountIds"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + + gotAccountIDs = append(gotAccountIDs, payload.AccountIDs...) + + w.WriteHeader(http.StatusNoContent) + }) + + _, err := testClient.ServiceDesk.RemoveCustomers(context.Background(), test.serviceDeskID, wantAccountIDs...) + + if err != nil { + t.Errorf("Error given: %s", err) + } + + if want, got := len(wantAccountIDs), len(gotAccountIDs); want != got { + t.Fatalf("want account id length: %d, got %d", want, got) + } + + sort.Strings(wantAccountIDs) + sort.Strings(gotAccountIDs) + + if !reflect.DeepEqual(wantAccountIDs, gotAccountIDs) { + t.Fatalf("want account ids: %v, got %v", wantAccountIDs, gotAccountIDs) + } + }) + } +} + +func TestServiceDeskService_ListCustomers(t *testing.T) { + tests := []struct { + name string + serviceDeskID interface{} + }{ + { + name: "string service desk id", + serviceDeskID: "10000", + }, + { + name: "int service desk id", + serviceDeskID: 10000, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + setup() + defer teardown() + + var ( + email = "fred@example.com" + wantOptions = &CustomerListOptions{ + Query: email, + Start: 1, + Limit: 10, + } + + gotOptions = new(CustomerListOptions) + ) + + testMux.HandleFunc(fmt.Sprintf("/rest/servicedeskapi/servicedesk/%v/customer", test.serviceDeskID), func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, fmt.Sprintf("/rest/servicedeskapi/servicedesk/%v/customer", test.serviceDeskID)) + + qs := r.URL.Query() + gotOptions.Query = qs.Get("query") + if start := qs.Get("start"); start != "" { + gotOptions.Start, _ = strconv.Atoi(start) + } + if limit := qs.Get("limit"); limit != "" { + gotOptions.Limit, _ = strconv.Atoi(limit) + } + + w.Write([]byte(`{ + "_expands": [], + "size": 1, + "start": 1, + "limit": 1, + "isLastPage": false, + "_links": { + "base": "https://your-domain.atlassian.net/rest/servicedeskapi", + "context": "context", + "next": "https://your-domain.atlassian.net/rest/servicedeskapi/servicedesk/1/customer?start=2&limit=1", + "prev": "https://your-domain.atlassian.net/rest/servicedeskapi/servicedesk/1/customer?start=0&limit=1" + }, + "values": [ + { + "accountId": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "name": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "key": "qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "emailAddress": "fred@example.com", + "displayName": "Fred F. User", + "active": true, + "timeZone": "Australia/Sydney", + "_links": { + "jiraRest": "https://your-domain.atlassian.net/rest/api/2/user?username=qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9bc3b5bcb0db050c6d7660b28a5b86c9%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", + "24x24": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9bc3b5bcb0db050c6d7660b28a5b86c9%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", + "16x16": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9bc3b5bcb0db050c6d7660b28a5b86c9%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", + "32x32": "https://avatar-cdn.atlassian.com/9bc3b5bcb0db050c6d7660b28a5b86c9?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2F9bc3b5bcb0db050c6d7660b28a5b86c9%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" + }, + "self": "https://your-domain.atlassian.net/rest/api/2/user?username=qm:a713c8ea-1075-4e30-9d96-891a7d181739:5ad6d3581db05e2a66fa80b" + } + } + ] + }`)) + }) + + customerList, _, err := testClient.ServiceDesk.ListCustomers(context.Background(), test.serviceDeskID, wantOptions) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(wantOptions, gotOptions) { + t.Fatalf("want options: %#v, got %#v", wantOptions, gotOptions) + } + + if want, got := 1, len(customerList.Values); want != got { + t.Fatalf("want customer count: %d, got %d", want, got) + } + + if want, got := email, customerList.Values[0].EmailAddress; want != got { + t.Fatalf("want customer email: %q, got %q", want, got) + } + }) + } +} diff --git a/onpremise/sprint.go b/onpremise/sprint.go new file mode 100644 index 00000000..1553f6b3 --- /dev/null +++ b/onpremise/sprint.go @@ -0,0 +1,117 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + + "github.com/google/go-querystring/query" +) + +// SprintService handles sprints in Jira Agile API. +// See https://docs.atlassian.com/jira-software/REST/cloud/ +type SprintService service + +// IssuesWrapper represents a wrapper struct for moving issues to sprint +type IssuesWrapper struct { + Issues []string `json:"issues"` +} + +// IssuesInSprintResult represents a wrapper struct for search result +type IssuesInSprintResult struct { + Issues []Issue `json:"issues"` +} + +// MoveIssuesToSprint moves issues to a sprint, for a given sprint Id. +// Issues can only be moved to open or active sprints. +// The maximum number of issues that can be moved in one operation is 50. +// +// Jira API docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/sprint-moveIssuesToSprint +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *SprintService) MoveIssuesToSprint(ctx context.Context, sprintID int, issueIDs []string) (*Response, error) { + apiEndpoint := fmt.Sprintf("rest/agile/1.0/sprint/%d/issue", sprintID) + + payload := IssuesWrapper{Issues: issueIDs} + + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, payload) + + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + err = NewJiraError(resp, err) + } + return resp, err +} + +// GetIssuesForSprint returns all issues in a sprint, for a given sprint Id. +// This only includes issues that the user has permission to view. +// By default, the returned issues are ordered by rank. +// +// Jira API Docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/sprint-getIssuesForSprint +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *SprintService) GetIssuesForSprint(ctx context.Context, sprintID int) ([]Issue, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/agile/1.0/sprint/%d/issue", sprintID) + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + + if err != nil { + return nil, nil, err + } + + result := new(IssuesInSprintResult) + resp, err := s.client.Do(req, result) + if err != nil { + err = NewJiraError(resp, err) + } + + return result.Issues, resp, err +} + +// GetIssue returns a full representation of the issue for the given issue key. +// Jira will attempt to identify the issue by the issueIdOrKey path parameter. +// This can be an issue id, or an issue key. +// If the issue cannot be found via an exact match, Jira will also look for the issue in a case-insensitive way, or by looking to see if the issue was moved. +// +// # The given options will be appended to the query string +// +// Jira API docs: https://docs.atlassian.com/jira-software/REST/7.3.1/#agile/1.0/issue-getIssue +// +// TODO: create agile service for holding all agile apis' implementation +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *SprintService) GetIssue(ctx context.Context, issueID string, options *GetQueryOptions) (*Issue, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/agile/1.0/issue/%s", issueID) + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + + if err != nil { + return nil, nil, err + } + + if options != nil { + q, err := query.Values(options) + if err != nil { + return nil, nil, err + } + req.URL.RawQuery = q.Encode() + } + + issue := new(Issue) + resp, err := s.client.Do(req, issue) + + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return issue, resp, nil +} diff --git a/onpremise/sprint_test.go b/onpremise/sprint_test.go new file mode 100644 index 00000000..cc0eaf2f --- /dev/null +++ b/onpremise/sprint_test.go @@ -0,0 +1,117 @@ +package onpremise + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "reflect" + "testing" +) + +func TestSprintService_MoveIssuesToSprint(t *testing.T) { + setup() + defer teardown() + + testAPIEndpoint := "/rest/agile/1.0/sprint/123/issue" + + issuesToMove := []string{"KEY-1", "KEY-2"} + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, testAPIEndpoint) + + decoder := json.NewDecoder(r.Body) + var payload IssuesWrapper + err := decoder.Decode(&payload) + if err != nil { + t.Errorf("Got error: %v", err) + } + + if payload.Issues[0] != issuesToMove[0] { + t.Errorf("Expected %s to be in payload, got %s instead", issuesToMove[0], payload.Issues[0]) + } + }) + _, err := testClient.Sprint.MoveIssuesToSprint(context.Background(), 123, issuesToMove) + + if err != nil { + t.Errorf("Got error: %v", err) + } +} + +func TestSprintService_GetIssuesForSprint(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/agile/1.0/sprint/123/issue" + + raw, err := os.ReadFile("../testing/mock-data/issues_in_sprint.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, string(raw)) + }) + + issues, _, err := testClient.Sprint.GetIssuesForSprint(context.Background(), 123) + if err != nil { + t.Errorf("Error given: %v", err) + } + if issues == nil { + t.Error("Expected issues in sprint list. Issues list is nil") + } + if len(issues) != 1 { + t.Errorf("Expect there to be 1 issue in the sprint, found %v", len(issues)) + } + +} + +func TestSprintService_GetIssue(t *testing.T) { + setup() + defer teardown() + + testAPIEndpoint := "/rest/agile/1.0/issue/10002" + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"labels":["test"],"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"sprint": {"id": 37,"self": "http://www.example.com/jira/rest/agile/1.0/sprint/13", "state": "future", "name": "sprint 2"}, "epic": {"id": 19415,"key": "EPIC-77","self": "https://example.atlassian.net/rest/agile/1.0/epic/19415","name": "Epic Name","summary": "Do it","color": {"key": "color_11"},"done": false},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","duedate":"2018-01-19","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`) + }) + + issue, _, err := testClient.Sprint.GetIssue(context.Background(), "10002", nil) + if err != nil { + t.Errorf("Error given: %s", err) + } + if issue == nil { + t.Errorf("Expected issue. Issue is nil %v", err) + return + } + if !reflect.DeepEqual(issue.Fields.Labels, []string{"test"}) { + t.Error("Expected labels for the returned issue") + } + if len(issue.Fields.Comments.Comments) != 1 { + t.Errorf("Expected one comment, %v found", len(issue.Fields.Comments.Comments)) + } + if len(issue.Names) != 10 { + t.Errorf("Expected 10 names, %v found", len(issue.Names)) + } + if !reflect.DeepEqual(issue.Names, map[string]string{ + "watcher": "watcher", + "attachment": "attachment", + "sub-tasks": "sub-tasks", + "description": "description", + "project": "project", + "comment": "comment", + "issuelinks": "issuelinks", + "worklog": "worklog", + "updated": "updated", + "timetracking": "timetracking", + }) { + t.Error("Expected names for the returned issue") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/onpremise/status.go b/onpremise/status.go new file mode 100644 index 00000000..ff2f0918 --- /dev/null +++ b/onpremise/status.go @@ -0,0 +1,46 @@ +package onpremise + +import ( + "context" + "net/http" +) + +// StatusService handles staties for the Jira instance / API. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-group-Workflow-statuses +type StatusService service + +// Status represents the current status of a Jira issue. +// Typical status are "Open", "In Progress", "Closed", ... +// Status can be user defined in every Jira instance. +type Status struct { + Self string `json:"self" structs:"self"` + Description string `json:"description" structs:"description"` + IconURL string `json:"iconUrl" structs:"iconUrl"` + Name string `json:"name" structs:"name"` + ID string `json:"id" structs:"id"` + StatusCategory StatusCategory `json:"statusCategory" structs:"statusCategory"` +} + +// GetAllStatuses returns a list of all statuses associated with workflows. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-status-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *StatusService) GetAllStatuses(ctx context.Context) ([]Status, *Response, error) { + apiEndpoint := "rest/api/2/status" + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + + if err != nil { + return nil, nil, err + } + + statusList := []Status{} + resp, err := s.client.Do(req, &statusList) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return statusList, resp, nil +} diff --git a/onpremise/status_test.go b/onpremise/status_test.go new file mode 100644 index 00000000..90594902 --- /dev/null +++ b/onpremise/status_test.go @@ -0,0 +1,36 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestStatusService_GetAllStatuses(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/status" + + raw, err := os.ReadFile("../testing/mock-data/all_statuses.json") + if err != nil { + t.Error(err.Error()) + } + + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint) + fmt.Fprint(w, string(raw)) + }) + + statusList, _, err := testClient.Status.GetAllStatuses(context.Background()) + + if statusList == nil { + t.Error("Expected statusList. statusList is nill") + } + + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/onpremise/statuscategory.go b/onpremise/statuscategory.go new file mode 100644 index 00000000..75534592 --- /dev/null +++ b/onpremise/statuscategory.go @@ -0,0 +1,78 @@ +package onpremise + +import ( + "context" + "errors" + "fmt" + "net/http" +) + +// StatusCategoryService handles status categories for the Jira instance / API. +// +// Use it to obtain a list of all status categories and the details of a category. +// Status categories provided a mechanism for categorizing statuses. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-workflow-status-categories/#api-group-workflow-status-categories +type StatusCategoryService service + +// StatusCategory represents the category a status belongs to. +// Those categories can be user defined in every Jira instance. +type StatusCategory struct { + Self string `json:"self" structs:"self"` + ID int `json:"id" structs:"id"` + Name string `json:"name" structs:"name"` + Key string `json:"key" structs:"key"` + ColorName string `json:"colorName" structs:"colorName"` +} + +// These constants are the keys of the default Jira status categories +const ( + StatusCategoryComplete = "done" + StatusCategoryInProgress = "indeterminate" + StatusCategoryToDo = "new" + StatusCategoryUndefined = "undefined" +) + +// GetList returns a list of all status categories. +// +// Jira API docs: https://docs.atlassian.com/software/jira/docs/api/REST/7.6.1/#api/2/statuscategory-getStatusCategories +func (s *StatusCategoryService) GetList(ctx context.Context) ([]StatusCategory, *Response, error) { + apiEndpoint := "/rest/api/2/statuscategory" + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + var statusCategories []StatusCategory + resp, err := s.client.Do(req, &statusCategories) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return statusCategories, resp, nil +} + +// Get returns a full representation of the StatusCategory having the given id or key. +// +// statusCategoryID represents the ID or key of the status category. +// +// Jira API docs: https://docs.atlassian.com/software/jira/docs/api/REST/7.6.1/#api/2/statuscategory-getStatusCategory +func (s *StatusCategoryService) Get(ctx context.Context, statusCategoryID string) (*StatusCategory, *Response, error) { + if statusCategoryID == "" { + return nil, nil, errors.New("no status category id set") + } + + apiEndpoint := fmt.Sprintf("/rest/api/2/statuscategory/%v", statusCategoryID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + statusCategory := new(StatusCategory) + resp, err := s.client.Do(req, statusCategory) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return statusCategory, resp, nil +} diff --git a/onpremise/statuscategory_test.go b/onpremise/statuscategory_test.go new file mode 100644 index 00000000..be27db3a --- /dev/null +++ b/onpremise/statuscategory_test.go @@ -0,0 +1,66 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" +) + +func TestStatusCategoryService_GetList(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/2/statuscategory" + + raw, err := os.ReadFile("../testing/mock-data/all_statuscategories.json") + if err != nil { + t.Error(err.Error()) + } + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + statusCategory, _, err := testClient.StatusCategory.GetList(context.Background()) + if statusCategory == nil { + t.Error("Expected statusCategory list. StatusCategory list is nil") + } + if l := len(statusCategory); l != 4 { + t.Errorf("Expected 4 statusCategory list items. Got %d", l) + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestStatusCategoryService_Get(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/2/statuscategory/1" + + raw, err := os.ReadFile("../testing/mock-data/status_category.json") + if err != nil { + t.Error(err.Error()) + } + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, string(raw)) + }) + + statusCategory, _, err := testClient.StatusCategory.Get(context.Background(), "1") + if err != nil { + t.Errorf("Error given: %s", err) + + } else if statusCategory == nil { + t.Error("Expected status category. StatusCategory is nil") + + // Checking testdata + } else if statusCategory.ColorName != "medium-gray" { + t.Errorf("Expected statusCategory.ColorName to be 'medium-gray'. Got '%s'", statusCategory.ColorName) + } +} diff --git a/onpremise/types.go b/onpremise/types.go new file mode 100644 index 00000000..7eec4767 --- /dev/null +++ b/onpremise/types.go @@ -0,0 +1,9 @@ +package onpremise + +// Bool is a helper routine that allocates a new bool value +// to store v and returns a pointer to it. +func Bool(v bool) *bool { + p := new(bool) + *p = v + return p +} diff --git a/user.go b/onpremise/user.go similarity index 58% rename from user.go rename to onpremise/user.go index 78d60396..bcbe30aa 100644 --- a/user.go +++ b/onpremise/user.go @@ -1,18 +1,16 @@ -package jira +package onpremise import ( "context" "encoding/json" "fmt" - "io/ioutil" + "net/http" ) // UserService handles users for the Jira instance / API. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-group-Users -type UserService struct { - client *Client -} +type UserService service // User represents a Jira user. type User struct { @@ -46,12 +44,15 @@ type userSearch []userSearchParam type userSearchF func(userSearch) userSearch -// GetWithContext gets user info from Jira using its Account Id +// Get gets user info from Jira using its Account Id // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-user-get -func (s *UserService) GetWithContext(ctx context.Context, accountId string) (*User, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) Get(ctx context.Context, accountId string) (*User, *Response, error) { apiEndpoint := fmt.Sprintf("/rest/api/2/user?accountId=%s", accountId) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -64,18 +65,16 @@ func (s *UserService) GetWithContext(ctx context.Context, accountId string) (*Us return user, resp, nil } -// Get wraps GetWithContext using the background context. -func (s *UserService) Get(accountId string) (*User, *Response, error) { - return s.GetWithContext(context.Background(), accountId) -} - -// GetByAccountIDWithContext gets user info from Jira +// GetByAccountID gets user info from Jira // Searching by another parameter that is not accountId is deprecated, // but this method is kept for backwards compatibility // Jira API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/user-getUser -func (s *UserService) GetByAccountIDWithContext(ctx context.Context, accountID string) (*User, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) GetByAccountID(ctx context.Context, accountID string) (*User, *Response, error) { apiEndpoint := fmt.Sprintf("/rest/api/2/user?accountId=%s", accountID) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -88,17 +87,15 @@ func (s *UserService) GetByAccountIDWithContext(ctx context.Context, accountID s return user, resp, nil } -// GetByAccountID wraps GetByAccountIDWithContext using the background context. -func (s *UserService) GetByAccountID(accountID string) (*User, *Response, error) { - return s.GetByAccountIDWithContext(context.Background(), accountID) -} - -// CreateWithContext creates an user in Jira. +// Create creates an user in Jira. // // Jira API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/user-createUser -func (s *UserService) CreateWithContext(ctx context.Context, user *User) (*User, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) Create(ctx context.Context, user *User) (*User, *Response, error) { apiEndpoint := "/rest/api/2/user" - req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, user) + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, user) if err != nil { return nil, nil, err } @@ -107,35 +104,28 @@ func (s *UserService) CreateWithContext(ctx context.Context, user *User) (*User, if err != nil { return nil, resp, err } + defer resp.Body.Close() responseUser := new(User) - defer resp.Body.Close() - data, err := ioutil.ReadAll(resp.Body) - if err != nil { - e := fmt.Errorf("could not read the returned data") - return nil, resp, NewJiraError(resp, e) - } - err = json.Unmarshal(data, responseUser) + err = json.NewDecoder(resp.Body).Decode(&responseUser) if err != nil { - e := fmt.Errorf("could not unmarshall the data into struct") - return nil, resp, NewJiraError(resp, e) + return nil, resp, err } - return responseUser, resp, nil -} -// Create wraps CreateWithContext using the background context. -func (s *UserService) Create(user *User) (*User, *Response, error) { - return s.CreateWithContext(context.Background(), user) + return responseUser, resp, nil } -// DeleteWithContext deletes an user from Jira. +// Delete deletes an user from Jira. // Returns http.StatusNoContent on success. // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-user-delete // Caller must close resp.Body -func (s *UserService) DeleteWithContext(ctx context.Context, accountId string) (*Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) Delete(ctx context.Context, accountId string) (*Response, error) { apiEndpoint := fmt.Sprintf("/rest/api/2/user?accountId=%s", accountId) - req, err := s.client.NewRequestWithContext(ctx, "DELETE", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodDelete, apiEndpoint, nil) if err != nil { return nil, err } @@ -147,18 +137,15 @@ func (s *UserService) DeleteWithContext(ctx context.Context, accountId string) ( return resp, nil } -// Delete wraps DeleteWithContext using the background context. -// Caller must close resp.Body -func (s *UserService) Delete(accountId string) (*Response, error) { - return s.DeleteWithContext(context.Background(), accountId) -} - -// GetGroupsWithContext returns the groups which the user belongs to +// GetGroups returns the groups which the user belongs to // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-user-groups-get -func (s *UserService) GetGroupsWithContext(ctx context.Context, accountId string) (*[]UserGroup, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) GetGroups(ctx context.Context, accountId string) (*[]UserGroup, *Response, error) { apiEndpoint := fmt.Sprintf("/rest/api/2/user/groups?accountId=%s", accountId) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -171,17 +158,15 @@ func (s *UserService) GetGroupsWithContext(ctx context.Context, accountId string return userGroups, resp, nil } -// GetGroups wraps GetGroupsWithContext using the background context. -func (s *UserService) GetGroups(accountId string) (*[]UserGroup, *Response, error) { - return s.GetGroupsWithContext(context.Background(), accountId) -} - -// GetSelfWithContext information about the current logged-in user +// GetSelf information about the current logged-in user // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-myself-get -func (s *UserService) GetSelfWithContext(ctx context.Context) (*User, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) GetSelf(ctx context.Context) (*User, *Response, error) { const apiEndpoint = "rest/api/2/myself" - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -193,12 +178,10 @@ func (s *UserService) GetSelfWithContext(ctx context.Context) (*User, *Response, return &user, resp, nil } -// GetSelf wraps GetSelfWithContext using the background context. -func (s *UserService) GetSelf() (*User, *Response, error) { - return s.GetSelfWithContext(context.Background()) -} - // WithMaxResults sets the max results to return +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func WithMaxResults(maxResults int) userSearchF { return func(s userSearch) userSearch { s = append(s, userSearchParam{name: "maxResults", value: fmt.Sprintf("%d", maxResults)}) @@ -207,6 +190,9 @@ func WithMaxResults(maxResults int) userSearchF { } // WithStartAt set the start pager +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func WithStartAt(startAt int) userSearchF { return func(s userSearch) userSearch { s = append(s, userSearchParam{name: "startAt", value: fmt.Sprintf("%d", startAt)}) @@ -215,6 +201,9 @@ func WithStartAt(startAt int) userSearchF { } // WithActive sets the active users lookup +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func WithActive(active bool) userSearchF { return func(s userSearch) userSearch { s = append(s, userSearchParam{name: "includeActive", value: fmt.Sprintf("%t", active)}) @@ -223,6 +212,9 @@ func WithActive(active bool) userSearchF { } // WithInactive sets the inactive users lookup +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func WithInactive(inactive bool) userSearchF { return func(s userSearch) userSearch { s = append(s, userSearchParam{name: "includeInactive", value: fmt.Sprintf("%t", inactive)}) @@ -231,6 +223,9 @@ func WithInactive(inactive bool) userSearchF { } // WithUsername sets the username to search +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func WithUsername(username string) userSearchF { return func(s userSearch) userSearch { s = append(s, userSearchParam{name: "username", value: username}) @@ -239,6 +234,9 @@ func WithUsername(username string) userSearchF { } // WithAccountId sets the account id to search +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func WithAccountId(accountId string) userSearchF { return func(s userSearch) userSearch { s = append(s, userSearchParam{name: "accountId", value: accountId}) @@ -247,6 +245,9 @@ func WithAccountId(accountId string) userSearchF { } // WithProperty sets the property (Property keys are specified by path) to search +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. func WithProperty(property string) userSearchF { return func(s userSearch) userSearch { s = append(s, userSearchParam{name: "property", value: property}) @@ -254,11 +255,14 @@ func WithProperty(property string) userSearchF { } } -// FindWithContext searches for user info from Jira: +// Find searches for user info from Jira: // It can find users by email or display name using the query parameter // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-user-search-get -func (s *UserService) FindWithContext(ctx context.Context, property string, tweaks ...userSearchF) ([]User, *Response, error) { +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *UserService) Find(ctx context.Context, property string, tweaks ...userSearchF) ([]User, *Response, error) { search := []userSearchParam{ { name: "query", @@ -275,7 +279,7 @@ func (s *UserService) FindWithContext(ctx context.Context, property string, twea } apiEndpoint := fmt.Sprintf("/rest/api/2/user/search?%s", queryString[:len(queryString)-1]) - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) if err != nil { return nil, nil, err } @@ -287,8 +291,3 @@ func (s *UserService) FindWithContext(ctx context.Context, property string, twea } return users, resp, nil } - -// Find wraps FindWithContext using the background context. -func (s *UserService) Find(property string, tweaks ...userSearchF) ([]User, *Response, error) { - return s.FindWithContext(context.Background(), property, tweaks...) -} diff --git a/user_test.go b/onpremise/user_test.go similarity index 88% rename from user_test.go rename to onpremise/user_test.go index ca572dba..273bc3a0 100644 --- a/user_test.go +++ b/onpremise/user_test.go @@ -1,6 +1,7 @@ -package jira +package onpremise import ( + "context" "fmt" "net/http" "testing" @@ -10,7 +11,7 @@ func TestUserService_Get_Success(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/user?accountId=000000000000000000000000") fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","key":"fred", @@ -22,7 +23,7 @@ func TestUserService_Get_Success(t *testing.T) { }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}`) }) - if user, _, err := testClient.User.Get("000000000000000000000000"); err != nil { + if user, _, err := testClient.User.Get(context.Background(), "000000000000000000000000"); err != nil { t.Errorf("Error given: %s", err) } else if user == nil { t.Error("Expected user. User is nil") @@ -33,7 +34,7 @@ func TestUserService_GetByAccountID_Success(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/user?accountId=000000000000000000000000") fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000","accountId": "000000000000000000000000", @@ -45,7 +46,7 @@ func TestUserService_GetByAccountID_Success(t *testing.T) { }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}`) }) - if user, _, err := testClient.User.GetByAccountID("000000000000000000000000"); err != nil { + if user, _, err := testClient.User.GetByAccountID(context.Background(), "000000000000000000000000"); err != nil { t.Errorf("Error given: %s", err) } else if user == nil { t.Error("Expected user. User is nil") @@ -56,7 +57,7 @@ func TestUserService_Create(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") + testMethod(t, r, http.MethodPost) testRequestURL(t, r, "/rest/api/2/user") w.WriteHeader(http.StatusCreated) @@ -72,7 +73,7 @@ func TestUserService_Create(t *testing.T) { ApplicationKeys: []string{"jira-core"}, } - if user, _, err := testClient.User.Create(u); err != nil { + if user, _, err := testClient.User.Create(context.Background(), u); err != nil { t.Errorf("Error given: %s", err) } else if user == nil { t.Error("Expected user. User is nil") @@ -83,13 +84,13 @@ func TestUserService_Delete(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "DELETE") + testMethod(t, r, http.MethodDelete) testRequestURL(t, r, "/rest/api/2/user?accountId=000000000000000000000000") w.WriteHeader(http.StatusNoContent) }) - resp, err := testClient.User.Delete("000000000000000000000000") + resp, err := testClient.User.Delete(context.Background(), "000000000000000000000000") if err != nil { t.Errorf("Error given: %s", err) } @@ -103,14 +104,14 @@ func TestUserService_GetGroups(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/user/groups", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/user/groups?accountId=000000000000000000000000") w.WriteHeader(http.StatusCreated) fmt.Fprint(w, `[{"name":"jira-software-users","self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000"}]`) }) - if groups, _, err := testClient.User.GetGroups("000000000000000000000000"); err != nil { + if groups, _, err := testClient.User.GetGroups(context.Background(), "000000000000000000000000"); err != nil { t.Errorf("Error given: %s", err) } else if groups == nil { t.Error("Expected user groups. []UserGroup is nil") @@ -121,7 +122,7 @@ func TestUserService_GetSelf(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/myself", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/myself") w.WriteHeader(http.StatusCreated) @@ -134,7 +135,7 @@ func TestUserService_GetSelf(t *testing.T) { }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}`) }) - if user, _, err := testClient.User.GetSelf(); err != nil { + if user, _, err := testClient.User.GetSelf(context.Background()); err != nil { t.Errorf("Error given: %s", err) } else if user == nil { t.Error("Expected user groups. []UserGroup is nil") @@ -149,7 +150,7 @@ func TestUserService_Find_Success(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/user/search", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/user/search?query=fred@example.com") fmt.Fprint(w, `[{"self":"http://www.example.com/jira/rest/api/2/user?accountId=000000000000000000000000","key":"fred", @@ -161,7 +162,7 @@ func TestUserService_Find_Success(t *testing.T) { }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}]`) }) - if user, _, err := testClient.User.Find("fred@example.com"); err != nil { + if user, _, err := testClient.User.Find(context.Background(), "fred@example.com"); err != nil { t.Errorf("Error given: %s", err) } else if user == nil { t.Error("Expected user. User is nil") @@ -172,7 +173,7 @@ func TestUserService_Find_SuccessParams(t *testing.T) { setup() defer teardown() testMux.HandleFunc("/rest/api/2/user/search", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") + testMethod(t, r, http.MethodGet) testRequestURL(t, r, "/rest/api/2/user/search?query=fred@example.com&startAt=100&maxResults=1000") fmt.Fprint(w, `[{"self":"http://www.example.com/jira/rest/api/2/user?query=fred","key":"fred", @@ -184,7 +185,7 @@ func TestUserService_Find_SuccessParams(t *testing.T) { }]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}]`) }) - if user, _, err := testClient.User.Find("fred@example.com", WithStartAt(100), WithMaxResults(1000)); err != nil { + if user, _, err := testClient.User.Find(context.Background(), "fred@example.com", WithStartAt(100), WithMaxResults(1000)); err != nil { t.Errorf("Error given: %s", err) } else if user == nil { t.Error("Expected user. User is nil") diff --git a/onpremise/version.go b/onpremise/version.go new file mode 100644 index 00000000..2a5418e3 --- /dev/null +++ b/onpremise/version.go @@ -0,0 +1,101 @@ +package onpremise + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// VersionService handles Versions for the Jira instance / API. +// +// Jira API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/version +type VersionService service + +// Version represents a single release version of a project +type Version struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + ID string `json:"id,omitempty" structs:"id,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + Description string `json:"description,omitempty" structs:"description,omitempty"` + Archived *bool `json:"archived,omitempty" structs:"archived,omitempty"` + Released *bool `json:"released,omitempty" structs:"released,omitempty"` + ReleaseDate string `json:"releaseDate,omitempty" structs:"releaseDate,omitempty"` + UserReleaseDate string `json:"userReleaseDate,omitempty" structs:"userReleaseDate,omitempty"` + ProjectID int `json:"projectId,omitempty" structs:"projectId,omitempty"` // Unlike other IDs, this is returned as a number + StartDate string `json:"startDate,omitempty" structs:"startDate,omitempty"` +} + +// Get gets version info from Jira +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-version-id-get +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *VersionService) Get(ctx context.Context, versionID int) (*Version, *Response, error) { + apiEndpoint := fmt.Sprintf("/rest/api/2/version/%v", versionID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + version := new(Version) + resp, err := s.client.Do(req, version) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + return version, resp, nil +} + +// Create creates a version in Jira. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-version-post +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *VersionService) Create(ctx context.Context, version *Version) (*Version, *Response, error) { + apiEndpoint := "/rest/api/2/version" + req, err := s.client.NewRequest(ctx, http.MethodPost, apiEndpoint, version) + if err != nil { + return nil, nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + return nil, resp, err + } + defer resp.Body.Close() + + responseVersion := new(Version) + err = json.NewDecoder(resp.Body).Decode(&responseVersion) + if err != nil { + return nil, resp, err + } + + return responseVersion, resp, nil +} + +// Update updates a version from a JSON representation. +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-version-id-put +// Caller must close resp.Body +// +// TODO Double check this method if this works as expected, is using the latest API and the response is complete +// This double check effort is done for v2 - Remove this two lines if this is completed. +func (s *VersionService) Update(ctx context.Context, version *Version) (*Version, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/version/%v", version.ID) + req, err := s.client.NewRequest(ctx, http.MethodPut, apiEndpoint, version) + if err != nil { + return nil, nil, err + } + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + // This is just to follow the rest of the API's convention of returning a version. + // Returning the same pointer here is pointless, so we return a copy instead. + ret := *version + return &ret, resp, nil +} diff --git a/onpremise/version_test.go b/onpremise/version_test.go new file mode 100644 index 00000000..ce731c0a --- /dev/null +++ b/onpremise/version_test.go @@ -0,0 +1,113 @@ +package onpremise + +import ( + "context" + "fmt" + "net/http" + "testing" +) + +func TestVersionService_Get_Success(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/version/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/2/version/10002") + + fmt.Fprint(w, `{ + "self": "http://www.example.com/jira/rest/api/2/version/10002", + "id": "10002", + "description": "An excellent version", + "name": "New Version 1", + "archived": false, + "released": true, + "releaseDate": "2010-07-06", + "overdue": true, + "userReleaseDate": "6/Jul/2010", + "startDate" : "2010-07-01", + "projectId": 10000 + }`) + }) + + version, _, err := testClient.Version.Get(context.Background(), 10002) + if version == nil { + t.Error("Expected version. Issue is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestVersionService_Create(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/version", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + testRequestURL(t, r, "/rest/api/2/version") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{ + "description": "An excellent version", + "name": "New Version 1", + "archived": false, + "released": true, + "releaseDate": "2010-07-06", + "userReleaseDate": "6/Jul/2010", + "project": "PXA", + "projectId": 10000 + }`) + }) + + v := &Version{ + Name: "New Version 1", + Description: "An excellent version", + ProjectID: 10000, + Released: Bool(true), + Archived: Bool(false), + ReleaseDate: "2010-07-06", + UserReleaseDate: "6/Jul/2010", + StartDate: "2018-07-01", + } + + version, _, err := testClient.Version.Create(context.Background(), v) + if version == nil { + t.Error("Expected version. Version is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestServiceService_Update(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/version/10002", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPut) + testRequestURL(t, r, "/rest/api/2/version/10002") + fmt.Fprint(w, `{ + "description": "An excellent updated version", + "name": "New Updated Version 1", + "archived": false, + "released": true, + "releaseDate": "2010-07-06", + "userReleaseDate": "6/Jul/2010", + "startDate" : "2010-07-01", + "project": "PXA", + "projectId": 10000 + }`) + }) + + v := &Version{ + ID: "10002", + Name: "New Updated Version 1", + Description: "An excellent updated version", + } + + version, _, err := testClient.Version.Update(context.Background(), v) + if version == nil { + t.Error("Expected version. Version is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/priority_test.go b/priority_test.go deleted file mode 100644 index b91a2367..00000000 --- a/priority_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package jira - -import ( - "fmt" - "io/ioutil" - "net/http" - "testing" -) - -func TestPriorityService_GetList(t *testing.T) { - setup() - defer teardown() - testAPIEdpoint := "/rest/api/2/priority" - - raw, err := ioutil.ReadFile("./mocks/all_priorities.json") - if err != nil { - t.Error(err.Error()) - } - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) - fmt.Fprint(w, string(raw)) - }) - - priorities, _, err := testClient.Priority.GetList() - if priorities == nil { - t.Error("Expected priority list. Priority list is nil") - } - if err != nil { - t.Errorf("Error given: %s", err) - } -} diff --git a/request_context.go b/request_context.go deleted file mode 100644 index 9f3e2647..00000000 --- a/request_context.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build go1.13 -// +build go1.13 - -// This file provides glue to use Context in `http.Request` with -// Go version 1.13 and higher. - -// The function `http.NewRequestWithContext` has been added in Go 1.13. -// Before the release 1.13, to use Context we need creat `http.Request` -// then use the method `WithContext` to create a new `http.Request` -// with Context from the existing `http.Request`. -// -// Doc: https://golang.org/doc/go1.13#net/http - -package jira - -import ( - "context" - "io" - "net/http" -) - -func newRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) { - return http.NewRequestWithContext(ctx, method, url, body) -} diff --git a/request_legacy.go b/request_legacy.go deleted file mode 100644 index 93eb65e8..00000000 --- a/request_legacy.go +++ /dev/null @@ -1,29 +0,0 @@ -//go:build !go1.13 -// +build !go1.13 - -// This file provides glue to use Context in `http.Request` with -// Go version before 1.13. - -// The function `http.NewRequestWithContext` has been added in Go 1.13. -// Before the release 1.13, to use Context we need creat `http.Request` -// then use the method `WithContext` to create a new `http.Request` -// with Context from the existing `http.Request`. -// -// Doc: https://golang.org/doc/go1.13#net/http - -package jira - -import ( - "context" - "io" - "net/http" -) - -func newRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) { - r, err := http.NewRequest(method, url, body) - if err != nil { - return nil, err - } - - return r.WithContext(ctx), nil -} diff --git a/resolution_test.go b/resolution_test.go deleted file mode 100644 index 7c01e751..00000000 --- a/resolution_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package jira - -import ( - "fmt" - "io/ioutil" - "net/http" - "testing" -) - -func TestResolutionService_GetList(t *testing.T) { - setup() - defer teardown() - testAPIEdpoint := "/rest/api/2/resolution" - - raw, err := ioutil.ReadFile("./mocks/all_resolutions.json") - if err != nil { - t.Error(err.Error()) - } - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) - fmt.Fprint(w, string(raw)) - }) - - resolution, _, err := testClient.Resolution.GetList() - if resolution == nil { - t.Error("Expected resolution list. Resolution list is nil") - } - if err != nil { - t.Errorf("Error given: %s", err) - } -} diff --git a/status_test.go b/status_test.go deleted file mode 100644 index 11a75348..00000000 --- a/status_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package jira - -import ( - "fmt" - "io/ioutil" - "net/http" - "testing" -) - -func TestStatusService_GetAllStatuses(t *testing.T) { - setup() - defer teardown() - testAPIEdpoint := "/rest/api/2/status" - - raw, err := ioutil.ReadFile("./mocks/all_statuses.json") - if err != nil { - t.Error(err.Error()) - } - - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) - fmt.Fprint(w, string(raw)) - }) - - statusList, _, err := testClient.Status.GetAllStatuses() - - if statusList == nil { - t.Error("Expected statusList. statusList is nill") - } - - if err != nil { - t.Errorf("Error given: %s", err) - } -} diff --git a/statuscategory.go b/statuscategory.go deleted file mode 100644 index bed5c566..00000000 --- a/statuscategory.go +++ /dev/null @@ -1,51 +0,0 @@ -package jira - -import "context" - -// StatusCategoryService handles status categories for the Jira instance / API. -// -// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-Statuscategory -type StatusCategoryService struct { - client *Client -} - -// StatusCategory represents the category a status belongs to. -// Those categories can be user defined in every Jira instance. -type StatusCategory struct { - Self string `json:"self" structs:"self"` - ID int `json:"id" structs:"id"` - Name string `json:"name" structs:"name"` - Key string `json:"key" structs:"key"` - ColorName string `json:"colorName" structs:"colorName"` -} - -// These constants are the keys of the default Jira status categories -const ( - StatusCategoryComplete = "done" - StatusCategoryInProgress = "indeterminate" - StatusCategoryToDo = "new" - StatusCategoryUndefined = "undefined" -) - -// GetListWithContext gets all status categories from Jira -// -// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-statuscategory-get -func (s *StatusCategoryService) GetListWithContext(ctx context.Context) ([]StatusCategory, *Response, error) { - apiEndpoint := "rest/api/2/statuscategory" - req, err := s.client.NewRequestWithContext(ctx, "GET", apiEndpoint, nil) - if err != nil { - return nil, nil, err - } - - statusCategoryList := []StatusCategory{} - resp, err := s.client.Do(req, &statusCategoryList) - if err != nil { - return nil, resp, NewJiraError(resp, err) - } - return statusCategoryList, resp, nil -} - -// GetList wraps GetListWithContext using the background context. -func (s *StatusCategoryService) GetList() ([]StatusCategory, *Response, error) { - return s.GetListWithContext(context.Background()) -} diff --git a/statuscategory_test.go b/statuscategory_test.go deleted file mode 100644 index 3f8b7ecb..00000000 --- a/statuscategory_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package jira - -import ( - "fmt" - "io/ioutil" - "net/http" - "testing" -) - -func TestStatusCategoryService_GetList(t *testing.T) { - setup() - defer teardown() - testAPIEdpoint := "/rest/api/2/statuscategory" - - raw, err := ioutil.ReadFile("./mocks/all_statuscategories.json") - if err != nil { - t.Error(err.Error()) - } - testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - testRequestURL(t, r, testAPIEdpoint) - fmt.Fprint(w, string(raw)) - }) - - statusCategory, _, err := testClient.StatusCategory.GetList() - if statusCategory == nil { - t.Error("Expected statusCategory list. StatusCategory list is nil") - } - if err != nil { - t.Errorf("Error given: %s", err) - } -} diff --git a/mocks/all_boards.json b/testing/mock-data/all_boards.json similarity index 78% rename from mocks/all_boards.json rename to testing/mock-data/all_boards.json index 25768fff..5e36776b 100644 --- a/mocks/all_boards.json +++ b/testing/mock-data/all_boards.json @@ -38,7 +38,15 @@ "id": 1, "self": "https://test.jira.org/rest/agile/1.0/board/1", "name": "Test Mobile", - "type": "scrum" + "type": "scrum", + "location": { + "projectId": 10000, + "displayName": "aproject (AP)", + "projectName": "aproject", + "projectKey": "AP", + "projectTypeKey": "software", + "name": "aproject (AP)" + } } ] } \ No newline at end of file diff --git a/mocks/all_boards_filtered.json b/testing/mock-data/all_boards_filtered.json similarity index 100% rename from mocks/all_boards_filtered.json rename to testing/mock-data/all_boards_filtered.json diff --git a/mocks/all_fields.json b/testing/mock-data/all_fields.json similarity index 100% rename from mocks/all_fields.json rename to testing/mock-data/all_fields.json diff --git a/mocks/all_filters.json b/testing/mock-data/all_filters.json similarity index 100% rename from mocks/all_filters.json rename to testing/mock-data/all_filters.json diff --git a/mocks/all_issuelinktypes.json b/testing/mock-data/all_issuelinktypes.json similarity index 100% rename from mocks/all_issuelinktypes.json rename to testing/mock-data/all_issuelinktypes.json diff --git a/mocks/all_permissionschemes.json b/testing/mock-data/all_permissionschemes.json similarity index 100% rename from mocks/all_permissionschemes.json rename to testing/mock-data/all_permissionschemes.json diff --git a/mocks/all_priorities.json b/testing/mock-data/all_priorities.json similarity index 100% rename from mocks/all_priorities.json rename to testing/mock-data/all_priorities.json diff --git a/mocks/all_projects.json b/testing/mock-data/all_projects.json similarity index 100% rename from mocks/all_projects.json rename to testing/mock-data/all_projects.json diff --git a/mocks/all_resolutions.json b/testing/mock-data/all_resolutions.json similarity index 100% rename from mocks/all_resolutions.json rename to testing/mock-data/all_resolutions.json diff --git a/mocks/all_roles.json b/testing/mock-data/all_roles.json similarity index 100% rename from mocks/all_roles.json rename to testing/mock-data/all_roles.json diff --git a/mocks/all_statuscategories.json b/testing/mock-data/all_statuscategories.json similarity index 100% rename from mocks/all_statuscategories.json rename to testing/mock-data/all_statuscategories.json diff --git a/mocks/all_statuses.json b/testing/mock-data/all_statuses.json similarity index 100% rename from mocks/all_statuses.json rename to testing/mock-data/all_statuses.json diff --git a/mocks/board_configuration.json b/testing/mock-data/board_configuration.json similarity index 100% rename from mocks/board_configuration.json rename to testing/mock-data/board_configuration.json diff --git a/mocks/check_permissions.json b/testing/mock-data/check_permissions.json similarity index 100% rename from mocks/check_permissions.json rename to testing/mock-data/check_permissions.json diff --git a/mocks/check_permissions_request.json b/testing/mock-data/check_permissions_request.json similarity index 100% rename from mocks/check_permissions_request.json rename to testing/mock-data/check_permissions_request.json diff --git a/testing/mock-data/component_get.json b/testing/mock-data/component_get.json new file mode 100644 index 00000000..cda055d8 --- /dev/null +++ b/testing/mock-data/component_get.json @@ -0,0 +1,50 @@ +{ + "self": "https://issues.apache.org/jira/rest/api/2/component/42102", + "id": "42102", + "name": "Some Component", + "lead": { + "self": "https://issues.apache.org/jira/rest/api/2/user?username=firstname.lastname@apache.org", + "key": "firstname.lastname", + "name": "firstname.lastname@apache.org", + "avatarUrls": { + "48x48": "https://issues.apache.org/jira/secure/useravatar?ownerId=firstname.lastname&avatarId=31851", + "24x24": "https://issues.apache.org/jira/secure/useravatar?size=small&ownerId=firstname.lastname&avatarId=31851", + "16x16": "https://issues.apache.org/jira/secure/useravatar?size=xsmall&ownerId=firstname.lastname&avatarId=31851", + "32x32": "https://issues.apache.org/jira/secure/useravatar?size=medium&ownerId=firstname.lastname&avatarId=31851" + }, + "displayName": "Firstname Lastname", + "active": true + }, + "assigneeType": "COMPONENT_LEAD", + "assignee": { + "self": "https://issues.apache.org/jira/rest/api/2/user?username=firstname.lastname@apache.org", + "key": "firstname.lastname", + "name": "firstname.lastname@apache.org", + "avatarUrls": { + "48x48": "https://issues.apache.org/jira/secure/useravatar?ownerId=firstname.lastname&avatarId=31851", + "24x24": "https://issues.apache.org/jira/secure/useravatar?size=small&ownerId=firstname.lastname&avatarId=31851", + "16x16": "https://issues.apache.org/jira/secure/useravatar?size=xsmall&ownerId=firstname.lastname&avatarId=31851", + "32x32": "https://issues.apache.org/jira/secure/useravatar?size=medium&ownerId=firstname.lastname&avatarId=31851" + }, + "displayName": "Firstname Lastname", + "active": true + }, + "realAssigneeType": "COMPONENT_LEAD", + "realAssignee": { + "self": "https://issues.apache.org/jira/rest/api/2/user?username=firstname.lastname@apache.org", + "key": "firstname.lastname", + "name": "firstname.lastname@apache.org", + "avatarUrls": { + "48x48": "https://issues.apache.org/jira/secure/useravatar?ownerId=firstname.lastname&avatarId=31851", + "24x24": "https://issues.apache.org/jira/secure/useravatar?size=small&ownerId=firstname.lastname&avatarId=31851", + "16x16": "https://issues.apache.org/jira/secure/useravatar?size=xsmall&ownerId=firstname.lastname&avatarId=31851", + "32x32": "https://issues.apache.org/jira/secure/useravatar?size=medium&ownerId=firstname.lastname&avatarId=31851" + }, + "displayName": "Firstname Lastname", + "active": true + }, + "isAssigneeTypeValid": true, + "project": "ABC", + "projectId": 12345, + "archived": false + } \ No newline at end of file diff --git a/mocks/favourite_filters.json b/testing/mock-data/favourite_filters.json similarity index 100% rename from mocks/favourite_filters.json rename to testing/mock-data/favourite_filters.json diff --git a/mocks/filter.json b/testing/mock-data/filter.json similarity index 100% rename from mocks/filter.json rename to testing/mock-data/filter.json diff --git a/mocks/issues_in_sprint.json b/testing/mock-data/issues_in_sprint.json similarity index 100% rename from mocks/issues_in_sprint.json rename to testing/mock-data/issues_in_sprint.json diff --git a/mocks/my_filters.json b/testing/mock-data/my_filters.json similarity index 100% rename from mocks/my_filters.json rename to testing/mock-data/my_filters.json diff --git a/mocks/no_permissionscheme.json b/testing/mock-data/no_permissionscheme.json similarity index 100% rename from mocks/no_permissionscheme.json rename to testing/mock-data/no_permissionscheme.json diff --git a/mocks/no_permissionschemes.json b/testing/mock-data/no_permissionschemes.json similarity index 100% rename from mocks/no_permissionschemes.json rename to testing/mock-data/no_permissionschemes.json diff --git a/mocks/no_role.json b/testing/mock-data/no_role.json similarity index 100% rename from mocks/no_role.json rename to testing/mock-data/no_role.json diff --git a/mocks/no_roles.json b/testing/mock-data/no_roles.json similarity index 100% rename from mocks/no_roles.json rename to testing/mock-data/no_roles.json diff --git a/mocks/permissionscheme.json b/testing/mock-data/permissionscheme.json similarity index 100% rename from mocks/permissionscheme.json rename to testing/mock-data/permissionscheme.json diff --git a/mocks/project.json b/testing/mock-data/project.json similarity index 100% rename from mocks/project.json rename to testing/mock-data/project.json diff --git a/mocks/remote_links.json b/testing/mock-data/remote_links.json similarity index 100% rename from mocks/remote_links.json rename to testing/mock-data/remote_links.json diff --git a/mocks/role.json b/testing/mock-data/role.json similarity index 100% rename from mocks/role.json rename to testing/mock-data/role.json diff --git a/mocks/search_filters.json b/testing/mock-data/search_filters.json similarity index 100% rename from mocks/search_filters.json rename to testing/mock-data/search_filters.json diff --git a/mocks/sprints.json b/testing/mock-data/sprints.json similarity index 77% rename from mocks/sprints.json rename to testing/mock-data/sprints.json index 508ca35b..ad57cb82 100644 --- a/mocks/sprints.json +++ b/testing/mock-data/sprints.json @@ -41,6 +41,21 @@ "self": "https://jira.com/rest/agile/1.0/sprint/832", "startDate": "2016-06-20T13:24:39.161-07:00", "state": "active" + }, + { + "id": 845, + "self":"https://jira.com/rest/agile/1.0/sprint/845", + "state": "future", + "name": "Minimal Example Sprint", + "originBoardId": 734 + }, + { + "id": 846, + "self": "https://jira.com/rest/agile/1.0/sprint/846", + "state": "future", + "name": "Sprint with a Goal", + "originBoardId": 734, + "goal": "My Goal" } ] } diff --git a/mocks/sprints_filtered.json b/testing/mock-data/sprints_filtered.json similarity index 86% rename from mocks/sprints_filtered.json rename to testing/mock-data/sprints_filtered.json index 29bdb230..5cb0a700 100644 --- a/mocks/sprints_filtered.json +++ b/testing/mock-data/sprints_filtered.json @@ -10,7 +10,8 @@ "originBoardId": 734, "self": "https://jira.com/rest/agile/1.0/sprint/832", "startDate": "2016-06-20T13:24:39.161-07:00", - "state": "active" + "state": "active", + "goal": "My Goal" } ] } diff --git a/testing/mock-data/status_category.json b/testing/mock-data/status_category.json new file mode 100644 index 00000000..6302477a --- /dev/null +++ b/testing/mock-data/status_category.json @@ -0,0 +1,7 @@ +{ + "self": "https://issues.apache.org/jira/rest/api/2/resolution/1", + "id": 1, + "key": "undefined", + "colorName": "medium-gray", + "name": "No Category" +} \ No newline at end of file diff --git a/mocks/transitions.json b/testing/mock-data/transitions.json similarity index 100% rename from mocks/transitions.json rename to testing/mock-data/transitions.json