diff --git a/.gitignore b/.gitignore index 5a222d1..52eed31 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ coverage.out action action.tar.gz +.vscode diff --git a/README.md b/README.md index 9b97eef..f948682 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Action](https://help.github.com/en/categories/automating-your-workflow-with-gith that can manage multiple labels for both Pull Requests and Issues using configurable matching rules. Available conditions: +* [Age](#age): label based on the age of a PR or Issue. * [Author can merge](#author-can-merge): label based on whether the author can merge the PR * [Authors](#authors): label based on the PR/Issue authors * [Base branch](#base-branch): label based on the PR's base branch name @@ -87,7 +88,7 @@ to control when to run it. You may combine multiple event triggers. -A final option is to trigger the action periodically using the +A final option is to trigger the action periodically using the [`schedule`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule) trigger. For backwards compatibility reasons this will examine all active pull requests and update their labels. If you wish to examine @@ -301,6 +302,30 @@ alphabetical order. Some important considerations: You can use tools like [regex101.com](https://regex101.com/?flavor=golang) to verify your conditions. +### Age (PRs and Issues) + +This condition is satisfied when the age of the PR or Issue are larger than +the given one. The age is calculated from the creation date. + +This condition is best used when with a schedule trigger. + +Example: + +```yaml +age: 1d +``` + +The syntax for values is based on a number, followed by a suffix: + +* s: seconds +* m: minutes +* h: hours +* d: days +* w: weeks +* y: years + +For example, `2d` means 2 days, `4w` means 4 weeks, and so on. + ### Author can merge (PRs) This condition is satisfied when the author of the PR can merge it. diff --git a/pkg/condition_age.go b/pkg/condition_age.go new file mode 100644 index 0000000..b27de0b --- /dev/null +++ b/pkg/condition_age.go @@ -0,0 +1,61 @@ +package labeler + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +func AgeCondition(l *Labeler) Condition { + return Condition{ + GetName: func() string { + return "Age of issue/PR" + }, + CanEvaluate: func(target *Target) bool { + return target.ghIssue != nil || target.ghPR != nil + }, + Evaluate: func(target *Target, matcher LabelMatcher) (bool, error) { + // Parse the age from the configuration + ageDuration, err := parseExtendedDuration(matcher.Age) + if err != nil { + return false, fmt.Errorf("failed to parse age parameter in configuration: %v", err) + } + + // Determine the creation time of the issue or PR + var createdAt time.Time + if target.ghIssue != nil { + createdAt = target.ghIssue.CreatedAt.Time + } else if target.ghPR != nil { + createdAt = target.ghPR.CreatedAt.Time + } + + age := time.Since(createdAt) + + return age > ageDuration, nil + }, + } +} + +func parseExtendedDuration(s string) (time.Duration, error) { + multiplier := time.Hour * 24 // default to days + + if strings.HasSuffix(s, "w") { + multiplier = time.Hour * 24 * 7 // weeks + s = strings.TrimSuffix(s, "w") + } else if strings.HasSuffix(s, "y") { + multiplier = time.Hour * 24 * 365 // years + s = strings.TrimSuffix(s, "y") + } else if strings.HasSuffix(s, "d") { + s = strings.TrimSuffix(s, "d") // days + } else { + return time.ParseDuration(s) // default to time.ParseDuration for hours, minutes, seconds + } + + value, err := strconv.Atoi(s) + if err != nil { + return 0, err + } + + return time.Duration(value) * multiplier, nil +} diff --git a/pkg/condition_age_test.go b/pkg/condition_age_test.go new file mode 100644 index 0000000..f3e2d3c --- /dev/null +++ b/pkg/condition_age_test.go @@ -0,0 +1,30 @@ +package labeler + +import ( + "testing" + "time" +) + +func TestParseExtendedDuration(t *testing.T) { + tests := []struct { + input string + expected time.Duration + }{ + {"1s", 1 * time.Second}, + {"2m", 2 * time.Minute}, + {"3h", 3 * time.Hour}, + {"4d", 4 * 24 * time.Hour}, + {"5w", 5 * 7 * 24 * time.Hour}, + {"6y", 6 * 365 * 24 * time.Hour}, + } + + for _, test := range tests { + result, err := parseExtendedDuration(test.input) + if err != nil { + t.Errorf("failed to parse duration from %s: %v", test.input, err) + } + if result != test.expected { + t.Errorf("expected %v, got %v", test.expected, result) + } + } +} diff --git a/pkg/labeler.go b/pkg/labeler.go index 7fbc931..bbe0a1f 100644 --- a/pkg/labeler.go +++ b/pkg/labeler.go @@ -14,6 +14,7 @@ type SizeConfig struct { } type LabelMatcher struct { + Age string AuthorCanMerge string `yaml:"author-can-merge"` Authors []string BaseBranch string `yaml:"base-branch"` @@ -209,6 +210,7 @@ func (l *Labeler) findMatches(target *Target, config *LabelerConfigV1) (LabelUpd set: map[string]bool{}, } conditions := []Condition{ + AgeCondition(l), AuthorCondition(), AuthorCanMergeCondition(), BaseBranchCondition(), diff --git a/pkg/labeler_test.go b/pkg/labeler_test.go index e6792e6..04296a7 100644 --- a/pkg/labeler_test.go +++ b/pkg/labeler_test.go @@ -172,7 +172,38 @@ func TestHandleEvent(t *testing.T) { initialLabels: []string{}, expectedLabels: []string{"NotADraft"}, }, - + { + event: "pull_request", + payloads: []string{"create_pr"}, + name: "Age of a PR in the future", + config: LabelerConfigV1{ + Version: 1, + Labels: []LabelMatcher{ + { + Label: "ThisIsOld", + Age: "100000000d", + }, + }, + }, + initialLabels: []string{}, + expectedLabels: []string{}, + }, + { + event: "pull_request", + payloads: []string{"create_pr"}, + name: "Age of a PR in the past", + config: LabelerConfigV1{ + Version: 1, + Labels: []LabelMatcher{ + { + Label: "ThisIsOld", + Age: "10d", + }, + }, + }, + initialLabels: []string{}, + expectedLabels: []string{"ThisIsOld"}, + }, { event: "pull_request", payloads: []string{"create_draft_pr"},