Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion bot/kodiak/evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
CheckConclusionState,
CheckRun,
Commit,
Issue,
MergeableState,
MergeStateStatus,
PRReview,
Expand Down Expand Up @@ -487,7 +488,8 @@ async def mergeable(
skippable_check_timeout: int,
api_call_retries_remaining: int,
api_call_errors: Sequence[APICallRetry],
subscription: Optional[Subscription],
blocking_issues: List[Issue],
subscription: Optional[Subscription] = None,
app_id: Optional[str] = None,
) -> None:
# TODO(chdsbd): Use structlog bind_contextvars to automatically set useful context (install id, repo, pr number).
Expand Down Expand Up @@ -602,6 +604,26 @@ async def set_status(msg: str, markdown_content: Optional[str] = None) -> None:
)
return

matching_blocking_issues: List[Issue] = []
for issue in blocking_issues or []:
labels = issue.labels.nodes or []
for label in labels:
if label is None:
continue
if label.name == config.disable_bot_label:
matching_blocking_issues.append(issue)
break
if matching_blocking_issues:
issue_numbers = ", ".join(
f"#{issue.number}"
for issue in sorted(matching_blocking_issues, key=lambda x: x.number)
)
await api.dequeue()
await api.set_status(
f"🚨 kodiak disabled by issue(s): {issue_numbers}. Close issue or remove label to re-enable Kodiak."
)
return

