Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
617db2d
feat: add placeholders for CI and release workflows
c-vigo Feb 18, 2026
3398e56
docs: update CHANGELOG
c-vigo Feb 18, 2026
704c5c9
chore: merge pull request #7 from vig-os/chore/6-create-ci-placeholders
c-vigo Feb 18, 2026
9889abe
chore: sync issues and PRs
commit-action-bot[bot] Feb 20, 2026
ffa21a6
chore: sync issues and PRs
commit-action-bot[bot] Feb 20, 2026
81771e5
chore: sync issues and PRs
commit-action-bot[bot] Feb 20, 2026
7ae203a
chore: sync issues and PRs
commit-action-bot[bot] Feb 20, 2026
9a60cc1
chore: sync issues and PRs
commit-action-bot[bot] Feb 21, 2026
a4d54ae
chore: sync issues and PRs
commit-action-bot[bot] Feb 22, 2026
a42f775
chore: sync issues and PRs
commit-action-bot[bot] Feb 23, 2026
26b22fb
chore: sync issues and PRs
commit-action-bot[bot] Feb 23, 2026
5e28af1
chore: sync issues and PRs
commit-action-bot[bot] Feb 23, 2026
970929d
chore: sync issues and PRs
commit-action-bot[bot] Feb 23, 2026
8d9b92c
chore: sync issues and PRs
commit-action-bot[bot] Feb 23, 2026
2e7d717
chore: sync issues and PRs
commit-action-bot[bot] Feb 23, 2026
479ccec
chore: sync issues and PRs
commit-action-bot[bot] Feb 23, 2026
726e953
chore: prepare release 0.2.0
vig-os-release-app[bot] Feb 23, 2026
3eb6acd
chore: merge main into release/0.2.0
c-vigo Feb 23, 2026
2a542e8
chore: sync main into release/0.2.0
c-vigo Feb 23, 2026
56da644
Merge branch 'main' into chore/22-sync-main
c-vigo Feb 23, 2026
db0eab9
chore: sync main into release/0.2.0
c-vigo Feb 23, 2026
750711c
fix: align syncSubIssues default with action.yml
c-vigo Feb 23, 2026
12bb18e
fix: return partial results from fetchIssueRelationships on batch fai…
c-vigo Feb 23, 2026
0d7b5e5
chore: run full `npm run prepare` pipeline
c-vigo Feb 23, 2026
040621b
docs: update build command in README to use `npm run prepare`
c-vigo Feb 23, 2026
f8bdb5a
chore: run full `npm run prepare` pipeline
c-vigo Feb 23, 2026
70d9d9d
fix: align syncSubIssues default with action.yml
c-vigo Feb 23, 2026
c0e5aa8
fix: return partial results from `fetchIssueRelationships` on batch f…
c-vigo Feb 23, 2026
5a0fc42
chore(ci): clarify weekly CodeQL scheduled scan purpose
c-vigo Feb 25, 2026
e203edd
chore(ci): clarify weekly CodeQL scheduled scan purpose
c-vigo Feb 25, 2026
2f0beac
fix: allow required read permissions for release integration workflow
c-vigo Feb 25, 2026
c4cf762
fix: allow reusable workflow permissions in release integration test
c-vigo Feb 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# Triggers:
# - Pull requests to dev, release/**, and main
# - Pushes to main (post-merge analysis)
# - Weekly schedule (catch newly disclosed patterns)
# - Weekly schedule (re-run with updated CodeQL rules/engines even when repo code is unchanged)

name: CodeQL

Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
# Trigger:
# - Manual workflow_dispatch with version input
#

name: Release

on: # yamllint disable-line rule:truthy
Expand Down Expand Up @@ -300,6 +301,10 @@ jobs:
name: Integration Test (Finalized)
needs: [validate, finalize]
uses: ./.github/workflows/integration-test.yml
permissions:
contents: read
issues: read
pull-requests: read
with:
ref: ${{ needs.finalize.outputs.finalize_sha }}

Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Security Scan Workflow
#
# This workflow placeholder is registered with GitHub to enable security scanning checks.
# It scans for vulnerabilities in dependencies, container images, and code.
# Runs on pull requests and pushes to dev and main branches as a security gate.
# Full implementation details are managed separately.

name: Security Scan

"on":
pull_request: # TODO: consider restricting to protected branches (dev, main, release/**) when implementing
push:
branches:
- dev
- main

permissions:
contents: read

jobs:
security-scan:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased
## [0.2.0] - TBD

### Added

