Skip to content
Closed
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
257 changes: 34 additions & 223 deletions .agents/skills/triage-issue/SKILL.md

Large diffs are not rendered by default.

53 changes: 8 additions & 45 deletions .github/workflows/issue-triage.yml
Original file line number Diff line number Diff line change
@@ -1,57 +1,20 @@
name: Issue Triage Gate
name: Issue Intake Labels

on:
issues:
types: [opened]

permissions:
contents: read
issues: write

jobs:
label-non-maintainer-issues:
label-new-issue:
runs-on: ubuntu-latest
if: github.repository_owner == 'NVIDIA'
steps:
- name: Check contributor permissions
id: contributor
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
result-encoding: string
script: |
const author = context.payload.issue.user.login;
const maintainerPermissions = ['admin', 'maintain', 'write'];

try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: author,
});

if (maintainerPermissions.includes(data.permission)) {
console.log(`${author} has maintainer permissions: ${data.permission}.`);
return 'false';
}

console.log(`${author} is not a maintainer; permission is ${data.permission}.`);
return 'true';
} catch (e) {
if (e.status === 404) {
console.log(`${author} is not a repository collaborator; labeling for triage.`);
return 'true';
}

throw e;
}

- name: Add triage label
if: steps.contributor.outputs.result == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['state:triage-needed'],
});
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Apply intake labels
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 scripts/issue_intake.py
92 changes: 92 additions & 0 deletions scripts/issue_intake.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Apply initial workflow labels to a newly opened GitHub issue."""

import json
import os
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

MAINTAINER_PERMISSIONS = {"admin", "maintain", "write"}


class GitHubAPI:
def __init__(
self, repository: str, token: str, api_url: str = "https://api.github.com"
):
self.base = f"{api_url.rstrip('/')}/repos/{repository}"
self.token = token

def request(self, method: str, path: str, payload: dict | None = None):
data = json.dumps(payload).encode() if payload is not None else None
request = urllib.request.Request(
f"{self.base}/{path}",
data=data,
method=method,
headers={
"Authorization": f"Bearer {self.token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
**({"Content-Type": "application/json"} if data is not None else {}),
},
)
with urllib.request.urlopen(request, timeout=20) as response:
return json.load(response)

def get_permission(self, login: str) -> str | None:
login = urllib.parse.quote(login, safe="")
try:
return self.request("GET", f"collaborators/{login}/permission")[
"permission"
]
except urllib.error.HTTPError as error:
if error.code == 404:
return None
raise

def get_labels(self, number: int) -> list[str]:
return [
label["name"] for label in self.request("GET", f"issues/{number}")["labels"]
]

def add_labels(self, number: int, labels: list[str]) -> None:
self.request("POST", f"issues/{number}/labels", {"labels": labels})


def apply_intake(api: GitHubAPI, number: int, author: str) -> list[str]:
permission = api.get_permission(author)
maintainer = permission in MAINTAINER_PERMISSIONS
existing = api.get_labels(number)
wanted = ("state:accepted",) if maintainer else ("state:new",)
missing = [
label
for label in wanted
if not any(
current.startswith(label.split(":", 1)[0] + ":") for current in existing
)
]
if missing:
api.add_labels(number, missing)
return missing


def main() -> None:
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
if "pull_request" in event or event.get("action") != "opened":
raise ValueError("expected an issues.opened event")
api = GitHubAPI(
os.environ["GITHUB_REPOSITORY"],
os.environ["GITHUB_TOKEN"],
os.getenv("GITHUB_API_URL", "https://api.github.com"),
)
number = int(event["issue"]["number"])
added = apply_intake(api, number, event["issue"]["user"]["login"])
print(f"Issue #{number}: added {', '.join(added) if added else 'no labels'}")


if __name__ == "__main__":
main()
64 changes: 64 additions & 0 deletions scripts/issue_intake_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Issue-opened workflow behavior, including retry and maintainer routing."""

import unittest

import issue_intake


class FakeGitHub:
def __init__(self, permission, labels=()):
self.permission = permission
self.labels = list(labels)
self.added = []

def get_permission(self, _login):
return self.permission

def get_labels(self, _number):
return self.labels

def add_labels(self, _number, labels):
self.added.append(labels)
self.labels.extend(labels)


class IssueIntakeTest(unittest.TestCase):
def test_external_author_gets_screening_labels(self):
api = FakeGitHub("read")
issue_intake.apply_intake(api, 42, "contributor")
self.assertEqual(api.added, [["state:new"]])

def test_maintainer_enters_accepted_path(self):
for permission in ("write", "maintain", "admin"):
with self.subTest(permission=permission):
api = FakeGitHub(permission)
issue_intake.apply_intake(api, 42, "maintainer")
self.assertEqual(api.added, [["state:accepted"]])

def test_unknown_collaborator_is_external(self):
api = FakeGitHub(None)
issue_intake.apply_intake(api, 42, "newcomer")
self.assertEqual(api.added, [["state:new"]])

