diff --git a/internal/app/label_export_test.go b/internal/app/label_export_test.go index 196fb9a0..dea509bc 100644 --- a/internal/app/label_export_test.go +++ b/internal/app/label_export_test.go @@ -42,7 +42,7 @@ func TestLabelExportReadsAllPagesAndPublishesOnlyCompleteFiles(t *testing.T) { require.Equal(t, []string{"1", "2", "3"}, pages) data, err := os.ReadFile(output) require.NoError(t, err) - require.Equal(t, "#ff0000 good first issue ;Help wanted\n#00ff00 bug ;\n", string(data)) + require.Equal(t, "#ff0000 good first issue ;Help wanted\n#00ff00 bug\n", string(data)) require.NoError(t, os.Remove(output)) fail = true require.Error(t, Execute(context.Background(), deps, args)) diff --git a/internal/app/labelfile.go b/internal/app/labelfile.go index 81651b50..44b468a8 100644 --- a/internal/app/labelfile.go +++ b/internal/app/labelfile.go @@ -17,7 +17,8 @@ import ( var labelColor = regexp.MustCompile(`^[0-9a-fA-F]{6}$`) -// parseLabelFile reads "#color name ;description" rows, skipping blank and comment rows. +// parseLabelFile reads "#color name ;description ;flags" rows, skipping blank and comment rows. The +// description and the flags, a comma-separated list of exclusive and archived, are optional. func parseLabelFile(r io.Reader) ([]services.LabelOptions, error) { var labels []services.LabelOptions scanner := bufio.NewScanner(r) @@ -43,6 +44,14 @@ func parseLabelRow(text string, line int) (services.LabelOptions, bool, error) { raw := strings.TrimSpace(text) parts := strings.SplitN(raw, ";", 2) fields := strings.Fields(parts[0]) + var exclusive, archived bool + if len(parts) == 2 { + if at := strings.LastIndex(parts[1], ";"); at >= 0 { + if flags, ok := labelFlags(parts[1][at+1:]); ok { + exclusive, archived, parts[1] = flags["exclusive"], flags["archived"], parts[1][:at] + } + } + } // Rows start with an optional "#" before the color, as Gitea's label templates do. if raw == "" || strings.HasPrefix(raw, "#") && !labelColor.MatchString(strings.TrimPrefix(fields[0], "#")) { return services.LabelOptions{}, false, nil @@ -57,15 +66,49 @@ func parseLabelRow(text string, line int) (services.LabelOptions, bool, error) { if !labelColor.MatchString(color) { return services.LabelOptions{}, false, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid color on row %d", line)) } - return services.LabelOptions{Name: &name, Color: &color, Description: &description}, true, nil + label := services.LabelOptions{Name: &name, Color: &color, Description: &description} + if exclusive { + label.Exclusive = &exclusive + } + if archived { + label.Archived = &archived + } + return label, true, nil +} + +// labelFlags parses the flags field of a label row, such as "exclusive,archived". +func labelFlags(field string) (map[string]bool, bool) { + flags := map[string]bool{} + for flag := range strings.SplitSeq(field, ",") { + flag = strings.TrimSpace(flag) + if flag != "exclusive" && flag != "archived" { + return nil, false + } + flags[flag] = true + } + return flags, true } // formatLabel renders a label as a label file row, rejecting labels the row cannot reproduce. func formatLabel(label services.Label) (string, error) { color := strings.TrimPrefix(label.Color, "#") - row := fmt.Sprintf("#%s %s ;%s", color, label.Name, label.Description) + row := fmt.Sprintf("#%s %s", color, label.Name) + var flags []string + if label.Exclusive { + flags = append(flags, "exclusive") + } + if label.Archived { + flags = append(flags, "archived") + } + if label.Description != "" || len(flags) > 0 { + row += " ;" + label.Description + } + if len(flags) > 0 { + row += " ;" + strings.Join(flags, ",") + } parsed, ok, err := parseLabelRow(row, 1) - if err != nil || !ok || strings.ContainsAny(row, "\r\n") || *parsed.Name != label.Name || *parsed.Color != color || *parsed.Description != label.Description { + if err != nil || !ok || strings.ContainsAny(row, "\r\n") || *parsed.Name != label.Name || *parsed.Color != color || + *parsed.Description != label.Description || (parsed.Exclusive != nil) != label.Exclusive || (parsed.Archived != nil) != label.Archived { return "", errors.New("label cannot be represented in the import format; use label export --json to preserve its text") } return row + "\n", nil diff --git a/internal/app/labelfile_test.go b/internal/app/labelfile_test.go index 0d29f1e2..4a279134 100644 --- a/internal/app/labelfile_test.go +++ b/internal/app/labelfile_test.go @@ -23,6 +23,9 @@ func TestLabelFileRoundTrip(t *testing.T) { {Name: "bug", Color: "ff0000", Description: "Something is broken"}, {Name: "kind/feature request", Color: "#00FF00", Description: "Wanted; maybe"}, {Name: "#hash", Color: "0000ff"}, + {Name: "priority/high", Color: "7057ff", Exclusive: true}, + {Name: "old", Color: "cccccc", Description: "Retired; kept", Exclusive: true, Archived: true}, + {Name: "flag-like", Color: "cccccc", Description: "ends in;archived text"}, } { row, err := formatLabel(label) require.NoError(t, err, label.Name) @@ -32,6 +35,19 @@ func TestLabelFileRoundTrip(t *testing.T) { require.Equal(t, label.Name, *parsed[0].Name) require.Equal(t, strings.TrimPrefix(label.Color, "#"), *parsed[0].Color) require.Equal(t, label.Description, *parsed[0].Description) + require.Equal(t, label.Exclusive, parsed[0].Exclusive != nil && *parsed[0].Exclusive, row) + require.Equal(t, label.Archived, parsed[0].Archived != nil && *parsed[0].Archived, row) + } + for label, row := range map[services.Label]string{ + {Name: "good first issue", Color: "7057ff"}: "#7057ff good first issue\n", + {Name: "bug", Color: "ee0701", Description: "Broken"}: "#ee0701 bug ;Broken\n", + {Name: "priority/high", Color: "7057ff", Exclusive: true}: "#7057ff priority/high ; ;exclusive\n", + {Name: "old", Color: "cccccc", Description: "Retired", Archived: true}: "#cccccc old ;Retired ;archived\n", + {Name: "both", Color: "cccccc", Exclusive: true, Archived: true}: "#cccccc both ; ;exclusive,archived\n", + } { + formatted, err := formatLabel(label) + require.NoError(t, err) + require.Equal(t, row, formatted) } for _, label := range []services.Label{ {Name: "", Color: "ff0000"},