grim/youtrack-import
Lots of work... Changed the way import works and implemented version and subsystem importing
--- a/bitbucket/cmd.go Wed Dec 11 14:22:12 2019 -0600
+++ b/bitbucket/cmd.go Tue Dec 17 04:30:16 2019 -0600
@@ -1,17 +1,12 @@
"hg.sr.ht/~grim/youtrack-import/globals"
"hg.sr.ht/~grim/youtrack-import/youtrack"
- Archive string `kong:"arg,name='archive',help='The zip file containing the archive'"`
- Project string `kong:"arg,name='project',help='The name of the project'"`
- ProjectID string `kong:"flag,name='project-id',help='The ID of the proejct'"`
+ Archive string `kong:"arg,name='archive',help='The zip file containing the archive'"` func (c *Cmd) Run(g *globals.Globals) error {
@@ -20,29 +15,10 @@
- client, err := youtrack.NewClient(g.URL, g.Token)
+ project, err := archive.convert() - fmt.Printf("Loaded %d issues\n", len(archive.Issues))
- for _, issue := range archive.Issues {
- vals.Set("project", c.Project)
- resp, err := client.Put("/issue", vals)
- if resp.StatusCode != http.StatusCreated {
- return fmt.Errorf("expected %d got %d", http.StatusCreated, resp.StatusCode)
- fmt.Printf("%#v\n", client)
+ return youtrack.Import(g, project) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/bitbucket/converter.go Tue Dec 17 04:30:16 2019 -0600
@@ -0,0 +1,76 @@
+ "hg.sr.ht/~grim/youtrack-import/youtrack" + typeMap = map[string]string{ + "enhancement": "Feature", + stateMap = map[string]string{ + "on hold": "Incomplete", + "invalid": "Can't Reproduce", + "duplicate": "Duplicate", + "wontfix": "Won't Fix", + priorityMap = map[string]string{ + "critical": "Critical", + "blocker": "Show-stopper", +func convertIssue(vals url.Values) { + if val, found := vals["type"]; found { + if replace, found := typeMap[val[0]]; found { + vals["type"] = []string{replace} + if val, found := vals["state"]; found { + if replace, found := stateMap[val[0]]; found { + vals["state"] = []string{replace} + if val, found := vals["priority"]; found { + if replace, found := priorityMap[val[0]]; found { + vals["priority"] = []string{replace} +func (a *Archive) convert() (*youtrack.Project, error) { + // create a string slice of all the components + components := make([]string, len(a.Components)) + for i := 0; i < len(a.Components); i++ { + components[i] = a.Components[i].Name + // create a string slice of all the versions + versions := make([]string, len(a.Versions)) + for i := 0; i < len(a.Versions); i++ { + versions[i] = a.Versions[i].Name + project := &youtrack.Project{ + Subsystems: components, --- a/bitbucket/types.go Wed Dec 11 14:22:12 2019 -0600
+++ b/bitbucket/types.go Tue Dec 17 04:30:16 2019 -0600
@@ -1,7 +1,6 @@
@@ -30,29 +29,6 @@
Voters []Author `json:"voters"`
-func (i *Issue) Encode() url.Values {
- // we check each value before adding it because we want to omit blanks
- if i.Assignee.DisplayName != "" {
- vals.Set("assignee", i.Assignee.DisplayName)
- vals.Set("subsystem", i.Component)
- vals.Set("description", i.Content)
- vals.Set("summary", i.Title)
Content string `json:"content"`
CreatedOn time.Time `json:"created_on"`
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/build-and-run Tue Dec 17 04:30:16 2019 -0600
@@ -0,0 +1,8 @@
+echo -n "deleting project \"${1}\" ... " +curl -X DELETE -H "Authorization: Bearer ${YOUTRACK_TOKEN}" "${YOUTRACK_URL}rest/admin/project/${1}" +./youtrack-import --project-id="${1}" --project-name="${2}" bitbucket "${3}" --- a/globals/globals.go Wed Dec 11 14:22:12 2019 -0600
+++ b/globals/globals.go Tue Dec 17 04:30:16 2019 -0600
@@ -1,6 +1,9 @@
- URL string `kong:"flag,name='url',required=True,help='The URL to the YouTrack server',env='YOUTRACK_URL'"`
- Token string `kong:"flag,name='token',required=True,help='The token used to authenticate to the YouTrack server',env='YOUTRACK_TOKEN'"`
+ URL string `kong:"flag,name='url',required=True,help='The URL to the YouTrack server',env='YOUTRACK_URL'"` + Token string `kong:"flag,name='token',required=True,help='The token used to authenticate to the YouTrack server',env='YOUTRACK_TOKEN'"` + ProjectName string `kong:"flag,name='project-name',required=True,help='The name of the project'"` + ProjectID string `kong:"flag,name='project-id',help='The ID of the project'"` + ProjectLeadLogin string `kong:"flag,name='project-lead-login',short='l',help='The login of the leader of this project',default='admin'"` --- a/go.mod Wed Dec 11 14:22:12 2019 -0600
+++ b/go.mod Tue Dec 17 04:30:16 2019 -0600
@@ -2,4 +2,7 @@
-require github.com/alecthomas/kong v0.2.1
+ github.com/alecthomas/kong v0.2.1 + github.com/gorilla/schema v1.1.0 --- a/go.sum Wed Dec 11 14:22:12 2019 -0600
+++ b/go.sum Tue Dec 17 04:30:16 2019 -0600
@@ -1,6 +1,8 @@
github.com/alecthomas/kong v0.2.1 h1:V1tLBhyQBC4rsbXbcOvm3GBaytJSwRNX69fp1WJxbqQ=
github.com/alecthomas/kong v0.2.1/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= +github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
--- a/youtrack/client.go Wed Dec 11 14:22:12 2019 -0600
+++ b/youtrack/client.go Tue Dec 17 04:30:16 2019 -0600
@@ -2,10 +2,11 @@
@@ -31,17 +32,22 @@
-func (c *Client) Put(uri string, vals url.Values) (*http.Response, error) {
- body := strings.NewReader(raw)
+func (c *Client) checkStatus(resp *http.Response, expected int) error { + if resp.StatusCode != expected { + body, err := ioutil.ReadAll(resp.Body) + return fmt.Errorf("%s", string(body)) - req, err := http.NewRequest(http.MethodPut, c.uri+uri, body)
+func (c *Client) request(method, url string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequest(method, url, body) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("Content-Length", fmt.Sprintf("%d", len(raw)))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/youtrack/customfield.go Tue Dec 17 04:30:16 2019 -0600
@@ -0,0 +1,63 @@
+type CustomFieldParam struct { + Name string `xml:"name,attr"` + Value string `xml:"value,attr"` +type ProjectCustomField struct { + XMLName xml.Name `xml:"projectCustomField"` + Type string `xml:"type,attr"` + EmptyText string `xml:"emptyText,attr"` + CanBeEmpty bool `xml:"canBeEmpty,attr"` + Name string `xml:"name,attr"` + URL string `xml:"url,attr"` + Params []CustomFieldParam `xml:"param"` +func (c *Client) GetProjectCustomField(projectID, name string) (*ProjectCustomField, error) { + resp, err := c.request( + c.uri+"/admin/project/"+projectID+"/customfield/"+name, + defer resp.Body.Close() + if err := c.checkStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("failed to get custom field: %v", err) + pcf := &ProjectCustomField{} + if err := xml.NewDecoder(resp.Body).Decode(pcf); err != nil { +func (pcf *ProjectCustomField) GetBundleName() (string, error) { + for _, param := range pcf.Params { + if param.Name == "bundle" { + return "", fmt.Errorf("Failed to find the bundle for subsystems") --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/youtrack/import.go Tue Dec 17 04:30:16 2019 -0600
@@ -0,0 +1,45 @@
+ "hg.sr.ht/~grim/youtrack-import/globals" +func Import(g *globals.Globals, p *Project) error { + client, err := NewClient(g.URL, g.Token) + g.ProjectID = strings.ToUpper(g.ProjectName) + p.LeadLogin = g.ProjectLeadLogin + fmt.Printf("creating project %q ... ", p.ID) + if err := client.CreateProject(p); err != nil { + fmt.Printf("failed.\n") + // create the subsystems + if err := client.CreateSubsystems(p); err != nil { + if err := client.CreateVersions(p); err != nil { --- a/youtrack/issues.go Wed Dec 11 14:22:12 2019 -0600
+++ /dev/null Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-func (c *Client) CreateIssue(vals url.Values) error {
- resp, err := c.Put("/issue", vals)
- if resp.StatusCode != http.StatusCreated {
- return fmt.Errorf("unexpected status code %d", resp.StatusCode)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/youtrack/ownedfield.go Tue Dec 17 04:30:16 2019 -0600
@@ -0,0 +1,59 @@
+type OwnedField struct { + Owner string `xml:"owner,attr"` + Description string `xml:"description,attr"` + Value string `xml:",cdata"` +type OwnedFieldBundle struct { + XMLName xml.Name `xml:"ownedFieldBundle"` + Name string `xml:"name,attr"` + Fields []OwnedField `xml:"ownedField"` +func (c *Client) GetOwnedFieldBundle(name string) (*OwnedFieldBundle, error) { + resp, err := c.request( + c.uri+"/admin/customfield/ownedFieldBundle/"+name, + defer resp.Body.Close() + if err := c.checkStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("failed to get owned field bundle: %v", err) + bundle := &OwnedFieldBundle{} + return bundle, xml.NewDecoder(resp.Body).Decode(bundle) +func (c *Client) AppendOwnedFieldBundle(name, value string) error { + resp, err := c.request( + c.uri+"/admin/customfield/ownedFieldBundle/"+name+"/"+value, + defer resp.Body.Close() + if err := c.checkStatus(resp, http.StatusOK); err != nil { + return fmt.Errorf("failed to append %q to %q: %s", value, name, err) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/youtrack/project.go Tue Dec 17 04:30:16 2019 -0600
@@ -0,0 +1,36 @@
+func (c *Client) CreateProject(p *Project) error { + req, err := http.NewRequest( + c.uri+"/admin/project/"+p.ID, + q.Add("projectName", p.Name) + q.Add("projectLeadLogin", p.LeadLogin) + req.URL.RawQuery = q.Encode() + req.Header.Set("Content-Length", "0") + resp, err := c.client.Do(req) + defer resp.Body.Close() + if err := c.checkStatus(resp, http.StatusCreated); err != nil { --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/youtrack/subsystems.go Tue Dec 17 04:30:16 2019 -0600
@@ -0,0 +1,53 @@
+func (c *Client) GetProjectSubsystems(projectID string) (*OwnedFieldBundle, error) { + pcf, err := c.GetProjectCustomField(projectID, "Subsystem") + name, err := pcf.GetBundleName() + return c.GetOwnedFieldBundle(name) +func (c *Client) CreateSubsystems(p *Project) error { + bundle, err := c.GetProjectSubsystems(p.ID) + // create a map of all the subsystems + subsystems := map[string]bool{} + for _, subsystem := range p.Subsystems { + subsystems[subsystem] = true + // now remove any existing subsystems from the map + for _, field := range bundle.Fields { + if _, found := subsystems[field.Value]; found { + delete(subsystems, field.Value) + // now run through the map and add the new values + for subsystem, _ := range subsystems { + fmt.Printf("creating subsystem %q ... ", subsystem) + if err := c.AppendOwnedFieldBundle(bundle.Name, subsystem); err != nil { + fmt.Printf("failed.\n") --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/youtrack/types.go Tue Dec 17 04:30:16 2019 -0600
@@ -0,0 +1,85 @@
+func (i *Issue) encode() xmlIssue { + x.AddField("numberInProject", fmt.Sprintf("%d", i.Number)) + x.AddField("summary", i.Summary) + x.AddField("created", fmt.Sprintf("%d", i.Created.Unix())) + x.AddField("reporterName", i.Reporter) + if i.Description != "" { + x.AddField("description", i.Description) + if !i.Updated.IsZero() { + x.AddField("updated", fmt.Sprintf("%d", i.Updated.Unix())) + x.AddField("updaterName", i.UpdatedBy) + if !i.Resolved.IsZero() { + x.AddField("resolved", fmt.Sprintf("%d", i.Resolved.Unix())) + x.AddFieldSlice("voterName", i.Voters) + if len(i.Watchers) > 0 { + x.AddFieldSlice("watcherName", i.Watchers) + if i.PermittedGroup != "" { + x.AddField("permittedGroup", i.PermittedGroup) + for _, c := range i.Comments { + x.AddComment(c.Author, c.Text, c.Created, c.Updated) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/youtrack/version.go Tue Dec 17 04:30:16 2019 -0600
@@ -0,0 +1,109 @@
+type VersionField struct { + Released bool `xml:"released,attr"` + Archived bool `xml:"archived,attr"` + Description string `xml:"description,attr"` + ReleaseDate int64 `xml:"releaseDate,attr"` + Name string `xml:",cdata"` +type VersionFieldBundle struct { + XMLName xml.Name `xml:"versions"` + Name string `xml:"name,attr"` + Versions []VersionField `xml:"version"` +func (c *Client) GetProjectVersions(projectID string) (*VersionFieldBundle, error) { + pcf, err := c.GetProjectCustomField(projectID, "Affected versions") + name, err := pcf.GetBundleName() + return c.GetVersionFieldBundle(name) +func (c *Client) GetVersionFieldBundle(name string) (*VersionFieldBundle, error) { + resp, err := c.request( + c.uri+"/admin/customfield/versionBundle/"+name, + defer resp.Body.Close() + if err := c.checkStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("failed to get versionBundle: %v", err) + bundle := &VersionFieldBundle{} + return bundle, xml.NewDecoder(resp.Body).Decode(bundle) +func (c *Client) AppendVersionFieldBundle(name, value string) error { + resp, err := c.request( + c.uri+"/admin/customfield/versionBundle/"+name+"/"+value, + defer resp.Body.Close() + if err := c.checkStatus(resp, http.StatusOK); err != nil { + return fmt.Errorf("failed to append %q to %q: %v", value, name, err) +func (c *Client) CreateVersions(p *Project) error { + bundle, err := c.GetProjectVersions(p.ID) + // create a map of all the versions + versions := map[string]bool{} + for _, version := range p.Versions { + versions[version] = true + // now remove any existing versions from the map + for _, version := range bundle.Versions { + if _, found := versions[version.Name]; found { + delete(versions, version.Name) + // now run through the map and add all the missing versions + for version, _ := range versions { + fmt.Printf("creating version %q ... ", version) + if err := c.AppendVersionFieldBundle(bundle.Name, version); err != nil { + fmt.Printf("failed.\n") --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/youtrack/xml.go Tue Dec 17 04:30:16 2019 -0600
@@ -0,0 +1,50 @@
+ XMLName xml.Name `xml:"field"` + Name string `xml:"name,attr"` + Values []string `xml:"value"` +type xmlComment struct { + XMLName xml.Name `xml:"comment"` + Author string `xml:"author,attr,omitempty"` + Text string `xml:"text,attr,omitempty"` + Created int64 `xml:"created,attr,omitempty"` + Updated int64 `xml:"updated,attr,omitempty"` + XMLName xml.Name `xml:"issue"` +func (x *xmlIssue) AddField(name, value string) { + x.AddFieldSlice(name, []string{value}) +func (x *xmlIssue) AddFieldSlice(name string, values []string) { + x.Fields = append(x.Fields, xmlField{Name: name, Values: values}) +func (x *xmlIssue) AddComment(author, text string, created, updated time.Time) { + Created: created.Unix(), + Updated: updated.Unix(), + x.Comments = append(x.Comments, c) + XMLName xml.Name `xml:"issues"`