Skip to content

Commit 38dbf15

Browse files
Merge pull request #32 from OpenElementsLabs/feat/016-git-markdown-source
Git Markdown source — type: git + GitSourceStrategy
2 parents f652113 + 5cc44a8 commit 38dbf15

17 files changed

Lines changed: 556 additions & 13 deletions

CLAUDE.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ search enhancements.
6262
│ ├── ContentSearchService.java # read facade: multiSearch + Highlighter (+ result records)
6363
│ ├── ContentMcpToolProvider.java # McpToolProvider: the 4 tools on /mcp
6464
│ ├── RobotsPolicy.java / HttpRobotsPolicy.java # robots.txt allow/disallow + Crawl-delay
65+
│ └── GitSourceStrategy.java / GitConfig.java # type: git — GitHub Markdown source (spec 016)
6566
├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config
6667
├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup)
6768
└── docs/
@@ -106,8 +107,12 @@ categories/preview image/locale) from OpenGraph/Article `<meta>`, JSON-LD, and `
106107
`ContentSourceStrategy` is the per-source-type seam that composes these stages: `WebsiteSourceStrategy`
107108
wires crawler → fetcher → extractor and maps the fetch result to a `FetchOutcome`
108109
(`INDEX`/`UNCHANGED`/`DELETE`/`SKIP`); `SourceStrategyRegistry` selects the strategy by
109-
`ContentSource.type()`, so a future `git` strategy (spec 016) plugs in as a bean with no indexer
110-
changes. `ContentIndexer` is the orchestration engine: discover → diff discovered `lastmod` against
110+
`ContentSource.type()`, so the `git` strategy (spec 016) plugs in as a bean with no indexer
111+
changes. `GitSourceStrategy` (`SourceType.GIT`, `GitConfig`) indexes Markdown from GitHub repos:
112+
Trees-API discovery filtered by `paths` globs (blob SHA = change marker), raw-content fetch with a
113+
server-side bearer token, YAML-frontmatter + Markdown extraction (Hugo shortcodes cleaned), path →
114+
canonical-URL mapping, and locale from the filename suffix — producing the same `ContentDocument` as
115+
websites, so downstream is unchanged. `ContentIndexer` is the orchestration engine: discover → diff discovered `lastmod` against
111116
the state read from the index (`ContentIndexStore`; the index *is* the state) → fetch only new/changed
112117
items via the strategy → batch-upsert, and delete documents that 404 or vanished from discovery.
113118
`MeilisearchContentIndexStore` implements the store over `MeilisearchClient` (paged `multiSearch` to
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Implementation Steps: Git Markdown Source
2+
3+
## Step 1: Config model
4+
5+
- [x] `GitConfig` record (`provider`, `repo`, `ref`, `paths`, `token`) with `hasToken()`
6+
- [x] Add optional `git` field to `ContentSource` (present only for `type: git`); existing call sites pass `null`
7+
8+
**Related behaviors:** private-repo token; config binding
9+
10+
---
11+
12+
## Step 2: `GitSourceStrategy`
13+
14+
- [x] `@Component implements ContentSourceStrategy` for `SourceType.GIT` (auto-routed by `SourceStrategyRegistry`)
15+
- [x] `discover`: GitHub Trees API (`/repos/{repo}/git/trees/{ref}?recursive=1`), keep blobs matching `paths` globs (`AntPathMatcher`), SHA as change marker; a hard failure (auth) throws so the source is isolated (not mass-deleted)
16+
- [x] `fetch`: raw content (`raw.githubusercontent.com`), split YAML frontmatter from Markdown body, clean Hugo shortcodes, map path → canonical URL, derive locale from filename suffix, build `ContentDocument` (SHA = `lastmod`)
17+
- [x] Secrets: bearer token applied server-side only; never logged; empty-default env placeholder
18+
19+
**Related behaviors:** discovery/filter; incremental SHA; frontmatter/shortcode/URL/locale extraction; token handling
20+
21+
---
22+
23+
## Step 3: Config + tests
24+
25+
- [x] `application.yaml`: example `oe-website-markdown` git source (disabled by default; token via `GITHUB_TOKEN_OE_WEBSITE`, empty default)
26+
- [x] `GitSourceStrategyTest` (MockRestServiceServer): discovery+filter+SHA, bearer token, discovery-failure, fetch extraction (frontmatter/shortcode/URL/locale), fetch-failure skip, and pure `mapToUrl`/`localeFromPath`/`parseMarkdown`
27+
- [x] `ConfiguredSourcesTest`: the git source binds as `SourceType.GIT`
28+
29+
**Acceptance criteria:**
30+
- [x] All tests pass (`mvn test`); build green
31+
32+
---
33+
34+
## Behavior Coverage
35+
36+
| Scenario | Layer | Covered in Step |
37+
|----------|-------|-----------------|
38+
| Files discovered via the trees API and filtered by paths | Backend | Step 3 (`discoveryFiltersByPaths`) |
39+
| Non-matching files excluded | Backend | Step 3 (`discoveryFiltersByPaths` — README/png excluded) |
40+
| Unchanged file (same SHA) is skipped | Backend | SHA returned as `lastmod` (`discoveryFiltersByPaths`); the unchanged/changed diff is `ContentIndexer`'s (spec 008 tests) |
41+
| Changed file (new SHA) is re-indexed | Backend | As above — diff handled by `ContentIndexer`; strategy supplies the SHA |
42+
| Frontmatter becomes metadata | Backend | Step 3 (`fetchExtractsFrontmatterAndBody`) |
43+
| Hugo shortcodes are cleaned | Backend | Step 3 (`fetchExtractsFrontmatterAndBody` — no `{{<`) |
44+
| Path maps to canonical URL | Backend | Step 3 (`datedFilenameMapsToCanonicalUrl`, `fetchExtractsFrontmatterAndBody`) |
45+
| Locale from filename suffix | Backend | Step 3 (`localeFromFilenameSuffix`) |
46+
| Private repo accessed with token | Backend | Step 3 (`tokenIsSentForPrivateRepo`) |
47+
| Token is never logged or served | Backend | Design: token applied only as a request header, never logged (auth logs use `repo`, not token); MCP serves only indexed docs |
48+
| Missing token for private repo fails clearly | Backend | Step 3 (`missingTokenFailsClearly`) |
49+
| Git-sourced docs behave like website docs | Backend | Downstream unchanged — same `ContentDocument`/indexer/tools (specs 008/011/012) |
50+
51+
All scenarios are backend; there is no frontend in this spec.

docs/specs/INDEX.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@ roadmap step, to be implemented sequentially (each builds on the previous).
2121
| 013 | 013-scoped-search-key | Scoped search key | backend, search, security | Read-only scoped Meilisearch key for the content index | #25 | done |
2222
| 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics | #27 | done |
2323
| 015 | 015-additional-web-sources | Additional web sources | backend, configuration | hiero.org/blog + Support & Care as config-only sources | #29 | done |
24-
| 016 | 016-git-markdown-source | Git markdown source | backend, architecture, security | `type: git` source + `GitSourceStrategy` (GitHub Markdown) | | open |
24+
| 016 | 016-git-markdown-source | Git markdown source | backend, architecture, security | `type: git` source + `GitSourceStrategy` (GitHub Markdown) | #31 | done |
2525
| 017 | 017-search-enhancements | Search enhancements | backend, search | Facets, synonyms/stop words, optional semantic search || open |

src/main/java/com/openelements/content/ContentSource.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
* @param contentSelector CSS selector for the main content element (applied in spec 006)
2525
* @param contentExclude CSS selectors removed before text extraction (applied in spec 006)
2626
* @param enabled whether the source is active; consumers in later specs skip disabled sources
27+
* @param git Git configuration; present only for {@link SourceType#GIT} sources (spec 016)
2728
*/
2829
public record ContentSource(
2930
String id,
@@ -34,7 +35,8 @@ public record ContentSource(
3435
List<String> urlExclude,
3536
String contentSelector,
3637
List<String> contentExclude,
37-
boolean enabled
38+
boolean enabled,
39+
GitConfig git
3840
) {
3941

4042
/** The include pattern applied when a source declares no {@code urlInclude}: match everything. */
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.openelements.content;
2+
3+
import java.util.List;
4+
5+
/**
6+
* Configuration for a {@link SourceType#GIT} source, bound from the {@code git:} sub-object of a
7+
* content source. Present only when {@code type: git}.
8+
*
9+
* @param provider the git provider ({@code github}); only GitHub is supported for now
10+
* @param repo the repository in {@code owner/name} form
11+
* @param ref the branch, tag, or commit to read (e.g. {@code main})
12+
* @param paths Ant-glob patterns selecting which files to index (e.g. {@code content/posts/**​/*.md})
13+
* @param token an access token for private repos; supplied via the environment, never in plaintext YAML
14+
*/
15+
public record GitConfig(
16+
String provider,
17+
String repo,
18+
String ref,
19+
List<String> paths,
20+
String token
21+
) {
22+
23+
public GitConfig {
24+
paths = paths == null ? List.of() : List.copyOf(paths);
25+
}
26+
27+
/** @return {@code true} if an access token is configured (private repo) */
28+
public boolean hasToken() {
29+
return token != null && !token.isBlank();
30+
}
31+
}

0 commit comments

Comments
 (0)