Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ contribution. This app is the dashboard for that loop:
its cache, but dismissing waits until you're back online). Records
staged by an older agent without a review plan still show the plain
card with both buttons.
Dependent PRs can be prepared as a **stack**: Contribute renders their
`base → branch` topology as one linked review, keeps every incremental
body and diff independently inspectable, and uses a second explicit
confirmation to publish the enumerated chain parent-first. True stacks
use dedicated upstream `stack/**` branches and therefore require upstream
push permission; independent contributions continue through the safer
reusable-fork path.
- **Open** — PRs and issues live on GitHub, plus anything the agent is
submitting right now. State is refreshed on open; the daily background job
also checks for comments, reviews, and failing checks that need follow-up.
Expand Down Expand Up @@ -120,7 +127,15 @@ shape:
"body_draft": "…", // the exact text that would go public
"branch": "…", "repo_path": "…",
"base_sha": "…", "head_sha": "…", "diff_sha256": "…",
"diff_stat": "…" // diff_excerpt is legacy — omit it
"diff_stat": "…", // diff_excerpt is legacy — omit it
"stack": { // optional: one complete 2–12 PR chain
"id": "notes-flow",
"name": "Notes flow",
"position": 2,
"total": 3,
"parent_record_id": "notes-flow-01",
"base_branch": "stack/notes-flow/01-model"
}
}
}
```
Expand Down
41 changes: 41 additions & 0 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,44 @@ export async function submitContribution({ appId, token, rec }) {
return { error: String((err && err.message) || err) }
}
}

// Batch approval path for one immutable PR stack. recordIds is the exact
// ordered list rendered in the confirmation, so the server cannot silently
// include a layer the partner did not review. The response always carries the
// latest known records, including partial success after a durable retry.
export async function submitContributionStack({ appId, token, recordIds }) {
try {
const r = await fetch(
'/api/github/contributions/' + encodeURIComponent(appId) + '/submit-stack',
{
method: 'POST',
headers: { ...authHeaders(token), 'Content-Type': 'application/json' },
body: JSON.stringify({ record_ids: recordIds }),
}
)
let body = null
try { body = await r.json() } catch { body = null }
if (r.ok) {
return {
ok: Array.isArray(body?.records) ? body.records : [],
submitted: Array.isArray(body?.submitted) ? body.submitted : [],
}
}
const detail = body?.detail
if (detail && typeof detail === 'object') {
return {
error: detail.message || 'Could not submit this PR stack.',
records: Array.isArray(detail.records) ? detail.records : [],
submitted: Array.isArray(detail.submitted) ? detail.submitted : [],
}
}
return {
error: typeof detail === 'string' ? detail : 'Could not submit this PR stack.',
}
} catch (err) {
if (window.mobius && window.mobius.online === false) {
return { error: 'You are offline — publishing a PR stack needs a connection.' }
}
return { error: String((err && err.message) || err) }
}
}
60 changes: 60 additions & 0 deletions contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ Contribute needs to submit it directly after approval:
plan: {action: pr|issue|issue_comment|discussion_comment, # mirrors record.type
repo, target_url?, title?, body_draft, branch?, repo_path?,
base_sha?, head_sha?, diff_sha256?, diff_stat,
stack?: {id, name?, position, total, parent_record_id, base_branch},
diff_excerpt?} # diff_stat REQUIRED; diff_excerpt legacy (unused)
```

Expand Down Expand Up @@ -199,13 +200,72 @@ record, and stop again.
A record flipped to `abandoned` means the partner dropped it — never argue with
one, never resurrect it unasked.

### The green light for a PR stack

When 2–12 prepared PR records carry one complete `plan.stack` chain,
Contribute groups them into one visual review and shows **Send N-PR stack**.
The second, explicit confirmation lists every title and `base → branch` pair;
that one click approves exactly those enumerated pushes and PR creations.

Before the first public push, the platform rechecks every record, every stored
diff, every parent SHA, the full branch topology, commit attribution, and the
whole stack's ability to merge with current upstream. It then publishes the
branches and opens the PRs from parent to child. If a later layer fails after a
parent PR was already created, the successful record remains open and every
unsent record returns to `prepared` with the durable error — retry never hides
the partial public state.

**True stacks require upstream push permission.** GitHub cannot use a branch
that exists only in the contributor's fork as the base of a PR in the upstream
repository. The stack path therefore publishes dedicated `stack/**` branches
directly to upstream, and the server refuses before pushing anything unless the
connected owner has `permissions.push` there. Without that permission, prepare
independent fork PRs instead; never simulate a stack by publishing a cumulative
diff that differs from the reviewed `.diff`.

