AgentStack
SKILL verified MIT Self-run

Sync Gitlab

skill-supermodo-skills-sync-gitlab · by supermodo

Sync a project's roadmap/work docs to GitLab CE issues and child tasks, one-way (markdown is the source of truth, never written back). Reads the supermodo docs convention work triads and BACKLOG; each deliverable becomes an Issue and each tasks.md checklist line a child Task. Config-driven, secrets by env-var name only, GitLab IDs persisted locally, every mutation batch previewed and separately c…

No reviews yet
0 installs
5 views
0.0% view→install

Install

$ agentstack add skill-supermodo-skills-sync-gitlab

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access Used
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets Used
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Sync Gitlab? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Sync markdown roadmap → GitLab CE

> Requires: the sibling protocols skill (shared protocol masters) and a valid skills.config.json (create with the config skill). Missing either → halt with that exact pointer; never guess.

You mirror this project's markdown work docs into GitLab CE one-way. Markdown is the source of truth; you NEVER write back to markdown. You act only on entities this package owns (see Identity). Everything else on the server is read-only to you.

> Cross-tool note (Claude Code ↔ Codex). Written in host-neutral terms. > Under Claude Code: the Bash tool runs curl/jq; Read/Grep read markdown. Under > Codex: exec_command runs curl/jq; native file reads / rg. Codex > approval/escalation ⇔ Claude Code permission prompts. Shell state does not > survive between separate invocations — set the secret variables and define > gl_curl in the SAME invocation that makes each API call.

Scope (v1)

In: the project's deliverables → GitLab Issues, and each tasks.md checklist line → a native GitLab child Task (issue_type=task) linked to its parent Issue via the work-items parent_id / hierarchyWidget.

Explicitly OUT (deferred, do not attempt): boards, issue templates, project memberships/invites, and milestone management. If the roadmap implies any of these, note it in the report — never mutate them.


Step 0 — Config (validate before anything)

Read skills.config.json at the project root and validate per ../protocols/references/config.md. sync-gitlab requires the gitlab section and docs:

"gitlab": {
  "url": "https://gitlab.example.com",   // MUST be https://
  "project": "group/name",               // GitLab project path
  "tokenEnv": "SUPERMODO_GITLAB_TOKEN",  // PAT env var NAME (SUPERMODO_* only)
  "cloudflareAccess": {                  // optional
    "clientIdEnv": "SUPERMODO_CF_CLIENT_ID",
    "clientSecretEnv": "SUPERMODO_CF_CLIENT_SECRET"
  }
}

Halt (point the user to config) if: configVersion ≠ 1; gitlab missing; url not https://; project absent; tokenEnv absent or not starting with SUPERMODO_; any *Env value not starting with SUPERMODO_. Never guess.

Derive once, without interpolating config values into shell strings:

API="/api/v4"          # e.g. https://gitlab.example.com/api/v4
GQL="/api/graphql"
PROJ=  # group/name -> group%2Fname

Step 1 — Secrets (NEVER source a dotenv)

Read ONLY the variables named by config. Prefer process env (if the named vars are exported, use them). Otherwise strict .env parse: read line-by-line as KEY=VALUE, take ONLY the config-named keys, and halt on any line whose value contains $, backtick, (, ), ;. Never source, never eval, never run the file through a shell.

# read_secret VARNAME FILE -> echoes value on stdout, empty if absent/rejected
read_secret() {
  local key="$1" file="$2"
  [ -f "$file" ] || return 0
  while IFS= read -r line; do
    case "$line" in
      "$key="*)
        local val="${line#*=}"
        case "$val" in *'$'*|*'`'*|*'('*|*')'*|*';'*)
          echo "Halt: unsafe characters in $key; refusing to parse." >&2; exit 1 ;;
        esac
        printf '%s' "$val"; return 0 ;;
    esac
  done                        (https verified)
  Project:  
  Headers:  Authorization: Bearer    [value hidden]
            CF-Access-Client-Id / CF-Access-Client-Secret     [only if configured]
Proceed with a read-only /user probe? [y/N]

On y, define the wrapper and probe (same invocation). The wrapper adds CF headers only when configured:

gl_curl() {
  local args=(-sS --fail-with-body -H "Authorization: Bearer $GL_TOKEN")
  [ -n "$CF_ID" ] && args+=(-H "CF-Access-Client-Id: $CF_ID" -H "CF-Access-Client-Secret: $CF_SECRET")
  curl "${args[@]}" "$@"
}
gl_curl "$API/user" | jq -r '.username'

