Files
nucleic/Sources/NucleicCore/GhCommandSummary.swift
T

215 lines
12 KiB
Swift

import Foundation
/// Classifies a raw `gh` (GitHub CLI) argv into the agentic action it performs — the ground-truth
/// counterpart to ``GitCommandSummary`` for the `git` interceptor. The in-container `gh` shim
/// reports each invocation (POST `/gh-event`); this type decides, from the noun + verb, whether it
/// was a *mutating* action worth observing (open/merge/close a PR, cut a release, hit the API with a
/// write method, …) and gives it a human label + glyph. Read-only `gh` (list/view/status) and
/// commands we don't model classify to `nil`, so the host ignores them.
///
/// Like ``GitCommandSummary``, parsing is deliberately structural (noun, verb, a few flag values)
/// rather than a full gh grammar — the host only needs to recognize the action, not re-run it.
public enum GhCommandSummary {
/// A `gh` action observed with certainty from a raw argv. `kind` is the normalized
/// `noun.verb` (`pr.create`, `pr.merge`, `release.create`, `api`, …).
public struct ObservedGhOp: Equatable, Sendable {
public let kind: String
/// Human one-liner (e.g. "Open PR: Fix the bug", "Merge pull request #42").
public let label: String
/// SF Symbol name for the op's glyph (shared with the Control activity feed).
public let symbol: String
/// True for state-changing actions (the only ones we classify); reads return `nil` instead.
public let isMutating: Bool
/// `gh pr merge` specifically — the closest gh analog to a `git merge` landing.
public let isMerge: Bool
public init(kind: String, label: String, symbol: String, isMutating: Bool, isMerge: Bool) {
self.kind = kind
self.label = label
self.symbol = symbol
self.isMutating = isMutating
self.isMerge = isMerge
}
}
/// Classify a raw gh argv — the tokens after `gh` as the shim reports them (a leading `gh` or
/// path-to-gh is tolerated). Returns `nil` for reads and commands we don't model.
public static func classify(argv: [String]) -> ObservedGhOp? {
var rest = ShellLexer.stripLeadingPrefixes(argv)[...]
if let first = rest.first, first == "gh" || first.hasSuffix("/gh") { rest = rest.dropFirst() }
// gh's pre-command global flags (`--version`, `--help`, …) are all self-contained.
while let opt = rest.first, opt.hasPrefix("-") { rest = rest.dropFirst() }
guard let noun = rest.first else { return nil }
let after = Array(rest.dropFirst())
switch noun {
case "pr": return pullRequest(after)
case "issue": return issue(after)
case "release": return release(after)
case "repo": return repo(after)
case "gist":
return table("gist", after, symbol: "doc.text", verbs: [
"create": "Create a gist", "delete": "Delete a gist", "edit": "Edit a gist",
"rename": "Rename a gist file",
])
case "workflow":
return table("workflow", after, symbol: "play.circle", verbs: [
"run": "Run a workflow", "enable": "Enable a workflow", "disable": "Disable a workflow",
])
case "run":
return table("run", after, symbol: "arrow.clockwise", verbs: [
"rerun": "Re-run a workflow run", "cancel": "Cancel a workflow run",
"delete": "Delete a workflow run",
])
case "secret":
return table("secret", after, symbol: "key", verbs: [
"set": "Set a secret", "delete": "Delete a secret", "remove": "Delete a secret",
])
case "variable":
return table("variable", after, symbol: "character.textbox", verbs: [
"set": "Set a variable", "delete": "Delete a variable", "remove": "Delete a variable",
])
case "label":
return table("label", after, symbol: "tag", verbs: [
"create": "Create a label", "delete": "Delete a label", "edit": "Edit a label",
"clone": "Clone labels",
])
case "cache":
return table("cache", after, symbol: "trash", verbs: ["delete": "Delete a cache"])
case "ssh-key":
return table("ssh-key", after, symbol: "key", verbs: [
"add": "Add an SSH key", "delete": "Delete an SSH key",
])
case "gpg-key":
return table("gpg-key", after, symbol: "key", verbs: [
"add": "Add a GPG key", "delete": "Delete a GPG key",
])
case "api": return api(after)
default: return nil
}
}
// MARK: - Nouns with enriched labels
private static func pullRequest(_ a: [String]) -> ObservedGhOp? {
guard let verb = verb(a) else { return nil }
switch verb {
case "create":
let title = ShellLexer.flagValue(a, short: "-t", long: "--title").map(clip)
return op("pr.create", title.map { "Open PR: \($0)" } ?? "Open a pull request",
"arrow.triangle.branch")
case "merge":
return op("pr.merge", numbered("Merge pull request", a), "arrow.triangle.merge", isMerge: true)
case "close": return op("pr.close", numbered("Close pull request", a), "xmark.circle")
case "reopen": return op("pr.reopen", numbered("Reopen pull request", a), "arrow.uturn.backward.circle")
case "ready": return op("pr.ready", numbered("Mark pull request ready", a), "checkmark.circle")
case "edit": return op("pr.edit", numbered("Edit pull request", a), "pencil")
case "comment": return op("pr.comment", numbered("Comment on pull request", a), "text.bubble")
case "review": return op("pr.review", numbered("Review pull request", a), "checkmark.bubble")
default: return nil // list/view/status/checks/diff/checkout → reads or local-only
}
}
private static func issue(_ a: [String]) -> ObservedGhOp? {
guard let verb = verb(a) else { return nil }
switch verb {
case "create":
let title = ShellLexer.flagValue(a, short: "-t", long: "--title").map(clip)
return op("issue.create", title.map { "Open issue: \($0)" } ?? "Open an issue",
"exclamationmark.circle")
case "close": return op("issue.close", numbered("Close issue", a), "xmark.circle")
case "reopen": return op("issue.reopen", numbered("Reopen issue", a), "arrow.uturn.backward.circle")
case "edit": return op("issue.edit", numbered("Edit issue", a), "pencil")
case "comment": return op("issue.comment", numbered("Comment on issue", a), "text.bubble")
case "delete": return op("issue.delete", numbered("Delete issue", a), "trash")
case "transfer": return op("issue.transfer", numbered("Transfer issue", a), "arrow.right.circle")
default: return nil
}
}
private static func release(_ a: [String]) -> ObservedGhOp? {
guard let verb = verb(a) else { return nil }
let tag = positionals(a).dropFirst().first // the token after the verb, e.g. the tag
func tagged(_ phrase: String) -> String { tag.map { "\(phrase) \($0)" } ?? "\(phrase)" }
switch verb {
case "create": return op("release.create", tag.map { "Create release \($0)" } ?? "Create a release", "tag")
case "delete": return op("release.delete", tagged("Delete release"), "trash")
case "edit": return op("release.edit", tagged("Edit release"), "pencil")
case "upload": return op("release.upload", tagged("Upload assets to release"), "arrow.up.doc")
case "delete-asset": return op("release.delete-asset", "Delete a release asset", "trash")
default: return nil
}
}
private static func repo(_ a: [String]) -> ObservedGhOp? {
guard let verb = verb(a) else { return nil }
let name = positionals(a).dropFirst().first
func named(_ phrase: String) -> String { name.map { "\(phrase) \($0)" } ?? "\(phrase)" }
switch verb {
case "create": return op("repo.create", name.map { "Create repository \($0)" } ?? "Create a repository", "folder.badge.plus")
case "delete": return op("repo.delete", named("Delete repository"), "trash")
case "fork": return op("repo.fork", named("Fork repository"), "tuningfork")
case "rename": return op("repo.rename", "Rename repository", "pencil")
case "edit": return op("repo.edit", "Edit repository settings", "pencil")
case "archive": return op("repo.archive", named("Archive repository"), "archivebox")
case "unarchive": return op("repo.unarchive", named("Unarchive repository"), "archivebox")
case "sync": return op("repo.sync", named("Sync repository"), "arrow.triangle.2.circlepath")
default: return nil // clone/view/list/set-default → reads or local (git covers clone)
}
}
/// `gh api`: mutating when an explicit write method is given (`-X POST|PUT|PATCH|DELETE`) or a
/// field flag forces a POST; a bare `gh api <endpoint>` is a GET (read) → `nil`.
private static func api(_ a: [String]) -> ObservedGhOp? {
let method = (ShellLexer.flagValue(a, short: "-X", long: "--method") ?? "").uppercased()
let hasField = a.contains {
$0 == "-f" || $0 == "--raw-field" || $0 == "-F" || $0 == "--field" || $0 == "--input"
}
let effective = method.isEmpty ? (hasField ? "POST" : "GET") : method
guard ["POST", "PUT", "PATCH", "DELETE"].contains(effective) else { return nil }
let endpoint = positionals(a).first
let label = endpoint.map { "GitHub API \(effective) \($0)" } ?? "GitHub API \(effective)"
return op("api", label, "network")
}
// MARK: - Helpers
/// A noun whose verbs map 1:1 to a fixed label — mutating verbs only; anything else → `nil`.
private static func table(
_ noun: String, _ a: [String], symbol: String, verbs: [String: String]
) -> ObservedGhOp? {
guard let verb = verb(a), let phrase = verbs[verb] else { return nil }
return op("\(noun).\(verb)", phrase, symbol)
}
private static func op(
_ kind: String, _ label: String, _ symbol: String, isMerge: Bool = false
) -> ObservedGhOp {
ObservedGhOp(kind: kind, label: label, symbol: symbol, isMutating: true, isMerge: isMerge)
}
/// The verb — the first positional after the noun (`create`, `merge`, …).
private static func verb(_ a: [String]) -> String? { positionals(a).first }
/// "<phrase> #<n>" when a numeric operand (a PR/issue number) follows the verb, else "<phrase>".
private static func numbered(_ phrase: String, _ a: [String]) -> String {
let n = positionals(a).dropFirst().first { $0.allSatisfy(\.isNumber) && !$0.isEmpty }
return n.map { "\(phrase) #\($0)" } ?? "\(phrase)"
}
private static func positionals(_ a: [String]) -> [String] {
ShellLexer.positionals(a, valueFlags: valueFlags)
}
private static func clip(_ s: String) -> String { HeuristicTitle.clip(s, max: 60) }
/// gh flags whose following token is a value (not an operand), so `positionals` finds the verb
/// and key operand without swallowing a `--title`/`--body`/`--method` value.
private static let valueFlags: Set<String> = [
"-t", "--title", "-b", "--body", "-F", "--body-file", "--notes", "--notes-file",
"-B", "--base", "-H", "--head", "-R", "--repo", "-X", "--method", "-f", "--raw-field",
"--field", "--input", "-l", "--label", "-a", "--assignee", "-m", "--milestone",
"-p", "--project", "--target", "--template",
]
}