This is an automated email from the ASF dual-hosted git repository.
akm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-agents.git
The following commit(s) were added to refs/heads/main by this push:
new abb7478 Updated consolidation code, adding github issue filing agent
abb7478 is described below
commit abb747810d6e904396c15b0e926d8467a2ce6c58
Author: Andrew Musselman <[email protected]>
AuthorDate: Tue Mar 31 11:41:42 2026 -0700
Updated consolidation code, adding github issue filing agent
---
.../tooling-trusted-releases/ASVS/agents/README.md | 1 +
.../code.py | 421 +++++++++++++--
.../ASVS/agents/file_asvs_triage_issues/code.py | 583 +++++++++++++++++++++
.../ASVS/agents/file_asvs_triage_issues/prompt.py | 84 +++
4 files changed, 1049 insertions(+), 40 deletions(-)
diff --git a/repos/tooling-trusted-releases/ASVS/agents/README.md
b/repos/tooling-trusted-releases/ASVS/agents/README.md
index a635c7c..51f3019 100644
--- a/repos/tooling-trusted-releases/ASVS/agents/README.md
+++ b/repos/tooling-trusted-releases/ASVS/agents/README.md
@@ -13,6 +13,7 @@ Each subdirectory contains one Gofannon agent used in the
ASVS security audit pi
| [`run_asvs_security_audit`](run_asvs_security_audit/) | Core audit agent —
6-step analysis pipeline per ASVS requirement | Audit |
|
[`add_markdown_file_to_github_directory`](add_markdown_file_to_github_directory/)
| Create or update a markdown file in a GitHub repo | Utility |
|
[`consolidate_asvs_security_audit_reports`](consolidate_asvs_security_audit_reports/)
| Multi-directory consolidation with level tracking and deduplication |
Post-processing |
+| [`file_asvs_triage_issues`](file_asvs_triage_issues/) | File GitHub Issues
from raw issues and triage notes | Post-processing |
## File structure
diff --git
a/repos/tooling-trusted-releases/ASVS/agents/consolidate_asvs_security_audit_reports/code.py
b/repos/tooling-trusted-releases/ASVS/agents/consolidate_asvs_security_audit_reports/code.py
index 8a21fec..8252370 100644
---
a/repos/tooling-trusted-releases/ASVS/agents/consolidate_asvs_security_audit_reports/code.py
+++
b/repos/tooling-trusted-releases/ASVS/agents/consolidate_asvs_security_audit_reports/code.py
@@ -190,7 +190,7 @@ For each finding, capture:
- asvs_level: the ASVS level this report covers (provided below)
- affected_files: list of objects with "file" and "line" keys
- recommended_remediation: the recommended fix
-- related_findings: any cross-references to other findings mentioned
+- recommended_remediation: the recommended fix
- positive_controls: list of any positive security controls or good practices
noted
Also extract:
@@ -342,23 +342,88 @@ Return ONLY valid JSON in this format:
print("\n=== PHASE 3: Domain-grouped consolidation (Sonnet, up to 3
concurrent) ===")
DOMAIN_GROUPS = {
- "input_encoding": ["1.2.1", "1.2.2", "1.2.3", "1.2.4", "1.2.5",
- "1.3.1", "1.3.2", "1.5.1"],
- "business_logic": ["2.1.1", "2.2.1", "2.2.2", "2.3.1"],
- "session_csrf": ["3.2.1", "3.2.2", "3.3.1", "3.4.1", "3.4.2",
- "3.5.1", "3.5.2", "3.5.3"],
- "content_type": ["4.1.1", "4.4.1"],
- "file_path": ["5.2.1", "5.2.2", "5.3.1", "5.3.2"],
- "auth_rate_limit": ["6.1.1", "6.2.1", "6.2.2", "6.2.3", "6.2.4",
- "6.2.5", "6.2.6", "6.2.7", "6.2.8",
- "6.3.1", "6.3.2", "6.4.1", "6.4.2"],
- "session_token": ["7.2.1", "7.2.2", "7.2.3", "7.2.4", "7.4.1",
"7.4.2"],
- "authorization": ["8.1.1", "8.2.1", "8.2.2", "8.3.1"],
- "jwt_token": ["9.1.1", "9.1.2", "9.1.3", "9.2.1"],
- "oauth": ["10.4.1", "10.4.2", "10.4.3", "10.4.4", "10.4.5"],
- "crypto_tls": ["11.3.1", "11.3.2", "11.4.1", "12.1.1", "12.2.1",
"12.2.2"],
- "api_scm_client": ["13.4.1", "14.2.1", "14.3.1"],
- "dependencies": ["15.1.1", "15.2.1", "15.3.1"],
+ "input_encoding": [
+ "1.1.1", "1.1.2",
+ "1.2.1", "1.2.2", "1.2.3", "1.2.4", "1.2.5",
+ "1.3.1", "1.3.2",
+ "1.4.1", "1.4.2", "1.4.3",
+ "1.5.1",
+ ],
+ "business_logic": [
+ "2.1.1", "2.2.1", "2.2.2", "2.3.1",
+ "2.4.1",
+ ],
+ "session_csrf": [
+ "3.2.1", "3.2.2", "3.3.1", "3.4.1", "3.4.2",
+ "3.5.1", "3.5.2", "3.5.3",
+ "3.7.1", "3.7.2",
+ ],
+ "content_type": [
+ "4.1.1", "4.2.1", "4.3.1", "4.3.2", "4.4.1",
+ ],
+ "file_path": [
+ "5.1.1", "5.2.1", "5.2.2", "5.3.1", "5.3.2",
+ "5.4.1", "5.4.2", "5.4.3",
+ ],
+ "auth_rate_limit": [
+ "6.1.1", "6.2.1", "6.2.2", "6.2.3", "6.2.4",
+ "6.2.5", "6.2.6", "6.2.7", "6.2.8",
+ "6.3.1", "6.3.2", "6.4.1", "6.4.2",
+ "6.5.1", "6.5.2", "6.5.3", "6.5.4", "6.5.5",
+ "6.6.1", "6.6.2", "6.6.3",
+ "6.8.1", "6.8.2", "6.8.3", "6.8.4",
+ ],
+ "session_token": [
+ "7.1.1", "7.1.2", "7.1.3",
+ "7.2.1", "7.2.2", "7.2.3", "7.2.4",
+ "7.3.1", "7.3.2",
+ "7.4.1", "7.4.2",
+ "7.5.1", "7.5.2",
+ "7.6.1", "7.6.2",
+ ],
+ "authorization": [
+ "8.1.1", "8.2.1", "8.2.2", "8.3.1",
+ "8.4.1",
+ ],
+ "jwt_token": [
+ "9.1.1", "9.1.2", "9.1.3", "9.2.1",
+ ],
+ "oauth": [
+ "10.1.1", "10.1.2", "10.2.1", "10.2.2",
+ "10.3.1", "10.3.2", "10.3.3", "10.3.4",
+ "10.4.1", "10.4.2", "10.4.3", "10.4.4", "10.4.5",
+ "10.5.1", "10.5.2", "10.5.3", "10.5.4", "10.5.5",
+ "10.6.1", "10.6.2",
+ "10.7.1", "10.7.2", "10.7.3",
+ ],
+ "crypto_tls": [
+ "11.1.1", "11.1.2", "11.2.1", "11.2.2", "11.2.3",
+ "11.3.1", "11.3.2", "11.4.1",
+ "11.5.1", "11.6.1",
+ "12.1.1", "12.2.1", "12.2.2",
+ "12.3.1", "12.3.2", "12.3.3", "12.3.4",
+ ],
+ "api_scm_client": [
+ "13.1.1", "13.2.1", "13.2.2", "13.2.3", "13.2.4", "13.2.5",
+ "13.3.1", "13.3.2",
+ "13.4.1",
+ "14.1.1", "14.1.2",
+ "14.2.1", "14.3.1",
+ ],
+ "dependencies": [
+ "15.1.1", "15.2.1", "15.3.1",
+ ],
+ "audit_logging": [
+ "16.1.1",
+ "16.2.1", "16.2.2", "16.2.3", "16.2.4", "16.2.5",
+ "16.3.1", "16.3.2", "16.3.3", "16.3.4",
+ "16.4.1", "16.4.2", "16.4.3",
+ "16.5.1", "16.5.2", "16.5.3",
+ ],
+ "webrtc": [
+ "17.1.1", "17.2.1", "17.2.2", "17.2.3", "17.2.4",
+ "17.3.1", "17.3.2",
+ ],
}
# Extend domain groups for L2/L3 sections not in L1 map
@@ -410,10 +475,10 @@ Return ONLY valid JSON in this format:
Your job is to:
1. **Identify TRUE duplicates**: The EXACT same vulnerability in the EXACT
same code location, reported by multiple ASVS sections OR across levels. Merge
these into ONE finding and note ALL source reports and levels.
-2. **Identify RELATED findings**: Same vulnerability class but DIFFERENT code
locations or DIFFERENT fixes needed. Keep these as SEPARATE findings with
cross-references.
-3. **Preserve EVERY unique finding**. If in doubt, keep findings SEPARATE.
-4. **Track ASVS levels**: Each finding must list which ASVS level(s) flagged
it. A finding from L2 that is NOT in L1 should be tagged as L2-only.
-5. **Use the ASVS requirement descriptions** provided to understand what each
section tests for.
+2. **Preserve EVERY unique finding**. If in doubt, keep findings SEPARATE.
+3. **Track ASVS levels**: Each finding must list which ASVS level(s) flagged
it. A finding from L2 that is NOT in L1 should be tagged as L2-only.
+4. **Use the ASVS requirement descriptions** provided to understand what each
section tests for.
+5. **Do NOT add cross-references between findings.** Cross-references will be
computed deterministically after consolidation.
**Deduplication test**: If a developer could fix one WITHOUT fixing the other,
they are SEPARATE findings.
@@ -431,7 +496,6 @@ Return valid JSON with this structure:
"asvs_levels": ["L1", "L2"],
"affected_files": [{"file": "path", "line": "N"}],
"source_reports": ["L1:filename.md", "L2:filename.md", ...],
- "related_findings": ["DOMAIN-N", ...],
"recommended_remediation": "specific fix with code examples where
possible",
"merged_from": ["original finding IDs that were deduplicated into this
one"]
}
@@ -509,8 +573,8 @@ Return valid JSON with this structure:
provider=FAST_PROVIDER,
model=FAST_MODEL,
messages=sub_messages,
- parameters=FAST_PARAMS,
- timeout=300,
+ parameters={**FAST_PARAMS, "max_tokens":
64000},
+ timeout=600,
)
json_match = re.search(r'\{[\s\S]*\}', result)
if json_match:
@@ -541,8 +605,8 @@ Return valid JSON with this structure:
provider=FAST_PROVIDER,
model=FAST_MODEL,
messages=messages,
- parameters=FAST_PARAMS,
- timeout=300,
+ parameters={**FAST_PARAMS, "max_tokens": 64000},
+ timeout=600,
)
json_match = re.search(r'\{[\s\S]*\}', result)
if json_match:
@@ -591,7 +655,6 @@ Return valid JSON with this structure:
"asvs_levels": [level],
"affected_files": finding.get("affected_files",
[]),
"source_reports": [report_key],
- "related_findings":
finding.get("related_findings", []),
"recommended_remediation":
finding.get("recommended_remediation", ""),
"merged_from": [],
})
@@ -609,7 +672,221 @@ Return valid JSON with this structure:
len(d.get("consolidated_findings", []))
for d in domain_consolidated.values()
)
- print(f"\nTotal consolidated findings: {total_consolidated} (from
{total_extracted} extracted)")
+ print(f"\nTotal consolidated findings (pre-cross-domain):
{total_consolidated} (from {total_extracted} extracted)")
+
+ # ============================================================
+ # PHASE 3.5: Cross-Domain Deduplication
+ # ============================================================
+ print("\n=== PHASE 3.5: Cross-domain deduplication ===")
+
+ # Collect all findings into a flat list with domain tracking
+ xd_all = []
+ for domain, data in domain_consolidated.items():
+ for fi, finding in enumerate(data.get("consolidated_findings",
[])):
+ finding["_xd_domain"] = domain
+ finding["_xd_idx"] = fi
+ xd_all.append(finding)
+
+ # Extract primary affected file for each finding
+ def primary_file(finding):
+ af = finding.get("affected_files", [])
+ if not af:
+ return ""
+ first = af[0]
+ if isinstance(first, dict):
+ return first.get("file", "").split(":")[0].split("
(")[0].strip().strip("`")
+ return str(first).split(":")[0].split(" (")[0].strip().strip("`")
+
+ # Normalize title for comparison
+ def norm_title(t):
+ t = t.lower().strip()
+ # Remove common prefix/suffix variations
+ for noise in ["completely ", "critical: ", "high: ", "medium: ",
"low: "]:
+ t = t.replace(noise, "")
+ # Remove punctuation
+ t = re.sub(r'[^a-z0-9 ]', '', t)
+ # Collapse whitespace
+ t = re.sub(r'\s+', ' ', t).strip()
+ return t
+
+ # Group by primary file
+ file_groups = {}
+ for finding in xd_all:
+ pf = primary_file(finding)
+ if pf:
+ file_groups.setdefault(pf, []).append(finding)
+
+ # Pass 1: Deterministic dedup — same file + similar title = merge
+ xd_merge_count = 0
+ xd_removed = set() # (domain, idx) tuples of findings absorbed into
another
+
+ def merge_into(primary, duplicate):
+ """Merge duplicate's metadata into primary finding."""
+ # Combine source reports
+ existing_sources = set(primary.get("source_reports", []))
+ for sr in duplicate.get("source_reports", []):
+ if sr not in existing_sources:
+ primary.setdefault("source_reports", []).append(sr)
+ existing_sources.add(sr)
+ # Combine ASVS sections
+ existing_sections = set(primary.get("asvs_sections", []))
+ for sec in duplicate.get("asvs_sections", []):
+ if sec not in existing_sections:
+ primary.setdefault("asvs_sections", []).append(sec)
+ existing_sections.add(sec)
+ # Combine ASVS levels
+ existing_levels = set(primary.get("asvs_levels", []))
+ for lv in duplicate.get("asvs_levels", []):
+ if lv not in existing_levels:
+ primary.setdefault("asvs_levels", []).append(lv)
+ existing_levels.add(lv)
+ # Track what was merged
+ dup_id = duplicate.get("temp_id", "unknown")
+ primary.setdefault("merged_from", []).append(dup_id)
+ # Keep longer description
+ if len(duplicate.get("description", "")) >
len(primary.get("description", "")):
+ primary["description"] = duplicate["description"]
+ # Keep more detailed remediation
+ if len(duplicate.get("recommended_remediation", "")) >
len(primary.get("recommended_remediation", "")):
+ primary["recommended_remediation"] =
duplicate["recommended_remediation"]
+
+ for pf, group in file_groups.items():
+ if len(group) < 2:
+ continue
+ # Within each file group, cluster by normalized title
+ title_clusters = {}
+ for finding in group:
+ nt = norm_title(finding.get("title", ""))
+ title_clusters.setdefault(nt, []).append(finding)
+
+ for nt, cluster in title_clusters.items():
+ if len(cluster) < 2:
+ continue
+ # Pick the finding with the most source reports as primary
+ cluster.sort(key=lambda f: len(f.get("source_reports", [])),
reverse=True)
+ primary = cluster[0]
+ for duplicate in cluster[1:]:
+ dup_key = (duplicate["_xd_domain"], duplicate["_xd_idx"])
+ if dup_key in xd_removed:
+ continue
+ merge_into(primary, duplicate)
+ xd_removed.add(dup_key)
+ xd_merge_count += 1
+ print(f" Merged: '{duplicate.get('title', '')[:60]}' from
{duplicate['_xd_domain']} into {primary['_xd_domain']}")
+
+ # Pass 2: LLM-assisted dedup for same-file groups with remaining
duplicates
+ # Only run on groups where 3+ findings share a file after Pass 1
+ xd_llm_groups = {}
+ for pf, group in file_groups.items():
+ remaining = [f for f in group if (f["_xd_domain"], f["_xd_idx"])
not in xd_removed]
+ if len(remaining) >= 3:
+ xd_llm_groups[pf] = remaining
+
+ if xd_llm_groups:
+ print(f"\n LLM-assisted dedup: {len(xd_llm_groups)} file groups
with 3+ remaining findings")
+
+ XD_DEDUP_PROMPT = """You are deduplicating security findings that
affect the SAME file but came from DIFFERENT ASVS domain groups.
+
+Two findings are TRUE DUPLICATES if:
+- They describe the EXACT SAME bug in the EXACT SAME code location
+- A developer fixing one would automatically fix the other
+- The only difference is which ASVS section flagged them
+
+Two findings are NOT duplicates if:
+- They describe different bugs even in the same file
+- They require different fixes
+- They affect different functions/lines
+
+For each group of findings below, return a JSON object:
+{
+ "merges": [
+ {"keep": "TEMP-ID-to-keep", "absorb": ["TEMP-ID-1", "TEMP-ID-2"],
"reason": "same bug: description"}
+ ]
+}
+
+If no duplicates exist in a group, return: {"merges": []}
+Return ONLY valid JSON."""
+
+ for pf, group in xd_llm_groups.items():
+ group_data = []
+ for f in group:
+ group_data.append({
+ "temp_id": f"{f['_xd_domain']}:{f.get('temp_id',
'?')}",
+ "domain": f["_xd_domain"],
+ "title": f.get("title", ""),
+ "severity": f.get("severity", ""),
+ "description": f.get("description", "")[:500],
+ "asvs_sections": f.get("asvs_sections", []),
+ "affected_files": f.get("affected_files", [])[:3],
+ "source_reports_count": len(f.get("source_reports",
[])),
+ })
+
+ user_msg = f"File: {pf}\n\nFindings
({len(group_data)}):\n{json.dumps(group_data, indent=2, default=str)}"
+ messages = [{"role": "user", "content":
f"{XD_DEDUP_PROMPT}\n\n{user_msg}"}]
+
+ msg_tokens = count_message_tokens(messages, FAST_PROVIDER,
FAST_MODEL)
+ if msg_tokens > int(FAST_CONTEXT_WINDOW * 0.60):
+ print(f" {pf}: {len(group)} findings, too large for LLM
dedup — skipping")
+ continue
+
+ try:
+ result, _ = await call_llm(
+ provider=FAST_PROVIDER,
+ model=FAST_MODEL,
+ messages=messages,
+ parameters=FAST_PARAMS,
+ timeout=120,
+ )
+ json_match = re.search(r'\{[\s\S]*\}', result)
+ if json_match:
+ dedup_result = json.loads(json_match.group())
+ merges = dedup_result.get("merges", [])
+ if merges:
+ # Build lookup: "domain:temp_id" -> finding
+ lookup = {}
+ for f in group:
+ key = f"{f['_xd_domain']}:{f.get('temp_id',
'?')}"
+ lookup[key] = f
+
+ for merge in merges:
+ keep_key = merge.get("keep", "")
+ absorb_keys = merge.get("absorb", [])
+ reason = merge.get("reason", "")
+ keep_finding = lookup.get(keep_key)
+ if not keep_finding:
+ continue
+ for abs_key in absorb_keys:
+ abs_finding = lookup.get(abs_key)
+ if not abs_finding:
+ continue
+ abs_dup_key = (abs_finding["_xd_domain"],
abs_finding["_xd_idx"])
+ if abs_dup_key in xd_removed:
+ continue
+ merge_into(keep_finding, abs_finding)
+ xd_removed.add(abs_dup_key)
+ xd_merge_count += 1
+ print(f" LLM merged: {abs_key} into
{keep_key} ({reason[:60]})")
+ except Exception as e:
+ print(f" {pf}: LLM dedup failed ({type(e).__name__}),
skipping")
+
+ # Remove absorbed findings from domain_consolidated
+ if xd_removed:
+ for domain, data in domain_consolidated.items():
+ original = data.get("consolidated_findings", [])
+ filtered = [f for fi, f in enumerate(original) if (domain, fi)
not in xd_removed]
+ data["consolidated_findings"] = filtered
+
+ # Clean up tracking fields
+ for domain, data in domain_consolidated.items():
+ for f in data.get("consolidated_findings", []):
+ f.pop("_xd_domain", None)
+ f.pop("_xd_idx", None)
+
+ total_after_xd = sum(
+ len(d.get("consolidated_findings", []))
+ for d in domain_consolidated.values()
+ )
+ print(f"\nCross-domain dedup: {xd_merge_count} merges,
{total_consolidated} → {total_after_xd} findings")
# ============================================================
# PHASE 4: Final Merge and Report Generation (Batched by severity)
@@ -618,7 +895,6 @@ Return valid JSON with this structure:
severity_order = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3,
"Informational": 4}
all_findings = []
- domain_id_map = {}
for domain, data in domain_consolidated.items():
for finding in data.get("consolidated_findings", []):
@@ -637,18 +913,83 @@ Return valid JSON with this structure:
for i, finding in enumerate(all_findings, 1):
global_id = f"FINDING-{i:03d}"
- old_id = finding.get("temp_id", "")
- domain = finding["_domain"]
- domain_id_map[f"{domain}:{old_id}"] = global_id
finding["global_id"] = global_id
+ # Build cross-references deterministically — no LLM judgment
+ print("Building deterministic cross-references...")
+
+ def extract_primary_file(finding):
+ af = finding.get("affected_files", [])
+ if not af:
+ return ""
+ first = af[0]
+ if isinstance(first, dict):
+ raw = first.get("file", "")
+ else:
+ raw = str(first)
+ return re.sub(r'[:\s(].*', '', raw).strip().strip("`")
+
+ def extract_function_names(finding):
+ """Extract function/method names from affected_files entries."""
+ names = set()
+ for af in finding.get("affected_files", []):
+ raw = af.get("file", "") if isinstance(af, dict) else str(af)
+ # Match function references like "func()" or "Class.method()"
+ for m in
re.finditer(r'(?:^|[:\s])([a-zA-Z_]\w*(?:\.\w+)*)\s*\(', raw):
+ names.add(m.group(1))
+ return names
+
+ # Index findings by primary file, CWE, and function names
+ by_file = {}
+ by_cwe = {}
+ by_func = {}
+ for finding in all_findings:
+ gid = finding["global_id"]
+ pf = extract_primary_file(finding)
+ if pf:
+ by_file.setdefault(pf, set()).add(gid)
+ cwe = finding.get("cwe", "")
+ if cwe and cwe != "null":
+ by_cwe.setdefault(cwe, set()).add(gid)
+ for fn in extract_function_names(finding):
+ by_func.setdefault(fn, set()).add(gid)
+
+ # Assign cross-references using hard rules
+ xref_count = 0
for finding in all_findings:
- domain = finding["_domain"]
- new_related = []
- for ref in finding.get("related_findings", []):
- mapped = domain_id_map.get(f"{domain}:{ref}", ref)
- new_related.append(mapped)
- finding["related_findings"] = new_related
+ gid = finding["global_id"]
+ related = set()
+
+ # Rule 1: same primary file
+ pf = extract_primary_file(finding)
+ if pf and pf in by_file:
+ related |= by_file[pf]
+
+ # Rule 2: same CWE
+ cwe = finding.get("cwe", "")
+ if cwe and cwe != "null" and cwe in by_cwe:
+ related |= by_cwe[cwe]
+
+ # Rule 3: same function name
+ for fn in extract_function_names(finding):
+ if fn in by_func:
+ related |= by_func[fn]
+
+ # Remove self-reference
+ related.discard(gid)
+
+ # Cap at 10 most relevant (same-file first, then same-CWE, then
same-func)
+ if len(related) > 10:
+ # Prioritize same-file references
+ same_file = by_file.get(pf, set()) - {gid} if pf else set()
+ others = related - same_file
+ related = same_file | set(list(others)[:10 - len(same_file)])
+
+ finding["related_findings"] = sorted(related) if related else []
+ if related:
+ xref_count += 1
+
+ print(f" {xref_count} findings have cross-references")
# Collect ASVS statuses
all_asvs_statuses = {}
@@ -1206,4 +1547,4 @@ Affected files: {files}
f" - `{output_directory}/{issues_filename}`"
}
finally:
- await http_client.aclose()
+ await http_client.aclose()
\ No newline at end of file
diff --git
a/repos/tooling-trusted-releases/ASVS/agents/file_asvs_triage_issues/code.py
b/repos/tooling-trusted-releases/ASVS/agents/file_asvs_triage_issues/code.py
new file mode 100644
index 0000000..b80ec85
--- /dev/null
+++ b/repos/tooling-trusted-releases/ASVS/agents/file_asvs_triage_issues/code.py
@@ -0,0 +1,583 @@
+from agent_factory.remote_mcp_client import RemoteMCPClient
+from services.llm_service import call_llm
+import httpx
+
+async def run(input_dict, tools):
+ mcpc = { url : RemoteMCPClient(remote_url = url) for url in tools.keys() }
+ http_client = httpx.AsyncClient()
+ try:
+ github_repo = input_dict["github_repo"]
+ github_token = input_dict["github_token"]
+ commit_hash = input_dict["commit_hash"]
+ issues_url = input_dict["issues_url"]
+ triage_content = input_dict["triage_content"]
+
+ issues_filed = []
+ issues_skipped = []
+ issues_consolidated = []
+ errors = []
+
+ api_base = "https://api.github.com"
+ gh_headers = {
+ "Authorization": f"token {github_token}",
+ "Accept": "application/vnd.github.v3+json",
+ }
+
+ # Convert GitHub blob URL to raw URL if needed
+ raw_url = issues_url
+ if "github.com" in raw_url and "/blob/" in raw_url:
+ raw_url = raw_url.replace("github.com",
"raw.githubusercontent.com").replace("/blob/", "/")
+
+ # ═══════════════════════════════════════════════════════════════════
+ # Step 1: Parse triage content FIRST
+ # ═══════════════════════════════════════════════════════════════════
+
+ triage = []
+ skip_label_kw = {'documentation', 'priority', 'discussion',
'long-term', 'longterm'}
+
+ for line in triage_content.strip().replace('\r\n', '\n').split('\n'):
+ line = line.strip()
+ if not line or line.startswith('#') or re.match(r'^[-|:=\s]+$',
line):
+ continue
+ if re.match(r'^\|?\s*(Finding|ID|#|Num|Number)\s*\|', line,
re.IGNORECASE):
+ continue
+
+ # ── Table format (pipe-delimited) ──
+ if '|' in line:
+ parts = [p.strip() for p in line.split('|') if p.strip()]
+ if len(parts) >= 2:
+ id_m = re.search(r'(\d+)', parts[0])
+ if id_m:
+ rest_text = ' - '.join(parts[1:])
+ disp_m = re.match(
+
r'(Todo|Fixed|Done|N/?A|Skip\w*|Ignore\w*|Won\'?t\s*Fix|Deferred|Accepted)\s*[-\u2013\u2014:.]?\s*(.*)',
+ rest_text, re.IGNORECASE
+ )
+ if disp_m:
+ triage.append({
+ 'finding_id': id_m.group(1),
+ 'disposition': disp_m.group(1).strip(),
+ 'commentary': re.sub(r'^[-\u2013\u2014:.]\s*',
'', disp_m.group(2)).strip(),
+ 'raw_line': line
+ })
+ continue
+
+ # ── Free-form format ──
+ id_m =
re.match(r'(?:FINDING[-_]?\s*)?(\d+)\s*[-\u2013\u2014:.]\s*(.*)', line,
re.IGNORECASE)
+ if not id_m:
+ id_m = re.match(r'(?:FINDING[-_]?\s*)?(\d+)\s+(.*)', line,
re.IGNORECASE)
+ if not id_m:
+ continue
+
+ fid = id_m.group(1)
+ rest = re.sub(r'^[-\u2013\u2014:.]\s*', '', id_m.group(2)).strip()
+
+ disp_m = re.match(
+
r'(Todo|Fixed|Done|N/?A|Skip\w*|Ignore\w*|Won\'?t\s*Fix|Deferred|Accepted)\s*[-\u2013\u2014:.]?\s*(.*)',
+ rest, re.IGNORECASE
+ )
+ if disp_m:
+ triage.append({
+ 'finding_id': fid,
+ 'disposition': disp_m.group(1).strip(),
+ 'commentary': re.sub(r'^[-\u2013\u2014:.]\s*', '',
disp_m.group(2)).strip(),
+ 'raw_line': line
+ })
+ else:
+ words = rest.split(None, 1)
+ triage.append({
+ 'finding_id': fid,
+ 'disposition': words[0] if words else 'Unknown',
+ 'commentary': re.sub(r'^[-\u2013\u2014:.]\s*', '',
words[1]).strip() if len(words) > 1 else '',
+ 'raw_line': line
+ })
+
+ print(f"Parsed {len(triage)} triage entries", flush=True)
+ if not triage:
+ return {
+ "summary": "No triage entries could be parsed from the
provided content.",
+ "issues_filed": [], "issues_skipped": [],
"issues_consolidated": [],
+ "errors": ["No triage entries could be parsed"]
+ }
+
+ # ═══════════════════════════════════════════════════════════════════
+ # Step 2: Filter to Todo entries and collect needed finding IDs
+ # ═══════════════════════════════════════════════════════════════════
+
+ todo_entries = []
+ needed_ids = set()
+
+ for entry in triage:
+ fid = entry['finding_id']
+ disp = entry['disposition']
+ comm_lower = entry['commentary'].lower()
+
+ if disp.lower() != 'todo':
+ issues_skipped.append({"finding_id": fid, "reason":
f"Disposition: {disp}"})
+ continue
+
+ first_word = re.sub(r'^[\s\-]+', '',
comm_lower).split()[0].rstrip('.,;:-') if
comm_lower.strip().lstrip('-').strip() else ''
+ if first_word in ('asfquart', 'asfpy'):
+ issues_skipped.append({"finding_id": fid, "reason": f"Refers
to {first_word}"})
+ continue
+
+ todo_entries.append(entry)
+ needed_ids.add(fid)
+
+ rel_m = re.search(
+ r'(?:related\s+to|adjacent\s+to)\s+(?:FINDING[-_]?\s*)?(\d+)',
+ entry['commentary'], re.IGNORECASE
+ )
+ if rel_m:
+ needed_ids.add(rel_m.group(1))
+
+ if not todo_entries:
+ return {
+ "summary": f"No Todo findings to process out of {len(triage)}
triage entries.",
+ "issues_filed": [], "issues_skipped": issues_skipped,
+ "issues_consolidated": [], "errors": [],
+ }
+
+ print(f"{len(todo_entries)} Todo entries, {len(needed_ids)} finding
IDs needed", flush=True)
+
+ # ═══════════════════════════════════════════════════════════════════
+ # Step 3: Fetch issues markdown
+ # ═══════════════════════════════════════════════════════════════════
+
+ print(f"Fetching issues markdown from: {raw_url}", flush=True)
+ fetch_headers = {"User-Agent": "ASVS-Issue-Filer", "Accept":
"text/plain"}
+ if github_token:
+ fetch_headers["Authorization"] = f"token {github_token}"
+
+ try:
+ resp = await http_client.get(raw_url, headers=fetch_headers,
follow_redirects=True, timeout=30.0)
+ resp.raise_for_status()
+ issues_md = resp.text
+ print(f"Fetched {len(issues_md)} characters of issues markdown",
flush=True)
+ except Exception as e:
+ return {
+ "summary": f"Failed to fetch issues markdown: {e}",
+ "issues_filed": [], "issues_skipped": issues_skipped,
+ "issues_consolidated": [], "errors": [f"Failed to fetch issues
markdown: {e}"]
+ }
+
+ # ═══════════════════════════════════════════════════════════════════
+ # Step 4: Parse ONLY the FINDING sections whose IDs we need
+ # ═══════════════════════════════════════════════════════════════════
+
+ finding_pattern =
re.compile(r'^##\s+(?:Issue:\s*)?FINDING-(\d+)\s*[-\u2013\u2014:]\s*(.+?)(?:\n|$)',
re.MULTILINE)
+ all_matches = list(finding_pattern.finditer(issues_md))
+
+ findings = {}
+
+ for i, m in enumerate(all_matches):
+ fid = m.group(1)
+ if fid not in needed_ids:
+ continue
+
+ title = m.group(2).strip()
+ section_start = m.end()
+ section_end = all_matches[i + 1].start() if i + 1 <
len(all_matches) else len(issues_md)
+ body_text = issues_md[section_start:section_end].strip()
+
+ # Extract labels
+ lbl_match = re.search(r'\*{0,2}Labels\*{0,2}\s*:\s*(.+)',
body_text)
+ raw_lbls = []
+ if lbl_match:
+ raw_lbls = [l.strip().strip('`').strip('*').strip() for l in
lbl_match.group(1).split(',') if l.strip()]
+
+ proc_labels = []
+ for lbl in raw_lbls:
+ ll = lbl.lower()
+ if ll == 'bug':
+ continue
+ elif ll == 'security':
+ proc_labels.append('security')
+ elif ll.startswith('priority:'):
+ proc_labels.append(lbl.split(':', 1)[1].strip())
+ elif ll.startswith('asvs-level:'):
+ lvl = lbl.split(':', 1)[1].strip()
+ if 'asvs' not in proc_labels:
+ proc_labels.append('asvs')
+ proc_labels.append(lvl)
+ else:
+ proc_labels.append(lbl)
+ proc_labels.append(commit_hash)
+
+ desc_match = re.search(r'#+\s*Description\s*\n(.*)', body_text,
re.DOTALL)
+ if desc_match:
+ desc = desc_match.group(1).strip()
+ elif lbl_match:
+ desc = body_text[lbl_match.end():].strip()
+ else:
+ desc = body_text
+
+ findings[fid] = {'title': title, 'labels': proc_labels,
'description': desc}
+
+ print(f"Parsed {len(findings)}/{len(needed_ids)} needed findings "
+ f"(out of {len(all_matches)} total in file)", flush=True)
+
+ if not findings:
+ print("WARNING: No matching findings found in issues markdown",
flush=True)
+
+ # ═══════════════════════════════════════════════════════════════════
+ # Step 5: Build consolidation map (with chain resolution)
+ # ═══════════════════════════════════════════════════════════════════
+
+ consol_map_raw = {}
+ for entry in todo_entries:
+ m = re.search(
+ r'(?:related\s+to|adjacent\s+to)\s+(?:FINDING[-_]?\s*)?(\d+)',
+ entry['commentary'], re.IGNORECASE
+ )
+ if m:
+ target = m.group(1)
+ consol_map_raw[entry['finding_id']] = target
+
+ def resolve_target(fid, cmap, visited=None):
+ if visited is None:
+ visited = set()
+ if fid in visited:
+ return fid
+ visited.add(fid)
+ if fid in cmap:
+ return resolve_target(cmap[fid], cmap, visited)
+ return fid
+
+ consol_map = {}
+ for fid in consol_map_raw:
+ t = resolve_target(fid, consol_map_raw)
+ if t != fid:
+ consol_map[fid] = t
+
+ print(f"Consolidation map (resolved): {consol_map}", flush=True)
+
+ # ═══════════════════════════════════════════════════════════════════
+ # Step 6: Helper functions
+ # ═══════════════════════════════════════════════════════════════════
+
+ LABEL_COLORS = {
+ 'security': 'e11d48', 'critical': 'd73a4a', 'high': 'ff6600',
+ 'medium': 'f59e0b', 'low': '22c55e', 'asvs': '6366f1',
+ 'L1': '8b5cf6', 'L2': '8b5cf6', 'L3': '8b5cf6',
+ 'documentation': '0075ca', 'discussion': '0e8a16',
+ 'priority': 'ff6600', 'long term goal': '7c3aed',
+ 'LLM': '1d76db',
+ }
+
+ created_labels_cache = set()
+
+ async def ensure_label(name):
+ if name in created_labels_cache:
+ return
+ color = LABEL_COLORS.get(name, 'bfd4f2')
+ try:
+ await http_client.post(
+ f"{api_base}/repos/{github_repo}/labels",
+ headers=gh_headers,
+ json={"name": name, "color": color},
+ timeout=10.0,
+ )
+ except Exception:
+ pass
+ created_labels_cache.add(name)
+
+ async def fetch_gh_issue(url):
+ try:
+ m = re.search(r'github\.com/([^/]+/[^/]+)/issues/(\d+)', url)
+ if m:
+ r = await http_client.get(
+ f"{api_base}/repos/{m.group(1)}/issues/{m.group(2)}",
+ headers=gh_headers, timeout=15.0
+ )
+ if r.status_code == 200:
+ return r.json()
+ except Exception:
+ pass
+ return None
+
+ async def compare_issues(f_title, f_desc, issue_data):
+ ex_title = issue_data.get('title', '')
+ ex_body = (issue_data.get('body', '') or '')[:3000]
+ is_open = issue_data.get('state', '') == 'open'
+
+ try:
+ prompt = (
+ "Compare these two security findings.\n\n"
+ f"EXISTING GitHub issue (state: {'open' if is_open else
'closed'}):\n"
+ f"Title: {ex_title}\nBody excerpt: {ex_body}\n\n"
+ f"NEW finding:\nTitle: {f_title}\nDescription excerpt:
{f_desc[:3000]}\n\n"
+ "Are these: \"same\" (exact same vulnerability),
\"related\" "
+ "(related but distinct), or \"different\" (unrelated)?\n"
+ "Reply with ONE word: same, related, or different."
+ )
+ content, _ = await call_llm(
+ provider="bedrock",
+ model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
+ messages=[{"role": "user", "content": prompt}],
+ parameters={"temperature": 1, "reasoning_effort":
"medium", "max_tokens": 32117},
+ )
+ r = content.strip().lower()
+ if 'same' in r:
+ return 'same', is_open
+ elif 'related' in r:
+ return 'related', is_open
+ return 'different', is_open
+ except Exception as e:
+ errors.append(f"LLM comparison failed ({e}), falling back to
heuristic")
+ def normalize(text):
+ return set(re.findall(r'\w+', (text or '').lower()))
+ existing_words = normalize(ex_title + ' ' + ex_body)
+ new_words = normalize(f_title + ' ' + f_desc)
+ if new_words:
+ overlap = len(existing_words & new_words) / len(new_words)
+ if overlap > 0.7:
+ return 'same', is_open
+ elif overlap > 0.4:
+ return 'related', is_open
+ return 'different', is_open
+
+ # ═══════════════════════════════════════════════════════════════════
+ # Step 7: Core issue-filing function
+ # ═══════════════════════════════════════════════════════════════════
+
+ filed_map = {}
+
+ non_username_words = {
+ 'documentation', 'priority', 'discussion', 'this', 'that',
+ 'the', 'need', 'needs', 'should', 'also', 'see', 'check', 'add',
+ 'fix', 'update', 'review', 'test', 'investigate', 'consider',
+ 'related', 'adjacent', 'similar', 'same', 'duplicate', 'existing',
+ 'http', 'https', 'we', 'it', 'is', 'are', 'was', 'has', 'have',
+ 'will', 'would', 'could', 'can', 'may', 'not', 'and', 'or', 'but',
+ 'for', 'from', 'with', 'about', 'todo', 'done', 'fixed', 'skip',
+ 'term', 'goal', 'note', 'notes', 'open', 'close', 'closed',
+ 'new', 'old', 'all', 'some', 'any', 'no', 'yes', 'true', 'false',
+ 'confirm', 'audit_guidance', 'low', 'long-term', 'in-line',
'inline',
+ }
+
+ async def file_issue_for_finding(fid, comm):
+ comm_lower = comm.lower()
+
+ if fid not in findings:
+ errors.append(f"FINDING-{fid} not found in issues markdown")
+ return False
+
+ finding = findings[fid]
+ title = finding['title']
+ description = finding['description']
+ labels = list(finding['labels'])
+ assignees = []
+
+ # ── Detect assignee ──
+ solo_m = re.match(r'^@?([a-zA-Z][\w-]{0,38})$', comm.strip())
+ if solo_m and solo_m.group(1).lower() not in non_username_words
and solo_m.group(1).lower() not in skip_label_kw:
+ assignees.append(solo_m.group(1))
+
+ if not assignees:
+ at_m = re.match(r'^@([a-zA-Z][\w-]{0,38})\b', comm.strip())
+ if at_m:
+ assignees.append(at_m.group(1))
+
+ if not assignees and comm:
+ dash_m =
re.match(r'^([a-zA-Z][\w-]{0,38})\s*[-\u2013\u2014]\s+', comm.strip())
+ if dash_m and dash_m.group(1).lower() not in
non_username_words:
+ assignees.append(dash_m.group(1))
+
+ if not assignees:
+ assign_m =
re.search(r'assign(?:ed)?\s+(?:to\s+)?@?([a-zA-Z][\w-]{0,38})', comm,
re.IGNORECASE)
+ if assign_m:
+ assignees.append(assign_m.group(1))
+
+ # ── Extra labels from commentary keywords ──
+ if 'documentation' in comm_lower:
+ labels.append('documentation')
+ if 'discussion' in comm_lower:
+ labels.append('discussion')
+ if 'long-term' in comm_lower or 'long term' in comm_lower:
+ labels.append('long term goal')
+
+ # Priority override: "low" in commentary replaces finding's
priority labels
+ if re.search(r'\blow\b', comm_lower):
+ priority_labels = {'critical', 'high', 'medium', 'low'}
+ labels = [l for l in labels if l.lower() not in
priority_labels]
+ labels.append('low')
+ elif re.search(r'\bpriority\b', comm_lower):
+ labels.append('priority')
+
+ # audit_guidance (covers "inline audit_guidance", "in-line
audit_guidance") → LLM label
+ if 'audit_guidance' in comm_lower:
+ labels.append('LLM')
+
+ # ── Check GitHub issue links ──
+ gh_link_m =
re.search(r'(https?://github\.com/[^\s)\]>]+/issues/\d+)', comm)
+ related_link = None
+
+ if gh_link_m:
+ existing_url = gh_link_m.group(1).rstrip('.,;')
+ print(f" Checking linked issue: {existing_url}", flush=True)
+ existing_issue = await fetch_gh_issue(existing_url)
+
+ if existing_issue:
+ comparison, is_open = await compare_issues(title,
description, existing_issue)
+ print(f" Comparison result: {comparison}, is_open:
{is_open}", flush=True)
+
+ if comparison == 'same' and is_open:
+ issues_skipped.append({
+ "finding_id": fid,
+ "reason": f"Duplicate of open issue:
{existing_url}"
+ })
+ return False
+ elif comparison in ('related', 'same'):
+ related_link = existing_url
+
+ # ── Build issue body ──
+ body = description
+
+ for other_id, target_id in consol_map.items():
+ if target_id == fid and other_id in findings:
+ cf = findings[other_id]
+ body += (
+ f"\n\n---\n\n### Consolidated: FINDING-{other_id} -
{cf['title']}"
+ f"\n\n{cf['description']}"
+ )
+
+ if related_link:
+ body += f"\n\n---\n\n**Related issue:** {related_link}"
+
+ if comm:
+ temp = comm
+ for a in assignees:
+ temp = re.sub(r'@?' + re.escape(a), '', temp, count=1,
flags=re.IGNORECASE)
+ if gh_link_m:
+ temp = temp.replace(gh_link_m.group(1).rstrip('.,;'), '')
+ for kw_pat in [r'\bdocumentation\b', r'\bpriority\b',
r'\bdiscussion\b', r'\blong[- ]term\b', r'\baudit_guidance\b', r'\blow\b']:
+ temp = re.sub(kw_pat, '', temp, flags=re.IGNORECASE)
+ temp =
re.sub(r'(?:related\s+to|adjacent\s+to)\s+(?:FINDING[-_]?\s*)?\d+', '', temp,
flags=re.IGNORECASE)
+ temp = re.sub(r'[-\u2013\u2014:.,\s]+', ' ', temp).strip()
+ if temp and len(temp) > 1:
+ body += f"\n\n---\n\n**Triage notes:** {comm}"
+
+ # Deduplicate labels
+ seen_labels = set()
+ unique_labels = []
+ for l in labels:
+ key = l.lower()
+ if key not in seen_labels:
+ seen_labels.add(key)
+ unique_labels.append(l)
+ labels = unique_labels
+
+ for lbl in labels:
+ await ensure_label(lbl)
+
+ # Truncate body at GitHub's 65536-char limit
+ if len(body) > 65000:
+ body = body[:64900] + "\n\n---\n*[Truncated]*"
+
+ # ── Create the GitHub issue ──
+ try:
+ payload = {"title": title[:256], "body": body, "labels":
labels}
+ if assignees:
+ payload["assignees"] = list(set(assignees))
+
+ print(f" Creating: \"{title[:80]}\" | labels={labels} |
assignees={assignees}", flush=True)
+
+ r = await http_client.post(
+ f"{api_base}/repos/{github_repo}/issues",
+ headers=gh_headers, json=payload, timeout=30.0
+ )
+
+ if r.status_code == 422 and assignees:
+ print(f" Got 422, retrying without assignees...",
flush=True)
+ payload.pop("assignees", None)
+ assignees = []
+ r = await http_client.post(
+ f"{api_base}/repos/{github_repo}/issues",
+ headers=gh_headers, json=payload, timeout=30.0
+ )
+
+ r.raise_for_status()
+ result = r.json()
+ gh_url = result.get('html_url', '')
+ filed_map[fid] = gh_url
+
+ issues_filed.append({
+ "finding_id": fid,
+ "title": title,
+ "github_url": gh_url,
+ "labels": labels,
+ "assignees": list(set(assignees))
+ })
+ print(f" \u2713 {gh_url}", flush=True)
+ await asyncio.sleep(1)
+ return True
+
+ except Exception as e:
+ err_msg = f"Failed to create issue for FINDING-{fid}: {e}"
+ try:
+ err_msg += f" | Response: {r.text[:300]}"
+ except Exception:
+ pass
+ errors.append(err_msg)
+ return False
+
+ # ═══════════════════════════════════════════════════════════════════
+ # Step 8: Pass 1 — non-consolidated Todo entries
+ # ═══════════════════════════════════════════════════════════════════
+
+ print(f"\n=== Pass 1: {len(todo_entries) - len(consol_map_raw)}
non-consolidated ===", flush=True)
+
+ for entry in todo_entries:
+ fid = entry['finding_id']
+ if fid in consol_map:
+ continue
+ print(f"\nFINDING-{fid}: Todo | {entry['commentary']}", flush=True)
+ await file_issue_for_finding(fid, entry['commentary'])
+
+ # ═══════════════════════════════════════════════════════════════════
+ # Step 9: Pass 2 — consolidated entries
+ # ═══════════════════════════════════════════════════════════════════
+
+ print(f"\n=== Pass 2: {len(consol_map)} consolidated ===", flush=True)
+
+ for entry in todo_entries:
+ fid = entry['finding_id']
+ if fid not in consol_map:
+ continue
+
+ target = consol_map[fid]
+ if target in filed_map:
+ issues_consolidated.append({"finding_id": fid,
"consolidated_into": target})
+ print(f"FINDING-{fid}: Consolidated into FINDING-{target}
({filed_map[target]})", flush=True)
+ else:
+ print(f"\nFINDING-{fid}: Target {target} not filed; filing
independently", flush=True)
+ await file_issue_for_finding(fid, entry['commentary'])
+
+ # ═══════════════════════════════════════════════════════════════════
+ # Step 10: Summary
+ # ═══════════════════════════════════════════════════════════════════
+
+ summary = (
+ f"Processed {len(triage)} triage entries for {github_repo}. "
+ f"{len(todo_entries)} Todo (of {len(triage)} total). "
+ f"Parsed {len(findings)}/{len(needed_ids)} needed findings "
+ f"(out of {len(all_matches)} in file). "
+ f"Filed {len(issues_filed)} issues, skipped {len(issues_skipped)},
"
+ f"consolidated {len(issues_consolidated)} findings."
+ )
+ if errors:
+ summary += f" Encountered {len(errors)} errors."
+
+ print(f"\n{'='*60}\n{summary}", flush=True)
+
+ return {
+ "summary": summary,
+ "issues_filed": issues_filed,
+ "issues_skipped": issues_skipped,
+ "issues_consolidated": issues_consolidated,
+ "errors": errors
+ }
+
+ finally:
+ await http_client.aclose()
\ No newline at end of file
diff --git
a/repos/tooling-trusted-releases/ASVS/agents/file_asvs_triage_issues/prompt.py
b/repos/tooling-trusted-releases/ASVS/agents/file_asvs_triage_issues/prompt.py
new file mode 100644
index 0000000..264865e
--- /dev/null
+++
b/repos/tooling-trusted-releases/ASVS/agents/file_asvs_triage_issues/prompt.py
@@ -0,0 +1,84 @@
+Processes an ASVS security triage file and files GitHub issues for findings
that need attention. Takes a triage file (pasted text), a raw issues markdown
URL from GitHub, a target repo, a commit hash, and a GitHub token. For each
triaged finding marked "Todo", files a GitHub issue with the correct title,
description, labels, and assignees according to the triage disposition rules.
+
+You will read through this triage file. Each row of the file starts with a
finding id, then a disposition like Todo or Fixed, sometimes with commentary.
There is a raw list of potential issues at
https://github.com/apache/tooling-agents/blob/main/repos/tooling-trusted-releases/ASVS/reports/da901ba/issues-L1-L2.md,
each titled FINDING-id, where id maps to the finding id in the triage file. I
want the agent to take in a github owner/repo and token, commit hash and a link
to the raw issues [...]
+
+input schema:
+{
+ "type": "object",
+ "properties": {
+ "github_repo": {
+ "type": "string",
+ "description": "GitHub owner/repo, e.g. 'apache/creadur-rat'"
+ },
+ "github_token": {
+ "type": "string",
+ "description": "GitHub personal access token with repo scope"
+ },
+ "commit_hash": {
+ "type": "string",
+ "description": "Short commit hash to add as a label to every filed
issue, e.g. 'da901ba'"
+ },
+ "issues_url": {
+ "type": "string",
+ "description": "URL to the raw issues markdown file on GitHub (use
the raw.githubusercontent.com URL so it returns plain text)"
+ },
+ "triage_content": {
+ "type": "string",
+ "description": "Full text of the triage file pasted in. Each line:
finding_id followed by a disposition like Todo or Fixed, optionally with
commentary."
+ }
+ },
+ "required": ["github_repo", "github_token", "commit_hash", "issues_url",
"triage_content"]
+}
+
+output schema:
+{
+ "type": "object",
+ "properties": {
+ "summary": {
+ "type": "string",
+ "description": "Human-readable summary of all actions taken"
+ },
+ "issues_filed": {
+ "type": "array",
+ "description": "List of issues that were successfully created on
GitHub",
+ "items": {
+ "type": "object",
+ "properties": {
+ "finding_id": { "type": "string" },
+ "title": { "type": "string" },
+ "github_url": { "type": "string" },
+ "labels": { "type": "array", "items": { "type": "string" }
},
+ "assignees": { "type": "array", "items": { "type":
"string" } }
+ }
+ }
+ },
+ "issues_skipped": {
+ "type": "array",
+ "description": "Findings that were not filed and why",
+ "items": {
+ "type": "object",
+ "properties": {
+ "finding_id": { "type": "string" },
+ "reason": { "type": "string" }
+ }
+ }
+ },
+ "issues_consolidated": {
+ "type": "array",
+ "description": "Findings folded into another finding's issue",
+ "items": {
+ "type": "object",
+ "properties": {
+ "finding_id": { "type": "string" },
+ "consolidated_into": { "type": "string" }
+ }
+ }
+ },
+ "errors": {
+ "type": "array",
+ "description": "Any errors encountered",
+ "items": { "type": "string" }
+ }
+ },
+ "required": ["summary", "issues_filed", "issues_skipped",
"issues_consolidated", "errors"]
+}
\ No newline at end of file
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]