- This repository is an Atlassian Jira Server/Data Center plugin that adds a
Codeissue tab and Agile issue detail panel backed by stored pull request metadata. - The plugin also exposes a REST endpoint for creating/updating pull request entries, a user-profile toggle for notification preferences, and an admin screen for global notification/API-user settings.
- Build target is Jira Data Center
11.3.x(Platform 8: Spring 6, Jakarta EE 10) via AMPS9.11.x; JDK 21 is required (jira-api 11.x is Java 21 bytecode — JDK 17 fails to compile).
- Java plugin packaged as
atlassian-plugin. - Dependency injection is done with Atlassian Spring Scanner using
jakarta.injectannotations (Jira 11's Spring 6 droppedjavax.inject; do not reintroduce javax.* namespaces anywhere — servlet, ws.rs, xml.bind and inject are all jakarta now). - REST uses Atlassian REST v2 (
atlassian-rest-v2-api); security annotations must come fromcom.atlassian.plugins.rest.api.security.annotationorcom.atlassian.annotations.security— the legacycom.atlassian.plugins.rest.common.securitypackage is not recognized on Jira 10+. Writes (POST/DELETE) are@LicensedOnly(platform-enforced authentication) with a matching in-code 401 check; onlyGETis@UnrestrictedAccessbecause reads mirror issue browse visibility. - Velocity method calls must be declared in the
velocity-allowlistmodule inatlassian-plugin.xml(enforced since Jira 10.5); when adding a method call to a template, add a matching<method>entry. Templates use$i18n.getText(...)for user-visible strings (keys insrc/main/resources/i18n/basic-jira-development-panels.properties, registered as the plugin'si18nresource); descriptor labels/tooltips usekey=attributes against the same bundle. - Jira auto-HTML-escapes Velocity output by default (since Jira 6.0): text references like
$pullRequest.nameare safe as-is, and adding manual$textutils.htmlEncode()would DOUBLE-escape. URL safety is enforced server-side instead (SafeUrls+ scheme stripping inmapEntityToModel), never in templates. - REST v2 wire behavior: unknown JSON fields are rejected with a 400 by Jackson unless tolerated —
PullRequestModelcarries@JsonIgnoreProperties(ignoreUnknown = true)for that reason;java.util.Dateserializes as epoch milliseconds; Jira 11 disables basic auth by default, so use a Personal Access Token (Authorization: Bearer ...) when curl-testing the endpoint. Error responses (400/401/403) carry{"errorMessages": ["..."]}bodies; 404/500 are deliberately bodyless. - Versions of
providedplatform artifacts (SAL, ActiveObjects, atlassian-rest, spring-scanner, jakarta APIs) should track Atlassian'splatform-depsBOM for the target Jira version (Jira 11.3 = platform-deps 8.3.x) — do not guess them. - Persistence uses Active Objects (
PullRequestEntity). Every String column is explicitly@StringLength(255)(AO's default, pinned so schema and REST validation cannot drift —PullRequestResource.MAX_FIELD_LENGTHmust match);URLmust stay a plain VARCHAR because it is indexed and is the upsert key. The entity is@Preloadto avoid per-column lazy loads when rendering panels. - Server-rendered UI uses Velocity templates under
src/main/resources/templates. - Tests use JUnit 4 and Mockito; integration tests use
AtlassianPluginsTestRunner.
src/main/java/com/alanmosely/jira/plugin/api: REST model and resource.PullRequestResourcehandlesPOST /rest/pullrequest/1.0/code/{issueKey}.src/main/java/com/alanmosely/jira/plugin/impl: Core service logic inPullRequestServiceImpl.src/main/java/com/alanmosely/jira/plugin/ao: Active Objects entity definition.src/main/java/com/alanmosely/jira/plugin/tabpanelandwebpanel: Issue-view integrations that render stored pull requests.src/main/java/com/alanmosely/jira/plugin/context,servlets,admin,conditions: Profile toggle, servlet, admin action, and display condition glue code.src/main/java/com/alanmosely/jira/plugin/util:SafeUrls(the single owner of the http(s)-only URL policy used by REST validation, panel rendering and email links — never fork it) andSettingsKeys(the SAL settings key constants plus thecodeNotificationsuser-property name).src/main/resources/atlassian-plugin.xml: Plugin module registration. Keep this in sync with any renamed classes, templates, servlet paths, or module keys.src/main/resources/templates: Velocity views for the tab panel, Agile web panel, user profile panel, and admin page.src/test/java/com/...: Unit tests, mostly aroundPullRequestServiceImpl.src/test/java/it/...: Integration tests for the service and AO persistence.
- Pull requests are stored in AO as
PullRequestEntityrecords keyed effectively by issue and PR URL. Fields are stored trimmed, with blank optional fields stored as null (a blank field means the same absence as an omitted one, so GET has a single representation); the upsert always rewritesISSUE_KEYto the issue's canonical key (so legacy rows lose their pre-rename key on the next update). A client-suppliedupdatedtimestamp is honored; otherwise arrival time is used. PullRequestServiceImplprefers JiraissueIdwhen it can resolve one and falls back toissueKeyif not. This is important for renamed project keys.- Retrieval/backfill logic merges newer
ISSUE_IDrows with legacyISSUE_KEYrows and backfills missingissueIdvalues on read. Legacy rows are found viaIssueManager.getAllIssueKeys(the issue's full key history, which covers project key renames) — never by scanning allISSUE_ID IS NULLrows. Preserve this behavior when touching persistence queries. - Concurrent POSTs for the same issue + URL can race the find-then-create upsert and leave duplicate rows (AO cannot declare a composite unique constraint). The read path deduplicates by URL keeping the newest row; the upsert self-heals by writing to the newest matching row across ONE combined pool of
ISSUE_IDrows and legacy rows (the same rule the display uses —updatedis client-controllable, so upsert target and displayed row MUST agree; a staged bucket-by-bucket search would let the unsearched bucket shadow the target) and deleting older duplicates in the same transaction;deletePullRequestdeliberately deletes ALL rows matching the URL. URL matching is trim-tolerant everywhere (upsert, legacy rescue, dedupe, delete) because rows written before trim-on-store may hold padded URLs. - The existence check (
hasPullRequests) runs on every issue view via the web-panel condition and the tab panel'sshowPanel, so it uses indexed counts only — it deliberately skips the un-indexableUPPER(ISSUE_KEY)rescue that the read path still performs. Legacy rows matching only case-insensitively stay hidden until a read or upsert backfills theirISSUE_ID; this is an accepted trade-off, do not "fix" it by adding the UPPER count back. - The issue tab panel intentionally has NO descriptor condition —
PullRequestTabPanel.showPanelperforms the same check, and a condition would double the queries per issue view. The agile web panel keeps its condition (web panels have no showPanel equivalent). - The REST resource:
POST /rest/pullrequest/1.0/code/{issueKey}validates payloads (namerequired,url/repoUrlmust be http(s), every field ≤ 255 chars =MAX_FIELD_LENGTH, matching the AO schema), and returns 500 when the save fails —createPullRequestintentionally propagates persistence exceptions and only swallows notification failures.GETreturns the stored (sanitized) entries to anyone who can browse the issue, and 500 on a read failure —getPullRequestspropagates persistence exceptions; the panels catch them themselves.DELETE ?url=removes all rows matching that URL under the same authorization as POST, 204/404. - Global admin settings are stored in SAL plugin settings under the
com.alanmosely.jira.plugin.pullrequestadminprefix; the key constants live inutil/SettingsKeys.java(the two service test classes still use string literals — a deliberate tripwire for accidental key changes). - User notification opt-in is stored as a Jira user property named
com.alanmosely.jira.plugin.codeNotifications(SettingsKeys.CODE_NOTIFICATIONS_USER_PROPERTY— production code reads the constant). - REST authorization, in order: (1) writes require an authenticated caller —
@LicensedOnlyplus an in-code 401; (2) the optional configured API user is compared case-insensitively viaLocale.ROOTlower-casing (NOTequalsIgnoreCase, which merges Turkish dotted/dotless i and would let a distinct account impersonate the API user); (3) a browse-permission check on the target issue (missing and invisible issues both return 404 to prevent key probing). Changes here are security-sensitive. - The admin webwork action is guarded three ways:
roles-required="admin"in the descriptor, an in-codeGlobalPermissionKey.ADMINISTERcheck (websudo is re-authentication, not authorization, and can be disabled instance-wide viajira.websudo.is.disabled), and@WebSudoRequired. Keep all three. - Rows whose stored issue key matches the viewed issue but whose non-null
issueIdbelongs to a different (since-deleted) issue are deliberately NOT displayed — the pre-2.0 stored-key fallback that surfaced them was showing another issue's pull requests.
- Preferred local dev commands come from the Atlassian SDK per the README:
atlas-runatlas-debugatlas-packageatlas-mvn testatlas-mvn integration-test(KNOWN BROKEN — the README notes this target has never worked here; the ITs undersrc/test/java/it/compile but have no working runner)
- Set
JAVA_HOMEto a JDK 21 before anyatlas-mvncommand — the system default java is often older. The failure signature for a wrong JDK isclass file has wrong version 65.0, should be 61.0on every jira-api class; that means your JDK is too old, not that the dependency is broken. - CI (
.github/workflows/build.yml) builds every PR and master push with plainmvnon JDK 21 — no Atlassian SDK. The pom must therefore stay resolvable without the SDK's settings.xml: that is whyjenkins-releasesis declared in the pom (jira-api transitively needscommons-httpclient:3.1-jenkins-3, hosted only there). If CI fails withCould not find artifactwhileatlas-mvnworks locally, a dependency is leaking in through SDK settings — declare its repository in the pom. atlas-runshuts down as soon as stdin reaches EOF (AMPS treats it as Ctrl+D), so in a headless or background shell keep stdin open, e.g.tail -f /dev/null | atlas-run. The dev instance serves athttp://localhost:2990/jira(admin/admin) and keeps its home undertarget/between runs;atlas-cleanresets it.- Do not use
atlas-integration-testhere; the README explicitly says it runs the wrong product (refapp) instead of Jira. - If you change only Java service logic, unit tests in
src/test/java/com/...are the first check. - If you change AO queries, Jira integration points, or plugin wiring, verify against a running instance via
atlas-run(REST smoke test + panel rendering) — the integration tests have no working runner (see above), soatlas-runis the only way those paths actually execute against a real database. - Unit tests reach Jira state through protected seams overridden by test subclasses:
resolveIssue(String)/resolveAllIssueKeys(Issue)onPullRequestServiceImpl, andloggedInUser()/issueByKey(String)/canBrowse(Issue, ApplicationUser)onPullRequestResource— extend those seams for new Jira lookups instead of trying to mockComponentAccessorstatics. - Email HTML is built by the package-private statics
buildEmailSubject/buildEmailBody/escapeHtml/linkOrTextonPullRequestServiceImplprecisely soPullRequestEmailContentTestcan pin the escaping — keep them pure (noComponentAccessor). - Runtime-verified on Jira 11.3.10 via
atlas-run(2026-09-02, re-verified after the hardening pass): plugin enables; REST handles create/GET/upsert/delete/validation/authz (201/200/204/400/401/403/404 including the over-length 400 that names the field and JSONerrorMessagesbodies); the API-user restriction gates POST/DELETE but not GET, and admin-screen save of it round-trips both ways; the Code tab renders with i18n headers resolved and<script>/<b>payloads escaped as text; the profile toggle (now a plain submit button) round-trips its XSRF token through the servlet; the admin screen saves via!save.jspa, the pre-2.0 URL still renders, and its form carries a non-empty token. Still unverified: the agile board web panel (atl.gh.issue.details.tab— needs a Jira Software instance with a board) and actual email delivery (needs a mail server; email HTML escaping is covered byPullRequestEmailContentTest).
- Versioning: bump
<version>inpom.xml(plainX.Y.Zfor production,X.Y.Z-RC1for release candidates). Note v2.0.0 is a known-bad release (the as-contributed Jira 11 migration that cannot install); v2.0.1 is the first working 2.x. - Flow: feature branch -> PR -> merge with a merge commit (not squash) -> tag
vX.Y.Zon the merge commit and push the tag. - GitHub release: named exactly after the tag, body is a short bullet list of changes ending with
**Full Changelog**: .../compare/vPREV...vNEW, RC/SNAPSHOT releases are marked prerelease. - Always attach the built jar (
atlas-mvn clean packagewith JDK 21) asbasic-jira-development-panels-X.Y.Z.jar— every release ships the jar as an asset; build it from the merged code with the pom version already bumped.
- Keep plugin descriptor, Spring-scanned components, and Velocity template names aligned. A rename in Java usually requires a matching update in
atlassian-plugin.xml. - Preserve the storage keys and property names unless you are intentionally migrating data/settings. Production code reads them from
util/SettingsKeys.java; the unit and integration tests deliberately duplicate them as literals, so a deliberate key change must touch the tests too. - When changing pull request persistence, verify all of the following still hold:
- existing PRs are updated rather than duplicated for the same issue + URL
- renamed Jira project keys still resolve existing records
- legacy rows without
issueIdcontinue to be found and backfilled
- When changing the profile/admin views, keep the expected context keys stable:
- profile template expects
codeNotifications,pluginUrl(context-relative when a request is executing, so the same-origin XSRF check holds behind proxies) andatlToken(the XSRF token the notifications servlet validates; an empty token makes every preference save fail with 403) - tab and web panels expect
pullRequests; nullable model fields must be rendered with quiet references ($!pullRequest.status) or they print the literal Velocity reference - admin template uses
areNotificationsEnabled()andgetApiUser() - the profile toggle is a plain form submit button on purpose — do not reintroduce the JS-driven anchor, it broke without JavaScript
- profile template expects
- Notification code depends heavily on
ComponentAccessorand live Jira services. Be conservative about refactors and prefer adding tests around behavior changes. - HTML in notification emails is assembled manually in Java via the package-private builders in
PullRequestServiceImpl: every interpolated field must go throughescapeHtml(...)(a local escaper covering& < > " '— apostrophes included, unlike the deprecated commons-langescapeHtml4it replaced) and URLs must go throughlinkOrText(...); keep attributes double-quoted by convention. Emails are queued after the save transaction commits — keep it that way, the mail queue is not transactional.
- Unit coverage now spans
PullRequestServiceImpl(persistence, dedupe, legacy-key pinning),PullRequestResource(the full 401/400/403/404/201/204/500 matrix),SafeUrls, and the email escaping layer (PullRequestEmailContentTest). The servlet, admin action, context provider, condition, and Velocity rendering still have no direct test coverage. - UI changes in templates should be manually verified in a running Jira instance.
- Security-sensitive areas:
PullRequestResourceaccess control- admin configuration persistence (
PullRequestAdminAction— keep descriptorroles-required, in-code ADMINISTER check and websudo together) - user preference writes in
PullRequestNotificationsServlet
- Start by reading
README.md,pom.xml, andsrc/main/resources/atlassian-plugin.xml. - For behavior changes, inspect
PullRequestServiceImplfirst; most repo logic funnels through it. - For UI changes, trace the Java provider/action class and the matching Velocity template together.
- Before editing persistence logic, read the unit test covering issue-key rename/backfill behavior.
- If you add new modules or settings, update tests and document the new key/module in this file or the README.