Secrets pass ONLY through -H arguments with shell variables — never in a URL or -d string. HTML response (/ (spec.md, plan.md, tasks.md) → one parent Issue. Title/summary from spec.md; acceptance from ## Acceptance (else ## Requirements). BACKLOG entries promoted to triads are in scope; struck-through history is not.

  • Child Tasks — every checklist line in tasks.md → one child Task.

Identity is the immutable inline ID `, NEVER list position or title. Line shape: - [] . : pending · / in-progress · x done · ^/- paused. Done and paused → Task closed; pending and in-progress → open. Halt with file:line on a checklist line missing its task:` marker or a duplicate slug within a file.

Build a model: deliverables keyed by slug, each with ordered tasks[] keyed by task-slug. Halt on duplicate deliverable slug; never auto-fix markdown — surface file:line and stop.


Identity — the ownership marker (read this before any write)

Every entity this package manages carries BOTH:

  1. a label in the supermodo:* namespace — at minimum supermodo:managed,

plus supermodo:task: (child Tasks) / supermodo:work: (parent Issues) encoding the source ID; and

  1. a description footer line, exactly:

` where ` is the deliverable or task slug.

The source ID is generated deterministically from the markdown slug — stable across runs. Returned GitLab iid/id are persisted to the map (below).

> Quarantine / read-only rule (absolute). Any GitLab Issue or Task that does > NOT carry the supermodo:* marker is READ-ONLY: no updates, no closes, no > label changes, no reparenting. It is invisible to mutation. This subsumes any > project's suggestion/quarantine convention — no marker, no touch, full stop.


State files (symlink-contained)

Under .skills/supermodo/ (gitignored). Before EVERY read/write, resolve real paths (follow symlinks) and halt unless the destination stays beneath the project's real .skills/supermodo/ — per ../protocols/references/reports.md. Write temp-then-rename in the same directory.

  • gitlab-map.json — `{ "": { "kind": "issue|task", "iid": N,

"id": N, "parentIid": N|null } }`. The authoritative source-id → GitLab-id map.

  • gitlab-journal.jsonl — durable mutation journal (Recovery, below).

Step 4 — Diff (read-only API calls, paginated)

Compute per-entity CREATE | UPDATE | CLOSE | REOPEN | UNCHANGED. Pagination is uniform on EVERY collection (issues, tasks, labels): per_page=100, follow the x-next-page header until exhausted. Never read only page 1.

# paginate: echoes concatenated JSON arrays' elements. $1 = full URL sans page.
gl_page() {
  local url="$1" page=1 next
  while :; do
    local hdr body
    body="$(gl_curl -D /tmp/h.$$ "$url&per_page=100&page=$page")" || return 1
    echo "$body" | jq -c '.[]'
    next="$(awk -F': ' 'tolower($1)=="x-next-page"{gsub(/\r/,"",$2);print $2}' /tmp/h.$$)"
    [ -n "$next" ] || break; page="$next"
  done; rm -f /tmp/h.$$
}
gl_page "$API/projects/$PROJ/issues?state=all"        # parents + tasks (filter issue_type client-side)
gl_page "$API/projects/$PROJ/labels"                  # for merge-preserve

Split issues into parents (issue_type null/issue) and tasks (issue_type=="task"). Match model entities to server entities by the supermodo:id= footer / supermodo:* label ONLY — not by title. Entities with no marker are skipped (read-only rule). For each managed entity decide the action; for child Tasks also detect missing/wrong parent link.

Record, per matched entity, the server updated_at — the preview snapshot used by the concurrency check.


Step 5 — Preview + mutation approval (separate, explicit)

Present a COMPLETE diff — every entity, its action, and the exact fields/labels that change:

Proposed GitLab mutations (project ):
  Issues:   create,  update,  close,  reopen
  Tasks:    create,  update,  close,  reopen,  parent-link
  Read-only (no supermodo marker, skipped): 

  CREATE issue  work:   ""   labels: supermodo:managed,supermodo:work:
  UPDATE task   task:   fields: state x→closed; +supermodo:task:
  ...  (list ALL, not a sample, when the batch is small; page long batches)

Proceed with these mutations? [y/N]

Wait for an explicit y/yes in a SEPARATE turn — never a shell read, never the same turn as the preview. This confirmation is DISTINCT from the Step-2 auth acknowledgement. Anything else halts cleanly, zero mutations. If the count is 0, print "No changes — already in sync." and exit.


Step 6 — Apply (writes, journaled, conflict-checked)

Order: parent Issues, then their child Tasks (create parent before its tasks so parentIid exists). Per entity:

a. Journal planned — append one JSONL line before the mutation: {"entity":"","action":"...","before":, "srcHash":"","ts":""}.

b. Concurrency refetch — immediately GET this entity fresh and compare its updated_at to the preview snapshot. If it changed, someone else edited it: ABORT this entity, do not mutate, and re-run Diff→Preview for it (re-preview).

c. Mutate. Secrets only via -H; JSON bodies built with jq -n (never string interpolation of values):

# CREATE parent Issue
BODY=$(jq -n --arg t "$title" --arg d "$desc$FOOTER" --arg l "supermodo:managed,supermodo:work:$slug" \
  '{title:$t, description:$d, labels:$l}')
RESP=$(gl_curl -X POST "$API/projects/$PROJ/issues" -H "Content-Type: application/json" -d "$BODY")
IID=$(echo "$RESP" | jq -r .iid); GID=$(echo "$RESP" | jq -r .id)

# CREATE child Task, then parent-link via GraphQL work-items
BODY=$(jq -n --arg t "$title" --arg d "$desc$FOOTER" \
  --arg l "supermodo:managed,supermodo:task:$slug" \
  '{title:$t, description:$d, issue_type:"task", labels:$l}')
TRESP=$(gl_curl -X POST "$API/projects/$PROJ/issues" -H "Content-Type: application/json" -d "$BODY")
TGID="gid://gitlab/WorkItem/$(echo "$TRESP" | jq -r .id)"
PGID="gid://gitlab/WorkItem/$GID"
Q='mutation($id:WorkItemID!,$p:WorkItemID!){workItemUpdate(input:{id:$id,hierarchyWidget:{parentId:$p}}){errors}}'
gl_curl -X POST "$GQL" -H "Content-Type: application/json" \
  -d "$(jq -n --arg q "$Q" --arg id "$TGID" --arg p "$PGID" '{query:$q,variables:{id:$id,p:$p}}')"

d. Label preservation (CRITICAL). GitLab PUT labels REPLACES the full set. Before any update: take the current labels from the fresh GET, keep EVERY label verbatim, and change ONLY supermodo:* labels (add/remove within that namespace only). Then PUT the merged set. Touch no other namespace.

KEEP=$(echo "$current" | jq -r '[.labels[] | select(startswith("supermodo:")|not)] | join(",")')
NEW="$KEEP,supermodo:managed,supermodo:task:$slug"   # our namespace, recomputed
gl_curl -X PUT "$API/projects/$PROJ/issues/$iid" -H "Content-Type: application/json" \
  -d "$(jq -n --arg l "$NEW" --arg s "close" '{labels:$l, state_event:$s}')"

e. Post-create duplicate guard. Immediately after each CREATE, re-query the project for the source-ID marker (supermodo:work: / supermodo:task:). If MORE THAN ONE entity carries it, a concurrent run created a twin: HALT for deterministic reconciliation (report both iids; do not auto-delete). Otherwise persist iid/id/parentIid to gitlab-map.json.

f. Journal done — append {"entity":...,"action":...,"after":,"iid":N,"ts":...}.

Dependency failure: if a parent Issue create/update fails, STOP — do not create its child Tasks (they would orphan). Record it; the unfinished tail stays journaled for resume.

Per-mutation errors: 2xx → log+continue; 409 on create → treat as exists, verify via marker, continue; other 4xx → halt Apply, print body; 5xx → log+continue (Verify surfaces drift); network error → halt.


Recovery / resume

On invocation, if gitlab-journal.jsonl has planned entries without a matching done, the previous run was interrupted. Resume is idempotent:

  1. Recompute the markdown + config hash. If it differs from the journal's

srcHash, the source changed → invalidate: discard the plan, re-run Diff→Preview from scratch (re-confirm).

  1. Per entity, refetch and compare updated_at to the journal ref. If it

differs, the remote changed → re-preview that entity, don't blind re-apply.

  1. Only entities whose source AND remote are unchanged resume from the journal.

Stop on the first dependency failure as in Apply.

Journal + map make re-runs deterministic: a clean re-run with no markdown change produces ZERO mutations.


Step 7 — Verify

Re-run the paginated Diff read-only; confirm every planned entity reached its desired state and each child Task's parent link is intact. Report (do NOT auto-fix) drift: 2xx-but-unchanged, expected-but-failed, unexpected new entities (concurrent editor), unparented tasks. A re-run reconciles deterministically.


Final report

Persist to .skills/supermodo/sync-gitlab/.md (per ../protocols/references/reports.md) and summarize in chat:

sync-gitlab — 
Host/project:  / 
Parsed:   deliverables,  tasks
Applied: issues c/u/cl/r · tasks c/u/cl/r/parent-linked
Skipped (no marker, read-only): 
Failed:    Drift:    Duplicates halted: 
Result: 

Never write back to markdown. Never auto-commit. Chat reporting follows output.verbosity (default concise); safety output (auth/mutation confirmations, warnings) is never compressed.

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.