def test_retry_does_not_add_duplicate_labels(self):
api = FakeGitHub("read")
issue_intake.apply_intake(api, 42, "contributor")
issue_intake.apply_intake(api, 42, "contributor")
self.assertEqual(len(api.added), 1)

def test_existing_state_and_type_are_preserved(self):
api = FakeGitHub("read", ["state:validated", "type:bug"])
issue_intake.apply_intake(api, 42, "contributor")
self.assertEqual(api.added, [])

def test_existing_state_needs_no_additional_label(self):
api = FakeGitHub("read", ["state:new"])
issue_intake.apply_intake(api, 42, "contributor")
self.assertEqual(api.added, [])


if __name__ == "__main__":
unittest.main()
144 changes: 144 additions & 0 deletions scripts/triage_handoff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Send a private triage assessment to the OpenShell duty engineer in Slack."""

import argparse
import hashlib
import json
import os
import re
import urllib.parse
import urllib.request
from pathlib import Path

ISSUE_URL = re.compile(r"https://github\.com/NVIDIA/OpenShell/issues/([1-9][0-9]*)\Z")


class SlackClient:
def __init__(self, token: str):
self.token = token

def request(self, method: str, payload: dict):
body = json.dumps(payload).encode() if method == "chat.postMessage" else None
query = "" if body is not None else "?" + urllib.parse.urlencode(payload)
request = urllib.request.Request(
f"https://slack.com/api/{method}{query}",
data=body,
headers={
"Authorization": f"Bearer {self.token}",
"Accept": "application/json",
**({"Content-Type": "application/json"} if body is not None else {}),
},
)
with urllib.request.urlopen(request, timeout=20) as response:
result = json.load(response)
if not result.get("ok"):
raise RuntimeError(
f"Slack {method} failed: {result.get('error', 'unknown error')}"
)
return result

def messages(self, channel: str, thread_ts: str | None = None) -> list[dict]:
method = "conversations.replies" if thread_ts else "conversations.history"
messages = []
cursor = None
while True:
payload = {"channel": channel, "limit": 200}
if thread_ts:
payload["ts"] = thread_ts
if cursor:
payload["cursor"] = cursor
page = self.request(method, payload)
messages.extend(page.get("messages", []))
cursor = page.get("response_metadata", {}).get("next_cursor")
if not cursor:
return messages

def post(self, channel: str, text: str, thread_ts: str | None = None) -> str:
payload = {
"channel": channel,
"text": text,
"unfurl_links": False,
"unfurl_media": False,
}
if thread_ts:
payload["thread_ts"] = thread_ts
return self.request("chat.postMessage", payload)["ts"]


def deliver(
slack: SlackClient,
channel: str,
duty_group: str,
issue_url: str,
summary: str,
*,
security: bool = False,
) -> bool:
match = ISSUE_URL.fullmatch(issue_url)
if match is None:
raise ValueError("issue URL must identify an OpenShell GitHub issue")
if not re.fullmatch(r"[CG][A-Z0-9]+", channel):
raise ValueError("invalid Slack channel ID")
if not re.fullmatch(r"S[A-Z0-9]+", duty_group):
raise ValueError("invalid Slack user group ID")
if security:
summary = "Possible security report. Follow SECURITY.md and handle any sensitive details privately."
elif not summary.strip():
raise ValueError("triage summary is empty")
elif len(summary) > 20_000:
raise ValueError("triage summary exceeds 20,000 characters")

number = match.group(1)
marker = f"OpenShell triage issue #{number}"
safe_summary = (
summary.strip().replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
)
digest = hashlib.sha256(safe_summary.encode()).hexdigest()[:16]
text = f"<!subteam^{duty_group}> {marker}\n{issue_url}\n\n{safe_summary}\n\nAssessment: {digest}"

roots = slack.messages(channel)
root = next(
(message for message in roots if marker in message.get("text", "")), None
)
if root:
replies = slack.messages(channel, root["ts"])
if any(
f"Assessment: {digest}" in message.get("text", "")
for message in [root, *replies]
):
return False
slack.post(channel, text, root["ts"])
else:
slack.post(channel, text)
return True


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--issue-url", required=True)
parser.add_argument("--summary-file", type=Path)
parser.add_argument("--security", action="store_true")
args = parser.parse_args()
if not args.security and args.summary_file is None:
parser.error("--summary-file is required unless --security is set")
summary = args.summary_file.read_text() if args.summary_file else ""
posted = deliver(
SlackClient(os.environ["OPENSHELL_TRIAGE_SLACK_BOT_TOKEN"]),
os.getenv("OPENSHELL_TRIAGE_SLACK_CHANNEL_ID", "C0C20SDLDNW"),
os.getenv("OPENSHELL_TRIAGE_SLACK_DUTY_GROUP_ID", "S0C099KM56F"),
args.issue_url,
summary,
security=args.security,
)
print(
"Triage handoff posted."
if posted
else "Identical triage handoff already posted."
)


if __name__ == "__main__":
main()
Loading
Loading