Communicating Across Products and Oceans

Dipping into the Stream

You may already know about the Activity Stream feature that will be part of JIRA Studio. The idea is that all of the activity on a project, across its issues, wiki content, source commits and code reviews, can be viewed in a continuous, integrated timeline. We recently rolled the feature out to JIRA Studio beta testers, and we're continuing to expand and improve it for the upcoming public launch. Here's a peek at what it looks like today:

stream_grab.png

We were able to implement Activity Streams entirely as a plugin — actually, a suite of three plugins, for JIRA, Confluence and FishEye/Crucible. This has been an exciting and challenging project that has given me a chance to work with all of Atlassian's products and development teams around the world. I joined Atlassian in late 2007 as part of a new team that is dedicated full-time to plugin development and developer relations. What makes this group different from the other Atlassian development teams is that we work out of Atlassian's San Francisco office instead of the headquarters in Sydney. So while development teams in Sydney were busy implementing the core enabling functionality and integration features for JIRA Studio, development of the Activity Stream plugin suite was taking place independently in San Francisco.

Going with the Flow

Well, in truth, it wasn't entirely independent. The Activity Stream plugins leverage other technology that was in development concurrently, such as Trusted Application Authentication and AppLinks. The JIRA Activity Stream plugin uses these to make HTTP requests on behalf of the logged in user to the Activity Stream plugins in linked instances of Confluence, FishEye, and Crucible, as well as querying JIRA directly itself, and mixes the results together into an Atom feed. The UI works by making an AJAX request for the Atom feed, and then rendering the results in a JIRA portlet or tab panel. Of course, the feed can also be accessed directly by feed readers, aggregators, or anything else that can read Atom XML.

Here's what it all looks like put together:

architecture_diagram.png

As I sketched out this diagram, I realized that it reminded me of another familiar pattern of interaction. Just as we designed the plugins to use loosely coupled components and flexible protocols to communicate across various products and assemble an integrated result, the plugin development team has been able to work effectively with several teams in Sydney by using powerful collaboration tools, despite thousands of miles of distance and a nineteen hour time difference. Even better, Atlassian's newest development team — in Gdansk, Poland, thousands of miles in the other direction — has been among the first groups to use JIRA Studio and the Activity Stream plugins in production on a real project, and has given us great feedback already, via JIRA of course!

communication_diagram.png

Looking Downstream

Now that the groundwork is in place, we're looking at other ways to use Activity Streams throughout JIRA Studio. For example, there could be a stream for a particular issue in JIRA, including any wiki pages, changesets or code reviews that link to it, or a stream specific to an individual user. Streams could include builds from a linked Bamboo instance, and maybe even bookmarks from del.icio.us, events from Google Calendar, status updates from Twitter, or anything else that provides an RSS or Atom feed. And, of course, we're working on making these plugins available outside of JIRA Studio, for anyone who uses JIRA by itself or in conjunction with other Atlassian software. We don't know yet how soon this will be, but keep watching this blog and the plugin home page for updates.

We want to hear your ideas, too! You can keep tabs on development and offer your suggestions in the Atlassian Activity Stream Plugin Developer Network JIRA project.

  • Comments Off

Got 3-Minutes to Nominate Us?

SD Times is asking for nominations for their annual innovation and leadership awards. We'd love to have your support. The nomination form asks you to fill out only 4 fields. The hardest will be #3:

"In your own words, please describe what the nominee did, in calendar year 2007, that demonstrated innovation and leadership in the software development community. In other words, how did the nominee set the agenda for the industry, advance the state of the art, and get everyone talking?"

So, here's a cheat sheet of things we did in 2007 in roughly chronological order. Surely, something here set an industry trend, or at the very least, got people talking:

  1. Launched two new products — Bamboo and Crowd
  2. Launched Wikipatterns.com — a site that is inclusive of all success stories for wikis
  3. Handed out $25,000 in cash for Codegeist, our annual plugin competition
  4. Transformed ourselves into scurvy-ridden and baffling pirates who descended upon innocent Google employees and Sydneyites
  5. Acquired three awesome products — FishEye, Crucible, and Clover — when we acquired Cenqua
  6. Built a connector to SharePoint
  7. Passed around free beer to lucky attendees at Javapolis!

Please vote here :-)

  • Comments Off

The Monthly Code Smell - Issue 1

