diff --git a/go.mod b/go.mod --- a/go.mod +++ b/go.mod @@ -61,9 +61,11 @@ require ( dario.cat/mergo v1.0.1 // indirect + github.com/BurntSushi/toml v0.3.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect + github.com/adrg/frontmatter v0.2.0 // indirect github.com/alecthomas/repr v0.5.2 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect @@ -231,6 +233,7 @@ gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gotest.tools/v3 v3.5.2 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum --- a/go.sum +++ b/go.sum @@ -4,6 +4,7 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Blank-Xu/sql-adapter v1.1.1 h1:+g7QXU9sl/qT6Po97teMpf3GjAO0X9aFaqgSePXvYko= github.com/Blank-Xu/sql-adapter v1.1.1/go.mod h1:o2g8EZhZ3TudnYEGDkoU+3jCTCgDgx1o/Ig5ajKkaLY= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= @@ -11,6 +12,8 @@ github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= +github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4= +github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= @@ -770,6 +773,7 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tailwind.config.js b/tailwind.config.js --- a/tailwind.config.js +++ b/tailwind.config.js @@ -2,74 +2,87 @@ const colors = require("tailwindcss/colors"); module.exports = { - content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"], - darkMode: "media", - theme: { - container: { - padding: "2rem", - center: true, - screens: { - sm: "500px", - md: "600px", - lg: "800px", - xl: "1000px", - "2xl": "1200px", - }, - }, - extend: { - fontFamily: { - sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"], - mono: [ - "IBMPlexMono", - "ui-monospace", - "SFMono-Regular", - "Menlo", - "Monaco", - "Consolas", - "Liberation Mono", - "Courier New", - "monospace", - ], - }, - typography: { - DEFAULT: { - css: { - maxWidth: "none", - pre: { - "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, - }, - code: { - "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {}, - }, - "code::before": { - content: '""', - }, - "code::after": { - content: '""', - }, - blockquote: { - quotes: "none", - }, - 'h1, h2, h3, h4': { - "@apply mt-4 mb-2": {} - }, - h1: { - "@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": {} - }, - h2: { - "@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": {} - }, - h3: { - "@apply mt-2": {} - }, - }, - }, - }, - gridTemplateColumns: { - '14': 'repeat(14, minmax(0, 1fr))', - '28': 'repeat(28, minmax(0, 1fr))', - } - }, - }, - plugins: [require("@tailwindcss/typography")], + content: [ + "./appview/pages/templates/**/*.html", + "./appview/pages/chroma.go", + "./docs/*.html", + "./blog/templates/**/*.html", + "./blog/posts/**/*.md", + ], + darkMode: "media", + theme: { + container: { + padding: "2rem", + center: true, + screens: { + sm: "500px", + md: "600px", + lg: "800px", + xl: "1000px", + "2xl": "1200px", + }, + }, + extend: { + fontFamily: { + sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"], + mono: [ + "IBMPlexMono", + "ui-monospace", + "SFMono-Regular", + "Menlo", + "Monaco", + "Consolas", + "Liberation Mono", + "Courier New", + "monospace", + ], + }, + typography: { + DEFAULT: { + css: { + maxWidth: "none", + pre: { + "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": + {}, + }, + code: { + "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": + {}, + }, + "code::before": { + content: '""', + }, + "code::after": { + content: '""', + }, + blockquote: { + quotes: "none", + }, + "h1, h2, h3, h4": { + "@apply mt-4 mb-2": {}, + }, + h1: { + "@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": + {}, + }, + h2: { + "@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": + {}, + }, + h3: { + "@apply mt-2": {}, + }, + img: { + "@apply rounded border border-gray-200 dark:border-gray-700": {}, + }, + }, + }, + }, + gridTemplateColumns: { + 14: "repeat(14, minmax(0, 1fr))", + 28: "repeat(28, minmax(0, 1fr))", + }, + }, + }, + plugins: [require("@tailwindcss/typography")], }; diff --git a/blog/blog.go b/blog/blog.go new file mode 100644 --- /dev/null +++ b/blog/blog.go @@ -0,0 +1,174 @@ +package blog + +import ( + "bytes" + "cmp" + "html/template" + "io" + "io/fs" + "os" + "slices" + "strings" + "time" + + "github.com/adrg/frontmatter" + "github.com/gorilla/feeds" + + "tangled.org/core/appview/pages" + "tangled.org/core/appview/pages/markup" + textension "tangled.org/core/appview/pages/markup/extension" +) + +type Author struct { + Name string `yaml:"name"` + Email string `yaml:"email"` + Handle string `yaml:"handle"` +} + +type PostMeta struct { + Slug string `yaml:"slug"` + Title string `yaml:"title"` + Subtitle string `yaml:"subtitle"` + Date string `yaml:"date"` + Authors []Author `yaml:"authors"` + Image string `yaml:"image"` + Draft bool `yaml:"draft"` +} + +type Post struct { + Meta PostMeta + Body template.HTML +} + +func (p Post) ParsedDate() time.Time { + t, _ := time.Parse("2006-01-02", p.Meta.Date) + return t +} + +type indexParams struct { + LoggedInUser any + Posts []Post + Featured []Post +} + +type postParams struct { + LoggedInUser any + Post Post +} + +// Posts parses and returns all non-draft posts sorted newest-first. +func Posts(postsDir string) ([]Post, error) { + return parsePosts(postsDir, false) +} + +// AllPosts parses and returns all posts including drafts, sorted newest-first. +func AllPosts(postsDir string) ([]Post, error) { + return parsePosts(postsDir, true) +} + +func parsePosts(postsDir string, includeDrafts bool) ([]Post, error) { + fsys := os.DirFS(postsDir) + + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return nil, err + } + + rctx := &markup.RenderContext{ + RendererType: markup.RendererTypeDefault, + Sanitizer: markup.NewSanitizer(), + } + var posts []Post + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + + data, err := fs.ReadFile(fsys, entry.Name()) + if err != nil { + return nil, err + } + + var meta PostMeta + rest, err := frontmatter.Parse(bytes.NewReader(data), &meta) + if err != nil { + return nil, err + } + + if meta.Draft && !includeDrafts { + continue + } + + htmlStr := rctx.RenderMarkdownWith(string(rest), markup.NewMarkdownWith("", textension.Dashes)) + sanitized := rctx.SanitizeDefault(htmlStr) + + posts = append(posts, Post{ + Meta: meta, + Body: template.HTML(sanitized), + }) + } + + slices.SortFunc(posts, func(a, b Post) int { + return cmp.Compare(b.Meta.Date, a.Meta.Date) + }) + + return posts, nil +} + +func AtomFeed(posts []Post, baseURL string) (string, error) { + feed := &feeds.Feed{ + Title: "the tangled blog", + Link: &feeds.Link{Href: baseURL}, + Author: &feeds.Author{Name: "Tangled"}, + Created: time.Now(), + } + + for _, p := range posts { + postURL := strings.TrimRight(baseURL, "/") + "/" + p.Meta.Slug + + var authorName string + for i, a := range p.Meta.Authors { + if i > 0 { + authorName += " & " + } + authorName += a.Name + } + + feed.Items = append(feed.Items, &feeds.Item{ + Title: p.Meta.Title, + Link: &feeds.Link{Href: postURL}, + Description: p.Meta.Subtitle, + Author: &feeds.Author{Name: authorName}, + Created: p.ParsedDate(), + }) + } + + return feed.ToAtom() +} + +// RenderIndex renders the blog index page to w. +func RenderIndex(p *pages.Pages, templatesDir string, posts []Post, w io.Writer) error { + tpl, err := p.ParseWith(os.DirFS(templatesDir), "index.html") + if err != nil { + return err + } + var featured []Post + for _, post := range posts { + if post.Meta.Image != "" { + featured = append(featured, post) + if len(featured) == 3 { + break + } + } + } + return tpl.ExecuteTemplate(w, "layouts/base", indexParams{Posts: posts, Featured: featured}) +} + +// RenderPost renders a single blog post page to w. +func RenderPost(p *pages.Pages, templatesDir string, post Post, w io.Writer) error { + tpl, err := p.ParseWith(os.DirFS(templatesDir), "post.html") + if err != nil { + return err + } + return tpl.ExecuteTemplate(w, "layouts/base", postParams{Post: post}) +} diff --git a/blog/config.yaml b/blog/config.yaml new file mode 100644 --- /dev/null +++ b/blog/config.yaml @@ -0,0 +1,12 @@ +preBuild: + - tailwindcss -i ../input.css -o static/tw.css +title: the tangled blog +# note the trailing slash! +url: "https://blog.tangled.org/" +description: "" +author: + name: "Anirudh Oppiliappan" + email: "anirudh@tangled.sh" +defaultTemplate: text.html +extraTemplateDirs: + - ../appview/pages/templates diff --git a/blog/posts/6-months.md b/blog/posts/6-months.md new file mode 100644 --- /dev/null +++ b/blog/posts/6-months.md @@ -0,0 +1,160 @@ +--- +atroot: true +template: +slug: 6-months +title: 6 months of Tangled +subtitle: a quick recap, and notes on the future +date: 2025-10-21 +image: https://assets.tangled.network/blog/6-months.png +authors: + - name: Anirudh + email: anirudh@tangled.org + handle: anirudh.fi + - name: Akshay + email: akshay@tangled.org + handle: oppi.li +draft: false +--- + +Hello Tanglers! It's been over 6 months since we first announced +Tangled, so we figured we'd do a quick retrospective of what we built so +far and what's next. + +If you're new here, here's a quick overview: Tangled is a git hosting +and collaboration platform built on top of the [AT +Protocol](https://atproto.com). You can read a bit more about our +architecture [here](/intro). + +## new logo and mascot: dolly! + +Tangled finally has a logo! Designed by Akshay himself, Dolly is in +reference to the first ever *cloned* mammal. For a full set of brand assets and guidelines, see our new [branding page](https://tangled.org/brand). + +![logo with text](https://assets.tangled.network/blog/logo_with_text.jpeg) + +With that, let's recap the major platform improvements so far! + +## pull requests: doubling down on jujutsu + +One of the first major features we built was our [pull requests +system](/pulls), which follows a unique round-based submission & review +approach. This was really fun to innovate on -- it remains one of +Tangled's core differentiators, and one we plan to keep improving. + +In the same vein, we're the first ever code forge to support [stacking +pull requests](/stacking) using Jujutsu! We're big fans of the tool and +we use it everyday as we hack on +[tangled.org/core](https://tangled.org/@tangled.org/core). + +Ultimately, we think PR-based collaboration should evolve beyond the +traditional model, and we're excited to keep experimenting with new +ideas that make code review and contribution easier! + +## spindle + +CI was our most requested feature, and we spent a *lot* of time debating +how to approach it. We considered integrating with existing platforms, +but none were good fits. So we gave in to NIH and [built spindle +ourselves](/ci)! This allowed us to go in on Nix using Nixery to build +CI images on the fly and cache them. + +Spindle is still early but designed to be extensible and is AT-native. +The current Docker/Nixery-based engine is limiting -- we plan to switch +to micro VMs down the line to run full-fledged NixOS (and other base +images). Meanwhile, if you've got ideas for other spindle backends +(Kubernetes?!), we'd love to [hear from you](https://chat.tangled.org). + +## XRPC APIs + +We introduced a complete migration of the knotserver to an +[XRPC](https://atproto.com/specs/xrpc) API. Alongside this, we also +decoupled the knot from the appview by getting rid of the registration +secret, which was centralizing. Knots (and spindles) simply declare +their owner, and any appview can verify ownership. Once we stabilize the +[lexicon definitions](lexicons) for these XRPC calls, building clients +for knots, or alternate implementations should become much simpler. + +[lexicons]: https://tangled.sh/@tangled.sh/core/tree/master/lexicons + +## issues rework + +Issues got a major rework (and facelift) too! They are now threaded: +top-level comments with replies. This makes Q/A style discussions much +easier to follow! + +![issue thread](https://assets.tangled.network/blog/issue-threading.webp) + +## hosted PDS + +A complaint we often recieved was the need for a Bluesky account to use +Tangled; and besides, we realised that the overlap between Bluesky users +and possible Tangled users only goes so far -- we aim to be a generic +code forge after all, AT just happens to be an implementation +detail. + +To address this, we spun up the tngl.sh PDS hosted right here in +Finland. The only way to get an account on this PDS is by [signing +up](https://tangled.sh/signup). There's a lot we can do to improve this +experience as a generic PDS host, but we're still working out details +around that. + +## labels + +You can easily categorize issues and pulls via labels! There is plenty +of customization available: + +- labels can be basic, or they can have a key and value set, for example: +`wontfix` or `priority/high` +- labels can be constrained to a set of values: `priority: [high medium low]` +- there can be multiple labels of a given type: `reviewed-by: @oppi.li`, +`reviewed-by: @anirudh.fi` + +The options are endless! You can access them via your repo's settings page. + +
+
+ + A set of labels applied to an issue. + +
A set of labels applied to an issue.
+
+ +
+ + Create custom key-value type labels. + +
Create custom key-value type labels.
+
+
+ + +## notifications + +In-app notifications now exist! You get notifications for a variety of events now: + +* new issues/pulls on your repos (also for collaborators) +* comments on your issues/pulls (also for collaborators) +* close/reopen (or merge) of issues/pulls +* new stars +* new follows + +All of this can be fine-tuned in [/settings/notifications](https://tangled.org/settings/notifications). + +![notifications](https://assets.tangled.network/blog/notifications.png) + + +## the future + +We're working on a *lot* of exciting new things and possibly some big +announcements to come. Be on the lookout for: + +* email notifications +* preliminary support for issue and PR search +* total "atprotation" [^1] -- the last two holdouts here are repo and pull records +* total federation -- i.e. supporting third-party appviews by making it + reproducible +* achieve complete independence from Bluesky PBC by hosting our own relay + +That's all for now; we'll see you in the atmosphere! Meanwhile, if you'd like to contribute to projects on Tangled, make sure to check out the [good first issues page](https://tangled.org/goodfirstissues) to get started! + +[^1]: atprotation implies a two-way sync between the PDS and appview. Currently, pull requests and repositories are not ingested -- so writing/updating either records on your PDS will not show up on the appview. diff --git a/blog/posts/ci.md b/blog/posts/ci.md new file mode 100644 --- /dev/null +++ b/blog/posts/ci.md @@ -0,0 +1,214 @@ +--- +atroot: true +template: +slug: ci +title: introducing spindle +subtitle: tangled's new CI runner is now generally available +date: 2025-08-06 +authors: + - name: Anirudh + email: anirudh@tangled.sh + handle: anirudh.fi + - name: Akshay + email: akshay@tangled.sh + handle: oppi.li +--- + +Since launching Tangled, continuous integration has +consistently topped our feature request list. Today, CI is +no longer a wishlist item, but a fully-featured reality. + +Meet **spindle**: Tangled's new CI runner built atop Nix and +AT Protocol. In typical Tangled fashion we've been +dogfooding spindle for a while now; this very blog post +you're reading was [built and published using +spindle](https://tangled.sh/@tangled.sh/site/pipelines/452/workflow/deploy.yaml). + +Tangled is a new social-enabled Git collaboration platform, +[read our intro](/intro) for more about the project. + +![spindle architecture](https://assets.tangled.network/blog/spindle-arch.png) + +## how spindle works + +Spindle is designed around simplicity and the decentralized +nature of the AT Protocol. In ingests "pipeline" records and +emits job status updates. + +When you push code or open a pull request, the knot hosting +your repository emits a pipeline event +(`sh.tangled.pipeline`). Running as a dedicated service, +spindle subscribes to these events via websocket connections +to your knot. + +Once triggered, spindle reads your pipeline manifest, spins +up the necessary execution environment (covered below), and +runs your defined workflow steps. Throughout execution, it +streams real-time logs and status updates +(`sh.tangled.pipeline.status`) back through websockets, +which the Tangled appview subscribes to for live updates. + +Over at the appview, these updates are ingested and stored, +and logs are streamed live. + +## spindle pipelines + +The pipeline manifest is defined in YAML, and should be +relatively familiar to those that have used other CI +solutions. Here's a minimal example: + +```yaml +# test.yaml + +when: + - event: ["push", "pull_request"] + branch: ["master"] + +dependencies: + nixpkgs: + - go + +steps: + - name: run all tests + environment: + CGO_ENABLED: 1 + command: | + go test -v ./... +``` + +You can read the [full manifest spec +here](https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/pipeline.md), +but the `dependencies` block is the real interesting bit. +Dependencies for your workflow, like Go, Node.js, Python +etc. can be pulled in from nixpkgs. +[Nixpkgs](https://github.com/nixos/nixpkgs/) -- for the +uninitiated -- is a vast collection of packages for the Nix +package manager. Fortunately, you needn't know nor care +about Nix to use it! Just head to https://search.nixos.org +to find your package of choice (I'll bet 1€ that it's +there[^1]), toss it in the list and run your build. The +Nix-savvy of you lot will be happy to know that you can use +custom registries too. + +[^1]: I mean, if it isn't there, it's nowhere. + +Workflow manifests are intentionally simple. We do not want +to include a "marketplace" of workflows or complex job +orchestration. The bulk of the work should be offloaded to a +build system, and CI should be used simply for finishing +touches. That being said, this is still the first revision +for CI, there is a lot more on the roadmap! + +Let's take a look at how spindle executes workflow steps. + +## workflow execution + +At present, the spindle "engine" supports just the Docker +backend[^2]. Podman is known to work with the Docker socket +feature enabled. Each step is run in a separate container, +with the `/tangled/workspace` and `/nix` volumes persisted +across steps. + +[^2]: Support for additional backends like Firecracker are + planned. Contributions welcome! + +The container image is built using +[Nixery](https://nixery.dev). Nixery is a nifty little tool +that takes a path-separated set of Nix packages and returns +an OCI image with each package in a separate layer. Try this +in your terminal if you've got Docker installed: + +``` +docker run nixery.dev/bash/hello-go hello-go +``` + +This should output `Hello, world!`. This is running the +[hello-go](https://search.nixos.org/packages?channel=25.05&show=hello-go) +package from nixpkgs. + +Nixery is super handy since we can construct these images +for CI environments on the fly, with all dependencies baked +in, and the best part: caching for commonly used packages is +free thanks to Docker (pre-existing layers get reused). We +run a Nixery instance of our own at +https://nixery.tangled.sh but you may override that if you +choose to. + +## debugging CI + +We understand that debugging CI can be the worst. There are +two parts to this problem: + +- CI services often bring their own workflow definition + formats and it can sometimes be difficult to know why the + workflow won't run or why the workflow definition is + incorrect +- The CI job itself fails, but this has more to do with the + build system of choice + +To mend the first problem: we are making use of git +[push-options](https://git-scm.com/docs/git-push#Documentation/git-push.txt--ooption). +When you push to a repository with an option like so: + +``` +git push origin master -o verbose-ci +``` + +The server runs a basic set of analysis rules on your +workflow file, and reports any errors: + +``` +λ git push origin main -o verbose-ci + . + . + . + . +remote: error: failed to parse workflow(s): +remote: - at .tangled/workflows/fmt.yml: yaml: line 14: did not find expected key +remote: +remote: warning(s) on pipeline: +remote: - at build.yml: workflow skipped: did not match trigger push +``` + +The analysis performed at the moment is quite basic (expect +it to get better over time), but it is already quite useful +to help debug workflows that don't trigger! + +## pipeline secrets + +Secrets are a bit tricky since atproto has no notion of +private data. Secrets are instead written directly from the +appview to the spindle instance using [service +auth](https://docs.bsky.app/docs/api/com-atproto-server-get-service-auth). +In essence, the appview makes a signed request using the +logged-in user's DID key; spindle verifies this signature by +fetching the public key from the DID document. + +![pipeline secrets](https://assets.tangled.network/blog/pipeline-secrets.png) + +The secrets themselves are stored in a secret manager. By +default, this is the same sqlite database that spindle uses. +This is *fine* for self-hosters. The hosted, flagship +instance at https://spindle.tangled.sh however uses +[OpenBao](https://openbao.org), an OSS fork of HashiCorp +Vault. + +## get started now + +You can run your own spindle instance pretty easily: the +[spindle self-hosting +guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md) +should have you covered. Once done, head to your +repository's settings tab and set it up! Doesn't work? Feel +free to pop into [Discord](https://chat.tangled.sh) to get +help -- we have a nice little crew that's always around to +help. + +All Tangled users have access to our hosted spindle +instance, free of charge[^3]. You don't have any more +excuses to not migrate to Tangled now -- [get +started](https://tangled.sh/login) with your AT Protocol +account today. + +[^3]: We can't promise we won't charge for it at some point + but there will always be a free tier. diff --git a/blog/posts/docs.md b/blog/posts/docs.md new file mode 100644 --- /dev/null +++ b/blog/posts/docs.md @@ -0,0 +1,241 @@ +--- +atroot: true +template: +slug: docs +title: we rolled our own documentation site +subtitle: you don't need mintlify +date: 2026-01-12 +authors: + - name: Akshay + email: akshay@tangled.org + handle: oppi.li +draft: false +--- + +We recently organized our documentation and put it up on +https://docs.tangled.org, using just pandoc. For several +reasons, using pandoc to roll your own static sites is more +than sufficient for small projects. + +![docs.tangled.org](https://assets.tangled.network/blog/docs_homepage.png) + +## requirements + +- Lives in [our + monorepo](https://tangled.org/tangled.org/core). +- No JS: a collection of pages containing just text + should not require JS to view! +- Searchability: in practice, documentation engines that + come bundled with a search-engine have always been lack + lustre. I tend to Ctrl+F or use an actual search engine in + most scenarios. +- Low complexity: building, testing, deploying should be + easy. +- Easy to style + +## evaluating the ecosystem + +I took the time to evaluate several documentation engine +solutions: + +- [Mintlify](https://www.mintlify.com/): It is quite obvious + from their homepage that mintlify is performing an AI + pivot for the sake of doing so. +- [Docusaurus](https://docusaurus.io/): The generated + documentation site is quite nice, but the value of pages + being served as a full-blown React SPA is questionable. +- [MkDocs](https://www.mkdocs.org/): Works great with JS + disabled, however the table of contents needs to be + maintained via `mkdocs.yml`, which can be quite tedious. +- [MdBook](https://rust-lang.github.io/mdBook/index.html): + As above, you need a `SUMMARY.md` file to control the + table-of-contents. + +MkDocs and MdBook are still on my radar however, in case we +need a bigger feature set. + +## using pandoc + +[pandoc](https://pandoc.org/) is a wonderfully customizable +markup converter. It provides a "chunkedhtml" output format, +which is perfect for generating documentation sites. Without +any customization, +[this](https://pandoc.org/demo/example33/) is the generated +output, for this [markdown file +input](https://pandoc.org/demo/MANUAL.txt). + +- You get an autogenerated TOC based on the document layout +- Each section is turned into a page of its own + +Massaging pandoc to work for us was quite straightforward: + +- I first combined all our individual markdown files into + [one big + `DOCS.md`](https://tangled.org/tangled.org/core/blob/master/docs/DOCS.md) + file. +- Modified the [default + template](https://github.com/jgm/pandoc-templates/blob/master/default.chunkedhtml) + to put the TOC on every page, to form a "sidebar", see + [`docs/template.html`](https://tangled.org/tangled.org/core/blob/master/docs/template.html) +- Inserted tailwind `prose` classes where necessary, such + that markdown content is rendered the same way between + `tangled.org` and `docs.tangled.org` + +Generating the docs is done with one pandoc command: + +```bash +pandoc docs/DOCS.md \ + -o out/ \ + -t chunkedhtml \ + --variable toc \ + --toc-depth=2 \ + --css=docs/stylesheet.css \ + --chunk-template="%i.html" \ + --highlight-style=docs/highlight.theme \ + --template=docs/template.html +``` + +## avoiding javascript + +The "sidebar" style table-of-contents needs to be collapsed +on mobile displays. Most of the engines I evaluated seem to +require JS to collapse and expand the sidebar, with MkDocs +being the outlier, it uses a checkbox with the +[`:checked`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:checked) +pseudo-class trick to avoid JS. + +The other ways to do this are: + +- Use ``: this is definitely a + "hack", clicking outside the sidebar does not collapse it. + Using Ctrl+F or "Find in page" still works through the + details tag though. +- Use the new `popover` API: this seems like the perfect fit + for a "sidebar" component. + +The bar at the top includes a button to trigger the popover: + +```html + +``` + +And a `fixed` position div includes the TOC itself: + +```html +
+ +
+``` + +The TOC is scrollable independently and can be collapsed by +clicking anywhere on the screen outside the sidebar. +Searching for content in the page via "Find in page" does +not show any results that are present in the popover +however. The collapsible TOC is only available on smaller +viewports, the TOC is not hidden on larger viewports. + +## search + +There is no native search on the site for now. Taking +inspiration from [https://htmx.org](https://htmx.org)'s search bar, our search +bar also simply redirects to Google: + +```html +
+ + ... +
+``` + +I mentioned earlier that Ctrl+F has typically worked better +for me than, say, the search engine provided by Docusaurus. +To that end, the same docs have been exported to a ["single +page" format](https://docs.tangled.org/single-page.html), by +just removing the `chunkedhtml` related options: + +```diff + pandoc docs/DOCS.md \ + -o out/ \ +- -t chunkedhtml \ + --variable toc \ + --toc-depth=2 \ + --css=docs/stylesheet.css \ +- --chunk-template="%i.html" \ + --highlight-style=docs/highlight.theme \ + --template=docs/template.html +``` + +With all the content on a single page, it is trivial to +search through the entire site with the browser. If the docs +do outgrow this, I will consider other options! + +## building and deploying + +We use [nix](https://nixos.org) and +[colmena](https://colmena.cli.rs/) to build and deploy all +Tangled services. A nix derivation to [build the +documentation](https://tangled.org/tangled.org/core/blob/master/nix/pkgs/docs.nix) +site is written very easily with the `runCommandLocal` +helper: + +```nix +runCommandLocal "docs" {} '' + . + . + . + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md ... + . + . + . +'' +``` + +The NixOS machine is configured to serve the site [via +nginx](https://tangled.org/tangled.org/infra/blob/master/hosts/nixery/services/nginx.nix#L7): + +```nix +services.nginx = { + enable = true; + virtualHosts = { + "docs.tangled.org" = { + root = "${tangled-pkgs.docs}"; + locations."/" = { + tryFiles = "$uri $uri/ =404"; + index = "index.html"; + }; + }; + }; +}; +``` + +And deployed using `colmena`: + +```bash +nix run nixpkgs#colmena -- apply +``` + +To update the site, I first run: + +```bash +nix flake update tangled +``` + +Which bumps the `tangled` flake input, and thus +`tangled-pkgs.docs`. The above `colmena` invocation applies +the changes to the machine serving the site. + +## notes + +Going homegrown has made it a lot easier to style the +documentation site to match the main site. Unfortunately +there are still a few discrepancies between pandoc's +markdown rendering and +[goldmark's](https://pkg.go.dev/github.com/yuin/goldmark/) +markdown rendering (which is what we use in Tangled). We may +yet roll our own SSG, +[TigerStyle](https://tigerbeetle.com/blog/2025-02-27-why-we-designed-tigerbeetles-docs-from-scratch/)! diff --git a/blog/posts/intro.md b/blog/posts/intro.md new file mode 100644 --- /dev/null +++ b/blog/posts/intro.md @@ -0,0 +1,64 @@ +--- +atroot: true +template: +slug: intro +title: introducing tangled +subtitle: a git collaboration platform, built on atproto +date: 2025-03-02 +authors: + - name: Anirudh + email: anirudh@tangled.sh + handle: anirudh.fi +--- + + +[Tangled](https://tangled.sh) is a new social-enabled Git collaboration +platform, built on top of the [AT Protocol](https://atproto.com). We +envision a place where developers have complete ownership of their code, +open source communities can freely self-govern and most importantly, +coding can be social and fun again. + +There are several models for decentralized code collaboration platforms, +ranging from ActivityPub's (Forgejo) federated model, to Radicle's +entirely P2P model. Our approach attempts to be the best of both worlds +by adopting atproto -- a protocol for building decentralized social +applications with a central identity. + +![tangled architecture](https://assets.tangled.network/blog/arch.svg) + +Our approach to this is the idea of "knots". Knots are lightweight, +headless servers that enable users to host Git repositories with ease. +Knots are designed for either single or multi-tenant use which is +perfect for self-hosting on a Raspberry Pi at home, or larger +"community" servers. By default, Tangled provides managed knots where +you can host your repositories for free. + +The [App View][appview] at [tangled.sh](https://tangled.sh) acts as a +consolidated "view" into the whole network, allowing users to access, +clone and contribute to repositories hosted across different knots -- +completely seamlessly. + +Tangled is still in its infancy, and we're building out several of its +core features as we [dogfood it ourselves][dogfood]. We developed these +three tenets to guide our decisions: + +1. Ownership of data +2. Low barrier to entry +3. No compromise on user-experience + +Collaborating on code isn't easy, and the tools and workflows we use +should feel natural and stay out of the way. Tangled's architecture +enables common workflows to work as you'd expect, all while remaining +decentralized. + +We believe that atproto has greatly simplfied one of the hardest parts +of social media: having your friends on it. Today, we're rolling out +invite-only access to Tangled -- join us on IRC at `#tangled` on +[libera.chat](https://libera.chat) and we'll get you set up. + +**Update**: Tangled is open to public, simply login at +[tangled.sh/login](https://tangled.sh/login)! Have fun! + +[pds]: https://atproto.com/guides/glossary#pds-personal-data-server +[appview]: https://docs.bsky.app/docs/advanced-guides/federation-architecture#app-views +[dogfood]: https://tangled.sh/@tangled.sh/core diff --git a/blog/posts/pulls.md b/blog/posts/pulls.md new file mode 100644 --- /dev/null +++ b/blog/posts/pulls.md @@ -0,0 +1,195 @@ +--- +atroot: true +template: +slug: pulls +title: the lifecycle of a pull request +subtitle: we shipped a bunch of PR features recently; here's how we built it +date: 2025-04-16 +image: https://assets.tangled.network/blog/hidden-ref.png +authors: + - name: Anirudh + email: anirudh@tangled.sh + handle: anirudh.fi + - name: Akshay + email: akshay@tangled.sh + handle: oppi.li +draft: false +--- + +We've spent the last couple of weeks building out a pull +request system for Tangled, and today we want to lift the +hood and show you how it works. + +If you're new to Tangled, [read our intro](/intro) for the +full story! + +You have three options to contribute to a repository: + +- Paste a patch on the web UI +- Compare two local branches (you'll see this only if you're a +collaborator on the repo) +- Compare across forks + +Whatever you choose, at the core of every PR is the patch. +First, you write some code. Then, you run `git diff` to +produce a patch and make everyone's lives easier, or push to +a branch, and we generate it ourselves by comparing against +the target. + +## patch generation + +When you create a PR from a branch, we create a "patch" by +calculating the difference between your branch and the +target branch. Consider this scenario: + +
+ +
A is the merge-base for +feature and main.
+
+ +Your `feature` branch has advanced 2 commits since you first +branched out, but in the meanwhile, `main` has also advanced +2 commits. Doing a trivial `git diff feature main` will +produce a confusing patch: + +- the patch will apply the changes from `X` and `Y` +- the patch will **revert** the changes from `B` and `C` + +We obviously do not want the second part! To only show the +changes added by `feature`, we have to identify the +"merge-base": the nearest common ancestor of `feature` and +`main`. + + +In this case, `A` is the nearest common ancestor, and +subsequently, the patch calculated will contain just `X` and +`Y`. + +### ref comparisons across forks + +The plumbing described above is easy to do across two +branches, but what about forks? And what if they live on +different servers altogether (as they can in Tangled!)? + +Here's the concept: since we already have all the necessary +components to compare two local refs, why not simply +"localize" the remote ref? + +In simpler terms, we instruct Git to fetch the target branch +from the original repository and store it in your fork under +a special name. This approach allows us to compare your +changes against the most current version of the branch +you're trying to contribute to, all while remaining within +your fork. + +
+ +
Hidden tracking ref.
+
+ +We call this a "hidden tracking ref." When you create a pull +request from a fork, we establish a refspec that tracks the +remote branch, which we then use to generate a diff. A +refspec is essentially a rule that tells Git how to map +references between a remote and your local repository during +fetch or push operations. + +For example, if your fork has a feature branch called +`feature-1`, and you want to make a pull request to the +`main` branch of the original repository, we fetch the +remote `main` into a local hidden ref using a refspec like +this: + +``` ++refs/heads/main:refs/hidden/feature-1/main +``` + +Since we already have a remote (`origin`, by default) to the +original repository (remember, we cloned it earlier), we can +use `fetch` with this refspec to bring the remote `main` +branch into our local hidden ref. Each pull request gets its +own hidden ref, hence the `refs/hidden/:localRef/:remoteRef` +format. We keep this ref updated whenever you push new +commits to your feature branch, ensuring that comparisons -- +and any potential merge conflicts -- are always based on the +latest state of the target branch. + +And just like earlier, we produce the patch by diffing your +feature branch with the hidden tracking ref. Also, the entire pull +request is stored as [an atproto record][atproto-record] and updated +each time the patch changes. + +[atproto-record]: https://pdsls.dev/at://did:plc:qfpnj4og54vl56wngdriaxug/sh.tangled.repo.pull/3lmwniim2i722 + +Neat, now that we have a patch; we can move on the hard +part: code review. + + +## your patch does the rounds + +Tangled uses a "round-based" review format. Your initial +submission starts "round 0". Once your submission receives +scrutiny, you can address reviews and resubmit your patch. +This resubmission starts "round 1". You keep whittling on +your patch till it is good enough, and eventually merged (or +closed if you are unlucky). + +
+ +
A new pull request with a couple +rounds of reviews.
+
+ +Rounds are a far superior to standard branch-based +approaches: + +- Submissions are immutable: how many times have your + reviews gone out-of-date because the author pushed commits + _during_ your review? +- Reviews are attached to submissions: at a glance, it is + easy to tell which comment applies to which "version" of + the pull-request +- The author can choose when to resubmit! They can commit as + much as they want to their branch, but a new round begins + when they choose to hit "resubmit" +- It is possible to "interdiff" and observe changes made + across submissions (this is coming very soon to Tangled!) + +This [post by Mitchell +Hashimoto](https://mitchellh.com/writing/github-changesets) +goes into further detail on what can be achieved with +round-based reviews. + +## future plans + +To close off this post, we wanted to share some of our +future plans for pull requests: + +* `format-patch` support: both for pasting in the UI and + internally. This allows us to show commits in the PR page, + and offer different merge strategies to choose from + (squash, rebase, ...). + **Update 2025-08-12**: We have format-patch support! + +* Gerrit-style `refs/for/main`: we're still hashing out the + details but being able to push commits to a ref to + "auto-create" a PR would be super handy! + +* Change ID support: This will allow us to group changes + together and track them across multiple commits, and to + provide "history" for each change. This works great with [Jujutsu][jj]. + **Update 2025-08-12**: This has now landed: https://blog.tangled.org/stacking + +Join us on [Discord](https://chat.tangled.sh) or +`#tangled` on libera.chat (the two are bridged, so we will +never miss a message!). We are always available to help +setup knots, listen to feedback on features, or even +shepherd contributions! + +**Update 2025-08-12**: We move fast, and we now have jujutsu support, and an +early in-house CI: https://blog.tangled.org/ci. You no longer need a Bluesky +account to sign-up; head to https://tangled.sh/signup and sign up with your +email! + +[jj]: https://jj-vcs.github.io/jj/latest/ diff --git a/blog/posts/seed.md b/blog/posts/seed.md new file mode 100644 --- /dev/null +++ b/blog/posts/seed.md @@ -0,0 +1,74 @@ +--- +atroot: true +template: +slug: seed +title: announcing our €3,8M seed round +subtitle: and more on what's next +date: 2026-03-02 +image: https://assets.tangled.network/blog/seed.png +authors: + - name: Anirudh + email: anirudh@tangled.org + handle: anirudh.fi +--- + +![seed](https://assets.tangled.network/seed.png) + +Today, we're announcing our €3,8M ($4.5M) financing round led by +[byFounders](https://byfounders.vc), with participation from [Bain +Capital Crypto](https://baincapitalcrypto.com/), +[Antler](https://antler.co), Thomas Dohmke (former GitHub CEO), Avery +Pennarun (CEO of Tailscale), among other incredible angels. + +For the past year, we've been building Tangled from the ground up -- +starting from first principles and asking ourselves what code +collaboration should really look like. We made deliberate, +[future-facing technology](https://anirudh.fi/future) choices. We chose +to build on top of the AT Protocol as it helped us realize a federated, +open network where users can own their code and social data. We shipped +stacked PRs to enable more efficient contribution and review workflows. +What started off as a side project, grew to over 7k+ users, who've +created over 5k+ repositories. + +Our vision for Tangled has always been big: we want to build the best +code forge ever, and become foundational infrastructure for the next +generation of open source. Whatever that looks like: hundreds of devs +building artisanal libraries, or one dev and a hundred agents building a +micro-SaaS. + +And finding the right investors to help us acheive this vision wasn't +something we took lightly. We spent months getting to know potential +partners -- among which, byFounders stood out immediately. Like us, +they're community-driven at their core, and their commitment to +transparency runs deep -- you can see the very term sheet we signed on +their website! With these shared fundamental values, we knew byFounders +were the right people to have in our corner and we're incredibly excited +to work with them. + +## what's next + +We're heads down building. For 2026, expect to see: + +* a fully revamped CI (spindle v2!) built on micro VMs to allow for + faster builds and more choice of build environments. Oh, and a proper + Nix CI -- we know you want it. +* protocol-level improvements across the board that'll unlock nifty + things like repo migrations across knots, organizations, and more! +* a customizable "mission control" dashboard for your active PRs, + issues, and anything else you might want to track. +* a migration tool to help you move off GitHub +* all things search: code search, repo search, etc. +* platform and infrastructure performance improvements & more global +presence +* Tangled CLI! + +If all this sounds exciting to you: we're growing our team! Shoot us +[an email](mailto:team@tangled.org) telling us a bit about yourself and +any past work that might be relevant, and what part of the roadmap +interests you most. We can hire from (almost) anywhere. + +New to Tangled? [Get started here](https://docs.tangled.org/). Oh, and +come hang on [Discord](https://chat.tangled.org)! + +A sincere thank you to everyone that helped us get here -- we're giddy +about what's to come. diff --git a/blog/posts/stacking.md b/blog/posts/stacking.md new file mode 100644 --- /dev/null +++ b/blog/posts/stacking.md @@ -0,0 +1,351 @@ +--- +atroot: true +template: +slug: stacking +title: jujutsu on tangled +subtitle: tangled now supports jujutsu change-ids! +date: 2025-06-02 +image: https://assets.tangled.network/blog/interdiff_difference.jpeg +authors: + - name: Akshay + email: akshay@tangled.sh + handle: oppi.li +draft: false +--- + +Jujutsu is built around structuring your work into +meaningful commits. Naturally, during code-review, you'd +expect reviewers to be able to comment on individual +commits, and also see the evolution of a commit over time, +as reviews are addressed. We set out to natively support +this model of code-review on Tangled. + +Tangled is a new social-enabled Git collaboration platform, +[read our intro](/intro) for more about the project. + +For starters, I would like to contrast the two schools of +code-review, the "diff-soup" model and the interdiff model. + +## the diff-soup model + +When you create a PR on traditional code forges (GitHub +specifically), the UX implicitly encourages you to address +your code review by *adding commits* on top of your original +set of changes: + +- GitHub's "Apply Suggestion" button directly commits the + suggestion into your PR +- GitHub only shows you the diff of all files at once by + default +- It is difficult to know what changed across force pushes + +Consider a hypothetical PR that adds 3 commits: + +``` +[c] implement new feature across the board (HEAD) + | +[b] introduce new feature + | +[a] some small refactor +``` + +And when only newly added commits are easy to review, this +is what ends up happening: + +``` +[f] formatting & linting (HEAD) + | +[e] update name of new feature + | +[d] fix bug in refactor + | +[c] implement new feature across the board + | +[b] introduce new feature + | +[a] some small refactor +``` + +It is impossible to tell what addresses what at a glance, +there is an implicit relation between each change: + +``` +[f] formatting & linting + | +[e] update name of new feature -------------. + | | +[d] fix bug in refactor -----------. | + | | | +[c] implement new feature across the board | + | | | +[b] introduce new feature <-----------------' + | | +[a] some small refactor <----------' +``` + +This has the downside of clobbering the output of `git +blame` (if there is a bug in the new feature, you will first +land on `e`, and upon digging further, you will land on +`b`). This becomes incredibly tricky to navigate if reviews +go on through multiple cycles. + + +## the interdiff model + +With jujutsu however, you have the tools at hand to +fearlessly edit, split, squash and rework old commits (you +can absolutely achieve this with git and interactive +rebasing, but it is certainly not trivial). + +Let's try that again: + +``` +[c] implement new feature across the board (HEAD) + | +[b] introduce new feature + | +[a] some small refactor +``` + +To fix the bug in the refactor: + +``` +$ jj edit a +Working copy (@) now at: [a] some small refactor + +$ # hack hack hack + +$ jj log -r a:: +Rebased 2 descendant commits onto updated working copy +[c] implement new feature across the board (HEAD) + | +[b] introduce new feature + | +[a] some small refactor +``` + +Jujutsu automatically rebases the descendants without having +to lift a finger. Brilliant! You can repeat the same +exercise for all review comments, and effectively, your +PR will have evolved like so: + +``` + a -> b -> c initial attempt + | | | + v v v + a' -> b' -> c' after first cycle of reviews +``` + +## the catch + +If you use `git rebase`, you will know that it modifies +history and therefore changes the commit SHA. How then, +should one tell the difference between the "old" and "new" +state of affairs? + +Tools like `git-range-diff` make use of a variety of +text-based heuristics to roughly match `a` to `a'` and `b` +to `b'` etc. + +Jujutsu however, works around this by assigning stable +"change id"s to each change (which internally point to a git +commit, if you use the git backing). If you edit a commit, +its SHA changes, but its change-id remains the same. + +And this is the essence of our new stacked PRs feature! + +## interdiff code review on tangled + +To really explain how this works, let's start with a [new +codebase](https://tangled.sh/@oppi.li/stacking-demo/): + +``` +$ jj git init --colocate + +# -- initialize codebase -- + +$ jj log +@ n set: introduce Set type main HEAD 1h +``` + +I have kicked things off by creating a new go module that +adds a `HashSet` data structure. My first changeset +introduces some basic set operations: + +``` +$ jj log +@ so set: introduce set difference HEAD +├ sq set: introduce set intersection +├ mk set: introduce set union +├ my set: introduce basic set operations +~ + +$ jj git push -c @ +Changes to push to origin: + Add bookmark push-soqmukrvport to fc06362295bd +``` + +When submitting a pull request, select "Submit as stacked PRs": + +
+ + + +
Submitting Stacked PRs
+
+ +This submits each change as an individual pull request: + +
+ + + +
The "stack" is similar to Gerrit's relation chain
+
+ +After a while, I receive a couple of review comments, not on +my entire submission, but rather, on each *individual +change*. Additionally, the reviewer is happy with my first +change, and has gone ahead and merged that: + +
+
+ + The first change has been merged + +
The first change has been merged
+
+ +
+ + A review on the set union implementation + +
A review on the set union implementation
+
+ +
+ + A review on the set difference implementation + +
A review on the set difference implementation
+
+
+ +Let us address the first review: + +> can you use the new `maps.Copy` api here? + +``` +$ jj log +@ so set: introduce set difference push-soqmukrvport +├ sq set: introduce set intersection +├ mk set: introduce set union +├ my set: introduce basic set operations +~ + +# let's edit the implementation of `Union` +$ jj edit mk + +# hack, hack, hack + +$ jj log +Rebased 2 descendant commits onto updated working copy +├ so set: introduce set difference push-soqmukrvport* +├ sq set: introduce set intersection +@ mk set: introduce set union +├ my set: introduce basic set operations +~ +``` + +Next, let us address the bug: + +> there is a logic bug here, the condition should be negated. + +``` +# let's edit the implementation of `Difference` +$ jj edit so + +# hack, hack, hack +``` + +We are done addressing reviews: +``` +$ jj git push +Changes to push to origin: + Move sideways bookmark push-soqmukrvport from fc06362295bd to dfe2750f6d40 +``` + +Upon resubmitting the PR for review, Tangled is able to +accurately trace the commit across rewrites, using jujutsu +change-ids, and map it to the corresponding PR: + +
+
+ + PR #2 advances to the next round + +
PR #2 advances to the next round
+
+ +
+ + PR #4 advances to the next round + +
PR #4 advances to the next round
+
+
+ +Of note here are a few things: + +- The initial submission is still visible under `round #0` +- By resubmitting, the round has simply advanced to `round + #1` +- There is a helpful "interdiff" button to look at the + difference between the two submissions + +The individual diffs are still available, but most +importantly, the reviewer can view the *evolution* of a +change by hitting the interdiff button: + +
+
+ + Diff from round #0 + +
Diff from round #0
+
+ +
+ + Diff from round #1 + +
Diff from round #1
+
+
+ +
+ + Interdiff between round #0 and #1 + +
Interdiff between round #1 and #0
+
+ +Indeed, the logic bug has been addressed! + +## start stacking today + +If you are a jujutsu user, you can enable this flag on more +recent versions of jujutsu: + +``` +λ jj --version +jj 0.29.0-8c7ca30074767257d75e3842581b61e764d022cf + +# -- in your config.toml file -- +[git] +write-change-id-header = true +``` + +This feature writes `change-id` headers directly into the +git commit object, and is visible to code forges upon push, +and allows you to stack your PRs on Tangled. diff --git a/blog/templates/index.html b/blog/templates/index.html new file mode 100644 --- /dev/null +++ b/blog/templates/index.html @@ -0,0 +1,76 @@ +{{ define "title" }}the tangled blog{{ end }} + +{{ define "extrameta" }} + + +{{ end }} + + +{{ define "bodyClasses" }}!bg-white dark:!bg-gray-900{{ end }} + +{{ define "topbarLayout" }} +
+ {{ template "layouts/fragments/topbar" . }} +
+{{ end }} + +{{ define "content" }} +
+ +
+

the tangled blog

+

all the ropes and scaffolding

+
+ + {{ if .Featured }} + + {{ end }} + + + +
+{{ end }} + +{{ define "footerLayout" }}{{ template "blog/fragments/footer" . }}{{ end }} diff --git a/blog/templates/post.html b/blog/templates/post.html new file mode 100644 --- /dev/null +++ b/blog/templates/post.html @@ -0,0 +1,68 @@ +{{ define "title" }}{{ .Post.Meta.Title }} — tangled blog{{ end }} + +{{ define "extrameta" }} + + + + +{{ if .Post.Meta.Image }} + + + + + +{{ end }} + + + +{{ end }} + +{{ define "bodyClasses" }}!bg-white dark:!bg-gray-900{{ end }} + +{{ define "topbarLayout" }} +
+ {{ template "layouts/fragments/topbar" . }} +
+{{ end }} + +{{ define "footerLayout" }}{{ template "blog/fragments/footer" . }}{{ end }} + +{{ define "content" }} +
+
+ +
+ {{ $authors := .Post.Meta.Authors }} +

+ {{ $date := .Post.ParsedDate }} + {{ $date.Format "02 Jan, 2006" }} +

+ +

+ {{ .Post.Meta.Title }} + {{ if .Post.Meta.Draft }}[draft]{{ end }} +

+

{{ .Post.Meta.Subtitle }}

+ +
+
+ {{ range $authors }} + {{ .Handle }} + {{ end }} +
+
+ {{ range $i, $a := $authors }} + {{ if gt $i 0 }}&{{ end }} + {{ $a.Handle }} + {{ end }} +
+
+
+ +
+ {{ .Post.Body }} +
+ +
+
+{{ end }} diff --git a/blog/templates/text.html b/blog/templates/text.html new file mode 100644 --- /dev/null +++ b/blog/templates/text.html @@ -0,0 +1,91 @@ +{{ define "fragments/logotypeSmall" }} + + {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} + tangled + alpha + +{{ end }} + + + + + + + + + + + + + + + + + + + + + + + + + + {{ index .Meta "title" }} + + + +
+ +
+ +
+
+ +
+

+ {{ $dateStr := index .Meta "date" }} + {{ $date := parsedate $dateStr }} + {{ $date.Format "02 Jan, 2006" }} + + · + + by + {{ $authors := index .Meta "authors" }} + {{ if eq (len $authors) 2 }} + {{ (index $authors 0).name }} + & + {{ (index $authors 1).name }} + {{ else }} + {{ range $authors }} + {{ .name }} + {{ end }} + {{ end }} +

+ + {{ if index .Meta "draft" }} +

{{ index .Meta "title" }} [draft]

+ {{ else }} +

{{ index .Meta "title" }}

+ {{ end }} +

{{ index .Meta "subtitle" }}

+
+ +
+ {{ .Body }} +
+ +
+
+ + + + + diff --git a/cmd/blog/main.go b/cmd/blog/main.go new file mode 100644 --- /dev/null +++ b/cmd/blog/main.go @@ -0,0 +1,193 @@ +package main + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + + "tangled.org/core/appview/config" + "tangled.org/core/appview/pages" + "tangled.org/core/blog" + "tangled.org/core/idresolver" + tlog "tangled.org/core/log" +) + +const ( + postsDir = "blog/posts" + templatesDir = "blog/templates" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: blog [flags]") + os.Exit(1) + } + + ctx := context.Background() + logger := tlog.New("blog") + + switch os.Args[1] { + case "build": + if err := runBuild(ctx, logger); err != nil { + logger.Error("build failed", "err", err) + os.Exit(1) + } + case "serve": + addr := "0.0.0.0:3001" + if len(os.Args) >= 3 { + addr = os.Args[2] + } + if err := runServe(ctx, logger, addr); err != nil { + logger.Error("serve failed", "err", err) + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", os.Args[1]) + os.Exit(1) + } +} + +func makePages(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*pages.Pages, error) { + resolver := idresolver.DefaultResolver(cfg.Plc.PLCURL) + return pages.NewPages(cfg, resolver, nil, logger), nil +} + +func runBuild(ctx context.Context, logger *slog.Logger) error { + cfg, err := config.LoadConfig(ctx) + if err != nil { + cfg = &config.Config{} + } + + p, err := makePages(ctx, cfg, logger) + if err != nil { + return fmt.Errorf("creating pages: %w", err) + } + + posts, err := blog.Posts(postsDir) + if err != nil { + return fmt.Errorf("parsing posts: %w", err) + } + + outDir := "build" + if err := os.MkdirAll(outDir, 0755); err != nil { + return err + } + + // index + if err := renderToFile(outDir, "index.html", func(w io.Writer) error { + return blog.RenderIndex(p, templatesDir, posts, w) + }); err != nil { + return fmt.Errorf("rendering index: %w", err) + } + + // posts — each at build//index.html directly (no /blog/ prefix) + for _, post := range posts { + post := post + postDir := filepath.Join(outDir, post.Meta.Slug) + if err := os.MkdirAll(postDir, 0755); err != nil { + return err + } + if err := renderToFile(postDir, "index.html", func(w io.Writer) error { + return blog.RenderPost(p, templatesDir, post, w) + }); err != nil { + return fmt.Errorf("rendering post %s: %w", post.Meta.Slug, err) + } + } + + // atom feed — at build/feed.xml + baseURL := "https://blog.tangled.org" + atom, err := blog.AtomFeed(posts, baseURL) + if err != nil { + return fmt.Errorf("generating atom feed: %w", err) + } + if err := os.WriteFile(filepath.Join(outDir, "feed.xml"), []byte(atom), 0644); err != nil { + return fmt.Errorf("writing feed: %w", err) + } + + logger.Info("build complete", "dir", outDir) + return nil +} + +func runServe(ctx context.Context, logger *slog.Logger, addr string) error { + cfg, err := config.LoadConfig(ctx) + if err != nil { + cfg = &config.Config{} + } + + p, err := makePages(ctx, cfg, logger) + if err != nil { + return fmt.Errorf("creating pages: %w", err) + } + + mux := http.NewServeMux() + + // index + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + posts, err := blog.AllPosts(postsDir) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := blog.RenderIndex(p, templatesDir, posts, w); err != nil { + logger.Error("render index", "err", err) + } + }) + + // individual posts directly at / + mux.HandleFunc("GET /{slug}", func(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + posts, err := blog.AllPosts(postsDir) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + for _, post := range posts { + if post.Meta.Slug == slug { + if err := blog.RenderPost(p, templatesDir, post, w); err != nil { + logger.Error("render post", "err", err) + } + return + } + } + http.NotFound(w, r) + }) + + // atom feed at /feed.xml + mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { + posts, err := blog.Posts(postsDir) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + atom, err := blog.AtomFeed(posts, "https://blog.tangled.org") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/atom+xml") + fmt.Fprint(w, atom) + }) + + // appview static files (tw.css, fonts, icons, logos) + mux.Handle("GET /static/", p.Static()) + + logger.Info("serving", "addr", addr) + return http.ListenAndServe(addr, mux) +} + +func renderToFile(dir, name string, fn func(io.Writer) error) error { + f, err := os.Create(filepath.Join(dir, name)) + if err != nil { + return err + } + defer f.Close() + return fn(f) +} diff --git a/blog/templates/fragments/footer.html b/blog/templates/fragments/footer.html new file mode 100644 --- /dev/null +++ b/blog/templates/fragments/footer.html @@ -0,0 +1,17 @@ +{{ define "blog/fragments/footer" }} + +{{ end }}