pkg/server/meta/youtrack.go (601 lines of code) (raw):

package meta import ( "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "regexp" "slices" "sort" "strconv" "strings" "sync" "github.com/JetBrains/ij-perf-report-aggregator/pkg/server/auth" "github.com/jackc/pgx/v5/pgxpool" ) type YoutrackCreateIssueRequest struct { ProjectId string `json:"projectId"` AccidentId string `json:"accidentId"` TicketLabel string `json:"ticketLabel"` BuildLink string `json:"buildLink"` ChangesLink string `json:"changesLink"` CustomFields []CustomField `json:"customFields"` DashboardLink string `json:"dashboardLink"` AffectedMetric string `json:"affectedMetric"` Delta string `json:"delta"` TestMethodName *string `json:"testMethodName"` TestType string `json:"testType"` CurrentBuildId *int `json:"currentBuildId"` } type UploadAttachmentsToIssueRequest struct { IssueId string `json:"issueId"` TeamCityAttachmentInfo TeamCityAttachmentInfo `json:"teamcityAttachmentInfo"` AffectedTest string `json:"affectedTest"` ChartPng *[]byte `json:"chartPng"` TestType string `json:"testType"` } type GenerateDescriptionData struct { Kind string AffectedTest string AffectedMetric string Delta string StackTrace string BuildLink string Changes string DashboardLink string TestHistoryUrl *string TestMethod *string TestType string Commits *CommitRevisions } type CreateIssueResponse struct { Issue YoutrackIssue `json:"issue"` Exceptions []string `json:"exceptions"` } type VersionResponse struct { Values []struct { Name string `json:"name"` } `json:"values"` } var ( teamCityClient = NewTeamCityClient("https://buildserver.labs.intellij.net", os.Getenv("TEAMCITY_TOKEN")) youtrackClient = NewYoutrackClient("https://youtrack.jetbrains.com", os.Getenv("YOUTRACK_TOKEN")) ytAuth = auth.NewYTAuth("https://youtrack.jetbrains.com", os.Getenv("YOUTRACK_TOKEN")) ) func CreatePostCreateIssueByAccident(metaDb *pgxpool.Pool) http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { response := CreateIssueResponse{} body := request.Body all, err := io.ReadAll(body) if err != nil { handleError(writer, "cannot read body", err, &response.Exceptions) _ = marshalAndWriteIssueResponse(writer, &response) return } defer body.Close() var params YoutrackCreateIssueRequest if err = json.Unmarshal(all, &params); err != nil { handleError(writer, "cannot unmarshal parameters", err, &response.Exceptions) _ = marshalAndWriteIssueResponse(writer, &response) return } relatedAccident, err := getAccidentById(request.Context(), metaDb, params.AccidentId) lowerKind := strings.ToLower(relatedAccident.Kind) if err != nil { handleError(writer, "cannot get accident", err, &response.Exceptions) _ = marshalAndWriteIssueResponse(writer, &response) return } affectedTest := relatedAccident.AffectedTest affectedMetric := params.AffectedMetric if strings.HasSuffix(affectedTest, affectedMetric) { affectedTest = strings.TrimSuffix(affectedTest, "/"+affectedMetric) } testHistoryUrl := "" if params.TestMethodName != nil && lowerKind == "exception" { testHistoryUrl, err = teamCityClient.getTestHistoryUrl(request.Context(), *params.TestMethodName) } if err != nil { logError("cannot get test history link", err, &response.Exceptions) } var commits *CommitRevisions if params.CurrentBuildId != nil { commits, err = teamCityClient.getChanges(request.Context(), strconv.Itoa(*params.CurrentBuildId)) if err != nil { logError("cannot get commits from build", err, &response.Exceptions) } } descriptionData := GenerateDescriptionData{ Kind: lowerKind, AffectedTest: affectedTest, AffectedMetric: affectedMetric, Delta: params.Delta, StackTrace: relatedAccident.Stacktrace, BuildLink: params.BuildLink, Changes: params.ChangesLink, DashboardLink: params.DashboardLink, TestHistoryUrl: &testHistoryUrl, TestMethod: params.TestMethodName, TestType: params.TestType, Commits: commits, } accessToken := request.Header.Get("X-Auth-Request-Access-Token") user, err := auth.FetchUserInfo(request.Context(), accessToken) if err != nil { slog.Warn("cannot fetch user info", "error", err) } userId, err := ytAuth.GetUser(request.Context(), user.Email) if err != nil { slog.Warn("error getting user id:", "error", err) userId = nil } issueInfo := CreateIssueInfo{ Summary: params.TicketLabel, Description: generateDescription(descriptionData), Project: YoutrackProject{ID: params.ProjectId}, Reporter: userId, Visibility: Visibility{ PermittedGroups: []auth.YTUser{{ID: "10-3"}}, PermittedUsers: []auth.YTUser{{ID: "11-1539792"}}, Type: "LimitedVisibility", }, CustomFields: []CustomField{ { Name: "Type", Type: "SingleEnumIssueCustomField", Value: struct { Name string `json:"name"` }{ Name: "Performance Problem", }, }, }, } setSubsystems(params, &issueInfo) projectsToSetVersionsFor := []string{ "22-22", // IJPL "22-619", // IDEA // "22-25", // RUBY "22-414", // KTIJ "22-96", // WEB, } if slices.Contains(projectsToSetVersionsFor, params.ProjectId) { latestAffectedVersion := getVersionFieldValue(params.ProjectId, "Affected versions", nil, request, response) baseVersion := strings.SplitN(latestAffectedVersion, " ", 2)[0] setVersionField("Affected versions", baseVersion, params, request, response, &issueInfo) setVersionField("Planned for", baseVersion, params, request, response, &issueInfo) } setPriority(params, &issueInfo) setTags(params, &issueInfo) issue, err := youtrackClient.CreateIssue(request.Context(), issueInfo) if err != nil { handleError(writer, "failed to create issue", err, &response.Exceptions) _ = marshalAndWriteIssueResponse(writer, &response) return } response.Issue = *issue err = marshalAndWriteIssueResponse(writer, &response) if err != nil { writer.WriteHeader(http.StatusInternalServerError) return } relatedAccident.Reason = fmt.Sprintf("%s: %s", issue.IDReadable, relatedAccident.Reason) err = updateAccidentReason(request.Context(), metaDb, relatedAccident) if err != nil { logError("unable to update accident reason", err, &response.Exceptions) } } } type artifactCollector interface { getArtifactsPath(params UploadAttachmentsToIssueRequest) string checkArtifact(artifactName string) bool } type fleetStartupCollector struct{} func (f fleetStartupCollector) getArtifactsPath(UploadAttachmentsToIssueRequest) string { return "" } func (f fleetStartupCollector) checkArtifact(artifactName string) bool { return strings.HasSuffix(artifactName, "fleet.fahrplan.json") } type fleetPerfTestCollector struct{} func (f fleetPerfTestCollector) getArtifactsPath(UploadAttachmentsToIssueRequest) string { return "" } func (f fleetPerfTestCollector) checkArtifact(artifactName string) bool { return artifactName == "logs.zip" } type perfUnitTestCollector struct{} func (f perfUnitTestCollector) getArtifactsPath(params UploadAttachmentsToIssueRequest) string { return params.AffectedTest } func (f perfUnitTestCollector) checkArtifact(artifactName string) bool { return artifactName == "log.zip" } type perfintCollector struct{} func (f perfintCollector) getArtifactsPath(params UploadAttachmentsToIssueRequest) string { return strings.ReplaceAll(params.AffectedTest, "_", "-") } func (f perfintCollector) checkArtifact(artifactName string) bool { prefixes := []string{"logs-", "snapshots-"} for _, prefix := range prefixes { if strings.HasPrefix(artifactName, prefix) { return true } } if artifactName == "metrics.performance.json" { return true } return false } func getArtifactCollector(testType string) artifactCollector { switch testType { case "fleet": return fleetStartupCollector{} case "intellij_dev", "intellij": return perfintCollector{} case "fleet_perf": return fleetPerfTestCollector{} case "perfUnitTests": return perfUnitTestCollector{} default: return nil } } func CreatePostUploadAttachmentsToIssue() http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { type Exceptions struct { Exceptions []string `json:"exceptions"` } var exceptions Exceptions body := request.Body all, err := io.ReadAll(body) if err != nil { handleError(writer, "cannot read body", err, &exceptions.Exceptions) _ = marshalAndWriteIssueResponse(writer, exceptions) return } defer body.Close() var params UploadAttachmentsToIssueRequest if err = json.Unmarshal(all, &params); err != nil { handleError(writer, "cannot unmarshal parameters", err, &exceptions.Exceptions) _ = marshalAndWriteIssueResponse(writer, exceptions) return } errCh := make(chan error, 10) builds := []int{params.TeamCityAttachmentInfo.CurrentBuildId} if params.TeamCityAttachmentInfo.PreviousBuildId != nil { builds = append(builds, *params.TeamCityAttachmentInfo.PreviousBuildId) } if params.ChartPng != nil { err := youtrackClient.UploadAttachment(request.Context(), params.IssueId, *params.ChartPng, "dashboard.png") if err != nil { slog.Error("Failed to upload dashboard attachment to youtrack", "error", err) errCh <- err } } collector := getArtifactCollector(params.TestType) if collector != nil { var wg sync.WaitGroup for index, buildId := range builds { wg.Go(func() { testArtifactPath := collector.getArtifactsPath(params) children, err := teamCityClient.getArtifactChildren(request.Context(), buildId, testArtifactPath) if err != nil { slog.Error("Failed to get teamcity artifact children", "error", err) errCh <- err return } var filteredChildren []string for _, str := range children { if collector.checkArtifact(str) { filteredChildren = append(filteredChildren, str) } } var attachmentPostfix string if index == 0 { attachmentPostfix = "current" } else { attachmentPostfix = "before" } var childWg sync.WaitGroup for _, str := range filteredChildren { childWg.Go(func() { artifact, err := teamCityClient.downloadArtifact(request.Context(), buildId, testArtifactPath+"/"+str) if err != nil { slog.Error("Failed to download artefacts form teamcity", "error", err) errCh <- err return } attachmentName := getAttachmentName(str, attachmentPostfix) err = youtrackClient.UploadAttachment(request.Context(), params.IssueId, artifact, attachmentName) if err != nil { slog.Error("Failed to upload attachment to youtrack", "error", err) errCh <- err return } }) } childWg.Wait() }) } wg.Wait() } close(errCh) for err := range errCh { if err != nil { exceptions.Exceptions = append(exceptions.Exceptions, err.Error()) } } if len(exceptions.Exceptions) > 0 { writer.WriteHeader(http.StatusInternalServerError) _ = marshalAndWriteIssueResponse(writer, exceptions) return } _ = marshalAndWriteIssueResponse(writer, exceptions) } } func generateDescription(generateDescriptorData GenerateDescriptionData) string { var parts []string // Metric if generateDescriptorData.AffectedMetric != "" && generateDescriptorData.Delta != "" { parts = append(parts, fmt.Sprintf("**Metric:**\n%s (Delta: %s)", generateDescriptorData.AffectedMetric, generateDescriptorData.Delta)) } // Test if generateDescriptorData.AffectedTest != "" { parts = append(parts, "**Test:**\n"+generateDescriptorData.AffectedTest) } // Test method if generateDescriptorData.TestMethod != nil && *generateDescriptorData.TestMethod != "" { parts = append(parts, "**Test method name:**\n"+*generateDescriptorData.TestMethod) } // Build if generateDescriptorData.BuildLink != "" { parts = append(parts, fmt.Sprintf("**Build:**\n[build link](%s)", generateDescriptorData.BuildLink)) } // Changes in space if generateDescriptorData.Changes != "" { parts = append(parts, fmt.Sprintf("**Changes in space:**\n[space link](%s)", generateDescriptorData.Changes)) } // Commits if generateDescriptorData.Commits != nil { commitsSection := fmt.Sprintf("**Commits:**\nFirst: %s\nLast: %s", generateDescriptorData.Commits.FirstCommit, generateDescriptorData.Commits.LastCommit) parts = append(parts, commitsSection) } // Idea logs and snapshots if generateDescriptorData.TestType == "intellij" || generateDescriptorData.TestType == "intellij_dev" { logs := "**Idea logs, screenshots, thread dumps etc:**\nCurrent: [logs-current.zip](logs-current.zip)" snapshots := "**Snapshots:**\nCurrent: [snapshots-current.zip](snapshots-current.zip)" metrics := "**Metrics:**\nCurrent: [metrics.performance-current.json](metrics.performance-current.json)" if generateDescriptorData.Kind != "exception" { logs += "\nBefore: [logs-before.zip](logs-before.zip)" snapshots += "\nBefore: [snapshots-before.zip](snapshots-before.zip)" metrics += "\nBefore: [metrics.performance-before.json](metrics.performance-before.json)" } parts = append(parts, logs, snapshots, metrics) } if generateDescriptorData.TestType == "perfUnitTests" { snapshots := "**Snapshots:**\nCurrent: [log-current.zip](log-current.zip)" snapshots += "\nBefore: [log-before.zip](log-before.zip)" parts = append(parts, snapshots) } // Dashboard if generateDescriptorData.DashboardLink != "" { parts = append(parts, fmt.Sprintf("**Chart:**\n[link to test chart](%s)", generateDescriptorData.DashboardLink), "![](dashboard.png)") } // Stacktrace or test history if generateDescriptorData.Kind == "exception" { if generateDescriptorData.StackTrace != "" { parts = append(parts, fmt.Sprintf("**Stacktrace:**\n```%s```", generateDescriptorData.StackTrace)) } } else { if generateDescriptorData.TestHistoryUrl != nil && *generateDescriptorData.TestHistoryUrl != "" { parts = append(parts, fmt.Sprintf("**Test history:**\n[test history link](%s)", *generateDescriptorData.TestHistoryUrl)) } } description := strings.Join(parts, "\n\n") return description } func getAttachmentName(filename, suffix string) string { // Handle metrics.performance.json specially if strings.HasPrefix(filename, "metrics.performance") && strings.HasSuffix(filename, ".json") { return fmt.Sprintf("metrics.performance-%s.json", suffix) } parts := strings.Split(filename, ".") if len(parts) != 2 { return filename } nameWithoutExt := parts[0] ext := parts[1] nameParts := strings.Split(nameWithoutExt, "-") updatedName := nameParts[0] + "-" + suffix return fmt.Sprintf("%s.%s", updatedName, ext) } func handleError(writer http.ResponseWriter, message string, err error, exceptions *[]string) { slog.Error(message, "error", err) writer.WriteHeader(http.StatusInternalServerError) *exceptions = append(*exceptions, fmt.Sprintf("Message: %s. Error: %s", message, err.Error())) } func logError(message string, err error, exceptions *[]string) { slog.Error(message, "error", err) *exceptions = append(*exceptions, fmt.Sprintf("Message: %s. Error: %s", message, err.Error())) } func marshalAndWriteIssueResponse(writer http.ResponseWriter, response any) error { jsonBytes, err := json.Marshal(response) if err != nil { slog.Error("cannot marshal response", "error", err) return err } _, err = writer.Write(jsonBytes) if err != nil { slog.Error("cannot write response", "error", err) return err } return nil } func setSubsystems(params YoutrackCreateIssueRequest, issueInfo *CreateIssueInfo) { if params.ProjectId == "22-414" { // If project ID is KTIJ set subsystem as they require it subsystemsCustomField := CustomField{ Name: "Subsystems", Type: "MultiOwnedIssueCustomField", Value: []CustomFieldValue{ {Name: "IDE"}, }, } issueInfo.CustomFields = append(issueInfo.CustomFields, subsystemsCustomField) } } func setVersionField(versionFieldName string, desiredMajorVersion string, params YoutrackCreateIssueRequest, request *http.Request, response CreateIssueResponse, issueInfo *CreateIssueInfo) { versionFieldId := getFieldIdByName(params.ProjectId, versionFieldName, request, response) if versionFieldId == "" { return } versionFieldValue := getVersionFieldValue(params.ProjectId, versionFieldName, &desiredMajorVersion, request, response) versionCustomField := CustomField{ Type: "MultiVersionIssueCustomField", ID: versionFieldId, Value: []CustomFieldValue{ {Name: versionFieldValue}, }, } issueInfo.CustomFields = append(issueInfo.CustomFields, versionCustomField) } func setPriority(params YoutrackCreateIssueRequest, issueInfo *CreateIssueInfo) { var priorityFieldName string switch params.ProjectId { case "22-68": // KT have their own priority field with unique values return case "22-139": // The field is called Severity in CLion priorityFieldName = "Severity" default: priorityFieldName = "Priority" } priorityField := CustomField{ Type: "SingleEnumIssueCustomField", Name: priorityFieldName, Value: CustomFieldValue{ Name: "Major", }, } issueInfo.CustomFields = append(issueInfo.CustomFields, priorityField) } func setTags(params YoutrackCreateIssueRequest, issueInfo *CreateIssueInfo) { var tags []Tag switch params.ProjectId { case "22-68", // KT "22-414": // KTIJ tags = append(tags, Tag{ Name: "kotlin-regression", ID: "68-78861", Type: "Tag", }, Tag{ Name: "blocking-release-idea", ID: "68-297083", Type: "Tag", }) case "22-22", // IJPL "22-619", // IDEA // "22-25", // RUBY "22-96", // WEB "22-19", // WI "22-211": // GO tags = append(tags, Tag{ Name: "Regression", ID: "68-3044", Type: "Tag", }, Tag{ Name: "blocking-release", ID: "68-158967", Type: "Tag", }) } issueInfo.Tags = tags } func getFieldIdByName(projectId string, fieldName string, request *http.Request, response CreateIssueResponse) string { fetchProjectFieldsUrl := fmt.Sprintf("/api/admin/projects/%s/customFields?fields=id,field(name)&$top=-1", projectId) responseData, err := youtrackClient.fetchFromYouTrack(request.Context(), fetchProjectFieldsUrl, "GET", nil, nil) if err != nil { logError("cannot fetch fields for "+projectId, err, &response.Exceptions) } type projectField struct { ID string `json:"id"` Field struct { Name string `json:"name"` } `json:"field"` } var fields []projectField if err := json.Unmarshal(responseData, &fields); err != nil { logError("cannot unmarshal fields for "+projectId, err, &response.Exceptions) } for _, f := range fields { if f.Field.Name == fieldName { return f.ID } } logError("cannot find field "+fieldName+" for "+projectId, nil, &response.Exceptions) return "" } func getVersionFieldValue(projectId string, versionFieldName string, desiredMajorVersion *string, request *http.Request, response CreateIssueResponse) string { versionFieldId := getFieldIdByName(projectId, versionFieldName, request, response) fetchAffectedVersionsUrl := fmt.Sprintf("/api/admin/projects/%s/customFields/%s/bundle?fields=id,name,values(name)", projectId, versionFieldId) responseData, err := youtrackClient.fetchFromYouTrack(request.Context(), fetchAffectedVersionsUrl, "GET", nil, nil) if err != nil { logError("cannot fetch versions for "+projectId, err, &response.Exceptions) } var versionResp VersionResponse if err := json.Unmarshal(responseData, &versionResp); err != nil { logError("cannot unmarshal versions for "+projectId, err, &response.Exceptions) } var pattern *regexp.Regexp if desiredMajorVersion != nil { pattern = regexp.MustCompile(fmt.Sprintf(`^%s\s?[A-Za-z]*$`, *desiredMajorVersion)) } else { pattern = regexp.MustCompile(`^\d+\.\d+\s?[A-Za-z]*$`) } var versions []string for _, v := range versionResp.Values { if pattern.MatchString(v.Name) { versions = append(versions, v.Name) } } if len(versions) == 0 { logError("cannot find major versions for "+projectId, err, &response.Exceptions) } sort.Slice(versions, func(i, j int) bool { return compareVersions(versions[i], versions[j]) > 0 }) return versions[0] } func compareVersions(a, b string) int { parse := func(s string) (int, int, bool) { var major, minor int hasSuffix := false parts := strings.SplitN(s, " ", 2) numeric := parts[0] if len(parts) > 1 && parts[1] != "" { hasSuffix = true } nums := strings.SplitN(numeric, ".", 2) if len(nums) > 0 { major, _ = strconv.Atoi(nums[0]) } if len(nums) > 1 { minor, _ = strconv.Atoi(nums[1]) } return major, minor, hasSuffix } aMajor, aMinor, aHasSuffix := parse(a) bMajor, bMinor, bHasSuffix := parse(b) if aMajor != bMajor { if aMajor > bMajor { return 1 } return -1 } if aMinor != bMinor { if aMinor > bMinor { return 1 } return -1 } if aHasSuffix != bHasSuffix { if !aHasSuffix { return 1 } return -1 } return 0 }