Expand All @@ -23,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CHANGELOG management CLI (`prepare_changelog.py`) for automated release note preparation
- Dependabot configuration for automated dependency updates
- CODEOWNERS file for automated review assignment
- CodeQL analysis workflow for automated security vulnerability scanning
- Scorecard workflow for ongoing supply-chain security assessments
- Security scan workflow for continuous security monitoring

### Changed

Expand All @@ -48,7 +51,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CodeQL scans JavaScript/TypeScript on push and PR
- Scorecard publishes results to the Security tab via SARIF


## [0.1.1](https://github.com/vig-os/sync-issues-action/releases/tag/v0.1.1) - 2025-12-19

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ You can also run the following specific tests:
## Development

1. Make changes to `src/index.ts`
2. Build: `npm run build && npm run package`
2. Build: `npm run prepare` (runs `tsc` then `ncc build` to update all of `dist/`)
3. Run tests: `npm test`
4. Test locally with `local-action`

Expand Down
49 changes: 24 additions & 25 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29961,6 +29961,7 @@ var __importStar = (this && this.__importStar) || (function () {
};
})();
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.GRAPHQL_BATCH_SIZE = void 0;
exports.fetchIssueRelationships = fetchIssueRelationships;
exports.formatIssueAsMarkdown = formatIssueAsMarkdown;
exports.formatPRAsMarkdown = formatPRAsMarkdown;
Expand Down Expand Up @@ -30069,7 +30070,7 @@ async function run() {
}
}
}
async function syncIssuesToMarkdown(octokit, owner, repo, outputDir, includeClosed, updatedSince, forceUpdate = false, syncSubIssues = false) {
async function syncIssuesToMarkdown(octokit, owner, repo, outputDir, includeClosed, updatedSince, forceUpdate = false, syncSubIssues = true) {
const state = includeClosed ? 'all' : 'open';
let page = 1;
const perPage = 100;
Expand Down Expand Up @@ -30206,26 +30207,26 @@ async function fetchComments(octokit, owner, repo, issueNumber) {
}
return comments;
}
const GRAPHQL_BATCH_SIZE = 50;
exports.GRAPHQL_BATCH_SIZE = 50;
async function fetchIssueRelationships(octokit, owner, repo, issueNumbers) {
const relationships = new Map();
if (issueNumbers.length === 0) {
return relationships;
}
try {
for (let i = 0; i < issueNumbers.length; i += GRAPHQL_BATCH_SIZE) {
const batch = issueNumbers.slice(i, i + GRAPHQL_BATCH_SIZE);
const issueFields = batch
.map((num) => `issue_${num}: issue(number: ${num}) {
parent { number }
subIssues(first: 100) { nodes { number } }
}`)
.join('\n');
const query = `query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
${issueFields}
}
}`;
for (let i = 0; i < issueNumbers.length; i += exports.GRAPHQL_BATCH_SIZE) {
const batch = issueNumbers.slice(i, i + exports.GRAPHQL_BATCH_SIZE);
const issueFields = batch
.map((num) => `issue_${num}: issue(number: ${num}) {
parent { number }
subIssues(first: 100) { nodes { number } }
}`)
.join('\n');
const query = `query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
${issueFields}
}
}`;
try {
const response = await octokit.graphql(query, {
owner,
repo,
Expand All @@ -30240,16 +30241,14 @@ async function fetchIssueRelationships(octokit, owner, repo, issueNumbers) {
}
}
}
}
catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
if (message.includes("doesn't exist on type")) {
core.info('Sub-issues API is not available for this repository. Skipping relationship sync.');
}
else {
core.warning(`Failed to fetch sub-issue relationships: ${message}`);
catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
if (message.includes("doesn't exist on type")) {
core.info('Sub-issues API is not available for this repository. Skipping relationship sync.');
break;
}
core.warning(`Failed to fetch sub-issue relationships (batch ${Math.floor(i / exports.GRAPHQL_BATCH_SIZE) + 1}): ${message}`);
}
return new Map();
}
return relationships;
}
Expand Down
46 changes: 45 additions & 1 deletion dist/src/__tests__/unit/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -955,7 +955,7 @@ describe('Sync Issues Action', () => {
expect(result.size).toBe(0);
expect(mockOctokit.graphql).not.toHaveBeenCalled();
});
it('should return empty map and warn on GraphQL error', async () => {
it('should warn on GraphQL error and return empty results for that batch', async () => {
mockOctokit.graphql.mockRejectedValueOnce(new Error('GraphQL rate limit'));
const result = await (0, index_1.fetchIssueRelationships)(mockOctokit, 'owner', 'repo', [1, 2]);
expect(result.size).toBe(0);
Expand All @@ -975,6 +975,50 @@ describe('Sync Issues Action', () => {
expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('Server error'));
expect(core.info).not.toHaveBeenCalled();
});
it('should return partial results when a later batch fails', async () => {
const batch1Issues = Array.from({ length: index_1.GRAPHQL_BATCH_SIZE }, (_, i) => i + 1);
const batch2Issues = [index_1.GRAPHQL_BATCH_SIZE + 1, index_1.GRAPHQL_BATCH_SIZE + 2];
const allIssues = [...batch1Issues, ...batch2Issues];
const batch1Response = {};
for (const num of batch1Issues) {
batch1Response[`issue_${num}`] = {
parent: null,
subIssues: { nodes: [] },
};
}
mockOctokit.graphql
.mockResolvedValueOnce({ repository: batch1Response })
.mockRejectedValueOnce(new Error('Transient network error'));
const result = await (0, index_1.fetchIssueRelationships)(mockOctokit, 'owner', 'repo', allIssues);
expect(result.size).toBe(index_1.GRAPHQL_BATCH_SIZE);
for (const num of batch1Issues) {
expect(result.get(num)).toEqual({ parent: null, children: [] });
}
expect(result.has(index_1.GRAPHQL_BATCH_SIZE + 1)).toBe(false);
expect(result.has(index_1.GRAPHQL_BATCH_SIZE + 2)).toBe(false);
expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('Transient network error'));
});
it('should break and return partial results on schema error in later batch', async () => {
const batch1Issues = Array.from({ length: index_1.GRAPHQL_BATCH_SIZE }, (_, i) => i + 1);
const batch2Issues = [index_1.GRAPHQL_BATCH_SIZE + 1];
const allIssues = [...batch1Issues, ...batch2Issues];
const batch1Response = {};
for (const num of batch1Issues) {
batch1Response[`issue_${num}`] = {
parent: { number: 999 },
subIssues: { nodes: [] },
};
}
mockOctokit.graphql
.mockResolvedValueOnce({ repository: batch1Response })
.mockRejectedValueOnce(new Error("Field 'parent' doesn't exist on type 'Issue'"));
const result = await (0, index_1.fetchIssueRelationships)(mockOctokit, 'owner', 'repo', allIssues);
expect(result.size).toBe(index_1.GRAPHQL_BATCH_SIZE);
expect(result.get(1)).toEqual({ parent: 999, children: [] });
expect(result.has(index_1.GRAPHQL_BATCH_SIZE + 1)).toBe(false);
expect(core.info).toHaveBeenCalledWith('Sub-issues API is not available for this repository. Skipping relationship sync.');
expect(core.warning).not.toHaveBeenCalled();
});
});
describe('shiftHeadersToMinLevel', () => {
it('should return empty/falsy content unchanged', () => {
Expand Down
1 change: 1 addition & 0 deletions dist/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ interface IssueRelationship {
children: number[];
}
declare function run(): Promise<void>;
export declare const GRAPHQL_BATCH_SIZE = 50;
export declare function fetchIssueRelationships(octokit: ReturnType<typeof github.getOctokit>, owner: string, repo: string, issueNumbers: number[]): Promise<Map<number, IssueRelationship>>;
export declare function formatIssueAsMarkdown(issue: Issue, comments?: Comment[], relationship?: IssueRelationship): string;
export declare function formatPRAsMarkdown(pr: PullRequest, comments?: Comment[], reviewComments?: ReviewComment[], commits?: Array<{
Expand Down
49 changes: 24 additions & 25 deletions dist/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.GRAPHQL_BATCH_SIZE = void 0;
exports.fetchIssueRelationships = fetchIssueRelationships;
exports.formatIssueAsMarkdown = formatIssueAsMarkdown;
exports.formatPRAsMarkdown = formatPRAsMarkdown;
Expand Down Expand Up @@ -141,7 +142,7 @@ async function run() {
}
}
}
async function syncIssuesToMarkdown(octokit, owner, repo, outputDir, includeClosed, updatedSince, forceUpdate = false, syncSubIssues = false) {
async function syncIssuesToMarkdown(octokit, owner, repo, outputDir, includeClosed, updatedSince, forceUpdate = false, syncSubIssues = true) {
const state = includeClosed ? 'all' : 'open';
let page = 1;
const perPage = 100;
Expand Down Expand Up @@ -278,26 +279,26 @@ async function fetchComments(octokit, owner, repo, issueNumber) {
}
return comments;
}
const GRAPHQL_BATCH_SIZE = 50;
exports.GRAPHQL_BATCH_SIZE = 50;
async function fetchIssueRelationships(octokit, owner, repo, issueNumbers) {
const relationships = new Map();
if (issueNumbers.length === 0) {
return relationships;
}
try {
for (let i = 0; i < issueNumbers.length; i += GRAPHQL_BATCH_SIZE) {
const batch = issueNumbers.slice(i, i + GRAPHQL_BATCH_SIZE);
const issueFields = batch
.map((num) => `issue_${num}: issue(number: ${num}) {
parent { number }
subIssues(first: 100) { nodes { number } }
}`)
.join('\n');
const query = `query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
${issueFields}
}
}`;
for (let i = 0; i < issueNumbers.length; i += exports.GRAPHQL_BATCH_SIZE) {
const batch = issueNumbers.slice(i, i + exports.GRAPHQL_BATCH_SIZE);
const issueFields = batch
.map((num) => `issue_${num}: issue(number: ${num}) {
parent { number }
subIssues(first: 100) { nodes { number } }
}`)
.join('\n');
const query = `query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
${issueFields}
}
}`;
try {
const response = await octokit.graphql(query, {
owner,
repo,
Expand All @@ -312,16 +313,14 @@ async function fetchIssueRelationships(octokit, owner, repo, issueNumbers) {
}
}
}
}
catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
if (message.includes("doesn't exist on type")) {
core.info('Sub-issues API is not available for this repository. Skipping relationship sync.');
}
else {
core.warning(`Failed to fetch sub-issue relationships: ${message}`);
catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
if (message.includes("doesn't exist on type")) {
core.info('Sub-issues API is not available for this repository. Skipping relationship sync.');
break;
}
core.warning(`Failed to fetch sub-issue relationships (batch ${Math.floor(i / exports.GRAPHQL_BATCH_SIZE) + 1}): ${message}`);
}
return new Map();
}
return relationships;
}
Expand Down
39 changes: 35 additions & 4 deletions docs/issues/issue-10.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
---
type: issue
state: open
state: closed
created: 2026-02-20T10:58:09Z
updated: 2026-02-20T10:58:09Z
updated: 2026-02-20T14:13:37Z
author: c-vigo
author_url: https://github.com/c-vigo
url: https://github.com/vig-os/sync-issues-action/issues/10
comments: 0
comments: 1
labels: none
assignees: none
milestone: none
projects: none
relationship: none
synced: 2026-02-20T12:25:11.501Z
synced: 2026-02-20T14:13:51.874Z
---

# [Issue 10]: [[BUG] --force-update does not re-sync issues (only PRs)](https://github.com/vig-os/sync-issues-action/issues/10)
Expand Down Expand Up @@ -57,3 +57,34 @@ Investigate why the `updated-since` parameter is not honored for issue fetching.
## Changelog Category

Fixed
---

# [Comment #1]() by [c-vigo]()

_Posted on February 20, 2026 at 12:43 PM_

## Implementation Plan

Issue: #10
Branch: bugfix/10-force-update-issues

### Root Cause

Both `syncIssuesToMarkdown` (line 250) and `syncPRsToMarkdown` (line 331) in `src/index.ts` call `hasContentChanged` before writing. This function strips frontmatter (including the `synced:` timestamp) via `normalizeContent` and compares the body only. When nothing has changed on GitHub, the body is identical and the write is skipped -- even during a force-update.

The user observes PRs being re-written because closed PRs gain a new commits section (or other metadata shifts), while issues with no GitHub-side changes remain byte-identical and are skipped.

The action currently has no way to know the caller intends a force-update; `updated-since` set to epoch controls *which items are fetched* from the API, but not whether `hasContentChanged` is bypassed.

### Fix

Add a `force-update` boolean input to the action. When active, skip the `hasContentChanged` gate and always write (which updates the `synced:` frontmatter timestamp, producing a real git diff).

### Tasks

- [x] Task 1: Write failing test -- when `force-update` is `'true'` and an issue file already exists with identical body content, the action should still re-write the file — `src/__tests__/unit/index.test.ts` — verify: `npx jest -t "should re-write issue files"`
- [x] Task 2: Write failing test -- same scenario for PRs — `src/__tests__/unit/index.test.ts` — verify: `npx jest -t "should re-write PR files"`
- [x] Task 3: Add `force-update` input (boolean string, default `'false'`) — `action.yml` — verify: input present in file
- [x] Task 4: Read `force-update` input, thread `forceUpdate` flag into `syncIssuesToMarkdown` and `syncPRsToMarkdown`, bypass `hasContentChanged` when true — `src/index.ts` — verify: `npx jest`
- [x] Task 5: Pass `force-update` workflow dispatch input to the action — `.github/workflows/sync-issues.yml` — verify: input present in `with:` block
- [x] Task 6: Run full test suite — verify: `npx jest` (89 passed, 0 failed)
Loading
Loading