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]

Reply via email to