PR summary

This commit is contained in:
Tienson Qin
2026-02-13 04:49:22 +08:00
parent 31a9b88eac
commit 13f10d50eb
9 changed files with 259 additions and 13 deletions

View File

@@ -252,6 +252,7 @@
[:map
[:title {:optional true} :string]
[:body {:optional true} :string]
[:commit-message {:optional true} :string]
[:head-branch {:optional true} :string]
[:base-branch {:optional true} :string]
[:create-pr {:optional true} :boolean]

View File

@@ -537,14 +537,25 @@
[^js self current-session body user-id repo-url head-branch force? create-pr?]
(p/let [pr-token (source-control/pr-token (.-env self))
requested-base-branch (source-control/sanitize-branch-name (:base-branch body))
default-base (source-control/sanitize-branch-name (default-base-branch (.-env self)))
detected-base-branch (when (and (nil? requested-base-branch)
(string? pr-token))
(source-control/<default-branch! (.-env self)
pr-token
repo-url))
detected-base-branch (source-control/sanitize-branch-name detected-base-branch)
base-branch (or requested-base-branch
detected-base-branch
(default-base-branch (.-env self)))]
default-base)
base-branch (if (= base-branch head-branch)
(or (some (fn [candidate]
(let [candidate (source-control/sanitize-branch-name candidate)]
(when (and (string? candidate)
(not= candidate head-branch))
candidate)))
[default-base "main" "master"])
base-branch)
base-branch)]
(cond
(false? create-pr?)
(http/json-response :sessions/pr
@@ -587,6 +598,7 @@
[^js self current-session body user-id repo-url runtime force? create-pr? push-branch-fn]
(let [provider (runtime-provider/resolve-provider (.-env self) runtime)
push-token (source-control/push-token (.-env self))
commit-message (some-> (:commit-message body) str string/trim not-empty)
head-branch (source-control/resolve-head-branch (:head-branch body)
(generated-head-branch (:id current-session)))]
(if-not (string? head-branch)
@@ -597,11 +609,12 @@
:force force?})
push-result (push-branch-fn provider
runtime
{:session-id (:id current-session)
:repo-url repo-url
:head-branch head-branch
:force force?
:push-token push-token})
(cond-> {:session-id (:id current-session)
:repo-url repo-url
:head-branch head-branch
:force force?
:push-token push-token}
commit-message (assoc :commit-message commit-message)))
_ (<append-publish-event! self "git.push.succeeded"
{:by user-id
:head-branch head-branch

View File

@@ -430,7 +430,10 @@
(defn- push-command
[{:keys [repo-dir remote-url head-branch] :as opts}]
(let [force? (true? (:force opts))
commit-message (or (some-> (:commit-message opts) string/trim not-empty)
"chore(agent): update files")
branch (escape-shell-single head-branch)
commit-message (escape-shell-single commit-message)
remote (escape-shell-single remote-url)]
(str "set -e; "
"cd '" (escape-shell-single repo-dir) "'; "
@@ -439,7 +442,7 @@
"git add -A; "
"if git diff --cached --quiet; then true; else "
"git -c user.name='Logseq Agent' -c user.email='agent@logseq.local' "
"commit -m 'chore(agent): update files'; "
"commit -m '" commit-message "'; "
"fi; "
"git push '" remote "' HEAD:refs/heads/" branch
(when force? " --force"))))
@@ -956,6 +959,7 @@
(let [script (push-command {:repo-dir repo-dir
:remote-url remote-url
:head-branch head-branch
:commit-message (:commit-message opts)
:force force?})]
(-> (p/let [result (sprites-exec-post! env name ["bash" "-lc" script])
@@ -1119,6 +1123,7 @@
script (push-command {:repo-dir repo-dir
:remote-url remote-url
:head-branch head-branch
:commit-message (:commit-message opts)
:force force?})]
(-> (p/let [result (<cloudflare-exec! sandbox script)
{:keys [stdout stderr exit-code success]} (cloudflare-exec-output result)]

View File

@@ -2,7 +2,8 @@
(:require [cljs.test :refer [async deftest is testing]]
[clojure.string :as string]
[logseq.db-sync.worker.agent.do :as agent-do]
[logseq.db-sync.worker.agent.runtime-provider :as runtime-provider]))
[logseq.db-sync.worker.agent.runtime-provider :as runtime-provider]
[logseq.db-sync.worker.agent.source-control :as source-control]))
(defn- make-agent-storage []
(let [data (js/Map.)]
@@ -618,7 +619,8 @@
(deftest pr-endpoint-push-only-success-test
(testing "session publish endpoint supports push-only flow"
(async done
(let [env #js {"AGENT_RUNTIME_PROVIDER" "local-dev"}
(let [push-calls (atom [])
env #js {"AGENT_RUNTIME_PROVIDER" "local-dev"}
self (make-self env)
headers {"content-type" "application/json"
"x-user-id" "user-1"}]
@@ -635,6 +637,7 @@
(.then (fn [_]
(with-redefs [runtime-provider/<push-branch!
(fn [_provider _runtime opts]
(swap! push-calls conj opts)
(js/Promise.resolve
{:head-branch (:head-branch opts)
:repo-url (:repo-url opts)
@@ -645,6 +648,7 @@
"POST"
{:create-pr false
:head-branch "m14/push-only"
:commit-message "feat: summarize PR changes"
:force true}
headers)))))
(.then (fn [resp]
@@ -654,6 +658,57 @@
(is (= "pushed" (:status body)))
(is (= "m14/push-only" (:head-branch body)))
(is (= true (:force body)))
(is (= "feat: summarize PR changes"
(:commit-message (first @push-calls))))
(done)))))
(.catch (fn [error]
(is false (str "unexpected error: " error))
(done))))))))
(deftest pr-endpoint-fallbacks-base-branch-when-equal-to-head-test
(testing "session publish endpoint avoids head==base branch when resolving base branch"
(async done
(let [env #js {"AGENT_RUNTIME_PROVIDER" "local-dev"}
self (make-self env)
headers {"content-type" "application/json"
"x-user-id" "user-1"}
same-branch "logseq-agent/same-branch"]
(-> (.put (.-storage self)
"session"
(clj->js {:id "sess-pr-branch-fallback"
:status "running"
:task {:project {:repo-url "https://github.com/example/repo"}}
:runtime {:provider "local-dev"
:session-id "sess-pr-branch-fallback"}
:audit {}
:created-at 0
:updated-at 0}))
(.then (fn [_]
(with-redefs [runtime-provider/<push-branch!
(fn [_provider _runtime opts]
(js/Promise.resolve
{:head-branch (:head-branch opts)
:repo-url (:repo-url opts)
:force (:force opts)
:remote "origin"}))
source-control/pr-token
(fn [_env] "token-1")
source-control/<default-branch!
(fn [_env _token _repo-url]
(js/Promise.resolve same-branch))]
(agent-do/handle-fetch self
(json-request "http://db-sync.local/__session__/pr"
"POST"
{:create-pr false
:head-branch same-branch}
headers)))))
(.then (fn [resp]
(is (= 200 (.-status resp)))
(.then (<json resp)
(fn [body]
(is (= "pushed" (:status body)))
(is (= same-branch (:head-branch body)))
(is (= "main" (:base-branch body)))
(done)))))
(.catch (fn [error]
(is false (str "unexpected error: " error))

View File

@@ -53,6 +53,7 @@
(testing "accepts sessions/pr request payload"
(let [body {:title "feat: add m14 publish"
:body "This change enables push + optional PR."
:commit-message "feat: summarize m14 publish changes"
:head-branch "m14/publish"
:base-branch "main"
:create-pr true
@@ -64,4 +65,5 @@
(testing "rejects invalid sessions/pr payload"
(is (nil? (http/coerce-http-request :sessions/pr {:create-pr "yes"})))
(is (nil? (http/coerce-http-request :sessions/pr {:force "no"})))
(is (nil? (http/coerce-http-request :sessions/pr {:commit-message 100})))
(is (nil? (http/coerce-http-request :sessions/pr {:head-branch 42})))))

View File

@@ -244,6 +244,7 @@
{:session-id "sess-push"
:repo-url "https://github.com/example/repo"
:head-branch "feature/m14"
:commit-message "feat: summarize PR updates"
:force true
:push-token "token-1"})
(.then (fn [result]
@@ -253,6 +254,7 @@
(is (= true (:force result)))
(is (string/includes? (:script @captured) "git push"))
(is (string/includes? (:script @captured) "feature/m14"))
(is (string/includes? (:script @captured) "commit -m 'feat: summarize PR updates'"))
(is (string/includes? (:script @captured) "x-access-token:token-1"))
(done)))
(.catch (fn [error]

View File

@@ -352,6 +352,24 @@
[message]
(count (pr-str (select-keys message [:id :role :parts]))))
(def ^:private commit-message-max-len 120)
(defn- latest-assistant-summary
[messages]
(some->> messages
reverse
(keep (fn [message]
(when (= "assistant" (normalize-role (:role message)))
(ui-message-text message))))
first
normalized-text))
(defn- summary->commit-message
[summary]
(when-let [summary (normalized-text summary)]
(let [single-line (string/replace summary #"\s+" " ")]
(subs single-line 0 (min commit-message-max-len (count single-line))))))
(defn- session-messages-need-sync?
[session-messages ui-messages]
(let [ui-by-id (into {} (map (juxt :id identity) ui-messages))]
@@ -418,9 +436,14 @@
publish! (fn [create-pr?]
(when (and base session-id (not publish-disabled?))
(set-publish-mode! (if create-pr? :pr :push))
(-> (agent-handler/<publish-session! block {:create-pr? create-pr?})
(p/catch (fn [_] nil))
(p/finally (fn [] (set-publish-mode! nil))))))]
(let [summary (latest-assistant-summary session-chat-messages)
commit-message (summary->commit-message summary)
opts (cond-> {:create-pr? create-pr?}
(string? summary) (assoc :body summary)
(string? commit-message) (assoc :commit-message commit-message))]
(-> (agent-handler/<publish-session! block opts)
(p/catch (fn [_] nil))
(p/finally (fn [] (set-publish-mode! nil)))))))]
(hooks/use-effect!
(fn []
(when (agent-handler/task-ready? block)

View File

@@ -3,6 +3,7 @@
(:require [clojure.string :as string]
[frontend.db :as db]
[frontend.handler.db-based.sync :as db-sync]
[frontend.handler.editor :as editor-handler]
[frontend.handler.notification :as notification]
[frontend.handler.property :as property-handler]
[frontend.handler.property.util :as pu]
@@ -403,14 +404,28 @@
resp))))))
(defn- publish-request-body
[{:keys [title body head-branch base-branch create-pr? force?]}]
[{:keys [title body commit-message head-branch base-branch create-pr? force?]}]
(cond-> {:create-pr (if (nil? create-pr?) true (true? create-pr?))
:force (true? force?)}
(string? (blank->nil title)) (assoc :title (blank->nil title))
(string? (blank->nil body)) (assoc :body (blank->nil body))
(string? (blank->nil commit-message)) (assoc :commit-message (blank->nil commit-message))
(string? (blank->nil head-branch)) (assoc :head-branch (blank->nil head-branch))
(string? (blank->nil base-branch)) (assoc :base-branch (blank->nil base-branch))))
(defn- maybe-insert-pr-sibling-blocks!
[block-uuid resp summary]
(when-let [url (or (blank->nil (:pr-url resp))
(blank->nil (:manual-pr-url resp)))]
(p/let [block (editor-handler/api-insert-new-block! (str "PR URL: " url)
{:block-uuid block-uuid
:sibling? false})]
(when-let [summary (blank->nil summary)]
(editor-handler/api-insert-new-block! (str "PR Summary: " summary)
{:block-uuid (:block/uuid block)
:sibling? true})))))
(defn- publish-status-message
[resp]
(or (:message resp)
@@ -466,6 +481,7 @@
(p/then (fn [resp]
(update-session-state! block-uuid {:last-publish resp
:last-publish-at (util/time-ms)})
(maybe-insert-pr-sibling-blocks! block-uuid resp (:body raw-body))
(notification/show! (publish-status-message resp)
(if (= "manual-pr-required" (:status resp))
:warning

View File

@@ -3,7 +3,10 @@
[clojure.string :as string]
[frontend.handler.agent :as agent-handler]
[frontend.handler.db-based.sync :as db-sync]
[frontend.handler.editor :as editor-handler]
[frontend.handler.notification :as notification]
[frontend.handler.user :as user-handler]
[frontend.state :as state]
[promesa.core :as p]))
(deftest start-session-sends-initial-message-test
@@ -86,3 +89,129 @@
(p/catch (fn [e]
(is false (str e))
(done)))))))
(deftest publish-session-pr-created-inserts-url-and-summary-sibling-blocks-test
(async done
(let [insert-calls (atom [])
fetch-calls (atom [])
block {:block/uuid #uuid "cccccccc-cccc-cccc-cccc-cccccccccccc"}
prev-state @state/state]
(swap! state/state assoc :agent/sessions {(str (:block/uuid block)) {:session-id "sess-pr-1"}})
(-> (p/with-redefs [db-sync/http-base (fn [] "http://base")
db-sync/fetch-json (fn [url opts _]
(swap! fetch-calls conj {:url url :opts opts})
(if (string/includes? url "/events")
(p/resolved {:events []})
(p/resolved {:status "pr-created"
:pr-url "https://github.com/example/repo/pull/123"})))
user-handler/task--ensure-id&access-token (fn [resolve _reject]
(resolve true))
agent-handler/task-ready? (fn [_] true)
notification/show! (fn [& _] nil)
editor-handler/api-insert-new-block! (fn [content opts]
(swap! insert-calls conj {:content content :opts opts}))]
(p/let [_ (agent-handler/<publish-session! block {:create-pr? true
:body "Agent summary for PR"})
[first-call] @fetch-calls
_ (reset! state/state prev-state)]
(is (= "http://base/sessions/sess-pr-1/pr" (:url first-call)))
(is (= 2 (count @insert-calls)))
(is (= "PR URL: https://github.com/example/repo/pull/123"
(:content (first @insert-calls))))
(is (= "PR Summary: Agent summary for PR"
(:content (second @insert-calls))))
(is (every? #(= true (get-in % [:opts :sibling?])) @insert-calls))
(is (every? #(= (:block/uuid block) (get-in % [:opts :block-uuid])) @insert-calls))
(done)))
(p/catch (fn [e]
(reset! state/state prev-state)
(is false (str e))
(done)))))))
(deftest publish-session-manual-pr-inserts-manual-url-and-summary-sibling-blocks-test
(async done
(let [insert-calls (atom [])
block {:block/uuid #uuid "dddddddd-dddd-dddd-dddd-dddddddddddd"}
prev-state @state/state]
(swap! state/state assoc :agent/sessions {(str (:block/uuid block)) {:session-id "sess-pr-2"}})
(-> (p/with-redefs [db-sync/http-base (fn [] "http://base")
db-sync/fetch-json (fn [url _opts _]
(if (string/includes? url "/events")
(p/resolved {:events []})
(p/resolved {:status "manual-pr-required"
:manual-pr-url "https://github.com/example/repo/compare/a...b"})))
user-handler/task--ensure-id&access-token (fn [resolve _reject]
(resolve true))
agent-handler/task-ready? (fn [_] true)
notification/show! (fn [& _] nil)
editor-handler/api-insert-new-block! (fn [content opts]
(swap! insert-calls conj {:content content :opts opts}))]
(p/let [_ (agent-handler/<publish-session! block {:create-pr? true
:body "Agent summary for manual PR"})
_ (reset! state/state prev-state)]
(is (= 2 (count @insert-calls)))
(is (= "PR URL: https://github.com/example/repo/compare/a...b"
(:content (first @insert-calls))))
(is (= "PR Summary: Agent summary for manual PR"
(:content (second @insert-calls))))
(done)))
(p/catch (fn [e]
(reset! state/state prev-state)
(is false (str e))
(done)))))))
(deftest publish-session-push-only-does-not-insert-pr-sibling-blocks-test
(async done
(let [insert-calls (atom [])
block {:block/uuid #uuid "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"}
prev-state @state/state]
(swap! state/state assoc :agent/sessions {(str (:block/uuid block)) {:session-id "sess-pr-3"}})
(-> (p/with-redefs [db-sync/http-base (fn [] "http://base")
db-sync/fetch-json (fn [url _opts _]
(if (string/includes? url "/events")
(p/resolved {:events []})
(p/resolved {:status "pushed"})))
user-handler/task--ensure-id&access-token (fn [resolve _reject]
(resolve true))
agent-handler/task-ready? (fn [_] true)
notification/show! (fn [& _] nil)
editor-handler/api-insert-new-block! (fn [content opts]
(swap! insert-calls conj {:content content :opts opts}))]
(p/let [_ (agent-handler/<publish-session! block {:create-pr? false
:body "Summary should be ignored"})
_ (reset! state/state prev-state)]
(is (empty? @insert-calls))
(done)))
(p/catch (fn [e]
(reset! state/state prev-state)
(is false (str e))
(done)))))))
(deftest publish-session-sends-commit-message-test
(async done
(let [request-bodies (atom [])
block {:block/uuid #uuid "ffffffff-ffff-ffff-ffff-ffffffffffff"}
prev-state @state/state]
(swap! state/state assoc :agent/sessions {(str (:block/uuid block)) {:session-id "sess-pr-4"}})
(-> (p/with-redefs [db-sync/http-base (fn [] "http://base")
db-sync/fetch-json (fn [url opts _]
(if (string/includes? url "/events")
(p/resolved {:events []})
(do
(swap! request-bodies conj (js->clj (js/JSON.parse (:body opts))
:keywordize-keys true))
(p/resolved {:status "pushed"}))))
user-handler/task--ensure-id&access-token (fn [resolve _reject]
(resolve true))
agent-handler/task-ready? (fn [_] true)
notification/show! (fn [& _] nil)]
(p/let [_ (agent-handler/<publish-session! block {:create-pr? false
:commit-message "feat: summarize PR changes"})
_ (reset! state/state prev-state)]
(is (= "feat: summarize PR changes"
(:commit-message (first @request-bodies))))
(done)))
(p/catch (fn [e]
(reset! state/state prev-state)
(is false (str e))
(done)))))))