Skip to content

Commit 41a7961

Browse files
authored
Merge pull request #674 from github-community-projects/claude/add-grouping-sorting-options
feat: add grouping and sorting options for shipped work report
2 parents deb1a60 + 36170c7 commit 41a7961

4 files changed

Lines changed: 611 additions & 92 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ This action can be configured to authenticate with GitHub App Installation or Pe
172172
| `OUTPUT_FILE` | False | `issue_metrics.md` or `issue_metrics.json` | Output filename. |
173173
| `REPORT_TITLE` | False | `"Issue Metrics"` | Title to have on the report issue. |
174174
| `SEARCH_QUERY` | True | `""` | The query by which you can filter issues/PRs which must contain a `repo:`, `org:`, `owner:`, or a `user:` entry. For discussions, include `type:discussions` in the query. |
175+
| `GROUP_BY` | False | `""` | Group items in the report by the specified field. Supported values: `author`, `assignee`. When set, items will be grouped into separate sections by the chosen field. |
176+
| `SORT_BY` | False | `""` | Sort items in the report by the specified field. Supported values: `time_to_close`, `time_to_first_response`, `time_to_answer`, `time_in_draft`, `created_at`. When set, items will be sorted by the chosen metric. |
177+
| `SORT_ORDER` | False | `asc` | Sort order for the items. Supported values: `asc` (ascending), `desc` (descending). Only applies when `SORT_BY` is set. |
175178

176179
## Further Documentation
177180

