Repository: srvaroa/labeler (go)

Issue:
Apply mutally exclusive Age labels
Hi there, great work on this Action. I am wondering if it's possible to apply different labels based on age. Right now I have a config which looks a bit like this:

```yaml
  - label: "Age: Getting Stale"
    age: 8d

  - label: "Age: Quite Stale"
    age: 22d
```

The problem I am having with this is that it will apply _both_ labels after 22 days, whereas I would ideally like it to simply apply one or the other based on the age of the pull request (or issue, but in my case for PRs). Something like this I guess (similar to the upper/lower limits for file changes).

```yaml
  # Pseudo-code, don't actually use this!
  - label: "Age: Getting Stale"
    age:
      - older_than: 8d
      - newer_than: 22d
  
  # Pseudo-code, don't actually use this!
  - label: "Age: Quite Stale"
    age:
      - older_than: 22d
```

Is there a way to configure this already (maybe with a `negate` rule?), or is this something we would need to add? I know about the [last modified matcher](https://github.com/srvaroa/labeler?tab=readme-ov-file#last-modified), but the goal is to label PRs which were originally opened a long time ago (doesn't matter if they have been touched recently).

If this is a new feature, please let me know where I can get started implementing this.

Pull request description:
Convert age matcher to use a duration config
Adds the ability to specify ranges for the `age` matcher. Same as the `last-modified` matcher, this makes use of the same `DurationConfig` struct. Maintains support for the previous string matcher (ie: `age: 8d`), but removes references to it in the documentation and marks it as "deprecated" (so that if we wanted to clean stuff up on the next major version bump, we could remove this).

```yaml
# Before:
  - age: 7d

# After:
  - age:
    at-least: 7d
```


Closes #158

Patch:
diff --git a/README.md b/README.md
index 9bb2073..685cf7f 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ Please consider [sponsoring the project](https://github.com/sponsors/srvaroa) if
 ### How to trigger action
 
 To trigger the action on events, add a file `.github/workflows/main.yml`
-to your repository: 
+to your repository:
 
 ```yaml
 name: Label PRs
@@ -294,7 +294,7 @@ With this config, the behaviour changes:
 
 ## Conditions
 
-Below are the conditions currently supported in label matchers, in 
+Below are the conditions currently supported in label matchers, in
 alphabetical order. Some important considerations:
 
 * Conditions evaluate only when they are explicitly added in
@@ -308,20 +308,29 @@ alphabetical order. Some important considerations:
 
 ### 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 evaluates the creation date of the PR or Issue.
 
-If you're looking to evaluate on the modification date of the issue or PR, 
+If you're looking to evaluate on the modification date of the issue or PR,
 check on <a href="#last-modified" ></a>
 
 This condition is best used when with a <a href="#schedule">schedule trigger</a>.
 
-Example:
+Examples:
+
+```yaml
+age-range:
+  at-most: 1d
+```
+
+Will label PRs or issues that were created at most one day ago.
 
 ```yaml
-age: 1d
+age-range:
+  at-least: 1w
 ```
 
+Will label PRs or issues that were created at least one week ago.
+
 The syntax for values is based on a number, followed by a suffix:
 
 * s: seconds
@@ -412,7 +421,7 @@ This condition is satisfied when any of the PR files matches on the
 given regexs.
 
 ```yaml
-files: 
+files:
 - "cmd\\/.*_tests.go"
 - ".*\\/subfolder\\/.*\\.md"
 ```
@@ -425,9 +434,9 @@ and therefore must be escaped itself to appear as a literal in the regex.
 
 ### Last Modified (PRs and Issues) <a name="last-modified" />
 
-This condition evaluates the modification date of the PR or Issue. 
+This condition evaluates the modification date of the PR or Issue.
 
-If you're looking to evaluate on the creation date of the issue or PR, 
+If you're looking to evaluate on the creation date of the issue or PR,
 check on <a href="#age" ></a>
 
 This condition is best used when with a <a href="#schedule">schedule trigger</a>.
@@ -462,19 +471,19 @@ For example, `2d` means 2 days, `4w` means 4 weeks, and so on.
 
 This condition is satisfied when the [mergeable
 state](https://developer.github.com/v3/pulls/#response-1) matches that
-of the PR. 
+of the PR.
 
 ```yaml
 mergeable: True
 ```
 
-Will match if the label is mergeable. 
+Will match if the label is mergeable.
 
 ```yaml
 mergeable: False
 ```
 
-Will match if the label is not mergeable. 
+Will match if the label is not mergeable.
 
 ### Size (PRs only) <a name="size" />
 
@@ -518,7 +527,7 @@ file or a Regex expression:
     size:
         exclude-files: ["yarn.lock", "\\/root\\/.+\\/test.md"]
         above: 100
-``` 
+```
 
 This condition will apply the `L` label if the diff is above 100 lines,
 but NOT taking into account changes in `yarn.lock`, or any `test.md`
diff --git a/pkg/condition_age.go b/pkg/condition_age.go
index be04a36..af03ea6 100644
--- a/pkg/condition_age.go
+++ b/pkg/condition_age.go
@@ -14,10 +14,35 @@ func AgeCondition(l *Labeler) Condition {
 			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)
+			// Backward compatibility: If "age" is provided as a string, treat it as "at-least"
+			var atLeastDuration, atMostDuration time.Duration
+			var err error
+
+			//	If they have specified a legacy "age" field, use that
+			//	and treat it is as "at-least"
+			if matcher.Age != "" {
+				atLeastDuration, err = parseExtendedDuration(matcher.Age)
+				if err != nil {
+					return false, fmt.Errorf("failed to parse age parameter in configuration: %v", err)
+				}
+			} else if matcher.AgeRange != nil {
+				// Parse "at-least" if specified
+				if matcher.AgeRange.AtLeast != "" {
+					atLeastDuration, err = parseExtendedDuration(matcher.AgeRange.AtLeast)
+					if err != nil {
+						return false, fmt.Errorf("failed to parse `age.at-least` parameter in configuration: %v", err)
+					}
+				}
+
+				// Parse "at-most" if specified
+				if matcher.AgeRange.AtMost != "" {
+					atMostDuration, err = parseExtendedDuration(matcher.AgeRange.AtMost)
+					if err != nil {
+						return false, fmt.Errorf("failed to parse `age.at-most` parameter in configuration: %v", err)
+					}
+				}
+			} else {
+				return false, fmt.Errorf("no age conditions are set in config")
 			}
 
 			// Determine the creation time of the issue or PR
@@ -30,7 +55,15 @@ func AgeCondition(l *Labeler) Condition {
 
 			age := time.Since(createdAt)
 
-			return age > ageDuration, nil
+			//	Check if the age of the issue/PR is within the specified range
+			if atLeastDuration != 0 && age < atLeastDuration {
+				return false, nil
+			}
+			if atMostDuration != 0 && age > atMostDuration {
+				return false, nil
+			}
+
+			return true, nil
 		},
 	}
 }
diff --git a/pkg/labeler.go b/pkg/labeler.go
index a64016c..5a9ac33 100644
--- a/pkg/labeler.go
+++ b/pkg/labeler.go
@@ -19,7 +19,8 @@ type SizeConfig struct {
 }
 
 type LabelMatcher struct {
-	Age            string
+	Age            string          `yaml:"age,omitempty"` // Deprecated age config.
+	AgeRange       *DurationConfig `yaml:"age-range,omitempty"`
 	AuthorCanMerge string `yaml:"author-can-merge"`
 	Authors        []string
 	AuthorInTeam   string `yaml:"author-in-team"`


Test patch:
diff --git a/pkg/labeler_test.go b/pkg/labeler_test.go
index b0aade9..9c9ad96 100644
--- a/pkg/labeler_test.go
+++ b/pkg/labeler_test.go
@@ -204,6 +204,32 @@ func TestHandleEvent(t *testing.T) {
 			initialLabels:  []string{},
 			expectedLabels: []string{"ThisIsOld"},
 		},
+		// Test the AgeRange configuration
+		{
+			event:    "pull_request",
+			payloads: []string{"create_pr"},
+			name:     "Age of a PR in the past",
+			config: LabelerConfigV1{
+				Version: 1,
+				Labels: []LabelMatcher{
+					{
+						Label: "Getting Old",
+						AgeRange: &DurationConfig{
+							AtLeast: "7d",
+							AtMost: "14d",
+						},
+					},
+					{
+						Label: "Getting Ancient",
+						AgeRange: &DurationConfig{
+							AtLeast: "14d",
+						},
+					},
+				},
+			},
+			initialLabels:  []string{},
+			expectedLabels: []string{"Getting Ancient"},
+		},
 		{
 			event:    "pull_request",
 			payloads: []string{"create_draft_pr"},