misc.python.materialize.linear
Linear utilities.
1# Copyright Materialize, Inc. and contributors. All rights reserved. 2# 3# Use of this software is governed by the Business Source License 4# included in the LICENSE file at the root of this repository. 5# 6# As of the Change Date specified in that file, in accordance with 7# the Business Source License, use of this software will be governed 8# by the Apache License, Version 2.0. 9 10"""Linear utilities.""" 11 12import os 13import re 14from typing import Any 15 16import requests 17 18from materialize.github import ( 19 CI_APPLY_TO, 20 CI_IGNORE_FAILURE, 21 CI_LOCATION, 22 CI_RE, 23 GitHubIssueWithInvalidRegexp, 24 KnownGitHubIssue, 25) 26 27LINEAR_CLOSED_STATE_TYPES = {"completed", "canceled"} 28 29 30def _search_issues_graphql(token: str) -> list[dict[str, Any]]: 31 query = """ 32 query($term: String!, $cursor: String) { 33 searchIssues( 34 term: $term 35 first: 100 36 after: $cursor 37 includeArchived: false 38 ) { 39 nodes { 40 identifier 41 title 42 description 43 url 44 state { 45 type 46 } 47 } 48 pageInfo { 49 hasNextPage 50 endCursor 51 } 52 } 53 } 54 """ 55 56 all_issues: list[dict[str, Any]] = [] 57 cursor = None 58 59 while True: 60 variables: dict[str, Any] = {"term": "ci-regexp"} 61 if cursor: 62 variables["cursor"] = cursor 63 64 response = requests.post( 65 "https://api.linear.app/graphql", 66 headers={ 67 "Authorization": token, 68 "Content-Type": "application/json", 69 }, 70 json={"query": query, "variables": variables}, 71 ) 72 73 if response.status_code != 200: 74 raise ValueError( 75 f"Bad return code from Linear GraphQL: {response.status_code}, " 76 f"response={response.text[:500]}, " 77 f"has_token=True" 78 ) 79 80 result = response.json() 81 if "errors" in result: 82 raise ValueError(f"Linear GraphQL errors: {result['errors']}") 83 84 search_data = result["data"]["searchIssues"] 85 for node in search_data["nodes"]: 86 if node is None: 87 continue 88 description = node.get("description") or "" 89 if "ci-regexp:" in description: 90 all_issues.append(node) 91 92 if not search_data["pageInfo"]["hasNextPage"]: 93 break 94 cursor = search_data["pageInfo"]["endCursor"] 95 96 return all_issues 97 98 99def get_known_issues_from_linear( 100 token: str | None = os.getenv("LINEAR_READ_ONLY_TOKEN"), 101) -> tuple[list[KnownGitHubIssue], list[GitHubIssueWithInvalidRegexp]]: 102 if not token: 103 return ([], []) 104 105 issues = _search_issues_graphql(token) 106 107 known_issues = [] 108 issues_with_invalid_regex = [] 109 110 for issue in issues: 111 body = issue.get("description") or "" 112 113 state_type = issue.get("state", {}).get("type", "") 114 state = "CLOSED" if state_type in LINEAR_CLOSED_STATE_TYPES else "OPEN" 115 116 info = { 117 "number": issue["identifier"], 118 "title": issue["title"], 119 "body": body, 120 "url": issue["url"], 121 "state": state, 122 "source": "linear", 123 } 124 125 matches = CI_RE.findall(body) 126 matches_apply_to = CI_APPLY_TO.findall(body) 127 matches_location = CI_LOCATION.findall(body) 128 matches_ignore_failure = CI_IGNORE_FAILURE.findall(body) 129 130 if len(matches) > 1: 131 issues_with_invalid_regex.append( 132 GitHubIssueWithInvalidRegexp( 133 internal_error_type="LINEAR_INVALID_REGEXP", 134 issue_url=issue["url"], 135 issue_title=issue["title"], 136 issue_number=issue["identifier"], 137 regex_pattern=f"Multiple regexes, but only one supported: {[match.strip() for match in matches]}", 138 ) 139 ) 140 continue 141 142 if len(matches_ignore_failure) > 1: 143 issues_with_invalid_regex.append( 144 GitHubIssueWithInvalidRegexp( 145 internal_error_type="LINEAR_INVALID_IGNORE_FAILURE", 146 issue_url=issue["url"], 147 issue_title=issue["title"], 148 issue_number=issue["identifier"], 149 regex_pattern=f"Multiple ci-ignore-failures, but only one supported: {[match.strip() for match in matches_ignore_failure]}", 150 ) 151 ) 152 continue 153 154 if len(matches) == 0: 155 continue 156 157 if len(matches_location) >= 2: 158 issues_with_invalid_regex.append( 159 GitHubIssueWithInvalidRegexp( 160 internal_error_type="LINEAR_INVALID_LOCATION", 161 issue_url=issue["url"], 162 issue_title=issue["title"], 163 issue_number=issue["identifier"], 164 regex_pattern=f"Multiple ci-locations, but only one supported: {[match.strip() for match in matches_location]}", 165 ) 166 ) 167 continue 168 169 location: str | None = ( 170 matches_location[0] if len(matches_location) == 1 else None 171 ) 172 173 ignore_failure = len(matches_ignore_failure) == 1 and matches_ignore_failure[ 174 0 175 ].strip() in ("true", "yes", "1") 176 177 try: 178 regex_pattern = re.compile(matches[0].strip().encode()) 179 except: 180 issues_with_invalid_regex.append( 181 GitHubIssueWithInvalidRegexp( 182 internal_error_type="LINEAR_INVALID_REGEXP", 183 issue_url=issue["url"], 184 issue_title=issue["title"], 185 issue_number=issue["identifier"], 186 regex_pattern=matches[0].strip(), 187 ) 188 ) 189 continue 190 191 if matches_apply_to: 192 for match_apply_to in matches_apply_to: 193 known_issues.append( 194 KnownGitHubIssue( 195 regex_pattern, 196 match_apply_to.strip().lower(), 197 info, 198 ignore_failure, 199 location, 200 ) 201 ) 202 else: 203 known_issues.append( 204 KnownGitHubIssue(regex_pattern, None, info, ignore_failure, location) 205 ) 206 207 return (known_issues, issues_with_invalid_regex)
LINEAR_CLOSED_STATE_TYPES =
{'canceled', 'completed'}
def
get_known_issues_from_linear( token: str | None = None) -> tuple[list[materialize.github.KnownGitHubIssue], list[materialize.github.GitHubIssueWithInvalidRegexp]]:
100def get_known_issues_from_linear( 101 token: str | None = os.getenv("LINEAR_READ_ONLY_TOKEN"), 102) -> tuple[list[KnownGitHubIssue], list[GitHubIssueWithInvalidRegexp]]: 103 if not token: 104 return ([], []) 105 106 issues = _search_issues_graphql(token) 107 108 known_issues = [] 109 issues_with_invalid_regex = [] 110 111 for issue in issues: 112 body = issue.get("description") or "" 113 114 state_type = issue.get("state", {}).get("type", "") 115 state = "CLOSED" if state_type in LINEAR_CLOSED_STATE_TYPES else "OPEN" 116 117 info = { 118 "number": issue["identifier"], 119 "title": issue["title"], 120 "body": body, 121 "url": issue["url"], 122 "state": state, 123 "source": "linear", 124 } 125 126 matches = CI_RE.findall(body) 127 matches_apply_to = CI_APPLY_TO.findall(body) 128 matches_location = CI_LOCATION.findall(body) 129 matches_ignore_failure = CI_IGNORE_FAILURE.findall(body) 130 131 if len(matches) > 1: 132 issues_with_invalid_regex.append( 133 GitHubIssueWithInvalidRegexp( 134 internal_error_type="LINEAR_INVALID_REGEXP", 135 issue_url=issue["url"], 136 issue_title=issue["title"], 137 issue_number=issue["identifier"], 138 regex_pattern=f"Multiple regexes, but only one supported: {[match.strip() for match in matches]}", 139 ) 140 ) 141 continue 142 143 if len(matches_ignore_failure) > 1: 144 issues_with_invalid_regex.append( 145 GitHubIssueWithInvalidRegexp( 146 internal_error_type="LINEAR_INVALID_IGNORE_FAILURE", 147 issue_url=issue["url"], 148 issue_title=issue["title"], 149 issue_number=issue["identifier"], 150 regex_pattern=f"Multiple ci-ignore-failures, but only one supported: {[match.strip() for match in matches_ignore_failure]}", 151 ) 152 ) 153 continue 154 155 if len(matches) == 0: 156 continue 157 158 if len(matches_location) >= 2: 159 issues_with_invalid_regex.append( 160 GitHubIssueWithInvalidRegexp( 161 internal_error_type="LINEAR_INVALID_LOCATION", 162 issue_url=issue["url"], 163 issue_title=issue["title"], 164 issue_number=issue["identifier"], 165 regex_pattern=f"Multiple ci-locations, but only one supported: {[match.strip() for match in matches_location]}", 166 ) 167 ) 168 continue 169 170 location: str | None = ( 171 matches_location[0] if len(matches_location) == 1 else None 172 ) 173 174 ignore_failure = len(matches_ignore_failure) == 1 and matches_ignore_failure[ 175 0 176 ].strip() in ("true", "yes", "1") 177 178 try: 179 regex_pattern = re.compile(matches[0].strip().encode()) 180 except: 181 issues_with_invalid_regex.append( 182 GitHubIssueWithInvalidRegexp( 183 internal_error_type="LINEAR_INVALID_REGEXP", 184 issue_url=issue["url"], 185 issue_title=issue["title"], 186 issue_number=issue["identifier"], 187 regex_pattern=matches[0].strip(), 188 ) 189 ) 190 continue 191 192 if matches_apply_to: 193 for match_apply_to in matches_apply_to: 194 known_issues.append( 195 KnownGitHubIssue( 196 regex_pattern, 197 match_apply_to.strip().lower(), 198 info, 199 ignore_failure, 200 location, 201 ) 202 ) 203 else: 204 known_issues.append( 205 KnownGitHubIssue(regex_pattern, None, info, ignore_failure, location) 206 ) 207 208 return (known_issues, issues_with_invalid_regex)