config.py

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ class EnvVars:
5959
in addition to other metrics
6060
hide_pr_statistics (bool): If set to TRUE, hide PR comment statistics in the output
6161
hide_items_list (bool): If set to TRUE, hide the list of individual items in the report
62+
group_by (str | None): If set, group items by the specified field (e.g., 'author')
63+
sort_by (str | None): If set, sort items by the specified field (e.g., 'time_to_close')
64+
sort_order (str): Sort order, either 'asc' for ascending or 'desc' for descending
6265
"""
6366

6467
def __init__(
@@ -92,6 +95,9 @@ def __init__(
9295
draft_pr_tracking: bool = False,
9396
hide_pr_statistics: bool = True,
9497
hide_items_list: bool = False,
98+
group_by: str | None = None,
99+
sort_by: str | None = None,
100+
sort_order: str = "asc",
95101
):
96102
self.gh_app_id = gh_app_id
97103
self.gh_app_installation_id = gh_app_installation_id
@@ -122,39 +128,45 @@ def __init__(
122128
self.draft_pr_tracking = draft_pr_tracking
123129
self.hide_pr_statistics = hide_pr_statistics
124130
self.hide_items_list = hide_items_list
131+
self.group_by = group_by
132+
self.sort_by = sort_by
133+
self.sort_order = sort_order
125134

126135
def __repr__(self):
127136
return (
128-
f"EnvVars("
129-
f"{self.gh_app_id},"
130-
f"{self.gh_app_installation_id},"
131-
f"{self.gh_app_private_key_bytes},"
132-
f"{self.gh_app_enterprise_only},"
133-
f"{self.gh_token},"
134-
f"{self.ghe},"
135-
f"{self.hide_assignee},"
136-
f"{self.hide_author},"
137-
f"{self.hide_items_closed_count}),"
138-
f"{self.hide_label_metrics},"
139-
f"{self.hide_time_to_answer},"
140-
f"{self.hide_time_to_close},"
141-
f"{self.hide_time_to_first_response},"
142-
f"{self.hide_created_at},"
143-
f"{self.hide_status},"
144-
f"{self.ignore_users},"
145-
f"{self.labels_to_measure},"
146-
f"{self.enable_mentor_count},"
147-
f"{self.min_mentor_comments},"
148-
f"{self.max_comments_eval},"
149-
f"{self.heavily_involved_cutoff},"
150-
f"{self.search_query}"
151-
f"{self.non_mentioning_links}"
152-
f"{self.report_title}"
153-
f"{self.output_file}"
154-
f"{self.rate_limit_bypass}"
155-
f"{self.draft_pr_tracking}"
156-
f"{self.hide_pr_statistics}"
157-
f"{self.hide_items_list}"
137+
"EnvVars("
138+
f"{self.gh_app_id}, "
139+
f"{self.gh_app_installation_id}, "
140+
f"{self.gh_app_private_key_bytes}, "
141+
f"{self.gh_app_enterprise_only}, "
142+
f"{self.gh_token}, "
143+
f"{self.ghe}, "
144+
f"{self.hide_assignee}, "
145+
f"{self.hide_author}, "
146+
f"{self.hide_items_closed_count}, "
147+
f"{self.hide_label_metrics}, "
148+
f"{self.hide_time_to_answer}, "
149+
f"{self.hide_time_to_close}, "
150+
f"{self.hide_time_to_first_response}, "
151+
f"{self.hide_created_at}, "
152+
f"{self.hide_status}, "
153+
f"{self.ignore_users}, "
154+
f"{self.labels_to_measure}, "
155+
f"{self.enable_mentor_count}, "
156+
f"{self.min_mentor_comments}, "
157+
f"{self.max_comments_eval}, "
158+
f"{self.heavily_involved_cutoff}, "
159+
f"{self.search_query}, "
160+
f"{self.non_mentioning_links}, "
161+
f"{self.report_title}, "
162+
f"{self.output_file}, "
163+
f"{self.rate_limit_bypass}, "
164+
f"{self.draft_pr_tracking}, "
165+
f"{self.hide_pr_statistics}, "
166+
f"{self.hide_items_list}, "
167+
f"{self.group_by}, "
168+
f"{self.sort_by}, "
169+
f"{self.sort_order})"
158170
)
159171

160172

@@ -242,6 +254,13 @@ def get_env_vars(test: bool = False) -> EnvVars:
242254
rate_limit_bypass = get_bool_env_var("RATE_LIMIT_BYPASS", False)
243255
draft_pr_tracking = get_bool_env_var("DRAFT_PR_TRACKING", False)
244256

257+
# Grouping and sorting options
258+
group_by = os.getenv("GROUP_BY", "").strip().lower() or None
259+
sort_by = os.getenv("SORT_BY", "").strip().lower() or None
260+
sort_order = os.getenv("SORT_ORDER", "asc").strip().lower()
261+
if sort_order not in ["asc", "desc"]:
262+
sort_order = "asc"
263+
245264
# Hidden columns
246265
hide_assignee = get_bool_env_var("HIDE_ASSIGNEE", False)
247266
hide_author = get_bool_env_var("HIDE_AUTHOR", False)
@@ -290,4 +309,7 @@ def get_env_vars(test: bool = False) -> EnvVars:
290309
draft_pr_tracking,
291310
hide_pr_statistics,
292311
hide_items_list,
312+
group_by,
313+
sort_by,
314+
sort_order,
293315
)

markdown_writer.py

Lines changed: 173 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,21 @@
3030
average_time_to_answer: timedelta
3131
) -> List[str]:
3232
Get the columns that are not hidden.
33+
sort_issues(
34+
issues: List[IssueWithMetrics],
35+
sort_by: str | None,
36+
sort_order: str
37+
) -> List[IssueWithMetrics]:
38+
Sort issues by the specified field.
39+
group_issues(
40+
issues: List[IssueWithMetrics],
41+
group_by: str | None
42+
) -> dict[str, List[IssueWithMetrics]]:
43+
Group issues by the specified field.
3344
"""
3445

3546
from datetime import timedelta
36-
from typing import List, Union
47+
from typing import Dict, List, Union
3748

3849
from classes import IssueWithMetrics
3950
from config import get_env_vars
@@ -98,6 +109,94 @@ def get_non_hidden_columns(labels) -> List[str]:
98109
return columns
99110

100111

