├── .github └── FUNDING.yml ├── .gitignore ├── .settings ├── org.eclipse.core.resources.prefs └── org.eclipse.buildship.core.prefs ├── src ├── test │ ├── resources │ │ └── com │ │ │ └── github │ │ │ └── onozaty │ │ │ └── redmine │ │ │ └── issue │ │ │ └── loader │ │ │ ├── issues-status_id.csv │ │ │ ├── replace.csv │ │ │ ├── issues-project_id-subject.csv │ │ │ ├── issues-single-watchers.csv │ │ │ ├── issues-multiple-custom_fields.csv │ │ │ ├── issues-normalize.csv │ │ │ ├── update-pk-only.json │ │ │ ├── update-none-pk.json │ │ │ ├── issues-all_fields.csv │ │ │ ├── update-status_id-with-custom_field.json │ │ │ ├── create-project_id-subject.json │ │ │ ├── timeout.json │ │ │ ├── basic-auth.json │ │ │ ├── mapping-unmatch.json │ │ │ ├── update-issue_id.json │ │ │ ├── create-single-watchers.json │ │ │ ├── replace.json │ │ │ ├── create-normalize.json │ │ │ ├── create-multiple-custom_fields.json │ │ │ ├── create-none-project_id.json │ │ │ ├── create-none-subject.json │ │ │ ├── update-with-subject.json │ │ │ ├── update-multi-pk.json │ │ │ ├── update-all_fields-with-issue_id.json │ │ │ └── create-all_fields.json │ └── java │ │ └── com │ │ └── github │ │ └── onozaty │ │ └── redmine │ │ └── issue │ │ └── loader │ │ ├── IssueLoaderTest.java │ │ └── IssueLoadRunnerTest.java └── main │ └── java │ └── com │ └── github │ └── onozaty │ └── redmine │ └── issue │ └── loader │ ├── input │ ├── LoadMode.java │ ├── PrimaryKey.java │ ├── IssueRecord.java │ ├── FieldSetting.java │ ├── IssueId.java │ ├── BasicAuth.java │ ├── ReplaceString.java │ ├── CustomField.java │ ├── IssueTargetFieldsBuilder.java │ ├── Config.java │ ├── FieldType.java │ └── IssueRecords.java │ ├── client │ ├── QueryParameter.java │ ├── IssuesBody.java │ ├── IssueBody.java │ ├── Issue.java │ └── Client.java │ ├── IssueLoader.java │ └── IssueLoadRunner.java ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── sample ├── issues.csv ├── config-basic-auth.json ├── config-create-multiple_custom_field.json ├── config-replace.json ├── config-update-with-issue_id.json ├── config-update-with-custom_field.json └── config-create.json ├── .project ├── settings.gradle ├── LICENSE ├── .classpath ├── gradlew.bat ├── README.ja.md ├── gradlew └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: onozaty 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/build/ 2 | **/bin/ 3 | **/.gradle/ 4 | -------------------------------------------------------------------------------- /.settings/org.eclipse.core.resources.prefs: -------------------------------------------------------------------------------- 1 | eclipse.preferences.version=1 2 | encoding/=UTF-8 3 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/issues-status_id.csv: -------------------------------------------------------------------------------- 1 | Field1,Status Id 2 | A,1 3 | B,2 4 | C,3 5 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/replace.csv: -------------------------------------------------------------------------------- 1 | Project,Subject,Description 2 | プロジェクト1,𠮷田,絵文字😀😁😂 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onozaty/redmine-issue-loader/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/issues-project_id-subject.csv: -------------------------------------------------------------------------------- 1 | Project,Subject 2 | プロジェクト1,タイトル1 3 | プロジェクト2,タイトル2 4 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/issues-single-watchers.csv: -------------------------------------------------------------------------------- 1 | Project,Subject,Watchers 2 | プロジェクト1,xxx,1 3 | プロジェクト2,yyy, 4 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/issues-multiple-custom_fields.csv: -------------------------------------------------------------------------------- 1 | Project,Subject,Field1,Field2 2 | プロジェクト1,xxx,A;B,a b 3 | プロジェクト2,yyy,A,b 4 | プロジェクト1,zzz,, 5 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/input/LoadMode.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.input; 2 | 3 | public enum LoadMode { 4 | 5 | CREATE, 6 | 7 | UPDATE 8 | } 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/issues-normalize.csv: -------------------------------------------------------------------------------- 1 | Project,Subject,Start date,Due date,Private 2 | プロジェクト1,ハイフン、0埋めあり,2012-01-01,2012-03-01,TRUE 3 | プロジェクト1,ハイフン、0埋め無し,2012-1-2,2012-3-2,FALSE 4 | プロジェクト1,スラッシュ、0埋めあり,2012/02/01,2012/04/01,true 5 | プロジェクト1,スラッシュ、0埋め無し,2012/2/2,2012/4/2,false 6 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/client/QueryParameter.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.client; 2 | 3 | import lombok.Value; 4 | 5 | @Value 6 | public class QueryParameter { 7 | 8 | private final String name; 9 | 10 | private final String value; 11 | } 12 | -------------------------------------------------------------------------------- /.settings/org.eclipse.buildship.core.prefs: -------------------------------------------------------------------------------- 1 | build.commands=org.eclipse.jdt.core.javabuilder 2 | connection.arguments= 3 | connection.java.home=null 4 | connection.jvm.arguments= 5 | connection.project.dir= 6 | derived.resources=.gradle,build 7 | eclipse.preferences.version=1 8 | natures=org.eclipse.jdt.core.javanature 9 | project.path=\: 10 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/update-pk-only.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "UPDATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "#", 9 | "type": "ISSUE_ID", 10 | "primaryKey": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/input/PrimaryKey.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.input; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import com.github.onozaty.redmine.issue.loader.client.QueryParameter; 5 | 6 | public interface PrimaryKey { 7 | 8 | @JsonIgnore 9 | QueryParameter getQueryParameter(); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/input/IssueRecord.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.input; 2 | 3 | import java.util.Map; 4 | 5 | import lombok.Builder; 6 | import lombok.Data; 7 | 8 | @Builder 9 | @Data 10 | public class IssueRecord { 11 | 12 | private PrimaryKey primaryKey; 13 | 14 | private Map fields; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/client/IssuesBody.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.client; 2 | 3 | import java.util.List; 4 | 5 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 6 | 7 | import lombok.Data; 8 | 9 | @Data 10 | @JsonIgnoreProperties(ignoreUnknown = true) 11 | public class IssuesBody { 12 | 13 | private List issues; 14 | } 15 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/update-none-pk.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "UPDATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Field1", 9 | "type": "CUSTOM_FIELD", 10 | "customFieldId": 1 11 | }, 12 | { 13 | "headerName": "Status Id", 14 | "type": "STATUS_ID" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /sample/issues.csv: -------------------------------------------------------------------------------- 1 | #,Project,Tracker,Status,Priority,Assignee,Category,Target version,Parent #,Subject,Description,Start date,Due date,% Done,Private,Estimated time,Field1,Field2,Field3,Watchers 2 | 1,Project A,Bug,New,Normal,User A,Category1,v1.0,,xxx,aaa,2019/2/1,2019/2/20,10,true,2.5,A,1,A,User B 3 | 2,Project B,Bug,In Progress,Low,User B,,,,yyy,,2019/3/2,,,false,,B,1;2,B, 4 | 3,Project A,Support,Closed,High,,Category2,v2.0,1,zzz,ccc,,2019/10/30,90,false,10,C,,C,User A;User B 5 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/issues-all_fields.csv: -------------------------------------------------------------------------------- 1 | #,Project,Tracker,Status,Priority,Assignee,Category,Target version,Parent #,Subject,Description,Start date,Due date,% Done,Private,Estimated time,Field1,Field2,Field3,Watchers 2 | 1,プロジェクト1,トラッカー2,新規,通常,ユーザA,カテゴリ2,v2.0,,xxx,説明1,2019/02/01,2019/02/20,10,true,2.5,A,a,C,ユーザB 3 | 2,プロジェクト2,トラッカー2,進行中,低め,,カテゴリ2,,,yyy,説明2,2019/03/02,,,false,,B,b,A;B,ユーザA;ユーザB 4 | 3,プロジェクト1,トラッカー3,解決,高め,ユーザB,カテゴリ1,v1.0,1,zzz,説明3,2019/03/12,2019/10/30,90,false,10,C,c,, 5 | -------------------------------------------------------------------------------- /sample/config-basic-auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://192.168.33.10", 4 | "basicAuth": { 5 | "username": "user1", 6 | "password": "password1" 7 | }, 8 | "csvEncoding": "UTF-8", 9 | "fields": [ 10 | { 11 | "headerName": "Project", 12 | "type": "PROJECT_ID", 13 | "mappings": { 14 | "Project A": 1, 15 | "Project B": 2 16 | } 17 | }, 18 | { 19 | "headerName": "Subject", 20 | "type": "SUBJECT" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/update-status_id-with-custom_field.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "UPDATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Field1", 9 | "type": "CUSTOM_FIELD", 10 | "customFieldId": 1, 11 | "primaryKey": true 12 | }, 13 | { 14 | "headerName": "Status Id", 15 | "type": "STATUS_ID" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/input/FieldSetting.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.input; 2 | 3 | import java.util.Map; 4 | 5 | import lombok.Data; 6 | 7 | @Data 8 | public class FieldSetting { 9 | 10 | private String headerName; 11 | 12 | private FieldType type; 13 | 14 | private Integer customFieldId; 15 | 16 | private boolean isPrimaryKey; 17 | 18 | private String multipleItemSeparator; 19 | 20 | private Map mappings; 21 | } 22 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/create-project_id-subject.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Project", 9 | "type": "PROJECT_ID", 10 | "mappings": { 11 | "プロジェクト1": 1, 12 | "プロジェクト2": 2 13 | } 14 | }, 15 | { 16 | "headerName": "Subject", 17 | "type": "SUBJECT" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/timeout.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "timeout": 20, 6 | "csvEncoding": "UTF-8", 7 | "fields": [ 8 | { 9 | "headerName": "Project", 10 | "type": "PROJECT_ID", 11 | "mappings": { 12 | "プロジェクト1": 1, 13 | "プロジェクト2": 2 14 | } 15 | }, 16 | { 17 | "headerName": "Subject", 18 | "type": "SUBJECT" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/basic-auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://localhost", 4 | "basicAuth": { 5 | "username": "user", 6 | "password": "pass" 7 | }, 8 | "csvEncoding": "UTF-8", 9 | "fields": [ 10 | { 11 | "headerName": "Project", 12 | "type": "PROJECT_ID", 13 | "mappings": { 14 | "プロジェクト1": 1, 15 | "プロジェクト2": 2 16 | } 17 | }, 18 | { 19 | "headerName": "Subject", 20 | "type": "SUBJECT" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/mapping-unmatch.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "UPDATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "#", 9 | "type": "ISSUE_ID", 10 | "primaryKey": true 11 | }, 12 | { 13 | "headerName": "Project", 14 | "type": "PROJECT_ID", 15 | "mappings": { 16 | "プロジェクトx": 1, 17 | "プロジェクトy": 2 18 | } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/input/IssueId.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.input; 2 | 3 | import com.github.onozaty.redmine.issue.loader.client.QueryParameter; 4 | 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | 8 | @AllArgsConstructor 9 | @Data 10 | public class IssueId implements PrimaryKey { 11 | 12 | private int id; 13 | 14 | @Override 15 | public QueryParameter getQueryParameter() { 16 | return new QueryParameter("issue_id", String.valueOf(id)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/update-issue_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "UPDATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "#", 9 | "type": "ISSUE_ID" 10 | }, 11 | { 12 | "headerName": "Field1", 13 | "type": "CUSTOM_FIELD", 14 | "customFieldId": 1, 15 | "primaryKey": true 16 | }, 17 | { 18 | "headerName": "Field2", 19 | "type": "CUSTOM_FIELD", 20 | "customFieldId": 2 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/create-single-watchers.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Project", 9 | "type": "PROJECT_ID", 10 | "mappings": { 11 | "プロジェクト1": 1, 12 | "プロジェクト2": 2 13 | } 14 | }, 15 | { 16 | "headerName": "Subject", 17 | "type": "SUBJECT" 18 | }, 19 | { 20 | "headerName": "Watchers", 21 | "type": "WATCHER_USER_IDS" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/client/IssueBody.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.client; 2 | 3 | import java.util.Map; 4 | 5 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 6 | import com.fasterxml.jackson.annotation.JsonProperty; 7 | 8 | import lombok.AllArgsConstructor; 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | 12 | @Data 13 | @AllArgsConstructor 14 | @NoArgsConstructor 15 | @JsonIgnoreProperties(ignoreUnknown = true) 16 | public class IssueBody { 17 | 18 | @JsonProperty("issue") 19 | private Map fields; 20 | } 21 | -------------------------------------------------------------------------------- /sample/config-create-multiple_custom_field.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://192.168.33.10", 4 | "apiKey": "6c6b3511f8ed975364299d6e729cd296f8c2b779", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Project", 9 | "type": "PROJECT_ID", 10 | "mappings": { 11 | "Project A": 1, 12 | "Project B": 2 13 | } 14 | }, 15 | { 16 | "headerName": "Subject", 17 | "type": "SUBJECT" 18 | }, 19 | { 20 | "headerName": "Field2", 21 | "type": "CUSTOM_FIELD", 22 | "customFieldId": 2, 23 | "multipleItemSeparator": ";" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /sample/config-replace.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://192.168.33.10", 4 | "apiKey": "6c6b3511f8ed975364299d6e729cd296f8c2b779", 5 | "replaceString": { 6 | "pattern": "[^\u0000-\uffff]", 7 | "replacement": "_" 8 | }, 9 | "csvEncoding": "UTF-8", 10 | "fields": [ 11 | { 12 | "headerName": "Project", 13 | "type": "PROJECT_ID", 14 | "mappings": { 15 | "Project A": 1, 16 | "Project B": 2 17 | } 18 | }, 19 | { 20 | "headerName": "Subject", 21 | "type": "SUBJECT" 22 | }, 23 | { 24 | "headerName": "Description", 25 | "type": "DESCRIPTION" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/replace.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "replaceString": { 6 | "pattern": "[^\u0000-\uffff]", 7 | "replacement": "_" 8 | }, 9 | "csvEncoding": "UTF-8", 10 | "fields": [ 11 | { 12 | "headerName": "Project", 13 | "type": "PROJECT_ID", 14 | "mappings": { 15 | "プロジェクト1": 1, 16 | "プロジェクト2": 2 17 | } 18 | }, 19 | { 20 | "headerName": "Subject", 21 | "type": "SUBJECT" 22 | }, 23 | { 24 | "headerName": "Description", 25 | "type": "DESCRIPTION" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | redmine-issue-loader 4 | Project redmine-issue-loader created by Buildship. 5 | 6 | 7 | 8 | 9 | org.eclipse.jdt.core.javabuilder 10 | 11 | 12 | 13 | 14 | org.eclipse.buildship.core.gradleprojectbuilder 15 | 16 | 17 | 18 | 19 | 20 | org.eclipse.buildship.core.gradleprojectnature 21 | org.eclipse.jdt.core.javanature 22 | 23 | 24 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * This settings file was auto generated by the Gradle buildInit task 3 | * by 'onozato' at '18/12/11 23:54' with Gradle 2.14.1 4 | * 5 | * The settings file is used to specify which projects to include in your build. 6 | * In a single project build this file can be empty or even removed. 7 | * 8 | * Detailed information about configuring a multi-project build in Gradle can be found 9 | * in the user guide at https://docs.gradle.org/2.14.1/userguide/multi_project_builds.html 10 | */ 11 | 12 | /* 13 | // To declare projects as part of a multi-project build use the 'include' method 14 | include 'shared' 15 | include 'api' 16 | include 'services:webservice' 17 | */ 18 | 19 | rootProject.name = 'redmine-issue-loader' 20 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/client/Issue.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.client; 2 | 3 | import java.util.List; 4 | 5 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 6 | import com.github.onozaty.redmine.issue.loader.input.CustomField; 7 | 8 | import lombok.AllArgsConstructor; 9 | import lombok.Builder; 10 | import lombok.Data; 11 | import lombok.NoArgsConstructor; 12 | import lombok.Singular; 13 | 14 | @Data 15 | @Builder 16 | @AllArgsConstructor 17 | @NoArgsConstructor 18 | @JsonIgnoreProperties(ignoreUnknown = true) 19 | public class Issue { 20 | 21 | private Integer id; 22 | 23 | @Singular 24 | private List customFields; 25 | } 26 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/create-normalize.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Project", 9 | "type": "PROJECT_ID", 10 | "mappings": { 11 | "プロジェクト1": 1, 12 | "プロジェクト2": 2 13 | } 14 | }, 15 | { 16 | "headerName": "Subject", 17 | "type": "SUBJECT" 18 | }, 19 | { 20 | "headerName": "Start date", 21 | "type": "START_DATE" 22 | }, 23 | { 24 | "headerName": "Due date", 25 | "type": "DUE_DATE" 26 | }, 27 | { 28 | "headerName": "Private", 29 | "type": "IS_PRIVATE" 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/input/BasicAuth.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.input; 2 | 3 | import java.nio.charset.StandardCharsets; 4 | import java.util.Base64; 5 | 6 | import lombok.AccessLevel; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Builder; 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | 12 | @Data 13 | @NoArgsConstructor 14 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 15 | @Builder 16 | public class BasicAuth { 17 | 18 | private String username; 19 | 20 | private String password; 21 | 22 | public String toAuthorizationValue() { 23 | 24 | return "Basic " 25 | + Base64.getEncoder().encodeToString( 26 | (username + ":" + password).getBytes(StandardCharsets.UTF_8)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/input/ReplaceString.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.input; 2 | 3 | import org.apache.commons.lang3.StringUtils; 4 | 5 | import lombok.AccessLevel; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Builder.Default; 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | 12 | @Data 13 | @NoArgsConstructor 14 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 15 | @Builder 16 | public class ReplaceString { 17 | 18 | private String pattern; 19 | 20 | @Default 21 | private String replacement = ""; 22 | 23 | public String replace(String target) { 24 | 25 | if (StringUtils.isEmpty(target) || StringUtils.isEmpty(pattern)) { 26 | return target; 27 | } 28 | 29 | return target.replaceAll(pattern, replacement); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/input/CustomField.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.input; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import com.github.onozaty.redmine.issue.loader.client.QueryParameter; 5 | 6 | import lombok.AllArgsConstructor; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Data 11 | @AllArgsConstructor 12 | @NoArgsConstructor 13 | @JsonIgnoreProperties(ignoreUnknown = true) 14 | public class CustomField implements PrimaryKey { 15 | 16 | private int id; 17 | 18 | /** 19 | * カスタムフィールドの値です。 20 | * 複数選択の場合、リストが入ります。 21 | */ 22 | private Object value; 23 | 24 | @Override 25 | public QueryParameter getQueryParameter() { 26 | // カスタムフィールドの「フィルタとして使用」が有効となっている必要あり 27 | return new QueryParameter("cf_" + id, String.valueOf(value)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/create-multiple-custom_fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Project", 9 | "type": "PROJECT_ID", 10 | "mappings": { 11 | "プロジェクト1": 1, 12 | "プロジェクト2": 2 13 | } 14 | }, 15 | { 16 | "headerName": "Subject", 17 | "type": "SUBJECT" 18 | }, 19 | { 20 | "headerName": "Field1", 21 | "type": "CUSTOM_FIELD", 22 | "customFieldId": 1, 23 | "multipleItemSeparator": ";", 24 | "mappings": { 25 | "A": 1, 26 | "B": 2, 27 | "C": 3 28 | } 29 | }, 30 | { 31 | "headerName": "Field2", 32 | "type": "CUSTOM_FIELD", 33 | "customFieldId": 2, 34 | "multipleItemSeparator": "\t" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 onozaty 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.classpath: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/input/IssueTargetFieldsBuilder.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.input; 2 | 3 | import java.util.ArrayList; 4 | import java.util.LinkedHashMap; 5 | import java.util.List; 6 | import java.util.Map; 7 | 8 | public class IssueTargetFieldsBuilder { 9 | 10 | private Map updateTargetFields = new LinkedHashMap<>(); // テスト時に順序を保証したいので 11 | 12 | public IssueTargetFieldsBuilder field(FieldType type, Object value) { 13 | 14 | updateTargetFields.put(type.getFieldName(), value); 15 | return this; 16 | } 17 | 18 | public IssueTargetFieldsBuilder customField(CustomField customField) { 19 | 20 | @SuppressWarnings("unchecked") 21 | List customFields = (List) updateTargetFields.get("custom_fields"); 22 | 23 | if (customFields == null) { 24 | customFields = new ArrayList<>(); 25 | updateTargetFields.put("custom_fields", customFields); 26 | } 27 | 28 | customFields.add(customField); 29 | 30 | return this; 31 | } 32 | 33 | public Map build() { 34 | return updateTargetFields; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/input/Config.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.input; 2 | 3 | import java.io.IOException; 4 | import java.nio.file.Path; 5 | import java.util.List; 6 | 7 | import com.fasterxml.jackson.core.JsonParseException; 8 | import com.fasterxml.jackson.databind.JsonMappingException; 9 | import com.fasterxml.jackson.databind.ObjectMapper; 10 | 11 | import lombok.Data; 12 | import lombok.NoArgsConstructor; 13 | import lombok.NonNull; 14 | 15 | @Data 16 | @NoArgsConstructor 17 | public class Config { 18 | 19 | private static final ObjectMapper objectMapper = new ObjectMapper(); 20 | 21 | @NonNull 22 | private LoadMode mode; 23 | 24 | @NonNull 25 | private String readmineUrl; 26 | 27 | private String apiKey; 28 | 29 | private BasicAuth basicAuth; 30 | 31 | private int timeout = 10; 32 | 33 | private ReplaceString replaceString; 34 | 35 | @NonNull 36 | private String csvEncoding; 37 | 38 | @NonNull 39 | private List fields; 40 | 41 | public static Config of(Path configPath) throws JsonParseException, JsonMappingException, IOException { 42 | return objectMapper.readValue(configPath.toFile(), Config.class); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/create-none-project_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Tracker", 9 | "type": "TRACKER_ID", 10 | "mappings": { 11 | "トラッカー1": 1, 12 | "トラッカー2": 2, 13 | "トラッカー3": 3 14 | } 15 | }, 16 | { 17 | "headerName": "Status", 18 | "type": "STATUS_ID", 19 | "mappings": { 20 | "新規": 1, 21 | "進行中": 2, 22 | "解決": 3 23 | } 24 | }, 25 | { 26 | "headerName": "Priority", 27 | "type": "PRIORITY_ID", 28 | "mappings": { 29 | "低め": 1, 30 | "通常": 2, 31 | "高め": 3 32 | } 33 | }, 34 | { 35 | "headerName": "Subject", 36 | "type": "SUBJECT" 37 | }, 38 | { 39 | "headerName": "Description", 40 | "type": "DESCRIPTION" 41 | }, 42 | { 43 | "headerName": "Category", 44 | "type": "CATEGORY_ID", 45 | "mappings": { 46 | "カテゴリ1": 1, 47 | "カテゴリ2": 2 48 | } 49 | }, 50 | { 51 | "headerName": "Parent #", 52 | "type": "PARENT_ISSUE_ID" 53 | }, 54 | { 55 | "headerName": "Field1", 56 | "type": "CUSTOM_FIELD", 57 | "customFieldId": 1 58 | }, 59 | { 60 | "headerName": "Field2", 61 | "type": "CUSTOM_FIELD", 62 | "customFieldId": 2 63 | } 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/create-none-subject.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Project", 9 | "type": "PROJECT_ID", 10 | "mappings": { 11 | "プロジェクト1": 1, 12 | "プロジェクト2": 2 13 | } 14 | }, 15 | { 16 | "headerName": "Tracker", 17 | "type": "TRACKER_ID", 18 | "mappings": { 19 | "トラッカー1": 1, 20 | "トラッカー2": 2, 21 | "トラッカー3": 3 22 | } 23 | }, 24 | { 25 | "headerName": "Status", 26 | "type": "STATUS_ID", 27 | "mappings": { 28 | "新規": 1, 29 | "進行中": 2, 30 | "解決": 3 31 | } 32 | }, 33 | { 34 | "headerName": "Priority", 35 | "type": "PRIORITY_ID", 36 | "mappings": { 37 | "低め": 1, 38 | "通常": 2, 39 | "高め": 3 40 | } 41 | }, 42 | { 43 | "headerName": "Description", 44 | "type": "DESCRIPTION" 45 | }, 46 | { 47 | "headerName": "Category", 48 | "type": "CATEGORY_ID", 49 | "mappings": { 50 | "カテゴリ1": 1, 51 | "カテゴリ2": 2 52 | } 53 | }, 54 | { 55 | "headerName": "Parent #", 56 | "type": "PARENT_ISSUE_ID" 57 | }, 58 | { 59 | "headerName": "Field1", 60 | "type": "CUSTOM_FIELD", 61 | "customFieldId": 1 62 | }, 63 | { 64 | "headerName": "Field2", 65 | "type": "CUSTOM_FIELD", 66 | "customFieldId": 2 67 | } 68 | ] 69 | } 70 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/update-with-subject.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "UPDATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Project", 9 | "type": "PROJECT_ID", 10 | "mappings": { 11 | "プロジェクト1": 1, 12 | "プロジェクト2": 2 13 | } 14 | }, 15 | { 16 | "headerName": "Tracker", 17 | "type": "TRACKER_ID", 18 | "mappings": { 19 | "トラッカー1": 1, 20 | "トラッカー2": 2, 21 | "トラッカー3": 3 22 | } 23 | }, 24 | { 25 | "headerName": "Status", 26 | "type": "STATUS_ID", 27 | "mappings": { 28 | "新規": 1, 29 | "進行中": 2, 30 | "解決": 3 31 | } 32 | }, 33 | { 34 | "headerName": "Priority", 35 | "type": "PRIORITY_ID", 36 | "mappings": { 37 | "低め": 1, 38 | "通常": 2, 39 | "高め": 3 40 | } 41 | }, 42 | { 43 | "headerName": "Subject", 44 | "type": "SUBJECT", 45 | "primaryKey": true 46 | }, 47 | { 48 | "headerName": "Description", 49 | "type": "DESCRIPTION" 50 | }, 51 | { 52 | "headerName": "Category", 53 | "type": "CATEGORY_ID", 54 | "mappings": { 55 | "カテゴリ1": 1, 56 | "カテゴリ2": 2 57 | } 58 | }, 59 | { 60 | "headerName": "Parent #", 61 | "type": "PARENT_ISSUE_ID" 62 | }, 63 | { 64 | "headerName": "Field1", 65 | "type": "CUSTOM_FIELD", 66 | "customFieldId": 1 67 | }, 68 | { 69 | "headerName": "Field2", 70 | "type": "CUSTOM_FIELD", 71 | "customFieldId": 2 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/update-multi-pk.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "UPDATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "#", 9 | "type": "ISSUE_ID", 10 | "primaryKey": true 11 | }, 12 | { 13 | "headerName": "Project", 14 | "type": "PROJECT_ID", 15 | "mappings": { 16 | "プロジェクト1": 1, 17 | "プロジェクト2": 2 18 | } 19 | }, 20 | { 21 | "headerName": "Tracker", 22 | "type": "TRACKER_ID", 23 | "mappings": { 24 | "トラッカー1": 1, 25 | "トラッカー2": 2, 26 | "トラッカー3": 3 27 | } 28 | }, 29 | { 30 | "headerName": "Status", 31 | "type": "STATUS_ID", 32 | "mappings": { 33 | "新規": 1, 34 | "進行中": 2, 35 | "解決": 3 36 | } 37 | }, 38 | { 39 | "headerName": "Priority", 40 | "type": "PRIORITY_ID", 41 | "mappings": { 42 | "低め": 1, 43 | "通常": 2, 44 | "高め": 3 45 | } 46 | }, 47 | { 48 | "headerName": "Subject", 49 | "type": "SUBJECT" 50 | }, 51 | { 52 | "headerName": "Description", 53 | "type": "DESCRIPTION" 54 | }, 55 | { 56 | "headerName": "Category", 57 | "type": "CATEGORY_ID", 58 | "mappings": { 59 | "カテゴリ1": 1, 60 | "カテゴリ2": 2 61 | } 62 | }, 63 | { 64 | "headerName": "Parent #", 65 | "type": "PARENT_ISSUE_ID" 66 | }, 67 | { 68 | "headerName": "Field1", 69 | "type": "CUSTOM_FIELD", 70 | "customFieldId": 1 71 | }, 72 | { 73 | "headerName": "Field2", 74 | "type": "CUSTOM_FIELD", 75 | "customFieldId": 2, 76 | "primaryKey": true 77 | } 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/IssueLoader.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader; 2 | 3 | import java.io.IOException; 4 | import java.util.Arrays; 5 | import java.util.List; 6 | import java.util.Map; 7 | 8 | import com.github.onozaty.redmine.issue.loader.client.Client; 9 | import com.github.onozaty.redmine.issue.loader.client.Issue; 10 | import com.github.onozaty.redmine.issue.loader.client.QueryParameter; 11 | import com.github.onozaty.redmine.issue.loader.input.IssueId; 12 | import com.github.onozaty.redmine.issue.loader.input.PrimaryKey; 13 | 14 | import lombok.Value; 15 | 16 | @Value 17 | public class IssueLoader { 18 | 19 | private static final QueryParameter ALL_STATUS_QUERY = new QueryParameter("status_id", "*"); 20 | 21 | private final Client client; 22 | 23 | public IssueId create(Map targetFields) throws IOException { 24 | 25 | // 新規作成 26 | int issueId = client.createIssue(targetFields); 27 | 28 | return new IssueId(issueId); 29 | } 30 | 31 | public IssueId update(PrimaryKey key, Map targetFields) throws IOException { 32 | 33 | // キーとなる情報を使ってIssueを検索 34 | List targetIssues = client.getIssues( 35 | Arrays.asList( 36 | ALL_STATUS_QUERY, // 終了しているチケットも対象にするため指定 37 | key.getQueryParameter())); 38 | 39 | // 1件ではない場合はエラー 40 | if (targetIssues.size() == 0) { 41 | throw new IllegalStateException("The target issue was not found. " + key); 42 | } else if (targetIssues.size() > 1) { 43 | throw new IllegalStateException("There are multiple target issue. " + key); 44 | } 45 | 46 | int targetIssueId = targetIssues.get(0).getId(); 47 | 48 | // 内容更新 49 | client.updateIssue(targetIssueId, targetFields); 50 | 51 | // 更新対象となったIssueIdを返却 52 | return new IssueId(targetIssueId); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/update-all_fields-with-issue_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "UPDATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "#", 9 | "type": "ISSUE_ID", 10 | "primaryKey": true 11 | }, 12 | { 13 | "headerName": "Project", 14 | "type": "PROJECT_ID", 15 | "mappings": { 16 | "プロジェクト1": 1, 17 | "プロジェクト2": 2 18 | } 19 | }, 20 | { 21 | "headerName": "Tracker", 22 | "type": "TRACKER_ID", 23 | "mappings": { 24 | "トラッカー1": 1, 25 | "トラッカー2": 2, 26 | "トラッカー3": 3 27 | } 28 | }, 29 | { 30 | "headerName": "Status", 31 | "type": "STATUS_ID", 32 | "mappings": { 33 | "新規": 1, 34 | "進行中": 2, 35 | "解決": 3 36 | } 37 | }, 38 | { 39 | "headerName": "Priority", 40 | "type": "PRIORITY_ID", 41 | "mappings": { 42 | "低め": 1, 43 | "通常": 2, 44 | "高め": 3 45 | } 46 | }, 47 | { 48 | "headerName": "Assignee", 49 | "type": "ASSIGNED_TO_ID", 50 | "mappings": { 51 | "ユーザA": 5, 52 | "ユーザB": 6 53 | } 54 | }, 55 | { 56 | "headerName": "Category", 57 | "type": "CATEGORY_ID", 58 | "mappings": { 59 | "カテゴリ1": 1, 60 | "カテゴリ2": 2 61 | } 62 | }, 63 | { 64 | "headerName": "Target version", 65 | "type": "FIXED_VERSION_ID", 66 | "mappings": { 67 | "v1.0": 1, 68 | "v2.0": 2 69 | } 70 | }, 71 | { 72 | "headerName": "Parent #", 73 | "type": "PARENT_ISSUE_ID" 74 | }, 75 | { 76 | "headerName": "Subject", 77 | "type": "SUBJECT" 78 | }, 79 | { 80 | "headerName": "Description", 81 | "type": "DESCRIPTION" 82 | }, 83 | { 84 | "headerName": "Start date", 85 | "type": "START_DATE" 86 | }, 87 | { 88 | "headerName": "Due date", 89 | "type": "DUE_DATE" 90 | }, 91 | { 92 | "headerName": "% Done", 93 | "type": "DONE_RATIO" 94 | }, 95 | { 96 | "headerName": "Private", 97 | "type": "IS_PRIVATE" 98 | }, 99 | { 100 | "headerName": "Estimated time", 101 | "type": "ESTIMATED_HOURS" 102 | }, 103 | { 104 | "headerName": "Field1", 105 | "type": "CUSTOM_FIELD", 106 | "customFieldId": 1 107 | }, 108 | { 109 | "headerName": "Field2", 110 | "type": "CUSTOM_FIELD", 111 | "customFieldId": 2 112 | } 113 | ] 114 | } -------------------------------------------------------------------------------- /sample/config-update-with-issue_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "UPDATE", 3 | "readmineUrl": "http://192.168.33.10", 4 | "apiKey": "6c6b3511f8ed975364299d6e729cd296f8c2b779", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "#", 9 | "type": "ISSUE_ID", 10 | "primaryKey": true 11 | }, 12 | { 13 | "headerName": "Project", 14 | "type": "PROJECT_ID", 15 | "mappings": { 16 | "Project A": 1, 17 | "Project B": 2 18 | } 19 | }, 20 | { 21 | "headerName": "Tracker", 22 | "type": "TRACKER_ID", 23 | "mappings": { 24 | "Bug": 1, 25 | "Feature": 2, 26 | "Support": 3 27 | } 28 | }, 29 | { 30 | "headerName": "Status", 31 | "type": "STATUS_ID", 32 | "mappings": { 33 | "New": 1, 34 | "In Progress": 2, 35 | "Resolved": 3, 36 | "Feedback": 4, 37 | "Closed": 5, 38 | "Rejected": 6 39 | } 40 | }, 41 | { 42 | "headerName": "Priority", 43 | "type": "PRIORITY_ID", 44 | "mappings": { 45 | "Low": 1, 46 | "Normal": 2, 47 | "High": 3, 48 | "Urgent": 4, 49 | "Immediate": 5 50 | } 51 | }, 52 | { 53 | "headerName": "Assignee", 54 | "type": "ASSIGNED_TO_ID", 55 | "mappings": { 56 | "User A": 5, 57 | "User B": 6 58 | } 59 | }, 60 | { 61 | "headerName": "Category", 62 | "type": "CATEGORY_ID", 63 | "mappings": { 64 | "Category1": 1, 65 | "Category2": 2 66 | } 67 | }, 68 | { 69 | "headerName": "Target version", 70 | "type": "FIXED_VERSION_ID", 71 | "mappings": { 72 | "v1.0": 1, 73 | "v2.0": 2 74 | } 75 | }, 76 | { 77 | "headerName": "Parent #", 78 | "type": "PARENT_ISSUE_ID" 79 | }, 80 | { 81 | "headerName": "Subject", 82 | "type": "SUBJECT" 83 | }, 84 | { 85 | "headerName": "Description", 86 | "type": "DESCRIPTION" 87 | }, 88 | { 89 | "headerName": "Start date", 90 | "type": "START_DATE" 91 | }, 92 | { 93 | "headerName": "Due date", 94 | "type": "DUE_DATE" 95 | }, 96 | { 97 | "headerName": "% Done", 98 | "type": "DONE_RATIO" 99 | }, 100 | { 101 | "headerName": "Private", 102 | "type": "IS_PRIVATE" 103 | }, 104 | { 105 | "headerName": "Estimated time", 106 | "type": "ESTIMATED_HOURS" 107 | }, 108 | { 109 | "headerName": "Field1", 110 | "type": "CUSTOM_FIELD", 111 | "customFieldId": 1 112 | } 113 | ] 114 | } 115 | -------------------------------------------------------------------------------- /sample/config-update-with-custom_field.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "UPDATE", 3 | "readmineUrl": "http://192.168.33.10", 4 | "apiKey": "6c6b3511f8ed975364299d6e729cd296f8c2b779", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Project", 9 | "type": "PROJECT_ID", 10 | "mappings": { 11 | "Project A": 1, 12 | "Project B": 2 13 | } 14 | }, 15 | { 16 | "headerName": "Tracker", 17 | "type": "TRACKER_ID", 18 | "mappings": { 19 | "Bug": 1, 20 | "Feature": 2, 21 | "Support": 3 22 | } 23 | }, 24 | { 25 | "headerName": "Status", 26 | "type": "STATUS_ID", 27 | "mappings": { 28 | "New": 1, 29 | "In Progress": 2, 30 | "Resolved": 3, 31 | "Feedback": 4, 32 | "Closed": 5, 33 | "Rejected": 6 34 | } 35 | }, 36 | { 37 | "headerName": "Priority", 38 | "type": "PRIORITY_ID", 39 | "mappings": { 40 | "Low": 1, 41 | "Normal": 2, 42 | "High": 3, 43 | "Urgent": 4, 44 | "Immediate": 5 45 | } 46 | }, 47 | { 48 | "headerName": "Assignee", 49 | "type": "ASSIGNED_TO_ID", 50 | "mappings": { 51 | "User A": 5, 52 | "User B": 6 53 | } 54 | }, 55 | { 56 | "headerName": "Category", 57 | "type": "CATEGORY_ID", 58 | "mappings": { 59 | "Category1": 1, 60 | "Category2": 2 61 | } 62 | }, 63 | { 64 | "headerName": "Target version", 65 | "type": "FIXED_VERSION_ID", 66 | "mappings": { 67 | "v1.0": 1, 68 | "v2.0": 2 69 | } 70 | }, 71 | { 72 | "headerName": "Parent #", 73 | "type": "PARENT_ISSUE_ID" 74 | }, 75 | { 76 | "headerName": "Subject", 77 | "type": "SUBJECT" 78 | }, 79 | { 80 | "headerName": "Description", 81 | "type": "DESCRIPTION" 82 | }, 83 | { 84 | "headerName": "Start date", 85 | "type": "START_DATE" 86 | }, 87 | { 88 | "headerName": "Due date", 89 | "type": "DUE_DATE" 90 | }, 91 | { 92 | "headerName": "% Done", 93 | "type": "DONE_RATIO" 94 | }, 95 | { 96 | "headerName": "Private", 97 | "type": "IS_PRIVATE" 98 | }, 99 | { 100 | "headerName": "Estimated time", 101 | "type": "ESTIMATED_HOURS" 102 | }, 103 | { 104 | "headerName": "Field1", 105 | "type": "CUSTOM_FIELD", 106 | "customFieldId": 1 107 | }, 108 | { 109 | "headerName": "Field3", 110 | "type": "CUSTOM_FIELD", 111 | "customFieldId": 3, 112 | "primaryKey": true 113 | } 114 | ] 115 | } 116 | -------------------------------------------------------------------------------- /sample/config-create.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://192.168.33.10", 4 | "apiKey": "6c6b3511f8ed975364299d6e729cd296f8c2b779", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Project", 9 | "type": "PROJECT_ID", 10 | "mappings": { 11 | "Project A": 1, 12 | "Project B": 2 13 | } 14 | }, 15 | { 16 | "headerName": "Tracker", 17 | "type": "TRACKER_ID", 18 | "mappings": { 19 | "Bug": 1, 20 | "Feature": 2, 21 | "Support": 3 22 | } 23 | }, 24 | { 25 | "headerName": "Status", 26 | "type": "STATUS_ID", 27 | "mappings": { 28 | "New": 1, 29 | "In Progress": 2, 30 | "Resolved": 3, 31 | "Feedback": 4, 32 | "Closed": 5, 33 | "Rejected": 6 34 | } 35 | }, 36 | { 37 | "headerName": "Priority", 38 | "type": "PRIORITY_ID", 39 | "mappings": { 40 | "Low": 1, 41 | "Normal": 2, 42 | "High": 3, 43 | "Urgent": 4, 44 | "Immediate": 5 45 | } 46 | }, 47 | { 48 | "headerName": "Assignee", 49 | "type": "ASSIGNED_TO_ID", 50 | "mappings": { 51 | "User A": 5, 52 | "User B": 6 53 | } 54 | }, 55 | { 56 | "headerName": "Category", 57 | "type": "CATEGORY_ID", 58 | "mappings": { 59 | "Category1": 1, 60 | "Category2": 2 61 | } 62 | }, 63 | { 64 | "headerName": "Target version", 65 | "type": "FIXED_VERSION_ID", 66 | "mappings": { 67 | "v1.0": 1, 68 | "v2.0": 2 69 | } 70 | }, 71 | { 72 | "headerName": "Parent #", 73 | "type": "PARENT_ISSUE_ID" 74 | }, 75 | { 76 | "headerName": "Subject", 77 | "type": "SUBJECT" 78 | }, 79 | { 80 | "headerName": "Description", 81 | "type": "DESCRIPTION" 82 | }, 83 | { 84 | "headerName": "Start date", 85 | "type": "START_DATE" 86 | }, 87 | { 88 | "headerName": "Due date", 89 | "type": "DUE_DATE" 90 | }, 91 | { 92 | "headerName": "% Done", 93 | "type": "DONE_RATIO" 94 | }, 95 | { 96 | "headerName": "Private", 97 | "type": "IS_PRIVATE" 98 | }, 99 | { 100 | "headerName": "Estimated time", 101 | "type": "ESTIMATED_HOURS" 102 | }, 103 | { 104 | "headerName": "Field1", 105 | "type": "CUSTOM_FIELD", 106 | "customFieldId": 1 107 | }, 108 | { 109 | "headerName": "Watchers", 110 | "type": "WATCHER_USER_IDS", 111 | "multipleItemSeparator": ";", 112 | "mappings": { 113 | "User A": 5, 114 | "User B": 6 115 | } 116 | } 117 | ] 118 | } 119 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/test/resources/com/github/onozaty/redmine/issue/loader/create-all_fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "CREATE", 3 | "readmineUrl": "http://localhost", 4 | "apiKey": "apikey1234567890", 5 | "csvEncoding": "UTF-8", 6 | "fields": [ 7 | { 8 | "headerName": "Project", 9 | "type": "PROJECT_ID", 10 | "mappings": { 11 | "プロジェクト1": 1, 12 | "プロジェクト2": 2 13 | } 14 | }, 15 | { 16 | "headerName": "Tracker", 17 | "type": "TRACKER_ID", 18 | "mappings": { 19 | "トラッカー1": 1, 20 | "トラッカー2": 2, 21 | "トラッカー3": 3 22 | } 23 | }, 24 | { 25 | "headerName": "Status", 26 | "type": "STATUS_ID", 27 | "mappings": { 28 | "新規": 1, 29 | "進行中": 2, 30 | "解決": 3 31 | } 32 | }, 33 | { 34 | "headerName": "Priority", 35 | "type": "PRIORITY_ID", 36 | "mappings": { 37 | "低め": 1, 38 | "通常": 2, 39 | "高め": 3 40 | } 41 | }, 42 | { 43 | "headerName": "Assignee", 44 | "type": "ASSIGNED_TO_ID", 45 | "mappings": { 46 | "ユーザA": 5, 47 | "ユーザB": 6 48 | } 49 | }, 50 | { 51 | "headerName": "Category", 52 | "type": "CATEGORY_ID", 53 | "mappings": { 54 | "カテゴリ1": 1, 55 | "カテゴリ2": 2 56 | } 57 | }, 58 | { 59 | "headerName": "Target version", 60 | "type": "FIXED_VERSION_ID", 61 | "mappings": { 62 | "v1.0": 1, 63 | "v2.0": 2 64 | } 65 | }, 66 | { 67 | "headerName": "Parent #", 68 | "type": "PARENT_ISSUE_ID" 69 | }, 70 | { 71 | "headerName": "Subject", 72 | "type": "SUBJECT" 73 | }, 74 | { 75 | "headerName": "Description", 76 | "type": "DESCRIPTION" 77 | }, 78 | { 79 | "headerName": "Start date", 80 | "type": "START_DATE" 81 | }, 82 | { 83 | "headerName": "Due date", 84 | "type": "DUE_DATE" 85 | }, 86 | { 87 | "headerName": "% Done", 88 | "type": "DONE_RATIO" 89 | }, 90 | { 91 | "headerName": "Private", 92 | "type": "IS_PRIVATE" 93 | }, 94 | { 95 | "headerName": "Estimated time", 96 | "type": "ESTIMATED_HOURS" 97 | }, 98 | { 99 | "headerName": "Field1", 100 | "type": "CUSTOM_FIELD", 101 | "customFieldId": 1 102 | }, 103 | { 104 | "headerName": "Field2", 105 | "type": "CUSTOM_FIELD", 106 | "customFieldId": 2 107 | }, 108 | { 109 | "headerName": "Field3", 110 | "type": "CUSTOM_FIELD", 111 | "customFieldId": 3, 112 | "multipleItemSeparator": ";" 113 | }, 114 | { 115 | "headerName": "Watchers", 116 | "type": "WATCHER_USER_IDS", 117 | "multipleItemSeparator": ";", 118 | "mappings": { 119 | "ユーザA": 5, 120 | "ユーザB": 6 121 | } 122 | } 123 | ] 124 | } 125 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/input/FieldType.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.input; 2 | 3 | import java.time.LocalDate; 4 | import java.time.format.DateTimeFormatter; 5 | import java.time.format.DateTimeParseException; 6 | import java.util.Arrays; 7 | import java.util.Collections; 8 | import java.util.List; 9 | import java.util.function.Function; 10 | 11 | import org.apache.commons.lang3.StringUtils; 12 | 13 | import lombok.Getter; 14 | 15 | public enum FieldType { 16 | 17 | ISSUE_ID("id"), 18 | 19 | PROJECT_ID("project_id"), 20 | 21 | TRACKER_ID("tracker_id"), 22 | 23 | STATUS_ID("status_id"), 24 | 25 | PRIORITY_ID("priority_id"), 26 | 27 | ASSIGNED_TO_ID("assigned_to_id"), 28 | 29 | CATEGORY_ID("category_id"), 30 | 31 | FIXED_VERSION_ID("fixed_version_id"), 32 | 33 | PARENT_ISSUE_ID("parent_issue_id"), 34 | 35 | SUBJECT("subject"), 36 | 37 | DESCRIPTION("description"), 38 | 39 | START_DATE("start_date", FieldType::normalizeDate), 40 | 41 | DUE_DATE("due_date", FieldType::normalizeDate), 42 | 43 | DONE_RATIO("done_ratio"), 44 | 45 | IS_PRIVATE("is_private", StringUtils::lowerCase), 46 | 47 | ESTIMATED_HOURS("estimated_hours"), 48 | 49 | CUSTOM_FIELD("custom_field"), 50 | 51 | WATCHER_USER_IDS("watcher_user_ids"); 52 | 53 | @Getter 54 | private final String fieldName; 55 | 56 | private final Function normalize; 57 | 58 | private FieldType(String fieldName, Function normalize) { 59 | this.fieldName = fieldName; 60 | this.normalize = normalize; 61 | } 62 | 63 | private FieldType(String fieldName) { 64 | this.fieldName = fieldName; 65 | this.normalize = x -> x; 66 | } 67 | 68 | public String normalize(String value) { 69 | return normalize.apply(value); 70 | } 71 | 72 | private static final List BEFORE_NORMALIZE_DATE_FORMATTERS = Collections.unmodifiableList( 73 | Arrays.asList( 74 | DateTimeFormatter.ofPattern("yyyy/M/d"), 75 | DateTimeFormatter.ofPattern("yyyy-M-d"))); 76 | 77 | private static final DateTimeFormatter NORMALIZED_DATE_FORMATTER = DateTimeFormatter 78 | .ofPattern("yyyy-MM-dd"); 79 | 80 | private static String normalizeDate(String value) { 81 | 82 | if (StringUtils.isEmpty(value)) { 83 | return value; 84 | } 85 | 86 | LocalDate date = null; 87 | for (DateTimeFormatter dateFormatter : BEFORE_NORMALIZE_DATE_FORMATTERS) { 88 | try { 89 | date = LocalDate.parse(value, dateFormatter); 90 | // 変換できたら抜ける 91 | break; 92 | } catch (DateTimeParseException e) { 93 | // 変換できなかったら次へ 94 | } 95 | } 96 | 97 | if (date == null) { 98 | // 全てのフォーマットに一致しなかった場合 99 | throw new IllegalArgumentException(String.format("%s is invalid date format.", value)); 100 | } 101 | 102 | return NORMALIZED_DATE_FORMATTER.format(date); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/client/Client.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.client; 2 | 3 | import java.io.IOException; 4 | import java.util.List; 5 | import java.util.Map; 6 | import java.util.concurrent.TimeUnit; 7 | 8 | import org.apache.commons.lang3.StringUtils; 9 | 10 | import com.fasterxml.jackson.databind.ObjectMapper; 11 | import com.fasterxml.jackson.databind.PropertyNamingStrategies; 12 | import com.github.onozaty.redmine.issue.loader.input.BasicAuth; 13 | 14 | import lombok.Builder; 15 | import lombok.Value; 16 | import okhttp3.HttpUrl; 17 | import okhttp3.MediaType; 18 | import okhttp3.OkHttpClient; 19 | import okhttp3.Request; 20 | import okhttp3.RequestBody; 21 | import okhttp3.Response; 22 | 23 | @Value 24 | public class Client { 25 | 26 | private final OkHttpClient httpClient; 27 | 28 | private final ObjectMapper objectMapper = new ObjectMapper() 29 | .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); 30 | 31 | private final String redmineBaseUrl; 32 | 33 | private final String apiKey; 34 | 35 | private final BasicAuth basicAuth; 36 | 37 | @Builder 38 | public Client(String redmineBaseUrl, String apiKey, BasicAuth basicAuth, int timeout) { 39 | this.redmineBaseUrl = redmineBaseUrl; 40 | this.apiKey = apiKey; 41 | this.basicAuth = basicAuth; 42 | 43 | httpClient = new OkHttpClient.Builder() 44 | .connectTimeout(timeout, TimeUnit.SECONDS) 45 | .readTimeout(timeout, TimeUnit.SECONDS) 46 | .writeTimeout(timeout, TimeUnit.SECONDS) 47 | .build(); 48 | } 49 | 50 | public List getIssues(List queryParameters) throws IOException { 51 | return get("issues.json", queryParameters, IssuesBody.class).getIssues(); 52 | } 53 | 54 | public int createIssue(Map targetFields) throws IOException { 55 | return (int) post("issues.json", new IssueBody(targetFields), IssueBody.class).getFields().get("id"); 56 | } 57 | 58 | public void updateIssue(int issueId, Map targetFields) throws IOException { 59 | put("issues/" + issueId + ".json", new IssueBody(targetFields)); 60 | } 61 | 62 | private T get(String path, List queryParameters, Class responseType) throws IOException { 63 | 64 | okhttp3.HttpUrl.Builder httpUrlBuilder = HttpUrl.get(redmineBaseUrl) 65 | .resolve(path) 66 | .newBuilder(); 67 | 68 | for (QueryParameter queryParameter : queryParameters) { 69 | httpUrlBuilder.addQueryParameter(queryParameter.getName(), queryParameter.getValue()); 70 | } 71 | 72 | Request request = newRequestBuilder(httpUrlBuilder.build()) 73 | .build(); 74 | 75 | Response response = httpClient.newCall(request).execute(); 76 | 77 | if (!response.isSuccessful()) { 78 | throw new IOException("Failed to call Redmine API. " + response); 79 | } 80 | 81 | return objectMapper.readValue( 82 | response.body().string(), 83 | responseType); 84 | } 85 | 86 | private T post(String path, Object body, Class responseType) throws IOException { 87 | 88 | HttpUrl url = HttpUrl.get(redmineBaseUrl) 89 | .resolve(path) 90 | .newBuilder() 91 | .build(); 92 | 93 | RequestBody requestBody = RequestBody.create( 94 | objectMapper.writeValueAsString(body), 95 | MediaType.get("application/json; charset=utf-8")); 96 | 97 | Request request = newRequestBuilder(url) 98 | .post(requestBody) 99 | .build(); 100 | 101 | Response response = httpClient.newCall(request).execute(); 102 | 103 | if (!response.isSuccessful()) { 104 | throw new IOException("Failed to call Redmine API. " + response); 105 | } 106 | 107 | return objectMapper.readValue( 108 | response.body().string(), 109 | responseType); 110 | } 111 | 112 | private void put(String path, Object body) throws IOException { 113 | 114 | HttpUrl url = HttpUrl.get(redmineBaseUrl) 115 | .resolve(path) 116 | .newBuilder() 117 | .build(); 118 | 119 | RequestBody requestBody = RequestBody.create( 120 | objectMapper.writeValueAsString(body), 121 | MediaType.get("application/json; charset=utf-8")); 122 | 123 | Request request = newRequestBuilder(url) 124 | .put(requestBody) 125 | .build(); 126 | 127 | Response response = httpClient.newCall(request).execute(); 128 | 129 | if (!response.isSuccessful()) { 130 | throw new IOException("Failed to call Redmine API. " + response); 131 | } 132 | } 133 | 134 | private Request.Builder newRequestBuilder(HttpUrl url) { 135 | 136 | Request.Builder requestBuilder = new Request.Builder(); 137 | requestBuilder.url(url); 138 | 139 | if (StringUtils.isNotEmpty(apiKey)) { 140 | requestBuilder.addHeader("X-Redmine-API-Key", apiKey); 141 | } else { 142 | requestBuilder.addHeader("Authorization", basicAuth.toAuthorizationValue()); 143 | } 144 | 145 | return requestBuilder; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/IssueLoadRunner.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader; 2 | 3 | import java.io.IOException; 4 | import java.io.PrintStream; 5 | import java.nio.file.Path; 6 | import java.nio.file.Paths; 7 | 8 | import com.github.onozaty.redmine.issue.loader.client.Client; 9 | import com.github.onozaty.redmine.issue.loader.input.Config; 10 | import com.github.onozaty.redmine.issue.loader.input.FieldSetting; 11 | import com.github.onozaty.redmine.issue.loader.input.FieldType; 12 | import com.github.onozaty.redmine.issue.loader.input.IssueId; 13 | import com.github.onozaty.redmine.issue.loader.input.IssueRecord; 14 | import com.github.onozaty.redmine.issue.loader.input.IssueRecords; 15 | import com.github.onozaty.redmine.issue.loader.input.LoadMode; 16 | 17 | import lombok.AllArgsConstructor; 18 | import lombok.NoArgsConstructor; 19 | 20 | @AllArgsConstructor 21 | @NoArgsConstructor 22 | public class IssueLoadRunner { 23 | 24 | private PrintStream out; 25 | 26 | public static void main(String[] args) throws IOException { 27 | 28 | if (args.length != 2) { 29 | System.err.println("usage: java -jar redmine-issue-loader-all.jar "); 30 | System.exit(1); 31 | } 32 | 33 | Path configPath = Paths.get(args[0]); 34 | Path csvPath = Paths.get(args[1]); 35 | 36 | System.out.println("Processing start..."); 37 | 38 | IssueLoadRunner issueLoadRunner = new IssueLoadRunner(System.out); 39 | int loadedCount = issueLoadRunner.execute(configPath, csvPath); 40 | 41 | System.out.println( 42 | String.format("Processing is completed. %d issues were loaded.", loadedCount)); 43 | } 44 | 45 | public int execute(Path configPath, Path csvPath) throws IOException { 46 | 47 | Config config = Config.of(configPath); 48 | 49 | return execute(config, csvPath); 50 | } 51 | 52 | public int execute(Config config, Path csvPath) throws IOException { 53 | 54 | validate(config); 55 | 56 | Client client = Client.builder() 57 | .redmineBaseUrl(config.getReadmineUrl()) 58 | .apiKey(config.getApiKey()) 59 | .basicAuth(config.getBasicAuth()) 60 | .timeout(config.getTimeout()) 61 | .build(); 62 | 63 | IssueLoader loader = new IssueLoader(client); 64 | 65 | int issueCount = 0; 66 | try (IssueRecords issueRecords = IssueRecords.parse(csvPath, config)) { 67 | 68 | for (IssueRecord issueRecord : issueRecords) { 69 | 70 | if (config.getMode() == LoadMode.CREATE) { 71 | IssueId issueId = loader.create(issueRecord.getFields()); 72 | println(String.format("#%d is created.", issueId.getId())); 73 | } else { 74 | IssueId issueId = loader.update(issueRecord.getPrimaryKey(), issueRecord.getFields()); 75 | println(String.format("#%d is updated.", issueId.getId())); 76 | } 77 | 78 | issueCount++; 79 | } 80 | } 81 | 82 | return issueCount; 83 | } 84 | 85 | private void validate(Config config) { 86 | 87 | if (config.getMode() == LoadMode.CREATE) { 88 | // 新規作成 89 | 90 | // プロジェクトID、Subjectは必須 91 | if (config.getFields().stream().noneMatch(x -> x.getType() == FieldType.PROJECT_ID) 92 | || config.getFields().stream().noneMatch(x -> x.getType() == FieldType.SUBJECT)) { 93 | throw new IllegalArgumentException("Project ID and Subject are required when created."); 94 | } 95 | 96 | } else { 97 | // 更新 98 | 99 | long primaryKeyCount = config.getFields().stream() 100 | .filter(FieldSetting::isPrimaryKey) 101 | .count(); 102 | 103 | // プライマリーキーが1件ではない場合はエラー 104 | if (primaryKeyCount == 0) { 105 | throw new IllegalArgumentException("Primary key was not found."); 106 | } else if (primaryKeyCount > 1) { 107 | throw new IllegalArgumentException("There are multiple primary keys."); 108 | } 109 | 110 | if (config.getFields().size() == 1) { 111 | // プライマリーキーしかない 112 | // -> 更新対象のフィールドが無い 113 | throw new IllegalArgumentException("The field to be updated is not set."); 114 | } 115 | 116 | if (config.getFields().stream() 117 | .anyMatch(x -> x.getType() == FieldType.ISSUE_ID && !x.isPrimaryKey())) { 118 | // チケットIDがプライマリキー以外で指定 119 | throw new IllegalArgumentException("Issue ID can only be used as a primary key."); 120 | } 121 | 122 | FieldType primaryKeyFieldType = config.getFields().stream() 123 | .filter(FieldSetting::isPrimaryKey) 124 | .map(FieldSetting::getType) 125 | .findFirst() 126 | .get(); 127 | 128 | if (primaryKeyFieldType != FieldType.ISSUE_ID 129 | && primaryKeyFieldType != FieldType.CUSTOM_FIELD) { 130 | // チケットIDとカスタムフィールド以外はプライマリキーとして使用不可 131 | throw new IllegalArgumentException( 132 | "Field type [" + primaryKeyFieldType + "] can not be used as a primary key."); 133 | } 134 | } 135 | } 136 | 137 | private void println(String message) { 138 | if (out != null) { 139 | out.println(message); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/main/java/com/github/onozaty/redmine/issue/loader/input/IssueRecords.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader.input; 2 | 3 | import java.io.Closeable; 4 | import java.io.IOException; 5 | import java.io.InputStreamReader; 6 | import java.io.Reader; 7 | import java.nio.charset.Charset; 8 | import java.nio.file.Files; 9 | import java.nio.file.Path; 10 | import java.util.Arrays; 11 | import java.util.Collections; 12 | import java.util.Iterator; 13 | import java.util.List; 14 | import java.util.stream.Collectors; 15 | import java.util.stream.Stream; 16 | 17 | import org.apache.commons.csv.CSVFormat; 18 | import org.apache.commons.csv.CSVParser; 19 | import org.apache.commons.csv.CSVRecord; 20 | import org.apache.commons.io.input.BOMInputStream; 21 | import org.apache.commons.lang3.StringUtils; 22 | 23 | import lombok.AccessLevel; 24 | import lombok.RequiredArgsConstructor; 25 | 26 | @RequiredArgsConstructor(access = AccessLevel.PRIVATE) 27 | public class IssueRecords implements Iterable, Closeable { 28 | 29 | private final Config config; 30 | private final CSVParser csvParser; 31 | 32 | public static IssueRecords parse(Path csvPath, Config config) throws IOException { 33 | 34 | Reader csvReader = new InputStreamReader( 35 | // UTF-8のBOMを考慮 36 | new BOMInputStream(Files.newInputStream(csvPath)), Charset.forName(config.getCsvEncoding())); 37 | 38 | return new IssueRecords( 39 | config, 40 | CSVFormat.EXCEL.builder() 41 | .setHeader() 42 | .build() 43 | .parse(csvReader)); 44 | } 45 | 46 | @Override 47 | public Iterator iterator() { 48 | 49 | Iterator csvIterator = csvParser.iterator(); 50 | 51 | return new Iterator() { 52 | 53 | @Override 54 | public boolean hasNext() { 55 | return csvIterator.hasNext(); 56 | } 57 | 58 | @Override 59 | public IssueRecord next() { 60 | 61 | CSVRecord nextCsvRecord = csvIterator.next(); 62 | if (nextCsvRecord == null) { 63 | return null; 64 | } 65 | 66 | return toIssueRecord(nextCsvRecord); 67 | } 68 | }; 69 | } 70 | 71 | @Override 72 | public void close() throws IOException { 73 | csvParser.close(); 74 | } 75 | 76 | private IssueRecord toIssueRecord(CSVRecord csvRecord) { 77 | 78 | PrimaryKey primaryKey = null; 79 | IssueTargetFieldsBuilder targetFieldsBuilder = new IssueTargetFieldsBuilder(); 80 | 81 | for (FieldSetting fieldSetting : config.getFields()) { 82 | 83 | String value = csvRecord.get(fieldSetting.getHeaderName()); 84 | 85 | FieldType fieldType = fieldSetting.getType(); 86 | switch (fieldType) { 87 | case ISSUE_ID: 88 | 89 | value = convertValue(value, fieldSetting); 90 | primaryKey = new IssueId(Integer.parseInt(value)); 91 | break; 92 | 93 | case CUSTOM_FIELD: 94 | 95 | // 複数選択の場合は文字列のリスト、それ以外は文字列 96 | Object customFiledValue; 97 | if (StringUtils.isNotEmpty(fieldSetting.getMultipleItemSeparator())) { 98 | 99 | customFiledValue = Stream.of(StringUtils.split(value, fieldSetting.getMultipleItemSeparator())) 100 | .map(v -> convertValue(v, fieldSetting)) 101 | .collect(Collectors.toList()); 102 | } else { 103 | 104 | customFiledValue = convertValue(value, fieldSetting); 105 | } 106 | 107 | CustomField customField = new CustomField(fieldSetting.getCustomFieldId(), customFiledValue); 108 | 109 | if (fieldSetting.isPrimaryKey()) { 110 | primaryKey = customField; 111 | } else { 112 | targetFieldsBuilder.customField(customField); 113 | } 114 | 115 | break; 116 | 117 | case WATCHER_USER_IDS: 118 | 119 | // ウォッチャーはリスト 120 | 121 | List watcherUserIds; 122 | 123 | if (StringUtils.isEmpty(value)) { 124 | 125 | watcherUserIds = Collections.emptyList(); 126 | 127 | } else if (StringUtils.isNotEmpty(fieldSetting.getMultipleItemSeparator())) { 128 | 129 | watcherUserIds = Stream.of(StringUtils.split(value, fieldSetting.getMultipleItemSeparator())) 130 | .map(v -> convertValue(v, fieldSetting)) 131 | .map(Integer::valueOf) 132 | .collect(Collectors.toList()); 133 | 134 | } else { 135 | 136 | // 区切り文字が無い場合、1ユーザとして登録 137 | watcherUserIds = Arrays.asList(Integer.valueOf(convertValue(value, fieldSetting))); 138 | } 139 | 140 | targetFieldsBuilder.field(fieldType, watcherUserIds); 141 | 142 | break; 143 | 144 | default: 145 | // その他の項目は更新対象フィールドとして利用 146 | value = convertValue(value, fieldSetting); 147 | targetFieldsBuilder.field(fieldType, value); 148 | break; 149 | } 150 | } 151 | 152 | return new IssueRecord(primaryKey, targetFieldsBuilder.build()); 153 | } 154 | 155 | private String convertValue(String value, FieldSetting fieldSetting) { 156 | 157 | if (value.isEmpty()) { 158 | return value; 159 | } 160 | 161 | if (config.getReplaceString() != null) { 162 | // 置換文字が設定されている場合、置換後の文字を使う 163 | value = config.getReplaceString().replace(value); 164 | } 165 | 166 | if (fieldSetting.getMappings() == null) { 167 | // 変換表が無い場合、正規化だけ行う 168 | return fieldSetting.getType().normalize(value); 169 | } 170 | 171 | // 変換表がある場合、CSVから取り出した値を変換 172 | String convertedValue = fieldSetting.getMappings().get(value); 173 | 174 | if (convertedValue == null) { 175 | // 一致するものが無い場合エラー 176 | throw new IllegalArgumentException( 177 | String.format( 178 | "Could not mapping \"%s\" of field [%s].", 179 | value, 180 | fieldSetting.getHeaderName())); 181 | } 182 | 183 | return convertedValue; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /README.ja.md: -------------------------------------------------------------------------------- 1 | # redmine-issue-loader 2 | 3 | CSVファイルに記載された情報を読み込んで、Redmineにチケットとして登録、更新するツールです。 4 | 5 | 下記のような特徴があります。 6 | 7 | * 複数のチケットをまとめて作成、更新できる。 8 | * 設定ファイルにマッピング情報を記載することによって、CSVファイルの内容を変換してRedmineへ登録することができる。 9 | * チケットIDだけでなく、カスタムフィールドをキーとしてチケットを更新することができる。 10 | ただし、そのカスタムフィールドの値がシステム全体で一意である必要あり。 11 | 12 | ## 利用方法 13 | 14 | 実行にはJava(JDK8以上)が必要となります。 15 | 16 | 下記から最新の実行ファイル(`redmine-issue-loader-x.x.x-all.jar`)を入手します。 17 | 18 | * https://github.com/onozaty/redmine-issue-loader/releases/latest 19 | 20 | 入手したjarファイルを指定してアプリケーションを実行します。 21 | 22 | ``` 23 | java -jar redmine-issue-loader-2.5.0-all.jar config.json issues.csv 24 | ``` 25 | 26 | 第1引数が設定ファイル、第2引数がチケットの情報が書かれたCSVファイルとなります。 27 | 28 | 実行すると、下記のように処理されたチケットの情報が出力されます。 29 | 30 | ``` 31 | Processing start... 32 | #1 is created. 33 | #2 is created. 34 | #3 is created. 35 | Processing is completed. 3 issues were loaded. 36 | ``` 37 | 38 | ## 設定ファイル 39 | 40 | 設定ファイルには、Redmineの接続情報や、CSVファイルとRedmine上のフィールドのマッピング情報を記載します。 41 | 42 | ### 例: 新規作成時 43 | 44 | 新規作成時の設定ファイルの例です。 45 | 46 | ```json 47 | { 48 | "mode": "CREATE", 49 | "readmineUrl": "http://192.168.33.10", 50 | "apiKey": "8fba5d86e1d310d13860ba7ddd96be1b69743e7f", 51 | "csvEncoding": "UTF-8", 52 | "fields": [ 53 | { 54 | "headerName": "Project", 55 | "type": "PROJECT_ID", 56 | "mappings" : { 57 | "Project A" : 1, 58 | "Project B" : 2 59 | } 60 | }, 61 | { 62 | "headerName": "Tracker", 63 | "type": "TRACKER_ID", 64 | "mappings" : { 65 | "Bug" : 1, 66 | "Feature" : 2, 67 | "Support" : 3 68 | } 69 | }, 70 | { 71 | "headerName": "Subject", 72 | "type": "SUBJECT" 73 | }, 74 | { 75 | "headerName": "Description", 76 | "type": "DESCRIPTION" 77 | }, 78 | { 79 | "headerName": "Field1", 80 | "type": "CUSTOM_FIELD", 81 | "customFieldId": 1 82 | }, 83 | { 84 | "headerName": "Field2", 85 | "type": "CUSTOM_FIELD", 86 | "customFieldId": 2, 87 | "multipleItemSeparator": ";" 88 | }, 89 | { 90 | "headerName": "Watchers", 91 | "type": "WATCHER_USER_IDS", 92 | "multipleItemSeparator": ";", 93 | "mappings": { 94 | "User A": 5, 95 | "User B": 6 96 | } 97 | } 98 | ] 99 | } 100 | ``` 101 | 102 | 上記に対応するCSVファイルの例です。 103 | 104 | ```csv 105 | Project,Tracker,Subject,Description,Field1,Field2,Watchers 106 | Project A,Bug,xxxx,yyyy,A,1;2,User A;User B 107 | Project B,Feature,aaaa,bbbb,,, 108 | Project B,Bug,zzzz,zzzz,1,2,User B 109 | ``` 110 | 111 | ### 例: 更新時 112 | 113 | 更新時の設定ファイルの例です。 114 | 115 | ```json 116 | { 117 | "mode": "UPDATE", 118 | "readmineUrl": "http://192.168.33.10", 119 | "apiKey": "8fba5d86e1d310d13860ba7ddd96be1b69743e7f", 120 | "csvEncoding": "UTF-8", 121 | "fields": [ 122 | { 123 | "headerName": "#", 124 | "type": "ISSUE_ID", 125 | "primaryKey": true 126 | }, 127 | { 128 | "headerName": "Status Id", 129 | "type": "STATUS_ID", 130 | "primaryKey": false 131 | }, 132 | { 133 | "headerName": "Field1", 134 | "type": "CUSTOM_FIELD", 135 | "customFieldId": 1, 136 | "primaryKey": false 137 | } 138 | ] 139 | } 140 | ``` 141 | 142 | 上記に対応するCSVファイルの例です。 143 | 144 | ```csv 145 | #,Subject,Status Id,Field1 146 | 1,xxxx,1,A 147 | 2,yyyy,2,B 148 | 3,zzzz,3,C 149 | ``` 150 | 151 | ### 各項目の内容 152 | 153 | 各項目の内容は下記の通りです。 154 | 155 | * `mode` : 処理モード。`CREATE`が新規作成、`UPDATE`が更新。 156 | * `readmineUrl` : Redmineの接続先URL。 157 | * `apiKey` : RedmineのAPIアクセスキー。 158 | * `basicAuth` : RedmineのBasic認証で利用する情報。(APIアクセスキーまたはBasic認証のどちらかを指定する必要があります) 159 | * `username` : Basic認証で利用するユーザ名。 160 | * `password` : Basic認証で利用するパスワード。 161 | * `timeout` : Redmineへリクエスト時のタイムアウト秒。未設定の場合は`10`となる。 162 | * `replaceString` : 文字列置換の設定。 163 | * `pattern` : 置換対象文字の正規表現。 164 | * `replacement` : 置換後の文字。 165 | * `csvEncoding` : CSVファイルのエンコーディング。 166 | * `fields` : CSVの各フィールド情報。CSV内の全てのフィールドを記載するのではなく、キーとして使用するものと、Redmineに登録するフィールドを記載する。 167 | * `headerName` : CSV内のヘッダ名。 168 | * `type` : 種別。種別として指定可能なものは後述。 169 | * `customFieldId` : カスタムフィールドのID。種別が`CUSTOM_FIELD`の場合に設定する。 170 | * `multipleItemSeparator` : 値を分割する文字。種別が`WATCHER_USER_IDS`、または`CUSTOM_FIELD`で複数選択の場合に設定する。 171 | * `primaryKey` : プライマリーキーか。更新時のみ有効な項目であり、`true`となっているフィールドの情報を使って更新対象のチケットを検索し、`false`となっているフィールドが更新されることとなる。 172 | * `mappings` : CSV上の値とRedmine上での値のマッピングを記載することによって、CSVの内容を変換して登録できる。たとえば、プロジェクト名をプロジェクトIDに変換する場合など。 173 | 174 | フィールドの種別として指定可能なものは、下記となります。 175 | 176 | |種別名|新規作成|更新|内容|ID確認URL| 177 | |------|-------|----|----|----| 178 | |`ISSUE_ID`|×|○|チケットのID。更新時のプライマリーキーとしてのみ指定可能。|-| 179 | |`PROJECT_ID`|○|○|プロジェクトのID。新規作成時は必須項目となる。|`/projects.xml`| 180 | |`TRACKER_ID`|○|○|トラッカーのID。|`/trackers.xml`| 181 | |`STATUS_ID`|○|○|ステータスのID。|`/issue_statuses.xml`| 182 | |`PRIORITY_ID`|○|○|優先度のID。|`/enumerations/issue_priorities.xml`| 183 | |`ASSIGNED_TO_ID`|○|○|担当者のID。|`/users.xml`| 184 | |`CATEGORY_ID`|○|○|カテゴリのID。|`/projects/:project_id/issue_categories.xml`
`:project_id`の部分は、対象プロジェクトのIDを指定。| 185 | |`FIXED_VERSION_ID`|○|○|対象バージョンのID。|`/projects/:project_id/versions.xml`
`:project_id`の部分は、対象プロジェクトのIDを指定。| 186 | |`PARENT_ISSUE_ID`|○|○|親チケットのID。|-| 187 | |`SUBJECT`|○|○|題名。新規作成時は必須項目となる。|-| 188 | |`DESCRIPTION`|○|○|説明。|-| 189 | |`START_DATE`|○|○|開始日。`YYYY-MM-DD`または`YYYY/MM/DD`形式にて。|-| 190 | |`DUE_DATE`|○|○|期日。`YYYY-MM-DD`または`YYYY/MM/DD`形式にて。|-| 191 | |`DONE_RATIO`|○|○|進捗率。|-| 192 | |`IS_PRIVATE`|○|○|プライベートか。`true`または`false`を指定。|-| 193 | |`ESTIMATED_HOURS`|○|○|予定工数。|-| 194 | |`CUSTOM_FIELD`|○|○|カスタムフィールド。更新時のプライマリーキーとしても利用できる。
この種別を指定する際には、`customFieldId`として対応するカスタムフィールドのIDを指定する必要がある。|`/custom_fields.xml`| 195 | |`WATCHER_USER_IDS`|○|×|ウォッチャーのID。更新には対応していない。|`/users.xml`| 196 | 197 | IDとして指定するものは、上記表のID確認URLでIDを確認することができます。 198 | 199 | たとえば、RedmineのURLが`http://localhost`となっている環境で、プロジェクトのIDを確認したい場合、`http://localhost/projects.xml`でアクセスすることによって、下記のような形式でプロジェクトのIDが確認できます。 200 | 201 | ```xml 202 | 203 | 204 | 1 205 | Project A 206 | a 207 | 208 | 1 209 | true 210 | 2019-01-05T12:46:56Z 211 | 2019-01-05T12:46:56Z 212 | 213 | 214 | 2 215 | Project B 216 | b 217 | 218 | 1 219 | true 220 | 2019-01-05T12:47:07Z 221 | 2019-01-05T12:47:07Z 222 | 223 | 224 | ``` 225 | 226 | CSV上でプロジェクト名で書かれている場合、上記の内容をもとに`mappings`を指定することによって、IDへの変換を行うことができます。 227 | ```json 228 | { 229 | "headerName": "Project", 230 | "type": "PROJECT_ID", 231 | "mappings" : { 232 | "Project A" : 1, 233 | "Project B" : 2 234 | } 235 | } 236 | ``` 237 | 238 | 設定ファイルとCSVファイルのサンプルは、`sample`フォルダ配下にありますので、そちらを参考にカスタマイズしてみてください。 239 | 240 | ## 注意事項 241 | 242 | * Redmine の REST API を利用しますので、REST APIが有効になっている必要があります。 243 | * カスタムフィールドをキーとする場合、対象のカスタムフィールドの設定として「フィルタとして使用」がONとなっている必要があります。 244 | 245 | ## ビルド方法 246 | 247 | ソースコードからビルドして利用する場合、Java(JDK8以上)がインストールされた環境で、下記コマンドでアプリケーションをビルドします。 248 | 249 | ``` 250 | gradlew shadowJar 251 | ``` 252 | 253 | `build/libs/redmine-issue-loader-x.x.x-all.jar`という実行ファイルが出来上がります。(`x.x.x`はバージョン番号) 254 | 255 | ## ライセンス 256 | 257 | MIT 258 | 259 | ## 作者 260 | 261 | [onozaty](https://github.com/onozaty) 262 | 263 | [スポンサー](https://github.com/sponsors/onozaty) となり、本プロジェクトを維持することに貢献していただける方を募集しています。 264 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redmine-issue-loader 2 | 3 | It load the issue information from the CSV file and registers or updates it as a issue to Redmine. 4 | 5 | It has the following characteristics. 6 | 7 | * Multiple tickets can be created or updated at once. 8 | * By setting the mapping information in the config file, contents of the CSV file can be converted and registered to Redmine. 9 | * It is possible to update the issue using not only the issue ID but also the custom field as a key. However, the value of that custom field must be unique throughout the system. 10 | 11 | ## Usage 12 | 13 | Java (JDK8 or higher) is required for execution. 14 | 15 | Download the latest jar file (`redmine-issue-loader-x.x.x-all.jar`) from below. 16 | 17 | * https://github.com/onozaty/redmine-issue-loader/releases/latest 18 | 19 | Execute the application with the following command. 20 | 21 | ``` 22 | java -jar redmine-issue-loader-2.4.1-all.jar config.json issues.csv 23 | ``` 24 | 25 | The first argument is the configuration file. The second argument will be the CSV file with the Issue information. 26 | 27 | When executed, information on the loaded issue is output as shown below. 28 | 29 | ``` 30 | Processing start... 31 | #1 is created. 32 | #2 is created. 33 | #3 is created. 34 | Processing is completed. 3 issues were loaded. 35 | ``` 36 | 37 | ## Configuration file 38 | 39 | In the configuration file, you will list the connection information of Redmine, the mapping information of the fields on the CSV file and Redmine. 40 | 41 | ### Ex: create issue 42 | 43 | An example of a configuration file when creating a new issue. 44 | 45 | ```json 46 | { 47 | "mode": "CREATE", 48 | "readmineUrl": "http://192.168.33.10", 49 | "apiKey": "8fba5d86e1d310d13860ba7ddd96be1b69743e7f", 50 | "csvEncoding": "UTF-8", 51 | "fields": [ 52 | { 53 | "headerName": "Project", 54 | "type": "PROJECT_ID", 55 | "mappings" : { 56 | "Project A" : 1, 57 | "Project B" : 2 58 | } 59 | }, 60 | { 61 | "headerName": "Tracker", 62 | "type": "TRACKER_ID", 63 | "mappings" : { 64 | "Bug" : 1, 65 | "Feature" : 2, 66 | "Support" : 3 67 | } 68 | }, 69 | { 70 | "headerName": "Subject", 71 | "type": "SUBJECT" 72 | }, 73 | { 74 | "headerName": "Description", 75 | "type": "DESCRIPTION" 76 | }, 77 | { 78 | "headerName": "Field1", 79 | "type": "CUSTOM_FIELD", 80 | "customFieldId": 1 81 | }, 82 | { 83 | "headerName": "Field2", 84 | "type": "CUSTOM_FIELD", 85 | "customFieldId": 2, 86 | "multipleItemSeparator": ";" 87 | }, 88 | { 89 | "headerName": "Watchers", 90 | "type": "WATCHER_USER_IDS", 91 | "multipleItemSeparator": ";", 92 | "mappings": { 93 | "User A": 5, 94 | "User B": 6 95 | } 96 | } 97 | ] 98 | } 99 | ``` 100 | 101 | An example of a CSV file corresponding to the above configuration file. 102 | 103 | ```csv 104 | Project,Tracker,Subject,Description,Field1,Field2,Watchers 105 | Project A,Bug,xxxx,yyyy,A,1;2,User A;User B 106 | Project B,Feature,aaaa,bbbb,,, 107 | Project B,Bug,zzzz,zzzz,1,2,User B 108 | ``` 109 | 110 | ### Ex: update issue 111 | 112 | An example of a configuration file when updating an issue. 113 | 114 | ```json 115 | { 116 | "mode": "UPDATE", 117 | "readmineUrl": "http://192.168.33.10", 118 | "apiKey": "8fba5d86e1d310d13860ba7ddd96be1b69743e7f", 119 | "csvEncoding": "UTF-8", 120 | "fields": [ 121 | { 122 | "headerName": "#", 123 | "type": "ISSUE_ID", 124 | "primaryKey": true 125 | }, 126 | { 127 | "headerName": "Status Id", 128 | "type": "STATUS_ID", 129 | "primaryKey": false 130 | }, 131 | { 132 | "headerName": "Field1", 133 | "type": "CUSTOM_FIELD", 134 | "customFieldId": 1, 135 | "primaryKey": false 136 | } 137 | ] 138 | } 139 | ``` 140 | 141 | An example of a configuration file when updating an issue. 142 | 143 | ```csv 144 | #,Subject,Status Id,Field1 145 | 1,xxxx,1,A 146 | 2,yyyy,2,B 147 | 3,zzzz,3,C 148 | ``` 149 | 150 | ### Contents of each item 151 | 152 | The contents of each item are as follows. 153 | 154 | * `mode` : processing mode. `CREATE` is newly created, `UPDATE` is updated. 155 | * `readmineUrl` : Redmine's URL. 156 | * `apiKey` : Redmine API access key. 157 | * `basicAuth` : Basic authentication. You must specify either API access key or Basic authentication. 158 | * `username` : User name used for basic authentication. 159 | * `password` : password used for basic authentication. 160 | * `timeout` : Timeout seconds when making a request to Redmine. If not set, it will be `10`. 161 | * `csvEncoding` : CSV file encoding. 162 | * `replaceString` : String replacement settings. 163 | * `pattern` : Regular expression of the character to be replaced. 164 | * `replacement` : The replacement character. 165 | * `fields` : CSV field information. It is not necessary to write all the CSV fields. Write what you use as the key and the field to register. 166 | * `headerName` : Header name in CSV. 167 | * `type` : Type. What can be specified as a type is described later. 168 | * `customFieldId` : ID of the custom field. Set if the type is `CUSTOM_FIELD`. 169 | * `multipleItemSeparator` : The character to separate the values. Set if the type is `WATCHER_USER_IDS` or `CUSTOM_FIELD` and multiple selection. 170 | * `primaryKey` : Primary key? Search for issues to be updated using the information of the field set to `true`, and the field` false` will be updated. It is not necessary to specify when mode is `CREATE`. 171 | * `mappings` : By describing the mapping between the value on CSV and the value on Redmine, contents of CSV can be converted and registered. For example, to convert a project name to a project ID. 172 | 173 | Items that can be specified as a type of field are as follows. 174 | 175 | |Type|mode: `CREATE`|mode: `UPDATE`|Contents|ID confirmation URL| 176 | |------|-------|----|----|----| 177 | |`ISSUE_ID`|×|○|Issue ID. It can only be specified as primary key for update.|-| 178 | |`PROJECT_ID`|○|○|Project ID. It is required item when new issue created.|`/projects.xml`| 179 | |`TRACKER_ID`|○|○|Tracker ID.|`/trackers.xml`| 180 | |`STATUS_ID`|○|○|Status ID.|`/issue_statuses.xml`| 181 | |`PRIORITY_ID`|○|○|Priority ID.|`/enumerations/issue_priorities.xml`| 182 | |`ASSIGNED_TO_ID`|○|○|Assignee ID.|`/users.xml`| 183 | |`CATEGORY_ID`|○|○|Category ID.|`/projects/:project_id/issue_categories.xml`
`:project_id` part specifies the ID of the target project.| 184 | |`FIXED_VERSION_ID`|○|○|Target version ID.|`/projects/:project_id/versions.xml`
`:project_id` part specifies the ID of the target project.| 185 | |`PARENT_ISSUE_ID`|○|○|Parent issue ID.|-| 186 | |`SUBJECT`|○|○|Subject. It is required item when new issue created.|-| 187 | |`DESCRIPTION`|○|○|Description.|-| 188 | |`START_DATE`|○|○|Start date. The format is `YYYY-MM-DD` or `YYYY/MM/DD`.|-| 189 | |`DUE_DATE`|○|○|Due date. The format is `YYYY-MM-DD` or `YYYY/MM/DD`.|-| 190 | |`DONE_RATIO`|○|○|Done rate.|-| 191 | |`IS_PRIVATE`|○|○|Private. `true` or `false`.|-| 192 | |`ESTIMATED_HOURS`|○|○|Estimated time.|-| 193 | |`CUSTOM_FIELD`|○|○|Custom field. It can also be used as a primary key for updating.
When specifying this type, you need to specify the ID of the corresponding custom field as `customFieldId`.|`/custom_fields.xml`| 194 | |`WATCHER_USER_IDS`|○|×|Watcher ID. It does not support update mode.|`/users.xml`| 195 | 196 | Items specified as ID can be confirmed with the ID confirmation URL in the table above. 197 | 198 | For example, in an environment where the URL of Redmine is `http://localhost`, if you want to check the ID of the project, you can confirm it by `http://localhost/projects.xml`. 199 | 200 | ```xml 201 | 202 | 203 | 1 204 | Project A 205 | a 206 | 207 | 1 208 | true 209 | 2019-01-05T12:46:56Z 210 | 2019-01-05T12:46:56Z 211 | 212 | 213 | 2 214 | Project B 215 | b 216 | 217 | 1 218 | true 219 | 2019-01-05T12:47:07Z 220 | 2019-01-05T12:47:07Z 221 | 222 | 223 | ``` 224 | 225 | If written in project name on CSV, you can convert to ID by specifying `mappings` based on the above contents. 226 | ```json 227 | { 228 | "headerName": "Project", 229 | "type": "PROJECT_ID", 230 | "mappings" : { 231 | "Project A" : 1, 232 | "Project B" : 2 233 | } 234 | } 235 | ``` 236 | 237 | Samples of the configuration file and CSV file are located under the `sample` folder, so please refer to that sample as a reference. 238 | 239 | ## Notes 240 | 241 | * It will use Redmine's REST API, so the REST API must be enabled. 242 | * When using a custom field as a key, "Used as a filter" must be ON as the target custom field setting. 243 | 244 | ## How to build 245 | 246 | When building from the source code, build the application with the following command in the environment where Java (JDK 8 or higher) is installed. 247 | 248 | ``` 249 | gradlew shadowJar 250 | ``` 251 | 252 | `build/libs/redmine-issue-loader-x.x.x-all.jar` will be created. (`x.x.x` is version number) 253 | 254 | ## License 255 | 256 | MIT 257 | 258 | ## Author 259 | 260 | [onozaty](https://github.com/onozaty) 261 | 262 | I am looking for people who are willing to become [sponsors](https://github.com/sponsors/onozaty) and contribute to maintaining this project. 263 | -------------------------------------------------------------------------------- /src/test/java/com/github/onozaty/redmine/issue/loader/IssueLoaderTest.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 5 | 6 | import java.io.IOException; 7 | 8 | import org.junit.Test; 9 | 10 | import com.github.onozaty.redmine.issue.loader.client.Client; 11 | import com.github.onozaty.redmine.issue.loader.input.BasicAuth; 12 | import com.github.onozaty.redmine.issue.loader.input.CustomField; 13 | import com.github.onozaty.redmine.issue.loader.input.FieldType; 14 | import com.github.onozaty.redmine.issue.loader.input.IssueId; 15 | import com.github.onozaty.redmine.issue.loader.input.IssueTargetFieldsBuilder; 16 | 17 | import okhttp3.mockwebserver.MockResponse; 18 | import okhttp3.mockwebserver.MockWebServer; 19 | import okhttp3.mockwebserver.RecordedRequest; 20 | 21 | public class IssueLoaderTest { 22 | 23 | @Test 24 | public void create_全項目() throws IOException, InterruptedException { 25 | 26 | try (MockWebServer server = new MockWebServer()) { 27 | 28 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":2}}")); 29 | 30 | server.start(); 31 | 32 | final String apiKey = "API1234567890"; 33 | Client client = createClient(server, apiKey); 34 | 35 | IssueLoader loader = new IssueLoader(client); 36 | IssueId issueId = loader.create( 37 | new IssueTargetFieldsBuilder() 38 | .field(FieldType.PROJECT_ID, "1") 39 | .field(FieldType.TRACKER_ID, "2") 40 | .field(FieldType.STATUS_ID, "3") 41 | .field(FieldType.PRIORITY_ID, "4") 42 | .field(FieldType.ASSIGNED_TO_ID, "5") 43 | .field(FieldType.CATEGORY_ID, "6") 44 | .field(FieldType.FIXED_VERSION_ID, "7") 45 | .field(FieldType.PARENT_ISSUE_ID, "8") 46 | .field(FieldType.SUBJECT, "タイトル") 47 | .field(FieldType.DESCRIPTION, "説明") 48 | .field(FieldType.START_DATE, "2012-12-12") 49 | .field(FieldType.DUE_DATE, "2013-01-01") 50 | .field(FieldType.DONE_RATIO, "9") 51 | .field(FieldType.IS_PRIVATE, "true") 52 | .field(FieldType.ESTIMATED_HOURS, "10.5") 53 | .customField(new CustomField(1, "カスタム1")) 54 | .build()); 55 | 56 | assertThat(issueId).isEqualTo(new IssueId(2)); 57 | 58 | assertThat(server.getRequestCount()).isEqualTo(1); 59 | 60 | RecordedRequest request = server.takeRequest(); 61 | assertThat(request.getMethod()).isEqualTo("POST"); 62 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo(apiKey); 63 | assertThat(request.getPath()).isEqualTo("/issues.json"); 64 | assertThat(request.getBody().readUtf8()).isEqualTo( 65 | "{\"issue\":{\"project_id\":\"1\",\"tracker_id\":\"2\",\"status_id\":\"3\",\"priority_id\":\"4\",\"assigned_to_id\":\"5\",\"category_id\":\"6\",\"fixed_version_id\":\"7\",\"parent_issue_id\":\"8\",\"subject\":\"タイトル\",\"description\":\"説明\",\"start_date\":\"2012-12-12\",\"due_date\":\"2013-01-01\",\"done_ratio\":\"9\",\"is_private\":\"true\",\"estimated_hours\":\"10.5\",\"custom_fields\":[{\"id\":1,\"value\":\"カスタム1\"}]}}"); 66 | } 67 | } 68 | 69 | @Test 70 | public void create_プロジェクトIDとSubject() throws IOException, InterruptedException { 71 | 72 | try (MockWebServer server = new MockWebServer()) { 73 | 74 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":2}}")); 75 | 76 | server.start(); 77 | 78 | final String apiKey = "API1234567890"; 79 | Client client = createClient(server, apiKey); 80 | 81 | IssueLoader loader = new IssueLoader(client); 82 | IssueId issueId = loader.create( 83 | new IssueTargetFieldsBuilder() 84 | .field(FieldType.PROJECT_ID, "1") 85 | .field(FieldType.SUBJECT, "タイトル") 86 | .build()); 87 | 88 | assertThat(issueId).isEqualTo(new IssueId(2)); 89 | 90 | assertThat(server.getRequestCount()).isEqualTo(1); 91 | 92 | RecordedRequest request = server.takeRequest(); 93 | assertThat(request.getMethod()).isEqualTo("POST"); 94 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo(apiKey); 95 | assertThat(request.getPath()).isEqualTo("/issues.json"); 96 | assertThat(request.getBody().readUtf8()).isEqualTo( 97 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"タイトル\"}}"); 98 | } 99 | } 100 | 101 | @Test 102 | public void create_Basic認証() throws IOException, InterruptedException { 103 | 104 | try (MockWebServer server = new MockWebServer()) { 105 | 106 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":2}}")); 107 | 108 | server.start(); 109 | 110 | BasicAuth basicAuth = BasicAuth.builder() 111 | .username("user") 112 | .password("pass") 113 | .build(); 114 | 115 | Client client = Client.builder() 116 | .redmineBaseUrl(server.url("/").toString()) 117 | .basicAuth(basicAuth) 118 | .timeout(10) 119 | .build(); 120 | 121 | IssueLoader loader = new IssueLoader(client); 122 | IssueId issueId = loader.create( 123 | new IssueTargetFieldsBuilder() 124 | .field(FieldType.PROJECT_ID, "1") 125 | .field(FieldType.SUBJECT, "タイトル") 126 | .build()); 127 | 128 | assertThat(issueId).isEqualTo(new IssueId(2)); 129 | 130 | assertThat(server.getRequestCount()).isEqualTo(1); 131 | 132 | RecordedRequest request = server.takeRequest(); 133 | assertThat(request.getMethod()).isEqualTo("POST"); 134 | assertThat(request.getHeader("X-Redmine-API-Key")).isNull(); 135 | assertThat(request.getHeader("Authorization")).isEqualTo("Basic dXNlcjpwYXNz"); 136 | assertThat(request.getPath()).isEqualTo("/issues.json"); 137 | assertThat(request.getBody().readUtf8()).isEqualTo( 138 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"タイトル\"}}"); 139 | } 140 | } 141 | 142 | @Test 143 | public void update_カスタムフィールドをキーとしてカスタムフィールド更新() throws IOException, InterruptedException { 144 | 145 | try (MockWebServer server = new MockWebServer()) { 146 | 147 | server.enqueue(new MockResponse().setBody("{\"issues\":[{\"id\":2}]}")); 148 | server.enqueue(new MockResponse()); 149 | 150 | server.start(); 151 | 152 | final String apiKey = "API1234567890"; 153 | Client client = createClient(server, apiKey); 154 | 155 | IssueLoader loader = new IssueLoader(client); 156 | loader.update( 157 | new CustomField(1, "C"), 158 | new IssueTargetFieldsBuilder() 159 | .customField(new CustomField(2, "xxx")) 160 | .customField(new CustomField(3, "yyy")) 161 | .build()); 162 | 163 | assertThat(server.getRequestCount()).isEqualTo(2); 164 | 165 | { 166 | RecordedRequest request = server.takeRequest(); 167 | assertThat(request.getMethod()).isEqualTo("GET"); 168 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo(apiKey); 169 | assertThat(request.getPath()).isEqualTo("/issues.json?status_id=*&cf_1=C"); 170 | } 171 | { 172 | RecordedRequest request = server.takeRequest(); 173 | assertThat(request.getMethod()).isEqualTo("PUT"); 174 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo(apiKey); 175 | assertThat(request.getPath()).isEqualTo("/issues/2.json"); 176 | assertThat(request.getBody().readUtf8()).isEqualTo( 177 | "{\"issue\":{\"custom_fields\":[{\"id\":2,\"value\":\"xxx\"},{\"id\":3,\"value\":\"yyy\"}]}}"); 178 | } 179 | } 180 | } 181 | 182 | @Test 183 | public void update_チケットIDをキーとしてステータス更新() throws IOException, InterruptedException { 184 | 185 | try (MockWebServer server = new MockWebServer()) { 186 | 187 | server.enqueue(new MockResponse().setBody("{\"issues\":[{\"id\":2}]}")); 188 | server.enqueue(new MockResponse()); 189 | 190 | server.start(); 191 | 192 | final String apiKey = "API1234567890"; 193 | Client client = createClient(server, apiKey); 194 | 195 | IssueLoader loader = new IssueLoader(client); 196 | loader.update( 197 | new IssueId(2), 198 | new IssueTargetFieldsBuilder() 199 | .field(FieldType.STATUS_ID, "1") 200 | .build()); 201 | 202 | assertThat(server.getRequestCount()).isEqualTo(2); 203 | 204 | { 205 | RecordedRequest request = server.takeRequest(); 206 | assertThat(request.getMethod()).isEqualTo("GET"); 207 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo(apiKey); 208 | assertThat(request.getPath()).isEqualTo("/issues.json?status_id=*&issue_id=2"); 209 | } 210 | { 211 | RecordedRequest request = server.takeRequest(); 212 | assertThat(request.getMethod()).isEqualTo("PUT"); 213 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo(apiKey); 214 | assertThat(request.getPath()).isEqualTo("/issues/2.json"); 215 | assertThat(request.getBody().readUtf8()).isEqualTo("{\"issue\":{\"status_id\":\"1\"}}"); 216 | } 217 | } 218 | } 219 | 220 | @Test 221 | public void update_チケットIDをキーとして全項目更新() throws IOException, InterruptedException { 222 | 223 | try (MockWebServer server = new MockWebServer()) { 224 | 225 | server.enqueue(new MockResponse().setBody("{\"issues\":[{\"id\":2}]}")); 226 | server.enqueue(new MockResponse()); 227 | 228 | server.start(); 229 | 230 | final String apiKey = "API1234567890"; 231 | Client client = createClient(server, apiKey); 232 | 233 | IssueLoader loader = new IssueLoader(client); 234 | loader.update( 235 | new IssueId(2), 236 | new IssueTargetFieldsBuilder() 237 | .field(FieldType.PROJECT_ID, "1") 238 | .field(FieldType.TRACKER_ID, "2") 239 | .field(FieldType.STATUS_ID, "3") 240 | .field(FieldType.PRIORITY_ID, "4") 241 | .field(FieldType.ASSIGNED_TO_ID, "5") 242 | .field(FieldType.CATEGORY_ID, "6") 243 | .field(FieldType.FIXED_VERSION_ID, "7") 244 | .field(FieldType.PARENT_ISSUE_ID, "8") 245 | .field(FieldType.SUBJECT, "タイトル") 246 | .field(FieldType.DESCRIPTION, "説明") 247 | .field(FieldType.START_DATE, "2012-12-12") 248 | .field(FieldType.DUE_DATE, "2013-01-01") 249 | .field(FieldType.DONE_RATIO, "9") 250 | .field(FieldType.IS_PRIVATE, "true") 251 | .field(FieldType.ESTIMATED_HOURS, "10.5") 252 | .customField(new CustomField(1, "カスタム1")) 253 | .build()); 254 | 255 | assertThat(server.getRequestCount()).isEqualTo(2); 256 | 257 | { 258 | RecordedRequest request = server.takeRequest(); 259 | assertThat(request.getMethod()).isEqualTo("GET"); 260 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo(apiKey); 261 | assertThat(request.getPath()).isEqualTo("/issues.json?status_id=*&issue_id=2"); 262 | } 263 | { 264 | RecordedRequest request = server.takeRequest(); 265 | assertThat(request.getMethod()).isEqualTo("PUT"); 266 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo(apiKey); 267 | assertThat(request.getPath()).isEqualTo("/issues/2.json"); 268 | assertThat(request.getBody().readUtf8()).isEqualTo( 269 | "{\"issue\":{\"project_id\":\"1\",\"tracker_id\":\"2\",\"status_id\":\"3\",\"priority_id\":\"4\",\"assigned_to_id\":\"5\",\"category_id\":\"6\",\"fixed_version_id\":\"7\",\"parent_issue_id\":\"8\",\"subject\":\"タイトル\",\"description\":\"説明\",\"start_date\":\"2012-12-12\",\"due_date\":\"2013-01-01\",\"done_ratio\":\"9\",\"is_private\":\"true\",\"estimated_hours\":\"10.5\",\"custom_fields\":[{\"id\":1,\"value\":\"カスタム1\"}]}}"); 270 | } 271 | } 272 | } 273 | 274 | @Test 275 | public void update_キーに一致するチケットが0件() throws IOException, InterruptedException { 276 | 277 | try (MockWebServer server = new MockWebServer()) { 278 | 279 | server.enqueue(new MockResponse().setBody("{\"issues\":[]}")); 280 | 281 | server.start(); 282 | 283 | final String apiKey = "API1234567890"; 284 | Client client = createClient(server, apiKey); 285 | 286 | IssueLoader loader = new IssueLoader(client); 287 | 288 | // 例外がスローされることを確認 289 | assertThatThrownBy(() -> { 290 | loader.update( 291 | new CustomField(1, "C"), 292 | new IssueTargetFieldsBuilder() 293 | .customField(new CustomField(2, "xxx")) 294 | .customField(new CustomField(3, "yyy")) 295 | .build()); 296 | }) 297 | .isInstanceOf(IllegalStateException.class) 298 | .hasMessage("The target issue was not found. CustomField(id=1, value=C)"); 299 | 300 | assertThat(server.getRequestCount()).isEqualTo(1); 301 | 302 | { 303 | RecordedRequest request = server.takeRequest(); 304 | assertThat(request.getMethod()).isEqualTo("GET"); 305 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo(apiKey); 306 | assertThat(request.getPath()).isEqualTo("/issues.json?status_id=*&cf_1=C"); 307 | } 308 | } 309 | } 310 | 311 | @Test 312 | public void update_キーに一致するチケットが複数件() throws IOException, InterruptedException { 313 | 314 | try (MockWebServer server = new MockWebServer()) { 315 | 316 | server.enqueue(new MockResponse().setBody("{\"issues\":[{\"id\":2},{\"id\":3}]}")); 317 | 318 | server.start(); 319 | 320 | final String apiKey = "API1234567890"; 321 | Client client = createClient(server, apiKey); 322 | 323 | IssueLoader loader = new IssueLoader(client); 324 | 325 | // 例外がスローされることを確認 326 | assertThatThrownBy(() -> { 327 | loader.update( 328 | new CustomField(1, "C"), 329 | new IssueTargetFieldsBuilder() 330 | .customField(new CustomField(2, "xxx")) 331 | .customField(new CustomField(3, "yyy")) 332 | .build()); 333 | }) 334 | .isInstanceOf(IllegalStateException.class) 335 | .hasMessage("There are multiple target issue. CustomField(id=1, value=C)"); 336 | 337 | assertThat(server.getRequestCount()).isEqualTo(1); 338 | 339 | { 340 | RecordedRequest request = server.takeRequest(); 341 | assertThat(request.getMethod()).isEqualTo("GET"); 342 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo(apiKey); 343 | assertThat(request.getPath()).isEqualTo("/issues.json?status_id=*&cf_1=C"); 344 | } 345 | } 346 | } 347 | 348 | @Test 349 | public void create_Redmineとの通信でエラー() throws IOException, InterruptedException { 350 | 351 | try (MockWebServer server = new MockWebServer()) { 352 | 353 | server.enqueue(new MockResponse().setStatus("HTTP/1.1 422 Unprocessable Entity")); 354 | 355 | server.start(); 356 | 357 | final String apiKey = "API1234567890"; 358 | Client client = createClient(server, apiKey); 359 | 360 | IssueLoader loader = new IssueLoader(client); 361 | 362 | assertThatThrownBy(() -> { 363 | loader.create( 364 | new IssueTargetFieldsBuilder() 365 | .field(FieldType.PROJECT_ID, "1") 366 | .field(FieldType.SUBJECT, "タイトル") 367 | .build()); 368 | }) 369 | .isInstanceOf(IOException.class) 370 | .hasMessageStartingWith("Failed to call Redmine API."); 371 | } 372 | } 373 | 374 | @Test 375 | public void update_Redmineとの通信でエラー_PKでの検索() throws IOException, InterruptedException { 376 | 377 | try (MockWebServer server = new MockWebServer()) { 378 | 379 | server.enqueue(new MockResponse().setStatus("HTTP/1.1 422 Unprocessable Entity")); 380 | 381 | server.start(); 382 | 383 | final String apiKey = "API1234567890"; 384 | Client client = createClient(server, apiKey); 385 | 386 | IssueLoader loader = new IssueLoader(client); 387 | 388 | assertThatThrownBy(() -> { 389 | loader.update( 390 | new CustomField(1, "C"), 391 | new IssueTargetFieldsBuilder() 392 | .customField(new CustomField(2, "xxx")) 393 | .customField(new CustomField(3, "yyy")) 394 | .build()); 395 | }) 396 | .isInstanceOf(IOException.class) 397 | .hasMessageStartingWith("Failed to call Redmine API."); 398 | } 399 | } 400 | 401 | @Test 402 | public void update_Redmineとの通信でエラー_更新() throws IOException, InterruptedException { 403 | 404 | try (MockWebServer server = new MockWebServer()) { 405 | 406 | server.enqueue(new MockResponse().setBody("{\"issues\":[{\"id\":2}]}")); 407 | server.enqueue(new MockResponse().setStatus("HTTP/1.1 422 Unprocessable Entity")); 408 | 409 | server.start(); 410 | 411 | final String apiKey = "API1234567890"; 412 | Client client = createClient(server, apiKey); 413 | 414 | IssueLoader loader = new IssueLoader(client); 415 | 416 | assertThatThrownBy(() -> { 417 | loader.update( 418 | new CustomField(1, "C"), 419 | new IssueTargetFieldsBuilder() 420 | .customField(new CustomField(2, "xxx")) 421 | .customField(new CustomField(3, "yyy")) 422 | .build()); 423 | }) 424 | .isInstanceOf(IOException.class) 425 | .hasMessageStartingWith("Failed to call Redmine API."); 426 | } 427 | } 428 | 429 | private Client createClient(MockWebServer server, final String apiKey) { 430 | 431 | return Client.builder() 432 | .redmineBaseUrl(server.url("/").toString()) 433 | .apiKey(apiKey) 434 | .timeout(10) 435 | .build(); 436 | } 437 | 438 | } 439 | -------------------------------------------------------------------------------- /src/test/java/com/github/onozaty/redmine/issue/loader/IssueLoadRunnerTest.java: -------------------------------------------------------------------------------- 1 | package com.github.onozaty.redmine.issue.loader; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 5 | 6 | import java.io.IOException; 7 | import java.net.SocketTimeoutException; 8 | import java.net.URISyntaxException; 9 | import java.nio.file.Path; 10 | import java.nio.file.Paths; 11 | import java.util.concurrent.TimeUnit; 12 | 13 | import org.junit.Test; 14 | 15 | import com.github.onozaty.redmine.issue.loader.input.Config; 16 | 17 | import okhttp3.mockwebserver.MockResponse; 18 | import okhttp3.mockwebserver.MockWebServer; 19 | import okhttp3.mockwebserver.RecordedRequest; 20 | 21 | public class IssueLoadRunnerTest { 22 | 23 | @Test 24 | public void execute_新規作成_全項目() throws URISyntaxException, IOException, InterruptedException { 25 | 26 | try (MockWebServer server = new MockWebServer()) { 27 | 28 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":1}}")); 29 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":2}}")); 30 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":3}}")); 31 | 32 | server.start(); 33 | 34 | Path configPath = Paths.get(IssueLoadRunnerTest.class.getResource("create-all_fields.json").toURI()); 35 | Config config = Config.of(configPath); 36 | 37 | // Mockに対してリクエスト送信するよう設定 38 | config.setReadmineUrl(server.url("/").toString()); 39 | 40 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-all_fields.csv").toURI()); 41 | 42 | IssueLoadRunner runner = new IssueLoadRunner(System.out); 43 | runner.execute(config, csvPath); 44 | 45 | assertThat(server.getRequestCount()).isEqualTo(3); 46 | 47 | // 1レコード目 48 | { 49 | RecordedRequest request = server.takeRequest(); 50 | assertThat(request.getMethod()).isEqualTo("POST"); 51 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 52 | assertThat(request.getPath()).isEqualTo("/issues.json"); 53 | assertThat(request.getBody().readUtf8()).isEqualTo( 54 | "{\"issue\":{\"project_id\":\"1\",\"tracker_id\":\"2\",\"status_id\":\"1\",\"priority_id\":\"2\",\"assigned_to_id\":\"5\",\"category_id\":\"2\",\"fixed_version_id\":\"2\",\"parent_issue_id\":\"\",\"subject\":\"xxx\",\"description\":\"説明1\",\"start_date\":\"2019-02-01\",\"due_date\":\"2019-02-20\",\"done_ratio\":\"10\",\"is_private\":\"true\",\"estimated_hours\":\"2.5\",\"custom_fields\":[{\"id\":1,\"value\":\"A\"},{\"id\":2,\"value\":\"a\"},{\"id\":3,\"value\":[\"C\"]}],\"watcher_user_ids\":[6]}}"); 55 | } 56 | 57 | // 2レコード目 58 | { 59 | RecordedRequest request = server.takeRequest(); 60 | assertThat(request.getMethod()).isEqualTo("POST"); 61 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 62 | assertThat(request.getPath()).isEqualTo("/issues.json"); 63 | assertThat(request.getBody().readUtf8()).isEqualTo( 64 | "{\"issue\":{\"project_id\":\"2\",\"tracker_id\":\"2\",\"status_id\":\"2\",\"priority_id\":\"1\",\"assigned_to_id\":\"\",\"category_id\":\"2\",\"fixed_version_id\":\"\",\"parent_issue_id\":\"\",\"subject\":\"yyy\",\"description\":\"説明2\",\"start_date\":\"2019-03-02\",\"due_date\":\"\",\"done_ratio\":\"\",\"is_private\":\"false\",\"estimated_hours\":\"\",\"custom_fields\":[{\"id\":1,\"value\":\"B\"},{\"id\":2,\"value\":\"b\"},{\"id\":3,\"value\":[\"A\",\"B\"]}],\"watcher_user_ids\":[5,6]}}"); 65 | } 66 | 67 | // 3レコード目 68 | { 69 | RecordedRequest request = server.takeRequest(); 70 | assertThat(request.getMethod()).isEqualTo("POST"); 71 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 72 | assertThat(request.getPath()).isEqualTo("/issues.json"); 73 | assertThat(request.getBody().readUtf8()).isEqualTo( 74 | "{\"issue\":{\"project_id\":\"1\",\"tracker_id\":\"3\",\"status_id\":\"3\",\"priority_id\":\"3\",\"assigned_to_id\":\"6\",\"category_id\":\"1\",\"fixed_version_id\":\"1\",\"parent_issue_id\":\"1\",\"subject\":\"zzz\",\"description\":\"説明3\",\"start_date\":\"2019-03-12\",\"due_date\":\"2019-10-30\",\"done_ratio\":\"90\",\"is_private\":\"false\",\"estimated_hours\":\"10\",\"custom_fields\":[{\"id\":1,\"value\":\"C\"},{\"id\":2,\"value\":\"c\"},{\"id\":3,\"value\":[]}],\"watcher_user_ids\":[]}}"); 75 | } 76 | } 77 | } 78 | 79 | @Test 80 | public void execute_新規作成_プロジェクトIDとSubject() throws URISyntaxException, IOException, InterruptedException { 81 | 82 | try (MockWebServer server = new MockWebServer()) { 83 | 84 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":1}}")); 85 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":2}}")); 86 | 87 | server.start(); 88 | 89 | Path configPath = Paths 90 | .get(IssueLoadRunnerTest.class.getResource("create-project_id-subject.json").toURI()); 91 | Config config = Config.of(configPath); 92 | 93 | // Mockに対してリクエスト送信するよう設定 94 | config.setReadmineUrl(server.url("/").toString()); 95 | 96 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-project_id-subject.csv").toURI()); 97 | 98 | IssueLoadRunner runner = new IssueLoadRunner(); 99 | runner.execute(config, csvPath); 100 | 101 | assertThat(server.getRequestCount()).isEqualTo(2); 102 | 103 | // 1レコード目 104 | { 105 | RecordedRequest request = server.takeRequest(); 106 | assertThat(request.getMethod()).isEqualTo("POST"); 107 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 108 | assertThat(request.getPath()).isEqualTo("/issues.json"); 109 | assertThat(request.getBody().readUtf8()).isEqualTo( 110 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"タイトル1\"}}"); 111 | } 112 | 113 | // 2レコード目 114 | { 115 | RecordedRequest request = server.takeRequest(); 116 | assertThat(request.getMethod()).isEqualTo("POST"); 117 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 118 | assertThat(request.getPath()).isEqualTo("/issues.json"); 119 | assertThat(request.getBody().readUtf8()).isEqualTo( 120 | "{\"issue\":{\"project_id\":\"2\",\"subject\":\"タイトル2\"}}"); 121 | } 122 | } 123 | } 124 | 125 | @Test 126 | public void execute_新規作成_複数選択カスタムフィールド() throws URISyntaxException, IOException, InterruptedException { 127 | 128 | try (MockWebServer server = new MockWebServer()) { 129 | 130 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":1}}")); 131 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":2}}")); 132 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":3}}")); 133 | 134 | server.start(); 135 | 136 | Path configPath = 137 | Paths.get(IssueLoadRunnerTest.class.getResource("create-multiple-custom_fields.json").toURI()); 138 | Config config = Config.of(configPath); 139 | 140 | // Mockに対してリクエスト送信するよう設定 141 | config.setReadmineUrl(server.url("/").toString()); 142 | 143 | Path csvPath = 144 | Paths.get(IssueLoadRunnerTest.class.getResource("issues-multiple-custom_fields.csv").toURI()); 145 | 146 | IssueLoadRunner runner = new IssueLoadRunner(System.out); 147 | runner.execute(config, csvPath); 148 | 149 | assertThat(server.getRequestCount()).isEqualTo(3); 150 | 151 | // 1レコード目 152 | { 153 | RecordedRequest request = server.takeRequest(); 154 | assertThat(request.getMethod()).isEqualTo("POST"); 155 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 156 | assertThat(request.getPath()).isEqualTo("/issues.json"); 157 | assertThat(request.getBody().readUtf8()).isEqualTo( 158 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"xxx\",\"custom_fields\":[{\"id\":1,\"value\":[\"1\",\"2\"]},{\"id\":2,\"value\":[\"a\",\"b\"]}]}}"); 159 | } 160 | 161 | // 2レコード目 162 | { 163 | RecordedRequest request = server.takeRequest(); 164 | assertThat(request.getMethod()).isEqualTo("POST"); 165 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 166 | assertThat(request.getPath()).isEqualTo("/issues.json"); 167 | assertThat(request.getBody().readUtf8()).isEqualTo( 168 | "{\"issue\":{\"project_id\":\"2\",\"subject\":\"yyy\",\"custom_fields\":[{\"id\":1,\"value\":[\"1\"]},{\"id\":2,\"value\":[\"b\"]}]}}"); 169 | } 170 | 171 | // 3レコード目 172 | { 173 | RecordedRequest request = server.takeRequest(); 174 | assertThat(request.getMethod()).isEqualTo("POST"); 175 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 176 | assertThat(request.getPath()).isEqualTo("/issues.json"); 177 | assertThat(request.getBody().readUtf8()).isEqualTo( 178 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"zzz\",\"custom_fields\":[{\"id\":1,\"value\":[]},{\"id\":2,\"value\":[]}]}}"); 179 | } 180 | } 181 | } 182 | 183 | @Test 184 | public void execute_新規作成_ウォッチャー区切り文字無し() throws URISyntaxException, IOException, InterruptedException { 185 | 186 | try (MockWebServer server = new MockWebServer()) { 187 | 188 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":1}}")); 189 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":2}}")); 190 | 191 | server.start(); 192 | 193 | Path configPath = 194 | Paths.get(IssueLoadRunnerTest.class.getResource("create-single-watchers.json").toURI()); 195 | Config config = Config.of(configPath); 196 | 197 | // Mockに対してリクエスト送信するよう設定 198 | config.setReadmineUrl(server.url("/").toString()); 199 | 200 | Path csvPath = 201 | Paths.get(IssueLoadRunnerTest.class.getResource("issues-single-watchers.csv").toURI()); 202 | 203 | IssueLoadRunner runner = new IssueLoadRunner(System.out); 204 | runner.execute(config, csvPath); 205 | 206 | assertThat(server.getRequestCount()).isEqualTo(2); 207 | 208 | // 1レコード目 209 | { 210 | RecordedRequest request = server.takeRequest(); 211 | assertThat(request.getMethod()).isEqualTo("POST"); 212 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 213 | assertThat(request.getPath()).isEqualTo("/issues.json"); 214 | assertThat(request.getBody().readUtf8()).isEqualTo( 215 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"xxx\",\"watcher_user_ids\":[1]}}"); 216 | } 217 | 218 | // 2レコード目 219 | { 220 | RecordedRequest request = server.takeRequest(); 221 | assertThat(request.getMethod()).isEqualTo("POST"); 222 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 223 | assertThat(request.getPath()).isEqualTo("/issues.json"); 224 | assertThat(request.getBody().readUtf8()).isEqualTo( 225 | "{\"issue\":{\"project_id\":\"2\",\"subject\":\"yyy\",\"watcher_user_ids\":[]}}"); 226 | } 227 | } 228 | } 229 | 230 | @Test 231 | public void execute_Basic認証() throws URISyntaxException, IOException, InterruptedException { 232 | 233 | try (MockWebServer server = new MockWebServer()) { 234 | 235 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":1}}")); 236 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":2}}")); 237 | 238 | server.start(); 239 | 240 | Path configPath = Paths 241 | .get(IssueLoadRunnerTest.class.getResource("basic-auth.json").toURI()); 242 | Config config = Config.of(configPath); 243 | 244 | // Mockに対してリクエスト送信するよう設定 245 | config.setReadmineUrl(server.url("/").toString()); 246 | 247 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-project_id-subject.csv").toURI()); 248 | 249 | IssueLoadRunner runner = new IssueLoadRunner(); 250 | runner.execute(config, csvPath); 251 | 252 | assertThat(server.getRequestCount()).isEqualTo(2); 253 | 254 | // 1レコード目 255 | { 256 | RecordedRequest request = server.takeRequest(); 257 | assertThat(request.getMethod()).isEqualTo("POST"); 258 | assertThat(request.getHeader("X-Redmine-API-Key")).isNull(); 259 | assertThat(request.getHeader("Authorization")).isEqualTo("Basic dXNlcjpwYXNz"); 260 | assertThat(request.getPath()).isEqualTo("/issues.json"); 261 | assertThat(request.getBody().readUtf8()).isEqualTo( 262 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"タイトル1\"}}"); 263 | } 264 | 265 | // 2レコード目 266 | { 267 | RecordedRequest request = server.takeRequest(); 268 | assertThat(request.getMethod()).isEqualTo("POST"); 269 | assertThat(request.getHeader("X-Redmine-API-Key")).isNull(); 270 | assertThat(request.getHeader("Authorization")).isEqualTo("Basic dXNlcjpwYXNz"); 271 | assertThat(request.getPath()).isEqualTo("/issues.json"); 272 | assertThat(request.getBody().readUtf8()).isEqualTo( 273 | "{\"issue\":{\"project_id\":\"2\",\"subject\":\"タイトル2\"}}"); 274 | } 275 | } 276 | } 277 | 278 | @Test 279 | public void execute_チケットIDをキーとして全項目更新() throws URISyntaxException, IOException, InterruptedException { 280 | 281 | try (MockWebServer server = new MockWebServer()) { 282 | 283 | server.enqueue(new MockResponse().setBody("{\"issues\":[{\"id\":1}]}")); 284 | server.enqueue(new MockResponse()); 285 | server.enqueue(new MockResponse().setBody("{\"issues\":[{\"id\":2}]}")); 286 | server.enqueue(new MockResponse()); 287 | server.enqueue(new MockResponse().setBody("{\"issues\":[{\"id\":3}]}")); 288 | server.enqueue(new MockResponse()); 289 | 290 | server.start(); 291 | 292 | Path configPath = Paths 293 | .get(IssueLoadRunnerTest.class.getResource("update-all_fields-with-issue_id.json").toURI()); 294 | Config config = Config.of(configPath); 295 | 296 | // Mockに対してリクエスト送信するよう設定 297 | config.setReadmineUrl(server.url("/").toString()); 298 | 299 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-all_fields.csv").toURI()); 300 | 301 | IssueLoadRunner runner = new IssueLoadRunner(); 302 | runner.execute(config, csvPath); 303 | 304 | assertThat(server.getRequestCount()).isEqualTo(6); 305 | 306 | // 1レコード目 307 | { 308 | RecordedRequest request = server.takeRequest(); 309 | assertThat(request.getMethod()).isEqualTo("GET"); 310 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 311 | assertThat(request.getPath()).isEqualTo("/issues.json?status_id=*&issue_id=1"); 312 | } 313 | { 314 | RecordedRequest request = server.takeRequest(); 315 | assertThat(request.getMethod()).isEqualTo("PUT"); 316 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 317 | assertThat(request.getPath()).isEqualTo("/issues/1.json"); 318 | assertThat(request.getBody().readUtf8()).isEqualTo( 319 | "{\"issue\":{\"project_id\":\"1\",\"tracker_id\":\"2\",\"status_id\":\"1\",\"priority_id\":\"2\",\"assigned_to_id\":\"5\",\"category_id\":\"2\",\"fixed_version_id\":\"2\",\"parent_issue_id\":\"\",\"subject\":\"xxx\",\"description\":\"説明1\",\"start_date\":\"2019-02-01\",\"due_date\":\"2019-02-20\",\"done_ratio\":\"10\",\"is_private\":\"true\",\"estimated_hours\":\"2.5\",\"custom_fields\":[{\"id\":1,\"value\":\"A\"},{\"id\":2,\"value\":\"a\"}]}}"); 320 | } 321 | 322 | // 2レコード目 323 | { 324 | RecordedRequest request = server.takeRequest(); 325 | assertThat(request.getMethod()).isEqualTo("GET"); 326 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 327 | assertThat(request.getPath()).isEqualTo("/issues.json?status_id=*&issue_id=2"); 328 | } 329 | { 330 | RecordedRequest request = server.takeRequest(); 331 | assertThat(request.getMethod()).isEqualTo("PUT"); 332 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 333 | assertThat(request.getPath()).isEqualTo("/issues/2.json"); 334 | assertThat(request.getBody().readUtf8()).isEqualTo( 335 | "{\"issue\":{\"project_id\":\"2\",\"tracker_id\":\"2\",\"status_id\":\"2\",\"priority_id\":\"1\",\"assigned_to_id\":\"\",\"category_id\":\"2\",\"fixed_version_id\":\"\",\"parent_issue_id\":\"\",\"subject\":\"yyy\",\"description\":\"説明2\",\"start_date\":\"2019-03-02\",\"due_date\":\"\",\"done_ratio\":\"\",\"is_private\":\"false\",\"estimated_hours\":\"\",\"custom_fields\":[{\"id\":1,\"value\":\"B\"},{\"id\":2,\"value\":\"b\"}]}}"); 336 | } 337 | 338 | // 3レコード目 339 | { 340 | RecordedRequest request = server.takeRequest(); 341 | assertThat(request.getMethod()).isEqualTo("GET"); 342 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 343 | assertThat(request.getPath()).isEqualTo("/issues.json?status_id=*&issue_id=3"); 344 | } 345 | { 346 | RecordedRequest request = server.takeRequest(); 347 | assertThat(request.getMethod()).isEqualTo("PUT"); 348 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 349 | assertThat(request.getPath()).isEqualTo("/issues/3.json"); 350 | assertThat(request.getBody().readUtf8()).isEqualTo( 351 | "{\"issue\":{\"project_id\":\"1\",\"tracker_id\":\"3\",\"status_id\":\"3\",\"priority_id\":\"3\",\"assigned_to_id\":\"6\",\"category_id\":\"1\",\"fixed_version_id\":\"1\",\"parent_issue_id\":\"1\",\"subject\":\"zzz\",\"description\":\"説明3\",\"start_date\":\"2019-03-12\",\"due_date\":\"2019-10-30\",\"done_ratio\":\"90\",\"is_private\":\"false\",\"estimated_hours\":\"10\",\"custom_fields\":[{\"id\":1,\"value\":\"C\"},{\"id\":2,\"value\":\"c\"}]}}"); 352 | } 353 | } 354 | } 355 | 356 | @Test 357 | public void execute_カスタムフィールドをキーとしてステータスIDを更新() throws URISyntaxException, IOException, InterruptedException { 358 | 359 | try (MockWebServer server = new MockWebServer()) { 360 | 361 | server.enqueue(new MockResponse().setBody("{\"issues\":[{\"id\":1}]}")); 362 | server.enqueue(new MockResponse()); 363 | server.enqueue(new MockResponse().setBody("{\"issues\":[{\"id\":2}]}")); 364 | server.enqueue(new MockResponse()); 365 | server.enqueue(new MockResponse().setBody("{\"issues\":[{\"id\":3}]}")); 366 | server.enqueue(new MockResponse()); 367 | 368 | server.start(); 369 | 370 | Path configPath = Paths 371 | .get(IssueLoadRunnerTest.class.getResource("update-status_id-with-custom_field.json").toURI()); 372 | Config config = Config.of(configPath); 373 | 374 | // Mockに対してリクエスト送信するよう設定 375 | config.setReadmineUrl(server.url("/").toString()); 376 | 377 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-status_id.csv").toURI()); 378 | 379 | IssueLoadRunner runner = new IssueLoadRunner(); 380 | runner.execute(config, csvPath); 381 | 382 | assertThat(server.getRequestCount()).isEqualTo(6); 383 | 384 | // 1レコード目 385 | { 386 | RecordedRequest request = server.takeRequest(); 387 | assertThat(request.getMethod()).isEqualTo("GET"); 388 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 389 | assertThat(request.getPath()).isEqualTo("/issues.json?status_id=*&cf_1=A"); 390 | } 391 | { 392 | RecordedRequest request = server.takeRequest(); 393 | assertThat(request.getMethod()).isEqualTo("PUT"); 394 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 395 | assertThat(request.getPath()).isEqualTo("/issues/1.json"); 396 | assertThat(request.getBody().readUtf8()).isEqualTo("{\"issue\":{\"status_id\":\"1\"}}"); 397 | } 398 | 399 | // 2レコード目 400 | { 401 | RecordedRequest request = server.takeRequest(); 402 | assertThat(request.getMethod()).isEqualTo("GET"); 403 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 404 | assertThat(request.getPath()).isEqualTo("/issues.json?status_id=*&cf_1=B"); 405 | } 406 | { 407 | RecordedRequest request = server.takeRequest(); 408 | assertThat(request.getMethod()).isEqualTo("PUT"); 409 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 410 | assertThat(request.getPath()).isEqualTo("/issues/2.json"); 411 | assertThat(request.getBody().readUtf8()).isEqualTo("{\"issue\":{\"status_id\":\"2\"}}"); 412 | } 413 | 414 | // 3レコード目 415 | { 416 | RecordedRequest request = server.takeRequest(); 417 | assertThat(request.getMethod()).isEqualTo("GET"); 418 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 419 | assertThat(request.getPath()).isEqualTo("/issues.json?status_id=*&cf_1=C"); 420 | } 421 | { 422 | RecordedRequest request = server.takeRequest(); 423 | assertThat(request.getMethod()).isEqualTo("PUT"); 424 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 425 | assertThat(request.getPath()).isEqualTo("/issues/3.json"); 426 | assertThat(request.getBody().readUtf8()).isEqualTo("{\"issue\":{\"status_id\":\"3\"}}"); 427 | } 428 | } 429 | } 430 | 431 | @Test 432 | public void execute_新規作成_プロジェクトIDなし() throws URISyntaxException, IOException, InterruptedException { 433 | 434 | try (MockWebServer server = new MockWebServer()) { 435 | 436 | Path configPath = Paths.get(IssueLoadRunnerTest.class.getResource("create-none-project_id.json").toURI()); 437 | Config config = Config.of(configPath); 438 | 439 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-all_fields.csv").toURI()); 440 | 441 | IssueLoadRunner runner = new IssueLoadRunner(); 442 | 443 | // 例外がスローされることを確認 444 | assertThatThrownBy(() -> runner.execute(config, csvPath)) 445 | .isInstanceOf(IllegalArgumentException.class) 446 | .hasMessage("Project ID and Subject are required when created."); 447 | } 448 | } 449 | 450 | @Test 451 | public void execute_新規作成_題名無し() throws URISyntaxException, IOException, InterruptedException { 452 | 453 | try (MockWebServer server = new MockWebServer()) { 454 | 455 | Path configPath = Paths.get(IssueLoadRunnerTest.class.getResource("create-none-subject.json").toURI()); 456 | Config config = Config.of(configPath); 457 | 458 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-all_fields.csv").toURI()); 459 | 460 | IssueLoadRunner runner = new IssueLoadRunner(); 461 | 462 | // 例外がスローされることを確認 463 | assertThatThrownBy(() -> runner.execute(config, csvPath)) 464 | .isInstanceOf(IllegalArgumentException.class) 465 | .hasMessage("Project ID and Subject are required when created."); 466 | } 467 | } 468 | 469 | @Test 470 | public void execute_更新_PK無し() throws URISyntaxException, IOException, InterruptedException { 471 | 472 | try (MockWebServer server = new MockWebServer()) { 473 | 474 | Path configPath = Paths.get(IssueLoadRunnerTest.class.getResource("update-none-pk.json").toURI()); 475 | Config config = Config.of(configPath); 476 | 477 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-all_fields.csv").toURI()); 478 | 479 | IssueLoadRunner runner = new IssueLoadRunner(); 480 | 481 | // 例外がスローされることを確認 482 | assertThatThrownBy(() -> runner.execute(config, csvPath)) 483 | .isInstanceOf(IllegalArgumentException.class) 484 | .hasMessage("Primary key was not found."); 485 | } 486 | } 487 | 488 | @Test 489 | public void execute_更新_PK複数() throws URISyntaxException, IOException, InterruptedException { 490 | 491 | try (MockWebServer server = new MockWebServer()) { 492 | 493 | Path configPath = Paths.get(IssueLoadRunnerTest.class.getResource("update-multi-pk.json").toURI()); 494 | Config config = Config.of(configPath); 495 | 496 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-all_fields.csv").toURI()); 497 | 498 | IssueLoadRunner runner = new IssueLoadRunner(); 499 | 500 | // 例外がスローされることを確認 501 | assertThatThrownBy(() -> runner.execute(config, csvPath)) 502 | .isInstanceOf(IllegalArgumentException.class) 503 | .hasMessage("There are multiple primary keys."); 504 | } 505 | } 506 | 507 | @Test 508 | public void execute_更新_PK以外のフィールド指定無し() throws URISyntaxException, IOException, InterruptedException { 509 | 510 | try (MockWebServer server = new MockWebServer()) { 511 | 512 | Path configPath = Paths.get(IssueLoadRunnerTest.class.getResource("update-pk-only.json").toURI()); 513 | Config config = Config.of(configPath); 514 | 515 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-all_fields.csv").toURI()); 516 | 517 | IssueLoadRunner runner = new IssueLoadRunner(); 518 | 519 | // 例外がスローされることを確認 520 | assertThatThrownBy(() -> runner.execute(config, csvPath)) 521 | .isInstanceOf(IllegalArgumentException.class) 522 | .hasMessage("The field to be updated is not set."); 523 | } 524 | } 525 | 526 | @Test 527 | public void execute_更新_チケットIDを更新() throws URISyntaxException, IOException, InterruptedException { 528 | 529 | try (MockWebServer server = new MockWebServer()) { 530 | 531 | Path configPath = Paths.get(IssueLoadRunnerTest.class.getResource("update-issue_id.json").toURI()); 532 | Config config = Config.of(configPath); 533 | 534 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-all_fields.csv").toURI()); 535 | 536 | IssueLoadRunner runner = new IssueLoadRunner(); 537 | 538 | // 例外がスローされることを確認 539 | assertThatThrownBy(() -> runner.execute(config, csvPath)) 540 | .isInstanceOf(IllegalArgumentException.class) 541 | .hasMessage("Issue ID can only be used as a primary key."); 542 | } 543 | } 544 | 545 | @Test 546 | public void execute_更新_PKとしてチケットIDとカスタムフィールド以外指定() throws URISyntaxException, IOException, InterruptedException { 547 | 548 | try (MockWebServer server = new MockWebServer()) { 549 | 550 | Path configPath = Paths.get(IssueLoadRunnerTest.class.getResource("update-with-subject.json").toURI()); 551 | Config config = Config.of(configPath); 552 | 553 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-all_fields.csv").toURI()); 554 | 555 | IssueLoadRunner runner = new IssueLoadRunner(); 556 | 557 | // 例外がスローされることを確認 558 | assertThatThrownBy(() -> runner.execute(config, csvPath)) 559 | .isInstanceOf(IllegalArgumentException.class) 560 | .hasMessage("Field type [SUBJECT] can not be used as a primary key."); 561 | } 562 | } 563 | 564 | @Test 565 | public void execute_マッピング表に一致するものが無い() throws URISyntaxException, IOException, InterruptedException { 566 | 567 | try (MockWebServer server = new MockWebServer()) { 568 | 569 | Path configPath = Paths.get(IssueLoadRunnerTest.class.getResource("mapping-unmatch.json").toURI()); 570 | Config config = Config.of(configPath); 571 | 572 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-all_fields.csv").toURI()); 573 | 574 | IssueLoadRunner runner = new IssueLoadRunner(); 575 | 576 | // 例外がスローされることを確認 577 | assertThatThrownBy(() -> runner.execute(config, csvPath)) 578 | .isInstanceOf(IllegalArgumentException.class) 579 | .hasMessage("Could not mapping \"プロジェクト1\" of field [Project]."); 580 | } 581 | } 582 | 583 | @Test 584 | public void execute_正規化() throws URISyntaxException, IOException, InterruptedException { 585 | 586 | try (MockWebServer server = new MockWebServer()) { 587 | 588 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":1}}")); 589 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":2}}")); 590 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":3}}")); 591 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":4}}")); 592 | 593 | server.start(); 594 | 595 | Path configPath = Paths.get(IssueLoadRunnerTest.class.getResource("create-normalize.json").toURI()); 596 | Config config = Config.of(configPath); 597 | 598 | // Mockに対してリクエスト送信するよう設定 599 | config.setReadmineUrl(server.url("/").toString()); 600 | 601 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-normalize.csv").toURI()); 602 | 603 | IssueLoadRunner runner = new IssueLoadRunner(System.out); 604 | runner.execute(config, csvPath); 605 | 606 | assertThat(server.getRequestCount()).isEqualTo(4); 607 | 608 | // 1レコード目 609 | { 610 | RecordedRequest request = server.takeRequest(); 611 | assertThat(request.getMethod()).isEqualTo("POST"); 612 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 613 | assertThat(request.getPath()).isEqualTo("/issues.json"); 614 | assertThat(request.getBody().readUtf8()).isEqualTo( 615 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"ハイフン、0埋めあり\",\"start_date\":\"2012-01-01\",\"due_date\":\"2012-03-01\",\"is_private\":\"true\"}}"); 616 | } 617 | 618 | // 2レコード目 619 | { 620 | RecordedRequest request = server.takeRequest(); 621 | assertThat(request.getMethod()).isEqualTo("POST"); 622 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 623 | assertThat(request.getPath()).isEqualTo("/issues.json"); 624 | assertThat(request.getBody().readUtf8()).isEqualTo( 625 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"ハイフン、0埋め無し\",\"start_date\":\"2012-01-02\",\"due_date\":\"2012-03-02\",\"is_private\":\"false\"}}"); 626 | } 627 | 628 | // 3レコード目 629 | { 630 | RecordedRequest request = server.takeRequest(); 631 | assertThat(request.getMethod()).isEqualTo("POST"); 632 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 633 | assertThat(request.getPath()).isEqualTo("/issues.json"); 634 | assertThat(request.getBody().readUtf8()).isEqualTo( 635 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"スラッシュ、0埋めあり\",\"start_date\":\"2012-02-01\",\"due_date\":\"2012-04-01\",\"is_private\":\"true\"}}"); 636 | } 637 | 638 | // 4レコード目 639 | { 640 | RecordedRequest request = server.takeRequest(); 641 | assertThat(request.getMethod()).isEqualTo("POST"); 642 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 643 | assertThat(request.getPath()).isEqualTo("/issues.json"); 644 | assertThat(request.getBody().readUtf8()).isEqualTo( 645 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"スラッシュ、0埋め無し\",\"start_date\":\"2012-02-02\",\"due_date\":\"2012-04-02\",\"is_private\":\"false\"}}"); 646 | } 647 | } 648 | } 649 | 650 | @Test 651 | public void execute_タイムアウト設定_デフォルト() throws URISyntaxException, IOException, InterruptedException { 652 | 653 | try (MockWebServer server = new MockWebServer()) { 654 | 655 | // わざと15秒遅らせる(デフォルト10秒なのでタイムアウト発生) 656 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":1}}").setBodyDelay(15, TimeUnit.SECONDS)); 657 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":2}}")); 658 | 659 | server.start(); 660 | 661 | Path configPath = Paths 662 | .get(IssueLoadRunnerTest.class.getResource("create-project_id-subject.json").toURI()); 663 | Config config = Config.of(configPath); 664 | 665 | // Mockに対してリクエスト送信するよう設定 666 | config.setReadmineUrl(server.url("/").toString()); 667 | 668 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-project_id-subject.csv").toURI()); 669 | 670 | IssueLoadRunner runner = new IssueLoadRunner(); 671 | 672 | // 例外がスローされることを確認 673 | assertThatThrownBy(() -> runner.execute(config, csvPath)) 674 | .isInstanceOf(SocketTimeoutException.class); 675 | } 676 | } 677 | 678 | @Test 679 | public void execute_タイムアウト設定_デフォルトから変更() throws URISyntaxException, IOException, InterruptedException { 680 | 681 | try (MockWebServer server = new MockWebServer()) { 682 | 683 | // わざと15秒遅らせる(設定ファイルでデフォルト10秒のものを20秒に変えているのでタイムアウトしない) 684 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":1}}").setBodyDelay(15, TimeUnit.SECONDS)); 685 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":2}}")); 686 | 687 | server.start(); 688 | 689 | Path configPath = Paths 690 | .get(IssueLoadRunnerTest.class.getResource("timeout.json").toURI()); 691 | Config config = Config.of(configPath); 692 | 693 | // Mockに対してリクエスト送信するよう設定 694 | config.setReadmineUrl(server.url("/").toString()); 695 | 696 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("issues-project_id-subject.csv").toURI()); 697 | 698 | IssueLoadRunner runner = new IssueLoadRunner(); 699 | runner.execute(config, csvPath); 700 | 701 | assertThat(server.getRequestCount()).isEqualTo(2); 702 | 703 | // 1レコード目 704 | { 705 | RecordedRequest request = server.takeRequest(); 706 | assertThat(request.getMethod()).isEqualTo("POST"); 707 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 708 | assertThat(request.getHeader("Authorization")).isNull(); 709 | assertThat(request.getPath()).isEqualTo("/issues.json"); 710 | assertThat(request.getBody().readUtf8()).isEqualTo( 711 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"タイトル1\"}}"); 712 | } 713 | 714 | // 2レコード目 715 | { 716 | RecordedRequest request = server.takeRequest(); 717 | assertThat(request.getMethod()).isEqualTo("POST"); 718 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 719 | assertThat(request.getHeader("Authorization")).isNull(); 720 | assertThat(request.getPath()).isEqualTo("/issues.json"); 721 | assertThat(request.getBody().readUtf8()).isEqualTo( 722 | "{\"issue\":{\"project_id\":\"2\",\"subject\":\"タイトル2\"}}"); 723 | } 724 | } 725 | } 726 | 727 | @Test 728 | public void execute_文字置換() throws URISyntaxException, IOException, InterruptedException { 729 | 730 | try (MockWebServer server = new MockWebServer()) { 731 | 732 | server.enqueue(new MockResponse().setBody("{\"issue\":{\"id\":1}}")); 733 | 734 | server.start(); 735 | 736 | Path configPath = Paths.get(IssueLoadRunnerTest.class.getResource("replace.json").toURI()); 737 | Config config = Config.of(configPath); 738 | 739 | // Mockに対してリクエスト送信するよう設定 740 | config.setReadmineUrl(server.url("/").toString()); 741 | 742 | Path csvPath = Paths.get(IssueLoadRunnerTest.class.getResource("replace.csv").toURI()); 743 | 744 | IssueLoadRunner runner = new IssueLoadRunner(); 745 | runner.execute(config, csvPath); 746 | 747 | assertThat(server.getRequestCount()).isEqualTo(1); 748 | 749 | // 1レコード目 750 | { 751 | RecordedRequest request = server.takeRequest(); 752 | assertThat(request.getMethod()).isEqualTo("POST"); 753 | assertThat(request.getHeader("X-Redmine-API-Key")).isEqualTo("apikey1234567890"); 754 | assertThat(request.getPath()).isEqualTo("/issues.json"); 755 | assertThat(request.getBody().readUtf8()).isEqualTo( 756 | "{\"issue\":{\"project_id\":\"1\",\"subject\":\"_田\",\"description\":\"絵文字___\"}}"); 757 | } 758 | } 759 | } 760 | } 761 | --------------------------------------------------------------------------------