Skip to content

Commit 027a172

Browse files
feat(checkin): checkin via batch lock session — E08
Push local abapGit/gCTS files back into SAP. Counterpart to `adt checkout` / `adt import package` (which read SAP → disk). Format-agnostic via the E05 FormatPlugin registry: CheckinService calls loadFormatPlugin(id), same code path for abapgit and gcts. ## @abapify/adt-locks — BatchLockSession New primitive: acquires N locks sequentially, on failure releases the ones already acquired in reverse order. release() is idempotent so `try { begin(); … } finally { release(); }` is always safe. SAP has no transactional multi-object save, so this is best-effort rollback; single -use session. API: createBatchLockSession(lockService, targets, { rollbackOnError? }): BatchLockSession { begin(), release(), handles(), active } ## @abapify/adt-cli — CheckinService Services: - diff.ts file-tree vs remote → {create, update, unchanged, skip} - plan.ts tier classification DEVC → DDIC → app → CDS → other, uses getMainType() from @abapify/adk for subtype resolution - apply.ts pre-flight batch lock → ADK save({mode:'update'}) per tier - filetree.ts FsFileTree (avoids adt-export dep cycle) - service.ts orchestrator exported from adt-cli public API CLI: adt checkin <directory> [--format abapgit|gcts] [--transport TR] [--dry-run] MCP: checkin tool (parity) ## gcts vs abapgit FormatPlugin dispatch is transparent — both --format abapgit and --format gcts go through loadFormatPlugin(id) → plugin.format.export(). Today gcts.format.export is not yet implemented (E06 deferral); CLI and MCP both surface a consistent capability error. Once E06 v0.2 ships deserialisation, checkin works for gcts with zero adt-cli changes. ## Tests adt-locks: 37 → 46 (+9) adt-cli: +19 (5 parity + 11 plan + 3 apply) adt-mcp: parity via adt-cli harness ## Known follow-ups (in epic) - FormatPlugin.diff() for finer-grained change detection - gcts format.export (E06 v0.2) - Mock server PUT coverage for true checkout+checkin round-trip - Dependency tier list is pragmatic, not exhaustive Reference: jfilak/sapcli sap/cli/checkin.py. Roadmap: docs/roadmap/epics/e08-checkin.md ✅ Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 665c16d commit 027a172

20 files changed

Lines changed: 1657 additions & 0 deletions

File tree

docs/roadmap/epics/e08-checkin.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,15 @@ Do NOT commit without approval.
9898