Recently in the Confluence team we've been increasing the amount of code reviewing that we do before new code is released in the product. As the resident Confluence code review practice champion, this makes me happy. In fact in the three separate code reviews that I was involved with in one iteration, we prevented three fairly significant defects from making their way into Confluence 2.7 (and yes one of those was in my code).

Code reviews are great because not only can they prevent defects from shipping to customers, they are also a great learning tool for both author and reviewer. But why should the learning stop there, trapped in an obscure Crucible review or in a conversation that is quickly receding into the thick fog of time past? There are some things that I see quite often in a code review that would be best shared amongst all developers so that we can get a global reduction in certain problems within our code bases. Sharing the results of code reviews also serves as a meta-review of the reviewer's findings as well – after all reviewers may have their own misconceptions and ill founded beliefs.

As part of the reviewing process in the Confluence team I plan to put together a regular news post highlighting any common problems or particularly interesting cases turned up in reviews performed within the team. So without any further ado I present you with the inaugural edition of the Monthly Code Smell.

Sets are great filters

I saw this quite often during a code test I administered for my position at my previous employer and happened to see it again incidentally while reviewing some changes to the JIRA issues macro. Given a task to filter a collection for duplicates much code will go to the effort of iterating over a collection checking for the existence of an element in another collection and acting accordingly.

This piece of code filtered out any columns that weren't supported by the macro when the user specified their own list of columns.

private List prepareDisplayColumns(String columns)
{
    if (columns == null || columns.equals(""))
    { // No "columns" defined so using the defaults!
        return defaultColumns;
    }
    else
    {
        StringTokenizer tokenizer = new StringTokenizer(columns, ",;");
        List list = new LinkedList();

        while (tokenizer.hasMoreTokens())
        {
            String col = tokenizer.nextToken().toLowerCase().trim();

            if (defaultColumns.contains(col) && !list.contains(col))
                list.add(col);
        }

        if (list.isEmpty())
            return defaultColumns;
        else
            return list;
    }
}

Compare this with the following, functionally equivalent code:

private Set prepareDisplayColumns(String columns)
{
    if (!TextUtils.stringSet(columns))
        return defaultColumns; // An unmodifiable collection of course

    Set columnSet = new LinkedHashSet(Arrays.asList(columns.split(",|;")));
    columnSet.retainAll(defaultColumns);
    return columnSet.isEmpty() ? defaultColumns : Collections.unmodifiableSet(columnSet);
}

A LinkedHashSet was employed because the specification order of the columns in the delimited string was significant for the table rendering order. The LinkedHashSet guarantees that the iteration order for the set is the same as the addition order. Passing a collection to the set constructor returns a set of all unique elements in the original collection, effectively replacing the while loop and condition checking of the original code. The Collection method retainAll is often overlooked as an excellent way of enforcing membership constraints.

Finally it's worth noting that the JavaDoc for the StringTokenizer class discourages its use and recommends using String.split instead. It's hard to find out exactly why Sun weakly discourages its use rather than just flat out deprecating and removing it. It would appear that this has happened because of a few idiosyncracies in how the class performs tokenisation which Sun would like to preserve for backwards compatibility. Next time you think StringTokenzier think String.split instead (or java.util.Scanner if you are using Java 5)

In Soviet Russia, InputStreams abstract you

InputStream handling is something that is frequently done badly. One of the main reasons for this is that clients are often so abstracted from the actual source of bytes that they can't really understand the ramifications of certain actions or non-actions on their behalf.

Consider the following interface for a flexible resource reading strategy:

public interface ResourceReader
{
    String getName();

    String getContentType();

    InputStream getStreamForReading();
}

What can I as a client of this interface assume about the stream returned by getStreamForReading? Nothing really. It could be a ByteArrayInputStream which I can get away with not closing when I'm finished, or it could be a FileInputStream which will keep an operating system file handle opened until I call close().

There are other questions I as a consumer of this interface would like to know:

  1. Whose responsibility is it to close the stream returned?
  2. If I call getStreamForReading multiple times do I get a fresh InputStream ready for reading from the start or do I get the same instance as the first call which may have been read already?
  3. Is it actually legal for me to call getSteamForReading more than once on the same instance of this interface?

The answers to these questions all have ramifications on how code interacts with this interface and the InputStream it returns. Authors of such abstractions should always address these questions when writing any public interface that returns a stream. The answer to question one is almost always that the client should close the input stream when it is finished with it but there are cases where this won't be the case.

