From 082169ed1b86fda8afe9822cfbba8642cfee9972 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Fri, 19 Sep 2025 19:39:40 +0800 Subject: [PATCH 01/12] fix: changed canvas api login_id --- joint_teapot/workers/canvas.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/joint_teapot/workers/canvas.py b/joint_teapot/workers/canvas.py index 641b8af..2ba1782 100644 --- a/joint_teapot/workers/canvas.py +++ b/joint_teapot/workers/canvas.py @@ -39,8 +39,19 @@ class Canvas: student.name = ( re.sub(r"[^\x00-\x7F]+", "", student.name).strip().title() ) # We only care english name - student.sis_id = student.login_id - student.login_id = student.email.split("@")[0] + # Some users (like system users, announcers) might not have login_id + if hasattr(student, 'login_id') and student.login_id: + student.sis_id = student.login_id + student.login_id = student.email.split("@")[0] + else: + # For users without login_id, use email prefix as both sis_id and login_id + if hasattr(student, 'email') and student.email: + student.login_id = student.email.split("@")[0] + student.sis_id = student.login_id + else: + # Fallback for users without email + student.login_id = f"user_{student.id}" + student.sis_id = student.login_id return student self.students = [ -- 2.30.2 From 0e37b5d4442aec4bb722062ae04f3b6bb5c0c6d0 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Fri, 19 Sep 2025 19:41:19 +0800 Subject: [PATCH 02/12] feat: label and unlabel issues --- joint_teapot/workers/gitea.py | 208 ++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) diff --git a/joint_teapot/workers/gitea.py b/joint_teapot/workers/gitea.py index 159633a..35f3c89 100644 --- a/joint_teapot/workers/gitea.py +++ b/joint_teapot/workers/gitea.py @@ -4,6 +4,8 @@ from functools import lru_cache from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, TypeVar import focs_gitea +import requests +from urllib.parse import quote from canvasapi.group import Group, GroupMembership from canvasapi.paginated_list import PaginatedList from canvasapi.user import User @@ -548,6 +550,212 @@ class Gitea: self.create_milestone(repo_name, milestone, description, due_date) logger.info(f"Created milestone {milestone} in {repo_name}") + def label_issues(self, repo_name: str, label_name: str, issue_numbers: List[int], color: Optional[str] = None) -> None: + if not issue_numbers: + logger.warning("No issue numbers provided to label") + return + + try: + labels = self.issue_api.issue_list_labels(self.org_name, repo_name) + except ApiException as e: + logger.error(f"Failed to list labels for {repo_name}: {e}") + return + + label_obj = None + for l in labels: + if getattr(l, "name", None) == label_name: + label_obj = l + break + + if not label_obj: + chosen_color = None + if color: + c = color.strip() + if c.startswith('#'): + c = c[1:] + if re.fullmatch(r"[0-9a-fA-F]{6}", c): + chosen_color = c + else: + logger.warning(f"Provided color '{color}' is not a valid 3- or 6-digit hex; falling back to default") + if chosen_color is None: + chosen_color = "CCCCCC" + try: + label_obj = self.issue_api.issue_create_label( + self.org_name, + repo_name, + body={"name": label_name, "color": chosen_color, "exclusive": False}, + ) + logger.info(f"Created label '{label_name}' in {self.org_name}/{repo_name}") + except ApiException as e: + logger.error(f"Failed to create label {label_name} in {repo_name}: {e}") + if hasattr(e, 'body'): + logger.error(f"ApiException body: {getattr(e, 'body', None)}") + return + + label_id = getattr(label_obj, "id", None) + if label_id is None: + try: + label_id = label_obj.get("id") + except Exception: + label_id = None + + if label_id is None: + logger.error(f"Unable to determine id of label '{label_name}' in {repo_name}") + return + + try: + issues = {issue.number: issue for issue in list_all(self.issue_api.issue_list_issues, self.org_name, repo_name)} + except ApiException as e: + logger.error(f"Failed to list issues for {repo_name}: {e}") + return + + for num in issue_numbers: + issue = issues.get(num) + if issue is None: + logger.warning(f"Issue #{num} not found in {repo_name}") + continue + + def _extract_label_names_from_issue(issue_obj) -> List[str]: + try: + return [getattr(l, "name", l) for l in getattr(issue_obj, "labels", []) or []] + except Exception: + return [] + + existing_label_names = _extract_label_names_from_issue(issue) + if label_name in existing_label_names: + logger.info(f"Issue #{num} in {repo_name} already has label '{label_name}'") + continue + + existing_label_ids: List[int] = [] + try: + for l in getattr(issue, "labels", []) or []: + lid = getattr(l, "id", None) + if lid is None: + try: + lid = l.get("id") + except Exception: + lid = None + if lid is not None: + existing_label_ids.append(lid) + except Exception: + existing_label_ids = [] + + if label_id in existing_label_ids: + logger.info(f"Issue #{num} in {repo_name} already has label '{label_name}' (by id)") + continue + + # verification + def _fetch_issue_labels_via_api() -> List[str]: + try: + single = self.issue_api.issue_get_issue(self.org_name, repo_name, num) + return _extract_label_names_from_issue(single) + except Exception: + try: + updated = next((i for i in list_all(self.issue_api.issue_list_issues, self.org_name, repo_name) if getattr(i, "number", None) == num), None) + if updated is None: + return [] + return _extract_label_names_from_issue(updated) + except Exception: + return [] + + issue_labels = _fetch_issue_labels_via_api() + + # prepare low-level HTTP helpers for fallbacks + def _do_post_labels(issue_num: int, payload) -> requests.Response: + path = f"/repos/{self.org_name}/{repo_name}/issues/{issue_num}/labels" + full_url = f"{self.api_client.configuration.host}{path}" + token = getattr(self.api_client.configuration, 'api_key', {}) .get('access_token') or settings.gitea_access_token + headers_local = { + "Authorization": f"token {token}", + "Content-Type": "application/json", + } + return requests.post(full_url, headers=headers_local, json=payload, timeout=10) + + # fallback: if label still not present, POST JSON object {"labels":[name]} to add-labels endpoint + if label_name not in (issue_labels or []): + try: + resp = _do_post_labels(num, {"labels": [label_name]}) + if resp.status_code not in (200, 201): + logger.error(f"Failed to add label via add-labels endpoint for issue #{num}: status={resp.status_code}") + except Exception as e: + logger.error(f"Failed to POST object payload to issue #{num}: {e}") + + # final verification + issue_labels = _fetch_issue_labels_via_api() + if label_name in (issue_labels or []): + logger.info(f"Label '{label_name}' attached to issue #{num} in {repo_name}") + else: + logger.warning(f"Label '{label_name}' not attached to issue #{num} in {repo_name} after attempts") + + + def delete_label(self, repo_name: str, label_name: str, issue_numbers: Optional[List[int]] = None, delete_repo_label: bool = False) -> None: + token = getattr(self.api_client.configuration, 'api_key', {}) .get('access_token') or settings.gitea_access_token + headers_local = {"Authorization": f"token {token}"} + try: + repo_labels = self.issue_api.issue_list_labels(self.org_name, repo_name) + label_name_to_id: Dict[str, int] = { + (getattr(l, 'name', None) or (l.get('name') if isinstance(l, dict) else None)): (getattr(l, 'id', None) or (l.get('id') if isinstance(l, dict) else None)) + for l in repo_labels + } + except Exception: + label_name_to_id = {} + + def _delete_issue_label(issue_num: int) -> None: + lid = label_name_to_id.get(label_name) + if lid is None: + try: + for l in self.issue_api.issue_list_labels(self.org_name, repo_name): + name = getattr(l, 'name', None) or (l.get('name') if isinstance(l, dict) else None) + if name == label_name: + lid = getattr(l, 'id', None) or (l.get('id') if isinstance(l, dict) else None) + break + except Exception: + pass + + if lid is None: + logger.warning(f"No numeric id found for label '{label_name}' in {repo_name}; skipping issue-level delete for issue #{issue_num}") + return + + path_id = f"/repos/{self.org_name}/{repo_name}/issues/{issue_num}/labels/{lid}" + url_id = f"{self.api_client.configuration.host}{path_id}" + try: + resp = requests.delete(url_id, headers=headers_local, timeout=10) + if resp.status_code in (200, 204): + logger.info(f"Removed label '{label_name}' from {repo_name}#{issue_num}") + return + logger.warning(f"Numeric-id DELETE returned status {resp.status_code} for {repo_name}#{issue_num}: body={getattr(resp, 'text', None)}") + except Exception as e: + logger.error(f"Numeric-id DELETE error for {repo_name}#{issue_num}: {e}") + + def _delete_repo_label() -> None: + enc_name = quote(label_name, safe='') + path = f"/repos/{self.org_name}/{repo_name}/labels/{enc_name}" + url = f"{self.api_client.configuration.host}{path}" + try: + resp = requests.delete(url, headers=headers_local, timeout=10) + if resp.status_code in (200, 204): + logger.info(f"Removed repo-level label '{label_name}' from {repo_name}") + return + logger.error(f"Failed to delete repo label '{label_name}' from {repo_name}: status={resp.status_code}, body={getattr(resp, 'text', None)}") + except Exception as e: + logger.error(f"Error deleting repo label '{label_name}' from {repo_name}: {e}") + + if issue_numbers: + try: + issues = {issue.number: issue for issue in list_all(self.issue_api.issue_list_issues, self.org_name, repo_name)} + except ApiException as e: + logger.error(f"Failed to list issues for {repo_name}: {e}") + return + for num in issue_numbers: + if num not in issues: + logger.warning(f"Issue #{num} not found in {repo_name}") + continue + _delete_issue_label(num) + else: + if delete_repo_label: + _delete_repo_label() + else: + logger.warning("No issue numbers provided and --repo not set; nothing to do for delete_label") if __name__ == "__main__": gitea = Gitea() -- 2.30.2 From a2d636f64ee6a464773ee961f0609cb7ea76fab5 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Fri, 19 Sep 2025 19:41:40 +0800 Subject: [PATCH 03/12] feat: close issues --- joint_teapot/workers/gitea.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/joint_teapot/workers/gitea.py b/joint_teapot/workers/gitea.py index 35f3c89..80cd550 100644 --- a/joint_teapot/workers/gitea.py +++ b/joint_teapot/workers/gitea.py @@ -479,6 +479,35 @@ class Gitea: self.org_name, repo_name, issue.number, body={"state": "closed"} ) + def close_issues(self, repo_name: str, issue_numbers: List[int], dry_run: bool = True) -> None: + if not issue_numbers: + logger.warning("No issue numbers provided to close") + return + if dry_run: + logger.info("Dry run enabled. No changes will be made to issues.") + try: + issues = {issue.number: issue for issue in list_all(self.issue_api.issue_list_issues, self.org_name, repo_name)} + except ApiException as e: + logger.error(f"Failed to list issues for {repo_name}: {e}") + return + + for num in issue_numbers: + issue = issues.get(num) + if issue is None: + logger.warning(f"Issue #{num} not found in {repo_name}") + continue + if getattr(issue, "state", "") == "closed": + logger.info(f"Issue #{num} in {repo_name} already closed") + continue + try: + if dry_run: + logger.info(f"Would close issue #{num} in {repo_name} (dry run)") + continue + self.issue_api.issue_edit_issue(self.org_name, repo_name, num, body={"state": "closed"}) + logger.info(f"Closed issue #{num} in {repo_name}") + except ApiException as e: + logger.error(f"Failed to close issue #{num} in {repo_name}: {e}") + def archive_repos(self, regex: str = ".+", dry_run: bool = True) -> None: if dry_run: logger.info("Dry run enabled. No changes will be made to the repositories.") -- 2.30.2 From 9cf33f1216ab7579346b15abe0c44cf2b650af04 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Fri, 19 Sep 2025 19:42:36 +0800 Subject: [PATCH 04/12] feat: add app command for closing issues --- joint_teapot/app.py | 51 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/joint_teapot/app.py b/joint_teapot/app.py index e83b014..139e709 100644 --- a/joint_teapot/app.py +++ b/joint_teapot/app.py @@ -167,6 +167,57 @@ def close_all_issues() -> None: tea.pot.gitea.close_all_issues() +@app.command( + "close-issues", + help="close specific issues in a repository: joint-teapot close-issues REPO_NAME ISSUE_NUMBER [ISSUE_NUMBER ...]", +) +def close_issues( + repo_name: str, + issue_numbers: List[int] = Argument(..., help="One or more issue numbers to close"), + dry_run: bool = Option(False, "--dry-run/--no-dry-run", help="Enable dry run (no changes will be made)"), +) -> None: + tea.pot.gitea.close_issues(repo_name, issue_numbers, dry_run) + + +@app.command( + "label-issues", + help="add a label to specific issues in a repository: joint-teapot label-issues TAG_NAME REPO_NAME ISSUE_NUMBER [ISSUE_NUMBER ...]", +) +def label_issues( + label_name: str, + repo_name: str, + issue_numbers: List[int] = Argument(..., help="One or more issue numbers to label"), + issue_color: Optional[str] = Option(None, "--color", help="Color for newly created label (hex without # or with #)"), + dry_run: bool = Option(False, "--dry-run/--no-dry-run", help="Dry run: show what would be labeled without making changes"), +) -> None: + if dry_run: + logger.info(f"Dry run: would add label '{label_name}' to {repo_name} issues: {issue_numbers}") + return + tea.pot.gitea.label_issues(repo_name, label_name, issue_numbers, color=issue_color) + + +@app.command( + "delete-label", + help="remove a label from specific issues or delete the repository label: joint-teapot delete-label TAG_NAME REPO_NAME [ISSUE_NUMBER ...]", +) +def delete_label( + label_name: str, + repo_name: str, + issue_numbers: List[int] = Argument(None, help="Zero or more issue numbers to remove the label from (if empty and --repo is set, delete repo label)"), + repo: bool = Option(False, "--repo", help="Delete the repo-level label when set and issue_numbers is empty"), + dry_run: bool = Option(False, "--dry-run/--no-dry-run", help="Dry run: show what would be removed without making changes"), +) -> None: + if dry_run: + if issue_numbers: + logger.info(f"Dry run: would remove label '{label_name}' from {repo_name} issues: {issue_numbers}") + elif repo: + logger.info(f"Dry run: would delete repo label '{label_name}' from {repo_name}") + else: + logger.info("Dry run: no action specified (provide issue numbers or --repo to delete repo label)") + return + tea.pot.gitea.delete_label(repo_name, label_name, issue_numbers if issue_numbers else None, delete_repo_label=repo) + + @app.command( "archive-repos", help="archive repos in gitea organization according to regex" ) -- 2.30.2 From 2accba200932f8f44d4109e9250e5d41ccc6603a Mon Sep 17 00:00:00 2001 From: arthurcai Date: Fri, 19 Sep 2025 19:43:05 +0800 Subject: [PATCH 05/12] feat: update channel --- joint_teapot/workers/mattermost.py | 169 +++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/joint_teapot/workers/mattermost.py b/joint_teapot/workers/mattermost.py index 93cc47d..ebd2a23 100644 --- a/joint_teapot/workers/mattermost.py +++ b/joint_teapot/workers/mattermost.py @@ -103,6 +103,91 @@ class Mattermost: ) logger.info(f"Added member {member} to channel {channel_name}") + def update_channels_for_groups( + self, + groups: Dict[str, List[str]], + suffix: str = "", + update_teaching_team: bool = True, + dry_run: bool = False, + ) -> None: + for group_name, members in groups.items(): + channel_name = group_name + suffix + try: + channel = self.endpoint.channels.get_channel_by_name(self.team["id"], channel_name) + logger.info(f"Channel {channel_name} exists, updating members") + except Exception: + # channel does not exist + if dry_run: + info_members = list(members) + if update_teaching_team: + info_members = info_members + settings.mattermost_teaching_team + logger.info(f"Dry run: would create channel {channel_name} and add members: {info_members}") + continue + try: + channel = self.endpoint.channels.create_channel( + { + "team_id": self.team["id"], + "name": channel_name, + "display_name": channel_name, + "type": "P", + } + ) + logger.info(f"Created channel {channel_name} on Mattermost") + except Exception as e: + logger.warning( + f"Error when creating channel {channel_name}: {e} Perhaps channel already exists?" + ) + continue + + current_members = set() + try: + mm_members = self.endpoint.channels.get_channel_members(channel["id"]) or [] + for m in mm_members: + uname = None + if isinstance(m, dict): + uname = m.get("username") or m.get("name") + if not uname and "user" in m and isinstance(m["user"], dict): + uname = m["user"].get("username") or m["user"].get("name") + if not uname and "user_id" in m: + try: + u = self.endpoint.users.get_user(m["user_id"]) or {} + if isinstance(u, dict): + uname = u.get("username") or u.get("name") + except Exception: + uname = None + if uname: + current_members.add(uname.lower()) + except Exception: + current_members = set() + + add_members = list(members) + if update_teaching_team: + add_members = add_members + settings.mattermost_teaching_team + + for member in add_members: + if member.lower() in current_members: + logger.debug(f"Member {member} already in channel {channel_name}") + continue + if dry_run: + logger.info(f"Dry run: would add member {member} to channel {channel_name}") + continue + try: + mmuser = self.endpoint.users.get_user_by_username(member) + except Exception: + logger.warning(f"User {member} is not found on the Mattermost server") + self.endpoint.posts.create_post( + {"channel_id": channel["id"], "message": f"User {member} is not found on the Mattermost server"} + ) + continue + try: + self.endpoint.channels.add_user(channel["id"], {"user_id": mmuser["id"]}) + except Exception: + logger.warning(f"User {member} is not in the team") + self.endpoint.posts.create_post( + {"channel_id": channel["id"], "message": f"User {member} is not in the team"} + ) + logger.info(f"Added member {member} to channel {channel_name}") + def create_channels_for_individuals( self, students: PaginatedList, @@ -166,6 +251,90 @@ class Mattermost: logger.info(f"Added member {member} to channel {channel_name}") + def update_channels_for_individuals( + self, + students: PaginatedList, + update_teaching_team: bool = True, + dry_run: bool = False, + ) -> None: + for student in students: + display_name = student.name + channel_name = student.sis_id + try: + channel = self.endpoint.channels.get_channel_by_name(self.team["id"], channel_name) + logger.info(f"Channel {channel_name} exists, updating members") + except Exception: + if dry_run: + members_info = [student.login_id] + if update_teaching_team: + members_info = members_info + settings.mattermost_teaching_team + logger.info(f"Dry run: would create channel {display_name} ({channel_name}) and add members: {members_info}") + continue + try: + channel = self.endpoint.channels.create_channel( + { + "team_id": self.team["id"], + "name": channel_name, + "display_name": display_name, + "type": "P", + } + ) + logger.info(f"Created channel {display_name} ({channel_name}) on Mattermost") + except Exception as e: + logger.warning( + f"Error when creating channel {channel_name}: {e} Perhaps channel already exists?" + ) + continue + + current_members = set() + try: + mm_members = self.endpoint.channels.get_channel_members(channel["id"]) or [] + for m in mm_members: + uname = None + if isinstance(m, dict): + uname = m.get("username") or m.get("name") + if not uname and "user" in m and isinstance(m["user"], dict): + uname = m["user"].get("username") or m["user"].get("name") + if not uname and "user_id" in m: + try: + u = self.endpoint.users.get_user(m["user_id"]) or {} + if isinstance(u, dict): + uname = u.get("username") or u.get("name") + except Exception: + uname = None + if uname: + current_members.add(uname.lower()) + except Exception: + current_members = set() + + members = [student.login_id] + if update_teaching_team: + members = members + settings.mattermost_teaching_team + + for member in members: + if member.lower() in current_members: + logger.debug(f"Member {member} already in channel {channel_name}") + continue + if dry_run: + logger.info(f"Dry run: would add member {member} to channel {channel_name}") + continue + try: + mmuser = self.endpoint.users.get_user_by_username(member) + except Exception: + logger.warning(f"User {member} is not found on the Mattermost server") + self.endpoint.posts.create_post( + {"channel_id": channel["id"], "message": f"User {member} is not found on the Mattermost server"} + ) + continue + try: + self.endpoint.channels.add_user(channel["id"], {"user_id": mmuser["id"]}) + except Exception: + logger.warning(f"User {member} is not in the team") + self.endpoint.posts.create_post( + {"channel_id": channel["id"], "message": f"User {member} is not in the team"} + ) + logger.info(f"Added member {member} to channel {channel_name}") + def create_webhooks_for_repos( self, repos: List[str], gitea: Gitea, gitea_suffix: bool ) -> None: -- 2.30.2 From 77c5db499d7a8eff0532f0aad9aa4c803f245ab8 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Fri, 19 Sep 2025 19:43:29 +0800 Subject: [PATCH 06/12] feat: add app command for updating channels --- joint_teapot/app.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/joint_teapot/app.py b/joint_teapot/app.py index 139e709..338b8e5 100644 --- a/joint_teapot/app.py +++ b/joint_teapot/app.py @@ -294,6 +294,36 @@ def create_personal_channels_on_mm( tea.pot.create_channels_for_individuals(invite_teaching_team) +@app.command( + "update-group-channels-on-mm", + help="update Mattermost channels for student groups based on team information on Gitea; only add missing members", +) +def update_group_channels_on_mm( + prefix: str = Option("", help="Only process repositories starting with this prefix"), + suffix: str = Option("", help="Only process channels ending with this suffix"), + update_teaching_team: bool = Option(True, "--update-teaching-team/--no-update-teaching-team", help="Whether to update teaching team"), + dry_run: bool = Option(False, "--dry-run/--no-dry-run", help="Dry run: show what would be added without making changes"), +) -> None: + groups = { + group_name: members + for group_name, members in tea.pot.gitea.get_all_teams().items() + if group_name.startswith(prefix) + } + logger.info(f"{len(groups)} group channel(s) to update" + (f" with suffix {suffix}" if suffix else "")) + tea.pot.mattermost.update_channels_for_groups(groups, suffix, update_teaching_team, dry_run) + + +@app.command( + "update-personal-channels-on-mm", + help="update personal Mattermost channels for every student; only add missing members", +) +def update_personal_channels_on_mm( + update_teaching_team: bool = Option(True, "--update-teaching-team/--no-update-teaching-team", help="Whether to update teaching team"), + dry_run: bool = Option(False, "--dry-run/--no-dry-run", help="Dry run: show what would be added without making changes"), +) -> None: + tea.pot.mattermost.update_channels_for_individuals(tea.pot.canvas.students, update_teaching_team, dry_run) + + @app.command( "create-webhooks-for-mm", help="create a pair of webhooks on gitea and mm for all student groups on gitea, " -- 2.30.2 From 594337a54d2bc695bb5a5d23053aef757910340f Mon Sep 17 00:00:00 2001 From: arthurcai Date: Fri, 19 Sep 2025 19:43:58 +0800 Subject: [PATCH 07/12] doc: update README and add new commands --- README.md | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9b0f9da..6a4b250 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,24 @@ clone all gitea repos to local close all issues and pull requests in gitea organization -### `create-channels-on-mm` +### `create-group-channels-on-mm` -create channels for student groups according to group information on gitea. Optionally specify a prefix to ignore all repos whose names do not start with it. Optionally specify a suffix to add to all channels created. +create Mattermost channels for student groups based on team information on Gitea -Example: `python3 -m joint_teapot create-channels-on-mm --prefix p1 --suffix -private --invite-teaching-team` will fetch all repos whose names start with `"p1"` and create channels on mm for these repos like "p1team1-private". Members of a repo will be added to the corresponding channel. And teaching team (adjust in `.env`) will be invited to the channels. +**Options**: +- `--prefix TEXT`: Only process repositories starting with this prefix +- `--suffix TEXT`: Add suffix to created channels +- `--invite-teaching-team/--no-invite-teaching-team`: Whether to invite teaching team (default: invite) + +Example: `joint-teapot create-group-channels-on-mm --prefix "hteam" --suffix "-gitea"` will Create channels for webhook integration. Members of "hteam*" repo will be added to the corresponding channel. And teaching team (adjust in `.env`) will be invited to the channels. + +### `create-personal-channels-on-mm` + +create personal Mattermost channels for every student + +**Options**: + +- `--invite-teaching-team/--no-invite-teaching-team`: Whether to invite teaching team (default: invite) ### `create-comment` @@ -125,6 +138,26 @@ Example: `python3 -m joint_teapot unsubscribe-from-repos '\d{12}$'` will remove upload assignment grades to canvas from grade file (GRADE.txt by default), read the first line as grade, the rest as comments +### `label-issues` + +add a label to specific issues in a repository, create labels if not exist, with dry-run disabled by default. You may adjust the color of the label with `--color "#******"` if the label doesn't exist. + +### `delete-labels` + +remove a label from specific issues or delete the repository labels, with dry-run disabled by default. + +### `close-issues` + +close one or more specific issues in a repository, with dry-run disabled by default. + +### `update-group-channels-on-mm` + +update Mattermost channels for student groups based on team information on Gitea. It will only add missing users, never delete anyone. + +### `update-personal-channels-on-mm` + +update personal Mattermost channels for every student. It will only add missing users, never delete anyone. + ## License MIT -- 2.30.2 From a2f6a83b09ed2908208a914a13935c29e3412e02 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Fri, 19 Sep 2025 22:54:39 +0800 Subject: [PATCH 08/12] feat: add exclusive tag for addition and deletion --- joint_teapot/app.py | 95 +++++-- joint_teapot/workers/canvas.py | 4 +- joint_teapot/workers/gitea.py | 403 +++++++++++++++++++++++++---- joint_teapot/workers/mattermost.py | 72 ++++-- 4 files changed, 479 insertions(+), 95 deletions(-) diff --git a/joint_teapot/app.py b/joint_teapot/app.py index 338b8e5..4ba1198 100644 --- a/joint_teapot/app.py +++ b/joint_teapot/app.py @@ -174,7 +174,9 @@ def close_all_issues() -> None: def close_issues( repo_name: str, issue_numbers: List[int] = Argument(..., help="One or more issue numbers to close"), - dry_run: bool = Option(False, "--dry-run/--no-dry-run", help="Enable dry run (no changes will be made)"), + dry_run: bool = Option( + False, "--dry-run/--no-dry-run", help="Enable dry run (no changes will be made)" + ), ) -> None: tea.pot.gitea.close_issues(repo_name, issue_numbers, dry_run) @@ -187,11 +189,19 @@ def label_issues( label_name: str, repo_name: str, issue_numbers: List[int] = Argument(..., help="One or more issue numbers to label"), - issue_color: Optional[str] = Option(None, "--color", help="Color for newly created label (hex without # or with #)"), - dry_run: bool = Option(False, "--dry-run/--no-dry-run", help="Dry run: show what would be labeled without making changes"), + issue_color: Optional[str] = Option( + None, "--color", help="Color for newly created label (hex without # or with #)" + ), + dry_run: bool = Option( + False, + "--dry-run/--no-dry-run", + help="Dry run: show what would be labeled without making changes", + ), ) -> None: if dry_run: - logger.info(f"Dry run: would add label '{label_name}' to {repo_name} issues: {issue_numbers}") + logger.info( + f"Dry run: would add label '{label_name}' to {repo_name} issues: {issue_numbers}" + ) return tea.pot.gitea.label_issues(repo_name, label_name, issue_numbers, color=issue_color) @@ -203,19 +213,41 @@ def label_issues( def delete_label( label_name: str, repo_name: str, - issue_numbers: List[int] = Argument(None, help="Zero or more issue numbers to remove the label from (if empty and --repo is set, delete repo label)"), - repo: bool = Option(False, "--repo", help="Delete the repo-level label when set and issue_numbers is empty"), - dry_run: bool = Option(False, "--dry-run/--no-dry-run", help="Dry run: show what would be removed without making changes"), + issue_numbers: List[int] = Argument( + None, + help="Zero or more issue numbers to remove the label from (if empty and --repo is set, delete repo label)", + ), + repo: bool = Option( + False, + "--repo", + help="Delete the repo-level label when set and issue_numbers is empty", + ), + dry_run: bool = Option( + False, + "--dry-run/--no-dry-run", + help="Dry run: show what would be removed without making changes", + ), ) -> None: if dry_run: if issue_numbers: - logger.info(f"Dry run: would remove label '{label_name}' from {repo_name} issues: {issue_numbers}") + logger.info( + f"Dry run: would remove label '{label_name}' from {repo_name} issues: {issue_numbers}" + ) elif repo: - logger.info(f"Dry run: would delete repo label '{label_name}' from {repo_name}") + logger.info( + f"Dry run: would delete repo label '{label_name}' from {repo_name}" + ) else: - logger.info("Dry run: no action specified (provide issue numbers or --repo to delete repo label)") + logger.info( + "Dry run: no action specified (provide issue numbers or --repo to delete repo label)" + ) return - tea.pot.gitea.delete_label(repo_name, label_name, issue_numbers if issue_numbers else None, delete_repo_label=repo) + tea.pot.gitea.delete_label( + repo_name, + label_name, + issue_numbers if issue_numbers else None, + delete_repo_label=repo, + ) @app.command( @@ -299,18 +331,33 @@ def create_personal_channels_on_mm( help="update Mattermost channels for student groups based on team information on Gitea; only add missing members", ) def update_group_channels_on_mm( - prefix: str = Option("", help="Only process repositories starting with this prefix"), + prefix: str = Option( + "", help="Only process repositories starting with this prefix" + ), suffix: str = Option("", help="Only process channels ending with this suffix"), - update_teaching_team: bool = Option(True, "--update-teaching-team/--no-update-teaching-team", help="Whether to update teaching team"), - dry_run: bool = Option(False, "--dry-run/--no-dry-run", help="Dry run: show what would be added without making changes"), + update_teaching_team: bool = Option( + True, + "--update-teaching-team/--no-update-teaching-team", + help="Whether to update teaching team", + ), + dry_run: bool = Option( + False, + "--dry-run/--no-dry-run", + help="Dry run: show what would be added without making changes", + ), ) -> None: groups = { group_name: members for group_name, members in tea.pot.gitea.get_all_teams().items() if group_name.startswith(prefix) } - logger.info(f"{len(groups)} group channel(s) to update" + (f" with suffix {suffix}" if suffix else "")) - tea.pot.mattermost.update_channels_for_groups(groups, suffix, update_teaching_team, dry_run) + logger.info( + f"{len(groups)} group channel(s) to update" + + (f" with suffix {suffix}" if suffix else "") + ) + tea.pot.mattermost.update_channels_for_groups( + groups, suffix, update_teaching_team, dry_run + ) @app.command( @@ -318,10 +365,20 @@ def update_group_channels_on_mm( help="update personal Mattermost channels for every student; only add missing members", ) def update_personal_channels_on_mm( - update_teaching_team: bool = Option(True, "--update-teaching-team/--no-update-teaching-team", help="Whether to update teaching team"), - dry_run: bool = Option(False, "--dry-run/--no-dry-run", help="Dry run: show what would be added without making changes"), + update_teaching_team: bool = Option( + True, + "--update-teaching-team/--no-update-teaching-team", + help="Whether to update teaching team", + ), + dry_run: bool = Option( + False, + "--dry-run/--no-dry-run", + help="Dry run: show what would be added without making changes", + ), ) -> None: - tea.pot.mattermost.update_channels_for_individuals(tea.pot.canvas.students, update_teaching_team, dry_run) + tea.pot.mattermost.update_channels_for_individuals( + tea.pot.canvas.students, update_teaching_team, dry_run + ) @app.command( diff --git a/joint_teapot/workers/canvas.py b/joint_teapot/workers/canvas.py index 2ba1782..c7f88d4 100644 --- a/joint_teapot/workers/canvas.py +++ b/joint_teapot/workers/canvas.py @@ -40,12 +40,12 @@ class Canvas: re.sub(r"[^\x00-\x7F]+", "", student.name).strip().title() ) # We only care english name # Some users (like system users, announcers) might not have login_id - if hasattr(student, 'login_id') and student.login_id: + if hasattr(student, "login_id") and student.login_id: student.sis_id = student.login_id student.login_id = student.email.split("@")[0] else: # For users without login_id, use email prefix as both sis_id and login_id - if hasattr(student, 'email') and student.email: + if hasattr(student, "email") and student.email: student.login_id = student.email.split("@")[0] student.sis_id = student.login_id else: diff --git a/joint_teapot/workers/gitea.py b/joint_teapot/workers/gitea.py index 80cd550..70480ea 100644 --- a/joint_teapot/workers/gitea.py +++ b/joint_teapot/workers/gitea.py @@ -1,11 +1,11 @@ import re from enum import Enum from functools import lru_cache -from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, TypeVar +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast +from urllib.parse import quote import focs_gitea -import requests -from urllib.parse import quote +import requests # type: ignore from canvasapi.group import Group, GroupMembership from canvasapi.paginated_list import PaginatedList from canvasapi.user import User @@ -22,11 +22,13 @@ class PermissionEnum(Enum): admin = "admin" -T = TypeVar("T") +def list_all(method: Callable[..., Any], *args: Any, **kwargs: Any) -> List[Any]: + """Call paginated API methods repeatedly and collect results. - -def list_all(method: Callable[..., Iterable[T]], *args: Any, **kwargs: Any) -> List[T]: - all_res = [] + The exact return element types vary depending on the API client. We use + ``Any`` here to avoid over-constraining typing for the external client. + """ + all_res: List[Any] = [] page = 1 while True: res = method(*args, **kwargs, page=page) @@ -479,14 +481,21 @@ class Gitea: self.org_name, repo_name, issue.number, body={"state": "closed"} ) - def close_issues(self, repo_name: str, issue_numbers: List[int], dry_run: bool = True) -> None: + def close_issues( + self, repo_name: str, issue_numbers: List[int], dry_run: bool = True + ) -> None: if not issue_numbers: logger.warning("No issue numbers provided to close") return if dry_run: logger.info("Dry run enabled. No changes will be made to issues.") try: - issues = {issue.number: issue for issue in list_all(self.issue_api.issue_list_issues, self.org_name, repo_name)} + issues = { + issue.number: issue + for issue in list_all( + self.issue_api.issue_list_issues, self.org_name, repo_name + ) + } except ApiException as e: logger.error(f"Failed to list issues for {repo_name}: {e}") return @@ -503,7 +512,9 @@ class Gitea: if dry_run: logger.info(f"Would close issue #{num} in {repo_name} (dry run)") continue - self.issue_api.issue_edit_issue(self.org_name, repo_name, num, body={"state": "closed"}) + self.issue_api.issue_edit_issue( + self.org_name, repo_name, num, body={"state": "closed"} + ) logger.info(f"Closed issue #{num} in {repo_name}") except ApiException as e: logger.error(f"Failed to close issue #{num} in {repo_name}: {e}") @@ -579,17 +590,40 @@ class Gitea: self.create_milestone(repo_name, milestone, description, due_date) logger.info(f"Created milestone {milestone} in {repo_name}") - def label_issues(self, repo_name: str, label_name: str, issue_numbers: List[int], color: Optional[str] = None) -> None: + def label_issues( + self, + repo_name: str, + label_name: str, + issue_numbers: List[int], + color: Optional[str] = None, + ) -> None: if not issue_numbers: logger.warning("No issue numbers provided to label") return try: - labels = self.issue_api.issue_list_labels(self.org_name, repo_name) + labels = list( + cast( + Iterable[Any], + self.issue_api.issue_list_labels(self.org_name, repo_name), + ) + ) except ApiException as e: logger.error(f"Failed to list labels for {repo_name}: {e}") return + # Build a lookup for repo labels by name to read metadata like 'exclusive' + repo_label_name_to_obj: Dict[str, Any] = {} + try: + for l in labels: + name = getattr(l, "name", None) or ( + l.get("name") if isinstance(l, dict) else None + ) + if isinstance(name, str): + repo_label_name_to_obj[name] = l + except Exception: + repo_label_name_to_obj = {} + label_obj = None for l in labels: if getattr(l, "name", None) == label_name: @@ -600,40 +634,106 @@ class Gitea: chosen_color = None if color: c = color.strip() - if c.startswith('#'): + if c.startswith("#"): c = c[1:] if re.fullmatch(r"[0-9a-fA-F]{6}", c): chosen_color = c else: - logger.warning(f"Provided color '{color}' is not a valid 3- or 6-digit hex; falling back to default") + logger.warning( + f"Provided color '{color}' is not a valid 3- or 6-digit hex; falling back to default" + ) if chosen_color is None: chosen_color = "CCCCCC" try: + # Create the label and mark it as exclusive so subsequent labels added by this + # command are treated as exclusive by Gitea (if supported by the server) label_obj = self.issue_api.issue_create_label( self.org_name, repo_name, - body={"name": label_name, "color": chosen_color, "exclusive": False}, + body={"name": label_name, "color": chosen_color, "exclusive": True}, + ) + logger.info( + f"Created label '{label_name}' in {self.org_name}/{repo_name}" ) - logger.info(f"Created label '{label_name}' in {self.org_name}/{repo_name}") except ApiException as e: logger.error(f"Failed to create label {label_name} in {repo_name}: {e}") - if hasattr(e, 'body'): + if hasattr(e, "body"): logger.error(f"ApiException body: {getattr(e, 'body', None)}") return - label_id = getattr(label_obj, "id", None) - if label_id is None: + else: try: - label_id = label_obj.get("id") + existing_color = getattr(label_obj, "color", None) or ( + label_obj.get("color") if isinstance(label_obj, dict) else None + ) + if ( + existing_color + and isinstance(existing_color, str) + and existing_color.startswith("#") + ): + existing_color = existing_color[1:] + is_exclusive = getattr(label_obj, "exclusive", None) + if not is_exclusive: + enc_name = quote(label_name, safe="") + path = f"/repos/{self.org_name}/{repo_name}/labels/{enc_name}" + url = f"{self.api_client.configuration.host}{path}" + token = ( + getattr(self.api_client.configuration, "api_key", {}).get( + "access_token" + ) + or settings.gitea_access_token + ) + headers_local = { + "Authorization": f"token {token}", + "Content-Type": "application/json", + } + payload = {"exclusive": True, "name": label_name} + if existing_color: + payload["color"] = existing_color + try: + resp = requests.patch( + url, headers=headers_local, json=payload, timeout=10 + ) + if resp.status_code in (200, 201): + logger.info( + f"Marked existing label '{label_name}' as exclusive in {repo_name}" + ) + else: + logger.warning( + f"Failed to mark existing label '{label_name}' as exclusive: status={resp.status_code}, body={getattr(resp, 'text', None)}" + ) + except Exception as e: + logger.warning( + f"Error while trying to mark label '{label_name}' exclusive: {e}" + ) except Exception: - label_id = None + logger.debug( + f"Could not ensure exclusive attribute for label '{label_name}' (continuing)" + ) + + # Determine numeric id of the label in a type-safe manner + label_id = None + try: + if isinstance(label_obj, dict): + label_id = label_obj.get("id") + else: + label_id = getattr(label_obj, "id", None) + except Exception: + label_id = None if label_id is None: - logger.error(f"Unable to determine id of label '{label_name}' in {repo_name}") + logger.error( + f"Unable to determine id of label '{label_name}' in {repo_name}" + ) return try: - issues = {issue.number: issue for issue in list_all(self.issue_api.issue_list_issues, self.org_name, repo_name)} + issues = { + issue.number: issue + for issue in list_all( + self.issue_api.issue_list_issues, self.org_name, repo_name + ) + } except ApiException as e: logger.error(f"Failed to list issues for {repo_name}: {e}") return @@ -644,15 +744,20 @@ class Gitea: logger.warning(f"Issue #{num} not found in {repo_name}") continue - def _extract_label_names_from_issue(issue_obj) -> List[str]: + def _extract_label_names_from_issue(issue_obj: Any) -> List[str]: try: - return [getattr(l, "name", l) for l in getattr(issue_obj, "labels", []) or []] + return [ + getattr(l, "name", l) + for l in getattr(issue_obj, "labels", []) or [] + ] except Exception: return [] existing_label_names = _extract_label_names_from_issue(issue) if label_name in existing_label_names: - logger.info(f"Issue #{num} in {repo_name} already has label '{label_name}'") + logger.info( + f"Issue #{num} in {repo_name} already has label '{label_name}'" + ) continue existing_label_ids: List[int] = [] @@ -670,17 +775,32 @@ class Gitea: existing_label_ids = [] if label_id in existing_label_ids: - logger.info(f"Issue #{num} in {repo_name} already has label '{label_name}' (by id)") + logger.info( + f"Issue #{num} in {repo_name} already has label '{label_name}' (by id)" + ) continue # verification def _fetch_issue_labels_via_api() -> List[str]: try: - single = self.issue_api.issue_get_issue(self.org_name, repo_name, num) + single = self.issue_api.issue_get_issue( + self.org_name, repo_name, num + ) return _extract_label_names_from_issue(single) except Exception: try: - updated = next((i for i in list_all(self.issue_api.issue_list_issues, self.org_name, repo_name) if getattr(i, "number", None) == num), None) + updated = next( + ( + i + for i in list_all( + self.issue_api.issue_list_issues, + self.org_name, + repo_name, + ) + if getattr(i, "number", None) == num + ), + None, + ) if updated is None: return [] return _extract_label_names_from_issue(updated) @@ -690,42 +810,183 @@ class Gitea: issue_labels = _fetch_issue_labels_via_api() # prepare low-level HTTP helpers for fallbacks - def _do_post_labels(issue_num: int, payload) -> requests.Response: + def _do_post_labels(issue_num: int, payload: Any) -> Any: path = f"/repos/{self.org_name}/{repo_name}/issues/{issue_num}/labels" full_url = f"{self.api_client.configuration.host}{path}" - token = getattr(self.api_client.configuration, 'api_key', {}) .get('access_token') or settings.gitea_access_token + token = ( + getattr(self.api_client.configuration, "api_key", {}).get( + "access_token" + ) + or settings.gitea_access_token + ) headers_local = { "Authorization": f"token {token}", "Content-Type": "application/json", } - return requests.post(full_url, headers=headers_local, json=payload, timeout=10) + return requests.post( + full_url, headers=headers_local, json=payload, timeout=10 + ) # fallback: if label still not present, POST JSON object {"labels":[name]} to add-labels endpoint + # determine if the target label is marked exclusive (force to bool) + target_is_exclusive: bool = False + try: + target_label_obj = repo_label_name_to_obj.get(label_name) or label_obj + _tmp_any: Any = getattr(target_label_obj, "exclusive", None) or ( + target_label_obj.get("exclusive") + if isinstance(target_label_obj, dict) + else False + ) + target_is_exclusive = bool(_tmp_any) + except Exception: + target_is_exclusive = False + + if target_is_exclusive: + # find other exclusive labels present on this issue and remove them + other_exclusive_label_ids: List[int] = [] + try: + for lname in issue_labels or []: + if lname == label_name: + continue + lobj = repo_label_name_to_obj.get(lname) + is_ex = False + if lobj is not None: + is_ex = bool( + getattr(lobj, "exclusive", None) + or ( + lobj.get("exclusive") + if isinstance(lobj, dict) + else False + ) + ) + else: + # try to find by name in labels list + for ll in labels: + n = getattr(ll, "name", None) or ( + ll.get("name") if isinstance(ll, dict) else None + ) + if n == lname: + _tmp_any_ex: Any = getattr( + ll, "exclusive", None + ) or ( + ll.get("exclusive") + if isinstance(ll, dict) + else False + ) + is_ex = bool(_tmp_any_ex) + break + if is_ex: + # determine id + lid = None + try: + candidate = next( + ( + ll + for ll in labels + if ( + getattr(ll, "name", None) + or ( + ll.get("name") + if isinstance(ll, dict) + else None + ) + ) + == lname + ), + None, + ) + if candidate is not None: + lid = getattr(candidate, "id", None) or ( + candidate.get("id") + if isinstance(candidate, dict) + else None + ) + except Exception: + lid = None + if lid is not None: + other_exclusive_label_ids.append(lid) + except Exception: + other_exclusive_label_ids = [] + + # delete other exclusive labels by numeric id + token = ( + getattr(self.api_client.configuration, "api_key", {}).get( + "access_token" + ) + or settings.gitea_access_token + ) + headers_local = {"Authorization": f"token {token}"} + for other_lid in other_exclusive_label_ids: + try: + path_id = f"/repos/{self.org_name}/{repo_name}/issues/{num}/labels/{other_lid}" + url_id = f"{self.api_client.configuration.host}{path_id}" + resp = requests.delete( + url_id, headers=headers_local, timeout=10 + ) + if resp.status_code in (200, 204): + logger.info( + f"Removed exclusive label id {other_lid} from {repo_name}#{num}" + ) + else: + logger.warning( + f"Failed to remove exclusive label id {other_lid} from {repo_name}#{num}: status={resp.status_code}" + ) + except Exception as e: + logger.warning( + f"Error removing exclusive label id {other_lid} from {repo_name}#{num}: {e}" + ) + if label_name not in (issue_labels or []): try: resp = _do_post_labels(num, {"labels": [label_name]}) if resp.status_code not in (200, 201): - logger.error(f"Failed to add label via add-labels endpoint for issue #{num}: status={resp.status_code}") + logger.error( + f"Failed to add label via add-labels endpoint for issue #{num}: status={resp.status_code}" + ) except Exception as e: logger.error(f"Failed to POST object payload to issue #{num}: {e}") # final verification issue_labels = _fetch_issue_labels_via_api() if label_name in (issue_labels or []): - logger.info(f"Label '{label_name}' attached to issue #{num} in {repo_name}") + logger.info( + f"Label '{label_name}' attached to issue #{num} in {repo_name}" + ) else: - logger.warning(f"Label '{label_name}' not attached to issue #{num} in {repo_name} after attempts") + logger.warning( + f"Label '{label_name}' not attached to issue #{num} in {repo_name} after attempts" + ) - - def delete_label(self, repo_name: str, label_name: str, issue_numbers: Optional[List[int]] = None, delete_repo_label: bool = False) -> None: - token = getattr(self.api_client.configuration, 'api_key', {}) .get('access_token') or settings.gitea_access_token + def delete_label( + self, + repo_name: str, + label_name: str, + issue_numbers: Optional[List[int]] = None, + delete_repo_label: bool = False, + ) -> None: + token = ( + getattr(self.api_client.configuration, "api_key", {}).get("access_token") + or settings.gitea_access_token + ) headers_local = {"Authorization": f"token {token}"} + repo_labels: List[Any] = [] try: - repo_labels = self.issue_api.issue_list_labels(self.org_name, repo_name) - label_name_to_id: Dict[str, int] = { - (getattr(l, 'name', None) or (l.get('name') if isinstance(l, dict) else None)): (getattr(l, 'id', None) or (l.get('id') if isinstance(l, dict) else None)) - for l in repo_labels - } + repo_labels = list( + cast( + Iterable[Any], + self.issue_api.issue_list_labels(self.org_name, repo_name), + ) + ) + label_name_to_id: Dict[str, int] = {} + for l in repo_labels: + name = getattr(l, "name", None) or ( + l.get("name") if isinstance(l, dict) else None + ) + lid = getattr(l, "id", None) or ( + l.get("id") if isinstance(l, dict) else None + ) + if isinstance(name, str) and isinstance(lid, int): + label_name_to_id[name] = lid except Exception: label_name_to_id = {} @@ -733,45 +994,70 @@ class Gitea: lid = label_name_to_id.get(label_name) if lid is None: try: - for l in self.issue_api.issue_list_labels(self.org_name, repo_name): - name = getattr(l, 'name', None) or (l.get('name') if isinstance(l, dict) else None) + for l in repo_labels: + name = getattr(l, "name", None) or ( + l.get("name") if isinstance(l, dict) else None + ) if name == label_name: - lid = getattr(l, 'id', None) or (l.get('id') if isinstance(l, dict) else None) + lid = getattr(l, "id", None) or ( + l.get("id") if isinstance(l, dict) else None + ) break except Exception: pass if lid is None: - logger.warning(f"No numeric id found for label '{label_name}' in {repo_name}; skipping issue-level delete for issue #{issue_num}") + logger.warning( + f"No numeric id found for label '{label_name}' in {repo_name}; skipping issue-level delete for issue #{issue_num}" + ) return - path_id = f"/repos/{self.org_name}/{repo_name}/issues/{issue_num}/labels/{lid}" + path_id = ( + f"/repos/{self.org_name}/{repo_name}/issues/{issue_num}/labels/{lid}" + ) url_id = f"{self.api_client.configuration.host}{path_id}" try: resp = requests.delete(url_id, headers=headers_local, timeout=10) if resp.status_code in (200, 204): - logger.info(f"Removed label '{label_name}' from {repo_name}#{issue_num}") + logger.info( + f"Removed label '{label_name}' from {repo_name}#{issue_num}" + ) return - logger.warning(f"Numeric-id DELETE returned status {resp.status_code} for {repo_name}#{issue_num}: body={getattr(resp, 'text', None)}") + logger.warning( + f"Numeric-id DELETE returned status {resp.status_code} for {repo_name}#{issue_num}: body={getattr(resp, 'text', None)}" + ) except Exception as e: - logger.error(f"Numeric-id DELETE error for {repo_name}#{issue_num}: {e}") + logger.error( + f"Numeric-id DELETE error for {repo_name}#{issue_num}: {e}" + ) def _delete_repo_label() -> None: - enc_name = quote(label_name, safe='') + enc_name = quote(label_name, safe="") path = f"/repos/{self.org_name}/{repo_name}/labels/{enc_name}" url = f"{self.api_client.configuration.host}{path}" try: resp = requests.delete(url, headers=headers_local, timeout=10) if resp.status_code in (200, 204): - logger.info(f"Removed repo-level label '{label_name}' from {repo_name}") + logger.info( + f"Removed repo-level label '{label_name}' from {repo_name}" + ) return - logger.error(f"Failed to delete repo label '{label_name}' from {repo_name}: status={resp.status_code}, body={getattr(resp, 'text', None)}") + logger.error( + f"Failed to delete repo label '{label_name}' from {repo_name}: status={resp.status_code}, body={getattr(resp, 'text', None)}" + ) except Exception as e: - logger.error(f"Error deleting repo label '{label_name}' from {repo_name}: {e}") + logger.error( + f"Error deleting repo label '{label_name}' from {repo_name}: {e}" + ) if issue_numbers: try: - issues = {issue.number: issue for issue in list_all(self.issue_api.issue_list_issues, self.org_name, repo_name)} + issues = { + issue.number: issue + for issue in list_all( + self.issue_api.issue_list_issues, self.org_name, repo_name + ) + } except ApiException as e: logger.error(f"Failed to list issues for {repo_name}: {e}") return @@ -784,7 +1070,10 @@ class Gitea: if delete_repo_label: _delete_repo_label() else: - logger.warning("No issue numbers provided and --repo not set; nothing to do for delete_label") + logger.warning( + "No issue numbers provided and --repo not set; nothing to do for delete_label" + ) + if __name__ == "__main__": gitea = Gitea() diff --git a/joint_teapot/workers/mattermost.py b/joint_teapot/workers/mattermost.py index ebd2a23..3e0d49a 100644 --- a/joint_teapot/workers/mattermost.py +++ b/joint_teapot/workers/mattermost.py @@ -113,7 +113,9 @@ class Mattermost: for group_name, members in groups.items(): channel_name = group_name + suffix try: - channel = self.endpoint.channels.get_channel_by_name(self.team["id"], channel_name) + channel = self.endpoint.channels.get_channel_by_name( + self.team["id"], channel_name + ) logger.info(f"Channel {channel_name} exists, updating members") except Exception: # channel does not exist @@ -121,7 +123,9 @@ class Mattermost: info_members = list(members) if update_teaching_team: info_members = info_members + settings.mattermost_teaching_team - logger.info(f"Dry run: would create channel {channel_name} and add members: {info_members}") + logger.info( + f"Dry run: would create channel {channel_name} and add members: {info_members}" + ) continue try: channel = self.endpoint.channels.create_channel( @@ -141,7 +145,9 @@ class Mattermost: current_members = set() try: - mm_members = self.endpoint.channels.get_channel_members(channel["id"]) or [] + mm_members = ( + self.endpoint.channels.get_channel_members(channel["id"]) or [] + ) for m in mm_members: uname = None if isinstance(m, dict): @@ -169,22 +175,34 @@ class Mattermost: logger.debug(f"Member {member} already in channel {channel_name}") continue if dry_run: - logger.info(f"Dry run: would add member {member} to channel {channel_name}") + logger.info( + f"Dry run: would add member {member} to channel {channel_name}" + ) continue try: mmuser = self.endpoint.users.get_user_by_username(member) except Exception: - logger.warning(f"User {member} is not found on the Mattermost server") + logger.warning( + f"User {member} is not found on the Mattermost server" + ) self.endpoint.posts.create_post( - {"channel_id": channel["id"], "message": f"User {member} is not found on the Mattermost server"} + { + "channel_id": channel["id"], + "message": f"User {member} is not found on the Mattermost server", + } ) continue try: - self.endpoint.channels.add_user(channel["id"], {"user_id": mmuser["id"]}) + self.endpoint.channels.add_user( + channel["id"], {"user_id": mmuser["id"]} + ) except Exception: logger.warning(f"User {member} is not in the team") self.endpoint.posts.create_post( - {"channel_id": channel["id"], "message": f"User {member} is not in the team"} + { + "channel_id": channel["id"], + "message": f"User {member} is not in the team", + } ) logger.info(f"Added member {member} to channel {channel_name}") @@ -261,14 +279,18 @@ class Mattermost: display_name = student.name channel_name = student.sis_id try: - channel = self.endpoint.channels.get_channel_by_name(self.team["id"], channel_name) + channel = self.endpoint.channels.get_channel_by_name( + self.team["id"], channel_name + ) logger.info(f"Channel {channel_name} exists, updating members") except Exception: if dry_run: members_info = [student.login_id] if update_teaching_team: members_info = members_info + settings.mattermost_teaching_team - logger.info(f"Dry run: would create channel {display_name} ({channel_name}) and add members: {members_info}") + logger.info( + f"Dry run: would create channel {display_name} ({channel_name}) and add members: {members_info}" + ) continue try: channel = self.endpoint.channels.create_channel( @@ -279,7 +301,9 @@ class Mattermost: "type": "P", } ) - logger.info(f"Created channel {display_name} ({channel_name}) on Mattermost") + logger.info( + f"Created channel {display_name} ({channel_name}) on Mattermost" + ) except Exception as e: logger.warning( f"Error when creating channel {channel_name}: {e} Perhaps channel already exists?" @@ -288,7 +312,9 @@ class Mattermost: current_members = set() try: - mm_members = self.endpoint.channels.get_channel_members(channel["id"]) or [] + mm_members = ( + self.endpoint.channels.get_channel_members(channel["id"]) or [] + ) for m in mm_members: uname = None if isinstance(m, dict): @@ -316,22 +342,34 @@ class Mattermost: logger.debug(f"Member {member} already in channel {channel_name}") continue if dry_run: - logger.info(f"Dry run: would add member {member} to channel {channel_name}") + logger.info( + f"Dry run: would add member {member} to channel {channel_name}" + ) continue try: mmuser = self.endpoint.users.get_user_by_username(member) except Exception: - logger.warning(f"User {member} is not found on the Mattermost server") + logger.warning( + f"User {member} is not found on the Mattermost server" + ) self.endpoint.posts.create_post( - {"channel_id": channel["id"], "message": f"User {member} is not found on the Mattermost server"} + { + "channel_id": channel["id"], + "message": f"User {member} is not found on the Mattermost server", + } ) continue try: - self.endpoint.channels.add_user(channel["id"], {"user_id": mmuser["id"]}) + self.endpoint.channels.add_user( + channel["id"], {"user_id": mmuser["id"]} + ) except Exception: logger.warning(f"User {member} is not in the team") self.endpoint.posts.create_post( - {"channel_id": channel["id"], "message": f"User {member} is not in the team"} + { + "channel_id": channel["id"], + "message": f"User {member} is not in the team", + } ) logger.info(f"Added member {member} to channel {channel_name}") -- 2.30.2 From c0f7a75dc870ce4f1022cc0b9aacf73eeb648e73 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Fri, 19 Sep 2025 23:10:11 +0800 Subject: [PATCH 09/12] fix: cq --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6a4b250..32602b8 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ close one or more specific issues in a repository, with dry-run disabled by defa ### `update-group-channels-on-mm` -update Mattermost channels for student groups based on team information on Gitea. It will only add missing users, never delete anyone. +update group Mattermost channels for student groups based on team information on Gitea. It will only add missing users, never delete anyone. ### `update-personal-channels-on-mm` -- 2.30.2 From 7bddebe80687139a5c499f2a983169a0748558c3 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Sat, 20 Sep 2025 14:27:59 +0800 Subject: [PATCH 10/12] fix: cq --- .pre-commit-config.yaml | 3 ++- joint_teapot/workers/gitea.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1f6321e..8237763 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: hooks: - id: pyupgrade - repo: https://github.com/hadialqattan/pycln - rev: v2.4.0 + rev: v2.5.0 hooks: - id: pycln args: [-a] @@ -25,6 +25,7 @@ repos: rev: '1.7.5' hooks: - id: bandit + additional_dependencies: ['pbr'] - repo: https://github.com/PyCQA/isort rev: 5.12.0 hooks: diff --git a/joint_teapot/workers/gitea.py b/joint_teapot/workers/gitea.py index 70480ea..70dfb31 100644 --- a/joint_teapot/workers/gitea.py +++ b/joint_teapot/workers/gitea.py @@ -1004,7 +1004,9 @@ class Gitea: ) break except Exception: - pass + logger.warning( + f"Could not determine id of label '{label_name}' in {repo_name}" + ) if lid is None: logger.warning( -- 2.30.2 From f934d7978e1eb9972d4fd9e157ac9aa32631449c Mon Sep 17 00:00:00 2001 From: BoYanZh Date: Sat, 20 Sep 2025 20:29:11 -0700 Subject: [PATCH 11/12] chore: updgrade to latest hooks --- .pre-commit-config.yaml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8237763..4e73ee5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,19 +1,19 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v6.0.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - id: requirements-txt-fixer - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.4.1" + rev: "v1.18.2" hooks: - id: mypy additional_dependencies: - pydantic - repo: https://github.com/asottile/pyupgrade - rev: v3.9.0 + rev: v3.20.0 hooks: - id: pyupgrade - repo: https://github.com/hadialqattan/pycln @@ -22,21 +22,20 @@ repos: - id: pycln args: [-a] - repo: https://github.com/PyCQA/bandit - rev: '1.7.5' + rev: '1.8.6' hooks: - id: bandit - additional_dependencies: ['pbr'] - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 6.0.1 hooks: - id: isort args: ["--profile", "black", "--filter-files"] - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 25.9.0 hooks: - id: black - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.1 + rev: v1.5.5 hooks: - id: remove-crlf - id: remove-tabs -- 2.30.2 From 69f1552044b935c39adde17830758ba0ea241726 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Sun, 21 Sep 2025 23:20:34 +0800 Subject: [PATCH 12/12] refactor: label issues update and fix dry-run issues --- joint_teapot/app.py | 64 +--- joint_teapot/workers/gitea.py | 530 ++++++++++++++-------------------- 2 files changed, 226 insertions(+), 368 deletions(-) diff --git a/joint_teapot/app.py b/joint_teapot/app.py index 4ba1198..c04fc80 100644 --- a/joint_teapot/app.py +++ b/joint_teapot/app.py @@ -169,21 +169,18 @@ def close_all_issues() -> None: @app.command( "close-issues", - help="close specific issues in a repository: joint-teapot close-issues REPO_NAME ISSUE_NUMBER [ISSUE_NUMBER ...]", + help="close specific issues in a repository", ) def close_issues( repo_name: str, issue_numbers: List[int] = Argument(..., help="One or more issue numbers to close"), - dry_run: bool = Option( - False, "--dry-run/--no-dry-run", help="Enable dry run (no changes will be made)" - ), ) -> None: - tea.pot.gitea.close_issues(repo_name, issue_numbers, dry_run) + tea.pot.gitea.close_issues(repo_name, issue_numbers) @app.command( "label-issues", - help="add a label to specific issues in a repository: joint-teapot label-issues TAG_NAME REPO_NAME ISSUE_NUMBER [ISSUE_NUMBER ...]", + help="add a label to specific issues in a repository", ) def label_issues( label_name: str, @@ -192,61 +189,26 @@ def label_issues( issue_color: Optional[str] = Option( None, "--color", help="Color for newly created label (hex without # or with #)" ), - dry_run: bool = Option( - False, - "--dry-run/--no-dry-run", - help="Dry run: show what would be labeled without making changes", - ), ) -> None: - if dry_run: - logger.info( - f"Dry run: would add label '{label_name}' to {repo_name} issues: {issue_numbers}" - ) - return tea.pot.gitea.label_issues(repo_name, label_name, issue_numbers, color=issue_color) @app.command( "delete-label", - help="remove a label from specific issues or delete the repository label: joint-teapot delete-label TAG_NAME REPO_NAME [ISSUE_NUMBER ...]", + help="remove a label from specific issues or delete the repository label", ) def delete_label( label_name: str, repo_name: str, issue_numbers: List[int] = Argument( None, - help="Zero or more issue numbers to remove the label from (if empty and --repo is set, delete repo label)", - ), - repo: bool = Option( - False, - "--repo", - help="Delete the repo-level label when set and issue_numbers is empty", - ), - dry_run: bool = Option( - False, - "--dry-run/--no-dry-run", - help="Dry run: show what would be removed without making changes", + help="issue numbers to remove the label from", ), ) -> None: - if dry_run: - if issue_numbers: - logger.info( - f"Dry run: would remove label '{label_name}' from {repo_name} issues: {issue_numbers}" - ) - elif repo: - logger.info( - f"Dry run: would delete repo label '{label_name}' from {repo_name}" - ) - else: - logger.info( - "Dry run: no action specified (provide issue numbers or --repo to delete repo label)" - ) - return tea.pot.gitea.delete_label( repo_name, label_name, issue_numbers if issue_numbers else None, - delete_repo_label=repo, ) @@ -340,11 +302,6 @@ def update_group_channels_on_mm( "--update-teaching-team/--no-update-teaching-team", help="Whether to update teaching team", ), - dry_run: bool = Option( - False, - "--dry-run/--no-dry-run", - help="Dry run: show what would be added without making changes", - ), ) -> None: groups = { group_name: members @@ -355,9 +312,7 @@ def update_group_channels_on_mm( f"{len(groups)} group channel(s) to update" + (f" with suffix {suffix}" if suffix else "") ) - tea.pot.mattermost.update_channels_for_groups( - groups, suffix, update_teaching_team, dry_run - ) + tea.pot.mattermost.update_channels_for_groups(groups, suffix, update_teaching_team) @app.command( @@ -370,14 +325,9 @@ def update_personal_channels_on_mm( "--update-teaching-team/--no-update-teaching-team", help="Whether to update teaching team", ), - dry_run: bool = Option( - False, - "--dry-run/--no-dry-run", - help="Dry run: show what would be added without making changes", - ), ) -> None: tea.pot.mattermost.update_channels_for_individuals( - tea.pot.canvas.students, update_teaching_team, dry_run + tea.pot.canvas.students, update_teaching_team ) diff --git a/joint_teapot/workers/gitea.py b/joint_teapot/workers/gitea.py index 70dfb31..25fa919 100644 --- a/joint_teapot/workers/gitea.py +++ b/joint_teapot/workers/gitea.py @@ -40,6 +40,165 @@ def list_all(method: Callable[..., Any], *args: Any, **kwargs: Any) -> List[Any] return all_res +def _get_color(c: Optional[str]) -> str: + if not c: + return "CCCCCC" + s = c.strip() + if s.startswith("#"): + s = s[1:] + if re.fullmatch(r"[0-9a-fA-F]{6}", s): + return s + logger.warning(f"Provided color '{c}' is not a valid hex; falling back to #CCCCCC") + return "CCCCCC" + + +def _label_id_from_obj(obj: Any) -> Optional[int]: + try: + if isinstance(obj, dict): + return obj.get("id") + return getattr(obj, "id", None) + except Exception: + return None + + +def _get_repo_labels(gitea: Any, repo_name: str) -> List[Any]: + try: + return list( + cast( + Iterable[Any], + gitea.issue_api.issue_list_labels(gitea.org_name, repo_name), + ) + ) + except ApiException as e: + logger.error(f"Failed to list labels for {repo_name}: {e}") + return [] + + +def _list_issues_map(gitea: Any, repo_name: str) -> Dict[int, Any]: + try: + return { + issue.number: issue + for issue in list_all( + gitea.issue_api.issue_list_issues, gitea.org_name, repo_name + ) + } + except ApiException as e: + logger.error(f"Failed to list issues for {repo_name}: {e}") + return {} + + +def _api_post_labels(gitea: Any, repo_name: str, issue_num: int, payload: Any) -> Any: + path = f"/repos/{gitea.org_name}/{repo_name}/issues/{issue_num}/labels" + full_url = f"{gitea.api_client.configuration.host}{path}" + token = ( + getattr(gitea.api_client.configuration, "api_key", {}).get("access_token") + or settings.gitea_access_token + ) + headers_local = { + "Authorization": f"token {token}", + "Content-Type": "application/json", + } + return requests.post(full_url, headers=headers_local, json=payload, timeout=10) + + +def _patch_label_exclusive( + gitea: Any, repo_name: str, name: str, label_obj: Any +) -> None: + try: + existing_color = getattr(label_obj, "color", None) or ( + label_obj.get("color") if isinstance(label_obj, dict) else None + ) + if ( + existing_color + and isinstance(existing_color, str) + and existing_color.startswith("#") + ): + existing_color = existing_color[1:] + enc_name = quote(name, safe="") + path = f"/repos/{gitea.org_name}/{repo_name}/labels/{enc_name}" + url = f"{gitea.api_client.configuration.host}{path}" + token = ( + getattr(gitea.api_client.configuration, "api_key", {}).get("access_token") + or settings.gitea_access_token + ) + headers_local = { + "Authorization": f"token {token}", + "Content-Type": "application/json", + } + payload = {"exclusive": True, "name": name} + if existing_color: + payload["color"] = existing_color + resp = requests.patch(url, headers=headers_local, json=payload, timeout=10) + if resp.status_code in (200, 201): + logger.info(f"Marked existing label '{name}' as exclusive in {repo_name}") + else: + logger.warning( + f"Failed to mark existing label '{name}' as exclusive: status={resp.status_code}" + ) + except Exception as e: + logger.debug(f"Could not patch label exclusive for {name}: {e}") + + +def _delete_issue_label_by_id( + gitea: Any, repo_name: str, issue_num: int, lid: int +) -> None: + try: + path_id = f"/repos/{gitea.org_name}/{repo_name}/issues/{issue_num}/labels/{lid}" + url_id = f"{gitea.api_client.configuration.host}{path_id}" + token = ( + getattr(gitea.api_client.configuration, "api_key", {}).get("access_token") + or settings.gitea_access_token + ) + headers_local = {"Authorization": f"token {token}"} + resp = requests.delete(url_id, headers=headers_local, timeout=10) + if resp.status_code in (200, 204): + logger.info(f"Removed label id {lid} from {repo_name}#{issue_num}") + else: + logger.warning( + f"Failed to remove label id {lid} from {repo_name}#{issue_num}: status={resp.status_code}" + ) + except Exception as e: + logger.warning( + f"Error removing label id {lid} from {repo_name}#{issue_num}: {e}" + ) + + +def _create_label( + gitea: Any, repo_name: str, label_name: str, color: Optional[str], labels: List[Any] +) -> Optional[Any]: + for l in labels: + if getattr(l, "name", None) == label_name: + try: + is_ex = getattr(l, "exclusive", None) + if not bool(is_ex): + _patch_label_exclusive(gitea, repo_name, label_name, l) + except Exception: + return l + return l + + chosen_color = _get_color(color) + try: + new = gitea.issue_api.issue_create_label( + gitea.org_name, + repo_name, + body={"name": label_name, "color": chosen_color, "exclusive": True}, + ) + logger.info(f"Created label '{label_name}' in {gitea.org_name}/{repo_name}") + return new + except ApiException as e: + logger.error(f"Failed to create label {label_name} in {repo_name}: {e}") + if hasattr(e, "body"): + logger.error(f"ApiException body: {getattr(e, 'body', None)}") + return None + + +def _extract_label_names(issue_obj: Any) -> List[str]: + try: + return [getattr(l, "name", l) for l in getattr(issue_obj, "labels", []) or []] + except Exception: + return [] + + class Gitea: def __init__( self, @@ -482,7 +641,7 @@ class Gitea: ) def close_issues( - self, repo_name: str, issue_numbers: List[int], dry_run: bool = True + self, repo_name: str, issue_numbers: List[int], dry_run: bool = False ) -> None: if not issue_numbers: logger.warning("No issue numbers provided to close") @@ -601,354 +760,103 @@ class Gitea: logger.warning("No issue numbers provided to label") return - try: - labels = list( - cast( - Iterable[Any], - self.issue_api.issue_list_labels(self.org_name, repo_name), - ) - ) - except ApiException as e: - logger.error(f"Failed to list labels for {repo_name}: {e}") + labels = _get_repo_labels(self, repo_name) + if not labels: + logger.warning(f"No labels found for {repo_name}") return - # Build a lookup for repo labels by name to read metadata like 'exclusive' repo_label_name_to_obj: Dict[str, Any] = {} - try: - for l in labels: - name = getattr(l, "name", None) or ( - l.get("name") if isinstance(l, dict) else None - ) - if isinstance(name, str): - repo_label_name_to_obj[name] = l - except Exception: - repo_label_name_to_obj = {} - - label_obj = None for l in labels: - if getattr(l, "name", None) == label_name: - label_obj = l - break - - if not label_obj: - chosen_color = None - if color: - c = color.strip() - if c.startswith("#"): - c = c[1:] - if re.fullmatch(r"[0-9a-fA-F]{6}", c): - chosen_color = c - else: - logger.warning( - f"Provided color '{color}' is not a valid 3- or 6-digit hex; falling back to default" - ) - if chosen_color is None: - chosen_color = "CCCCCC" - try: - # Create the label and mark it as exclusive so subsequent labels added by this - # command are treated as exclusive by Gitea (if supported by the server) - label_obj = self.issue_api.issue_create_label( - self.org_name, - repo_name, - body={"name": label_name, "color": chosen_color, "exclusive": True}, - ) - logger.info( - f"Created label '{label_name}' in {self.org_name}/{repo_name}" - ) - except ApiException as e: - logger.error(f"Failed to create label {label_name} in {repo_name}: {e}") - if hasattr(e, "body"): - logger.error(f"ApiException body: {getattr(e, 'body', None)}") - return - - else: - try: - existing_color = getattr(label_obj, "color", None) or ( - label_obj.get("color") if isinstance(label_obj, dict) else None - ) - if ( - existing_color - and isinstance(existing_color, str) - and existing_color.startswith("#") - ): - existing_color = existing_color[1:] - is_exclusive = getattr(label_obj, "exclusive", None) - if not is_exclusive: - enc_name = quote(label_name, safe="") - path = f"/repos/{self.org_name}/{repo_name}/labels/{enc_name}" - url = f"{self.api_client.configuration.host}{path}" - token = ( - getattr(self.api_client.configuration, "api_key", {}).get( - "access_token" - ) - or settings.gitea_access_token - ) - headers_local = { - "Authorization": f"token {token}", - "Content-Type": "application/json", - } - payload = {"exclusive": True, "name": label_name} - if existing_color: - payload["color"] = existing_color - try: - resp = requests.patch( - url, headers=headers_local, json=payload, timeout=10 - ) - if resp.status_code in (200, 201): - logger.info( - f"Marked existing label '{label_name}' as exclusive in {repo_name}" - ) - else: - logger.warning( - f"Failed to mark existing label '{label_name}' as exclusive: status={resp.status_code}, body={getattr(resp, 'text', None)}" - ) - except Exception as e: - logger.warning( - f"Error while trying to mark label '{label_name}' exclusive: {e}" - ) - except Exception: - logger.debug( - f"Could not ensure exclusive attribute for label '{label_name}' (continuing)" - ) - - # Determine numeric id of the label in a type-safe manner - label_id = None - try: - if isinstance(label_obj, dict): - label_id = label_obj.get("id") - else: - label_id = getattr(label_obj, "id", None) - except Exception: - label_id = None - - if label_id is None: - logger.error( - f"Unable to determine id of label '{label_name}' in {repo_name}" + name = getattr(l, "name", None) or ( + l.get("name") if isinstance(l, dict) else None ) + if isinstance(name, str): + repo_label_name_to_obj[name] = l + + label_obj = _create_label(self, repo_name, label_name, color, labels) + if label_obj is None: + logger.error(f"Unable to ensure label '{label_name}' exists in {repo_name}") return - try: - issues = { - issue.number: issue - for issue in list_all( - self.issue_api.issue_list_issues, self.org_name, repo_name - ) - } - except ApiException as e: - logger.error(f"Failed to list issues for {repo_name}: {e}") + label_id = _label_id_from_obj(label_obj) + if label_id is None: + logger.error(f"Unable to find id of label '{label_name}' in {repo_name}") + return + issues_map = _list_issues_map(self, repo_name) + if not issues_map: return for num in issue_numbers: - issue = issues.get(num) + issue = issues_map.get(num) if issue is None: logger.warning(f"Issue #{num} not found in {repo_name}") continue - def _extract_label_names_from_issue(issue_obj: Any) -> List[str]: - try: - return [ - getattr(l, "name", l) - for l in getattr(issue_obj, "labels", []) or [] - ] - except Exception: - return [] - - existing_label_names = _extract_label_names_from_issue(issue) + existing_label_names = _extract_label_names(issue) if label_name in existing_label_names: logger.info( f"Issue #{num} in {repo_name} already has label '{label_name}'" ) continue - existing_label_ids: List[int] = [] try: - for l in getattr(issue, "labels", []) or []: - lid = getattr(l, "id", None) - if lid is None: - try: - lid = l.get("id") - except Exception: - lid = None - if lid is not None: - existing_label_ids.append(lid) + current = self.issue_api.issue_get_issue(self.org_name, repo_name, num) + issue_labels = _extract_label_names(current) except Exception: - existing_label_ids = [] + issue_labels = existing_label_names - if label_id in existing_label_ids: - logger.info( - f"Issue #{num} in {repo_name} already has label '{label_name}' (by id)" - ) - continue - - # verification - def _fetch_issue_labels_via_api() -> List[str]: - try: - single = self.issue_api.issue_get_issue( - self.org_name, repo_name, num - ) - return _extract_label_names_from_issue(single) - except Exception: - try: - updated = next( - ( - i - for i in list_all( - self.issue_api.issue_list_issues, - self.org_name, - repo_name, - ) - if getattr(i, "number", None) == num - ), - None, - ) - if updated is None: - return [] - return _extract_label_names_from_issue(updated) - except Exception: - return [] - - issue_labels = _fetch_issue_labels_via_api() - - # prepare low-level HTTP helpers for fallbacks - def _do_post_labels(issue_num: int, payload: Any) -> Any: - path = f"/repos/{self.org_name}/{repo_name}/issues/{issue_num}/labels" - full_url = f"{self.api_client.configuration.host}{path}" - token = ( - getattr(self.api_client.configuration, "api_key", {}).get( - "access_token" - ) - or settings.gitea_access_token - ) - headers_local = { - "Authorization": f"token {token}", - "Content-Type": "application/json", - } - return requests.post( - full_url, headers=headers_local, json=payload, timeout=10 - ) - - # fallback: if label still not present, POST JSON object {"labels":[name]} to add-labels endpoint - # determine if the target label is marked exclusive (force to bool) - target_is_exclusive: bool = False try: - target_label_obj = repo_label_name_to_obj.get(label_name) or label_obj - _tmp_any: Any = getattr(target_label_obj, "exclusive", None) or ( - target_label_obj.get("exclusive") - if isinstance(target_label_obj, dict) - else False + target_obj = repo_label_name_to_obj.get(label_name) or label_obj + target_is_exclusive = bool( + getattr(target_obj, "exclusive", None) + or ( + target_obj.get("exclusive") + if isinstance(target_obj, dict) + else False + ) ) - target_is_exclusive = bool(_tmp_any) except Exception: target_is_exclusive = False if target_is_exclusive: - # find other exclusive labels present on this issue and remove them - other_exclusive_label_ids: List[int] = [] - try: - for lname in issue_labels or []: - if lname == label_name: - continue - lobj = repo_label_name_to_obj.get(lname) - is_ex = False - if lobj is not None: - is_ex = bool( - getattr(lobj, "exclusive", None) - or ( - lobj.get("exclusive") - if isinstance(lobj, dict) - else False - ) + for lname in issue_labels or []: + if lname == label_name: + continue + lobj = repo_label_name_to_obj.get(lname) + is_ex = False + if lobj is not None: + is_ex = bool( + getattr(lobj, "exclusive", None) + or ( + lobj.get("exclusive") + if isinstance(lobj, dict) + else False ) - else: - # try to find by name in labels list - for ll in labels: - n = getattr(ll, "name", None) or ( - ll.get("name") if isinstance(ll, dict) else None - ) - if n == lname: - _tmp_any_ex: Any = getattr( - ll, "exclusive", None - ) or ( - ll.get("exclusive") - if isinstance(ll, dict) - else False - ) - is_ex = bool(_tmp_any_ex) - break - if is_ex: - # determine id - lid = None - try: - candidate = next( - ( - ll - for ll in labels - if ( - getattr(ll, "name", None) - or ( - ll.get("name") - if isinstance(ll, dict) - else None - ) - ) - == lname - ), - None, - ) - if candidate is not None: - lid = getattr(candidate, "id", None) or ( - candidate.get("id") - if isinstance(candidate, dict) - else None - ) - except Exception: - lid = None - if lid is not None: - other_exclusive_label_ids.append(lid) - except Exception: - other_exclusive_label_ids = [] - - # delete other exclusive labels by numeric id - token = ( - getattr(self.api_client.configuration, "api_key", {}).get( - "access_token" - ) - or settings.gitea_access_token - ) - headers_local = {"Authorization": f"token {token}"} - for other_lid in other_exclusive_label_ids: - try: - path_id = f"/repos/{self.org_name}/{repo_name}/issues/{num}/labels/{other_lid}" - url_id = f"{self.api_client.configuration.host}{path_id}" - resp = requests.delete( - url_id, headers=headers_local, timeout=10 - ) - if resp.status_code in (200, 204): - logger.info( - f"Removed exclusive label id {other_lid} from {repo_name}#{num}" - ) - else: - logger.warning( - f"Failed to remove exclusive label id {other_lid} from {repo_name}#{num}: status={resp.status_code}" - ) - except Exception as e: - logger.warning( - f"Error removing exclusive label id {other_lid} from {repo_name}#{num}: {e}" ) + if is_ex: + lid = _label_id_from_obj(lobj) if lobj is not None else None + if lid is not None: + _delete_issue_label_by_id(self, repo_name, num, lid) if label_name not in (issue_labels or []): try: - resp = _do_post_labels(num, {"labels": [label_name]}) - if resp.status_code not in (200, 201): + resp = _api_post_labels( + self, repo_name, num, {"labels": [label_name]} + ) + if getattr(resp, "status_code", None) not in (200, 201): logger.error( - f"Failed to add label via add-labels endpoint for issue #{num}: status={resp.status_code}" + f"Failed to add label via add-labels endpoint for issue #{num}: status={getattr(resp, 'status_code', None)}" ) except Exception as e: - logger.error(f"Failed to POST object payload to issue #{num}: {e}") + logger.error(f"Failed to POST labels to issue #{num}: {e}") - # final verification - issue_labels = _fetch_issue_labels_via_api() - if label_name in (issue_labels or []): + # verification + try: + final = self.issue_api.issue_get_issue(self.org_name, repo_name, num) + final_labels = _extract_label_names(final) + except Exception: + final_labels = [] + if label_name in (final_labels or []): logger.info( f"Label '{label_name}' attached to issue #{num} in {repo_name}" ) -- 2.30.2