grim/youtrack-import

Parents b4f0943b2566
Children c7b965f00b18
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 @@
package bitbucket
import (
- "fmt"
- "net/http"
-
"hg.sr.ht/~grim/youtrack-import/globals"
"hg.sr.ht/~grim/youtrack-import/youtrack"
)
type Cmd struct {
- 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 @@
return err
}
- client, err := youtrack.NewClient(g.URL, g.Token)
+ project, err := archive.convert()
if err != nil {
return err
}
- fmt.Printf("Loaded %d issues\n", len(archive.Issues))
-
- for _, issue := range archive.Issues {
- vals := issue.Encode()
-
- vals.Set("project", c.Project)
-
- resp, err := client.Put("/issue", vals)
- if err != nil {
- return err
- }
-
- if resp.StatusCode != http.StatusCreated {
- return fmt.Errorf("expected %d got %d", http.StatusCreated, resp.StatusCode)
- }
- }
-
- fmt.Printf("%#v\n", client)
-
- return nil
+ 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 @@
+package bitbucket
+
+import (
+ "net/url"
+
+ "hg.sr.ht/~grim/youtrack-import/youtrack"
+)
+
+var (
+ typeMap = map[string]string{
+ "bug": "Bug",
+ "enhancement": "Feature",
+ "proposal": "Feature",
+ "task": "Task",
+ }
+
+ stateMap = map[string]string{
+ "new": "Submitted",
+ "open": "Open",
+ "resolved": "Fixed",
+ "on hold": "Incomplete",
+ "invalid": "Can't Reproduce",
+ "duplicate": "Duplicate",
+ "wontfix": "Won't Fix",
+ "closed": "Fixed",
+ }
+
+ priorityMap = map[string]string{
+ "trivial": "Minor",
+ "minor": "Normal",
+ "major": "Major",
+ "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,
+ Versions: versions,
+ }
+
+ return project, nil
+}
--- 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 @@
package bitbucket
import (
- "net/url"
"time"
)
@@ -30,29 +29,6 @@
Voters []Author `json:"voters"`
}
-func (i *Issue) Encode() url.Values {
- vals := 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)
- }
-
- if i.Component != "" {
- vals.Set("subsystem", i.Component)
- }
-
- if i.Content != "" {
- vals.Set("description", i.Content)
- }
-
- if i.Title != "" {
- vals.Set("summary", i.Title)
- }
-
- return vals
-}
-
type Comment struct {
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 @@
+#!/bin/sh -e
+
+go build
+echo -n "deleting project \"${1}\" ... "
+curl -X DELETE -H "Authorization: Bearer ${YOUTRACK_TOKEN}" "${YOUTRACK_URL}rest/admin/project/${1}"
+echo "done."
+./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 @@
package globals
type Globals struct {
- 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 @@
go 1.13
-require github.com/alecthomas/kong v0.2.1
+require (
+ 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 @@
import (
"fmt"
+ "io"
+ "io/ioutil"
"net/http"
"net/url"
"path/filepath"
- "strings"
)
type Client struct {
@@ -31,17 +32,22 @@
}, nil
}
-func (c *Client) Put(uri string, vals url.Values) (*http.Response, error) {
- raw := vals.Encode()
- body := strings.NewReader(raw)
+func (c *Client) checkStatus(resp *http.Response, expected int) error {
+ if resp.StatusCode != expected {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("%s", string(body))
+ }
+ }
- req, err := http.NewRequest(http.MethodPut, c.uri+uri, body)
+ return nil
+}
+
+func (c *Client) request(method, url string, body io.Reader) (*http.Response, error) {
+ req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("Content-Length", fmt.Sprintf("%d", len(raw)))
-
return c.client.Do(req)
}
--- /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 @@
+package youtrack
+
+import (
+ "encoding/xml"
+ "fmt"
+ "net/http"
+)
+
+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(
+ http.MethodGet,
+ c.uri+"/admin/project/"+projectID+"/customfield/"+name,
+ nil,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ 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 {
+ return nil, err
+ }
+
+ return pcf, nil
+}
+
+func (pcf *ProjectCustomField) GetBundleName() (string, error) {
+ bundle := ""
+ for _, param := range pcf.Params {
+ if param.Name == "bundle" {
+ bundle = param.Value
+ break
+ }
+ }
+
+ if bundle == "" {
+ return "", fmt.Errorf("Failed to find the bundle for subsystems")
+ }
+
+ return bundle, nil
+}
--- /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 @@
+package youtrack
+
+import (
+ "fmt"
+ "strings"
+
+ "hg.sr.ht/~grim/youtrack-import/globals"
+)
+
+func Import(g *globals.Globals, p *Project) error {
+ client, err := NewClient(g.URL, g.Token)
+ if err != nil {
+ return err
+ }
+
+ if g.ProjectID == "" {
+ g.ProjectID = strings.ToUpper(g.ProjectName)
+ }
+
+ if p.ID == "" {
+ p.ID = g.ProjectID
+ }
+
+ p.Name = g.ProjectName
+ p.LeadLogin = g.ProjectLeadLogin
+
+ fmt.Printf("creating project %q ... ", p.ID)
+ if err := client.CreateProject(p); err != nil {
+ fmt.Printf("failed.\n")
+ return err
+ }
+ fmt.Printf("done.\n")
+
+ // create the subsystems
+ if err := client.CreateSubsystems(p); err != nil {
+ return err
+ }
+
+ // create the versions
+ if err := client.CreateVersions(p); err != nil {
+ return err
+ }
+
+ return 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 @@
-package youtrack
-
-import (
- "fmt"
- "net/http"
- "net/url"
-)
-
-func (c *Client) CreateIssue(vals url.Values) error {
- resp, err := c.Put("/issue", vals)
- if err != nil {
- return err
- }
-
- if resp.StatusCode != http.StatusCreated {
- return fmt.Errorf("unexpected status code %d", resp.StatusCode)
- }
-
- return nil
-}
--- /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 @@
+package youtrack
+
+import (
+ "encoding/xml"
+ "fmt"
+ "net/http"
+)
+
+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(
+ http.MethodGet,
+ c.uri+"/admin/customfield/ownedFieldBundle/"+name,
+ nil,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ 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(
+ http.MethodPut,
+ c.uri+"/admin/customfield/ownedFieldBundle/"+name+"/"+value,
+ nil,
+ )
+ if err != nil {
+ return err
+ }
+
+ 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)
+ }
+
+ return nil
+}
--- /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 @@
+package youtrack
+
+import (
+ "net/http"
+)
+
+func (c *Client) CreateProject(p *Project) error {
+ req, err := http.NewRequest(
+ http.MethodPut,
+ c.uri+"/admin/project/"+p.ID,
+ nil,
+ )
+ if err != nil {
+ return err
+ }
+
+ q := req.URL.Query()
+ 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)
+ if err != nil {
+ return err
+ }
+
+ defer resp.Body.Close()
+
+ if err := c.checkStatus(resp, http.StatusCreated); err != nil {
+ return err
+ }
+
+ return 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 @@
+package youtrack
+
+import (
+ "fmt"
+)
+
+func (c *Client) GetProjectSubsystems(projectID string) (*OwnedFieldBundle, error) {
+ pcf, err := c.GetProjectCustomField(projectID, "Subsystem")
+ if err != nil {
+ return nil, err
+ }
+
+ name, err := pcf.GetBundleName()
+ if err != nil {
+ return nil, err
+ }
+
+ return c.GetOwnedFieldBundle(name)
+}
+
+func (c *Client) CreateSubsystems(p *Project) error {
+ bundle, err := c.GetProjectSubsystems(p.ID)
+ if err != nil {
+ return err
+ }
+
+ // 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")
+
+ return err
+ }
+
+ fmt.Printf("done.\n")
+ }
+
+ return nil
+}
--- /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 @@
+package youtrack
+
+import (
+ "fmt"
+ "time"
+)
+
+type Project struct {
+ ID string
+ Name string
+ LeadLogin string
+
+ Subsystems []string
+ Versions []string
+
+ Issues []Issue
+}
+
+type Comment struct {
+ Author string
+ Text string
+ Created time.Time
+ Updated time.Time
+}
+
+type Issue struct {
+ Number int
+ Summary string
+ Description string
+ Created time.Time
+ Updated time.Time
+ UpdatedBy string
+ Resolved time.Time
+ Reporter string
+ Voters []string
+ Watchers []string
+ PermittedGroup string
+
+ Comments []Comment
+}
+
+func (i *Issue) encode() xmlIssue {
+ x := xmlIssue{}
+
+ // required fields
+ 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)
+
+ // optional fields
+ if i.Description != "" {
+ x.AddField("description", i.Description)
+ }
+
+ if !i.Updated.IsZero() {
+ x.AddField("updated", fmt.Sprintf("%d", i.Updated.Unix()))
+ }
+
+ if i.UpdatedBy != "" {
+ x.AddField("updaterName", i.UpdatedBy)
+ }
+
+ if !i.Resolved.IsZero() {
+ x.AddField("resolved", fmt.Sprintf("%d", i.Resolved.Unix()))
+ }
+
+ if len(i.Voters) > 0 {
+ 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)
+ }
+
+ return x
+}
--- /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 @@
+package youtrack
+
+import (
+ "encoding/xml"
+ "fmt"
+ "net/http"
+)
+
+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")
+ if err != nil {
+ return nil, err
+ }
+
+ name, err := pcf.GetBundleName()
+ if err != nil {
+ return nil, err
+ }
+
+ return c.GetVersionFieldBundle(name)
+}
+
+func (c *Client) GetVersionFieldBundle(name string) (*VersionFieldBundle, error) {
+ resp, err := c.request(
+ http.MethodGet,
+ c.uri+"/admin/customfield/versionBundle/"+name,
+ nil,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ 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(
+ http.MethodPut,
+ c.uri+"/admin/customfield/versionBundle/"+name+"/"+value,
+ nil,
+ )
+ if err != nil {
+ return err
+ }
+
+ 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)
+ }
+
+ return nil
+}
+
+func (c *Client) CreateVersions(p *Project) error {
+ bundle, err := c.GetProjectVersions(p.ID)
+ if err != nil {
+ return err
+ }
+
+ // 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")
+
+ return err
+ }
+
+ fmt.Printf("done.\n")
+ }
+
+ return nil
+}
--- /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 @@
+package youtrack
+
+import (
+ "encoding/xml"
+ "time"
+)
+
+type xmlField struct {
+ 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"`
+}
+
+type xmlIssue struct {
+ XMLName xml.Name `xml:"issue"`
+ Fields []xmlField
+ Comments []xmlComment
+}
+
+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) {
+ c := xmlComment{
+ Author: author,
+ Text: text,
+ Created: created.Unix(),
+ Updated: updated.Unix(),
+ }
+
+ x.Comments = append(x.Comments, c)
+}
+
+type xmlIssues struct {
+ XMLName xml.Name `xml:"issues"`
+ Issues []xmlIssue
+}