Skip to content
This repository was archived by the owner on Oct 14, 2024. It is now read-only.

Commit 9b32fde

Browse files
authored
Introduce extension API to ShellSentry (#40)
1 parent d6927ba commit 9b32fde

File tree

7 files changed

+247
-27
lines changed

7 files changed

+247
-27
lines changed

api/kotlin-cli-util.api

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,25 @@ public final class slack/cli/Toml {
3737
public final fun parseVersion (Ljava/io/File;)Ljava/util/Map;
3838
}
3939

40+
public final class slack/cli/shellsentry/AnalysisResult {
41+
public fun <init> (Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;ILkotlin/jvm/functions/Function1;)V
42+
public final fun component1 ()Ljava/lang/String;
43+
public final fun component2 ()Ljava/lang/String;
44+
public final fun component3 ()Lslack/cli/shellsentry/RetrySignal;
45+
public final fun component4 ()I
46+
public final fun component5 ()Lkotlin/jvm/functions/Function1;
47+
public final fun copy (Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;ILkotlin/jvm/functions/Function1;)Lslack/cli/shellsentry/AnalysisResult;
48+
public static synthetic fun copy$default (Lslack/cli/shellsentry/AnalysisResult;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;ILkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lslack/cli/shellsentry/AnalysisResult;
49+
public fun equals (Ljava/lang/Object;)Z
50+
public final fun getConfidence ()I
51+
public final fun getExplanation ()Ljava/lang/String;
52+
public final fun getMessage ()Ljava/lang/String;
53+
public final fun getRetrySignal ()Lslack/cli/shellsentry/RetrySignal;
54+
public final fun getThrowableMaker ()Lkotlin/jvm/functions/Function1;
55+
public fun hashCode ()I
56+
public fun toString ()Ljava/lang/String;
57+
}
58+
4059
public final class slack/cli/shellsentry/Issue {
4160
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;)V
4261
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;Ljava/lang/String;)V
@@ -71,6 +90,11 @@ public final class slack/cli/shellsentry/IssueJsonAdapter : com/squareup/moshi/J
7190
public fun toString ()Ljava/lang/String;
7291
}
7392

93+
public abstract class slack/cli/shellsentry/NoStacktraceThrowable : java/lang/Throwable {
94+
public fun <init> (Ljava/lang/String;)V
95+
public fun fillInStackTrace ()Ljava/lang/Throwable;
96+
}
97+
7498
public abstract interface class slack/cli/shellsentry/RetrySignal {
7599
}
76100

@@ -122,10 +146,10 @@ public final class slack/cli/shellsentry/RetrySignal_RetryDelayedJsonAdapter : c
122146

