|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +import os |
| 4 | +import re |
| 5 | +import argparse |
| 6 | +from typing import List |
| 7 | +import git |
| 8 | +from git import Repo |
| 9 | +import asana |
| 10 | +from dataclasses import dataclass |
| 11 | +import sys |
| 12 | +import subprocess |
| 13 | + |
| 14 | +@dataclass |
| 15 | +class AsanaTaskLink: |
| 16 | + url: str | None |
| 17 | + commit_hash: str |
| 18 | + |
| 19 | +def log(message: str) -> None: |
| 20 | + print(message, file=sys.stderr) |
| 21 | + |
| 22 | +def get_commits_between(repo_path: str, start_commit: str, end_commit: str) -> List[git.Commit]: |
| 23 | + """ |
| 24 | + Get a list of commits between two commit hashes in a git repository. |
| 25 | + """ |
| 26 | + repo = Repo(repo_path) |
| 27 | + |
| 28 | + # Get all commits between start (exclusive) and end (inclusive) |
| 29 | + commits = list(repo.iter_commits(f"{start_commit}..{end_commit}")) |
| 30 | + |
| 31 | + return commits |
| 32 | + |
| 33 | +def extract_asana_task_links(commits: List[git.Commit], url_prefix: str) -> List[AsanaTaskLink]: |
| 34 | + """ |
| 35 | + Extract Asana task links from commit messages. |
| 36 | + """ |
| 37 | + task_links = [] |
| 38 | + url_pattern = re.compile(rf"{re.escape(url_prefix)}\s*(https://app\.asana\.com/\S*)") |
| 39 | + |
| 40 | + for commit in commits: |
| 41 | + message = commit.message |
| 42 | + match = url_pattern.search(message) |
| 43 | + full_url = None |
| 44 | + if match: |
| 45 | + full_url = match.group(1) |
| 46 | + |
| 47 | + task_links.append(AsanaTaskLink( |
| 48 | + url=full_url, |
| 49 | + commit_hash=commit.hexsha, |
| 50 | + )) |
| 51 | + |
| 52 | + return task_links |
| 53 | + |
| 54 | +def extract_task_id_from_url(url: str) -> str: |
| 55 | + """ |
| 56 | + Extract task ID from various Asana URL formats. |
| 57 | + Examples: |
| 58 | + - https://app.asana.com/1/137249556945/project/1209107918776641/task/1210066941136479?focus=true |
| 59 | + - https://app.asana.com/1/137249556945/project/1209077031784564/task/1210067100873189 |
| 60 | + - https://app.asana.com/0/1208400340757517/1208902659709099 |
| 61 | + - https://app.asana.com/0/1208400340757517/1208902659709099/f |
| 62 | + """ |
| 63 | + # Remove any query parameters |
| 64 | + url = url.split('?')[0] |
| 65 | + |
| 66 | + # Split by slashes and get the last non-empty part |
| 67 | + parts = [p for p in url.split('/') if p] |
| 68 | + |
| 69 | + # The task ID is either: |
| 70 | + # - The last part before any query parameters (for /task/ format) |
| 71 | + # - The last part (for short format) |
| 72 | + task_id = parts[-1] |
| 73 | + |
| 74 | + # If the last part is 'f', get the previous part |
| 75 | + if task_id == 'f': |
| 76 | + task_id = parts[-2] |
| 77 | + |
| 78 | + return task_id |
| 79 | + |
| 80 | +def create_asana_task(client: asana.ApiClient, |
| 81 | + workspace_id: str, |
| 82 | + release_tag: str, |
| 83 | + task_links: List[AsanaTaskLink], |
| 84 | + section_id: str) -> str: |
| 85 | + """ |
| 86 | + Create a new Asana task with the list of task links in its description. |
| 87 | + """ |
| 88 | + # Format the description with all task links |
| 89 | + description = "<body>" |
| 90 | + description += "<h2>Included Tasks</h2>" |
| 91 | + for link in task_links: |
| 92 | + if link.url: |
| 93 | + task_link = f"<a data-asana-gid=\"{extract_task_id_from_url(link.url)}\"/>" |
| 94 | + else: |
| 95 | + task_link = "no task" |
| 96 | + commit_url = f"https://github.com/duckduckgo/Android/commit/{link.commit_hash}" |
| 97 | + description += f"- {task_link} - <a href=\"{commit_url}\">{link.commit_hash[:9]}</a>\n" |
| 98 | + |
| 99 | + description += "</body>" |
| 100 | + |
| 101 | + tasks_api = asana.TasksApi(client) |
| 102 | + section_api = asana.SectionsApi(client) |
| 103 | + |
| 104 | + # Create the task |
| 105 | + task = tasks_api.create_task( |
| 106 | + { |
| 107 | + "data": { |
| 108 | + "name": f"Android Nightly Release {release_tag}", |
| 109 | + "html_notes": description, |
| 110 | + "workspace": workspace_id |
| 111 | + } |
| 112 | + }, |
| 113 | + {} |
| 114 | + ) |
| 115 | + |
| 116 | + # Add the task to the project and optionally to a section |
| 117 | + section_api.add_task_for_section( |
| 118 | + section_id, |
| 119 | + { |
| 120 | + "body": { |
| 121 | + "data": { |
| 122 | + "task": task['gid'], |
| 123 | + } |
| 124 | + } |
| 125 | + } |
| 126 | + ) |
| 127 | + |
| 128 | + return task['gid'] |
| 129 | + |
| 130 | +def get_latest_nightly_tag_before_commit(repo_path: str, current_tag: str) -> str | None: |
| 131 | + """ |
| 132 | + Return the previous *nightly* tag before `current_tag`, sorted by creation date (not version number). |
| 133 | + """ |
| 134 | + nightly_pattern = re.compile(r'^\d+\.\d+\.\d+(?:\.\d+)?-nightly$') |
| 135 | + |
| 136 | + try: |
| 137 | + result = subprocess.run( |
| 138 | + ["git", "-C", repo_path, "tag", "--sort=creatordate"], |
| 139 | + capture_output=True, |
| 140 | + text=True, |
| 141 | + check=True |
| 142 | + ) |
| 143 | + tags = result.stdout.strip().splitlines() |
| 144 | + |
| 145 | + # Filter to only nightly tags |
| 146 | + nightly_tags = [tag for tag in tags if nightly_pattern.match(tag)] |
| 147 | + |
| 148 | + if current_tag not in nightly_tags: |
| 149 | + return None |
| 150 | + |
| 151 | + idx = nightly_tags.index(current_tag) |
| 152 | + return nightly_tags[idx - 1] if idx > 0 else None |
| 153 | + except subprocess.CalledProcessError: |
| 154 | + return None |
| 155 | + |
| 156 | +def main(): |
| 157 | + parser = argparse.ArgumentParser(description='Create an Asana task with links to tasks from git commits') |
| 158 | + parser.add_argument('--tag', required=True, help='Tag to use as end commit') # Example: v0.44.0 |
| 159 | + parser.add_argument('--android-repo-path', default='.', help='Path to Android git repository (default: current directory)') |
| 160 | + parser.add_argument('--trigger-phrase', required=True, help='Prefix for Asana task URLs in commit messages') |
| 161 | + parser.add_argument('--asana-project-id', required=True, help='Asana project ID') |
| 162 | + parser.add_argument('--asana-section-id', required=True, help='Asana section ID to place the task in') |
| 163 | + parser.add_argument('--asana-workspace-id', required=True, help='Asana workspace ID') |
| 164 | + parser.add_argument('--asana-api-key-env-var', required=True, help='Environment variable name containing the API key') |
| 165 | + |
| 166 | + args = parser.parse_args() |
| 167 | + |
| 168 | + try: |
| 169 | + # Get environment variables |
| 170 | + asana_api_key = os.getenv(args.asana_api_key_env_var) |
| 171 | + |
| 172 | + # Validate environment variables |
| 173 | + if not asana_api_key: |
| 174 | + log("Error: Missing required environment variable") |
| 175 | + log(f"Please set {args.asana_api_key_env_var}") |
| 176 | + return 1 |
| 177 | + |
| 178 | + configuration = asana.Configuration() |
| 179 | + configuration.access_token = asana_api_key |
| 180 | + client = asana.ApiClient(configuration) |
| 181 | + |
| 182 | + # Get the start tag (latest tag before the specified tag) |
| 183 | + # start_tag = get_latest_tag_before_commit(args.android_repo_path, args.tag) |
| 184 | + start_tag = get_latest_nightly_tag_before_commit(args.android_repo_path, args.tag) |
| 185 | + if not start_tag: |
| 186 | + log(f"Error: No previous version tag found before {args.tag}") |
| 187 | + return 1 |
| 188 | + |
| 189 | + log(f"Using tag {start_tag} as start commit") |
| 190 | + log(f"Using tag {args.tag} as end commit") |
| 191 | + |
| 192 | + # Get commits between the tags |
| 193 | + commits = get_commits_between(args.android_repo_path, start_tag, args.tag) |
| 194 | + |
| 195 | + log(f"Extracting task links from {len(commits)} commits") |
| 196 | + # Extract Asana task links from commit messages |
| 197 | + task_links = extract_asana_task_links(commits, args.trigger_phrase) |
| 198 | + |
| 199 | + # Create the Asana task with the tag name |
| 200 | + task_id = create_asana_task(client, args.asana_workspace_id, args.tag, task_links, args.asana_section_id) |
| 201 | + task_url = f"https://app.asana.com/1/{args.asana_workspace_id}/project/{args.asana_project_id}/task/{task_id}" |
| 202 | + print(task_url) # Only the URL is ever printed to stdout |
| 203 | + |
| 204 | + return 0 |
| 205 | + |
| 206 | + except Exception as e: |
| 207 | + import traceback |
| 208 | + log(f"Unexpected error: {e}") |
| 209 | + log(f"Stack trace:\n{traceback.format_exc()}") |
| 210 | + return 1 |
| 211 | + |
| 212 | +if __name__ == "__main__": |
| 213 | + exit(main()) |
0 commit comments