Skip to content

Commit ac638ac

Browse files
committed
client: 3-phase Code Scanning alert lifecycle mgmt
Add report, assess, and apply subcommands to gh-ql-mcp-client for managing Code Scanning alerts across their full lifecycle. Phase 1 — code-scanning report: - Fetches alerts across all states (open, dismissed, fixed) to capture the complete alert lifecycle picture - Preserves dismissal metadata (reason, comment, by, at) for anti-churn - Groups alerts by rule with per-state counts - Output: <owner>_<repo>.cs-report.json Phase 2 — code-scanning assess: - Detects overlapping alerts across different rules at the same file:line - Flags churn risk when open alerts overlap dismissed ones - Recommends keep / keep-dismissed / keep-fixed / review / discard - Output: <owner>_<repo>.cs-assess.json Phase 3 — code-scanning apply: - Builds dismiss plan from assess report, executes via GitHub API - Supports --dry-run, --accept-all-changes, --accept-change-for-rule - Per-rule authorization when explicit rule filters are provided - Output: <owner>_<repo>.cs-apply.json Server changes: - Extract normalizedUrisMatch() from urisMatch() for precomputed paths (addresses unresolved PR #236 review comment) - Rebuild server dist with sarif-utils refactor
1 parent 0342edf commit ac638ac

10 files changed

+1430
-12
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ codeql-development-mcp-server.code-workspace
6060
/evaluator-*.json
6161
/stderr.txt
6262
/stdout.txt
63+
sarif-downloads/
64+
*.cs-apply.json
65+
*.cs-assess.json
66+
*.cs-report.json
6367

6468
# Ignore test scaffolding directory created by query-scaffolding tests
6569
.test-query-scaffolding

client/cmd/code_scanning_apply.go

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"time"
8+
9+
gh "github.com/advanced-security/codeql-development-mcp-server/client/internal/github"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
// ---------------------------------------------------------------------------
14+
// Apply data types
15+
// ---------------------------------------------------------------------------
16+
17+
type applyAction struct {
18+
AlertNumber int `json:"alertNumber"`
19+
RuleID string `json:"ruleId"`
20+
Action string `json:"action"`
21+
DismissReason string `json:"dismissReason,omitempty"`
22+
DismissComment string `json:"dismissComment,omitempty"`
23+
Reason string `json:"reason,omitempty"`
24+
Authorized bool `json:"authorized"`
25+
Applied bool `json:"applied"`
26+
Error string `json:"error,omitempty"`
27+
}
28+
29+
type applySummary struct {
30+
TotalAlerts int `json:"totalAlerts"`
31+
DismissCount int `json:"dismissCount"`
32+
NoChangeCount int `json:"noChangeCount"`
33+
AppliedCount int `json:"appliedCount"`
34+
ErrorCount int `json:"errorCount"`
35+
DryRun bool `json:"dryRun"`
36+
}
37+
38+
type applyPlan struct {
39+
Repository string `json:"repository,omitempty"`
40+
GeneratedAt string `json:"generatedAt"`
41+
InputReport string `json:"inputReport,omitempty"`
42+
Actions []applyAction `json:"actions"`
43+
Summary applySummary `json:"summary"`
44+
}
45+
46+
type applyOptions struct {
47+
dryRun bool
48+
acceptAllChanges bool
49+
acceptChangeForRules []string
50+
dismissReason string
51+
dismissComment string
52+
}
53+
54+
// ---------------------------------------------------------------------------
55+
// buildApplyPlan — pure function, no I/O
56+
// ---------------------------------------------------------------------------
57+
58+
func buildApplyPlan(assessed []assessedAlert, opts applyOptions) applyPlan {
59+
acceptRules := make(map[string]bool)
60+
for _, r := range opts.acceptChangeForRules {
61+
acceptRules[r] = true
62+
}
63+
64+
reason := opts.dismissReason
65+
if reason == "" {
66+
reason = "won't fix"
67+
}
68+
69+
var actions []applyAction
70+
noChange := 0
71+
72+
for _, a := range assessed {
73+
switch a.Recommendation {
74+
case "keep", "keep-dismissed", "keep-fixed":
75+
noChange++
76+
continue
77+
case "discard", "review":
78+
action := applyAction{
79+
AlertNumber: a.Number,
80+
RuleID: a.Rule.ID,
81+
Action: "dismiss",
82+
DismissReason: reason,
83+
DismissComment: opts.dismissComment,
84+
Reason: a.RecommendReason,
85+
}
86+
if opts.acceptAllChanges || acceptRules[a.Rule.ID] {
87+
action.Authorized = true
88+
} else if a.Recommendation == "discard" && len(opts.acceptChangeForRules) == 0 {
89+
action.Authorized = true // discard auto-authorized when no rule filter set
90+
}
91+
actions = append(actions, action)
92+
default:
93+
noChange++
94+
}
95+
}
96+
97+
return applyPlan{
98+
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
99+
Actions: actions,
100+
Summary: applySummary{
101+
TotalAlerts: len(assessed),
102+
DismissCount: len(actions),
103+
NoChangeCount: noChange,
104+
DryRun: opts.dryRun,
105+
},
106+
}
107+
}
108+
109+
// ---------------------------------------------------------------------------
110+
// Cobra command
111+
// ---------------------------------------------------------------------------
112+
113+
var applyCmd = &cobra.Command{
114+
Use: "apply",
115+
Short: "Apply alert lifecycle changes from an assess report",
116+
Long: `Apply the recommended changes from a Phase 2 assess report to
117+
Code Scanning alerts via the GitHub API. Supports dry-run mode to preview
118+
changes without making them, and per-rule or blanket acceptance flags.
119+
120+
This is Phase 3 of the three-phase Code Scanning alert lifecycle workflow.`,
121+
RunE: runApply,
122+
}
123+
124+
var applyFlags struct {
125+
input string
126+
output string
127+
dryRun bool
128+
acceptAllChanges bool
129+
acceptChangeForRules []string
130+
dismissReason string
131+
dismissComment string
132+
repo string
133+
}
134+
135+
func init() {
136+
codeScanningCmd.AddCommand(applyCmd)
137+
138+
f := applyCmd.Flags()
139+
f.StringVar(&applyFlags.input, "input", "", "Path to Phase 2 assess report JSON (required)")
140+
f.StringVar(&applyFlags.output, "output", "", "Output file path (default: <owner>_<repo>.cs-apply.json)")
141+
f.StringVar(&applyFlags.repo, "repo", "", "Repository in owner/repo format (overrides report)")
142+
f.BoolVar(&applyFlags.dryRun, "dry-run", false, "Preview changes without applying them")
143+
f.BoolVar(&applyFlags.acceptAllChanges, "accept-all-changes", false, "Auto-authorize all recommended changes")
144+
f.StringSliceVar(&applyFlags.acceptChangeForRules, "accept-change-for-rule", nil, "Auto-authorize changes for specific rule IDs")
145+
f.StringVar(&applyFlags.dismissReason, "dismiss-reason", "won't fix", "Reason for dismissing alerts (false positive, won't fix, used in tests)")
146+
f.StringVar(&applyFlags.dismissComment, "dismiss-comment", "", "Comment to attach to dismissed alerts")
147+
148+
_ = applyCmd.MarkFlagRequired("input")
149+
}
150+
151+
func runApply(cmd *cobra.Command, _ []string) error {
152+
data, err := os.ReadFile(applyFlags.input)
153+
if err != nil {
154+
return fmt.Errorf("read input: %w", err)
155+
}
156+
157+
var assessReport codeScanningAssessReport
158+
if err := json.Unmarshal(data, &assessReport); err != nil {
159+
return fmt.Errorf("parse assess report: %w", err)
160+
}
161+
162+
repo := applyFlags.repo
163+
if repo == "" {
164+
repo = assessReport.Repository
165+
}
166+
167+
owner, repoName, err := parseRepo(repo)
168+
if err != nil {
169+
return err
170+
}
171+
172+
plan := buildApplyPlan(assessReport.Alerts, applyOptions{
173+
dryRun: applyFlags.dryRun,
174+
acceptAllChanges: applyFlags.acceptAllChanges,
175+
acceptChangeForRules: applyFlags.acceptChangeForRules,
176+
dismissReason: applyFlags.dismissReason,
177+
dismissComment: applyFlags.dismissComment,
178+
})
179+
plan.Repository = repo
180+
plan.InputReport = applyFlags.input
181+
182+
if applyFlags.dryRun {
183+
fmt.Fprintf(cmd.ErrOrStderr(), "DRY RUN — no changes will be made to %s/%s\n", owner, repoName)
184+
}
185+
186+
fmt.Fprintf(cmd.ErrOrStderr(), "Plan: %d alerts, %d to dismiss, %d unchanged\n",
187+
plan.Summary.TotalAlerts, plan.Summary.DismissCount, plan.Summary.NoChangeCount)
188+
189+
// Execute actions (unless dry-run)
190+
if !applyFlags.dryRun && len(plan.Actions) > 0 {
191+
client, err := gh.NewClient()
192+
if err != nil {
193+
return err
194+
}
195+
196+
for i, action := range plan.Actions {
197+
if !action.Authorized {
198+
fmt.Fprintf(cmd.ErrOrStderr(), " Skipping #%d (%s) — not authorized\n",
199+
action.AlertNumber, action.RuleID)
200+
continue
201+
}
202+
203+
fmt.Fprintf(cmd.ErrOrStderr(), " Dismissing #%d (%s)...\n",
204+
action.AlertNumber, action.RuleID)
205+
206+
_, err := client.UpdateAlert(gh.UpdateAlertOptions{
207+
Owner: owner,
208+
Repo: repoName,
209+
AlertNumber: action.AlertNumber,
210+
State: "dismissed",
211+
DismissedReason: action.DismissReason,
212+
DismissedComment: action.DismissComment,
213+
})
214+
if err != nil {
215+
plan.Actions[i].Error = err.Error()
216+
plan.Summary.ErrorCount++
217+
fmt.Fprintf(cmd.ErrOrStderr(), " Error: %v\n", err)
218+
} else {
219+
plan.Actions[i].Applied = true
220+
plan.Summary.AppliedCount++
221+
}
222+
}
223+
}
224+
225+
// Write output
226+
outPath := applyFlags.output
227+
if outPath == "" {
228+
// Derive from repository name: owner_repo.cs-apply.json
229+
if o, r, err := parseRepo(repo); err == nil {
230+
outPath = fmt.Sprintf("%s_%s.cs-apply.json", o, r)
231+
} else {
232+
outPath = "cs-apply.json"
233+
}
234+
}
235+
236+
outData, err := json.MarshalIndent(plan, "", " ")
237+
if err != nil {
238+
return fmt.Errorf("marshal plan: %w", err)
239+
}
240+
241+
if err := os.WriteFile(outPath, outData, 0o600); err != nil {
242+
return fmt.Errorf("write plan: %w", err)
243+
}
244+
245+
mode := "Plan"
246+
if !applyFlags.dryRun {
247+
mode = "Results"
248+
}
249+
fmt.Fprintf(cmd.ErrOrStderr(), "\n%s written to %s\n", mode, outPath)
250+
if plan.Summary.AppliedCount > 0 {
251+
fmt.Fprintf(cmd.ErrOrStderr(), " %d alerts dismissed\n", plan.Summary.AppliedCount)
252+
}
253+
if plan.Summary.ErrorCount > 0 {
254+
fmt.Fprintf(cmd.ErrOrStderr(), " %d errors\n", plan.Summary.ErrorCount)
255+
}
256+
257+
if OutputFormat() == "json" {
258+
fmt.Fprintln(cmd.OutOrStdout(), string(outData))
259+
}
260+
261+
return nil
262+
}

0 commit comments

Comments
 (0)