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, ¶ms); 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, ¶ms); 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), "")
}
// 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
}