1

Re-implement gitlab sync

This commit is contained in:
Evan Pratten 2024-03-22 15:17:16 -04:00
parent 79de920a4b
commit 5e357e2255

View File

@ -1,170 +1,36 @@
#! /usr/bin/env python3 #! /usr/bin/env python3
# NOTE: This script might be called by systemd. Run `systemctl --user list-timers --all` to see if it is active
# fmt:off
import sys
import os
from pathlib import Path
sys.path.append((Path(os.environ["EWCONFIG_ROOT"]) / "python_modules").as_posix())
# fmt:on
import argparse import argparse
import sys import sys
import logging import logging
import requests import requests
import json import subprocess
from dataclasses import dataclass, field from textwrap import dedent
from pathlib import Path from pathlib import Path
from typing import List, Optional, Dict, Any
from enum import Enum, auto try:
from datetime import datetime import gitlab
from ewconfig.secret_manager import get_semi_secret_string except ImportError:
from ewconfig.trello import TRELLO_API_KEY, get_trello_api_token print("Please install the 'python-gitlab' package from pip", file=sys.stderr)
from ewconfig.trello.cards import get_all_trello_cards, create_card sys.exit(1)
from ewconfig.trello.boards import PERSONAL_TASKS_BOARD
TRELLO_KEY = "fba640a85f15c91e93e6b3f88e59489c"
TRELLO_BOARD_ID = "tw3Cn3L6"
TRELLO_LIST_ID = "6593166e9dd338621ed6848d"
TRELLO_TAGS = {
"company": "64e03ac77d27032282436d28", # Tag used to sort by company
"waiting_to_merge": "65524315edf2d2edb0cc5d09", # Tag used to indicate a MR is waiting to merge
"draft": "65fdd81c83e5d6e00f1b9721", # Tag used to indicate a draft MR
}
GITLAB_ENDPOINT = "http://gitlab.guru-domain.gurustudio.com"
MY_ID = 64
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
GITLAB_PAT = get_semi_secret_string("guru_gitlab_pat", namespace="trello-sync")
GITLAB_ENDPOINT = "http://gitlab.guru-domain.gurustudio.com/api/v4"
MY_USER_ID = 64
TRELLO_API_TOKEN = get_trello_api_token()
TrelloCardId = str
class IssueState(Enum):
OPEN = "opened"
CLOSED = "closed"
@dataclass
class GitLabIssue:
title: str
issue_id: int
global_id: int
kind: str
state: IssueState
created: datetime
updated: datetime
web_url: str
reference_string: str
due_date: Optional[datetime] = None
def get_fmt_id(self) -> str:
if self.kind == "merge_request":
return f"!{self.global_id}"
return f"#{self.global_id}"
def list_contains_this(self, list_of_ids: List[str]) -> bool:
if self.kind == "issue" and self.global_id in list_of_ids:
return True
return self.get_fmt_id() in [str(x) for x in list_of_ids]
def get_personal_gitlab_issues(user_id: int = MY_USER_ID) -> List[GitLabIssue]:
# Make an API call
issues = []
response = requests.get(
f"{GITLAB_ENDPOINT}/issues",
params={
"assignee_id": user_id,
"private_token": GITLAB_PAT,
"per_page": 100,
"scope": "all",
},
)
response.raise_for_status()
issues.extend(response.json())
response = requests.get(
f"{GITLAB_ENDPOINT}/merge_requests",
params={
"assignee_id": user_id,
"private_token": GITLAB_PAT,
"per_page": 100,
"scope": "all",
"state": "opened",
},
)
response.raise_for_status()
issues.extend(response.json())
# Parse the response
output = []
for issue in issues:
output.append(
GitLabIssue(
title=issue["title"],
issue_id=issue["iid"],
global_id=issue["id"],
kind=issue.get("type", "merge_request").lower(),
state=IssueState(issue["state"]),
created=datetime.fromisoformat(issue["created_at"]),
updated=datetime.fromisoformat(issue["updated_at"]),
web_url=issue["web_url"],
reference_string=issue["references"]["full"],
due_date=datetime.fromisoformat(issue["due_date"])
if issue.get("due_date")
else None,
)
)
return output
def find_or_create_trello_issue_for(
trello_cards: List[Dict[str, Any]], gitlab_issue: GitLabIssue, dry_run: bool = False
) -> TrelloCardId:
# Look for a card that matches the issue
for card in trello_cards:
# Check the first line of the description for metadata
description = card["desc"]
desc_first_line = description.split("\n")[0]
if not desc_first_line.startswith("**Sync Metadata:** "):
continue
# Parse the metadata
metadata = json.loads(desc_first_line.split("`")[1])
# Check if the card matches
if metadata.get("ns") == "guru-gitlab" and gitlab_issue.list_contains_this(
metadata.get("ids", [])
):
print(card["labels"], card["idLabels"])
logger.info(f"Found matching card {card['id']}")
return card["id"]
# Build the description
issue_kind = " ".join([part.capitalize() for part in gitlab_issue.kind.split("_")])
card_description = "\n\n".join(
[
f"**Sync Metadata:** `{json.dumps({'ns': 'guru-gitlab', 'ids': [gitlab_issue.get_fmt_id()]})}`",
f"**GitLab {issue_kind}:** [`{gitlab_issue.reference_string}`]({gitlab_issue.web_url})\n",
"---",
]
)
# Make a new card
if not dry_run:
return create_card(
list_id=PERSONAL_TASKS_BOARD.lists["To Do"],
name=gitlab_issue.title,
description=card_description,
label_ids=[PERSONAL_TASKS_BOARD.tags["GURU"]],
position="top",
api_key=TRELLO_API_KEY,
api_token=TRELLO_API_TOKEN,
)
else:
return "dry-run"
def main() -> int: def main() -> int:
# Handle program arguments # Handle program arguments
ap = argparse.ArgumentParser(description="Syncs issues from GitLab to Trello") ap = argparse.ArgumentParser(
ap.add_argument( prog="guru-sync-issues", description="Sync issues from GitLab to Trello"
"--dry-run", help="Don't actually make any changes", action="store_true"
) )
ap.add_argument( ap.add_argument(
"-v", "--verbose", help="Enable verbose logging", action="store_true" "-v", "--verbose", help="Enable verbose logging", action="store_true"
@ -177,32 +43,194 @@ def main() -> int:
format="%(levelname)s: %(message)s", format="%(levelname)s: %(message)s",
) )
# Call `ewp-secrets` to obtain the GitLab token
secrets_proc = subprocess.run(
[
(Path(__file__).parent / "ewp-secrets").as_posix(),
"load",
"-n",
"gurustudio",
"-k",
"gitlab-pat",
],
capture_output=True,
)
# If the secret manager failed, exit
if secrets_proc.returncode != 0:
print("Failed to load GitLab PAT", file=sys.stderr)
print(
"Please run `ewp-secrets store -n gurustudio -k gitlab-pat` to set a token",
file=sys.stderr,
)
return 1
# Extract the GitLab token
gitlab_token = secrets_proc.stdout.decode().strip()
# Call `ewp-secrets` to obtain the Trello API token
secrets_proc = subprocess.run(
[
(Path(__file__).parent / "ewp-secrets").as_posix(),
"load",
"-n",
"ewpratten",
"-k",
"trello-api-token",
],
capture_output=True,
)
# If the secret manager failed, exit
if secrets_proc.returncode != 0:
print("Failed to load Trello API token", file=sys.stderr)
print(
"Please run `ewp-secrets store -n ewpratten -k trello-api-token` to set a token",
file=sys.stderr,
)
return 1
# Extract the Trello API token
trello_api_token = secrets_proc.stdout.decode().strip()
# Try to ping the gitlab server, and exit if it fails # Try to ping the gitlab server, and exit if it fails
try: try:
requests.get(GITLAB_ENDPOINT) logger.debug("Attempting to connect to GitLab server")
requests.get(GITLAB_ENDPOINT, timeout=2)
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
logger.error("Could not connect to GitLab server") logger.error("Could not connect to GitLab server")
return 1 return 1
# Find all issues # Authenticate with GitLab
issues = get_personal_gitlab_issues() gitlab_client = gitlab.Gitlab(
logger.info(f"Found {len(issues)} issues") GITLAB_ENDPOINT, private_token=gitlab_token, user_agent="guru-sync-issues"
# Get a list of cards on the board
trello_cards = get_all_trello_cards(
board_id=PERSONAL_TASKS_BOARD.id,
api_key=TRELLO_API_KEY,
api_token=TRELLO_API_TOKEN,
) )
logger.info(f"Found {len(trello_cards)} cards on the board")
# Handle each issue # Access the main group
for issue in issues: pp_group = gitlab_client.groups.get("pipeline-planning")
# Find the trello card id for this issue
trello_card_id = find_or_create_trello_issue_for( # Find all open issues I'm assigned to
trello_cards, issue, dry_run=args.dry_run open_issues = []
open_issues.extend(
gitlab_client.issues.list(state="opened", get_all=True, assignee_id=MY_ID)
)
open_issues.extend(
pp_group.issues.list(state="opened", get_all=True, assignee_id=MY_ID)
)
# De-dupe the issues
open_issues = list(set(open_issues))
# Find all open MRs I'm assigned to
open_mrs = []
open_mrs.extend(
gitlab_client.mergerequests.list(
state="opened", get_all=True, assignee_id=MY_ID
) )
logger.info(f"GitLab Issue {issue.global_id} is Trello Card {trello_card_id}") )
open_mrs.extend(
pp_group.mergerequests.list(state="opened", get_all=True, assignee_id=MY_ID)
)
# De-dupe the MRs
open_mrs = list(set(open_mrs))
# Log findings
logger.info(f"Found {len(open_issues)} open issues")
logger.info(f"Found {len(open_mrs)} open merge requests")
# Get all cards in Trello
response = requests.get(
f"https://api.trello.com/1/boards/{TRELLO_BOARD_ID}/cards",
params={
"key": TRELLO_KEY,
"token": trello_api_token,
},
)
response.raise_for_status()
trello_cards = response.json()
# Iterate over each GitLab actionable
actionables = [("issue", issue) for issue in open_issues] + [
("merge request", mr) for mr in open_mrs
]
for ty, issue in actionables:
# Get the URL of the issue
issue_url = issue.web_url
# Check if there is a card that references this URL
card = next((c for c in trello_cards if issue_url in c["desc"]), None)
# If none exists, make a new card
if not card:
logger.info(f"Creating card for issue: {issue.title}")
# Build params
card_params = {
"idList": TRELLO_TAGS["company"],
"name": issue.title,
"key": TRELLO_KEY,
"token": trello_api_token,
"pos": "top",
"desc": dedent(
f"""
## Linked GitLab Issues
- {issue_url}
---
"""
),
"idLabels": TRELLO_TAG_ID,
}
# Make the card
response = requests.post(
"https://api.trello.com/1/cards",
params=card_params,
)
response.raise_for_status()
# Capture the card for later
card = response.json()
logger.info(f"Created card: {card['id']}")
# Apply lables to MRs
if ty == "merge request":
if issue.title.startswith("Draft:") or issue.title.startswith("WIP:"):
# Check if the card already has the 'Draft' label
if any(
label["id"] == TRELLO_TAGS["draft"] for label in card["labels"]
):
continue
logger.info(f"Adding 'Draft' label to card {card['id']}")
response = requests.post(
f"https://api.trello.com/1/cards/{card['id']}/idLabels",
params={
"key": TRELLO_KEY,
"token": trello_api_token,
"value": TRELLO_TAGS["draft"],
},
)
response.raise_for_status()
else:
# Check if the card already has the 'Waiting to Merge' label
if any(
label["id"] == TRELLO_TAGS["waiting_to_merge"] for label in card["labels"]
):
continue
logger.info(f"Adding 'Waiting to Merge' label to card {card['id']}")
response = requests.post(
f"https://api.trello.com/1/cards/{card['id']}/idLabels",
params={
"key": TRELLO_KEY,
"token": trello_api_token,
"value": TRELLO_TAGS["waiting_to_merge"],
},
)
response.raise_for_status()
return 0 return 0