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