grim/youtrack-import
The basics of file attachments are working. Mime types are broken and there's some timing issue yet
--- a/bitbucket/archive.go Tue Jan 14 01:08:46 2020 -0600
+++ b/bitbucket/archive.go Tue Jan 14 04:41:41 2020 -0600
@@ -4,6 +4,7 @@
"hg.sr.ht/~grim/youtrack-import/youtrack"
@@ -13,7 +14,6 @@
for _, f := range r.File {
if f.Name == "db-2.0.json" {
@@ -28,6 +28,8 @@
@@ -35,6 +37,26 @@
return nil, fmt.Errorf("failed to find db-2.0.json in the archive")
+func (a *Archive) Close() error { +func (a *Archive) OpenAttachment(path string) (io.ReadCloser, error) { + // find the file in the archive + for _, f := range a.zip.File { + return nil, fmt.Errorf("failed to find attachment %q", path) func (a *Archive) ExtractUsers(usersMap *UsersMap) (map[string]*youtrack.User, error) {
// key is the id of the user
users := map[string]*youtrack.User{}
--- a/bitbucket/cmd.go Tue Jan 14 01:08:46 2020 -0600
+++ b/bitbucket/cmd.go Tue Jan 14 04:41:41 2020 -0600
@@ -15,6 +15,7 @@
usersMap, err := NewUsersMap(c.UsersMapFile)
--- a/bitbucket/converter.go Tue Jan 14 01:08:46 2020 -0600
+++ b/bitbucket/converter.go Tue Jan 14 04:41:41 2020 -0600
@@ -77,8 +77,9 @@
+ // add the comments and attachments yt.Comments = a.convertComments(userMap, bb.ID)
+ yt.Attachments = a.convertAttachments(userMap, bb) @@ -144,6 +145,34 @@
+func (a *Archive) convertAttachments(userMap map[string]*youtrack.User, issue Issue) []*youtrack.Attachment { + attachments := []*youtrack.Attachment{} + for _, attachment := range a.Attachments { + if attachment.Issue != issue.ID { + if user, found := userMap[attachment.User.AccountID]; found { + reader, err := a.OpenAttachment(attachment.Path) + fmt.Printf("%v\n", err) + yt := youtrack.NewAttachment(attachment.Issue, author, issue.CreatedOn) + yt.AddFile(reader, attachment.Filename) + attachments = append(attachments, yt) func (a *Archive) convert(usersMap *UsersMap) (*youtrack.Project, error) {
users, err := a.ExtractUsers(usersMap)
--- a/bitbucket/types.go Tue Jan 14 01:08:46 2020 -0600
+++ b/bitbucket/types.go Tue Jan 14 04:41:41 2020 -0600
@@ -1,6 +1,7 @@
@@ -88,4 +89,6 @@
Components []Component `json:"components"`
Milestones []Milestone `json:"milestones"`
Versions []Version `json:"versions"`
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/youtrack/attachment.go Tue Jan 14 04:41:41 2020 -0600
@@ -0,0 +1,107 @@
+type Attachment struct { + payload *strings.Builder + writer *multipart.Writer +func NewAttachment(issue int, author string, created time.Time) *Attachment { + a.payload = &strings.Builder{} + a.writer = multipart.NewWriter(a.payload) +func (a *Attachment) Payload() (string, error) { + if err := a.writer.Close(); err != nil { + return a.payload.String(), nil +func (a *Attachment) AddFile(r io.ReadCloser, filename string) error { + w, err := a.writer.CreateFormFile(filename, filename) + a.files = append(a.files, filename) +func (a *Attachment) ContentType() string { + return a.writer.FormDataContentType() +func (c *Client) importAttachment(p *Project, a *Attachment) error { + values.Add("authorLogin", a.author) + values.Add("created", formatTimeString(a.created)) + payload, err := a.Payload() + reader := strings.NewReader(payload) + issueID := fmt.Sprintf("%s-%d", p.ID, a.issue) + resp, err := c.requestContentType( + c.uri+"/import/"+issueID+"/attachment?"+values.Encode(), + defer resp.Body.Close() + return c.validateXmlImportReport( + []int{http.StatusOK, http.StatusCreated}, +func (c *Client) ImportAttachments(p *Project) error { + for _, issue := range p.Issues { + for _, attachment := range issue.Attachments { + if err := c.importAttachment(p, attachment); err != nil { --- a/youtrack/client.go Tue Jan 14 01:08:46 2020 -0600
+++ b/youtrack/client.go Tue Jan 14 04:41:41 2020 -0600
@@ -69,14 +69,17 @@
-func (c *Client) xmlRequest(method, url string, body io.Reader) (*http.Response, error) {
+func (c *Client) requestContentType(method, url, contentType string, body io.Reader) (*http.Response, error) { req, err := http.NewRequest(method, url, body)
- req.Header.Add("Content-Type", "application/xml")
+ req.Header.Add("Content-Type", contentType) +func (c *Client) xmlRequest(method, url string, body io.Reader) (*http.Response, error) { + return c.requestContentType(method, url, "application/xml", body) --- a/youtrack/import.go Tue Jan 14 01:08:46 2020 -0600
+++ b/youtrack/import.go Tue Jan 14 04:41:41 2020 -0600
@@ -51,5 +51,10 @@
+ // create the attachments + if err := client.ImportAttachments(p); err != nil { --- a/youtrack/issue.go Tue Jan 14 01:08:46 2020 -0600
+++ b/youtrack/issue.go Tue Jan 14 04:41:41 2020 -0600
@@ -29,7 +29,8 @@
State string `yt:"state"`
+ Attachments []*Attachment func (i *Issue) encode() xmlIssue {
@@ -125,24 +126,10 @@
- if err := c.checkStatuses(resp, []int{http.StatusOK, http.StatusBadRequest}); err != nil {
- return fmt.Errorf("failed to import issues: %v", err)
- report := xmlImportReport{}
- if err := xml.NewDecoder(resp.Body).Decode(&report); err != nil {
- if errs := report.Validate(); len(errs) > 0 {
- for _, err := range errs {
- fmt.Printf("%v\n", err)
- return fmt.Errorf("error importing issues")
+ return c.validateXmlImportReport( + []int{http.StatusOK, http.StatusBadRequest}, func (c *Client) ImportIssues(p *Project) error {
--- a/youtrack/xml.go Tue Jan 14 01:08:46 2020 -0600
+++ b/youtrack/xml.go Tue Jan 14 04:41:41 2020 -0600
@@ -3,6 +3,7 @@
@@ -69,6 +70,27 @@
Items []xmlImportReportItem `xml:"item"`
+func (c *Client) validateXmlImportReport(statusCodes []int, resp *http.Response) error { + if err := c.checkStatuses(resp, statusCodes); err != nil { + report := xmlImportReport{} + if err := xml.NewDecoder(resp.Body).Decode(&report); err != nil { + if errs := report.Validate(); len(errs) > 0 { + for _, err := range errs { + fmt.Printf("%v\n", err) + return fmt.Errorf("failed to import values") func (x *xmlImportReport) Validate() []error {