if (
app_config.SUBSCRIPTIONS_ENABLED
and repository.is_private
Expand Down
1 change: 1 addition & 0 deletions bot/kodiak/events/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from kodiak.events.check_run import CheckRunEvent # noqa: F401
from kodiak.events.issue import IssueEvent # noqa: F401
from kodiak.events.pull_request import PullRequestEvent # noqa: F401
from kodiak.events.pull_request_review import PullRequestReviewEvent # noqa: F401
from kodiak.events.pull_request_review_thread import ( # noqa: F401
Expand Down
36 changes: 36 additions & 0 deletions bot/kodiak/events/issue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import List, Optional

import pydantic

from kodiak.events.base import GithubEvent


class Owner(pydantic.BaseModel):
login: str


class Repository(pydantic.BaseModel):
name: str
owner: Owner


class Label(pydantic.BaseModel):
name: str


class Issue(pydantic.BaseModel):
number: int
state: str
labels: List[Label]


class IssueEvent(GithubEvent):
"""
https://developer.github.com/v3/activity/events/types/#issuesevent
"""

action: str
issue: Issue
repository: Repository
# For "unlabeled" and "labeled" actions, this contains the label that was changed
label: Optional[Label] = None
1 change: 1 addition & 0 deletions bot/kodiak/pull_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ async def evaluate_pr(
is_active_merge=is_active_merging,
skippable_check_timeout=skippable_check_timeout,
api_call_errors=api_call_errors,
blocking_issues=pr.event.blocking_issues,
api_call_retries_remaining=api_call_retries_remaining,
),
timeout=60,
Expand Down
50 changes: 49 additions & 1 deletion bot/kodiak/queries/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ def parse_config(data: dict[Any, Any]) -> ParsedConfig | None:


def get_event_info_query(
requires_conversation_resolution: bool, fetch_body_html: bool
requires_conversation_resolution: bool,
fetch_body_html: bool,
) -> str:
return """
query GetEventInfo($owner: String!, $repo: String!, $PRNumber: Int!) {
Expand Down Expand Up @@ -185,6 +186,16 @@ def get_event_info_query(
squashMergeAllowed
deleteBranchOnMerge
isPrivate
issues(first: 100, states: [OPEN], orderBy: {field: UPDATED_AT, direction: DESC}) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding this field increases the GraphQL rate limit consumption from 4 per request to 5.

@qefir qefir Dec 14, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, that’s not good. In that case, maybe it’s better to use a separate label taken from the env instead of kodiak.toml, and only filter issues with that label. That should be more lightweight and avoid the extra GraphQL rate-limit hit, right?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's a good solution. Do you plan to self-host this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we’re planning to self-host it. For our needs, getting the label from the env is sufficient, though it might not be the ideal approach for Kodiak in general

nodes {
number
labels(first: 100) {
nodes {
name
}
}
}
}
pullRequest(number: $PRNumber) {
id
author {
Expand Down Expand Up @@ -386,6 +397,23 @@ class ReviewThreadConnection(BaseModel):
nodes: Optional[List[ReviewThread]]


class IssueLabel(BaseModel):
name: str


class IssueLabelConnection(BaseModel):
nodes: Optional[List[IssueLabel]]


class Issue(BaseModel):
number: int
labels: IssueLabelConnection


class IssueConnection(BaseModel):
nodes: Optional[List[Issue]]


class PullRequest(BaseModel):
id: str
number: int
Expand Down Expand Up @@ -436,6 +464,7 @@ class EventInfoResponse:
check_runs: List[CheckRun] = field(default_factory=list)
valid_merge_methods: List[MergeMethod] = field(default_factory=list)
commits: List[Commit] = field(default_factory=list)
blocking_issues: List[Issue] = field(default_factory=list)


MERGE_PR_MUTATION = """
Expand Down Expand Up @@ -599,6 +628,23 @@ def get_labels(*, pr: Dict[str, Any]) -> List[str]:
return []


def get_blocking_issues(*, repo: Dict[str, Any]) -> List[Issue]:
"""
Extract blocking issues (open issues with disable_bot_label) from repository.
"""
try:
nodes = repo.get("issues", {}).get("nodes", [])
issues: List[Issue] = []
for issue_dict in nodes:
try:
issues.append(Issue.parse_obj(issue_dict))
except ValueError:
logger.warning("Could not parse blocking issue", exc_info=True)
return issues
except (KeyError, TypeError):
return []


def get_sha(*, pr: Dict[str, Any]) -> Optional[str]:
try:
return cast(str, pr["commits"]["nodes"][0]["commit"]["oid"])
Expand Down Expand Up @@ -1112,6 +1158,7 @@ async def get_event_info(self, pr_number: int) -> Optional[EventInfoResponse]:
branch_protection = get_branch_protection(
repo=repository, ref_name=pr.baseRefName
)
blocking_issues = get_blocking_issues(repo=repository)

all_reviews = get_reviews(pr=pull_request)
bot_reviews = self.get_bot_reviews(reviews=all_reviews)
Expand All @@ -1136,6 +1183,7 @@ async def get_event_info(self, pr_number: int) -> Optional[EventInfoResponse]:
check_runs=get_check_runs(pr=pull_request),
head_exists=get_head_exists(pr=pull_request),
valid_merge_methods=get_valid_merge_methods(repo=repository),
blocking_issues=blocking_issues,
)

async def get_open_pull_requests(
Expand Down
53 changes: 53 additions & 0 deletions bot/kodiak/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from kodiak import queries
from kodiak.events import (
CheckRunEvent,
IssueEvent,
PullRequestEvent,
PullRequestReviewEvent,
PullRequestReviewThreadEvent,
Expand Down Expand Up @@ -227,6 +228,56 @@ async def push(queue: WebhookQueueProtocol, push_event: PushEvent) -> None:
)


async def issue_event(queue: WebhookQueueProtocol, issue_event: IssueEvent) -> None:
"""
Trigger evaluation of all open PRs when a blocking issue is closed or unlabelled.
"""
owner = issue_event.repository.owner.login
repo = issue_event.repository.name
installation_id = str(issue_event.installation.id)
log = logger.bind(
owner=owner,
repo=repo,
install=installation_id,
issue_number=issue_event.issue.number,
action=issue_event.action,
)

log.info("processing issue event")

if issue_event.action not in ("closed", "unlabeled"):
log.info(
"issue event action not relevant for merge queue resumption",
action=issue_event.action,
)
return

if issue_event.action == "unlabeled" and issue_event.label:
log.info("label removed from issue", label_name=issue_event.label.name)

async with Client(
owner=owner, repo=repo, installation_id=installation_id
) as api_client:
prs = await api_client.get_open_pull_requests()
if prs is None:
log.info("api call to find pull requests failed")
return

log.info(
"triggering re-evaluation of open PRs due to issue event", pr_count=len(prs)
)
for pr in prs:
await queue.enqueue(
event=WebhookEvent(
repo_owner=owner,
repo_name=repo,
pull_request_number=pr.number,
target_name=pr.base.ref,
installation_id=installation_id,
)
)


def compress_payload(data: dict[str, object]) -> bytes:
cctx = zstd.ZstdCompressor()
return cctx.compress(json.dumps(data).encode())
Expand Down Expand Up @@ -264,6 +315,8 @@ async def handle_webhook_event(
await push(queue, PushEvent.parse_obj(payload))
elif event_name == "status":
await status_event(queue, StatusEvent.parse_obj(payload))
elif event_name == "issues":
await issue_event(queue, IssueEvent.parse_obj(payload))
else:
log = log.bind(event_parsed=False)

Expand Down
3 changes: 3 additions & 0 deletions bot/kodiak/test/fixtures/api/get_event/behind.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
"squashMergeAllowed": true,
"deleteBranchOnMerge": true,
"isPrivate": true,
"issues": {
"nodes": []
},
"pullRequest": {
"id": "e14ff7599399478fb9dbc2dacb87da72",
"isDraft": false,
Expand Down
3 changes: 3 additions & 0 deletions bot/kodiak/test/fixtures/api/get_event/no_author.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
"squashMergeAllowed": true,
"deleteBranchOnMerge": true,
"isPrivate": true,
"issues": {
"nodes": []
},
"pullRequest": {
"id": "e14ff7599399478fb9dbc2dacb87da72",
"isDraft": false,
Expand Down
3 changes: 3 additions & 0 deletions bot/kodiak/test/fixtures/api/get_event/no_latest_sha.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
"squashMergeAllowed": true,
"deleteBranchOnMerge": true,
"isPrivate": true,
"issues": {
"nodes": []
},
"pullRequest": {
"id": "e14ff7599399478fb9dbc2dacb87da72",
"isDraft": false,
Expand Down
36 changes: 36 additions & 0 deletions bot/kodiak/test_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
CheckConclusionState,
CheckRun,
Commit,
Issue,
IssueLabel,
IssueLabelConnection,
MergeableState,
MergeStateStatus,
NodeListPushAllowance,
Expand Down Expand Up @@ -335,6 +338,7 @@ async def __call__(
skippable_check_timeout: int = ...,
api_call_retries_remaining: int = ...,
api_call_errors: Sequence[APICallRetry] = ...,
blocking_issues: List[Issue] = ...,
repository: RepoInfo = ...,
subscription: Optional[Subscription] = ...,
app_id: Optional[str] = ...,
Expand Down Expand Up @@ -367,6 +371,7 @@ async def mergeable(
skippable_check_timeout: int = 5,
api_call_retries_remaining: int = 5,
api_call_errors: Sequence[APICallRetry] = [],
blocking_issues: List[Issue] = [],
repository: RepoInfo = create_repo_info(),
subscription: Optional[Subscription] = None,
app_id: Optional[str] = None,
Expand Down Expand Up @@ -394,6 +399,7 @@ async def mergeable(
skippable_check_timeout=skippable_check_timeout,
api_call_retries_remaining=api_call_retries_remaining,
api_call_errors=api_call_errors,
blocking_issues=blocking_issues,
subscription=subscription,
app_id=app_id,
)
Expand Down Expand Up @@ -2274,6 +2280,36 @@ async def test_mergeable_merge_failure_label() -> None:
assert api.update_branch.call_count == 0


async def test_mergeable_blocked_by_issue() -> None:
"""
Kodiak should take no action when a repository has an open issue
labelled with disable_bot_label.
"""
api = create_api()
mergeable = create_mergeable()
config = create_config()

blocking_issue = Issue(
number=5,
labels=IssueLabelConnection(nodes=[IssueLabel(name=config.disable_bot_label)]),
)

await mergeable(
api=api,
config=config,
blocking_issues=[blocking_issue],
merging=True,
)

assert api.dequeue.call_count == 1
assert api.merge.call_count == 0
assert api.queue_for_merge.call_count == 0
assert api.set_status.call_count == 1
assert (
"issue(s): #5" in api.set_status.calls[0]["msg"]
), "status should mention the blocking issue number"


async def test_mergeable_priority_merge_label() -> None:
"""
When a PR has merge.priority_merge_label, we should place it at the front of
Expand Down
1 change: 1 addition & 0 deletions bot/kodiak/test_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ def block_event() -> EventInfoResponse:
CheckRun(name="WIP (beta)", conclusion=CheckConclusionState.SUCCESS)
],
valid_merge_methods=[MergeMethod.squash],
blocking_issues=[],
)


Expand Down