112+
def sort_issues(
113+
issues: List[IssueWithMetrics], sort_by: str | None, sort_order: str
114+
) -> List[IssueWithMetrics]:
115+
"""Sort issues by the specified field.
116+
117+
Args:
118+
issues (List[IssueWithMetrics]): List of issues to sort.
119+
sort_by (str | None): Field to sort by (e.g., 'time_to_close', 'time_to_first_response').
120+
sort_order (str): Sort order, either 'asc' for ascending or 'desc' for descending.
121+
122+
Returns:
123+
List[IssueWithMetrics]: Sorted list of issues.
124+
"""
125+
if not sort_by or not issues:
126+
return issues
127+
128+
# Map of valid sort fields
129+
valid_fields = {
130+
"time_to_close",
131+
"time_to_first_response",
132+
"time_to_answer",
133+
"time_in_draft",
134+
"created_at",
135+
}
136+
137+
if sort_by not in valid_fields:
138+
return issues
139+
140+
reverse = sort_order == "desc"
141+
142+
# Sort with None values at the end, regardless of sort order
143+
non_none_issues: List[IssueWithMetrics] = []
144+
none_issues: List[IssueWithMetrics] = []
145+
146+
for issue in issues:
147+
value = getattr(issue, sort_by, None)
148+
if value is None:
149+
none_issues.append(issue)
150+
else:
151+
non_none_issues.append(issue)
152+
153+
sorted_non_none = sorted(
154+
non_none_issues,
155+
key=lambda issue: getattr(issue, sort_by),
156+
reverse=reverse,
157+
)
158+
159+
return sorted_non_none + none_issues
160+
161+
162+
def group_issues(
163+
issues: List[IssueWithMetrics], group_by: str | None
164+
) -> Dict[str, List[IssueWithMetrics]]:
165+
"""Group issues by the specified field.
166+
167+
Args:
168+
issues (List[IssueWithMetrics]): List of issues to group.
169+
group_by (str | None): Field to group by (e.g., 'author', 'assignee').
170+
171+
Returns:
172+
Dict[str, List[IssueWithMetrics]]: Dictionary of grouped issues.
173+
"""
174+
if not group_by or not issues:
175+
return {"": issues}
176+
177+
# Map of valid group fields
178+
valid_fields = {"author", "assignee"}
179+
180+
if group_by not in valid_fields:
181+
return {"": issues}
182+
183+
grouped: Dict[str, List[IssueWithMetrics]] = {}
184+
for issue in issues:
185+
if group_by == "author":
186+
key = issue.author or "Unknown"
187+
elif group_by == "assignee":
188+
# Use the first assignee or "Unassigned"
189+
key = issue.assignees[0] if issue.assignees else "Unassigned"
190+
else:
191+
key = "Unknown"
192+
193+
if key not in grouped:
194+
grouped[key] = []
195+
grouped[key].append(issue)
196+
197+
return grouped
198+
199+
101200
def write_to_markdown(
102201
issues_with_metrics: Union[List[IssueWithMetrics], None],
103202
average_time_to_first_response: Union[dict[str, timedelta], None],
@@ -188,70 +287,82 @@ def write_to_markdown(
188287

189288
# Write second table with individual issue/pr/discussion metrics
190289
# Skip this table if hide_items_list is True
191-
if not env_vars.hide_items_list:
192-
# First write the header
193-
file.write("|")
194-
for column in columns:
195-
file.write(f" {column} |")
196-
file.write("\n")
197-
198-
# Then write the column dividers
199-
file.write("|")
200-
for _ in columns:
201-
file.write(" --- |")
202-
file.write("\n")
203-
204-
# Then write the issues/pr/discussions row by row
205-
for issue in issues_with_metrics:
206-
# Replace the vertical bar with the HTML entity
207-
issue.title = issue.title.replace("|", "|")
208-
# Replace any whitespace
209-
issue.title = issue.title.strip()
210-
211-
endpoint = ghe.removeprefix("https://") if ghe else "github.com"
212-
if non_mentioning_links:
213-
file.write(
214-
f"| {issue.title} | "
215-
f"{issue.html_url}".replace(
216-
f"https://{endpoint}", f"https://www.{endpoint}"
290+
if not env_vars.hide_items_list: # pylint: disable=too-many-nested-blocks
291+
# Apply sorting and grouping
292+
sorted_issues = sort_issues(
293+
issues_with_metrics, env_vars.sort_by, env_vars.sort_order
294+
)
295+
grouped_issues_dict = group_issues(sorted_issues, env_vars.group_by)
296+
297+
# If grouping, write separate sections for each group
298+
for group_name, group_issues_list in grouped_issues_dict.items():
299+
# Write group header if grouping is enabled
300+
if env_vars.group_by and group_name:
301+
file.write(f"\n### {group_name}\n\n")
302+
303+
# First write the header
304+
file.write("|")
305+
for column in columns:
306+
file.write(f" {column} |")
307+
file.write("\n")
308+
309+
# Then write the column dividers
310+
file.write("|")
311+
for _ in columns:
312+
file.write(" --- |")
313+
file.write("\n")
314+
315+
# Then write the issues/pr/discussions row by row
316+
for issue in group_issues_list:
317+
# Replace the vertical bar with the HTML entity
318+
issue.title = issue.title.replace("|", "|")
319+
# Replace any whitespace
320+
issue.title = issue.title.strip()
321+
322+
endpoint = ghe.removeprefix("https://") if ghe else "github.com"
323+
if non_mentioning_links:
324+
file.write(
325+
f"| {issue.title} | "
326+
f"{issue.html_url}".replace(
327+
f"https://{endpoint}", f"https://www.{endpoint}"
328+
)
329+
+ " |"
217330
)
218-
+ " |"
219-
)
220-
else:
221-
file.write(f"| {issue.title} | {issue.html_url} |")
222-
if "Assignee" in columns:
223-
if issue.assignees:
224-
assignee_links = [
225-
f"[{assignee}](https://{endpoint}/{assignee})"
226-
for assignee in issue.assignees
227-
]
228-
file.write(f" {', '.join(assignee_links)} |")
229331
else:
230-
file.write(" None |")
231-
if "Author" in columns:
232-
file.write(
233-
f" [{issue.author}](https://{endpoint}/{issue.author}) |"
234-
)
235-
if "Time to first response" in columns:
236-
file.write(f" {issue.time_to_first_response} |")
237-
if "Time to close" in columns:
238-
file.write(f" {issue.time_to_close} |")
239-
if "Time to answer" in columns:
240-
file.write(f" {issue.time_to_answer} |")
241-
if "Time in draft" in columns:
242-
file.write(f" {issue.time_in_draft} |")
243-
if labels and issue.label_metrics:
244-
for label in labels:
245-
if f"Time spent in {label}" in columns:
246-
file.write(f" {issue.label_metrics[label]} |")
247-
if "Created At" in columns:
248-
file.write(f" {issue.created_at} |")
249-
if "Status" in columns:
250-
file.write(f" {issue.status} |")
251-
if "PR Comments" in columns:
252-
file.write(f" {issue.pr_comment_count or 'N/A'} |")
332+
file.write(f"| {issue.title} | {issue.html_url} |")
333+
if "Assignee" in columns:
334+
if issue.assignees:
335+
assignee_links = [
336+
f"[{assignee}](https://{endpoint}/{assignee})"
337+
for assignee in issue.assignees
338+
]
339+
file.write(f" {', '.join(assignee_links)} |")
340+
else:
341+
file.write(" None |")
342+
if "Author" in columns:
343+
file.write(
344+
f" [{issue.author}](https://{endpoint}/{issue.author}) |"
345+
)
346+
if "Time to first response" in columns:
347+
file.write(f" {issue.time_to_first_response} |")
348+
if "Time to close" in columns:
349+
file.write(f" {issue.time_to_close} |")
350+
if "Time to answer" in columns:
351+
file.write(f" {issue.time_to_answer} |")
352+
if "Time in draft" in columns:
353+
file.write(f" {issue.time_in_draft} |")
354+
if labels and issue.label_metrics:
355+
for label in labels:
356+
if f"Time spent in {label}" in columns:
357+
file.write(f" {issue.label_metrics[label]} |")
358+
if "Created At" in columns:
359+
file.write(f" {issue.created_at} |")
360+
if "Status" in columns:
361+
file.write(f" {issue.status} |")
362+
if "PR Comments" in columns:
363+
file.write(f" {issue.pr_comment_count or 'N/A'} |")
364+
file.write("\n")
253365
file.write("\n")
254-
file.write("\n")
255366
file.write("_This report was generated with the \
256367
[Issue Metrics Action](https://github.com/github-community-projects/issue-metrics)_\n")
257368
if search_query:

0 commit comments

Comments
 (0)