Repository: srvaroa/labeler
Pull request #113 (2023-11-21 12:05:26)

Support labels based on PR / Issue age
Signed-off-by: Galo Navarro <anglorvaroa@gmail.com>



Patch:
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 name="schedule" />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) <a name="age" />
+
+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 <a href="#schedule">schedule trigger</a>.
+
+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) <a name="author-can-merge" />
 
 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/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(),


Test patch:
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_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"},