---

## Prepare the branch

Run these during preparation, after the partner agrees to stage a PR for review.
Do not fork, push, or create a PR here.

### Prepare a linked PR stack

Use a stack only when the changes have a real dependency or review order. Each
layer is its own reviewed commit and its `.diff` is **incremental against the
previous layer**, never the cumulative diff against `main`.

1. Choose one privacy-safe stack id, for example `chat-settlement`. Every branch
must start `stack/<stack-id>/`, followed by an ordered descriptive suffix:
`stack/chat-settlement/01-runtime`, `.../02-ui`, `.../03-tests`.
2. Prepare layer 1 from the current upstream/default base SHA. Prepare layer 2
from layer 1's exact `head_sha`, and so on. Use one durable linked worktree
per record under `/data/contrib/<record-id>/worktree`.
3. Set the connected owner's repo-local author/committer identity **before every
commit**. Standalone send can normalize one tip commit; stack send cannot
rewrite a parent without invalidating every child's reviewed ancestry.
4. Store the canonical `base_sha..head_sha` diff and hash for each layer exactly
as for a standalone PR.
5. Put this additive object in every plan (positions are 1-based and complete):

```json
"stack": {
"id": "chat-settlement",
"name": "Chat settlement",
"position": 2,
"total": 3,
"parent_record_id": "chat-settlement-01",
"base_branch": "stack/chat-settlement/01-runtime"
}
```

Layer 1 has an empty `parent_record_id` and `base_branch` equal to upstream's
default branch (normally `main`). Every later `parent_record_id` names the
immediately preceding ledger record, `base_branch` equals that record's branch,
and its `base_sha` equals that record's `head_sha`. Re-read all records and diffs
as one review unit before saying the stack is ready.

### An app with a real origin (most catalog apps)