123147
public final class slack/cli/shellsentry/ShellSentry {
124148
public static final field Companion Lslack/cli/shellsentry/ShellSentry$Companion;
125-
public fun <init> (Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;)V
126-
public synthetic fun <init> (Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
127-
public final fun copy (Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;)Lslack/cli/shellsentry/ShellSentry;
128-
public static synthetic fun copy$default (Lslack/cli/shellsentry/ShellSentry;Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lslack/cli/shellsentry/ShellSentry;
149+
public fun <init> (Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;Ljava/util/List;)V
150+
public synthetic fun <init> (Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
151+
public final fun copy (Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;Ljava/util/List;)Lslack/cli/shellsentry/ShellSentry;
152+
public static synthetic fun copy$default (Lslack/cli/shellsentry/ShellSentry;Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;Ljava/util/List;ILjava/lang/Object;)Lslack/cli/shellsentry/ShellSentry;
129153
public fun equals (Ljava/lang/Object;)Z
130154
public final fun exec ()V
131155
public fun hashCode ()I
@@ -143,16 +167,18 @@ public final class slack/cli/shellsentry/ShellSentryCli : com/github/ajalt/clikt
143167

144168
public final class slack/cli/shellsentry/ShellSentryConfig {
145169
public fun <init> ()V
146-
public fun <init> (ILjava/lang/String;Ljava/util/List;)V
147-
public synthetic fun <init> (ILjava/lang/String;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
170+
public fun <init> (ILjava/lang/String;Ljava/util/List;I)V
171+
public synthetic fun <init> (ILjava/lang/String;Ljava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
148172
public final fun component1 ()I
149173
public final fun component2 ()Ljava/lang/String;
150174
public final fun component3 ()Ljava/util/List;
151-
public final fun copy (ILjava/lang/String;Ljava/util/List;)Lslack/cli/shellsentry/ShellSentryConfig;
152-
public static synthetic fun copy$default (Lslack/cli/shellsentry/ShellSentryConfig;ILjava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lslack/cli/shellsentry/ShellSentryConfig;
175+
public final fun component4 ()I
176+
public final fun copy (ILjava/lang/String;Ljava/util/List;I)Lslack/cli/shellsentry/ShellSentryConfig;
177+
public static synthetic fun copy$default (Lslack/cli/shellsentry/ShellSentryConfig;ILjava/lang/String;Ljava/util/List;IILjava/lang/Object;)Lslack/cli/shellsentry/ShellSentryConfig;
153178
public fun equals (Ljava/lang/Object;)Z
154179
public final fun getGradleEnterpriseServer ()Ljava/lang/String;
155180
public final fun getKnownIssues ()Ljava/util/List;
181+
public final fun getMinConfidence ()I
156182
public final fun getVersion ()I
157183
public fun hashCode ()I
158184
public fun toString ()Ljava/lang/String;
@@ -165,3 +191,7 @@ public final class slack/cli/shellsentry/ShellSentryConfigJsonAdapter : com/squa
165191
public fun toString ()Ljava/lang/String;
166192
}
167193

194+
public abstract interface class slack/cli/shellsentry/ShellSentryExtension {
195+
public abstract fun check (Ljava/lang/String;IZLjava/nio/file/Path;)Lslack/cli/shellsentry/AnalysisResult;
196+
}
197+

src/main/kotlin/slack/cli/shellsentry/Issue.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,12 @@ constructor(
8484
* Base class for an issue that can be reported to Bugsnag. This is a [Throwable] for BugSnag
8585
* purposes but doesn't fill in a stacktrace.
8686
*/
87-
internal class IssueThrowable(issue: Issue) : Throwable(issue.message) {
87+
public abstract class NoStacktraceThrowable(message: String) : Throwable(message) {
8888
override fun fillInStackTrace(): Throwable {
8989
// Do nothing, the stacktrace isn't relevant for these!
9090
return this
9191
}
9292
}
93+
94+
/** Common [Throwable] for all [Issue]s. This is used for reporting to Bugsnag. */
95+
internal class KnownIssue(issue: Issue) : NoStacktraceThrowable(issue.message)

src/main/kotlin/slack/cli/shellsentry/ResultProcessor.kt

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@ internal class ResultProcessor(
4646
private val bugsnagKey: String?,
4747
private val config: ShellSentryConfig,
4848
private val echo: (String) -> Unit,
49+
private val extensions: List<ShellSentryExtension> = emptyList(),
4950
) {
5051

51-
fun process(logFile: Path, isAfterRetry: Boolean): RetrySignal {
52+
@Suppress("LongMethod", "CyclomaticComplexMethod", "NestedBlockDepth", "ReturnCount")
53+
fun process(command: String, exitCode: Int, logFile: Path, isAfterRetry: Boolean): RetrySignal {
5254
echo("Processing CI log from ${logFile.absolutePathString()}")
5355

5456
val bugsnag: Bugsnag? by lazy { bugsnagKey?.let { key -> createBugsnag(key) } }
@@ -62,7 +64,7 @@ internal class ResultProcessor(
6264
// Report to bugsnag. Shared common Throwable but with different messages.
6365
bugsnag?.let {
6466
verboseEcho("Reporting to bugsnag: $retrySignal")
65-
it.notify(IssueThrowable(issue), Severity.ERROR) { report ->
67+
it.notify(KnownIssue(issue), Severity.ERROR) { report ->
6668
// Group by the throwable message
6769
report.setGroupingHash(issue.groupingHash)
6870
report.addToTab("Run Info", "After-Retry", isAfterRetry)
@@ -82,7 +84,42 @@ internal class ResultProcessor(
8284
}
8385
}
8486

85-
// TODO some day log these into bugsnag too?
87+
echo("No matching issues from config.json")
88+
if (extensions.isNotEmpty()) {
89+
echo("Checking extensions")
90+
for (extension in extensions) {
91+
val result = extension.check(command, exitCode, isAfterRetry, logFile) ?: continue
92+
93+
verboseEcho(result.message)
94+
verboseEcho(result.explanation)
95+
96+
if (
97+
result.retrySignal != RetrySignal.Unknown && result.confidence >= config.minConfidence
98+
) {
99+
// Report to bugsnag. Shared common Throwable but with different messages.
100+
bugsnag?.let {
101+
verboseEcho("Reporting to bugsnag: ${result.retrySignal}")
102+
it.notify(result.throwableMaker(result.message), Severity.ERROR) { report ->
103+
report.addToTab("Run Info", "After-Retry", isAfterRetry)
104+
config.gradleEnterpriseServer?.let(logLinesReversed::parseBuildScan)?.let { scanLink
105+
->
106+
report.addToTab("Run Info", "Build-Scan", scanLink)
107+
}
108+
report.addToTab("Extensions", "Explanation", result.explanation)
109+
}
110+
}
111+
?: run { verboseEcho("Skipping bugsnag reporting: ${result.retrySignal}") }
112+
113+
if (result.retrySignal is RetrySignal.Ack) {
114+
echo("Acknowledging issue but cannot retry: ${result.message}")
115+
} else {
116+
echo("Found retry signal: ${result.retrySignal}")
117+
}
118+
return result.retrySignal
119+
}
120+
}
121+
}
122+
86123
echo("No actionable items found in ${logFile.name}")
87124
return RetrySignal.Unknown
88125
}

src/main/kotlin/slack/cli/shellsentry/ShellSentry.kt

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public data class ShellSentry(
5353
private val debug: Boolean = false,
5454
private val noExit: Boolean = false,
5555
private val logger: (String) -> Unit = ::println,
56+
private val extensions: List<ShellSentryExtension> = emptyList()
5657
) {
5758

5859
@Suppress("CyclomaticComplexMethod", "LongMethod")
@@ -70,9 +71,9 @@ public data class ShellSentry(
7071
)
7172

7273
logger("Processing CI failure")
73-
val resultProcessor = ResultProcessor(verbose, bugsnagKey, config, logger)
74+
val resultProcessor = ResultProcessor(verbose, bugsnagKey, config, logger, extensions)
7475

75-
when (val retrySignal = resultProcessor.process(logFile, false)) {
76+
when (val retrySignal = resultProcessor.process(command, exitCode, logFile, false)) {
7677
is RetrySignal.Ack,
7778
RetrySignal.Unknown -> {
7879
logger("Processor exited with 0, exiting with original exit code...")
@@ -89,7 +90,12 @@ public data class ShellSentry(
8990
logFile = secondResult.outputFile
9091
if (secondResult.exitCode != 0) {
9192
// Process the second failure, then bounce out
92-
resultProcessor.process(secondResult.outputFile, isAfterRetry = true)
93+
resultProcessor.process(
94+
command,
95+
secondResult.exitCode,
96+
secondResult.outputFile,
97+
isAfterRetry = true
98+
)
9399
}
94100
}
95101
is RetrySignal.RetryImmediately -> {
@@ -100,7 +106,12 @@ public data class ShellSentry(
100106
logFile = secondResult.outputFile
101107
if (secondResult.exitCode != 0) {
102108
// Process the second failure, then bounce out
103-
resultProcessor.process(secondResult.outputFile, isAfterRetry = true)
109+
resultProcessor.process(
110+
command,
111+
secondResult.exitCode,
112+
secondResult.outputFile,
113+
isAfterRetry = true
114+
)
104115
}
105116
}
106117
}

src/main/kotlin/slack/cli/shellsentry/ShellSentryConfig.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ public data class ShellSentryConfig(
2929
@Json(name = "known_issues")
3030
val knownIssues: List<Issue> =
3131
KnownIssues::class.declaredMemberProperties.map { it.get(KnownIssues) as Issue },
32+
/**
33+
* A minimum confidence level on a scale of [0-100] to accept. [AnalysisResult]s from
34+
* [ShellSentryExtension]s with lower confidence than this will be discarded.
35+
*/
36+
@Json(name = "min_confidence") val minConfidence: Int = 75
3237
) {
3338
init {
3439
check(version == CURRENT_VERSION) {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright (C) 2023 Slack Technologies, LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package slack.cli.shellsentry
17+
18+
import java.nio.file.Path
19+
20+
/**
21+
* [ShellSentryExtension] is an extension API to bring your own, complex checkers to [ShellSentry].
22+
*
23+
* Extensions are given the command, exit code, and console output of the failed command. They can
24+
* then process it however they want and return an [AnalysisResult] if they find an issue.
25+
*
26+
* ## Example
27+
*
28+
* Below is an example of an extension that asks an AI chat bot to diagnose a failure.
29+
*
30+
* ```kotlin
31+
* public class AiExtension(private val aiClient: AiClient) : ShellSentryExtension {
32+
* override fun check(command: String, exitCode: Int, isAfterRetry: Boolean, consoleOutput: Path): AnalysisResult? {
33+
* val text = consoleOutput.readText()
34+
* val rawAnalysis = aiClient.analyze(text)
35+
* return rawAnalysis.toAnalysisResult()
36+
* }
37+
* }
38+
* ```
39+
*/
40+
public fun interface ShellSentryExtension {
41+
/**
42+
* Returns a result of this extension's analysis. Returns null if this extension could not handle
43+
* failure.
44+
*
45+
* @param command the command that was executed.
46+
* @param exitCode the exit code of the command. Guaranteed to be non-zero.
47+
* @param isAfterRetry whether this is after a retry.
48+
* @param consoleOutput the path to the console output of the command.
49+
* @see AnalysisResult for more details on what goes in a result.
50+
*/
51+
public fun check(
52+
command: String,
53+
exitCode: Int,
54+
isAfterRetry: Boolean,
55+
consoleOutput: Path
56+
): AnalysisResult?
57+
}
58+
59+
/** A returned analysis result in a [ShellSentryExtension]. */
60+
public data class AnalysisResult(
61+
/**
62+
* A broad single-line description of the error without specifying exact details, suitable for
63+
* crash reporter grouping.
64+
*/
65+
val message: String,
66+
/** A detailed, multi-line message explaining the error and suggesting a solution. */
67+
val explanation: String,
68+
/** A [RetrySignal] indicating if this can be retried. */
69+
val retrySignal: RetrySignal,
70+
/**
71+
* A confidence level, on a scale of [0-100]. This is useful for dynamic analysis that made be
72+
* subject to confidence levels, such as an AI analyzer.
73+
*/
74+
val confidence: Int,
75+
/**
76+
* A function that takes the [message] and returns a [Throwable] for reporting to Bugsnag.
77+
* Consider subclassing [NoStacktraceThrowable] if needed.
78+
*/
79+
val throwableMaker: (message: String) -> Throwable
80+
)

0 commit comments

Comments
 (0)