|
51 | 51 |
|
52 | 52 | _KINDS = frozenset({"pull-request", "issue-comment"}) |
53 | 53 |
|
| 54 | +#: Where a follow-up comment stops being a reply and becomes a review. The two |
| 55 | +#: comments that drew the complaint on python/mypy were 158 and 184 words; the |
| 56 | +#: one that answered a maintainer's two questions was 321 and was welcome, so |
| 57 | +#: this warns and does not refuse. |
| 58 | +COMMENT_WORD_LIMIT = 120 |
| 59 | + |
54 | 60 | # Claims only the person posting can make true. The agent cannot read a diff |
55 | 61 | # on the human's behalf, cannot take responsibility, and cannot vouch for the |
56 | 62 | # change; a body that says otherwise in the first person is false the moment |
@@ -278,6 +284,76 @@ def closed_threads(run_directory: Path, repository: str) -> dict[str, Any]: |
278 | 284 | return {"closed": True, "why": why, "threads": threads} |
279 | 285 |
|
280 | 286 |
|
| 287 | +def foreign_pull_request(repository: str, number: int) -> str | None: |
| 288 | + """The author of pull request `number` in `repository`, or None. |
| 289 | +
|
| 290 | + None means "not a pull request", or "GitHub could not be asked". The |
| 291 | + caller treats the second the same as the first, because the thread it is |
| 292 | + about to refuse is one it can also name from provenance; this lookup only |
| 293 | + catches the case provenance has not seen yet. |
| 294 | + """ |
| 295 | + if shutil.which("gh") is None: |
| 296 | + return None |
| 297 | + try: |
| 298 | + completed = subprocess.run( |
| 299 | + [ |
| 300 | + "gh", |
| 301 | + "api", |
| 302 | + f"repos/{repository_slug(repository)}/pulls/{number}", |
| 303 | + "--jq", |
| 304 | + ".user.login", |
| 305 | + ], |
| 306 | + capture_output=True, |
| 307 | + text=True, |
| 308 | + encoding="utf-8", |
| 309 | + errors="replace", |
| 310 | + timeout=clamp_timeout_seconds(15), |
| 311 | + check=False, |
| 312 | + shell=False, |
| 313 | + ) |
| 314 | + except (OSError, subprocess.TimeoutExpired): |
| 315 | + return None |
| 316 | + if completed.returncode != 0: |
| 317 | + return None |
| 318 | + return completed.stdout.strip() or None |
| 319 | + |
| 320 | + |
| 321 | +def foreign_thread_refusal( |
| 322 | + run_directory: Path, |
| 323 | + *, |
| 324 | + repository: str, |
| 325 | + kind: str, |
| 326 | + issue_number: int | None, |
| 327 | + pull_request_lookup: Callable[[str, int], str | None] | None = None, |
| 328 | +) -> str | None: |
| 329 | + """Why a comment must not go to this thread: it is somebody else's PR. |
| 330 | +
|
| 331 | + The harness reviews its own pull request and answers on its own issue. |
| 332 | + It has no business on a pull request someone else opened, whatever the |
| 333 | + state of our run: the comment that drew the complaint on python/mypy#21967 |
| 334 | + went out twelve seconds before our own pull request was closed, when |
| 335 | + nothing about the case was closed yet. |
| 336 | + See https://github.com/wolfgang-aura/Mailman/issues/87. |
| 337 | + """ |
| 338 | + if kind != "issue-comment" or issue_number is None: |
| 339 | + return None |
| 340 | + record = load_provenance(run_directory) or {} |
| 341 | + if issue_number == record.get("pull_request"): |
| 342 | + return None |
| 343 | + if issue_number == upstream_issue_number(run_directory, repository): |
| 344 | + return None |
| 345 | + lookup = pull_request_lookup or foreign_pull_request |
| 346 | + author = lookup(repository, issue_number) |
| 347 | + if author is None: |
| 348 | + return None |
| 349 | + return ( |
| 350 | + f"#{issue_number} is a pull request by {author}, not ours. This run " |
| 351 | + "comments on its own issue and its own pull request; a review of " |
| 352 | + "somebody else's pull request is not something it sends, and " |
| 353 | + "--closing-reply does not cover it." |
| 354 | + ) |
| 355 | + |
| 356 | + |
281 | 357 | def closure_refusal( |
282 | 358 | closure: dict[str, Any], *, kind: str, issue_number: int | None |
283 | 359 | ) -> str | None: |
@@ -359,6 +435,20 @@ def _preamble(record: dict[str, Any], claims: list[dict[str, Any]]) -> list[str] |
359 | 435 | "", |
360 | 436 | ] |
361 | 437 | ) |
| 438 | + words = record.get("word_count") or 0 |
| 439 | + if record.get("kind") == "issue-comment" and words > COMMENT_WORD_LIMIT: |
| 440 | + lines.extend( |
| 441 | + [ |
| 442 | + "-" * 72, |
| 443 | + f"LENGTH -- {words} words, and a reply is under {COMMENT_WORD_LIMIT}", |
| 444 | + "-" * 72, |
| 445 | + "", |
| 446 | + " A follow-up this long reads as a review. Answer what the thread", |
| 447 | + " asked and cut the rest; on python/mypy#21967 two comments of", |
| 448 | + " 158 and 184 words, each linking the other, drew a request to stop.", |
| 449 | + "", |
| 450 | + ] |
| 451 | + ) |
362 | 452 | warning = record.get("maintainer_edit_warning") |
363 | 453 | if warning: |
364 | 454 | lines.extend( |
@@ -453,11 +543,23 @@ def build_handoff( |
453 | 543 | data_root: Path | None = None, |
454 | 544 | closing_reply: bool = False, |
455 | 545 | owner_type_lookup: Callable[[str], str | None] = github_owner_type, |
| 546 | + pull_request_lookup: Callable[[str, int], str | None] | None = None, |
456 | 547 | ) -> tuple[dict[str, Any], str]: |
457 | 548 | """Record the body's digest and render the block that hands it over.""" |
458 | 549 | closure = closed_threads(run_directory, repository) |
459 | 550 | refusal = closure_refusal(closure, kind=kind, issue_number=issue_number) |
460 | | - if refusal and not closing_reply: |
| 551 | + if refusal is None: |
| 552 | + # Not one of the case's own threads, so the override has no say. |
| 553 | + foreign = foreign_thread_refusal( |
| 554 | + run_directory, |
| 555 | + repository=repository, |
| 556 | + kind=kind, |
| 557 | + issue_number=issue_number, |
| 558 | + pull_request_lookup=pull_request_lookup, |
| 559 | + ) |
| 560 | + if foreign: |
| 561 | + raise ValueError(foreign) |
| 562 | + elif not closing_reply: |
461 | 563 | raise ValueError(refusal) |
462 | 564 | if closing_reply: |
463 | 565 | if refusal is None: |
@@ -516,6 +618,7 @@ def build_handoff( |
516 | 618 | "digest": body_digest(body), |
517 | 619 | "first_person_claims": first_person_claims(body), |
518 | 620 | "preservation_claims": preservation_claims(body), |
| 621 | + "word_count": len(body.split()), |
519 | 622 | "head_owner": owner, |
520 | 623 | "head_owner_type": owner_type, |
521 | 624 | "maintainer_edit_warning": maintainer_edit_warning(owner, owner_type), |
|
0 commit comments