Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new Scheduled rule: EnforceMaxLifeOfIssues #7693

Merged
merged 9 commits into from
Feb 26, 2024
2 changes: 2 additions & 0 deletions tools/codeowners-utils/METADATA.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ This list of examples is exhaustive. If an example isn't in here then it won't w
```text

# AzureSdkOwners: @fakeUser3 @fakeUser4
# ServiceLabel: %fakeLabel12
/sdk/SomePath/ @fakeUser1 @fakeUser2 @Azure/fakeTeam1
OR
# AzureSdkOwners:
# ServiceLabel: %fakeLabel12
/sdk/SomePath/ @fakeUser1 @fakeUser2 @Azure/fakeTeam1
OR
# AzureSdkOwners: @fakeUser3 @fakeUser4
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,5 +275,53 @@ public async Task TestLockClosedIssues(string rule, string payloadFile, RuleStat
Assert.AreEqual(0, totalUpdates, $"{rule} is {ruleState} and should not have produced any updates.");
}
}

/// <summary>
/// Test the Enforce2YearMaxLifeOfIssues scheduled event.
/// Each item returned from the query will have three updates:
/// Issue will be closed
/// Issue will have a comment added
/// Issue will be locked
/// </summary>
/// <param name="rule">String, RulesConstants for the rule being tested</param>
/// <param name="payloadFile">JSon payload file for the event being tested</param>
/// <param name="ruleState">Whether or not the rule is on/off</param>
[Category("static")]
[TestCase(RulesConstants.Enforce2YearMaxLifeOfIssues, "Tests.JsonEventPayloads/ScheduledEvent_payload.json", RuleState.On)]
[TestCase(RulesConstants.Enforce2YearMaxLifeOfIssues, "Tests.JsonEventPayloads/ScheduledEvent_payload.json", RuleState.Off)]
public async Task TestEnforce2YearMaxLifeOfIssues(string rule, string payloadFile, RuleState ruleState)
{
// Need something divisible by 3 Because Enforce2YearMaxLifeOfIssues does 3 updates per issue
// creating 100 results should only result in 34 issues being closed and 34 comments created
// and 34 issues being locked = 102 expected updates.
int expectedUpdates = 102;
var mockGitHubEventClient = new MockGitHubEventClient(OrgConstants.ProductHeaderName);
mockGitHubEventClient.RulesConfiguration.Rules[rule] = ruleState;
var rawJson = TestHelpers.GetTestEventPayload(payloadFile);
ScheduledEventGitHubPayload scheduledEventPayload = SimpleJsonSerializer.Deserialize<ScheduledEventGitHubPayload>(rawJson);
// Create the fake issues to update.
mockGitHubEventClient.CreateSearchIssuesResult(expectedUpdates, scheduledEventPayload.Repository, ItemState.Open);
await ScheduledEventProcessing.Enforce2YearMaxLifeOfIssues(mockGitHubEventClient, scheduledEventPayload);

var totalUpdates = await mockGitHubEventClient.ProcessPendingUpdates(scheduledEventPayload.Repository.Id);
// Verify the RuleCheck
Assert.AreEqual(ruleState == RuleState.On, mockGitHubEventClient.RulesConfiguration.RuleEnabled(rule), $"Rule '{rule}' enabled should have been {ruleState == RuleState.On} but RuleEnabled returned {ruleState != RuleState.On}.'");
if (RuleState.On == ruleState)
{
// Create the fake issues to update.
Assert.AreEqual(expectedUpdates, totalUpdates, $"The number of updates should have been {expectedUpdates} but was instead, {totalUpdates}");
// There should be expectedUpdates/3 issueUpdates, expectedUpdates/3 comments and expectedUpdates/3 issues to lock
int numIssueUpdates = mockGitHubEventClient.GetGitHubIssuesToUpdate().Count;
Assert.AreEqual(expectedUpdates / 3, numIssueUpdates, $"The number of issue updates should have been {expectedUpdates / 3} but was instead, {numIssueUpdates}");
int numComments = mockGitHubEventClient.GetComments().Count;
Assert.AreEqual(expectedUpdates / 3, numComments, $"The number of comments should have been {expectedUpdates / 3} but was instead, {numComments}");
int numIssuesToLock = mockGitHubEventClient.GetGitHubIssuesToLock().Count;
Assert.AreEqual(expectedUpdates / 3, numIssuesToLock, $"The number of issues to lock should have been {expectedUpdates / 3} but was instead, {numIssuesToLock}");
}
else
{
Assert.AreEqual(0, totalUpdates, $"{rule} is {ruleState} and should not have produced any updates.");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public class RulesConstants
public const string IdentifyStalePullRequests = "IdentifyStalePullRequests";
public const string CloseAddressedIssues = "CloseAddressedIssues";
public const string LockClosedIssues = "LockClosedIssues";
public const string Enforce2YearMaxLifeOfIssues = "Enforce2YearMaxLifeOfIssues";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we maybe generalize the name a bit, in case we adjust the "2 year" duration at a later point? It would suck to have to go update all the repo config files or have the name and rule mismatch.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jsquire changed the rule to EnforceMaxLifeOfIssues which is more generic.


}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ public static async Task ProcessScheduledEvent(GitHubEventClient gitHubEventClie
await LockClosedIssues(gitHubEventClient, scheduledEventPayload);
break;
}
case RulesConstants.Enforce2YearMaxLifeOfIssues:
{
await Enforce2YearMaxLifeOfIssues(gitHubEventClient, scheduledEventPayload);
break;
}
default:
{
Console.WriteLine($"{cronTaskToRun} is not valid Scheduled Event rule. Please ensure the scheduled event yml is correctly passing in the correct rules constant.");
Expand Down Expand Up @@ -539,5 +544,93 @@ public static async Task LockClosedIssues(GitHubEventClient gitHubEventClient, S
}
}
}
/// <summary>
/// Trigger: Weekly, JRS-TBD When
/// Query Criteria
/// Issue is open
/// Issue was last updated more than 30 days ago
/// Issue is unlocked
/// Issue was created more than 2 years ago
/// Resulting Action:
/// Close the issue
/// Add a comment: Hi @{issue.User.Login}, we deeply appreciate your input into this project. Regrettably,
/// this issue has remained inactive for over 2 years, leading us to the decision to close it.
/// We've implemented this policy to maintain the relevance of our issue queue and facilitate
/// easier navigation for new contributors. If you still believe this topic requires attention,
/// please feel free to create a new issue, referencing this one. Thank you for your understanding
/// and ongoing support.
/// Lock the issue
/// </summary>
/// <param name="gitHubEventClient">Authenticated GitHubEventClient</param>
/// <param name="scheduledEventPayload">ScheduledEventGitHubPayload deserialized from the json event payload</param>
public static async Task Enforce2YearMaxLifeOfIssues(GitHubEventClient gitHubEventClient, ScheduledEventGitHubPayload scheduledEventPayload)
{
if (gitHubEventClient.RulesConfiguration.RuleEnabled(RulesConstants.Enforce2YearMaxLifeOfIssues))
{
int ScheduledTaskUpdateLimit = await gitHubEventClient.ComputeScheduledTaskUpdateLimit();

SearchIssuesRequest request = gitHubEventClient.CreateSearchRequest(
scheduledEventPayload.Repository.Owner.Login,
scheduledEventPayload.Repository.Name,
IssueTypeQualifier.Issue,
ItemState.Open,
30, // more than 30 days
new List<IssueIsQualifier> { IssueIsQualifier.Unlocked },
null,
null,
365*2 // Created date > 2 years
);

int numUpdates = 0;
// In theory, maximumPage will be 10 since there's 100 results per-page returned by default but
// this ensures that if we opt to change the page size
int maximumPage = RateLimitConstants.SearchIssuesRateLimit / request.PerPage;
for (request.Page = 1; request.Page <= maximumPage; request.Page++)
{
SearchIssuesResult result = await gitHubEventClient.QueryIssues(request);
int iCounter = 0;
while (
// Process every item in the page returned
iCounter < result.Items.Count &&
// unless the update limit has been hit
numUpdates < ScheduledTaskUpdateLimit
)
{
Issue issue = result.Items[iCounter++];
IssueUpdate issueUpdate = gitHubEventClient.GetIssueUpdate(issue, false);
// Close the issue
issueUpdate.State = ItemState.Closed;
issueUpdate.StateReason = ItemStateReason.NotPlanned;
gitHubEventClient.AddToIssueUpdateList(scheduledEventPayload.Repository.Id,
issue.Number,
issueUpdate);
// Add a comment
string comment = $"Hi @{issue.User.Login}, we deeply appreciate your input into this project. Regrettably, this issue has remained inactive for over 2 years, leading us to the decision to close it. We've implemented this policy to maintain the relevance of our issue queue and facilitate easier navigation for new contributors. If you still believe this topic requires attention, please feel free to create a new issue, referencing this one. Thank you for your understanding and ongoing support.";
gitHubEventClient.CreateComment(scheduledEventPayload.Repository.Id,
issue.Number,
comment);
// Lock the issue
gitHubEventClient.LockIssue(scheduledEventPayload.Repository.Id, issue.Number, LockReason.Resolved);
// Close, Comment and Lock = 3 updates per issue
numUpdates += 3;
}

// The number of items in the query isn't known until the query is run.
// If the number of items in the result equals the total number of items matching the query then
// all the items have been processed.
// OR
// If the number of items in the result is less than the number of items requested per page then
// the last page of results has been processed which was not a full page
// OR
// The number of updates has hit the limit for a scheduled task
if (result.Items.Count == result.TotalCount ||
result.Items.Count < request.PerPage ||
numUpdates >= ScheduledTaskUpdateLimit)
{
break;
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -253,15 +253,6 @@ await _gitHubClient.PullRequest.Review.Dismiss(dismissal.RepositoryId,
prReview);
}

// Process any issue locks
foreach (var issueToLock in _gitHubIssuesToLock)
{
numUpdates++;
await _gitHubClient.Issue.LockUnlock.Lock(issueToLock.RepositoryId,
issueToLock.IssueNumber,
issueToLock.LockReason);
}

// Process any Scheduled task IssueUpdates
foreach (var issueToUpdate in _gitHubIssuesToUpdate)
{
Expand All @@ -270,6 +261,17 @@ await _gitHubClient.Issue.Update(issueToUpdate.RepositoryId,
issueToUpdate.IssueOrPRNumber,
issueToUpdate.IssueUpdate);
}

// Process any issue locks last in case the issue is being updated or having a comment added
// prior to being locked
foreach (var issueToLock in _gitHubIssuesToLock)
{
numUpdates++;
await _gitHubClient.Issue.LockUnlock.Lock(issueToLock.RepositoryId,
issueToLock.IssueNumber,
issueToLock.LockReason);
}

Console.WriteLine("Finished processing pending updates.");
}
// For the moment, nothing special is being done when rate limit exceptions are
Expand Down Expand Up @@ -335,16 +337,16 @@ public int ComputeNumberOfExpectedUpdates()
Console.WriteLine($"Number of Review Dismissals {_gitHubReviewDismissals.Count}");
numUpdates += _gitHubReviewDismissals.Count;
}
if (_gitHubIssuesToLock.Count > 0)
{
Console.WriteLine($"Number of Issues to Lock {_gitHubIssuesToLock.Count}");
numUpdates += _gitHubIssuesToLock.Count;
}
if (_gitHubIssuesToUpdate.Count > 0)
{
Console.WriteLine($"Number of IssuesUpdates (only applicable for Scheduled events) {_gitHubIssuesToUpdate.Count}");
numUpdates += _gitHubIssuesToUpdate.Count;
}
if (_gitHubIssuesToLock.Count > 0)
{
Console.WriteLine($"Number of Issues to Lock {_gitHubIssuesToLock.Count}");
numUpdates += _gitHubIssuesToLock.Count;
}

return numUpdates;
}
Expand Down Expand Up @@ -865,10 +867,11 @@ public virtual async Task<bool> DoesUserHavePermissions(long repositoryId, strin
/// <param name="repoName">Should be repository.Name from the cron payload</param>
/// <param name="issueType">IssueTypeQualifier of Issue or PullRequest</param>
/// <param name="itemState">ItemState of Open or Closed</param>
/// <param name="daysSinceLastUpdate">Optional: Number of days since last updated</param>
/// <param name="issueIsQualifiers">Optional: List of IssueIsQualifier (ex. locked/unlocked) to include, null if none</param>
/// <param name="labelsToInclude">Optional: List of labels to include, null if none</param>
/// <param name="labelsToExclude">Optional: List of labels to exclude, null if none</param>
/// <param name="daysSinceLastUpdate">Optional: Number of days since last updated </param>
/// <param name="daysSinceCreated">Optional: Number of days since the issue was created</param>
/// <returns>SearchIssuesRequest created with the information passed in.</returns>
public SearchIssuesRequest CreateSearchRequest(string repoOwner,
string repoName,
Expand All @@ -877,7 +880,8 @@ public SearchIssuesRequest CreateSearchRequest(string repoOwner,
int daysSinceLastUpdate = 0,
List<IssueIsQualifier> issueIsQualifiers = null,
List<string> labelsToInclude = null,
List<string> labelsToExclude = null)
List<string> labelsToExclude = null,
int daysSinceCreated = 0)
{
var request = new SearchIssuesRequest();

Expand All @@ -904,6 +908,15 @@ public SearchIssuesRequest CreateSearchRequest(string repoOwner,
request.Updated = new DateRange(daysAgoOffset, SearchQualifierOperator.LessThan);
}

if (daysSinceCreated > 0)
{
// Octokit's DateRange wants a DateTimeOffset as other constructors are depricated
// AddDays of 0-days to effectively subtract them.
DateTime daysAgo = DateTime.UtcNow.AddDays(0 - daysSinceCreated);
DateTimeOffset daysAgoOffset = new DateTimeOffset(daysAgo);
request.Created = new DateRange(daysAgoOffset, SearchQualifierOperator.LessThan);
}

if (null != labelsToInclude)
{
request.Labels = labelsToInclude;
Expand Down
19 changes: 19 additions & 0 deletions tools/github-event-processor/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -617,3 +617,22 @@ OR
### Actions

- Lock issue conversations

## Enforce 2 year max life of issues

### Trigger

- CRON (weekly, Saturday at 12:30am)

### Criteria

- Issue is open
- Issue was opened > 2 years ago
- Issue was last updated more than 30 days ago

### Actions

- Close the issue
- Create the following comment
- "Hi @{issueAuthor}, we deeply appreciate your input into this project. Regrettably, this issue has remained inactive for over 2 years, leading us to the decision to close it. We've implemented this policy to maintain the relevance of our issue queue and facilitate easier navigation for new contributors. If you still believe this topic requires attention, please feel free to create a new issue, referencing this one. Thank you for your understanding and ongoing support."
- Lock issue conversations
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@
"IdentifyStaleIssues": "On",
"IdentifyStalePullRequests": "On",
"CloseAddressedIssues": "On",
"LockClosedIssues": "On"
"LockClosedIssues": "On",
"Enforce2YearMaxLifeOfIssues": "On"
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ on:
# pull request merged is the closed event with github.event.pull_request.merged = true
pull_request_target:
types: [closed, labeled, opened, reopened, review_requested, synchronize, unlabeled]
pull_request_review:
types: [submitted]

# This removes all unnecessary permissions, the ones needed will be set below.
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
Expand All @@ -29,8 +27,6 @@ jobs:
name: Handle ${{ github.event_name }} ${{ github.event.action }} event
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- name: 'Az CLI login'
if: ${{ github.event_name == 'issues' && github.event.action == 'opened' }}
uses: azure/login@v1
Expand Down Expand Up @@ -59,10 +55,9 @@ jobs:
run: >
dotnet tool install
Azure.Sdk.Tools.GitHubEventProcessor
--version 1.0.0-dev.20230929.3
--version 1.0.0-dev.20231114.3
--add-source https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-net/nuget/v3/index.json
--global
working-directory: .github/workflows
shell: bash
# End-Install

Expand All @@ -89,11 +84,12 @@ jobs:

- name: Process Action Event
run: |
echo $GITHUB_PAYLOAD > payload.json
cat > payload.json << 'EOF'
${{ toJson(github.event) }}
EOF
github-event-processor ${{ github.event_name }} payload.json
shell: bash
env:
GITHUB_PAYLOAD: ${{ toJson(github.event) }}
# This is a temporary secret generated by github
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
Loading