`git -C /data/apps/<slug> remote get-url origin` succeeds → work in the app's own
Expand Down
80 changes: 63 additions & 17 deletions index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
fetchLiveStates,
fetchSourceStatus,
submitContribution,
submitContributionStack,
} from './api.js'
import { StatTiles } from './ui/StatTiles.jsx'
import { ConnectionCard } from './ui/ConnectionCard.jsx'
Expand Down Expand Up @@ -70,7 +71,7 @@ function Header({ appId, fromCache }) {
</span>
<div>
<h1 className="co-title">Contribute</h1>
<span className="co-subtitle">Your local work and its path upstream</span>
<span className="co-subtitle">Review work and see where it sits upstream</span>
{fromCache && (
<span className="co-offline-note">Offline — showing your last synced feed.</span>
)}
Expand Down Expand Up @@ -102,12 +103,13 @@ export default function ContributeApp({ appId, token }) {
const [conn, setConn] = useState({ state: 'unknown' })
const [loading, setLoading] = useState(true)
const [view, setViewState] = useState(() => {
try { return sessionStorage.getItem('contribute-view-v1') || 'sources' }
catch { return 'sources' }
try { return sessionStorage.getItem('contribute-view-v2') || 'contributions' }
catch { return 'contributions' }
})
const [sourceSnapshot, setSourceSnapshot] = useState(null)
const [sourceLoading, setSourceLoading] = useState(true)
const [sourceError, setSourceError] = useState('')
const pageRef = useRef(null)
// Latest records for callbacks (the connect-flow refresh) that must not take
// a `records` dependency and re-bind on every ledger change.
const recordsRef = useRef(records)
Expand Down Expand Up @@ -245,13 +247,21 @@ export default function ContributeApp({ appId, token }) {

const setView = useCallback((next) => {
setViewState(next)
try { sessionStorage.setItem('contribute-view-v1', next) } catch { /* optional */ }
try { sessionStorage.setItem('contribute-view-v2', next) } catch { /* optional */ }
}, [])

// Contributions is one long reading feed; Repository map owns two internal
// panes on desktop. Reset the shared page scroller at the boundary so a deep
// feed position never shifts the map header or couples the two scroll modes.
useEffect(() => {
pageRef.current?.scrollTo({ top: 0, left: 0 })
}, [view])

// Send = direct PR submit. The platform claims the prepared record,
// recomputes the branch diff, safely fast-forwards a stale reusable fork,
// pushes to it, opens the PR, and returns the updated ledger record. On a
// partner-actionable failure the
// recomputes the branch diff, adapts it to a strictly-behind reusable fork
// without changing the fork's default branch, pushes the topic branch, opens
// the PR, and returns the updated ledger record. On a partner-actionable
// failure the
// server rolls the record back to `prepared` with last_submit_error, and the
// card stays ready for feedback/retry instead of handing off to an agent chat.
const onSend = useCallback(async (rec) => {
Expand All @@ -274,6 +284,39 @@ export default function ContributeApp({ appId, token }) {
return { error: outcome.error || 'Could not submit this PR.' }
}, [appId, token])

// One explicit confirmation can publish an exact, already-reviewed chain.
// The response may contain partial progress (for example, parent opened and
// child creation bounced), so merge every returned ledger record rather
// than treating the stack as all-or-nothing after public work has begun.
const onSendStack = useCallback(async (stackRecords) => {
const outcome = await submitContributionStack({
appId,
token,
recordIds: stackRecords.map((rec) => rec.id),
})
const updates = outcome.ok || outcome.records || []
if (updates.length > 0) {
const byId = new Map(updates.map((rec) => [rec.id, rec]))
setRecords((prev) => {
const next = prev.map((rec) => {
const update = byId.get(rec.id)
return update ? { ...update, path: rec.path } : rec
})
recordsRef.current = next
cacheFeed(next)
return next
})
}
if (outcome.ok) {
window.mobius?.signal?.('contribution_stack_submitted', {
stack_id: stackRecords[0]?.plan?.stack?.id || '',
item_count: outcome.submitted?.length || 0,
})
return { ok: true, submitted: outcome.submitted?.length || 0 }
}
return { error: outcome.error || 'Could not submit this PR stack.' }
}, [appId, token])

// Feedback = return to the chat that created the contribution, with a small
// draft already pointing at the exact record. Attention follow-ups can pass
// a more specific draft. Older records may not have
Expand Down Expand Up @@ -341,33 +384,34 @@ export default function ContributeApp({ appId, token }) {
const groups = useMemo(() => groupRecords(records), [records])
const isEmpty = records.length === 0
const sourceCount = sourceSnapshot
? 1 + (sourceSnapshot.apps?.length || 0)
? [sourceSnapshot.platform, ...(sourceSnapshot.apps || [])]
.filter((source) => source?.available).length
: null
const activeCount = stats.open + stats.ready

return (
<div className="co-root">
<style>{CSS}</style>
<div className={'co-page' + (view === 'sources' ? ' is-sources' : '')}>
<div ref={pageRef} className={'co-page' + (view === 'sources' ? ' is-sources' : '')}>
<Header appId={appId} fromCache={fromCache} />
<nav className="co-tabs" role="tablist" aria-label="Contribute views">
<button
type="button"
role="tab"
aria-selected={view === 'sources'}
className={view === 'sources' ? 'is-active' : ''}
onClick={() => setView('sources')}
aria-selected={view === 'contributions'}
className={view === 'contributions' ? 'is-active' : ''}
onClick={() => setView('contributions')}
>
Sources {sourceCount !== null && <span>{sourceCount}</span>}
Contributions {activeCount > 0 && <span>{activeCount}</span>}
</button>
<button
type="button"
role="tab"
aria-selected={view === 'contributions'}
className={view === 'contributions' ? 'is-active' : ''}
onClick={() => setView('contributions')}
aria-selected={view === 'sources'}
className={view === 'sources' ? 'is-active' : ''}
onClick={() => setView('sources')}
>
Contributions {activeCount > 0 && <span>{activeCount}</span>}
Repository map {sourceCount !== null && <span>{sourceCount}</span>}
</button>
</nav>

Expand All @@ -390,7 +434,9 @@ export default function ContributeApp({ appId, token }) {
{loading ? null : isEmpty ? <EmptyState /> : (
<Feed
groups={groups}
records={records}
onSend={onSend}
onSendStack={onSendStack}
onFeedback={onFeedback}
onDismiss={onDismiss}
onRestore={onRestore}
Expand Down
2 changes: 2 additions & 0 deletions mobius.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"source_files": [
"theme.js",
"domain.js",
"stack.js",
"source-map.js",
"diff.js",
"api.js",
Expand All @@ -49,6 +50,7 @@
"ui/DiffView.jsx",
"ui/FileDiffList.jsx",
"ui/ContributionCard.jsx",
"ui/ContributionStack.jsx",
"ui/Feed.jsx",
"ui/SourceMap.jsx"
]
Expand Down
Loading
Loading