diff --git a/deps/db-sync/README.md b/deps/db-sync/README.md index 3ceb8f8aae..38abb6309e 100644 --- a/deps/db-sync/README.md +++ b/deps/db-sync/README.md @@ -153,11 +153,37 @@ Cloudflare runtime flow: | CLOUDFLARE_REPO_CLONE_COMMAND | Optional repo clone command template for Cloudflare sandbox | | CLOUDFLARE_HEALTH_RETRIES | Cloudflare sandbox health check retry count | | CLOUDFLARE_HEALTH_INTERVAL_MS | Cloudflare sandbox health check retry interval (ms) | +| GITHUB_TOKEN | Fallback token used for both git push and PR API calls | +| GITHUB_PUSH_TOKEN | Optional token used only for git push (preferred over `GITHUB_TOKEN`) | +| GITHUB_PR_TOKEN | Optional token used only for PR creation (preferred over `GITHUB_TOKEN`) | +| GITHUB_API_BASE | Optional GitHub API base URL override (default `https://api.github.com`) | +| GITHUB_DEFAULT_BASE_BRANCH | Default PR base branch fallback (default `main`) | | OPENAI_API_KEY | Passed into Cloudflare sandbox runtime env (if set) | | ANTHROPIC_API_KEY | Passed into Cloudflare sandbox runtime env (if set) | | OPENAI_BASE_URL | Passed into Cloudflare sandbox runtime env (if set) | | ANTHROPIC_BASE_URL | Passed into Cloudflare sandbox runtime env (if set) | +## M14 Publish Endpoint + +Agent sessions now expose: + +`POST /sessions/:session-id/pr` + +This endpoint is available to any authenticated collaborator and supports: +- push only (`{"create-pr": false}`) +- push + PR (`{"create-pr": true}` or omitted) + +Response `status` values: +- `pushed` +- `pr-created` +- `manual-pr-required` + +If PR credentials are missing or PR API creation fails after a successful push, response includes `manual-pr-url`. + +For Cloudflare deploys, store tokens as Worker secrets: +- `wrangler secret put GITHUB_PUSH_TOKEN --env ` +- `wrangler secret put GITHUB_PR_TOKEN --env ` + ## Notes - Protocol definitions live in `docs/agent-guide/db-sync/protocol.md`. - DB sync implementation guide is in `docs/agent-guide/db-sync/db-sync-guide.md`. diff --git a/deps/db-sync/docs/milestones/agents/14-m14-git-push-and-optional-pr.md b/deps/db-sync/docs/milestones/agents/14-m14-git-push-and-optional-pr.md index 4a9d4cfe4c..4c84138eeb 100644 --- a/deps/db-sync/docs/milestones/agents/14-m14-git-push-and-optional-pr.md +++ b/deps/db-sync/docs/milestones/agents/14-m14-git-push-and-optional-pr.md @@ -1,23 +1,24 @@ -# M14: Git Push + Optional PR Submission +# M14: Git Push + PR for All Users -Status: Planned -Target: Enable agent sessions to push committed changes to a git repo and optionally create a pull request. +Status: Implemented +Target: Enable all authenticated collaborators to use agent sessions to push committed changes and create pull requests. ## Goal Allow task agents to complete a full delivery loop: 1) edit + commit locally 2) push branch to remote -3) optionally submit a PR when requested by user/task context +3) submit a PR when requested by user/task context ## Why M14 - Current milestones cover repo clone and runtime provisioning, but stop short of remote delivery. - Users still need manual handoff for pushing and PR creation. +- Push/PR should not be restricted to manager-only workflows. - End-to-end agent outcomes should include publish-ready outputs. ## Scope -1) Add an explicit agent capability profile for git delivery in task/session payload handling. +1) Add an explicit agent capability profile for git delivery in task/session payload handling with no role-based gating. 2) Enable authenticated `git push` from sandbox runtime for supported providers. -3) Add optional PR submission path (non-mandatory per task). +3) Add PR submission path available to all authenticated collaborators. 4) Persist and stream audit events for push/PR steps and failures. 5) Keep existing agent/session flows backward compatible. @@ -30,8 +31,8 @@ Allow task agents to complete a full delivery loop: ### WS1: Agent Capability + Session Payload - Define M14 capability contract for: - - `push-enabled` - - `pr-optional` + - `push-enabled` (default true) + - `pr-enabled` (default true) - Thread capability through session creation and runtime payload assembly. ### WS2: Runtime Git Push @@ -41,15 +42,15 @@ Allow task agents to complete a full delivery loop: - commit verification - push with error classification and retries where safe -### WS3: Optional PR Submission -- Add configurable PR toggle/intent in task or message flow. -- Create PR only when requested and branch push succeeds. +### WS3: PR Submission (All Users) +- Add PR intent in task or message flow for every authenticated collaborator. +- Create PR when requested and branch push succeeds. - Return PR URL and metadata in session events. ### WS4: Observability + Safety - Emit structured events for: - push started/succeeded/failed - - pr started/succeeded/failed/skipped + - pr started/succeeded/failed - Keep failures non-destructive and user-visible with actionable messages. ### WS5: Tests + Docs @@ -57,8 +58,8 @@ Allow task agents to complete a full delivery loop: - Document env/config requirements for auth and provider behavior. ## Exit Criteria -1) Agent can push committed changes to configured repo from an active session. -2) PR submission works when enabled and is skipped cleanly when disabled. +1) Any authenticated collaborator can push committed changes to configured repo from an active session. +2) Any authenticated collaborator can submit a PR from an active session. 3) Session event stream includes push/PR lifecycle and final links/errors. -4) Existing non-M14 sessions continue to work unchanged. - +4) No manager/member role gate exists for push/PR actions. +5) Existing non-M14 sessions continue to work unchanged. diff --git a/deps/db-sync/src/logseq/db_sync/malli_schema.cljs b/deps/db-sync/src/logseq/db_sync/malli_schema.cljs index f0ff9f37a7..a05f89e4f4 100644 --- a/deps/db-sync/src/logseq/db_sync/malli_schema.cljs +++ b/deps/db-sync/src/logseq/db_sync/malli_schema.cljs @@ -234,13 +234,26 @@ [:mode {:optional true} :string] [:permission-mode {:optional true} :string] [:api-token {:optional true} :string] - [:auth-json {:optional true} :string]]]]]) + [:auth-json {:optional true} :string]]]] + [:capabilities {:optional true} + [:map + [:push-enabled {:optional true} :boolean] + [:pr-enabled {:optional true} :boolean]]]]) (def sessions-message-request-schema [:map [:message :string] [:kind {:optional true} :string]]) +(def sessions-pr-request-schema + [:map + [:title {:optional true} :string] + [:body {:optional true} :string] + [:head-branch {:optional true} :string] + [:base-branch {:optional true} :string] + [:create-pr {:optional true} :boolean] + [:force {:optional true} :boolean]]) + (def sessions-create-response-schema [:map [:session-id :string] @@ -265,6 +278,16 @@ [:ok :boolean] [:flushed {:optional true} :int]]) +(def sessions-pr-response-schema + [:map + [:status :string] + [:head-branch :string] + [:base-branch {:optional true} :string] + [:pr-url {:optional true} :string] + [:manual-pr-url {:optional true} :string] + [:force {:optional true} :boolean] + [:message {:optional true} :string]]) + (def http-request-schemas {:graphs/create graph-create-request-schema :graph-members/create graph-member-create-request-schema @@ -274,7 +297,8 @@ :e2ee/graph-aes-key e2ee-graph-aes-key-request-schema :e2ee/grant-access e2ee-grant-access-request-schema :sessions/create sessions-create-request-schema - :sessions/message sessions-message-request-schema}) + :sessions/message sessions-message-request-schema + :sessions/pr sessions-pr-request-schema}) (def http-response-schemas {:graphs/list graphs-list-response-schema @@ -306,6 +330,7 @@ :sessions/resume sessions-resume-response-schema :sessions/interrupt http-ok-response-schema :sessions/cancel http-ok-response-schema + :sessions/pr sessions-pr-response-schema :sessions/events sessions-events-response-schema :error http-error-response-schema}) diff --git a/deps/db-sync/src/logseq/db_sync/node/config.cljs b/deps/db-sync/src/logseq/db_sync/node/config.cljs index 1b36ca4633..0abe7daae2 100644 --- a/deps/db-sync/src/logseq/db_sync/node/config.cljs +++ b/deps/db-sync/src/logseq/db_sync/node/config.cljs @@ -44,6 +44,11 @@ :cloudflare-repo-clone-command (env-value env "CLOUDFLARE_REPO_CLONE_COMMAND") :cloudflare-health-retries (env-value env "CLOUDFLARE_HEALTH_RETRIES") :cloudflare-health-interval-ms (env-value env "CLOUDFLARE_HEALTH_INTERVAL_MS") + :github-token (env-value env "GITHUB_TOKEN") + :github-push-token (env-value env "GITHUB_PUSH_TOKEN") + :github-pr-token (env-value env "GITHUB_PR_TOKEN") + :github-api-base (env-value env "GITHUB_API_BASE") + :github-default-base-branch (env-value env "GITHUB_DEFAULT_BASE_BRANCH") :openai-api-key (env-value env "OPENAI_API_KEY") :anthropic-api-key (env-value env "ANTHROPIC_API_KEY") :openai-base-url (env-value env "OPENAI_BASE_URL") diff --git a/deps/db-sync/src/logseq/db_sync/node/server.cljs b/deps/db-sync/src/logseq/db_sync/node/server.cljs index c2e7987c76..387067772b 100644 --- a/deps/db-sync/src/logseq/db_sync/node/server.cljs +++ b/deps/db-sync/src/logseq/db_sync/node/server.cljs @@ -77,6 +77,11 @@ (aset "CLOUDFLARE_REPO_CLONE_COMMAND" (:cloudflare-repo-clone-command cfg)) (aset "CLOUDFLARE_HEALTH_RETRIES" (:cloudflare-health-retries cfg)) (aset "CLOUDFLARE_HEALTH_INTERVAL_MS" (:cloudflare-health-interval-ms cfg)) + (aset "GITHUB_TOKEN" (:github-token cfg)) + (aset "GITHUB_PUSH_TOKEN" (:github-push-token cfg)) + (aset "GITHUB_PR_TOKEN" (:github-pr-token cfg)) + (aset "GITHUB_API_BASE" (:github-api-base cfg)) + (aset "GITHUB_DEFAULT_BASE_BRANCH" (:github-default-base-branch cfg)) (aset "OPENAI_API_KEY" (:openai-api-key cfg)) (aset "ANTHROPIC_API_KEY" (:anthropic-api-key cfg)) (aset "OPENAI_BASE_URL" (:openai-base-url cfg)) diff --git a/deps/db-sync/src/logseq/db_sync/worker/agent/do.cljs b/deps/db-sync/src/logseq/db_sync/worker/agent/do.cljs index b0a43ab3fb..7f0242cd09 100644 --- a/deps/db-sync/src/logseq/db_sync/worker/agent/do.cljs +++ b/deps/db-sync/src/logseq/db_sync/worker/agent/do.cljs @@ -5,6 +5,7 @@ [logseq.db-sync.platform.core :as platform] [logseq.db-sync.worker.agent.runtime-provider :as runtime-provider] [logseq.db-sync.worker.agent.session :as session] + [logseq.db-sync.worker.agent.source-control :as source-control] [logseq.db-sync.worker.http :as http] [promesa.core :as p])) @@ -114,12 +115,61 @@ (broadcast-event! self event) {:session session :event event}))))) +(defn- (get-in session [:task :project :repo-url]) str string/trim not-empty)) + +(defn- generated-head-branch + [session-id] + (let [suffix (some-> session-id + str + string/lower-case + (string/replace #"[^a-z0-9-]+" "-") + (string/replace #"-+" "-") + (string/replace #"^-+" "") + (string/replace #"-+$" ""))] + (str "logseq-agent/" (if (string/blank? suffix) "session" suffix)))) + +(defn- default-base-branch + [^js env] + (or (some-> (aget env "GITHUB_DEFAULT_BASE_BRANCH") str string/trim not-empty) + "main")) + +(defn- error-reason + [error] + (let [reason (some-> error ex-data :reason)] + (cond + (keyword? reason) (name reason) + (string? reason) reason + :else nil))) + (defn- (p/let [_ ( (:title body) str string/trim not-empty) + (str "Agent updates for session " (:id current-session))) + description (or (some-> (:body body) str string/trim not-empty) + "Automated changes from agent session.") + manual-url (source-control/manual-pr-url repo-url head-branch base-branch)] + (if-not (string? pr-token) + ( (p/let [_ (clj result :keywordize-keys true)) + user-id (user-id-from-request request) + force? (true? (:force body)) + create-pr? (if (contains? body :create-pr) + (true? (:create-pr body)) + true)] + (if-not (string? user-id) + (http/unauthorized) + (p/let [current-session ( {:id (:session-id body) :source {:node-id (:node-id body) :node-title (:node-title body)} :intent {:content (:content body)} :project (:project body) - :agent (:agent body)} + :agent (:agent body) + :capabilities capabilities} (some? attachments) (assoc-in [:intent :attachments] attachments))))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/agent/runtime_provider.cljs b/deps/db-sync/src/logseq/db_sync/worker/agent/runtime_provider.cljs index c6a599e29c..db627f9956 100644 --- a/deps/db-sync/src/logseq/db_sync/worker/agent/runtime_provider.cljs +++ b/deps/db-sync/src/logseq/db_sync/worker/agent/runtime_provider.cljs @@ -2,6 +2,7 @@ (:require [clojure.string :as string] [lambdaisland.glogi :as log] [logseq.db-sync.worker.agent.sandbox :as sandbox] + [logseq.db-sync.worker.agent.source-control :as source-control] [promesa.core :as p])) ;; ----------------------- @@ -386,6 +387,44 @@ (escape-shell-single repo-url) "' '" (escape-shell-single repo-dir) "'" " && chmod -R u+rw '" (escape-shell-single repo-dir) "'")))))) +(defn- classify-push-error + [error] + (let [data (ex-data error) + {:keys [stderr stdout]} (when (map? data) data) + message (str (or (ex-message error) "") + "\n" + (or stderr "") + "\n" + (or stdout "")) + message (string/lower-case message)] + (cond + (or (string/includes? message "authentication failed") + (string/includes? message "could not read username") + (string/includes? message "permission denied") + (string/includes? message "repository not found")) + :auth + + (or (string/includes? message "src refspec") + (string/includes? message "does not match any")) + :no-commits + + (or (string/includes? message "non-fast-forward") + (string/includes? message "[rejected]") + (string/includes? message "fetch first")) + :remote-rejected + + :else + :unknown))) + +(defn- push-command + [{:keys [repo-dir remote-url head-branch force]}] + (str "set -e; " + "cd '" (escape-shell-single repo-dir) "'; " + "git rev-parse --is-inside-work-tree >/dev/null; " + "git push '" (escape-shell-single remote-url) "' HEAD:refs/heads/" + (escape-shell-single head-branch) + (when force " --force"))) + (defn session-payload [task] (let [agent (or (get-in task [:agent :provider]) (get-in task [:agent :id]) @@ -566,6 +605,15 @@ (->promise (.exec sandbox cmd)) (throw (ex-info "cloudflare sandbox missing exec method" {})))) +(defn- cloudflare-exec-output + [result] + {:stdout (or (aget result "stdout") "") + :stderr (or (aget result "stderr") "") + :exit-code (or (aget result "exitCode") + (aget result "exit_code")) + :success (let [success (aget result "success")] + (if (boolean? success) success true))}) + (defn- (js/Promise.resolve ( (p/let [result (sprites-exec-post! env name ["bash" "-lc" script]) + {:keys [stdout stderr exit-code]} (sprites-exec-output result)] + (when (and (number? exit-code) (not (zero? exit-code))) + (throw (ex-info "git push failed" + {:reason :git-exit + :stdout stdout + :stderr stderr + :exit-code exit-code}))) + {:head-branch head-branch + :repo-url repo-url + :force force? + :remote "origin"}) + (p/catch (fn [error] + (p/rejected + (if-let [data (ex-data error)] + (ex-info (ex-message error) + (assoc data + :provider "sprites" + :reason (or (:reason data) + (classify-push-error error)))) + (ex-info "git push failed" + {:provider "sprites" + :reason (classify-push-error error) + :error (str error)}))))))))) + ( (p/let [result (> repo-url + non-empty-str + (re-matches #"^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$"))] + {:provider "github" + :owner owner + :name (strip-git-suffix repo)})) + +(defn- github-ssh-ref + [repo-url] + (when-let [[_ owner repo] + (some->> repo-url + non-empty-str + (re-matches #"^git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$"))] + {:provider "github" + :owner owner + :name (strip-git-suffix repo)})) + +(defn- github-ssh-url-ref + [repo-url] + (when-let [[_ owner repo] + (some->> repo-url + non-empty-str + (re-matches #"^ssh://git@github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$"))] + {:provider "github" + :owner owner + :name (strip-git-suffix repo)})) + +(defn repo-ref + [repo-url] + (let [repo-url (non-empty-str repo-url)] + (some->> [(github-https-ref repo-url) + (github-ssh-ref repo-url) + (github-ssh-url-ref repo-url)] + (remove nil?) + first))) + +(defn sanitize-branch-name + [branch] + (let [branch (non-empty-str branch)] + (when (and branch + (not= branch "HEAD") + (not (string/starts-with? branch "/")) + (not (string/ends-with? branch "/")) + (not (string/starts-with? branch ".")) + (not (string/ends-with? branch ".")) + (not (string/includes? branch " ")) + (not (string/includes? branch "..")) + (not (string/includes? branch "@{")) + (not (re-find #"[~^:?*\[\]\\]" branch))) + branch))) + +(defn resolve-head-branch + [requested fallback] + (or (sanitize-branch-name requested) + (sanitize-branch-name fallback))) + +(defn manual-pr-url + [repo-url head-branch base-branch] + (when-let [{:keys [provider owner name]} (repo-ref repo-url)] + (case provider + "github" + (str "https://github.com/" + owner + "/" + name + "/pull/new/" + (js/encodeURIComponent (or base-branch "main")) + "..." + (js/encodeURIComponent (or head-branch ""))) + nil))) + +(defn- api-base-url + [^js env] + (or (some-> (aget env "GITHUB_API_BASE") non-empty-str) + "https://api.github.com")) + +(defn pr-token + [^js env] + (or (some-> (aget env "GITHUB_PR_TOKEN") non-empty-str) + (some-> (aget env "GITHUB_TOKEN") non-empty-str))) + +(defn push-token + [^js env] + (or (some-> (aget env "GITHUB_PUSH_TOKEN") non-empty-str) + (some-> (aget env "GITHUB_TOKEN") non-empty-str))) + +(defn push-remote-url + [repo-url token] + (when-let [{:keys [provider owner name]} (repo-ref repo-url)] + (when (= "github" provider) + (if (string? token) + (str "https://x-access-token:" (js/encodeURIComponent token) "@github.com/" owner "/" name ".git") + (str "https://github.com/" owner "/" name ".git"))))) + +(defn- parse-json-safe + [text] + (if-not (string? text) + {} + (try + (js->clj (js/JSON.parse text) :keywordize-keys true) + (catch :default _ + {})))) + +(defn (:default_branch payload) non-empty-str) + nil)))) + (p/resolved nil)))) + +(defn {:title title + :body body + :head (or head-branch "") + :base (or base-branch "main")} + (true? draft) (assoc :draft true)) + headers (doto (js/Headers.) + (.set "accept" "application/vnd.github+json") + (.set "content-type" "application/json") + (.set "authorization" (str "Bearer " token)) + (.set "x-github-api-version" "2022-11-28"))] + (p/let [resp (js/fetch url #js {:method "POST" + :headers headers + :body (js/JSON.stringify (clj->js payload))}) + status (.-status resp) + text (.text resp) + parsed (parse-json-safe text)] + (if (<= 200 status 299) + {:id (:number parsed) + :url (:html_url parsed) + :state (cond + (true? (:draft parsed)) "draft" + (= "closed" (:state parsed)) "closed" + :else "open") + :head-branch (or (get-in parsed [:head :ref]) head-branch) + :base-branch (or (get-in parsed [:base :ref]) base-branch)} + (p/rejected (ex-info "create pull request failed" + {:reason :api-error + :status status + :body parsed})))))) + (p/rejected (ex-info "invalid repo url" {:reason :invalid-repo-url + :repo-url repo-url}))))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/handler/agent.cljs b/deps/db-sync/src/logseq/db_sync/worker/handler/agent.cljs index ec8eecb04b..bb35ef6fe1 100644 --- a/deps/db-sync/src/logseq/db_sync/worker/handler/agent.cljs +++ b/deps/db-sync/src/logseq/db_sync/worker/handler/agent.cljs @@ -139,6 +139,25 @@ (forward-request stub do-url "GET" headers nil)) (http/error-response "server error" 500))))) +(defn- handle-pr [{:keys [env request url claims route]}] + (let [session-id (get-in route [:path-params :session-id])] + (if-not (string? session-id) + (http/bad-request "invalid session id") + (.then (common/read-json request) + (fn [result] + (let [raw-body (if (nil? result) + {} + (js->clj result :keywordize-keys true)) + body (http/coerce-http-request :sessions/pr raw-body)] + (if (nil? body) + (http/bad-request "invalid body") + (if-let [^js stub (session-stub env session-id)] + (let [headers (base-headers request claims) + body-json (js/JSON.stringify (clj->js body)) + do-url (str (.-origin url) "/__session__/pr")] + (forward-request stub do-url "POST" headers body-json)) + (http/error-response "server error" 500))))))))) + (defn handle [{:keys [route] :as ctx}] (case (:handler route) :sessions/create (handle-create ctx) @@ -148,6 +167,7 @@ :sessions/resume (handle-control ctx "/__session__/resume") :sessions/interrupt (handle-control ctx "/__session__/interrupt") :sessions/cancel (handle-cancel ctx) + :sessions/pr (handle-pr ctx) :sessions/events (handle-events ctx) :sessions/stream (handle-stream ctx) (http/not-found))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/routes/index.cljs b/deps/db-sync/src/logseq/db_sync/worker/routes/index.cljs index 9262cf8bc2..18f9b621ee 100644 --- a/deps/db-sync/src/logseq/db_sync/worker/routes/index.cljs +++ b/deps/db-sync/src/logseq/db_sync/worker/routes/index.cljs @@ -31,6 +31,7 @@ ["/resume" {:methods {"POST" :sessions/resume}}] ["/interrupt" {:methods {"POST" :sessions/interrupt}}] ["/cancel" {:methods {"POST" :sessions/cancel}}] + ["/pr" {:methods {"POST" :sessions/pr}}] ["/events" {:methods {"GET" :sessions/events}}] ["/stream" {:methods {"GET" :sessions/stream}}]]]]) diff --git a/deps/db-sync/test/logseq/db_sync/agent_do_test.cljs b/deps/db-sync/test/logseq/db_sync/agent_do_test.cljs index e782d497fa..e8553b0363 100644 --- a/deps/db-sync/test/logseq/db_sync/agent_do_test.cljs +++ b/deps/db-sync/test/logseq/db_sync/agent_do_test.cljs @@ -1,7 +1,8 @@ (ns logseq.db-sync.agent-do-test (: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.do :as agent-do] + [logseq.db-sync.worker.agent.runtime-provider :as runtime-provider])) (defn- make-agent-storage [] (let [data (js/Map.)] @@ -584,3 +585,76 @@ (.catch (fn [error] (is false (str "unexpected error: " error)) (done)))))))) + +(deftest pr-endpoint-requires-authenticated-user-test + (testing "session publish endpoint requires x-user-id header" + (async done + (let [self (make-self #js {}) + headers {"content-type" "application/json"}] + (-> (.put (.-storage self) + "session" + (clj->js {:id "sess-pr-auth" + :status "running" + :task {:project {:repo-url "https://github.com/example/repo"}} + :runtime {:provider "local-dev" + :session-id "sess-pr-auth"} + :audit {} + :created-at 0 + :updated-at 0})) + (.then (fn [_] + (agent-do/handle-fetch self + (json-request "http://db-sync.local/__session__/pr" + "POST" + {:create-pr false + :head-branch "m14/pr-auth"} + headers)))) + (.then (fn [resp] + (is (= 401 (.-status resp))) + (done))) + (.catch (fn [error] + (is false (str "unexpected error: " error)) + (done)))))))) + +(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"} + self (make-self env) + headers {"content-type" "application/json" + "x-user-id" "user-1"}] + (-> (.put (.-storage self) + "session" + (clj->js {:id "sess-pr-push-only" + :status "running" + :task {:project {:repo-url "https://github.com/example/repo"}} + :runtime {:provider "local-dev" + :session-id "sess-pr-push-only"} + :audit {} + :created-at 0 + :updated-at 0})) + (.then (fn [_] + (with-redefs [runtime-provider/ (runtime-provider/ (runtime-provider/ (source-control/,"updated-at":}`. +- `POST /sessions/:session-id/messages` + - Send a user message to the running session. Body: `{"message":"...","kind":"user"}`. + - Response: `{"ok":true}`. +- `GET /sessions/:session-id/events?since=&limit=` + - Poll session events. Response: `{"events":[{event-id, session-id, type, ts, data}...]}`. +- `GET /sessions/:session-id/stream` + - SSE stream of session events. +- `POST /sessions/:session-id/pause` + - Pause the session. Response: `{"ok":true}`. +- `POST /sessions/:session-id/resume` + - Resume the session and flush queued messages. Response: `{"ok":true,"flushed":}`. +- `POST /sessions/:session-id/interrupt` + - Interrupt active execution (transitions to paused). Response: `{"ok":true}`. +- `POST /sessions/:session-id/cancel` + - Cancel the session and terminate runtime. Response: `{"ok":true}`. +- `POST /sessions/:session-id/pr` + - Push the session branch and optionally create a PR. Body fields: + - `create-pr` (optional, default `true`) + - `force` (optional, default `false`) + - `head-branch` (optional) + - `base-branch` (optional) + - `title` / `body` (optional PR metadata) + - Response statuses: + - `{"status":"pushed", ...}` for push-only + - `{"status":"pr-created","pr-url":"...", ...}` for push + PR success + - `{"status":"manual-pr-required","manual-pr-url":"...", ...}` when branch push succeeded but PR must be created manually diff --git a/src/main/frontend/components/agent_chat.cljs b/src/main/frontend/components/agent_chat.cljs index 5ae9411f8b..be705f44db 100644 --- a/src/main/frontend/components/agent_chat.cljs +++ b/src/main/frontend/components/agent_chat.cljs @@ -9,6 +9,7 @@ [frontend.state :as state] [logseq.shui.hooks :as hooks] [logseq.shui.ui :as shui] + [promesa.core :as p] [rum.core :as rum])) (defn- normalized-text @@ -401,9 +402,12 @@ (map message->chat-message) (remove nil?)) [draft set-draft!] (rum/use-state "") + [publish-mode set-publish-mode!] (rum/use-state nil) trimmed-draft (string/trim (or draft "")) busy? (contains? #{"submitted" "streaming"} chat-status) input-disabled? (or (not session-started?) (not (agent-handler/task-ready? block))) + publish-busy? (some? publish-mode) + publish-disabled? (or input-disabled? busy? publish-busy?) can-send? (and (not input-disabled?) (not (string/blank? trimmed-draft)) (not busy?)) @@ -411,7 +415,13 @@ (when (and can-send? base session-id) (set-draft! "") (-> (.sendMessage chat #js {:text trimmed-draft}) - (.catch (fn [_] nil)))))] + (.catch (fn [_] nil))))) + publish! (fn [create-pr?] + (when (and base session-id (not publish-disabled?)) + (set-publish-mode! (if create-pr? :pr :push)) + (-> (agent-handler/ {: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 head-branch)) (assoc :head-branch (blank->nil head-branch)) + (string? (blank->nil base-branch)) (assoc :base-branch (blank->nil base-branch)))) + +(defn- publish-status-message + [resp] + (or (:message resp) + (case (:status resp) + "pushed" "Branch pushed." + "pr-created" "Pull request created." + "manual-pr-required" "Branch pushed. Create pull request manually." + "Publish finished."))) + +(defn- publish-error-message + [error] + (or (some-> (ex-data error) :body :message) + (some-> (ex-data error) :body :error) + (some-> error ex-message) + "Publish failed.")) + +(defn block-uuid str))] + (cond + (not base) + (do + (notification/show! "DB sync is not configured." :error false) + (p/resolved nil)) + + (not (task-ready? block)) + (do + (notification/show! "Task needs Project (with Git Repo) and Agent." :warning) + (p/resolved nil)) + + (not (:session-id session)) + (do + (notification/show! "Start the agent session before publishing." :warning) + (p/resolved nil)) + + :else + (p/let [_ (js/Promise. user-handler/task--ensure-id&access-token) + raw-body (publish-request-body opts) + body (coerce-http-request :sessions/pr raw-body)] + (if (nil? body) + (do + (notification/show! "Invalid publish payload." :error false) + nil) + (-> (db-sync/fetch-json (str base "/sessions/" session-id "/pr") + {:method "POST" + :headers {"content-type" "application/json"} + :body (js/JSON.stringify (clj->js body))} + {:response-schema :sessions/pr}) + (p/then (fn [resp] + (update-session-state! block-uuid {:last-publish resp + :last-publish-at (util/time-ms)}) + (notification/show! (publish-status-message resp) + (if (= "manual-pr-required" (:status resp)) + :warning + :success) + false) + (