grim/youtrack-import
user maps with bitbucket work. Still needs more testing though
--- a/bitbucket/archive.go Mon Dec 30 20:17:16 2019 -0600
+++ b/bitbucket/archive.go Tue Dec 31 01:08:27 2019 -0600
@@ -4,6 +4,8 @@
+ "hg.sr.ht/~grim/youtrack-import/youtrack" func loadArchive(archive string) (*Archive, error) {
@@ -32,3 +34,72 @@
return nil, fmt.Errorf("failed to find db-2.0.json in the archive")
+func (a *Archive) ExtractUsers(usersMap *UsersMap) (map[string]*youtrack.User, error) { + // key is the id of the user + users := map[string]*youtrack.User{} + mapUser := func(a *Author) error { + bbUser, err := usersMap.Find(a.DisplayName) + users[a.AccountID] = bbUser.ToYoutrack() + // find all users from issues + for _, issue := range a.Issues { + if err := mapUser(&issue.Assignee); err != nil { + if err := mapUser(&issue.Reporter); err != nil { + for _, watcher := range issue.Watchers { + if err := mapUser(&watcher); err != nil { + for _, voter := range issue.Voters { + if err := mapUser(&voter); err != nil { + // find all users from comments + for _, comment := range a.Comments { + if err := mapUser(&comment.User); err != nil { + // find all users from attachments + for _, attachment := range a.Attachments { + if err := mapUser(&attachment.User); err != nil { + // find all users from logs + for _, log := range a.Logs { + if err := mapUser(&log.User); err != nil { + // extract the user from the default assignee + if err := mapUser(&a.Meta.DefaultAssignee); err != nil { --- a/bitbucket/cmd.go Mon Dec 30 20:17:16 2019 -0600
+++ b/bitbucket/cmd.go Tue Dec 31 01:08:27 2019 -0600
@@ -6,7 +6,8 @@
- Archive string `kong:"arg,name='archive',help='The zip file containing the archive'"`
+ Archive string `kong:"arg,name='archive',help='The zip file containing the archive'"` + UsersMapFile string `kong:"arg,name="users-map",help='A key=value file mapping emails to display names'"` func (c *Cmd) Run(g *globals.Globals) error {
@@ -15,7 +16,12 @@
- project, err := archive.convert()
+ usersMap, err := NewUsersMap(c.UsersMapFile) + project, err := archive.convert(usersMap) --- a/bitbucket/converter.go Mon Dec 30 20:17:16 2019 -0600
+++ b/bitbucket/converter.go Tue Dec 31 01:08:27 2019 -0600
@@ -32,14 +32,19 @@
-func (a *Archive) convertIssue(bb Issue) *youtrack.Issue {
+func (a *Archive) convertIssue(bb Issue, userMap map[string]*youtrack.User) *youtrack.Issue { + if user, found := userMap[bb.Reporter.AccountID]; found { - Reporter: bb.Reporter.String(),
@@ -61,10 +66,22 @@
-func (a *Archive) convert() (*youtrack.Project, error) {
+func (a *Archive) convert(usersMap *UsersMap) (*youtrack.Project, error) { + users, err := a.ExtractUsers(usersMap) + // create a slice of youtrack users for import + ytUsers := make([]*youtrack.User, len(users)) + for _, user := range users { // create a string slice of all the components
components := make([]string, len(a.Components))
for i := 0; i < len(a.Components); i++ {
@@ -80,10 +97,11 @@
// convert all of the issues
issues := make([]*youtrack.Issue, len(a.Issues))
for i := 0; i < len(a.Issues); i++ {
- issues[i] = a.convertIssue(a.Issues[i])
+ issues[i] = a.convertIssue(a.Issues[i], users) project := &youtrack.Project{
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/bitbucket/usersmap.go Tue Dec 31 01:08:27 2019 -0600
@@ -0,0 +1,57 @@
+ "hg.sr.ht/~grim/youtrack-import/youtrack" + FullName string `json:"fullName"` + Login string `json:"login"` + Email string `json:"email"` +func (u *User) ToYoutrack() *youtrack.User { + return youtrack.NewUser(u.Login, u.FullName, u.Email) +type UsersMapData struct { + Users []User `json:"users"` +func NewUsersMap(filename string) (*UsersMap, error) { + data, err := ioutil.ReadFile(filename) + if err := json.Unmarshal(data, &d); err != nil { + users: map[string]User{}, + for _, user := range d.Users { + m.users[user.FullName] = user +func (m *UsersMap) Find(displayName string) (User, error) { + if user, found := m.users[displayName]; found { + return User{}, fmt.Errorf("no user found for %q", displayName) --- a/build-and-run Mon Dec 30 20:17:16 2019 -0600
+++ b/build-and-run Tue Dec 31 01:08:27 2019 -0600
@@ -4,5 +4,5 @@
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}"
+./youtrack-import --project-id="${1}" --project-name="${2}" bitbucket "${3}" "${4}" --- a/youtrack/client.go Mon Dec 30 20:17:16 2019 -0600
+++ b/youtrack/client.go Tue Dec 31 01:08:27 2019 -0600
@@ -41,7 +41,7 @@
- fmt.Printf("failed to find an acceptable status code\n")
+ fmt.Printf("failed to find an acceptable status code: got %d\n", resp.StatusCode) body, err := ioutil.ReadAll(resp.Body)
--- a/youtrack/import.go Mon Dec 30 20:17:16 2019 -0600
+++ b/youtrack/import.go Tue Dec 31 01:08:27 2019 -0600
@@ -41,6 +41,11 @@
+ if err := client.ImportUsers(p); err != nil { if err := client.ImportIssues(p); err != nil {
--- a/youtrack/issue.go Mon Dec 30 20:17:16 2019 -0600
+++ b/youtrack/issue.go Tue Dec 31 01:08:27 2019 -0600
@@ -9,7 +9,7 @@
- Number int `yt"numberInProject"`
+ Number int `yt:"numberInProject"` Summary string `yt:"summary"`
Description string `yt:"description"`
Created time.Time `yt:"created"`
@@ -85,7 +85,7 @@
// convert the datastructure to an xml string
data, err := xml.Marshal(issues)
- return fmt.Errorf("wut: %v", err)
// create the reader for the put request
@@ -97,7 +97,7 @@
- return fmt.Errorf("wtf %v", err)
@@ -107,10 +107,16 @@
report := xmlImportReport{}
if err := xml.NewDecoder(resp.Body).Decode(&report); err != nil {
- return fmt.Errorf("repo: %v", err)
- fmt.Printf("%#v\n", report)
+ if errs := report.Validate(); len(errs) > 0 { + for _, err := range errs { + fmt.Printf("%v\n", err) + return fmt.Errorf("error importing issues") --- a/youtrack/project.go Mon Dec 30 20:17:16 2019 -0600
+++ b/youtrack/project.go Tue Dec 31 01:08:27 2019 -0600
@@ -9,6 +9,8 @@
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/youtrack/user.go Tue Dec 31 01:08:27 2019 -0600
@@ -0,0 +1,129 @@
+func NewUser(login, fullName, email string) *User { +func (u *User) encode() xmlUser { +func (c *Client) importUsersRange(u []*User, s, e int) []error { + users.Users = make([]xmlUser, e-s) + for i := s; i < e; i++ { + users.Users[i-s] = u[i].encode() + data, err := xml.Marshal(users) + putBody := bytes.NewReader(data) + resp, err := c.xmlRequest( + defer resp.Body.Close() + if err := c.checkStatuses(resp, []int{http.StatusOK, http.StatusBadRequest}); err != nil { + report := xmlImportReport{} + if err := xml.NewDecoder(resp.Body).Decode(&report); err != nil { + return report.Validate() +func (c *Client) ImportUsers(p *Project) error { + // loop through the users we were given and create a new list based on the + // ones that we need to create. + for _, user := range p.Users { + exists, err := c.UserExists(user.Login) + users = append(users, user) + for s := 0; s < len(users); s += r { + fmt.Printf("importing users %d-%d ...", s, e) + if errs := c.importUsersRange(users, s, e); len(errs) > 0 { + fmt.Printf("failed.\n") + for _, err := range errs { + fmt.Printf("%v\n", err) + return fmt.Errorf("error importing users") +func (c *Client) UserExists(username string) (bool, error) { + resp, err := c.xmlRequest( + c.uri+"/user/"+username, + if resp.StatusCode == http.StatusBadRequest { --- a/youtrack/xml.go Mon Dec 30 20:17:16 2019 -0600
+++ b/youtrack/xml.go Tue Dec 31 01:08:27 2019 -0600
@@ -2,6 +2,8 @@
@@ -66,7 +68,43 @@
Items []xmlImportReportItem `xml:"item"`
+func (x *xmlImportReport) Validate() []error { + for _, item := range x.Items { + if item.Imported == false { + for _, e := range item.Errors { + "field: %q; value: %q: %s", + errMsg = append(errMsg, msg) + ret = append(ret, fmt.Errorf("%s", strings.Join(errMsg, "\n"))) XMLName xml.Name `xml:"error"`
Message string `xml:",cdata"`
+ Login string `xml:"login,attr"` + FullName string `xml:"fullName,attr,omitempty"` + Email string `xml:"email,attr,omitempty"` + XMLName xml.Name `xml:"list"` + Users []xmlUser `xml:"user"`