Initial commit
This commit is contained in:
commit
2a3bc7e9a4
37
.gitea/workflows/pr-review.yml
Normal file
37
.gitea/workflows/pr-review.yml
Normal file
@ -0,0 +1,37 @@
|
||||
name: Claude PR Bot
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
claude-bot:
|
||||
runs-on: native
|
||||
if: >-
|
||||
(github.event_name == 'pull_request') ||
|
||||
(github.event_name == 'pull_request_review') ||
|
||||
(github.event_name == 'pull_request_review_comment') ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
run: curl -fsSL https://claude.ai/install.sh | bash
|
||||
|
||||
- name: Download os-ai-pr-bot
|
||||
run: |
|
||||
curl -fsSL -o /usr/local/bin/os-ai-pr-bot \
|
||||
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/latest/assets/os-ai-pr-bot" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}"
|
||||
chmod +x /usr/local/bin/os-ai-pr-bot
|
||||
|
||||
- name: Run Claude Bot
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: os-ai-pr-bot
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
dist-newstyle
|
||||
15
app/Main.hs
Normal file
15
app/Main.hs
Normal file
@ -0,0 +1,15 @@
|
||||
module Main (main) where
|
||||
|
||||
import System.Exit (exitFailure)
|
||||
import System.IO (hPutStrLn, stderr)
|
||||
|
||||
import Bot.Config (loadConfig, Config (..))
|
||||
import Bot.Event (parseEvent)
|
||||
import Bot.Review (handleEvent)
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
config <- loadConfig
|
||||
parseEvent (cfgEventName config) (cfgEventPath config) >>= \case
|
||||
Left err -> hPutStrLn stderr ("Event parse error: " <> err) >> exitFailure
|
||||
Right event -> handleEvent config event
|
||||
38
os-ai-pr-bot.cabal
Normal file
38
os-ai-pr-bot.cabal
Normal file
@ -0,0 +1,38 @@
|
||||
cabal-version: 3.0
|
||||
name: os-ai-pr-bot
|
||||
version: 0.1.0.0
|
||||
synopsis: Gitea Actions PR review bot using Claude Code
|
||||
license: MIT
|
||||
|
||||
executable os-ai-pr-bot
|
||||
default-language: GHC2021
|
||||
hs-source-dirs: app, src
|
||||
main-is: Main.hs
|
||||
other-modules:
|
||||
Bot.Config
|
||||
Bot.Event
|
||||
Bot.Trigger
|
||||
Bot.Gitea
|
||||
Bot.Claude
|
||||
Bot.Prompt
|
||||
Bot.Review
|
||||
|
||||
build-depends:
|
||||
, aeson >=2.0
|
||||
, base >=4.16 && <5
|
||||
, bytestring >=0.11
|
||||
, directory >=1.3
|
||||
, filepath >=1.4
|
||||
, http-client >=0.7
|
||||
, http-client-tls >=0.3
|
||||
, http-types >=0.12
|
||||
, process >=1.6
|
||||
, text >=2.0
|
||||
, time >=1.12
|
||||
, regex-tdfa >=1.3
|
||||
|
||||
default-extensions:
|
||||
LambdaCase
|
||||
OverloadedStrings
|
||||
|
||||
ghc-options: -Wall -O2
|
||||
142
src/Bot/Claude.hs
Normal file
142
src/Bot/Claude.hs
Normal file
@ -0,0 +1,142 @@
|
||||
module Bot.Claude
|
||||
( ReviewOutput (..)
|
||||
, ClaudeComment (..)
|
||||
, invokeClaudeReview
|
||||
, invokeClaudeReply
|
||||
) where
|
||||
|
||||
import Data.Aeson
|
||||
import Data.Aeson.Types (Parser, parseMaybe)
|
||||
import Data.ByteString.Lazy qualified as BL
|
||||
import Data.Text (Text)
|
||||
import Data.Text qualified as T
|
||||
import Data.Text.Encoding qualified as TE
|
||||
import Data.Text.IO qualified as TIO
|
||||
import System.Directory (getTemporaryDirectory, removeFile)
|
||||
import System.Exit (ExitCode (..))
|
||||
import System.FilePath ((</>))
|
||||
import System.Process (readProcessWithExitCode)
|
||||
|
||||
data ReviewOutput = ReviewOutput
|
||||
{ roSummary :: Text
|
||||
, roComments :: [ClaudeComment]
|
||||
}
|
||||
|
||||
data ClaudeComment = ClaudeComment
|
||||
{ ccPath :: Text
|
||||
, ccLine :: Int
|
||||
, ccComment :: Text
|
||||
}
|
||||
|
||||
instance FromJSON ReviewOutput where
|
||||
parseJSON = withObject "ReviewOutput" $ \o -> do
|
||||
summary <- o .: "summary"
|
||||
comments <- o .:? "comments" .!= []
|
||||
pure ReviewOutput { roSummary = summary, roComments = comments }
|
||||
|
||||
instance FromJSON ClaudeComment where
|
||||
parseJSON = withObject "ClaudeComment" $ \o -> do
|
||||
path <- o .: "path"
|
||||
line <- o .: "line"
|
||||
comment <- o .: "comment"
|
||||
pure ClaudeComment { ccPath = path, ccLine = line, ccComment = comment }
|
||||
|
||||
reviewJsonSchema :: String
|
||||
reviewJsonSchema = unlines
|
||||
[ "{"
|
||||
, " \"type\": \"object\","
|
||||
, " \"required\": [\"summary\", \"comments\"],"
|
||||
, " \"properties\": {"
|
||||
, " \"summary\": { \"type\": \"string\" },"
|
||||
, " \"comments\": {"
|
||||
, " \"type\": \"array\","
|
||||
, " \"items\": {"
|
||||
, " \"type\": \"object\","
|
||||
, " \"required\": [\"path\", \"line\", \"comment\"],"
|
||||
, " \"properties\": {"
|
||||
, " \"path\": { \"type\": \"string\" },"
|
||||
, " \"line\": { \"type\": \"integer\" },"
|
||||
, " \"comment\": { \"type\": \"string\" }"
|
||||
, " },"
|
||||
, " \"additionalProperties\": false"
|
||||
, " }"
|
||||
, " }"
|
||||
, " },"
|
||||
, " \"additionalProperties\": false"
|
||||
, "}"
|
||||
]
|
||||
|
||||
invokeClaudeReview :: Text -> IO (Either String ReviewOutput)
|
||||
invokeClaudeReview prompt = do
|
||||
tmpDir <- getTemporaryDirectory
|
||||
let promptFile = tmpDir </> "claude-review-prompt.txt"
|
||||
schemaFile = tmpDir </> "claude-review-schema.json"
|
||||
TIO.writeFile promptFile prompt
|
||||
writeFile schemaFile reviewJsonSchema
|
||||
(exitCode, stdout, stderr_) <- readProcessWithExitCode "claude"
|
||||
[ "-p"
|
||||
, "--output-format", "json"
|
||||
, "--max-turns", "1"
|
||||
, "--prompt-file", promptFile
|
||||
, "--json-schema", schemaFile
|
||||
] ""
|
||||
removeFile promptFile
|
||||
removeFile schemaFile
|
||||
case exitCode of
|
||||
ExitSuccess -> pure $ parseClaudeJsonOutput stdout
|
||||
ExitFailure code ->
|
||||
pure $ Left $ "claude exited with code " <> show code <> ": " <> stderr_
|
||||
|
||||
invokeClaudeReply :: Text -> IO (Either String Text)
|
||||
invokeClaudeReply prompt = do
|
||||
tmpDir <- getTemporaryDirectory
|
||||
let promptFile = tmpDir </> "claude-reply-prompt.txt"
|
||||
TIO.writeFile promptFile prompt
|
||||
(exitCode, stdout, stderr_) <- readProcessWithExitCode "claude"
|
||||
[ "-p"
|
||||
, "--output-format", "json"
|
||||
, "--max-turns", "1"
|
||||
, "--prompt-file", promptFile
|
||||
] ""
|
||||
removeFile promptFile
|
||||
case exitCode of
|
||||
ExitSuccess -> pure $ parseClaudeTextOutput stdout
|
||||
ExitFailure code ->
|
||||
pure $ Left $ "claude exited with code " <> show code <> ": " <> stderr_
|
||||
|
||||
-- Parse Claude's JSON envelope: { "result": <structured output> }
|
||||
parseClaudeJsonOutput :: String -> Either String ReviewOutput
|
||||
parseClaudeJsonOutput raw =
|
||||
case eitherDecode (strToLBS raw) of
|
||||
Right val ->
|
||||
case parseMaybe extractResult val of
|
||||
Just (String resultText) ->
|
||||
-- result is a JSON string that needs to be parsed again
|
||||
eitherDecode (BL.fromStrict $ TE.encodeUtf8 resultText)
|
||||
Just resultVal ->
|
||||
-- result is already a JSON object
|
||||
case fromJSON resultVal of
|
||||
Success v -> Right v
|
||||
Error e -> Left $ "Failed to parse result object: " <> e
|
||||
Nothing -> Left "Missing 'result' field in Claude output"
|
||||
Left err -> Left $ "Failed to parse Claude JSON: " <> err
|
||||
where
|
||||
extractResult :: Value -> Parser Value
|
||||
extractResult = withObject "envelope" (.: "result")
|
||||
|
||||
-- Parse Claude's JSON envelope for freeform text: { "result": "..." }
|
||||
parseClaudeTextOutput :: String -> Either String Text
|
||||
parseClaudeTextOutput raw =
|
||||
case eitherDecode (strToLBS raw) of
|
||||
Right val ->
|
||||
case parseMaybe extractResult val of
|
||||
Just (String resultText) -> Right resultText
|
||||
Just _ -> Left "Expected string 'result' in Claude output"
|
||||
Nothing -> Left "Missing 'result' field in Claude output"
|
||||
Left err -> Left $ "Failed to parse Claude JSON: " <> err
|
||||
where
|
||||
extractResult :: Value -> Parser Value
|
||||
extractResult = withObject "envelope" (.: "result")
|
||||
|
||||
strToLBS :: String -> BL.ByteString
|
||||
strToLBS = BL.fromStrict . TE.encodeUtf8 . T.pack
|
||||
65
src/Bot/Config.hs
Normal file
65
src/Bot/Config.hs
Normal file
@ -0,0 +1,65 @@
|
||||
module Bot.Config
|
||||
( Config (..)
|
||||
, loadConfig
|
||||
) where
|
||||
|
||||
import Data.Text (Text)
|
||||
import Data.Text qualified as T
|
||||
import System.Environment (lookupEnv)
|
||||
import System.Exit (exitFailure)
|
||||
import System.IO (hPutStrLn, stderr)
|
||||
|
||||
data Config = Config
|
||||
{ cfgGiteaUrl :: Text
|
||||
, cfgGiteaToken :: Text
|
||||
, cfgRepoOwner :: Text
|
||||
, cfgRepoName :: Text
|
||||
, cfgEventName :: Text
|
||||
, cfgEventPath :: FilePath
|
||||
, cfgRunId :: Text
|
||||
, cfgBotUser :: Text -- filled in later by Gitea.getCurrentUser
|
||||
, cfgTriggerPhrase :: Text
|
||||
}
|
||||
|
||||
loadConfig :: IO Config
|
||||
loadConfig = do
|
||||
giteaUrl <- requireEnv "GITHUB_SERVER_URL"
|
||||
token <- requireEnv "GITHUB_TOKEN"
|
||||
repository <- requireEnv "GITHUB_REPOSITORY"
|
||||
eventName <- requireEnv "GITHUB_EVENT_NAME"
|
||||
eventPath <- requireEnv "GITHUB_EVENT_PATH"
|
||||
runId <- lookupEnvText "GITHUB_RUN_ID" ""
|
||||
trigger <- lookupEnvText "INPUT_TRIGGER_PHRASE" "@claude"
|
||||
|
||||
let repoText = T.pack repository
|
||||
(owner, name) <- case T.splitOn "/" repoText of
|
||||
[o, n] -> pure (o, n)
|
||||
_ -> do
|
||||
hPutStrLn stderr $ "Invalid GITHUB_REPOSITORY format: " <> repository
|
||||
exitFailure
|
||||
|
||||
pure Config
|
||||
{ cfgGiteaUrl = T.pack giteaUrl
|
||||
, cfgGiteaToken = T.pack token
|
||||
, cfgRepoOwner = owner
|
||||
, cfgRepoName = name
|
||||
, cfgEventName = T.pack eventName
|
||||
, cfgEventPath = eventPath
|
||||
, cfgRunId = runId
|
||||
, cfgBotUser = "" -- set after getCurrentUser
|
||||
, cfgTriggerPhrase = trigger
|
||||
}
|
||||
|
||||
requireEnv :: String -> IO String
|
||||
requireEnv name = do
|
||||
val <- lookupEnv name
|
||||
case val of
|
||||
Just v -> pure v
|
||||
Nothing -> do
|
||||
hPutStrLn stderr $ "Missing required environment variable: " <> name
|
||||
exitFailure
|
||||
|
||||
lookupEnvText :: String -> Text -> IO Text
|
||||
lookupEnvText name def = do
|
||||
val <- lookupEnv name
|
||||
pure $ maybe def T.pack val
|
||||
147
src/Bot/Event.hs
Normal file
147
src/Bot/Event.hs
Normal file
@ -0,0 +1,147 @@
|
||||
module Bot.Event
|
||||
( Event (..)
|
||||
, PullRequestEvent (..)
|
||||
, IssueCommentEvent (..)
|
||||
, PRReviewEvent (..)
|
||||
, PRReviewCommentEvent (..)
|
||||
, parseEvent
|
||||
) where
|
||||
|
||||
import Data.Aeson
|
||||
import Data.Aeson.Types (Parser)
|
||||
import Data.ByteString.Lazy qualified as BL
|
||||
import Data.Text (Text)
|
||||
import Data.Text qualified as T
|
||||
|
||||
data Event
|
||||
= EvPullRequest PullRequestEvent
|
||||
| EvIssueComment IssueCommentEvent
|
||||
| EvPRReview PRReviewEvent
|
||||
| EvPRReviewComment PRReviewCommentEvent
|
||||
|
||||
data PullRequestEvent = PullRequestEvent
|
||||
{ preAction :: Text
|
||||
, preNumber :: Int
|
||||
, preTitle :: Text
|
||||
, preBody :: Maybe Text
|
||||
, preDraft :: Bool
|
||||
, preSender :: Text
|
||||
, preHead :: Text
|
||||
, preBase :: Text
|
||||
}
|
||||
|
||||
data IssueCommentEvent = IssueCommentEvent
|
||||
{ iceAction :: Text
|
||||
, iceIsPull :: Bool
|
||||
, iceNumber :: Int
|
||||
, iceCommentBody :: Text
|
||||
, iceSender :: Text
|
||||
}
|
||||
|
||||
data PRReviewEvent = PRReviewEvent
|
||||
{ prreAction :: Text
|
||||
, prreNumber :: Int
|
||||
, prreTitle :: Text
|
||||
, prreReviewBody :: Text
|
||||
, prreSender :: Text
|
||||
}
|
||||
|
||||
data PRReviewCommentEvent = PRReviewCommentEvent
|
||||
{ prrceAction :: Text
|
||||
, prrceNumber :: Int
|
||||
, prrceTitle :: Text
|
||||
, prrceCommentBody :: Text
|
||||
, prrceSender :: Text
|
||||
}
|
||||
|
||||
instance FromJSON PullRequestEvent where
|
||||
parseJSON = withObject "PullRequestEvent" $ \o -> do
|
||||
action <- o .: "action"
|
||||
pr <- o .: "pull_request"
|
||||
number <- pr .: "number"
|
||||
title <- pr .: "title"
|
||||
body <- pr .:? "body"
|
||||
draft <- pr .:? "draft" .!= False
|
||||
head_ <- pr .: "head" >>= (.: "label")
|
||||
base <- pr .: "base" >>= (.: "label")
|
||||
sender <- parseSender o
|
||||
pure PullRequestEvent
|
||||
{ preAction = action
|
||||
, preNumber = number
|
||||
, preTitle = title
|
||||
, preBody = body
|
||||
, preDraft = draft
|
||||
, preSender = sender
|
||||
, preHead = head_
|
||||
, preBase = base
|
||||
}
|
||||
|
||||
instance FromJSON IssueCommentEvent where
|
||||
parseJSON = withObject "IssueCommentEvent" $ \o -> do
|
||||
action <- o .: "action"
|
||||
issue <- o .: "issue"
|
||||
number <- issue .: "number"
|
||||
isPull <- do
|
||||
pr <- issue .:? "pull_request" :: Parser (Maybe Value)
|
||||
pure $ case pr of
|
||||
Just (Object _) -> True
|
||||
_ -> False
|
||||
comment <- o .: "comment"
|
||||
body <- comment .: "body"
|
||||
sender <- parseSender o
|
||||
pure IssueCommentEvent
|
||||
{ iceAction = action
|
||||
, iceIsPull = isPull
|
||||
, iceNumber = number
|
||||
, iceCommentBody = body
|
||||
, iceSender = sender
|
||||
}
|
||||
|
||||
instance FromJSON PRReviewEvent where
|
||||
parseJSON = withObject "PRReviewEvent" $ \o -> do
|
||||
action <- o .: "action"
|
||||
pr <- o .: "pull_request"
|
||||
number <- pr .: "number"
|
||||
title <- pr .: "title"
|
||||
review <- o .: "review"
|
||||
body <- review .:? "body" .!= ""
|
||||
sender <- parseSender o
|
||||
pure PRReviewEvent
|
||||
{ prreAction = action
|
||||
, prreNumber = number
|
||||
, prreTitle = title
|
||||
, prreReviewBody = body
|
||||
, prreSender = sender
|
||||
}
|
||||
|
||||
instance FromJSON PRReviewCommentEvent where
|
||||
parseJSON = withObject "PRReviewCommentEvent" $ \o -> do
|
||||
action <- o .: "action"
|
||||
pr <- o .: "pull_request"
|
||||
number <- pr .: "number"
|
||||
title <- pr .: "title"
|
||||
comment <- o .: "comment"
|
||||
body <- comment .: "body"
|
||||
sender <- parseSender o
|
||||
pure PRReviewCommentEvent
|
||||
{ prrceAction = action
|
||||
, prrceNumber = number
|
||||
, prrceTitle = title
|
||||
, prrceCommentBody = body
|
||||
, prrceSender = sender
|
||||
}
|
||||
|
||||
parseSender :: Object -> Parser Text
|
||||
parseSender o = do
|
||||
sender <- o .: "sender"
|
||||
sender .: "login"
|
||||
|
||||
parseEvent :: Text -> FilePath -> IO (Either String Event)
|
||||
parseEvent eventName path = do
|
||||
bytes <- BL.readFile path
|
||||
pure $ case eventName of
|
||||
_ | eventName == "pull_request" -> EvPullRequest <$> eitherDecode bytes
|
||||
| eventName == "issue_comment" -> EvIssueComment <$> eitherDecode bytes
|
||||
| eventName == "pull_request_review" -> EvPRReview <$> eitherDecode bytes
|
||||
| eventName == "pull_request_review_comment" -> EvPRReviewComment <$> eitherDecode bytes
|
||||
| otherwise -> Left $ "Unsupported event type: " <> T.unpack eventName
|
||||
262
src/Bot/Gitea.hs
Normal file
262
src/Bot/Gitea.hs
Normal file
@ -0,0 +1,262 @@
|
||||
module Bot.Gitea
|
||||
( GiteaClient
|
||||
, newGiteaClient
|
||||
, getCurrentUser
|
||||
, getPrInfo
|
||||
, getPrDiff
|
||||
, getIssueComments
|
||||
, getPrReviews
|
||||
, createComment
|
||||
, updateComment
|
||||
, postReview
|
||||
, PrInfo (..)
|
||||
, GiteaComment (..)
|
||||
, GiteaReview (..)
|
||||
, InlineComment (..)
|
||||
) where
|
||||
|
||||
import Data.Aeson
|
||||
import Data.Aeson.Types (parseMaybe)
|
||||
import Data.ByteString.Char8 qualified as BS
|
||||
import Data.ByteString.Lazy qualified as BL
|
||||
import Data.Text (Text)
|
||||
import Data.Text qualified as T
|
||||
import Data.Text.Encoding qualified as TE
|
||||
import Data.Time (UTCTime)
|
||||
import Network.HTTP.Client
|
||||
import Network.HTTP.Client.TLS (tlsManagerSettings)
|
||||
import Network.HTTP.Types.Status (statusCode)
|
||||
|
||||
import Bot.Config (Config (..))
|
||||
|
||||
data GiteaClient = GiteaClient
|
||||
{ gcManager :: Manager
|
||||
, gcBaseUrl :: Text -- e.g. "https://gitea.example.com/api/v1"
|
||||
, gcToken :: Text
|
||||
, gcOwner :: Text
|
||||
, gcRepo :: Text
|
||||
}
|
||||
|
||||
newGiteaClient :: Config -> IO GiteaClient
|
||||
newGiteaClient cfg = do
|
||||
mgr <- newManager tlsManagerSettings
|
||||
pure GiteaClient
|
||||
{ gcManager = mgr
|
||||
, gcBaseUrl = cfgGiteaUrl cfg <> "/api/v1"
|
||||
, gcToken = cfgGiteaToken cfg
|
||||
, gcOwner = cfgRepoOwner cfg
|
||||
, gcRepo = cfgRepoName cfg
|
||||
}
|
||||
|
||||
-- Helpers
|
||||
|
||||
repoPath :: GiteaClient -> Text
|
||||
repoPath gc = "/repos/" <> gcOwner gc <> "/" <> gcRepo gc
|
||||
|
||||
apiGet :: FromJSON a => GiteaClient -> Text -> IO a
|
||||
apiGet gc path = do
|
||||
let url = T.unpack (gcBaseUrl gc <> path)
|
||||
req <- parseRequest url
|
||||
let req' = req
|
||||
{ requestHeaders =
|
||||
[ ("Authorization", "token " <> TE.encodeUtf8 (gcToken gc))
|
||||
, ("Accept", "application/json")
|
||||
]
|
||||
}
|
||||
resp <- httpLbs req' (gcManager gc)
|
||||
case eitherDecode (responseBody resp) of
|
||||
Right v -> pure v
|
||||
Left err -> fail $ "API GET " <> url <> " decode error: " <> err
|
||||
|
||||
apiGetText :: GiteaClient -> Text -> IO Text
|
||||
apiGetText gc path = do
|
||||
let url = T.unpack (gcBaseUrl gc <> path)
|
||||
req <- parseRequest url
|
||||
let req' = req
|
||||
{ requestHeaders =
|
||||
[ ("Authorization", "token " <> TE.encodeUtf8 (gcToken gc))
|
||||
, ("Accept", "text/plain")
|
||||
]
|
||||
}
|
||||
resp <- httpLbs req' (gcManager gc)
|
||||
pure $ TE.decodeUtf8 $ BL.toStrict $ responseBody resp
|
||||
|
||||
apiPost :: FromJSON a => GiteaClient -> Text -> Value -> IO a
|
||||
apiPost gc path body = do
|
||||
let url = T.unpack (gcBaseUrl gc <> path)
|
||||
initReq <- parseRequest url
|
||||
let req = initReq
|
||||
{ method = "POST"
|
||||
, requestHeaders =
|
||||
[ ("Authorization", "token " <> TE.encodeUtf8 (gcToken gc))
|
||||
, ("Accept", "application/json")
|
||||
, ("Content-Type", "application/json")
|
||||
]
|
||||
, requestBody = RequestBodyLBS (encode body)
|
||||
}
|
||||
resp <- httpLbs req (gcManager gc)
|
||||
case eitherDecode (responseBody resp) of
|
||||
Right v -> pure v
|
||||
Left err -> fail $ "API POST " <> url <> " decode error: " <> err
|
||||
<> "\nStatus: " <> show (statusCode $ responseStatus resp)
|
||||
<> "\nBody: " <> show (BL.toStrict $ responseBody resp)
|
||||
|
||||
apiPatch :: GiteaClient -> Text -> Value -> IO ()
|
||||
apiPatch gc path body = do
|
||||
let url = T.unpack (gcBaseUrl gc <> path)
|
||||
initReq <- parseRequest url
|
||||
let req = initReq
|
||||
{ method = "PATCH"
|
||||
, requestHeaders =
|
||||
[ ("Authorization", "token " <> TE.encodeUtf8 (gcToken gc))
|
||||
, ("Accept", "application/json")
|
||||
, ("Content-Type", "application/json")
|
||||
]
|
||||
, requestBody = RequestBodyLBS (encode body)
|
||||
}
|
||||
resp <- httpLbs req (gcManager gc)
|
||||
let sc = statusCode (responseStatus resp)
|
||||
if sc >= 200 && sc < 300
|
||||
then pure ()
|
||||
else fail $ "API PATCH " <> url <> " failed with status " <> show sc
|
||||
<> ": " <> BS.unpack (BL.toStrict $ responseBody resp)
|
||||
|
||||
apiPostRaw :: GiteaClient -> Text -> Value -> IO (Int, BL.ByteString)
|
||||
apiPostRaw gc path body = do
|
||||
let url = T.unpack (gcBaseUrl gc <> path)
|
||||
initReq <- parseRequest url
|
||||
let req = initReq
|
||||
{ method = "POST"
|
||||
, requestHeaders =
|
||||
[ ("Authorization", "token " <> TE.encodeUtf8 (gcToken gc))
|
||||
, ("Accept", "application/json")
|
||||
, ("Content-Type", "application/json")
|
||||
]
|
||||
, requestBody = RequestBodyLBS (encode body)
|
||||
}
|
||||
resp <- httpLbs req (gcManager gc)
|
||||
pure (statusCode $ responseStatus resp, responseBody resp)
|
||||
|
||||
-- API operations
|
||||
|
||||
getCurrentUser :: GiteaClient -> IO Text
|
||||
getCurrentUser gc = do
|
||||
v <- apiGet gc "/user" :: IO Value
|
||||
case parseMaybe parseLogin v of
|
||||
Just login -> pure login
|
||||
Nothing -> fail "GET /user: missing login field"
|
||||
where
|
||||
parseLogin = withObject "User" $ \o -> o .: "login"
|
||||
|
||||
data PrInfo = PrInfo
|
||||
{ piTitle :: Text
|
||||
, piBody :: Maybe Text
|
||||
, piUser :: Text
|
||||
, piHead :: Text
|
||||
, piBase :: Text
|
||||
}
|
||||
|
||||
instance FromJSON PrInfo where
|
||||
parseJSON = withObject "PrInfo" $ \o -> do
|
||||
title <- o .: "title"
|
||||
body <- o .:? "body"
|
||||
user <- o .: "user" >>= (.: "login")
|
||||
head_ <- o .: "head" >>= (.: "label")
|
||||
base <- o .: "base" >>= (.: "label")
|
||||
pure PrInfo
|
||||
{ piTitle = title
|
||||
, piBody = body
|
||||
, piUser = user
|
||||
, piHead = head_
|
||||
, piBase = base
|
||||
}
|
||||
|
||||
getPrInfo :: GiteaClient -> Int -> IO PrInfo
|
||||
getPrInfo gc n =
|
||||
apiGet gc (repoPath gc <> "/pulls/" <> T.pack (show n))
|
||||
|
||||
getPrDiff :: GiteaClient -> Int -> IO Text
|
||||
getPrDiff gc n =
|
||||
apiGetText gc (repoPath gc <> "/pulls/" <> T.pack (show n) <> ".diff")
|
||||
|
||||
data GiteaComment = GiteaComment
|
||||
{ gcBody :: Text
|
||||
, gcUser :: Text
|
||||
, gcCreatedAt :: UTCTime
|
||||
}
|
||||
|
||||
instance FromJSON GiteaComment where
|
||||
parseJSON = withObject "GiteaComment" $ \o -> do
|
||||
body <- o .: "body"
|
||||
user <- o .: "user" >>= (.: "login")
|
||||
createdAt <- o .: "created_at"
|
||||
pure GiteaComment
|
||||
{ gcBody = body
|
||||
, gcUser = user
|
||||
, gcCreatedAt = createdAt
|
||||
}
|
||||
|
||||
getIssueComments :: GiteaClient -> Int -> IO [GiteaComment]
|
||||
getIssueComments gc n =
|
||||
apiGet gc (repoPath gc <> "/issues/" <> T.pack (show n) <> "/comments")
|
||||
|
||||
data GiteaReview = GiteaReview
|
||||
{ grBody :: Maybe Text
|
||||
, grUser :: Text
|
||||
, grState :: Text
|
||||
}
|
||||
|
||||
instance FromJSON GiteaReview where
|
||||
parseJSON = withObject "GiteaReview" $ \o -> do
|
||||
body <- o .:? "body"
|
||||
user <- o .: "user" >>= (.: "login")
|
||||
state <- o .: "state"
|
||||
pure GiteaReview
|
||||
{ grBody = body
|
||||
, grUser = user
|
||||
, grState = state
|
||||
}
|
||||
|
||||
getPrReviews :: GiteaClient -> Int -> IO [GiteaReview]
|
||||
getPrReviews gc n =
|
||||
apiGet gc (repoPath gc <> "/pulls/" <> T.pack (show n) <> "/reviews")
|
||||
|
||||
-- Returns the comment ID
|
||||
createComment :: GiteaClient -> Int -> Text -> IO Int
|
||||
createComment gc n body = do
|
||||
let payload = object ["body" .= body]
|
||||
v <- apiPost gc (repoPath gc <> "/issues/" <> T.pack (show n) <> "/comments") payload
|
||||
case parseMaybe parseId (v :: Value) of
|
||||
Just i -> pure i
|
||||
Nothing -> fail "createComment: missing 'id' field"
|
||||
where
|
||||
parseId = withObject "Comment" $ \o -> o .: "id"
|
||||
|
||||
updateComment :: GiteaClient -> Int -> Text -> IO ()
|
||||
updateComment gc commentId body = do
|
||||
let payload = object ["body" .= body]
|
||||
apiPatch gc (repoPath gc <> "/issues/comments/" <> T.pack (show commentId)) payload
|
||||
|
||||
data InlineComment = InlineComment
|
||||
{ icPath :: Text
|
||||
, icNewPosition :: Int
|
||||
, icBody :: Text
|
||||
}
|
||||
|
||||
postReview :: GiteaClient -> Int -> Text -> [InlineComment] -> IO ()
|
||||
postReview gc prNum body comments = do
|
||||
let commentObjs = map (\ic -> object
|
||||
[ "path" .= icPath ic
|
||||
, "new_position" .= icNewPosition ic
|
||||
, "body" .= icBody ic
|
||||
]) comments
|
||||
payload = object
|
||||
[ "body" .= body
|
||||
, "event" .= ("COMMENT" :: Text)
|
||||
, "comments" .= commentObjs
|
||||
]
|
||||
(sc, respBody) <- apiPostRaw gc (repoPath gc <> "/pulls/" <> T.pack (show prNum) <> "/reviews") payload
|
||||
if sc >= 200 && sc < 300
|
||||
then pure ()
|
||||
else fail $ "postReview failed with status " <> show sc
|
||||
<> ": " <> BS.unpack (BL.toStrict respBody)
|
||||
150
src/Bot/Prompt.hs
Normal file
150
src/Bot/Prompt.hs
Normal file
@ -0,0 +1,150 @@
|
||||
module Bot.Prompt
|
||||
( PRContext (..)
|
||||
, buildReviewPrompt
|
||||
, buildReplyPrompt
|
||||
) where
|
||||
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import Data.Text qualified as T
|
||||
import Data.Time (formatTime, defaultTimeLocale)
|
||||
|
||||
import Bot.Config (Config (..))
|
||||
import Bot.Event
|
||||
import Bot.Gitea (PrInfo (..), GiteaComment (..), GiteaReview (..))
|
||||
|
||||
data PRContext = PRContext
|
||||
{ ctxPrInfo :: PrInfo
|
||||
, ctxDiff :: Text
|
||||
, ctxComments :: [GiteaComment]
|
||||
, ctxReviews :: [GiteaReview]
|
||||
}
|
||||
|
||||
buildReviewPrompt :: Config -> Event -> PRContext -> Text
|
||||
buildReviewPrompt cfg event ctx =
|
||||
T.unlines
|
||||
[ "You are Claude, an AI assistant reviewing a Gitea pull request."
|
||||
, ""
|
||||
, "<context>"
|
||||
, "PR Title: " <> piTitle (ctxPrInfo ctx)
|
||||
, "PR Author: " <> piUser (ctxPrInfo ctx)
|
||||
, "PR Branch: " <> piHead (ctxPrInfo ctx) <> " → " <> piBase (ctxPrInfo ctx)
|
||||
, "</context>"
|
||||
, ""
|
||||
, "<pr_body>"
|
||||
, fromMaybe "No description provided" (piBody (ctxPrInfo ctx))
|
||||
, "</pr_body>"
|
||||
, ""
|
||||
, "<comments>"
|
||||
, formatComments (ctxComments ctx)
|
||||
, "</comments>"
|
||||
, ""
|
||||
, "<review_comments>"
|
||||
, formatReviews (ctxReviews ctx)
|
||||
, "</review_comments>"
|
||||
, ""
|
||||
, "<diff>"
|
||||
, ctxDiff ctx
|
||||
, "</diff>"
|
||||
, ""
|
||||
, "<metadata>"
|
||||
, "repository: " <> cfgRepoOwner cfg <> "/" <> cfgRepoName cfg
|
||||
, "pr_number: " <> T.pack (show (eventPRNumber event))
|
||||
, "trigger: " <> triggerContext cfg event
|
||||
, "triggered_by: " <> eventSender event
|
||||
, "</metadata>"
|
||||
, ""
|
||||
, triggerCommentSection event
|
||||
, "Provide a thorough code review. Focus on bugs, security, correctness, and suggestions."
|
||||
, "Output your response as JSON with two fields:"
|
||||
, " - \"summary\": a markdown summary of your review"
|
||||
, " - \"comments\": an array of inline comments, each with \"path\" (file path), \"line\" (line number in the new file), and \"comment\" (your feedback)"
|
||||
, "If you have no inline comments, return an empty array for \"comments\"."
|
||||
]
|
||||
|
||||
buildReplyPrompt :: Config -> Text -> PRContext -> Text
|
||||
buildReplyPrompt cfg triggerBody ctx =
|
||||
T.unlines
|
||||
[ "You are Claude, an AI assistant helping with a Gitea pull request."
|
||||
, ""
|
||||
, "<context>"
|
||||
, "PR Title: " <> piTitle (ctxPrInfo ctx)
|
||||
, "PR Author: " <> piUser (ctxPrInfo ctx)
|
||||
, "PR Branch: " <> piHead (ctxPrInfo ctx) <> " → " <> piBase (ctxPrInfo ctx)
|
||||
, "</context>"
|
||||
, ""
|
||||
, "<pr_body>"
|
||||
, fromMaybe "No description provided" (piBody (ctxPrInfo ctx))
|
||||
, "</pr_body>"
|
||||
, ""
|
||||
, "<comments>"
|
||||
, formatComments (ctxComments ctx)
|
||||
, "</comments>"
|
||||
, ""
|
||||
, "<review_comments>"
|
||||
, formatReviews (ctxReviews ctx)
|
||||
, "</review_comments>"
|
||||
, ""
|
||||
, "<diff>"
|
||||
, ctxDiff ctx
|
||||
, "</diff>"
|
||||
, ""
|
||||
, "<metadata>"
|
||||
, "repository: " <> cfgRepoOwner cfg <> "/" <> cfgRepoName cfg
|
||||
, "</metadata>"
|
||||
, ""
|
||||
, "<trigger_comment>"
|
||||
, triggerBody
|
||||
, "</trigger_comment>"
|
||||
, ""
|
||||
, "Respond helpfully and concisely to the comment above in the context of this pull request."
|
||||
]
|
||||
|
||||
-- Helpers
|
||||
|
||||
formatComments :: [GiteaComment] -> Text
|
||||
formatComments [] = "No comments"
|
||||
formatComments cs = T.intercalate "\n" $ map formatComment cs
|
||||
where
|
||||
formatComment c =
|
||||
"[" <> gcUser c <> " at " <> fmtTime (gcCreatedAt c) <> "]: " <> gcBody c
|
||||
fmtTime = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S UTC"
|
||||
|
||||
formatReviews :: [GiteaReview] -> Text
|
||||
formatReviews [] = "No review comments"
|
||||
formatReviews rs = T.intercalate "\n" $ map formatReview rs
|
||||
where
|
||||
formatReview r =
|
||||
"[Review by " <> grUser r <> "]: " <> grState r
|
||||
<> maybe "" (\b -> "\n " <> b) (grBody r)
|
||||
|
||||
eventPRNumber :: Event -> Int
|
||||
eventPRNumber = \case
|
||||
EvPullRequest pre -> preNumber pre
|
||||
EvIssueComment ice -> iceNumber ice
|
||||
EvPRReview prre -> prreNumber prre
|
||||
EvPRReviewComment prrce -> prrceNumber prrce
|
||||
|
||||
eventSender :: Event -> Text
|
||||
eventSender = \case
|
||||
EvPullRequest pre -> preSender pre
|
||||
EvIssueComment ice -> iceSender ice
|
||||
EvPRReview prre -> prreSender prre
|
||||
EvPRReviewComment prrce -> prrceSender prrce
|
||||
|
||||
triggerContext :: Config -> Event -> Text
|
||||
triggerContext cfg = \case
|
||||
EvPullRequest pre -> "pull request " <> preAction pre
|
||||
EvIssueComment _ -> "issue comment with '" <> cfgTriggerPhrase cfg <> "'"
|
||||
EvPRReview _ -> "PR review with '" <> cfgTriggerPhrase cfg <> "'"
|
||||
EvPRReviewComment _ -> "PR review comment with '" <> cfgTriggerPhrase cfg <> "'"
|
||||
|
||||
triggerCommentSection :: Event -> Text
|
||||
triggerCommentSection = \case
|
||||
EvIssueComment ice ->
|
||||
"<trigger_comment>\n" <> iceCommentBody ice <> "\n</trigger_comment>\n\n"
|
||||
EvPRReview prre ->
|
||||
"<trigger_comment>\n" <> prreReviewBody prre <> "\n</trigger_comment>\n\n"
|
||||
EvPRReviewComment prrce ->
|
||||
"<trigger_comment>\n" <> prrceCommentBody prrce <> "\n</trigger_comment>\n\n"
|
||||
_ -> ""
|
||||
168
src/Bot/Review.hs
Normal file
168
src/Bot/Review.hs
Normal file
@ -0,0 +1,168 @@
|
||||
module Bot.Review
|
||||
( handleEvent
|
||||
) where
|
||||
|
||||
import Control.Exception (SomeException, catch)
|
||||
import Data.Text (Text)
|
||||
import Data.Text qualified as T
|
||||
import System.IO (hPutStrLn, stderr)
|
||||
|
||||
import Bot.Claude
|
||||
import Bot.Config (Config (..))
|
||||
import Bot.Event
|
||||
import Bot.Gitea
|
||||
import Bot.Prompt
|
||||
import Bot.Trigger
|
||||
|
||||
handleEvent :: Config -> Event -> IO ()
|
||||
handleEvent cfg event = do
|
||||
client <- newGiteaClient cfg
|
||||
|
||||
-- Auto-detect bot username for loop prevention
|
||||
botUser <- getCurrentUser client
|
||||
let cfg' = cfg { cfgBotUser = botUser }
|
||||
putStrLn $ "Bot user: " <> T.unpack botUser
|
||||
|
||||
-- Check trigger
|
||||
case checkTrigger cfg' event of
|
||||
NotTriggered -> putStrLn "No trigger found, exiting."
|
||||
Triggered prNum -> do
|
||||
-- Loop prevention: skip events from bot itself
|
||||
let sender = eventSender event
|
||||
if sender == cfgBotUser cfg'
|
||||
then putStrLn $ "Skipping event from bot user: " <> T.unpack sender
|
||||
else dispatch cfg' client event prNum
|
||||
|
||||
dispatch :: Config -> GiteaClient -> Event -> Int -> IO ()
|
||||
dispatch cfg client event prNum = do
|
||||
-- Create tracking comment
|
||||
commentId <- createComment client prNum "\x23F3 Thinking..."
|
||||
putStrLn $ "Created tracking comment: " <> show commentId
|
||||
|
||||
-- Ensure tracking comment is updated even on error
|
||||
let jobUrl = cfgGiteaUrl cfg <> "/" <> cfgRepoOwner cfg <> "/" <> cfgRepoName cfg
|
||||
<> "/actions/runs/" <> cfgRunId cfg
|
||||
|
||||
let onError :: SomeException -> IO ()
|
||||
onError ex = do
|
||||
let errMsg = T.pack (show ex)
|
||||
hPutStrLn stderr $ "Error: " <> T.unpack errMsg
|
||||
let body = T.unlines
|
||||
[ "**Claude** encountered an error \x2014 [View job run](" <> jobUrl <> ")"
|
||||
, ""
|
||||
, T.take 2000 errMsg
|
||||
]
|
||||
updateComment client commentId body
|
||||
`catch` \(_ :: SomeException) -> pure ()
|
||||
|
||||
let run = do
|
||||
-- Gather PR context
|
||||
ctx <- gatherContext client prNum
|
||||
|
||||
case event of
|
||||
EvPullRequest _ -> do
|
||||
handleReview cfg client event prNum commentId jobUrl ctx
|
||||
|
||||
EvIssueComment _ -> do
|
||||
handleReview cfg client event prNum commentId jobUrl ctx
|
||||
|
||||
EvPRReview prre -> do
|
||||
handleReply cfg client (prreReviewBody prre) prNum commentId jobUrl ctx
|
||||
|
||||
EvPRReviewComment prrce -> do
|
||||
handleReply cfg client (prrceCommentBody prrce) prNum commentId jobUrl ctx
|
||||
|
||||
run `catch` onError
|
||||
|
||||
gatherContext :: GiteaClient -> Int -> IO PRContext
|
||||
gatherContext client prNum = do
|
||||
prInfo <- getPrInfo client prNum
|
||||
diff <- getPrDiff client prNum
|
||||
comments <- getIssueComments client prNum
|
||||
reviews <- getPrReviews client prNum
|
||||
pure PRContext
|
||||
{ ctxPrInfo = prInfo
|
||||
, ctxDiff = diff
|
||||
, ctxComments = comments
|
||||
, ctxReviews = reviews
|
||||
}
|
||||
|
||||
handleReview :: Config -> GiteaClient -> Event -> Int -> Int -> Text -> PRContext -> IO ()
|
||||
handleReview cfg client event prNum commentId jobUrl ctx = do
|
||||
let prompt = buildReviewPrompt cfg event ctx
|
||||
result <- invokeClaudeReview prompt
|
||||
case result of
|
||||
Left err -> do
|
||||
hPutStrLn stderr $ "Claude review error: " <> err
|
||||
let body = T.unlines
|
||||
[ "**Claude** encountered an error \x2014 [View job run](" <> jobUrl <> ")"
|
||||
, ""
|
||||
, T.pack err
|
||||
]
|
||||
updateComment client commentId body
|
||||
|
||||
Right review -> do
|
||||
-- Update tracking comment with summary
|
||||
let sender = eventSender event
|
||||
body = T.unlines
|
||||
[ "**Claude** reviewed @" <> sender <> "'s PR \x2014 [View job run](" <> jobUrl <> ")"
|
||||
, ""
|
||||
, "---"
|
||||
, ""
|
||||
, roSummary review
|
||||
]
|
||||
updateComment client commentId body
|
||||
|
||||
-- Post review with inline comments if any
|
||||
let inlineComments = map toInlineComment (roComments review)
|
||||
if null inlineComments
|
||||
then pure ()
|
||||
else postReviewWithFallback client prNum (roSummary review) inlineComments
|
||||
|
||||
handleReply :: Config -> GiteaClient -> Text -> Int -> Int -> Text -> PRContext -> IO ()
|
||||
handleReply cfg client triggerBody _prNum commentId jobUrl ctx = do
|
||||
let prompt = buildReplyPrompt cfg triggerBody ctx
|
||||
result <- invokeClaudeReply prompt
|
||||
case result of
|
||||
Left err -> do
|
||||
hPutStrLn stderr $ "Claude reply error: " <> err
|
||||
let body = T.unlines
|
||||
[ "**Claude** encountered an error \x2014 [View job run](" <> jobUrl <> ")"
|
||||
, ""
|
||||
, T.pack err
|
||||
]
|
||||
updateComment client commentId body
|
||||
|
||||
Right replyText -> do
|
||||
let body = T.unlines
|
||||
[ "**Claude** responded \x2014 [View job run](" <> jobUrl <> ")"
|
||||
, ""
|
||||
, "---"
|
||||
, ""
|
||||
, replyText
|
||||
]
|
||||
updateComment client commentId body
|
||||
|
||||
toInlineComment :: ClaudeComment -> InlineComment
|
||||
toInlineComment cc = InlineComment
|
||||
{ icPath = ccPath cc
|
||||
, icNewPosition = ccLine cc
|
||||
, icBody = ccComment cc
|
||||
}
|
||||
|
||||
-- Try posting review with inline comments; on failure, retry with summary only
|
||||
postReviewWithFallback :: GiteaClient -> Int -> Text -> [InlineComment] -> IO ()
|
||||
postReviewWithFallback client prNum summary comments = do
|
||||
postReview client prNum summary comments
|
||||
`catch` \(_ :: SomeException) -> do
|
||||
hPutStrLn stderr "Failed to post review with inline comments, retrying without..."
|
||||
postReview client prNum summary []
|
||||
`catch` \(_ :: SomeException) ->
|
||||
hPutStrLn stderr "Failed to post summary-only review as well."
|
||||
|
||||
eventSender :: Event -> Text
|
||||
eventSender = \case
|
||||
EvPullRequest pre -> preSender pre
|
||||
EvIssueComment ice -> iceSender ice
|
||||
EvPRReview prre -> prreSender prre
|
||||
EvPRReviewComment prrce -> prrceSender prrce
|
||||
62
src/Bot/Trigger.hs
Normal file
62
src/Bot/Trigger.hs
Normal file
@ -0,0 +1,62 @@
|
||||
module Bot.Trigger
|
||||
( TriggerResult (..)
|
||||
, checkTrigger
|
||||
) where
|
||||
|
||||
import Data.Text (Text)
|
||||
import Data.Text qualified as T
|
||||
import Text.Regex.TDFA ((=~))
|
||||
|
||||
import Bot.Config (Config (..))
|
||||
import Bot.Event
|
||||
|
||||
data TriggerResult
|
||||
= Triggered { trPRNumber :: Int }
|
||||
| NotTriggered
|
||||
|
||||
checkTrigger :: Config -> Event -> TriggerResult
|
||||
checkTrigger cfg = \case
|
||||
EvPullRequest pre
|
||||
| preAction pre `elem` ["opened", "reopened", "ready_for_review"] ->
|
||||
if matchesTrigger phrase (preTitle pre)
|
||||
|| maybe False (matchesTrigger phrase) (preBody pre)
|
||||
then Triggered (preNumber pre)
|
||||
else NotTriggered
|
||||
| otherwise -> NotTriggered
|
||||
|
||||
EvIssueComment ice
|
||||
| iceAction ice == "created" && iceIsPull ice ->
|
||||
if matchesTrigger phrase (iceCommentBody ice)
|
||||
then Triggered (iceNumber ice)
|
||||
else NotTriggered
|
||||
| otherwise -> NotTriggered
|
||||
|
||||
EvPRReview prre
|
||||
| prreAction prre == "submitted" ->
|
||||
if matchesTrigger phrase (prreReviewBody prre)
|
||||
then Triggered (prreNumber prre)
|
||||
else NotTriggered
|
||||
| otherwise -> NotTriggered
|
||||
|
||||
EvPRReviewComment prrce
|
||||
| prrceAction prrce == "created" ->
|
||||
if matchesTrigger phrase (prrceCommentBody prrce)
|
||||
then Triggered (prrceNumber prrce)
|
||||
else NotTriggered
|
||||
| otherwise -> NotTriggered
|
||||
where
|
||||
phrase = cfgTriggerPhrase cfg
|
||||
|
||||
matchesTrigger :: Text -> Text -> Bool
|
||||
matchesTrigger phrase text =
|
||||
T.unpack text =~ pattern
|
||||
where
|
||||
pattern :: String
|
||||
pattern = "(^|\\s)" <> escapeRegex (T.unpack phrase) <> "([\\s.,!?;:]|$)"
|
||||
|
||||
escapeRegex :: String -> String
|
||||
escapeRegex = concatMap esc
|
||||
where
|
||||
esc c
|
||||
| c `elem` (".*+?^${}()|[]\\" :: String) = ['\\', c]
|
||||
| otherwise = [c]
|
||||
Loading…
x
Reference in New Issue
Block a user