It is pretty hard in this case for the interface author to make any assertions about what type of InputStream will be returned by implementations. It might be a fully memory resident stream, a file stream or a network stream.

The point to remember is this: getting an InputStream can be a very expensive operation. In Confluence the expense of getting an InputStream of attachment data is dependent on the attachment storage configuration. When attachments are stored on the file system, getting an InputStream involves opening a FileInputStream onto the attachments disk location, counting as one file handle opened for the process. This is fairly inexpensive by itself (but could still put needless stress on the process file handle limit on a busy instance). However, when attachments are stored in the Confluence database, as is required in clustered instances of Confluence, there is a major expense in acquiring an attachment InputStream.

Streaming an attachment from a database requires holding a database connection, of which there are only a limited number available, for the duration of the streaming operation. The duration of this operation is dependent on two parameters:

  • The size of the attachment
  • The available bandwidth between the client and the database.

Given a large attachment and/or a slow client, the associated database connection could be out of action for a significant amount of time. To mitigate this problem, when a client asks for a database attachment stream, Confluence will first spool the attachment data to the local filesystem and return a FileInputStream onto the attachment data once spooling is complete. However this operation is still a significant investment of time, disk space and I/O effort.

That's why the following code was flagged as a defect:

InputStream is = resourceReader.getStreamForReading();

if (LastModifiedHandler.checkRequest(httpServletRequest, httpServletResponse, 
    resourceReader.getLastModificationDate()))
    {
        IOUtils.closeQuietly(is);
        return null;
    }

The lesson: sometimes you really can't predict what kind of effort goes into constructing a live InputStream so you should only ever obtain one if you are certain you need it.

String.getBytes() is a dirty trap

ackbar.jpg