9999
- SAP doesn't expose an atomic "transactional" object save. True rollback is a best-effort: release locks + log "manual cleanup needed" for partially-applied PUTs. Acceptable?
100100
- Should `checkin` honor `.gitignore`-style excludes in the source directory?
101+
102+
## Follow-ups discovered during implementation (v0.1)
103+
104+
- **`CheckinService.diff` is coarse-grained.** `diffObject()` currently treats "remote exists + local had pending sources" as `update`, and "remote 404" as `create`. No per-field comparison (SAP returns ETags which ADK's save() uses to short-circuit identical content on the server side — so `unchanged` via ETag still works, just post-PUT rather than in the plan). Revisit when `FormatPlugin.diff()` lands (see E05 follow-ups) to compute true file-level diffs up-front.
105+
106+
- **Batch pre-flight lock then release.** `apply.ts` acquires all tier locks in a `BatchLockSession` purely to surface conflicts early, then releases and lets ADK's per-object `save({mode:'upsert'})` re-lock. This double-lock is cheap because the security session's CSRF token survives both cycles, but it means BatchLockSession is really a **validation primitive**, not an execution one. When ADK exposes a way to thread pre-acquired lock handles into `save()`, we can collapse the two cycles into one.
107+
108+
- **gCTS format plugin currently rejects checkin.** `@abapify/adt-plugin-gcts` does not yet implement `format.export` (Git → SAP). `CheckinService` correctly surfaces this as a plugin-capability error on both CLI and MCP — proving the dispatch is format-agnostic — but a real gCTS checkin needs that plugin method (see E06 v0.1 follow-ups: "Git → SAP direction [] deferred alongside E08 checkin"). Adding it lights up gCTS checkin with zero further changes to `CheckinService`.
109+
110+
- **E2E parity coverage is shallow for apply paths.** The mock server in `@abapify/adt-fixtures` doesn't yet model PUT-with-lock-handle + activation for every object type. The parity tests (`parity.checkin.test.ts`) therefore validate discovery + format dispatch + dry-run + MCP tool advertisement; lock/ETag/PUT apply paths are validated through the `tests/services/checkin/apply.test.ts` unit tests with mocked `LockService`. Extending the mock server with object-specific write routes is a separate epic-sized follow-up.
111+
112+
- **Dependency tier list is pragmatic, not exhaustive.** `plan.ts` covers DDIC primitives, packages, app code, and CDS/RAP. Rare types (BAdI impl, XSLT, MSAG, etc.) fall into `other` and are applied last-tier. Extend `TIER_FOR_TYPE` as new object types get ADK/abapGit support.

packages/adt-cli/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,21 @@ export {
1010
type TransportImportOptions,
1111
type ImportResult,
1212
} from './lib/services/import/service';
13+
14+
// Checkin (E08) — inverse of checkout; pushes local abapGit/gCTS files → SAP.
15+
export {
16+
CheckinService,
17+
type CheckinOptions,
18+
type CheckinResult,
19+
type ChangePlan,
20+
type ChangePlanEntry,
21+
type ChangeAction,
22+
type DependencyTier,
23+
type ApplyResult,
24+
type ApplyTierResult,
25+
buildPlan,
26+
classifyTier,
27+
flattenPlanObjects,
28+
diffObject,
29+
applyPlan,
30+
} from './lib/services/checkin';

packages/adt-cli/src/lib/cli.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
userCommand,
3838
sourceCommand,
3939
strustCommand,
40+
checkinCommand,
4041
} from './commands';
4142
import { createPackageCommand } from './commands/package';
4243
import {
@@ -286,6 +287,9 @@ export async function createCLI(options?: {
286287
// Checkout command (download SAP objects to abapgit-compatible files)
287288
program.addCommand(createCheckoutCommand());
288289

290+
// Checkin command (push local abapGit/gCTS directory into SAP — inverse of checkout)
291+
program.addCommand(checkinCommand);
292+
289293
// REPL - Interactive hypermedia navigator
290294
program.addCommand(createReplCommand());
291295

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* `adt checkin <directory>` — push a local abapGit/gCTS-formatted directory
3+
* into SAP (the inverse of `adt checkout`).
4+
*
5+
* Thin commander wrapper — all orchestration lives in `CheckinService`.
6+
*/
7+
import { Command } from 'commander';
8+
import { CheckinService } from '../services/checkin';
9+
import { getAdtClientV2 } from '../utils/adt-client-v2';
10+
11+
export const checkinCommand = new Command('checkin')
12+
.description(
13+
'Push a local abapGit/gCTS-formatted directory into SAP (inverse of checkout)',
14+
)
15+
.argument('<directory>', 'Source directory containing serialised files')
16+
.option(
17+
'--format <format>',
18+
"Format plugin id (default 'abapgit'; try 'gcts' for AFF layout)",
19+
'abapgit',
20+
)
21+
.option('-p, --package <package>', 'Target root SAP package for the checkin')
22+
.option(
23+
'-t, --transport <transport>',
24+
'Transport request to use for lock/save operations',
25+
)
26+
.option(
27+
'--types <types>',
28+
'Filter by object types (comma-separated, e.g. CLAS,INTF)',
29+
)
30+
.option('--dry-run', 'Validate & plan only — no writes to SAP', false)
31+
.option(
32+
'--no-activate',
33+
'Skip activation after save (objects remain inactive)',
34+
)
35+
.option(
36+
'--unlock',
37+
'Force-unlock objects already locked by the current user before applying',
38+
false,
39+
)
40+
.option(
41+
'--abap-language-version <version>',
42+
"ABAP language version for new objects (e.g. '5' for Cloud)",
43+
)
44+
.option('--json', 'Emit the CheckinResult as JSON (machine-readable)', false)
45+
.action(async (directory, options) => {
46+
try {
47+
// Ensure auth + ADK bootstrap.
48+
await getAdtClientV2();
49+
50+
const service = new CheckinService();
51+
const types = options.types
52+
? options.types
53+
.split(',')
54+
.map((t: string) => t.trim().toUpperCase())
55+
.filter(Boolean)
56+
: undefined;
57+
58+
if (!options.json) {
59+
console.log(`🚀 Checkin: ${directory}`);
60+
console.log(`📦 Format: ${options.format}`);
61+
if (options.package) console.log(`📁 Root package: ${options.package}`);
62+
if (options.transport)
63+
console.log(`🚚 Transport: ${options.transport}`);
64+
if (options.dryRun) console.log(`🔍 Dry run (no SAP writes)`);
65+
}
66+
67+
const result = await service.checkin({
68+
sourceDir: directory,
69+
format: options.format,
70+
rootPackage: options.package,
71+
transport: options.transport,
72+
objectTypes: types,
73+
dryRun: options.dryRun,
74+
activate: options.activate,
75+
unlock: options.unlock,
76+
abapLanguageVersion: options.abapLanguageVersion,
77+
onLog: (_level, msg) => {
78+
if (!options.json) console.log(` ${msg}`);
79+
},
80+
onObject: (obj, status) => {
81+
if (!options.json)
82+
console.log(` • ${obj.type} ${obj.name} (${status})`);
83+
},
84+
});
85+
86+
if (options.json) {
87+
console.log(JSON.stringify(result, null, 2));
88+
} else {
89+
console.log(`\n📊 ${result.summary}`);
90+
if (result.aborted) {
91+
console.error(
92+
'⚠️ Checkin aborted before completing all tiers — inspect errors above.',
93+
);
94+
process.exit(1);
95+
}
96+
if (result.apply.totals.failed > 0) {
97+
process.exit(1);
98+
}
99+
}
100+
} catch (error) {
101+
const msg = error instanceof Error ? error.message : String(error);
102+
console.error(`❌ Checkin failed: ${msg}`);
103+
process.exit(1);
104+
}
105+
});

packages/adt-cli/src/lib/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ export { checkCommand } from './check';
2727
export { userCommand } from './user';
2828
export { sourceCommand } from './source';
2929
export { strustCommand } from './strust';
30+
export { checkinCommand } from './checkin';
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* Apply stage — executes a ChangePlan against SAP.
3+
*
4+
* Each tier is deployed as its own `AdkObjectSet` so that DDIC objects are
5+
* saved + activated before the application-code tier starts. Within a tier,
6+
* objects are saved with `mode: 'upsert'` — SAP's PUT endpoints auto-detect
7+
* whether the object already exists so we don't need separate create/update
8+
* paths here.
9+
*
10+
* Locking:
11+
* - Before each tier, an outer `BatchLockSession` pre-acquires locks to
12+
* catch conflicts early (e.g. another user editing). Because ADK's
13+
* `save()` also lock/unlocks per object, we release the batch session
14+
* immediately after pre-flight — the CSRF security session is preserved
15+
* so the subsequent per-object re-lock is cheap.
16+
* - If the batch pre-flight fails, the tier is aborted and all locks
17+
* acquired so far (in that tier) are rolled back; partial state from
18+
* previous tiers is documented to the caller.
19+
*/
20+
import type { AdkObject, AdkContext } from '@abapify/adk';
21+
import { AdkObjectSet } from '@abapify/adk';
22+
import {
23+
createBatchLockSession,
24+
type BatchLockTarget,
25+
} from '@abapify/adt-locks';
26+
import type { ChangePlan, DependencyTier } from './plan';
27+
28+
export interface ApplyOptions {
29+
/** Transport request used for lock + save operations. */
30+
transport?: string;
31+
/** Dry run — build plan but don't touch SAP. */
32+
dryRun?: boolean;
33+
/** Activate saved objects after each tier. Default: true. */
34+
activate?: boolean;
35+
/**
36+
* Force-unlock objects currently locked by the authenticated user before
37+
* the batch lock session begins. Mirrors `adt export --unlock`.
38+
*/
39+
unlock?: boolean;
40+
/** Per-tier progress hook. */
41+
onTierStart?: (tier: DependencyTier, size: number) => void;
42+
/** Per-object progress hook. */
43+
onObject?: (object: AdkObject, status: string) => void;
44+
}
45+
46+
export interface ApplyTierResult {
47+
tier: DependencyTier;
48+
size: number;
49+
saved: number;
50+
failed: number;
51+
unchanged: number;
52+
activated: number;
53+
errors: Array<{ name: string; type: string; error: string }>;
54+
}
55+
56+
export interface ApplyResult {
57+
/** Per-tier stats in the order they were applied. */
58+
tiers: ApplyTierResult[];
59+
/** Aggregate totals. */
60+
totals: {
61+
saved: number;
62+
failed: number;
63+
unchanged: number;
64+
activated: number;
65+
};
66+
/** True if the caller aborted before all tiers ran (lock conflict). */
67+
aborted: boolean;
68+
}
69+
70+
/** Execute the change plan. Always returns a result — caller decides exit. */
71+
export async function applyPlan(
72+
plan: ChangePlan,
73+
ctx: AdkContext,
74+
options: ApplyOptions = {},
75+
): Promise<ApplyResult> {
76+
const activate = options.activate ?? true;
77+
const result: ApplyResult = {
78+
tiers: [],
79+
totals: { saved: 0, failed: 0, unchanged: 0, activated: 0 },
80+
aborted: false,
81+
};
82+
83+
for (const group of plan.groups) {
84+
const tierResult: ApplyTierResult = {
85+
tier: group.tier,
86+
size: group.entries.length,
87+
saved: 0,
88+
failed: 0,
89+
unchanged: 0,
90+
activated: 0,
91+
errors: [],
92+
};
93+
options.onTierStart?.(group.tier, group.entries.length);
94+
95+
if (options.dryRun) {
96+
// Pretend everything succeeded for reporting only.
97+
for (const entry of group.entries) {
98+
options.onObject?.(entry.object, 'dry-run');
99+
tierResult.saved++;
100+
}
101+
result.tiers.push(tierResult);
102+
result.totals.saved += tierResult.saved;
103+
continue;
104+
}
105+
106+
// Optional: force-unlock stale locks owned by the current user.
107+
if (options.unlock && ctx.lockService) {
108+
for (const entry of group.entries) {
109+
try {
110+
await ctx.lockService.forceUnlock(entry.object.objectUri);
111+
} catch {
112+
/* not locked or locked by another user — ignore */
113+
}
114+
}
115+
}
116+
117+
// Pre-flight: batch-lock to surface conflicts BEFORE we start mutating.
118+
// We release immediately afterwards because ADK's save() will re-lock
119+
// per object within the same security session (CSRF survives unlock).
120+
let preflightFailed = false;
121+
if (ctx.lockService) {
122+
const targets: BatchLockTarget[] = group.entries.map((e) => ({
123+
objectUri: e.object.objectUri,
124+
options: {
125+
transport: options.transport,
126+
objectName: e.object.name,
127+
objectType: e.object.type,
128+
},
129+
}));
130+
const batch = createBatchLockSession(ctx.lockService, targets);
131+
try {
132+
await batch.begin();
133+
} catch (err) {
134+
const msg = err instanceof Error ? err.message : String(err);
135+
tierResult.errors.push({
136+
name: '(batch)',
137+
type: group.tier,
138+
error: `batch lock pre-flight failed: ${msg}`,
139+
});
140+
tierResult.failed = group.entries.length;
141+
preflightFailed = true;
142+
} finally {
143+
await batch.release();
144+
}
145+
}
146+
147+
if (preflightFailed) {
148+
result.tiers.push(tierResult);
149+
result.totals.failed += tierResult.failed;
150+
result.aborted = true;
151+
// Stop applying further tiers — the caller sees a partial-apply state.
152+
break;
153+
}
154+
155+
// Build the tier's AdkObjectSet and deploy.
156+
const set = new AdkObjectSet(ctx);
157+
for (const entry of group.entries) set.add(entry.object);
158+
159+
const deployResult = await set.deploy({
160+
transport: options.transport,
161+
activate,
162+
mode: 'upsert',
163+
onProgress: (_processed, _total, current) => {
164+
options.onObject?.(current, 'saved');
165+
},
166+
});
167+
168+
tierResult.saved = deployResult.save.success;
169+
tierResult.failed = deployResult.save.failed;
170+
tierResult.unchanged = deployResult.save.unchanged;
171+
tierResult.activated = deployResult.activation?.success ?? 0;
172+
for (const r of deployResult.save.results) {
173+
if (!r.success && !r.unchanged) {
174+
tierResult.errors.push({
175+
name: r.object.name,
176+
type: r.object.type,
177+
error: r.error ?? 'unknown error',
178+
});
179+
}
180+
}
181+
182+
result.tiers.push(tierResult);
183+
result.totals.saved += tierResult.saved;
184+
result.totals.failed += tierResult.failed;
185+
result.totals.unchanged += tierResult.unchanged;
186+
result.totals.activated += tierResult.activated;
187+
}
188+
189+
return result;
190+
}

0 commit comments

Comments
 (0)