Here's a riddle for all the punters: What character encoding is used to convert a string into bytes when I call String.getBytes()? Is it:

  1. UTF-8 because it's ubiquitous, capable and efficient?
  2. UTF-16 because it's how Java encodes strings internally?
  3. UCS-2 because that's how Java originally encoded strings internally?
  4. Something that is entirely dependent on the configuration of the platform that the runtime is on (and isn't necessarily a Unicode encoding)?
  5. EBCDIC because I said there was a dirty trap and that would certainly be one?

I would anticipate the following set of reactions:

  • #1 because that's a reasonable contract.
  • #2 because that's a reasonable contract.
  • #3 because Sun probably wanted to preserve 100% backwards compatibility.
  • It must be #4 because that's the way any answer list like this would be arranged for dramatic effect and #5 is obviously the obligatory joke answer.
  • #4 because it sounds like a trap.
  • #4 because I looked at the JavaDoc or already knew.
  • #5 because I'm incredibly cynical and/or didn't realise you were trying to be funny.

It wouldn't surprise me if a significant proportion of you assumed either UTF-8 or UTF-16; it certainly would be an arguably better implementation than what we have at the moment. The sad reality is however that the bytes returned by getBytes() depend on the configuration of the host operating system – a surprising result I think for an environment that strives to do things as consistently as possible across varied platforms.

This problem is particularly insidious in that, for most cases, everything will work fine, including the unit tests which exercise anything using this method. Problems will only start to rear their ugly head when getBytes() is used on a string that contains characters outside of the basic Latin script block.

Let's have a look at some examples of different encodings of the string "latin script!" :

Encodings of the string "latin script!"
Encoding Bytes
ISO-8859-1 6c 61 74 69 6e 20 73 63 72 69 70 74 21
windows-1252 6c 61 74 69 6e 20 73 63 72 69 70 74 21
MacRoman 6c 61 74 69 6e 20 73 63 72 69 70 74 21
US-ASCII 6c 61 74 69 6e 20 73 63 72 69 70 74 21
UTF-8 6c 61 74 69 6e 20 73 63 72 69 70 74 21
UTF-16 fe ff 00 6c 00 61 00 74 00 69 00 6e 00 20 00 73 00 63 00 72 00 69 00 70

As you can see this string is encoded as exactly the same bytes by the majority of encoders; the sole exception being the double byte UTF-16. The night that Ken Thompson sat down in a diner and designed the ASCII-like UTF-8 encoding on a placemat is legendary in Computer Science circles and indeed many other encodings of Latin scripts were also designed to be compatible with ye olde 7-bit ASCII, that incredibly ubiquitous encoding of antiquity. The reasons should be obvious: encoding designers still wanted the most commonly used script in software-land to remain compatible with the vast amount of ASCII understanding software still being used. This is why we see results like these.

Now what happens if we ask these encoders to encode some characters from an extended Latin script, such as those used by just about every non-English European alphabet? For example if we add diacritics to some of the characters to get the string "látin scrïpt!" and encode this string we get these results:

Encodings of the string "látin scrïpt!"
Encoding Bytes
ISO-8859-1 6c e1 74 69 6e 20 73 63 72 ef 70 74 21
windows-1252 6c e1 74 69 6e 20 73 63 72 ef 70 74 21
MacRoman 6c 87 74 69 6e 20 73 63 72 95 70 74 21
US-ASCII 6c 3f 74 69 6e 20 73 63 72 3f 70 74 21
UTF-8 6c c3 a1 74 69 6e 20 73 63 72 c3 af 70 74 21

This case is more interesting because characters that are not supported by ASCII are mostly represented differently by the various encodings. When looking at these results you could be tempted into thinking that ISO-8859-1 and windows-1252 are the same encodings – but they aren't; some characters are in fact represented differently, just none of the ones in this example.

You can also see that the ASCII encoder actually does return an encoding for this string. But you said the string contained characters not supported by ASCII! I did and it doesn't. The encoder will happily map any character it can't encode to ASCII code 3f, the quizzical question mark. That's right: the ASCII encoder will silently mangle your internationalised strings.

It turns out that this behaviour isn't just limited to the ASCII encoder either. On my machine all of the encodings do this when using String.getBytes(). All of the non-Unicode encodings will map a Greek sigma to the question mark for example. Notice that I said on my machine; the truth is that the JavaDoc for the getBytes() method says that the result of passing in a string with unsupported characters is undefined. That leaves the door open for some implementations to return null or throw an unchecked exception or handle it some other ghastly way. Nice!

Even if the system default encoding supports Unicode fully, you still might run into problems when transporting the encoded bytes from one VM to another, as is commonly done in networking protocols. The most frequently used way of converting an array of bytes to a String is the String constructor String(byte[]). This is String.getBytes() evil brother which will also use the platform default encoding to translate the provided bytes to characters. If the two VMs involved in the marshaling and unmarshaling of the encoded bytes use different default encodings, problems are more than likely to follow. You only have to imagine trying to transport the "bàsic latïn!" string from one VM which uses ISO-8859-1 by default to one that uses MacRoman: the string isn't going to be the same when it is reconstituted on the other side.

The lesson here is to avoid String.getBytes() and its ugly sibling at all costs. Most of the time getBytes("UTF-8") and new String(bytes, "UTF-8") is what you are going to be looking for. This introduces the mildly annoying inconvenience of having to catch UnsupportedEncodingException even though it is mandatory for all JREs to support UTF-8. Maybe it's time for the introduction of a PrettyMuchImpossibleException to wrap any exception raised while doing this.

Fin

Phew, that was a fair blog post. I hope to do more bite sized posts in the future; suggestions for future topics and other discussion welcome!

  • Comments Off

Maven in our development process. Part 1 - Requirements.

At Atlassian we use Maven as part of our development process. The key with Maven is to set it up correctly as a process. In order to do that you need to understand who uses it, what they use it for and what they expect from it.

It's quite common to read complaints about Maven, so in this four-part series I will show you how we have set up our process at Atlassian. I hope this will help you avoid some of the common pitfalls of setting up Maven.

Our Maven users

Atlassian's Maven infrastructure has many users. To help understanding our requirements, we divide them into several categories.

Obviously Maven is used by the Atlassian teams. The core development team is in the Sydney office where we have over 50 people. All product work is done here. Apart from being in the office, quite often we work from home. (The significance of this, as far as Maven is concerned, will become apparent later.)

There are also a few, smaller, development teams around the world, for instance at our San Francisco office.

Then we have Atlassian customers. Customers receive source builds so they can make some modifications to the product. These builds are produced using Maven, of course.

Atlassian contributors (also called the Developer network, or DevNet) are external developers who contribute their code to Atlassian, such as plugins for our products. These developers are also users of our Maven infrastructure.

Types of libraries managed by Maven

To simplify the discussion of libraries at Atlassian, we divide them into categories: public, contributed, closed-source, private and third-party.

The majority of libraries written by Atlassian and shared between the teams are public libraries. These are released under a BSD license. Atlassian's plugin framework is a good example of this kind of library. Some people outside Atlassian use it in their projects.

Contributed libraries are mainly plugins for our products written by external developers.

There are some libraries for which the source is not freely available. We call these closed source libraries. We ship the source code of closed source libraries only to our commercially-licensed customers as part of product source builds. These are primarily the libraries which make up a product like JIRA or Confluence.

Some internal libraries we keep to ourselves. These are private libraries. They are not part of any product and are used for our own needs. The source code for the Atlassian website is private, for instance.

Then there are various types of third-party libraries we depend on. The open-source libraries we use include JUnit, Log4J and Spring. We also use several Sun libraries which are actually not distributable, but still need to be available in a Maven repository when building our products.

Requirements summary

So what do the differences in these libraries mean for our Maven configuration at Atlassian? Well, we have quite complex rules about how each kind of library can be accessed by different users. Here they are in brief:

  • All Atlassian developers should have full access to read and deploy new versions of all types of libraries.
  • All our users should have full access to read public and contributed libraries. This includes binaries, Javadoc and source code.
  • Users should have access to read binaries and Javadoc for closed source libraries. This includes product code like JIRA and Confluence.
  • Atlassian developers should have access to source code via Maven for all libraries produced internally, to make debugging easier.
  • Customers – that often wish to modify and debug the product they purchased – should also be able to attach the sources of the closed source libraries in their IDEs.
  • DevNet teams should be able to deploy new versions of their libraries. As above, this includes binaries, Javadoc and source code.
  • Atlassian developers would like, wherever possible, to have binaries, Javadoc and source code available for the third-party libraries we use.
  • Finally, the process should work regardless which office you work in and, more importantly, even if you roam in and out of the office. In other words, those who work from home every now and again should not need to change any settings for Maven to work properly.

When running lots of builds with Maven, you needs to see it as a process rather than just a build tool. As with any build process, it should be:

  • Deterministic. A Maven build should execute as understood by the developer without random dependencies on the external factors.
  • Repeatable. A Maven build should execute with the same result today, tomorrow, in a year, and so on. (Provided the code didn't change, of course.) This is important for maintaining previous versions and reproducing problems when you have shared code.

Being the main tool our developers use on a daily basis - it also must be simple. Pretty much any operation you want to do to your project should be a single command away. Otherwise it will be degrading productivity and causing frustration.

So how does Maven meet these requirements? Stay tuned!

.. to be continued

Part 2 of this series will cover how our Maven infrastructure is organised to meet the above requirements.

Part 3 will describe you how our projects (all of those libraries I talked about) are configured to make their builds deterministic and repeatable.

Unfortunately, even after several years, our Maven infrastructure is not perfect. Part 4 will cover issues we hit every now and again and explain how we deal with them.

  • Comments Off

The New Guy on Working in New Ways

A new job always brings with it changes. I've already mentioned the different tools I've had to use, and their effect on me. But one cultural change in particular is having the biggest impact on me.

This. Right here. Blogging!

Blogging isn't new. Not even close. And Atlassian isn't unique in embracing blogging. But, man, we sure do a lot of it!
blog.jpg
Blogging is part of being open, and being open is a core part of not just Atlassian, but the individuals who make up Atlassian. Not all of us blog, but a lot of us do! We have not only the News and Developer blogs, but planet.atlassian.com syndicates blogs from 20 different Atlassians -- about a sixth of the company!

Internally, we do a lot of blogging on our Confluence instance. There are somewhere around twenty internal blog posts a day, from pretty much every group within the company. The majority of posts solicit at least a few comments, and some prompt lengthy conversations.

Blogging has changed the way I think about my job. Everytime I check something off my to-do list, I wonder if I should post a blog on EAC. I'm constantly asking myself "should I blog about this?" The answer is often "Yes!"

And then, of course, there's blogging here, in public. Believe me when I tell you that knowing your Mom is checking out stuff you do at work is.......strange. Very cool, but strange. (Hi, Mom!)

And now that I've been bitten by the blogging bug it won't be too long until I start my personal blog and add it to our Planet.

  • Comments Off

Back by Popular Demand: Distributed Agile Software Development Best Practices — Atlassian and GlobalLogic Velocity™

Join us on Wednesday, February 27 at 11 am PT, for the second free webinar hosted by GlobalLogic, an Atlassian partner, to discuss Agile development best practices. The webinar "Distributed Agile Software Development Best Practices" from Atlassian and GlobalLogic focuses on adopting Agile methods for distributed software development and the collaboration challenges with your geographically distributed software development teams.

Speaking at this free webinar are Johnny Scarborough, our AVP of Product Engineering and Jonathan Nolen from Atlassian.

To register for this free event, please go here.

  • Comments Off

Back by Popular Demand: Distributed Agile Software Development Best Practices — Atlassian and GlobalLogic Velocity™

Join us on Wednesday, February 27 at 11 am PT, for the second free webinar hosted by GlobalLogic, an Atlassian partner, to discuss Agile development best practices. The webinar "Distributed Agile Software Development Best Practices" from Atlassian and GlobalLogic focuses on adopting Agile methods for distributed software development and the collaboration challenges with your geographically distributed software development teams.

Speaking at this free webinar are Johnny Scarborough, our AVP of Product Engineering and Jonathan Nolen from Atlassian.

To register for this free event, please go here.

  • Comments Off

Announcing the 2008 Atlassian User Groups

Atlassian User Group

We're proud to announce the 2008 Atlassian User Group lineup! Atlassian User Groups are designed to give you the opportunity to learn more about our software, discover interesting implementations and answer any burning questions you've been harboring.

This year's User Groups will span three continents and over half a dozen countries.

2008 Locations:

May: San Francisco, Washington D.C., London, Zurich, Frankfurt

June: Paris, Boston

August: Toronto

September: NYC

We'll also be holding user groups in Sydney and Chicago, with those dates to be determined. Stay tuned!

Ways to get involved

1. Submit a speaking proposal

We're striving to bring engaging and informative speakers to each user group. We're currently accepting speaking proposals for all our user groups and are especially interested in hearing from customers using our development tools such as Bamboo, Crucible, FishEye, Clover and Crowd.

2. Sponsor a User Group

We're happy to announce that Publicis will be sponsoring our Paris AUG and Beecom will be sponsoring our AUG in Zurich. If you'd like to get involved, Atlassian is looking for customers to sponsor upcoming user groups.

3. Attend!

Our #1 priority with each user group, is to provide you with quality information that addresses your questions, provides new information and helps you to meet like-minded individuals. We're very interested in hearing from you. We've set up a wiki page where you can add your comments regarding the upcoming user group you'd like to attend. Want to know how to optimize your instance of JIRA, enhance your usage of Confluence by using more advanced macros and plugins or learn how to integrate our development tools? This is the space to let us know!

For more information on sponsoring a user group, speaking or attending please contact Laura at: lkhalil [at] atlassian [dot] com

  • Comments Off

Case Study: Dow Jones On Confluence

"It's funny. One of the first things people say when I talk about the wiki is, We can't have something out there that just anybody can edit. Just think of what might happen. And I say, Yeah, just think—people might actually collaborate!"

Dowjones_logoimage_Confluence_casestudy.png Read what else Jamie Thingelstad shares about Confluence use in the new Dow Jones case study, including:

  • why Dow Jones Online switched from OpenWiki to Confluence
  • how Confluence has become a default information, communication and collaboration tool, where 20-30 new wiki pages may be created in a single day
  • how the wiki connects distributed work environments
  • and more!

A big thanks to Jamie for his participation in the case study!

  • Comments Off

Atlassian Wants to Sponsor Your User Group!

663670843_ec38543813.jpg
Atlassian loves user groups and wants to help make yours a raging success.

Here's what we can offer:

* Pizza and drinks
* Book of the Month
* Nifty Atlassian Swag (w00t!)
* And oh so much more!

And why not invite us? If an Atlassian developer is in your area and you'd like for us to attend, we'll do our best to stop by.

For more details, check out our User Group Sponsorship page or feel free to e-mail me and I'll hook you up!


For more information contact Laura at lkhalil [at] atlassian [dot] com.
For a complete list of all the User Groups we sponsor, click here.

This February, we're sponsoring:


Colorado Springs Open Source User Group

Date: Thursday, February 28, 2008 at 6:30 PM

Scott Ryan will present on the Open Source Richfaces project. Scott is an Open Source Evangelist and board member of the Denver Open Source User's Group.

  • Comments Off